Launch a whole series or a single season, as a watch or lounge party
- 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>
This commit is contained in:
parent
363cf44ebe
commit
1a10e5bf57
14 changed files with 265 additions and 37 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -5,10 +5,15 @@
|
|||
<h1 id="seriesTitle"></h1>
|
||||
<p class="seriesOverview hide" id="seriesOverview"></p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 = '<p class="hint">Aucune saison trouvée.</p>';
|
||||
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) => `<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;
|
||||
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<void> {
|
||||
const listEl = root.querySelector('#episodeList')!;
|
||||
listEl.innerHTML = LOADING_HINT;
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<div class="staticCardInfo">
|
||||
<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>
|
||||
${p.livePartyId
|
||||
? '<span class="staticCardLive">● Séance en cours</span>'
|
||||
|
|
|
|||
|
|
@ -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<StaticPartyDetailDto['sourceType'], string> = {
|
||||
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<void> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
* 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<EmbyItem[]> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue