Asked for directly. Checked the real server before building anything: no plugin provides this (Cinema Intros is a different feature - pre-show trailers, not per-episode skip points), and Emby's own Chapters field turned out to be auto-generated placeholders every 5 minutes, not real markers. What the server does have natively, confirmed from its own /openapi.json rather than assumed: a per-library EnableMarkerDetection option backed by a built-in "Detect Episode Intros" scheduled task - audio-fingerprint detection, the same idea as Jellyfin's separate Intro Skipper plugin, already part of Emby itself. Enabling it (done directly on the server for the two TV libraries) adds real entries to the same Chapters array, distinguished by MarkerType: IntroStart/IntroEnd/ CreditsStart. This made the feature almost entirely "read what Emby already computed" - no fingerprinting, no new persistence, no background jobs of this app's own. CatalogService.getPlaybackMarkers fetches Fields=Chapters and picks out just those three marker types (nulls when detection hasn't reached this item or isn't enabled for its library - never an error), behind a new GET /api/catalog/item/:id/markers route mirroring getPlaybackOptions. The player gets one new floating button, #skipIntroBtn - an explicit label, not an icon, matching this app's own convention for every request-shaped control. It's a sibling of #bottomBar, not inside it, so it stays reachable through the bar's own auto-hide/.minimal collapse. Clicking it seeks to introEndTicks through the exact same activeDriver/onDriverStateChanged path any other seek uses, so it's automatically host-only and automatically cast-aware with no new special-casing for either. End credits got no button of their own - #nextBtn already does exactly "go to the next queued item" and was already shown whenever hasNextQueued is true; the only gap closed here is visibility, by un-minimizing the bar once (a one-shot flag, not a fight against someone deliberately re-minimizing it) the moment playback crosses creditsStartTicks. Verified: full typecheck/test/build on both projects (125/125 server tests, including new coverage for getPlaybackMarkers - real MarkerType values mixed with auto-generated Chapter ones, and both "no Chapters" and "no markers among the Chapters" resolving to nulls rather than throwing). The button's on-screen position was checked directly (getBoundingClientRect against the real compiled CSS) at two viewport sizes - clear of #bottomBar with a 10px gap, not overlapping. Not verified: the detection task had only just been enabled on the real server and hadn't finished analyzing any episode yet, so the actual end-to-end "a detected marker makes the button appear at the right second" behavior needs a real check once it's had time to run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
12 KiB
TypeScript
275 lines
12 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, 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('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('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');
|
|
});
|
|
});
|