watchparty/server/test/catalogService.test.ts
Nicolas f78b2f1408
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 20s
One shared Emby stream per party, not one per viewer
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has. Before this,
every viewer's own <video>/<audio> independently negotiated its own
PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby
transcoding the same title 5 separate times.

A bigger, higher-regression-risk change than most of this session's
other work: it replaces the core playback path every existing party
already relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something alongside it - there's no other
consumer of the per-viewer path, and the ask was explicit that there
should only ever be one mechanism.

- server/src/services/CatalogService.ts: proxyStream's negotiate-then-
  build-a-target logic extracted into negotiateStreamTarget(session,
  itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical
  logic, returns the target instead of proxying it directly so it can
  be cached. proxyStream/proxyRawStreamAsset removed.
- server/src/services/PartyStreamService.ts (new): one negotiated
  target per party.id, reused by every member; renegotiates when the
  desired item/audio/quality changes. Recovers from a dead upstream
  session with no new plumbing - EmbyClient.proxyResponse already sets
  res.statusCode before writing the body, so a failed proxied response
  is detected right after it resolves and the cache entry is dropped,
  so the next request (a manual retry, or the next segment fetch)
  renegotiates fresh instead of repeating the same dead target forever.
- server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's
  trailing itemId param genericized to routeKey - it's only ever read
  back to build the raw-asset URL, so it's party.id for the new stream
  routes and an inert real itemId for proxyImage/proxySubtitle (which
  never reach that branch anyway).
- server/src/index.ts: GET /api/catalog/party-stream/:partyId/
  master.m3u8 and .../raw replace /api/catalog/stream/:itemId/...
  entirely, membership-checked like the burn-in routes; a new idle-reap
  interval for PartyStreamService, same shape as TranscodeService's own.
- Forced consequence of "one stream", not a free design choice: audio
  track/quality become a host-only, party-wide choice for video -
  Party.streamAudioIndex/streamQuality, setStreamOptions/
  streamOptionsChanged, byte-for-byte the same shape
  setBurnInSubtitle/burnInChanged already established for burn-in
  subtitles. server/src/realtime/SocketGateway.ts gains the host-only,
  video-only handler; server/src/services/PartyService.ts resets
  streamAudioIndex (not streamQuality - a bandwidth preference isn't
  item-specific) on advanceQueue, same reasoning as burnIn* already
  gets; MusicQueueService.playNext ends the party's cached session on
  a track change.
- client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one
  partyStreamUrl(partyId) - no query params needed any more, the party
  itself is the source of truth for what it's streaming.
- client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged.
- client/src/views/player/Player.ts: audio/quality <select>s disabled
  for a follower (applyStreamOptionsGating, mirroring
  renderSubtitleOptions' own gating), restore the party's current
  choice on load/broadcast, and a host's change calls setStreamOptions
  instead of reloading locally - every member (host included) only
  ever reacts to the resulting broadcast, same discipline the rest of
  this player already uses. Dead reloadWithCurrentOptions removed.
- client/src/musicBar/MusicBar.ts: loadTrack points at
  partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had
  one.
- server/test/catalogService.test.ts: proxyStream's old cases moved to
  exercise negotiateStreamTarget instead, plus new quality-override and
  no-source-fails cases. server/test/partyStreamService.test.ts (new):
  cache hit/miss/param-change/item-change, the failure-invalidation
  path, endSession, idle reap. server/test/socketGateway.test.ts:
  setStreamOptions host-only gating + broadcast, mirroring the existing
  setBurnInSubtitle tests.
- docs/architecture.md, README.md: design, the forced audio/quality
  trade-off spelled out, and what's still unverified (a different kind
  than this session's other Emby-API guesses - the calls themselves
  were already live code, what's untested is real multi-viewer load).

Verified: full typecheck+test+build on both projects (85/85 server
tests, 12 new). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 20:05:34 +02:00

225 lines
10 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, 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('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);
});
});