mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -04:00
fix(upcoming): make feed cursor unicode-safe and consistent with sort order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2 files changed
+122
-31
No files matched your search
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user