mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -04:00
fix(credits): replace title cast on refresh and remove duplicate crew rows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+3555
-18
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
import type { CastMember } from "@sofa/api/schemas";
|
import type { CastMember } from "@sofa/api/schemas";
|
||||||
import {
|
import {
|
||||||
batchUpsertPersonsTransaction,
|
batchUpsertPersonsTransaction,
|
||||||
batchUpsertTitleCast,
|
|
||||||
getCastForTitleJoined,
|
getCastForTitleJoined,
|
||||||
getExistingPersonsByTmdbIds,
|
getExistingPersonsByTmdbIds,
|
||||||
getFallbackPersonsByTmdbIds,
|
getFallbackPersonsByTmdbIds,
|
||||||
getPersonsForTitleCast,
|
getPersonsForTitleCast,
|
||||||
|
replaceTitleCastTransaction,
|
||||||
} from "@sofa/db/queries/credits";
|
} from "@sofa/db/queries/credits";
|
||||||
import { getTitleById } from "@sofa/db/queries/title";
|
import { getTitleById } from "@sofa/db/queries/title";
|
||||||
import type { titleCast } from "@sofa/db/schema";
|
import type { titleCast } from "@sofa/db/schema";
|
||||||
@@ -164,7 +164,8 @@ export async function refreshCredits(titleId: string) {
|
|||||||
});
|
});
|
||||||
crewOrder++;
|
crewOrder++;
|
||||||
}
|
}
|
||||||
batchUpsertTitleCast(allCastRows);
|
// Skip empty payloads so a blank TMDB response doesn't wipe existing credits
|
||||||
|
if (allCastRows.length > 0) replaceTitleCastTransaction(titleId, allCastRows);
|
||||||
} else {
|
} else {
|
||||||
const credits = await getTvAggregateCredits(title.tmdbId);
|
const credits = await getTvAggregateCredits(title.tmdbId);
|
||||||
const tvCast = credits.cast ?? [];
|
const tvCast = credits.cast ?? [];
|
||||||
@@ -244,7 +245,8 @@ export async function refreshCredits(titleId: string) {
|
|||||||
});
|
});
|
||||||
crewOrder++;
|
crewOrder++;
|
||||||
}
|
}
|
||||||
batchUpsertTitleCast(allCastRows);
|
// Skip empty payloads so a blank TMDB response doesn't wipe existing credits
|
||||||
|
if (allCastRows.length > 0) replaceTitleCastTransaction(titleId, allCastRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`Credits refreshed for "${title.title}"`);
|
log.debug(`Credits refreshed for "${title.title}"`);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { persons, titleCast } from "@sofa/db/schema";
|
import { persons, titleCast } from "@sofa/db/schema";
|
||||||
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/test/db";
|
import { clearAllTables, eq, insertTitle, testClient, testDb } from "@sofa/test/db";
|
||||||
|
|
||||||
const { mockGetMovieCredits, mockGetTvAggregateCredits } = vi.hoisted(() => ({
|
const { mockGetMovieCredits, mockGetTvAggregateCredits } = vi.hoisted(() => ({
|
||||||
mockGetMovieCredits: vi.fn<
|
mockGetMovieCredits: vi.fn<
|
||||||
@@ -191,6 +195,77 @@ describe("refreshCredits", () => {
|
|||||||
expect(creator).toBeDefined();
|
expect(creator).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not duplicate crew rows when refreshed twice", async () => {
|
||||||
|
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||||
|
|
||||||
|
mockGetMovieCredits.mockResolvedValue({
|
||||||
|
cast: [{ id: 1, name: "Actor A", profile_path: "/a.jpg", character: "Hero", popularity: 10 }],
|
||||||
|
crew: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Dir Person",
|
||||||
|
profile_path: "/d.jpg",
|
||||||
|
job: "Director",
|
||||||
|
department: "Directing",
|
||||||
|
popularity: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshCredits("m1");
|
||||||
|
await refreshCredits("m1");
|
||||||
|
|
||||||
|
const castRows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all();
|
||||||
|
expect(castRows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes cast members no longer returned by TMDB", async () => {
|
||||||
|
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||||
|
const actorA = {
|
||||||
|
id: 1,
|
||||||
|
name: "Actor A",
|
||||||
|
profile_path: null,
|
||||||
|
character: "Hero",
|
||||||
|
popularity: 10,
|
||||||
|
};
|
||||||
|
const actorB = {
|
||||||
|
id: 2,
|
||||||
|
name: "Actor B",
|
||||||
|
profile_path: null,
|
||||||
|
character: "Villain",
|
||||||
|
popularity: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetMovieCredits.mockResolvedValue({ cast: [actorA, actorB], crew: [] });
|
||||||
|
await refreshCredits("m1");
|
||||||
|
expect(testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all()).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockGetMovieCredits.mockResolvedValue({ cast: [actorA], crew: [] });
|
||||||
|
await refreshCredits("m1");
|
||||||
|
expect(testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all()).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps existing credits when TMDB returns an empty payload", async () => {
|
||||||
|
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||||
|
|
||||||
|
mockGetMovieCredits.mockResolvedValue({
|
||||||
|
cast: [{ id: 1, name: "Actor A", profile_path: null, character: "Hero", popularity: 10 }],
|
||||||
|
crew: [],
|
||||||
|
});
|
||||||
|
await refreshCredits("m1");
|
||||||
|
|
||||||
|
mockGetMovieCredits.mockResolvedValue({ cast: [], crew: [] });
|
||||||
|
await refreshCredits("m1");
|
||||||
|
|
||||||
|
expect(testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all()).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("does nothing for nonexistent title", async () => {
|
test("does nothing for nonexistent title", async () => {
|
||||||
await refreshCredits("nonexistent");
|
await refreshCredits("nonexistent");
|
||||||
expect(mockGetMovieCredits).not.toHaveBeenCalled();
|
expect(mockGetMovieCredits).not.toHaveBeenCalled();
|
||||||
@@ -284,3 +359,38 @@ describe("refreshCredits", () => {
|
|||||||
expect(jobs).toEqual(["Director", "Writer"]);
|
expect(jobs).toEqual(["Director", "Writer"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── dedupe migration ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("dedupe_title_cast migration", () => {
|
||||||
|
test("keeps one row per credit", () => {
|
||||||
|
insertTitle({ id: "m1", tmdbId: 1 });
|
||||||
|
insertPerson("p1", 100, "Director Person");
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
testDb
|
||||||
|
.insert(titleCast)
|
||||||
|
.values({
|
||||||
|
titleId: "m1",
|
||||||
|
personId: "p1",
|
||||||
|
character: null,
|
||||||
|
department: "Directing",
|
||||||
|
job: "Director",
|
||||||
|
displayOrder: 100,
|
||||||
|
lastFetchedAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
insertCastEntry("m1", "p1", { character: "Hero" });
|
||||||
|
|
||||||
|
const dir = fileURLToPath(new URL("../../db/drizzle", import.meta.url));
|
||||||
|
const folder = readdirSync(dir).find((d) => d.endsWith("_dedupe_title_cast"));
|
||||||
|
expect(folder).toBeDefined();
|
||||||
|
testClient.exec(readFileSync(join(dir, folder!, "migration.sql"), "utf8"));
|
||||||
|
|
||||||
|
const rows = testDb.select().from(titleCast).where(eq(titleCast.titleId, "m1")).all();
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows.filter((r) => r.department === "Directing")).toHaveLength(1);
|
||||||
|
expect(rows.filter((r) => r.department === "Acting")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Crew rows store character = NULL and SQLite treats NULLs as distinct in the
|
||||||
|
-- titleCast_unique index, so every credits refresh inserted another copy.
|
||||||
|
-- Keep the most recently inserted row for each logical credit.
|
||||||
|
DELETE FROM titleCast
|
||||||
|
WHERE rowid NOT IN (
|
||||||
|
SELECT MAX(rowid) FROM titleCast
|
||||||
|
GROUP BY titleId, personId, department, COALESCE(character, ''), COALESCE(job, '')
|
||||||
|
);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -99,20 +99,31 @@ export function getPersonsForTitleCast(titleId: string) {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function batchUpsertTitleCast(rows: (typeof titleCast.$inferInsert)[]): void {
|
/**
|
||||||
if (rows.length === 0) return;
|
* Atomically replace all cast/crew rows for a title. Upserting is not enough:
|
||||||
db.insert(titleCast)
|
* crew rows have character = NULL, and SQLite treats NULLs as distinct in the
|
||||||
.values(rows)
|
* unique index, so upserts kept inserting duplicates.
|
||||||
.onConflictDoUpdate({
|
*/
|
||||||
target: [titleCast.titleId, titleCast.personId, titleCast.department, titleCast.character],
|
export function replaceTitleCastTransaction(
|
||||||
set: {
|
titleId: string,
|
||||||
job: sql`excluded.job`,
|
rows: (typeof titleCast.$inferInsert)[],
|
||||||
displayOrder: sql`excluded.displayOrder`,
|
): void {
|
||||||
episodeCount: sql`excluded.episodeCount`,
|
db.transaction((tx) => {
|
||||||
lastFetchedAt: sql`excluded.lastFetchedAt`,
|
tx.delete(titleCast).where(eq(titleCast.titleId, titleId)).run();
|
||||||
},
|
if (rows.length === 0) return;
|
||||||
})
|
tx.insert(titleCast)
|
||||||
.run();
|
.values(rows)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [titleCast.titleId, titleCast.personId, titleCast.department, titleCast.character],
|
||||||
|
set: {
|
||||||
|
job: sql`excluded.job`,
|
||||||
|
displayOrder: sql`excluded.displayOrder`,
|
||||||
|
episodeCount: sql`excluded.episodeCount`,
|
||||||
|
lastFetchedAt: sql`excluded.lastFetchedAt`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCastForTitleJoined(titleId: string) {
|
export function getCastForTitleJoined(titleId: string) {
|
||||||
|
|||||||
Reference in new issue
Block a user