Two different items both failed the same way when picking a subtitle - without knowing whether the second one was also a PGS (image/bitmap) track like the first confirmed one, that result was ambiguous: still the same known issue, or something new? Rather than keep digging through server logs per item, this app can just know the answer itself - Emby's PlaybackInfo response already includes each subtitle stream's Codec. - server/src/services/CatalogService.ts: getPlaybackOptions reads it and reports supportsBurnIn: false for image-based subtitle codecs (hdmv_pgs_subtitle/dvd_subtitle/dvb_subtitle - IMAGE_SUBTITLE_CODECS). This app forces SubtitleMethod=Encode whenever a subtitle is picked (applyPlaybackOverrides), asking ffmpeg's `subtitles` filter (libass) to burn it in - libass renders text via a font, it has no path for compositing a pre-rendered bitmap at all, confirmed against a real server (see docs/architecture.md's Round 4/5). - server/src/types.ts, client/src/types.ts: PlaybackTrackOption gains supportsBurnIn (subtitle tracks only, undefined for audio). - client/src/views/player/Player.ts: the subtitle <select> now disables these options with a "(image, non disponible)" label instead of letting anyone pick a track that's guaranteed to break playback - listed so it's clear why, not silently missing. - server/test/catalogService.test.ts: new case covering subrip/ass (supportsBurnIn: true) vs hdmv_pgs_subtitle (false) from the same MediaStreams response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
109 lines
5.4 KiB
TypeScript
109 lines
5.4 KiB
TypeScript
// 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<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('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
|
|
);
|
|
});
|
|
|
|
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not burn-in-able, 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)?.supportsBurnIn, true, 'subrip is text - burns in fine');
|
|
assert.equal(options.subtitleTracks.find((t) => t.index === 3)?.supportsBurnIn, false, 'PGS is a bitmap - this player\'s forced Encode can\'t render it');
|
|
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.supportsBurnIn, true, 'ass is text too');
|
|
});
|
|
});
|