watchparty/server/test/catalogService.test.ts
Nicolas 57bb297bff
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Add skip intro / next episode at credits, via Emby's own detection
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>
2026-09-15 23:16:38 +02:00

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');
});
});