diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts index cf5a807..023d13e 100644 --- a/packages/core/src/discovery.ts +++ b/packages/core/src/discovery.ts @@ -380,6 +380,51 @@ export interface UpcomingFeedResult { nextCursor: string | null; } +/** Sort/cursor key for one upcoming feed item. `s`/`e` are 0 for movies. */ +interface UpcomingCursorKey { + d: string; // date YYYY-MM-DD + n: string; // title name + i: string; // title id + s: number; // season number + e: number; // episode number +} + +function compareUpcomingKeys(a: UpcomingCursorKey, b: UpcomingCursorKey): number { + return ( + a.d.localeCompare(b.d) || + a.n.localeCompare(b.n) || + a.i.localeCompare(b.i) || + a.s - b.s || + a.e - b.e + ); +} + +function encodeUpcomingCursor(key: UpcomingCursorKey): string { + return Buffer.from(JSON.stringify(key), "utf8").toString("base64url"); +} + +function decodeUpcomingCursor(cursor: string): UpcomingCursorKey | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if ( + typeof parsed?.d !== "string" || + typeof parsed?.n !== "string" || + typeof parsed?.i !== "string" + ) { + return null; + } + return { + d: parsed.d, + n: parsed.n, + i: parsed.i, + s: typeof parsed.s === "number" ? parsed.s : 0, + e: typeof parsed.e === "number" ? parsed.e : 0, + }; + } catch { + return null; + } +} + export function getUpcomingFeed( userId: string, options: { @@ -403,27 +448,20 @@ export function getUpcomingFeed( // Use the cursor date as the lower bound so later pages skip already-seen dates, // but don't apply a DB-level LIMIT so same-day items aren't truncated. - let cursorDate: string | undefined; - let cursorName: string | undefined; - let cursorId: string | undefined; - if (cursor) { - try { - const parsed = JSON.parse(atob(cursor)); - cursorDate = parsed.d; - cursorName = parsed.n; - cursorId = parsed.i; - } catch { - // Invalid cursor — ignore - } - } - const fromDate = cursorDate ?? today; + const cursorKey = cursor ? decodeUpcomingCursor(cursor) : null; + const fromDate = cursorKey?.d ?? today; const episodeRows = mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses); const movieRows = mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses); // Merge into unified items - type RawItem = { date: string; titleId: string; titleName: string } & ( + type RawItem = { + date: string; + titleId: string; + titleName: string; + key: UpcomingCursorKey; + } & ( | { type: "tv"; row: (typeof episodeRows)[number] } | { type: "movie"; row: (typeof movieRows)[number] } ); @@ -433,6 +471,13 @@ export function getUpcomingFeed( date: r.airDate!, titleId: r.titleId, titleName: r.titleName, + key: { + d: r.airDate!, + n: r.titleName, + i: r.titleId, + s: r.seasonNumber, + e: r.episodeNumber, + }, type: "tv" as const, row: r, })), @@ -440,18 +485,14 @@ export function getUpcomingFeed( date: r.releaseDate!, titleId: r.titleId, titleName: r.titleName, + key: { d: r.releaseDate!, n: r.titleName, i: r.titleId, s: 0, e: 0 }, type: "movie" as const, row: r, })), ]; - // Sort by date ASC, then title ASC, then titleId for deterministic tiebreak - merged.sort( - (a, b) => - a.date.localeCompare(b.date) || - a.titleName.localeCompare(b.titleName) || - a.titleId.localeCompare(b.titleId), - ); + // Sort by (date, title, titleId, season, episode) ASC — the same key the cursor uses + merged.sort((a, b) => compareUpcomingKeys(a.key, b.key)); // Collapse batch drops: group 3+ episodes from the same title on the same date into one item const collapsed: (RawItem & { episodeCount: number })[] = []; @@ -488,15 +529,10 @@ export function getUpcomingFeed( } // Apply cursor: skip items at or before the cursor position. - // Cursor is base64-encoded JSON {d, n, i} matching the sort key (date, titleName, titleId). + // Cursor is base64url-encoded UTF-8 JSON {d, n, i, s, e} matching the sort key. let startIdx = 0; - if (cursorDate && cursorName && cursorId) { - startIdx = collapsed.findIndex( - (item) => - item.date > cursorDate || - (item.date === cursorDate && item.titleName > cursorName) || - (item.date === cursorDate && item.titleName === cursorName && item.titleId > cursorId), - ); + if (cursorKey) { + startIdx = collapsed.findIndex((item) => compareUpcomingKeys(item.key, cursorKey) > 0); if (startIdx === -1) startIdx = collapsed.length; } @@ -507,7 +543,7 @@ export function getUpcomingFeed( let nextCursor: string | null = null; if (hasMore && pageItems.length > 0) { const last = pageItems.at(-1)!; - nextCursor = btoa(JSON.stringify({ d: last.date, n: last.titleName, i: last.titleId })); + nextCursor = encodeUpcomingCursor(last.key); } // Batch-fetch display statuses and streaming providers diff --git a/packages/core/test/upcoming.test.ts b/packages/core/test/upcoming.test.ts index 450d3e1..ae5385c 100644 --- a/packages/core/test/upcoming.test.ts +++ b/packages/core/test/upcoming.test.ts @@ -220,6 +220,61 @@ describe("cursor pagination", () => { }); }); +// ── Cursor pagination edge cases ──────────────────────────────────── + +function collectAllTitles(limit: number) { + const names: string[] = []; + let cursor: string | undefined; + for (let guard = 0; guard < 50; guard++) { + const page = getUpcomingFeed("user-1", { days: 30, limit, cursor }); + names.push(...page.items.map((i) => `${i.titleName}#${i.episodeNumber ?? "-"}`)); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + return names; +} + +describe("cursor pagination edge cases", () => { + test("handles non-Latin-1 title names at a page boundary", () => { + insertTvShow("tv-s", 201, 1, 1, { title: "Shōgun", airDates: [daysFromNow(1)] }); + insertTvShow("tv-z", 202, 1, 1, { title: "Zzz Show", airDates: [daysFromNow(2)] }); + insertStatus("user-1", "tv-s", "in_progress"); + insertStatus("user-1", "tv-z", "in_progress"); + + const page1 = getUpcomingFeed("user-1", { days: 30, limit: 1 }); + expect(page1.items.map((i) => i.titleName)).toEqual(["Shōgun"]); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = getUpcomingFeed("user-1", { days: 30, limit: 1, cursor: page1.nextCursor! }); + expect(page2.items.map((i) => i.titleName)).toEqual(["Zzz Show"]); + }); + + test("does not skip mixed-case names airing on the same date", () => { + const d = daysFromNow(1); + insertTvShow("tv-a", 211, 1, 1, { title: "apple show", airDates: [d] }); + insertTvShow("tv-b", 212, 1, 1, { title: "Banana Show", airDates: [d] }); + insertTvShow("tv-c", 213, 1, 1, { title: "cherry show", airDates: [d] }); + insertStatus("user-1", "tv-a", "in_progress"); + insertStatus("user-1", "tv-b", "in_progress"); + insertStatus("user-1", "tv-c", "in_progress"); + + const all = getUpcomingFeed("user-1", { days: 30, limit: 50 }).items.map( + (i) => `${i.titleName}#${i.episodeNumber ?? "-"}`, + ); + const paged = collectAllTitles(1); + expect(paged).toHaveLength(3); + expect(paged).toEqual(all); + }); + + test("does not skip a second same-day episode split across pages", () => { + const d = daysFromNow(1); + insertTvShow("tv-x", 300, 1, 2, { title: "Pair Show", airDates: [d, d] }); + insertStatus("user-1", "tv-x", "in_progress"); + + expect(collectAllTitles(1)).toEqual(["Pair Show#1", "Pair Show#2"]); + }); +}); + // ── isNewSeason ───────────────────────────────────────────────────── describe("isNewSeason", () => {