// Exercises CatalogService's real logic (library-type filtering, the // audio/subtitle/quality override forcing) 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 } from '../src/services/CatalogService'; import { EmbyClient } from '../src/emby/EmbyClient'; import type { 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('proxyStream forces a subtitle pick (and SubtitleMethod=Encode) onto a negotiated TranscodingUrl', async (t) => { t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }], PlaySessionId: 'sess1' })); let capturedTarget = ''; t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; }); await CatalogService.proxyStream(fakeReq({ SubtitleStreamIndex: '3', AudioStreamIndex: '2' }), fakeRes(), session, 'item1'); assert.ok(capturedTarget.includes('SubtitleStreamIndex=3')); assert.ok(capturedTarget.includes('SubtitleMethod=Encode')); assert.ok(capturedTarget.includes('AudioStreamIndex=2')); }); it('proxyStream\'s hand-built fallback picks /Audio/ (and drops VideoCodec) for a music track', async (t) => { t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src2' }], PlaySessionId: 'sess2' })); let capturedTarget = ''; t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; }); await CatalogService.proxyStream(fakeReq({ mediaType: 'audio' }), fakeRes(), session, 'track1'); assert.ok(capturedTarget.startsWith('/Audio/track1/master.m3u8')); assert.ok(!capturedTarget.includes('VideoCodec')); }); it('proxyStream\'s hand-built fallback picks /Videos/ (with VideoCodec) for anything else', async (t) => { t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' })); let capturedTarget = ''; t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; }); await CatalogService.proxyStream(fakeReq({}), fakeRes(), session, 'movie1'); assert.ok(capturedTarget.startsWith('/Videos/movie1/master.m3u8')); assert.ok(capturedTarget.includes('VideoCodec=h264')); }); 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 ); }); });