watchparty/server/test/catalogService.test.ts
Nicolas 1a10e5bf57
All checks were successful
Publier l'image Docker / test (pull_request) Successful in 24s
Publier l'image Docker / publish (pull_request) Has been skipped
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>
2026-09-24 20:22:57 +02:00

347 lines
16 KiB
TypeScript

// Exercises CatalogService's real logic (library-type filtering, the
// audio/quality override forcing, subtitle proxying) through its PUBLIC
// methods against a mocked EmbyClient, not by calling private helpers directly.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbyItem, EmbySession } from '../src/types';
const session: EmbySession = { embyToken: 'tok', userId: 'u1', userName: 'Alice', serverId: 'srv1', createdUtc: Date.now() };
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) as unknown as Response;
}
function fakeReq(query: Record<string, string>): Request {
return { query, headers: {} } as unknown as Request;
}
function fakeRes(): Response {
return { status() { return this; }, json() {}, end() {} } as unknown as Response;
}
describe('CatalogService', () => {
it('getLibraries keeps only movies/tvshows/music/musicvideos out of everything /Views can return', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Items: [
{ Id: '1', Name: 'Films', CollectionType: 'movies' },
{ Id: '2', Name: 'Series', CollectionType: 'tvshows' },
{ Id: '3', Name: 'Musique', CollectionType: 'music' },
{ Id: '4', Name: 'Clips', CollectionType: 'musicvideos' },
{ Id: '5', Name: 'Photos', CollectionType: 'photos' },
{ Id: '6', Name: 'Live TV', CollectionType: 'livetv' },
{ Id: '7', Name: 'Collections', CollectionType: 'boxsets' },
{ Id: '8', Name: 'No type at all' }
]
}));
const libraries = await CatalogService.getLibraries(session);
assert.deepEqual(libraries.map((l) => l.name).sort(), ['Clips', 'Films', 'Musique', 'Series']);
});
it('negotiateStreamTarget forces an audio pick onto a negotiated TranscodingUrl (subtitles are a separate concern - see proxySubtitle)', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
PlaySessionId: 'sess1'
}));
const target = await CatalogService.negotiateStreamTarget(session, 'item1', false, '2', '');
assert.ok(target.includes('AudioStreamIndex=2'));
assert.ok(!target.includes('Subtitle'), 'this method never touches subtitle params at all');
});
it('negotiateStreamTarget\'s hand-built fallback picks /Audio/ (and drops VideoCodec) when isAudio is true', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src2' }], PlaySessionId: 'sess2' }));
const target = await CatalogService.negotiateStreamTarget(session, 'track1', true, '', '');
assert.ok(target.startsWith('/Audio/track1/master.m3u8'));
assert.ok(!target.includes('VideoCodec'));
});
it('negotiateStreamTarget\'s hand-built fallback picks /Videos/ (with VideoCodec) when isAudio is false', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' }));
const target = await CatalogService.negotiateStreamTarget(session, 'movie1', false, '', '');
assert.ok(target.startsWith('/Videos/movie1/master.m3u8'));
assert.ok(target.includes('VideoCodec=h264'));
});
it('negotiateStreamTarget applies a quality (MaxStreamingBitrate) pick the same way as an audio pick', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src4' }], PlaySessionId: 'sess4' }));
const target = await CatalogService.negotiateStreamTarget(session, 'movie2', false, '', '4000000');
assert.ok(target.includes('MaxStreamingBitrate=4000000'));
});
it('negotiateStreamTarget fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [] }));
await assert.rejects(
() => CatalogService.negotiateStreamTarget(session, 'missing', false, '', ''),
(err: Error & { status?: number }) => err.status === 502
);
});
it('getRawItem preserves Emby\'s own status (a real 404), not a blanket 502', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 404 }) as unknown as Response);
await assert.rejects(
() => CatalogService.getRawItem(session, 'missing'),
(err: Error & { status?: number }) => err.status === 404
);
});
it('getRawItem requests Fields=AlbumId - without it, a track started directly (not via its album) always missed imageUrlFor\'s own fallback', async (t) => {
let requestedPath = '';
t.mock.method(EmbyClient, 'fetch', async (pathAndQuery: string) => {
requestedPath = pathAndQuery;
return jsonResponse({ Id: 't1', Name: 'Track', Type: 'Audio', AlbumId: 'al1' });
});
await CatalogService.getRawItem(session, 't1');
assert.ok(requestedPath.includes('Fields=AlbumId'), requestedPath);
});
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not text-based, real text subtitles as fine', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{
Id: 'src1',
MediaStreams: [
{ Type: 'Audio', Index: 1, Language: 'fre' },
{ Type: 'Subtitle', Index: 2, Language: 'eng', Codec: 'subrip' },
{ Type: 'Subtitle', Index: 3, Language: 'eng', Codec: 'hdmv_pgs_subtitle' },
{ Type: 'Subtitle', Index: 4, Language: 'fre', Codec: 'ass' }
]
}]
}));
const options = await CatalogService.getPlaybackOptions(session, 'item1');
assert.equal(options.subtitleTracks.find((t) => t.index === 2)?.isTextBased, true, 'subrip is text');
assert.equal(options.subtitleTracks.find((t) => t.index === 3)?.isTextBased, false, 'PGS is a bitmap - no text for Emby to convert to WebVTT');
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
});
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.equal(markers.introStartTicks, 50000000);
assert.equal(markers.introEndTicks, 900000000);
assert.equal(markers.creditsStartTicks, 20000000000);
});
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxySubtitle(fakeReq({}), fakeRes(), session, 'item9', '2');
assert.equal(capturedTarget, '/Videos/item9/src7/Subtitles/2/Stream.vtt');
});
it('proxySubtitle fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [] }));
await assert.rejects(
() => CatalogService.proxySubtitle(fakeReq({}), fakeRes(), session, 'missing', '0'),
(err: Error & { status?: number }) => err.status === 502
);
});
it('getMediaSource returns the negotiated id/container/streams together - what TranscodeService needs', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{
Id: 'src9',
Container: 'mkv',
MediaStreams: [{ Type: 'Video', Index: 0 }, { Type: 'Subtitle', Index: 1 }]
}]
}));
const source = await CatalogService.getMediaSource(session, 'item1');
assert.equal(source.id, 'src9');
assert.equal(source.container, 'mkv');
assert.equal(source.streams.length, 2);
});
it('getMediaSource falls back to "mkv" when Emby omits Container', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src10' }] }));
const source = await CatalogService.getMediaSource(session, 'item1');
assert.equal(source.container, 'mkv');
});
it('getMediaSource fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [] }));
await assert.rejects(
() => CatalogService.getMediaSource(session, 'missing'),
(err: Error & { status?: number }) => err.status === 502
);
});
it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1' }],
PlaySessionId: 'play1'
}));
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
});
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
capturedPath = path;
return jsonResponse({ Items: [{ Id: 't1', Name: 'Track One', Type: 'Audio' }, { Id: 't2', Name: 'Track Two', Type: 'Audio' }] });
});
const items = await CatalogService.getPlaylistItemsRaw(session, 'pl1');
assert.ok(capturedPath.startsWith('/Playlists/pl1/Items?'));
assert.deepEqual(items.map((i) => i.Id), ['t1', 't2']);
});
it('getPlaylistItems maps a playlist\'s contents through the same DTO shape as everywhere else', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Items: [{ Id: 't1', Name: 'Track One', Type: 'Audio', AlbumArtist: 'Nova Wilder', RunTimeTicks: 20_000_000 }]
}));
const items = await CatalogService.getPlaylistItems(session, 'pl1');
assert.equal(items[0]?.name, 'Track One');
assert.equal(items[0]?.artistName, 'Nova Wilder');
assert.equal(items[0]?.durationTicks, 20_000_000);
});
});
describe('splitPlaylistByMediaType', () => {
const audio = (id: string): EmbyItem => ({ Id: id, Name: id, Type: 'Audio' });
const movie = (id: string): EmbyItem => ({ Id: id, Name: id, Type: 'Movie' });
it('a homogeneous music playlist stays entirely music', () => {
const { mediaType, items } = splitPlaylistByMediaType([audio('t1'), audio('t2')]);
assert.equal(mediaType, 'music');
assert.equal(items.length, 2);
});
it('a homogeneous video playlist stays entirely video', () => {
const { mediaType, items } = splitPlaylistByMediaType([movie('m1'), movie('m2')]);
assert.equal(mediaType, 'video');
assert.equal(items.length, 2);
});
it('a mixed playlist keeps only the first item\'s type, dropping the rest', () => {
const { mediaType, items } = splitPlaylistByMediaType([movie('m1'), audio('t1'), movie('m2')]);
assert.equal(mediaType, 'video');
assert.deepEqual(items.map((i) => i.Id), ['m1', 'm2']);
});
it('an empty playlist is handled without throwing', () => {
const { mediaType, items } = splitPlaylistByMediaType([]);
assert.equal(mediaType, 'video');
assert.equal(items.length, 0);
});
});
describe('imageUrlFor', () => {
it('a movie/episode points at its own id, same as ever', () => {
assert.equal(imageUrlFor({ Id: 'm1', Name: 'Un Film', Type: 'Movie' }), '/api/catalog/image/m1');
});
it('a music track falls back to its ALBUM\'s id - confirmed against a real server that a track essentially never has its own Primary image', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio', AlbumId: 'al1' }), '/api/catalog/image/al1');
});
it('a track with no AlbumId at all (nothing to fall back to) still points at its own id, not a broken URL', () => {
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']);
});
});