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
+22 -5
View File
@@ -315,6 +315,7 @@ export function upsertRecommendationsTransaction(
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterPath: titles.posterPath,
backdropPath: titles.backdropPath,
posterThumbHash: titles.posterThumbHash,
@@ -323,13 +324,22 @@ export function upsertRecommendationsTransaction(
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.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) => {
const insertedTmdbIds = new Set<number>();
for (const item of uniqueTitles.values()) {
const existingTitle = existingTitleMap.get(item.tmdbId);
const existingTitle = existingTitleMap.get(`${item.tmdbId}-${item.type}`);
if (existingTitle) {
const posterPathChanged = existingTitle.posterPath !== item.posterPath;
const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath;
@@ -392,11 +402,18 @@ export function upsertRecommendationsTransaction(
const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: titles.id, tmdbId: titles.tmdbId })
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.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
+17 -14
View File
@@ -65,21 +65,23 @@ interface ShellTitleEntry {
}
/**
* Insert shell title rows inside a transaction, returning a map of tmdbId to
* internal UUID for every entry that was inserted (or already existed via
* conflict fallback).
* Insert shell title rows inside a transaction, returning a map of
* "tmdbId-type" composite key to internal UUID for every entry that was
* inserted (or already existed via conflict fallback).
*/
export function batchInsertShellTitlesTransaction(
entries: ShellTitleEntry[],
existingTitleIdMap: Map<number, string>,
): Map<number, string> {
existingTitleIdMap: Map<string, string>,
): Map<string, string> {
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) => {
for (const entry of entries) {
if (insertedTmdbIds.has(entry.tmdbId)) continue;
insertedTmdbIds.add(entry.tmdbId);
const key = `${entry.tmdbId}-${entry.mediaType}`;
if (insertedKeys.has(key)) continue;
insertedKeys.add(key);
const row = tx
.insert(titles)
.values({
@@ -99,21 +101,22 @@ export function batchInsertShellTitlesTransaction(
.onConflictDoNothing()
.returning()
.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
// in the initial map)
const stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId));
const stillMissing = [...insertedKeys].filter((key) => !titleIdMap.has(key));
if (stillMissing.length > 0) {
const missingTmdbIds = stillMissing.map((key) => Number(key.split("-")[0]));
const fallbacks = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.where(inArray(titles.tmdbId, missingTmdbIds))
.all();
for (const fallback of fallbacks) {
titleIdMap.set(fallback.tmdbId, fallback.id);
for (const f of fallbacks) {
titleIdMap.set(`${f.tmdbId}-${f.type}`, f.id);
}
}