Asked for directly: Emby's admin "now playing" dashboard should see
this app's streams the same way it sees a real web/mobile/TV client's.
This is exactly the follow-up "Sync watch-party progress back to Emby"
already anticipated and deliberately deferred at the time ("wiring
clean start/stop into every transition... is real complexity for a
secondary payoff") - that section also explains why this app abandoned
Emby's session-shaped PlayingItems endpoints entirely in favor of
direct UserData writes. Given that history (two separate rounds of a
wrong-but-schema-plausible request shape for that other, now-abandoned
endpoint family), this one was built checking the real API first
rather than assuming.
Confirmed against the real server's own /openapi.json that the actual
session-lifecycle family is POST /Sessions/Playing (start),
/Sessions/Playing/Progress, and /Sessions/Playing/Stopped - distinct
from PlayingItems. None of the three schemas declare a required list
at all, which - given the neighboring history - was read as "don't
trust that": ItemId/MediaSourceId/PlaySessionId/PositionTicks are sent
on every call anyway, matching what a real client obviously would,
rather than risking a repeat of "technically matches the schema, still
400s".
One real Emby session per PARTY MEMBER, not one per party - a faithful
copy of how a real client behaves, and the actual point of "let admin
see who's watching what" would be lost by collapsing everyone onto one
shared entry. New server/src/services/EmbySessionService.ts mirrors
PlaystateService's own best-effort shape (every failure caught and
logged, never thrown) but stays its own class - PlaystateService's own
opening comment is specifically about why it doesn't use a
session-shaped endpoint, so folding this into it would muddy that
exact distinction.
Getting a MediaSourceId/PlaySessionId per member needed a new
CatalogService.negotiatePlaybackSession - a fresh PlaybackInfo
negotiation under that specific member's own account, since Emby scopes
PlaybackInfo per user and PartyStreamService's existing shared
negotiation is never guaranteed valid for reporting under everyone
else's account too. Returns null on any failure rather than throwing -
this only ever feeds an admin-dashboard nicety, never something that
should disrupt real playback.
Driven entirely from index.ts's existing periodic sweep rather than new
hooks scattered across every queue-advance/track-skip call site -
that sweep already visits every connected member on every ~20s tick,
already positioned to notice a member's current item no longer matches
their cached session and react (stop the old one if any, negotiate
fresh ids, start the new one; same item as last tick just sends
Progress with the cached ids). PartyMember gained the nullable
embySession field this tracks. Explicit stop calls also happen at
PartyService.leave() (alongside the existing CastTokenService
.revokeForMember) and removeIdleParties (a backstop for a disconnect
that never triggered a clean leave).
Verified: full typecheck/test/build (134/134 server tests - new
coverage for negotiatePlaybackSession's null-on-any-failure contract,
every EmbySessionService method's exact request shape, that a non-ok
response/thrown error is swallowed not propagated, and that leave()
calls stop with a member's real cached session info but only when they
actually had one open). Not verified: an actual live write against the
real server - deliberately not attempted, since that would mean using
a real account's Emby token for a genuine state-changing call, beyond
the read-only diagnostic use this session's shared API key was ever
meant for. So while the request shape is checked against the real
schema and the per-member logic is unit-tested in full, whether Emby's
dashboard actually displays these sessions the way a native client's
would - correct device/client labeling in particular, since this app
never sends the X-Emby-Authorization Device/Client/Version header a
real client would, only X-Emby-Token - is genuinely unconfirmed. Worth
watching the real dashboard the first time a party runs after this ships.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
268 lines
12 KiB
TypeScript
268 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('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('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
|
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
|
MediaSources: [{ Id: 'src1' }],
|
|
PlaySessionId: 'play1'
|
|
}));
|
|
|
|
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
|
|
|
|
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
|
|
});
|
|
|
|
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
|
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
|
|
|
|
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
|
});
|
|
|
|
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
|
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
|
|
|
|
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
|
});
|
|
|
|
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
|
|
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
|
|
|
|
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|