diff --git a/e2e/priority-lane-test.ts b/e2e/priority-lane-test.ts index 017521d..72e9e4b 100644 --- a/e2e/priority-lane-test.ts +++ b/e2e/priority-lane-test.ts @@ -32,14 +32,12 @@ const FILMS = [ { ratingKey: '4', title: 'Delta' }, ]; -// One SHOW, for the in-progress case. It has to be a show and not a fifth film: a movie's -// resolved item carries no `viewOffset` (the movie branch of `resolveMember` builds the item by -// hand from title + ratingKey), so `leadsInProgress` has only ever been able to see an EPISODE. -// That is pre-existing, and out of scope here — the point of this case is that the hoist still -// outranks the Priority lane, not to widen what the hoist can see. +// One SHOW, for the in-progress episode case. const SHOW = { ratingKey: '5', title: 'Echo' }; /** viewOffset (ms) for `SHOW`'s first episode; 0 = not started. */ let showResumeMs = 0; +/** The movie progress that Plex returns with its metadata. */ +let movieResumeKey: string | null = null; const clientWith = (): PlexClient => ({ async container(p: string) { @@ -48,7 +46,14 @@ const clientWith = (): PlexClient => ({ if (m[1] === SHOW.ratingKey) return { Metadata: [{ ...SHOW, type: 'show' }] }; const film = FILMS.find((f) => f.ratingKey === m[1]); if (!film) return { Metadata: [] }; - return { Metadata: [{ ...film, type: 'movie' }] }; + return { + Metadata: [{ + ...film, + type: 'movie', + viewOffset: movieResumeKey === film.ratingKey ? 45_000 : 0, + viewCount: 0, + }], + }; } if (p === `/library/metadata/${SHOW.ratingKey}/allLeaves`) { return { @@ -111,6 +116,15 @@ check('ordered queue plays file order', titles(await run(ORDERED, all)), ['Alpha // Every entry inherits `random`, so all four go through the shuffle — here, the reverse. check('random pool still shuffles', titles(await run(POOL, all)), ['Delta', 'Charlie', 'Bravo', 'Alpha']); +// A partly watched movie is the next sitting's head, even when the pool shuffle would put it +// last. This is the NFC rescan case: the card starts the same queue again after dinner. +movieResumeKey = '1'; +const resumedMovie = await run(POOL, all); +check('an in-progress movie leads the random pool', titles(resumedMovie), + ['Alpha', 'Delta', 'Charlie', 'Bravo']); +check('the in-progress movie keeps its Plex resume position', resumedMovie.offset, 45_000); +movieResumeKey = null; + // ── 3. A PROMOTE leads a random pool ────────────────────────────────────────────────────── // Charlie names its own lane; the other three shuffle behind it. const promoted = [entry('1'), entry('2'), entry('3', { placement: 'priority' }), entry('4')]; diff --git a/server/src/engine/resolve.ts b/server/src/engine/resolve.ts index 37a9ccb..2af93fa 100644 --- a/server/src/engine/resolve.ts +++ b/server/src/engine/resolve.ts @@ -594,18 +594,18 @@ export async function itemType( client: PlexClient, ratingKey: string | number, token: Token, -): Promise<['movie' | 'show', string | undefined] | [null, null]> { +): Promise<['movie' | 'show', string | undefined, number, number] | [null, null, 0, 0]> { let mc; try { mc = await client.container(`/library/metadata/${ratingKey}`, token); } catch { - return [null, null]; + return [null, null, 0, 0]; } const md = mc.Metadata || []; - if (!md.length) return [null, null]; + if (!md.length) return [null, null, 0, 0]; const t = md[0]!.type; - if (t !== 'movie' && t !== 'show') return [null, null]; - return [t, md[0]!.title]; + if (t !== 'movie' && t !== 'show') return [null, null, 0, 0]; + return [t, md[0]!.title, int0(md[0]!.viewOffset), int0(md[0]!.viewCount)]; } // [viewOffset_ms, viewCount] for one item under `token`'s account, [0, 0] on any miss. Port of @@ -660,16 +660,16 @@ export async function resolveTitle( year: number | null, guid: string | null, token: Token, -): Promise<[string, 'movie' | 'show', string] | [null, null, null]> { +): Promise<[string, 'movie' | 'show', string, number, number] | [null, null, null, 0, 0]> { const q = quote(title); let mc; try { mc = await client.container( `/library/sections/${section}/all?title=${q}&includeGuids=1&X-Plex-Container-Size=50`, token); } catch { - return [null, null, null]; + return [null, null, null, 0, 0]; } - let best: [string, 'movie' | 'show', string] | null = null; + let best: [string, 'movie' | 'show', string, number, number] | null = null; let bestScore = 0; const tl = title.toLowerCase(); for (const e of mc.Metadata || []) { @@ -690,11 +690,11 @@ export async function resolveTitle( const better = best === null || score > bestScore || (score === bestScore && /^\d+$/.test(rk) && parseInt(rk, 10) < parseInt(best[0], 10)); if (better) { - best = [rk, et, candTitle]; + best = [rk, et, candTitle, int0(e.viewOffset), int0(e.viewCount)]; bestScore = score; } } - if (best === null || bestScore <= 0) return [null, null, null]; + if (best === null || bestScore <= 0) return [null, null, null, 0, 0]; return best; } @@ -720,22 +720,26 @@ export async function resolveQueueEntry( desc: EntryDescriptor, cfg: ResolveCfg, token: Token, -): Promise<[string, 'movie' | 'show', string | undefined] | [null, null, null]> { +): Promise< + [string, 'movie' | 'show', string | undefined, number, number] | [null, null, null, 0, 0] +> { const rk = desc.ratingKey; if (rk) { - const [typ, title] = await itemType(client, rk, token); - if (typ == null) return [null, null, null]; - return [rk, typ, title]; + const [typ, title, viewOffset, viewCount] = await itemType(client, rk, token); + if (typ == null) return [null, null, null, 0, 0]; + return [rk, typ, title, viewOffset, viewCount]; } const title = desc.title; - if (!title) return [null, null, null]; + if (!title) return [null, null, null, 0, 0]; for (const sec of resolveSections(cfg)) { // `sec` may be undefined here (see resolveSections); the request was always built with // whatever it held, and Plex answering 404 is what the try/catch inside resolveTitle is for. - const [rrk, typ, resolved] = await resolveTitle(client, sec as number, title, desc.year, desc.guid, token); - if (typ != null) return [rrk, typ, resolved]; + const [rrk, typ, resolved, viewOffset, viewCount] = await resolveTitle( + client, sec as number, title, desc.year, desc.guid, token, + ); + if (typ != null) return [rrk, typ, resolved, viewOffset, viewCount]; } - return [null, null, null]; + return [null, null, null, 0, 0]; } // --------------------------------------------------------------------------- // @@ -980,7 +984,7 @@ export async function resolveMember( ); return { title: `Collection: ${name}`, type: 'collection', items, weight: toWeight(desc.weight) }; } - const [rk, typ, title] = await resolveQueueEntry(client, desc, cfg, token); + const [rk, typ, title, viewOffset, viewCount] = await resolveQueueEntry(client, desc, cfg, token); if (typ == null) return null; if (typ === 'movie') { // NOT skippable. A movie entry IS its own member — there is nothing inside it to skip — @@ -993,13 +997,18 @@ export async function resolveMember( const own = progress?.get(String(rk)); keepMovie = progress ? Boolean(own && !own.isCompleted && own.positionMs > 0) - : inProgress(...await itemViewState(client, rk, token)); + : inProgress(viewOffset, viewCount); } + const providerProgress = resume && progress == null && inProgress(viewOffset, viewCount) + ? { viewOffset, viewCount } : {}; const items: ResolvedItem[] = keepMovie // `show: null` is kept LITERALLY — the curated-parity oracle compares this JSON, and // `undefined` would drop the key entirely. The cast is only how `PlexPlayItem`'s // `show?: string` (types.ts) is satisfied without changing what is emitted. - ? [{ title, ratingKey: rk, show: null as unknown as undefined, season: null, episode: null }] : []; + ? [{ + title, ratingKey: rk, show: null as unknown as undefined, season: null, episode: null, + ...providerProgress, + }] : []; // `title` comes back from Plex and is `string | undefined`; the original stored it as-is. return { title: title as string, type: 'movie', ratingKey: rk, items, weight: toWeight(desc.weight) }; }