mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -202,14 +202,20 @@ async function syncPersonFilmography(
|
||||
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
|
||||
|
||||
const existingTitles = getExistingTitlesByTmdbIds(tmdbIds);
|
||||
const titleIdMap = new Map<number, string>(
|
||||
existingTitles.map((title) => [title.tmdbId, title.id]),
|
||||
// Key by (tmdbId, type) composite since the same tmdbId can be a movie and TV show
|
||||
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) {
|
||||
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) => ({
|
||||
tmdbId: entry.id,
|
||||
mediaType: entry.media_type as "movie" | "tv",
|
||||
@@ -225,8 +231,8 @@ async function syncPersonFilmography(
|
||||
}));
|
||||
|
||||
const updatedMap = batchInsertShellTitlesTransaction(shellEntries, titleIdMap);
|
||||
for (const [tmdbId, id] of updatedMap) {
|
||||
titleIdMap.set(tmdbId, id);
|
||||
for (const [key, id] of updatedMap) {
|
||||
titleIdMap.set(key, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +240,7 @@ async function syncPersonFilmography(
|
||||
let displayOrder = 0;
|
||||
|
||||
for (const credit of validCast) {
|
||||
const titleId = titleIdMap.get(credit.id);
|
||||
const titleId = titleIdMap.get(`${credit.id}-${credit.media_type}`);
|
||||
if (!titleId) continue;
|
||||
|
||||
nextRows.push({
|
||||
@@ -249,7 +255,7 @@ async function syncPersonFilmography(
|
||||
}
|
||||
|
||||
for (const credit of validCrew) {
|
||||
const titleId = titleIdMap.get(credit.id);
|
||||
const titleId = titleIdMap.get(`${credit.id}-${credit.media_type}`);
|
||||
if (!titleId) continue;
|
||||
|
||||
nextRows.push({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user