Report: "still don't see my Emby profile updated - watched items
should be marked read, partially-watched ones should show in Resume".
After two rounds of fixing PlayingItems/Progress's request shape
(missing MediaSourceId, then missing PlaySessionId), this pointed at
something more fundamental: this app deliberately never calls the
matching PlayingItems "Start"/"Stop" - a documented v1 scope cut - and
a bare Progress report with no corresponding Start may simply never
reach the resume-position/watched-history data real clients read,
regardless of how correctly-shaped the request is.
Confirmed directly against the real server that this data lives
somewhere else entirely, trivially readable/writable with no playback
session required at all: every item Emby returns already carries the
requesting user's own UserData embedded (PlaybackPositionTicks/Played/
PlayCount/IsFavorite/...) - the exact thing Continue Watching and the
watched checkmark actually read - via a plain
POST /Users/{userId}/Items/{itemId}/UserData.
Switched entirely to that instead of layering a third fix onto
PlayingItems:
- PlaystateService.updatePosition (replacing reportProgress, same
~20s sweep in index.ts) updates just PlaybackPositionTicks.
- New PlaystateService.markPlayed sets Played: true and resets
PlaybackPositionTicks to 0 - called once per connected member (with
a token) when the party moves past an item for good:
PartyService.advanceQueue (next episode) and
MusicQueueService.playNext (a track naturally finishing, or a
majority vote-skip - both go through this one method). Addresses
the other half of the report ("watched items marked as watched").
- Both go through one private fetch-merge-write helper: GET the item
first, apply only the field(s) being changed to a copy of its
current UserData, POST the whole result back. Emby's spec documents
the body as the complete UserItemDataDto with nothing indicating
merge-not-replace semantics, and this is the one write in the whole
app where guessing wrong risks actually erasing a user's own real
data (a favorite, a rating) - so never a bare partial POST.
Reverted the MediaSourceId/PlaySessionId plumbing rounds 1-2 added
(CatalogService.negotiateStreamTarget back to returning a bare target
path, PartyStreamService no longer caches either) - nothing needs them
now that UserData doesn't require an active PlayingItems session.
Not yet confirmed end to end against a real server (no safe way to
fire a real, state-mutating test call without one).
Tests: PlaystateService's fetch-merge-write behavior in detail (a
favorite/rating/play-count survives a position-only update byte for
byte, a Played-only update doesn't disturb IsFavorite, a failed read
skips the write entirely, an item with no UserData yet starts from an
empty object), its request/outcome logging including that the token
never leaks into a log line, and that advanceQueue/playNext call
markPlayed for the outgoing item only, only for connected+tokened
members. catalogService/partyStreamService tests reverted to their
simpler pre-existing shape. Full typecheck/test/build green
(107/107 tests). docs/architecture.md and README.md updated.
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);
|
|
});
|
|
});
|