Compare commits

...

6 commits

Author SHA1 Message Date
5ceaa2e050 Merge pull request 'Launch a whole series or a single season, as a watch or lounge party' (#30) from feat/launch-whole-series into master
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 12s
2026-09-24 23:00:50 +02:00
1a10e5bf57 Launch a whole series or a single season, as a watch or lounge party
All checks were successful
Publier l'image Docker / test (pull_request) Successful in 24s
Publier l'image Docker / publish (pull_request) Has been skipped
- lounge parties can now be created from one season (source type
  "season", keeping seriesId/seasonNumber) as well as a whole series
- POST /api/parties accepts a Series id (every season from S1E1) and a
  Season id (that season only); series tiles get a launch button
- whole-series queues skip specials (season 0, sorted first by Emby) -
  pure seriesQueueFrom/seasonQueue in CatalogService, unit-tested, also
  used by the "from this episode" path
- series page: "Lancer toute la série" + "Créer une lounge party" in the
  header, "Lancer cette saison" + "Lounge party de cette saison" above
  the selected season's episodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:22:57 +02:00
363cf44ebe Merge pull request 'Fix CI: flaky mtime test on master + PR checkout always failing' (#29) from fix/static-store-test-mtime-precision into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s
Reviewed-on: #29
2026-09-24 19:42:48 +02:00
56f969ba78 Fix CI source checkout on pull requests
All checks were successful
Publier l'image Docker / test (pull_request) Successful in 24s
Publier l'image Docker / publish (pull_request) Has been skipped
On a pull_request event github.ref_name is the PR number (e.g. "29"),
not a fetchable ref, so the test job failed at "Récupérer les sources"
on every PR ("couldn't find remote ref 29"). Fetch the PR's head branch
(github.head_ref) instead, falling back to ref_name for pushes/tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:37:15 +02:00
b0d2100210 Fix flaky mtime assertion in the static party store test on Linux
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 3s
Publier l'image Docker / publish (pull_request) Has been skipped
The "only rewrites the party that changed" test compared a file's
mtimeMs against the exact millisecond passed to utimesSync. Linux
reports mtimeMs as a float that can land a hair off it (...780.999 for
...781), failing CI on master. Compare against the mtime the filesystem
reports right after setting it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:36:21 +02:00
db3cf68843 Merge pull request 'Lounge parties: own tab, one file per party, watch/listen/lounge naming' (#28) from feat/static-parties-tab-and-per-file-storage into master
Some checks failed
Publier l'image Docker / test (push) Failing after 20s
Publier l'image Docker / publish (push) Has been skipped
2026-09-24 19:31:54 +02:00
16 changed files with 274 additions and 39 deletions

View file

@ -55,13 +55,16 @@ jobs:
# node:20-alpine n'a pas git préinstallé - même approche de clone # node:20-alpine n'a pas git préinstallé - même approche de clone
# manuel que le job publish (pas d'actions/checkout, action JS qui a # manuel que le job publish (pas d'actions/checkout, action JS qui a
# ses propres soucis dans les conteneurs Alpine minimalistes ici). # ses propres soucis dans les conteneurs Alpine minimalistes ici).
# Sur une PR, `ref_name` vaut le numéro de la PR (ex. `29`), qui n'est
# pas une ref récupérable ("couldn't find remote ref 29") : on récupère
# alors la branche source de la PR (`head_ref`, vide hors PR).
- name: Récupérer les sources - name: Récupérer les sources
run: | run: |
set -eu set -eu
apk add --no-cache git apk add --no-cache git
git init -q git init -q
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.ref_name }}" git fetch -q --depth 1 origin "${{ github.head_ref || github.ref_name }}"
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
- name: Serveur - installer les dépendances - name: Serveur - installer les dépendances

View file

@ -181,7 +181,7 @@ export interface LobbyPartyDto {
export interface StaticPartySummaryDto { export interface StaticPartySummaryDto {
id: string; id: string;
name: string; name: string;
sourceType: 'series' | 'playlist'; sourceType: 'series' | 'season' | 'playlist';
imageUrl: string; imageUrl: string;
itemCount: number; itemCount: number;
groupWatchedCount: number; groupWatchedCount: number;

View file

@ -63,7 +63,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
if (items[0]!.type !== 'Audio') { if (items[0]!.type !== 'Audio') {
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement; const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
createStaticBtn.classList.remove('hide'); createStaticBtn.classList.remove('hide');
wireCreateStaticButton(createStaticBtn, playlistId); wireCreateStaticButton(createStaticBtn, () => playlistId);
} }
listEl.innerHTML = items.map((item, i) => { listEl.innerHTML = items.map((item, i) => {

View file

@ -17,7 +17,8 @@
.seriesPoster.noImage { visibility: hidden; } .seriesPoster.noImage { visibility: hidden; }
.seriesActions { .seriesActions,
.seasonActions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -25,6 +26,11 @@
margin-top: 8px; margin-top: 8px;
} }
/* The selected season's own launch buttons, between the tabs and its
episodes. */
.seasonActions { margin: 0 0 16px; }
.seasonActions button { padding: 8px 14px; font-size: 13px; }
@media (max-width: 600px) { @media (max-width: 600px) {
.seriesHeader { flex-direction: column; } .seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; } .seriesPoster { width: 120px; }

View file

@ -5,10 +5,15 @@
<h1 id="seriesTitle"></h1> <h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p> <p class="seriesOverview hide" id="seriesOverview"></p>
<div class="seriesActions"> <div class="seriesActions">
<button id="createStaticBtn">Créer une lounge party</button> <button id="launchSeriesBtn">Lancer toute la série</button>
<button class="secondaryBtn" id="createStaticBtn">Créer une lounge party</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div> </div>
</div> </div>
<nav id="seasonTabs" class="tabs"></nav> <nav id="seasonTabs" class="tabs"></nav>
<div class="seasonActions hide" id="seasonActions">
<button id="launchSeasonBtn">Lancer cette saison</button>
<button class="secondaryBtn" id="createSeasonStaticBtn">Lounge party de cette saison</button>
</div>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div> <div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -35,13 +35,26 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
overviewEl.classList.remove('hide'); overviewEl.classList.remove('hide');
} }
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl; (root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, seriesId);
// Whole series: a watch party over every season from S1E1, or a lounge
// party over the same (see the server's seriesQueueFrom).
wireLaunchButton(root.querySelector('#launchSeriesBtn') as HTMLButtonElement, () => seriesId);
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, () => seriesId);
if (!seasons.length) { if (!seasons.length) {
(root.querySelector('#launchSeriesBtn') as HTMLButtonElement).disabled = true;
(root.querySelector('#createStaticBtn') as HTMLButtonElement).disabled = true;
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>'; root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return; return;
} }
// Just the selected season - both buttons read whichever tab is active
// at click time, so they're wired once rather than per tab switch.
let selectedSeasonId = seasons[0]!.id;
root.querySelector('#seasonActions')!.classList.remove('hide');
wireLaunchButton(root.querySelector('#launchSeasonBtn') as HTMLButtonElement, () => selectedSeasonId);
wireCreateStaticButton(root.querySelector('#createSeasonStaticBtn') as HTMLButtonElement, () => selectedSeasonId);
const tabsEl = root.querySelector('#seasonTabs')!; const tabsEl = root.querySelector('#seasonTabs')!;
tabsEl.innerHTML = seasons tabsEl.innerHTML = seasons
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`) .map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
@ -52,13 +65,35 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
if (tab.classList.contains('active')) return; if (tab.classList.contains('active')) return;
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
loadEpisodes(root, seriesId, tab.dataset.seasonId as string); selectedSeasonId = tab.dataset.seasonId as string;
loadEpisodes(root, seriesId, selectedSeasonId);
}); });
}); });
loadEpisodes(root, seriesId, seasons[0]!.id); loadEpisodes(root, seriesId, seasons[0]!.id);
} }
/**
* A button that launches a watch party over whatever `itemId()` returns
* at click time - the series (every season) or the selected season (see
* the server's POST /api/parties Series/Season branches) - then opens it.
*/
function wireLaunchButton(btn: HTMLButtonElement, itemId: () => string): void {
const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
createParty(itemId())
.then((party) => navigate(`/party/${party.partyId}`))
.catch((err: unknown) => {
console.error('[series] launch failed', err);
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
});
});
}
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> { async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
const listEl = root.querySelector('#episodeList')!; const listEl = root.querySelector('#episodeList')!;
listEl.innerHTML = LOADING_HINT; listEl.innerHTML = LOADING_HINT;

View file

@ -3,6 +3,7 @@ import html from './StaticParties.html';
import { connectLobby } from '../../api/party'; import { connectLobby } from '../../api/party';
import { listStaticParties } from '../../api/staticParty'; import { listStaticParties } from '../../api/staticParty';
import { escapeHtml } from '../../tiles'; import { escapeHtml } from '../../tiles';
import { SOURCE_LABELS } from '../staticParty/StaticParty';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
import type { StaticPartySummaryDto } from '../../types'; import type { StaticPartySummaryDto } from '../../types';
@ -64,7 +65,7 @@ function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" /> <img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="staticCardInfo"> <div class="staticCardInfo">
<strong>${escapeHtml(p.name)}</strong> <strong>${escapeHtml(p.name)}</strong>
<span class="hint">${p.sourceType === 'series' ? 'Série' : 'Playlist'} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span> <span class="hint">${SOURCE_LABELS[p.sourceType]} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
<div class="staticCardBar"><div style="width:${percent}%"></div></div> <div class="staticCardBar"><div style="width:${percent}%"></div></div>
${p.livePartyId ${p.livePartyId
? '<span class="staticCardLive">● Séance en cours</span>' ? '<span class="staticCardLive">● Séance en cours</span>'

View file

@ -16,6 +16,14 @@ import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast'; import { showToast } from '../../toast';
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types'; import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
/** How each lounge party source is named in the UI - shared with the
* "Lounge parties" tab (views/staticParties). */
export const SOURCE_LABELS: Record<StaticPartyDetailDto['sourceType'], string> = {
series: 'Série',
season: 'Saison',
playlist: 'Playlist'
};
/** /**
* Route `/static/:id` - one static (persistent) watch party: the group's * Route `/static/:id` - one static (persistent) watch party: the group's
* progress through the whole series/playlist, its members, and - the * progress through the whole series/playlist, its members, and - the
@ -61,7 +69,7 @@ async function reload(root: HTMLElement, staticId: string): Promise<void> {
function render(root: HTMLElement, detail: StaticPartyDetailDto): void { function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl; (root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
root.querySelector('#staticTitle')!.textContent = detail.name; root.querySelector('#staticTitle')!.textContent = detail.name;
const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist'; const sourceLabel = SOURCE_LABELS[detail.sourceType];
root.querySelector('#staticMeta')!.textContent = root.querySelector('#staticMeta')!.textContent =
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`; `${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0; const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;

View file

@ -3,14 +3,17 @@ import { navigate } from '../../router';
import { showToast } from '../../toast'; import { showToast } from '../../toast';
/** /**
* "Créer une lounge party" - shared by views/series and * "Créer une lounge party" - shared by views/series (whole series, or
* views/playlist: creates a static watch party over `sourceId` (a series * the selected season) and views/playlist: creates a lounge party over
* or a video playlist - the server decides which, and refuses anything * the source id (a series, a season or a video playlist - the server
* else with a message) and opens its page. * decides which, and refuses anything else with a message) and opens its
* page. `sourceId` is read at click time, so a caller whose source can
* change (the selected season tab) wires the button only once.
*/ */
export function wireCreateStaticButton(btn: HTMLButtonElement, sourceId: string): void { export function wireCreateStaticButton(btn: HTMLButtonElement, getSourceId: () => string): void {
const idleLabel = btn.textContent || ''; const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const sourceId = getSourceId();
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Création...'; btn.textContent = 'Création...';
createStaticParty(sourceId) createStaticParty(sourceId)

View file

@ -851,6 +851,23 @@ player against the new `itemId` on that message. A movie or a
standalone-started episode just gets a one-item queue, so this is a strict standalone-started episode just gets a one-item queue, so this is a strict
superset of the old single-item behavior, not a separate code path. superset of the old single-item behavior, not a separate code path.
**Later: a whole series or one season, not just "from this episode".**
`POST /api/parties` also accepts a `Series` id and a `Season` id, not
only an episode.
- **Series**: queues every season from S1E1. Specials (season 0) sort
first in Emby's list and are left out, so launching the whole series
builds the same queue as clicking S1E1. This is the pure
`CatalogService.seriesQueueFrom`, which the episode path now uses too.
- **Season**: queues only that season's episodes
(`CatalogService.seasonQueue`).
- **Where it's launched from**: the series page's "Lancer toute la
série" / "Lancer cette saison" buttons. A series tile in a library
also gets the normal "Lancer une watch party" button: `Series` joined
`WATCH_PARTY_TYPES`.
- **Lounge parties** take the same two sources (a whole series or one
season). See spec/static-watch-party.md.
### Player: language, subtitles, quality ### Player: language, subtitles, quality
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s `GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s

View file

@ -8,7 +8,7 @@ import { Server as SocketIoServer } from 'socket.io';
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config'; import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
import { Logger } from './Logger'; import { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService'; import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService'; import { CatalogService, CatalogFetchError, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService';
import { PartyService } from './services/PartyService'; import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService'; import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService'; import { TranscodeService } from './services/TranscodeService';
@ -356,6 +356,20 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
// resolveMusicTracksForItem). Asked for directly, alongside the same // resolveMusicTracksForItem). Asked for directly, alongside the same
// action on the party's own existing queue - see the "queue" route // action on the party's own existing queue - see the "queue" route
// below - both share this resolution. // below - both share this resolution.
// A whole series - every season, in order, from S1E1 (see
// seriesQueueFrom). Launched from the series page's own button or its
// library tile; an episode's own button still starts from that episode
// (the Episode branch below).
if (item.Type === 'Series') {
await createPartyFromSeries(res, session, item);
return;
}
// Just one season - the series page's per-season button (see seasonQueue).
if (item.Type === 'Season') {
await createPartyFromSeason(res, session, item);
return;
}
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') { if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
const tracks = await resolveMusicTracksForItem(session, item); const tracks = await resolveMusicTracksForItem(session, item);
createMusicPartyFromTracks(res, session, tracks); createMusicPartyFromTracks(res, session, tracks);
@ -372,9 +386,8 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
let queue: QueueItem[] | null = null; let queue: QueueItem[] | null = null;
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) { if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId); const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
const startIndex = all.findIndex((e) => e.Id === itemId); const remaining = seriesQueueFrom(all, itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item]; queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
queue = remaining.map(toVideoQueueItem);
} }
const party = PartyService.create( const party = PartyService.create(
@ -479,6 +492,45 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
res.json(PartyService.toPublicState(party, session.userId)); res.json(PartyService.toPublicState(party, session.userId));
} }
/** The "launch the whole series" half of POST /api/parties - a watch
* party queued with every episode of every season (see seriesQueueFrom). */
async function createPartyFromSeries(res: Response, session: EmbySession, series: EmbyItem): Promise<void> {
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, series.Id);
const queue = seriesQueueFrom(episodes).map(toVideoQueueItem);
if (!queue.length) {
res.status(400).json({ error: "Cette série n'a aucun épisode." });
return;
}
createVideoPartyFromQueue(res, session, queue, `series="${series.Name}"`);
}
/** The "launch just this season" half of POST /api/parties - only that
* season's episodes, nothing after it (see seasonQueue). */
async function createPartyFromSeason(res: Response, session: EmbySession, season: EmbyItem): Promise<void> {
if (!season.SeriesId) {
res.status(400).json({ error: 'Saison introuvable.' });
return;
}
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, season.SeriesId);
const queue = seasonQueue(episodes, season).map(toVideoQueueItem);
if (!queue.length) {
res.status(400).json({ error: "Cette saison n'a aucun épisode." });
return;
}
createVideoPartyFromQueue(res, session, queue, `season="${season.Name}" series=${season.SeriesId}`);
}
/** Creates a video watch party playing `queue` in order, hosted by
* `session` - shared by the whole-series and single-season launches. */
function createVideoPartyFromQueue(res: Response, session: EmbySession, queue: QueueItem[], logContext: string): void {
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
Logger.party(`queue launch partyId=${party.id} ${logContext} episodes=${queue.length}`);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum, /** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
* or MusicArtist - the three POST /api/parties and the "queue" route * or MusicArtist - the three POST /api/parties and the "queue" route
* below ever call this for) resolved into a flat, ordered track list - * below ever call this for) resolved into a flat, ordered track list -
@ -682,12 +734,18 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
// StaticPartyService and spec/static-watch-party.md. Each session is an // StaticPartyService and spec/static-watch-party.md. Each session is an
// ordinary live party (PartyService) linked back through party.staticLink. // ordinary live party (PartyService) linked back through party.staticLink.
/** Fetches a static party's source (every episode of the series, or the /** Fetches a static party's source under `session`'s own account - so
* playlist's video items) under `session`'s own account - so each item * each item carries *their* Emby UserData (Played): the whole series
* carries *their* Emby UserData (Played). */ * from S1E1 (specials left out - see seriesQueueFrom), one season's
* episodes, or a playlist's video items. */
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> { async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
if (source.type === 'series') { if (source.type === 'series') {
return CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId); return seriesQueueFrom(await CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId));
}
if (source.type === 'season') {
if (!source.seriesId) return [];
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, source.seriesId);
return seasonQueue(episodes, { Id: source.sourceId, IndexNumber: source.seasonNumber });
} }
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId)); const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
return mediaType === 'video' ? items : []; return mediaType === 'video' ? items : [];
@ -743,28 +801,38 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const session = req.embySession!; const session = req.embySession!;
const item = await CatalogService.getRawItem(session, sourceId); const item = await CatalogService.getRawItem(session, sourceId);
// An episode stands for its whole series - the natural entry point from // A whole series, one season of it, or a video playlist. An episode
// stands for its whole series - the natural entry point from
// views/series is the series itself, but nothing's lost accepting one. // views/series is the series itself, but nothing's lost accepting one.
// `sourceItem` is what gives the default name and the artwork: the
// series itself for a season (a season's own poster is often missing).
let source: StaticParty['source']; let source: StaticParty['source'];
let sourceItem = item; let sourceItem = item;
let defaultName = item.Name;
if (item.Type === 'Series') { if (item.Type === 'Series') {
source = { type: 'series', sourceId: item.Id }; source = { type: 'series', sourceId: item.Id };
} else if (item.Type === 'Season' && item.SeriesId) {
source = { type: 'season', sourceId: item.Id, seriesId: item.SeriesId, seasonNumber: item.IndexNumber };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
defaultName = `${sourceItem.Name} - ${item.Name}`;
} else if (item.Type === 'Episode' && item.SeriesId) { } else if (item.Type === 'Episode' && item.SeriesId) {
source = { type: 'series', sourceId: item.SeriesId }; source = { type: 'series', sourceId: item.SeriesId };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId); sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
defaultName = sourceItem.Name;
} else if (item.Type === 'Playlist') { } else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id }; source = { type: 'playlist', sourceId: item.Id };
} else { } else {
res.status(400).json({ error: 'Une lounge party se crée depuis une série ou une playlist vidéo.' }); res.status(400).json({ error: 'Une lounge party se crée depuis une série, une saison ou une playlist vidéo.' });
return; return;
} }
const raw = await fetchStaticSourceItems(session, source); const raw = await fetchStaticSourceItems(session, source);
if (!raw.length) { if (!raw.length) {
res.status(400).json({ error: source.type === 'playlist' ? 'Cette playlist ne contient aucune vidéo.' : "Cette série n'a aucun épisode." }); const emptyMessages = { series: "Cette série n'a aucun épisode.", season: "Cette saison n'a aucun épisode.", playlist: 'Cette playlist ne contient aucune vidéo.' };
res.status(400).json({ error: emptyMessages[source.type] });
return; return;
} }
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : sourceItem.Name; const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : defaultName;
const sp = StaticPartyService.create( const sp = StaticPartyService.create(
{ userId: session.userId, userName: session.userName }, { userId: session.userId, userName: session.userName },
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem) source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)

View file

@ -8,10 +8,11 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMar
// 'MusicVideo' is Emby's own item type for a musicvideos library's // 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos). // contents (distinct from the generic 'Video', which is home videos).
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't // 'Audio' is a music track. 'Series' isn't itself streamable, but
// directly playable - only show "start a party" for things Emby can // launching one queues the whole series (see seriesQueueFrom and POST
// actually stream. "Voir sur Emby" always works though. // /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']); // own pages have their own launch actions. "Voir sur Emby" always works.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio', 'Series']);
// Emby's /Views can return all sorts of things beyond actual media // Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, live TV, photos, books, a mixed-content // libraries - boxset collections, live TV, photos, books, a mixed-content
@ -68,6 +69,36 @@ export function imageUrlFor(item: EmbyItem): string {
return `/api/catalog/image/${imageItemId}`; return `/api/catalog/image/${imageItemId}`;
} }
/**
* What a watch party queues from a series' full episode list (Emby's own
* order, every season - see getAllSeriesEpisodesRaw): everything from
* `fromEpisodeId` on, or - when launching the whole series - everything
* from its first regular episode on. Specials (season 0) sort first in
* Emby's list, so "the whole series" deliberately starts at S1E1, the
* same queue clicking S1E1's own button builds. An unknown
* `fromEpisodeId` yields an empty list (the caller falls back to the one
* item). Pure.
*/
export function seriesQueueFrom(episodes: EmbyItem[], fromEpisodeId?: string): EmbyItem[] {
if (fromEpisodeId) {
const index = episodes.findIndex((e) => e.Id === fromEpisodeId);
return index >= 0 ? episodes.slice(index) : [];
}
const firstRegular = episodes.findIndex((e) => e.ParentIndexNumber !== 0);
return firstRegular >= 0 ? episodes.slice(firstRegular) : episodes;
}
/**
* Just one season's episodes, in Emby's order, out of a series' full
* episode list - "launch this season". Matched by SeasonId, falling back
* to the season number for an episode Emby returned without one. Pure.
*/
export function seasonQueue(episodes: EmbyItem[], season: { Id: string; IndexNumber?: number }): EmbyItem[] {
return episodes.filter((e) => e.SeasonId
? e.SeasonId === season.Id
: season.IndexNumber != null && e.ParentIndexNumber === season.IndexNumber);
}
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } { export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
if (!items.length) return { mediaType: 'video', items: [] }; if (!items.length) return { mediaType: 'video', items: [] };
const isMusic = items[0]!.Type === 'Audio'; const isMusic = items[0]!.Type === 'Audio';

View file

@ -173,6 +173,8 @@ export interface EmbyItem {
Type?: string; Type?: string;
SeriesId?: string; SeriesId?: string;
SeriesName?: string; SeriesName?: string;
/** An episode's own season - see CatalogService.seasonQueue. */
SeasonId?: string;
ParentIndexNumber?: number; ParentIndexNumber?: number;
IndexNumber?: number; IndexNumber?: number;
RunTimeTicks?: number; RunTimeTicks?: number;
@ -452,6 +454,10 @@ export interface LobbyPartyDto {
// --- Static (persistent) watch party ------------------------------------------ // --- Static (persistent) watch party ------------------------------------------
/** A lounge party's source: a whole series, one season of it, or a
* video playlist. */
export type StaticPartySourceType = 'series' | 'season' | 'playlist';
/** One member of a static watch party - persisted, independent of any /** One member of a static watch party - persisted, independent of any
* live connection (unlike PartyMember). */ * live connection (unlike PartyMember). */
export interface StaticPartyMember { export interface StaticPartyMember {
@ -481,7 +487,10 @@ export interface StaticPartyGroupProgress {
export interface StaticParty { export interface StaticParty {
id: string; id: string;
name: string; name: string;
source: { type: 'series' | 'playlist'; sourceId: string }; /** What the items come from. A `season` also records its series
* (`seriesId`) and number (`seasonNumber`) - episodes are fetched per
* series, then filtered (see CatalogService.seasonQueue). */
source: { type: StaticPartySourceType; sourceId: string; seriesId?: string; seasonNumber?: number };
imageUrl: string; imageUrl: string;
createdByUserId: string; createdByUserId: string;
createdUtc: number; createdUtc: number;
@ -506,7 +515,7 @@ export interface StaticPartyItemDto extends QueueItem {
export interface StaticPartySummaryDto { export interface StaticPartySummaryDto {
id: string; id: string;
name: string; name: string;
sourceType: 'series' | 'playlist'; sourceType: StaticPartySourceType;
imageUrl: string; imageUrl: string;
itemCount: number; itemCount: number;
groupWatchedCount: number; groupWatchedCount: number;

View file

@ -4,7 +4,7 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService'; import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient'; import { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbyItem, EmbySession } from '../src/types'; import type { EmbyItem, EmbySession } from '../src/types';
@ -314,3 +314,34 @@ describe('imageUrlFor', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1'); assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
}); });
}); });
describe('seriesQueueFrom / seasonQueue', () => {
const ep = (id: string, season: number, seasonId?: string): EmbyItem => ({ Id: id, Name: id, Type: 'Episode', ParentIndexNumber: season, SeasonId: seasonId });
// Emby's own order: specials (season 0) first, then every season in turn.
const all = [ep('sp1', 0, 'se0'), ep('s1e1', 1, 'se1'), ep('s1e2', 1, 'se1'), ep('s2e1', 2, 'se2'), ep('s2e2', 2, 'se2')];
it('the whole series starts at S1E1 - specials sort first and are skipped', () => {
assert.deepEqual(seriesQueueFrom(all).map((e) => e.Id), ['s1e1', 's1e2', 's2e1', 's2e2']);
});
it('a series with only specials still queues them', () => {
assert.deepEqual(seriesQueueFrom([ep('sp1', 0), ep('sp2', 0)]).map((e) => e.Id), ['sp1', 'sp2']);
});
it('from an episode, queues it and everything after it, across seasons', () => {
assert.deepEqual(seriesQueueFrom(all, 's1e2').map((e) => e.Id), ['s1e2', 's2e1', 's2e2']);
});
it('an unknown starting episode yields nothing (the caller falls back to that one item)', () => {
assert.deepEqual(seriesQueueFrom(all, 'nope'), []);
});
it('a season queues only its own episodes', () => {
assert.deepEqual(seasonQueue(all, { Id: 'se1', IndexNumber: 1 }).map((e) => e.Id), ['s1e1', 's1e2']);
});
it('a season falls back to the season number for episodes without a SeasonId', () => {
const noIds = [ep('a', 1), ep('b', 2), ep('c', 2)];
assert.deepEqual(seasonQueue(noIds, { Id: 'se2', IndexNumber: 2 }).map((e) => e.Id), ['b', 'c']);
});
});

View file

@ -195,11 +195,15 @@ describe('StaticPartyStore', () => {
const otherFile = path.join(partiesDir(), `${other.id}.json`); const otherFile = path.join(partiesDir(), `${other.id}.json`);
const past = new Date(Date.now() - 60000); const past = new Date(Date.now() - 60000);
fs.utimesSync(otherFile, past, past); fs.utimesSync(otherFile, past, past);
// Compared against what the filesystem itself reports back, not
// past.getTime(): Linux returns mtimeMs as a float that can land a
// hair off the exact millisecond set (e.g. ...780.999 for ...781).
const mtimeBefore = fs.statSync(otherFile).mtimeMs;
StaticPartyService.markWatched(sp, alice, 'e1'); StaticPartyService.markWatched(sp, alice, 'e1');
await StaticPartyStore.writeNow(); await StaticPartyStore.writeNow();
assert.equal(fs.statSync(otherFile).mtimeMs, past.getTime(), 'the untouched party\'s file was not rewritten'); assert.equal(fs.statSync(otherFile).mtimeMs, mtimeBefore, 'the untouched party\'s file was not rewritten');
}); });
it('deleting a party removes its file, even with a save still pending', async () => { it('deleting a party removes its file, even with a save still pending', async () => {

View file

@ -79,15 +79,25 @@ des watch parties en cours.
devient l'hôte. devient l'hôte.
- Quitter efface sa propre progression. - Quitter efface sa propre progression.
- Seul le créateur peut supprimer la watch party. - Seul le créateur peut supprimer la watch party.
- **Contenus** : séries et playlists vidéo uniquement. Une playlist musicale - **Contenus** : une série entière, **une seule saison**, ou une playlist
est refusée. vidéo. Une playlist musicale est refusée.
- Série entière : tous les épisodes de toutes les saisons à partir de S1E1.
Les spéciaux (saison 0) sont exclus : Emby les trie en premier, et une
séance commencerait par eux (`CatalogService.seriesQueueFrom`). Les
lounge parties créées avant ce changement gardent leurs spéciaux déjà
en liste.
- Saison : uniquement les épisodes de cette saison
(`CatalogService.seasonQueue`, par `SeasonId`, sinon par numéro de
saison). La source garde `seriesId` et `seasonNumber`, car les
épisodes sont récupérés par série puis filtrés. Nom par défaut :
« Série - Saison », avec l'affiche de la série.
## API (`server/src/index.ts`) ## API (`server/src/index.ts`)
| Méthode | Route | Rôle | | Méthode | Route | Rôle |
|---|---|---| |---|---|---|
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours | | GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
| POST | `/api/static-parties` | `{sourceId, name?}` : série, épisode (sa série) ou playlist | | POST | `/api/static-parties` | `{sourceId, name?}` : série, saison, épisode (sa série) ou playlist |
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` | | GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter | | POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
| DELETE | `/:id` | supprimer (créateur uniquement) | | DELETE | `/:id` | supprimer (créateur uniquement) |
@ -131,8 +141,12 @@ des watch parties en cours.
solo, ou lancer une watch party de rattrapage ; solo, ou lancer une watch party de rattrapage ;
- les membres ; - les membres ;
- la liste complète des items. - la liste complète des items.
- Bouton « Créer une lounge party » sur `views/series` et sur - Page série (`views/series`) :
`views/playlist` (vidéo uniquement). - en-tête : « Lancer toute la série » (watch party) et « Créer une lounge
party » (série entière) ;
- au-dessus des épisodes de l'onglet de saison sélectionné : « Lancer
cette saison » (watch party) et « Lounge party de cette saison ».
- « Créer une lounge party » sur `views/playlist` (vidéo uniquement).
- Onglet dédié « Lounge parties » (sidebar et menu mobile), route - Onglet dédié « Lounge parties » (sidebar et menu mobile), route
`/static` (`views/staticParties/`) : une carte par watch party avec la `/static` (`views/staticParties/`) : une carte par watch party avec la
progression, le prochain élément, un badge « N manqués » et une pastille progression, le prochain élément, un badge « N manqués » et une pastille