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 {
${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