// 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, 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): 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('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('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'); }); });