diff --git a/client/src/types.ts b/client/src/types.ts index 8d0924d..8e0573a 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -181,7 +181,7 @@ export interface LobbyPartyDto { export interface StaticPartySummaryDto { id: string; name: string; - sourceType: 'series' | 'playlist'; + sourceType: 'series' | 'season' | 'playlist'; imageUrl: string; itemCount: number; groupWatchedCount: number; diff --git a/client/src/views/playlist/Playlist.ts b/client/src/views/playlist/Playlist.ts index bee2bb5..fb33cc4 100644 --- a/client/src/views/playlist/Playlist.ts +++ b/client/src/views/playlist/Playlist.ts @@ -63,7 +63,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr if (items[0]!.type !== 'Audio') { const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement; createStaticBtn.classList.remove('hide'); - wireCreateStaticButton(createStaticBtn, playlistId); + wireCreateStaticButton(createStaticBtn, () => playlistId); } listEl.innerHTML = items.map((item, i) => { diff --git a/client/src/views/series/Series.css b/client/src/views/series/Series.css index aba8b10..780e069 100644 --- a/client/src/views/series/Series.css +++ b/client/src/views/series/Series.css @@ -17,7 +17,8 @@ .seriesPoster.noImage { visibility: hidden; } -.seriesActions { +.seriesActions, +.seasonActions { display: flex; flex-wrap: wrap; align-items: center; @@ -25,6 +26,11 @@ 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) { .seriesHeader { flex-direction: column; } .seriesPoster { width: 120px; } diff --git a/client/src/views/series/Series.html b/client/src/views/series/Series.html index d219701..b09b75c 100644 --- a/client/src/views/series/Series.html +++ b/client/src/views/series/Series.html @@ -5,10 +5,15 @@

- + + Voir sur Emby
+
+ + +

Chargement...

diff --git a/client/src/views/series/Series.ts b/client/src/views/series/Series.ts index 6b73ca0..fa15d54 100644 --- a/client/src/views/series/Series.ts +++ b/client/src/views/series/Series.ts @@ -35,13 +35,26 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom overviewEl.classList.remove('hide'); } (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) { + (root.querySelector('#launchSeriesBtn') as HTMLButtonElement).disabled = true; + (root.querySelector('#createStaticBtn') as HTMLButtonElement).disabled = true; root.querySelector('#episodeList')!.innerHTML = '

Aucune saison trouvée.

'; 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')!; tabsEl.innerHTML = seasons .map((s, i) => ``) @@ -52,13 +65,35 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom if (tab.classList.contains('active')) return; tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('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); } +/** + * 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 { const listEl = root.querySelector('#episodeList')!; listEl.innerHTML = LOADING_HINT; diff --git a/client/src/views/staticParties/StaticParties.ts b/client/src/views/staticParties/StaticParties.ts index 09dae4e..8f5f9a1 100644 --- a/client/src/views/staticParties/StaticParties.ts +++ b/client/src/views/staticParties/StaticParties.ts @@ -3,6 +3,7 @@ import html from './StaticParties.html'; import { connectLobby } from '../../api/party'; import { listStaticParties } from '../../api/staticParty'; import { escapeHtml } from '../../tiles'; +import { SOURCE_LABELS } from '../staticParty/StaticParty'; import { LOADING_HINT } from '../../loading'; import type { StaticPartySummaryDto } from '../../types'; @@ -64,7 +65,7 @@ function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
${escapeHtml(p.name)} - ${p.sourceType === 'series' ? 'Série' : 'Playlist'} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''} + ${SOURCE_LABELS[p.sourceType]} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}
${p.livePartyId ? '● Séance en cours' diff --git a/client/src/views/staticParty/StaticParty.ts b/client/src/views/staticParty/StaticParty.ts index 365b4e4..588546a 100644 --- a/client/src/views/staticParty/StaticParty.ts +++ b/client/src/views/staticParty/StaticParty.ts @@ -16,6 +16,14 @@ import { LOADING_HINT } from '../../loading'; import { showToast } from '../../toast'; 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 = { + series: 'Série', + season: 'Saison', + playlist: 'Playlist' +}; + /** * Route `/static/:id` - one static (persistent) watch party: the group's * progress through the whole series/playlist, its members, and - the @@ -61,7 +69,7 @@ async function reload(root: HTMLElement, staticId: string): Promise { function render(root: HTMLElement, detail: StaticPartyDetailDto): void { (root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl; root.querySelector('#staticTitle')!.textContent = detail.name; - const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist'; + const sourceLabel = SOURCE_LABELS[detail.sourceType]; root.querySelector('#staticMeta')!.textContent = `${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; diff --git a/client/src/views/staticParty/createStaticButton.ts b/client/src/views/staticParty/createStaticButton.ts index 4524f69..8a2a92f 100644 --- a/client/src/views/staticParty/createStaticButton.ts +++ b/client/src/views/staticParty/createStaticButton.ts @@ -3,14 +3,17 @@ import { navigate } from '../../router'; import { showToast } from '../../toast'; /** - * "Créer une lounge party" - shared by views/series and - * views/playlist: creates a static watch party over `sourceId` (a series - * or a video playlist - the server decides which, and refuses anything - * else with a message) and opens its page. + * "Créer une lounge party" - shared by views/series (whole series, or + * the selected season) and views/playlist: creates a lounge party over + * the source id (a series, a season or a video playlist - the server + * 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 || ''; btn.addEventListener('click', () => { + const sourceId = getSourceId(); btn.disabled = true; btn.textContent = 'Création...'; createStaticParty(sourceId) diff --git a/docs/architecture.md b/docs/architecture.md index efe9d77..a1106a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 `GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s diff --git a/server/src/index.ts b/server/src/index.ts index 43a3fe1..d641400 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,7 +8,7 @@ import { Server as SocketIoServer } from 'socket.io'; import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config'; import { Logger } from './Logger'; 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 { MusicQueueService } from './services/MusicQueueService'; 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 // action on the party's own existing queue - see the "queue" route // 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') { const tracks = await resolveMusicTracksForItem(session, item); createMusicPartyFromTracks(res, session, tracks); @@ -372,9 +386,8 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => { let queue: QueueItem[] | null = null; if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) { const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId); - const startIndex = all.findIndex((e) => e.Id === itemId); - const remaining = startIndex >= 0 ? all.slice(startIndex) : [item]; - queue = remaining.map(toVideoQueueItem); + const remaining = seriesQueueFrom(all, itemId); + queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem); } const party = PartyService.create( @@ -479,6 +492,45 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play 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 { + 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 { + 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, * or MusicArtist - the three POST /api/parties and the "queue" route * 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 // ordinary live party (PartyService) linked back through party.staticLink. -/** Fetches a static party's source (every episode of the series, or the - * playlist's video items) under `session`'s own account - so each item - * carries *their* Emby UserData (Played). */ +/** Fetches a static party's source under `session`'s own account - so + * each item carries *their* Emby UserData (Played): the whole series + * 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 { 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)); return mediaType === 'video' ? items : []; @@ -743,28 +801,38 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => { const session = req.embySession!; 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. + // `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 sourceItem = item; + let defaultName = item.Name; if (item.Type === 'Series') { 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) { source = { type: 'series', sourceId: item.SeriesId }; sourceItem = await CatalogService.getRawItem(session, item.SeriesId); + defaultName = sourceItem.Name; } else if (item.Type === 'Playlist') { source = { type: 'playlist', sourceId: item.Id }; } 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; } const raw = await fetchStaticSourceItems(session, source); 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; } - 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( { userId: session.userId, userName: session.userName }, source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem) diff --git a/server/src/services/CatalogService.ts b/server/src/services/CatalogService.ts index 613ab24..0d82b0d 100644 --- a/server/src/services/CatalogService.ts +++ b/server/src/services/CatalogService.ts @@ -8,10 +8,11 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMar // 'MusicVideo' is Emby's own item type for a musicvideos library's // contents (distinct from the generic 'Video', which is home videos). -// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't -// directly playable - only show "start a party" for things Emby can -// actually stream. "Voir sur Emby" always works though. -const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']); +// 'Audio' is a music track. 'Series' isn't itself streamable, but +// launching one queues the whole series (see seriesQueueFrom and POST +// /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their +// 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 // 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}`; } +/** + * 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[] } { if (!items.length) return { mediaType: 'video', items: [] }; const isMusic = items[0]!.Type === 'Audio'; diff --git a/server/src/types.ts b/server/src/types.ts index 0d2dd39..32efc06 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -173,6 +173,8 @@ export interface EmbyItem { Type?: string; SeriesId?: string; SeriesName?: string; + /** An episode's own season - see CatalogService.seasonQueue. */ + SeasonId?: string; ParentIndexNumber?: number; IndexNumber?: number; RunTimeTicks?: number; @@ -452,6 +454,10 @@ export interface LobbyPartyDto { // --- 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 * live connection (unlike PartyMember). */ export interface StaticPartyMember { @@ -481,7 +487,10 @@ export interface StaticPartyGroupProgress { export interface StaticParty { id: 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; createdByUserId: string; createdUtc: number; @@ -506,7 +515,7 @@ export interface StaticPartyItemDto extends QueueItem { export interface StaticPartySummaryDto { id: string; name: string; - sourceType: 'series' | 'playlist'; + sourceType: StaticPartySourceType; imageUrl: string; itemCount: number; groupWatchedCount: number; diff --git a/server/test/catalogService.test.ts b/server/test/catalogService.test.ts index b3b8e18..ed2e65d 100644 --- a/server/test/catalogService.test.ts +++ b/server/test/catalogService.test.ts @@ -4,7 +4,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; 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 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'); }); }); + +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']); + }); +}); diff --git a/spec/static-watch-party.md b/spec/static-watch-party.md index 116c209..4eccbb1 100644 --- a/spec/static-watch-party.md +++ b/spec/static-watch-party.md @@ -79,15 +79,25 @@ des watch parties en cours. devient l'hôte. - Quitter efface sa propre progression. - Seul le créateur peut supprimer la watch party. -- **Contenus** : séries et playlists vidéo uniquement. Une playlist musicale - est refusée. +- **Contenus** : une série entière, **une seule saison**, ou une playlist + 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`) | 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 | -| 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` | | POST | `/:id/join`, `/:id/leave` | devenir membre / quitter | | DELETE | `/:id` | supprimer (créateur uniquement) | @@ -131,8 +141,12 @@ des watch parties en cours. solo, ou lancer une watch party de rattrapage ; - les membres ; - la liste complète des items. -- Bouton « Créer une lounge party » sur `views/series` et sur - `views/playlist` (vidéo uniquement). +- Page série (`views/series`) : + - 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 `/static` (`views/staticParties/`) : une carte par watch party avec la progression, le prochain élément, un badge « N manqués » et une pastille