Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions e2e/priority-lane-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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')];
Expand Down
51 changes: 30 additions & 21 deletions server/src/engine/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || []) {
Expand All @@ -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;
}

Expand All @@ -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];
}

// --------------------------------------------------------------------------- //
Expand Down Expand Up @@ -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 —
Expand All @@ -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) };
}
Expand Down
Loading