fix(db): use (tmdbId, type) composite keys for title lookups

The unique constraint on titles is (tmdbId, type), meaning the same
numeric TMDB ID can exist as both a movie and TV show. Several places
were keying maps by tmdbId alone, which caused UNIQUE constraint
violations when an UPDATE inadvertently changed a row's type, and
silently dropped titles when both types existed for the same ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 11:47:14 -04:00
co-authored by Claude Opus 4.6
parent 1f8b3d8f37
commit 8d1cc46875
3 changed files with 53 additions and 27 deletions
+14 -8
View File
@@ -202,14 +202,20 @@ async function syncPersonFilmography(
const tmdbIds = [...new Set(allEntries.map((c) => c.id))]; const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
const existingTitles = getExistingTitlesByTmdbIds(tmdbIds); const existingTitles = getExistingTitlesByTmdbIds(tmdbIds);
const titleIdMap = new Map<number, string>( // Key by (tmdbId, type) composite since the same tmdbId can be a movie and TV show
existingTitles.map((title) => [title.tmdbId, title.id]), const titleIdMap = new Map<string, string>(
existingTitles.map((title) => [`${title.tmdbId}-${title.type}`, title.id]),
); );
const newEntries = allEntries.filter((entry) => !titleIdMap.has(entry.id)); const newEntries = allEntries.filter(
(entry) => !titleIdMap.has(`${entry.id}-${entry.media_type}`),
);
if (newEntries.length > 0) { if (newEntries.length > 0) {
const shellEntries = newEntries const shellEntries = newEntries
.filter((entry, index, arr) => arr.findIndex((e) => e.id === entry.id) === index) .filter(
(entry, index, arr) =>
arr.findIndex((e) => e.id === entry.id && e.media_type === entry.media_type) === index,
)
.map((entry) => ({ .map((entry) => ({
tmdbId: entry.id, tmdbId: entry.id,
mediaType: entry.media_type as "movie" | "tv", mediaType: entry.media_type as "movie" | "tv",
@@ -225,8 +231,8 @@ async function syncPersonFilmography(
})); }));
const updatedMap = batchInsertShellTitlesTransaction(shellEntries, titleIdMap); const updatedMap = batchInsertShellTitlesTransaction(shellEntries, titleIdMap);
for (const [tmdbId, id] of updatedMap) { for (const [key, id] of updatedMap) {
titleIdMap.set(tmdbId, id); titleIdMap.set(key, id);
} }
} }
@@ -234,7 +240,7 @@ async function syncPersonFilmography(
let displayOrder = 0; let displayOrder = 0;
for (const credit of validCast) { for (const credit of validCast) {
const titleId = titleIdMap.get(credit.id); const titleId = titleIdMap.get(`${credit.id}-${credit.media_type}`);
if (!titleId) continue; if (!titleId) continue;
nextRows.push({ nextRows.push({
@@ -249,7 +255,7 @@ async function syncPersonFilmography(
} }
for (const credit of validCrew) { for (const credit of validCrew) {
const titleId = titleIdMap.get(credit.id); const titleId = titleIdMap.get(`${credit.id}-${credit.media_type}`);
if (!titleId) continue; if (!titleId) continue;
nextRows.push({ nextRows.push({
+22 -5
View File
@@ -315,6 +315,7 @@ export function upsertRecommendationsTransaction(
.select({ .select({
id: titles.id, id: titles.id,
tmdbId: titles.tmdbId, tmdbId: titles.tmdbId,
type: titles.type,
posterPath: titles.posterPath, posterPath: titles.posterPath,
backdropPath: titles.backdropPath, backdropPath: titles.backdropPath,
posterThumbHash: titles.posterThumbHash, posterThumbHash: titles.posterThumbHash,
@@ -323,13 +324,22 @@ export function upsertRecommendationsTransaction(
.from(titles) .from(titles)
.where(inArray(titles.tmdbId, tmdbIds)) .where(inArray(titles.tmdbId, tmdbIds))
.all(); .all();
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
const titleIdMap = new Map<number, string>(existingTitles.map((t) => [t.tmdbId, t.id])); // Key by (tmdbId, type) composite since the unique constraint is on both columns.
// The same tmdbId can exist as both a movie and TV show.
const existingTitleMap = new Map(existingTitles.map((t) => [`${t.tmdbId}-${t.type}`, t]));
const titleIdMap = new Map<number, string>();
for (const [tmdbId, item] of uniqueTitles) {
const existing = existingTitleMap.get(`${tmdbId}-${item.type}`);
if (existing) {
titleIdMap.set(tmdbId, existing.id);
}
}
db.transaction((tx) => { db.transaction((tx) => {
const insertedTmdbIds = new Set<number>(); const insertedTmdbIds = new Set<number>();
for (const item of uniqueTitles.values()) { for (const item of uniqueTitles.values()) {
const existingTitle = existingTitleMap.get(item.tmdbId); const existingTitle = existingTitleMap.get(`${item.tmdbId}-${item.type}`);
if (existingTitle) { if (existingTitle) {
const posterPathChanged = existingTitle.posterPath !== item.posterPath; const posterPathChanged = existingTitle.posterPath !== item.posterPath;
const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath; const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath;
@@ -392,11 +402,18 @@ export function upsertRecommendationsTransaction(
const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id)); const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id));
if (stillMissing.length > 0) { if (stillMissing.length > 0) {
const fallbacks = tx const fallbacks = tx
.select({ id: titles.id, tmdbId: titles.tmdbId }) .select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles) .from(titles)
.where(inArray(titles.tmdbId, stillMissing)) .where(inArray(titles.tmdbId, stillMissing))
.all(); .all();
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
for (const f of fallbacks) {
// Only map if the type matches what we were trying to insert
const expected = uniqueTitles.get(f.tmdbId);
if (expected && expected.type === f.type) {
titleIdMap.set(f.tmdbId, f.id);
}
}
} }
// Batch upsert all recommendation rows // Batch upsert all recommendation rows
+17 -14
View File
@@ -65,21 +65,23 @@ interface ShellTitleEntry {
} }
/** /**
* Insert shell title rows inside a transaction, returning a map of tmdbId to * Insert shell title rows inside a transaction, returning a map of
* internal UUID for every entry that was inserted (or already existed via * "tmdbId-type" composite key to internal UUID for every entry that was
* conflict fallback). * inserted (or already existed via conflict fallback).
*/ */
export function batchInsertShellTitlesTransaction( export function batchInsertShellTitlesTransaction(
entries: ShellTitleEntry[], entries: ShellTitleEntry[],
existingTitleIdMap: Map<number, string>, existingTitleIdMap: Map<string, string>,
): Map<number, string> { ): Map<string, string> {
const titleIdMap = new Map(existingTitleIdMap); const titleIdMap = new Map(existingTitleIdMap);
const insertedTmdbIds = new Set<number>(); // Deduplicate by (tmdbId, type) since the unique constraint is on both columns
const insertedKeys = new Set<string>();
db.transaction((tx) => { db.transaction((tx) => {
for (const entry of entries) { for (const entry of entries) {
if (insertedTmdbIds.has(entry.tmdbId)) continue; const key = `${entry.tmdbId}-${entry.mediaType}`;
insertedTmdbIds.add(entry.tmdbId); if (insertedKeys.has(key)) continue;
insertedKeys.add(key);
const row = tx const row = tx
.insert(titles) .insert(titles)
.values({ .values({
@@ -99,21 +101,22 @@ export function batchInsertShellTitlesTransaction(
.onConflictDoNothing() .onConflictDoNothing()
.returning() .returning()
.get(); .get();
if (row) titleIdMap.set(entry.tmdbId, row.id); if (row) titleIdMap.set(key, row.id);
} }
}); });
// Resolve any rows that hit onConflictDoNothing (already existed but weren't // Resolve any rows that hit onConflictDoNothing (already existed but weren't
// in the initial map) // in the initial map)
const stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId)); const stillMissing = [...insertedKeys].filter((key) => !titleIdMap.has(key));
if (stillMissing.length > 0) { if (stillMissing.length > 0) {
const missingTmdbIds = stillMissing.map((key) => Number(key.split("-")[0]));
const fallbacks = db const fallbacks = db
.select({ id: titles.id, tmdbId: titles.tmdbId }) .select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles) .from(titles)
.where(inArray(titles.tmdbId, stillMissing)) .where(inArray(titles.tmdbId, missingTmdbIds))
.all(); .all();
for (const fallback of fallbacks) { for (const f of fallbacks) {
titleIdMap.set(fallback.tmdbId, fallback.id); titleIdMap.set(`${f.tmdbId}-${f.type}`, f.id);
} }
} }