mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -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;
|
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(
|
export function getUpcomingFeed(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: {
|
options: {
|
||||||
@@ -403,27 +448,20 @@ export function getUpcomingFeed(
|
|||||||
|
|
||||||
// Use the cursor date as the lower bound so later pages skip already-seen dates,
|
// 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.
|
// but don't apply a DB-level LIMIT so same-day items aren't truncated.
|
||||||
let cursorDate: string | undefined;
|
const cursorKey = cursor ? decodeUpcomingCursor(cursor) : null;
|
||||||
let cursorName: string | undefined;
|
const fromDate = cursorKey?.d ?? today;
|
||||||
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 episodeRows =
|
const episodeRows =
|
||||||
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
|
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
|
||||||
const movieRows =
|
const movieRows =
|
||||||
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
|
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
|
||||||
|
|
||||||
// Merge into unified items
|
// 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: "tv"; row: (typeof episodeRows)[number] }
|
||||||
| { type: "movie"; row: (typeof movieRows)[number] }
|
| { type: "movie"; row: (typeof movieRows)[number] }
|
||||||
);
|
);
|
||||||
@@ -433,6 +471,13 @@ export function getUpcomingFeed(
|
|||||||
date: r.airDate!,
|
date: r.airDate!,
|
||||||
titleId: r.titleId,
|
titleId: r.titleId,
|
||||||
titleName: r.titleName,
|
titleName: r.titleName,
|
||||||
|
key: {
|
||||||
|
d: r.airDate!,
|
||||||
|
n: r.titleName,
|
||||||
|
i: r.titleId,
|
||||||
|
s: r.seasonNumber,
|
||||||
|
e: r.episodeNumber,
|
||||||
|
},
|
||||||
type: "tv" as const,
|
type: "tv" as const,
|
||||||
row: r,
|
row: r,
|
||||||
})),
|
})),
|
||||||
@@ -440,18 +485,14 @@ export function getUpcomingFeed(
|
|||||||
date: r.releaseDate!,
|
date: r.releaseDate!,
|
||||||
titleId: r.titleId,
|
titleId: r.titleId,
|
||||||
titleName: r.titleName,
|
titleName: r.titleName,
|
||||||
|
key: { d: r.releaseDate!, n: r.titleName, i: r.titleId, s: 0, e: 0 },
|
||||||
type: "movie" as const,
|
type: "movie" as const,
|
||||||
row: r,
|
row: r,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sort by date ASC, then title ASC, then titleId for deterministic tiebreak
|
// Sort by (date, title, titleId, season, episode) ASC — the same key the cursor uses
|
||||||
merged.sort(
|
merged.sort((a, b) => compareUpcomingKeys(a.key, b.key));
|
||||||
(a, b) =>
|
|
||||||
a.date.localeCompare(b.date) ||
|
|
||||||
a.titleName.localeCompare(b.titleName) ||
|
|
||||||
a.titleId.localeCompare(b.titleId),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Collapse batch drops: group 3+ episodes from the same title on the same date into one item
|
// Collapse batch drops: group 3+ episodes from the same title on the same date into one item
|
||||||
const collapsed: (RawItem & { episodeCount: number })[] = [];
|
const collapsed: (RawItem & { episodeCount: number })[] = [];
|
||||||
@@ -488,15 +529,10 @@ export function getUpcomingFeed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply cursor: skip items at or before the cursor position.
|
// 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;
|
let startIdx = 0;
|
||||||
if (cursorDate && cursorName && cursorId) {
|
if (cursorKey) {
|
||||||
startIdx = collapsed.findIndex(
|
startIdx = collapsed.findIndex((item) => compareUpcomingKeys(item.key, cursorKey) > 0);
|
||||||
(item) =>
|
|
||||||
item.date > cursorDate ||
|
|
||||||
(item.date === cursorDate && item.titleName > cursorName) ||
|
|
||||||
(item.date === cursorDate && item.titleName === cursorName && item.titleId > cursorId),
|
|
||||||
);
|
|
||||||
if (startIdx === -1) startIdx = collapsed.length;
|
if (startIdx === -1) startIdx = collapsed.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +543,7 @@ export function getUpcomingFeed(
|
|||||||
let nextCursor: string | null = null;
|
let nextCursor: string | null = null;
|
||||||
if (hasMore && pageItems.length > 0) {
|
if (hasMore && pageItems.length > 0) {
|
||||||
const last = pageItems.at(-1)!;
|
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
|
// 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 ─────────────────────────────────────────────────────
|
// ── isNewSeason ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("isNewSeason", () => {
|
describe("isNewSeason", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user