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>
225 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|