// Turning a TMDB catalog entry into a row in the collection. // // The public board is a TMDB browser: almost everything on it is a title // Ampelos has never heard of, so every action that expresses an opinion about // one -- Watch Now, watchlist, timeless -- has to be able to bring it into // being first. That was inline in the Watch Now action and is shared now // because a second copy of it would drift, and the two copies disagreeing about // what a new media item looks like is a bug nobody would see until the fetcher // went looking for a series with no `series` row. // // Identity only. Overview and poster come along because the board already has // them and a title with neither reads as broken in the admin lists, but // runtime, release date, and a series' season and episode list are // refresh-metadata's job -- and it trusts the TMDB id written here over any // title search, which is what keeps a created row pointing at what the person // actually clicked. import { and, eq } from "drizzle-orm"; import { db } from "@/db/client"; import { externalIds, mediaItems, movies, series } from "@/db/schema"; export type MediaType = "tv_series" | "movie"; export type CatalogRef = { mediaType: MediaType; tmdbId: string; title: string; year: number | null; overview?: string | null; posterPath?: string | null; releaseDate?: string | null; }; /** The media item for a TMDB id, or null if the collection has never seen it. */ export async function findMediaItemByTmdbId(mediaType: MediaType, tmdbId: string) { const [row] = await db .select({ mediaItemId: externalIds.mediaItemId }) .from(externalIds) .innerJoin(mediaItems, eq(mediaItems.id, externalIds.mediaItemId)) .where( and( eq(externalIds.source, "tmdb"), eq(externalIds.externalId, tmdbId), eq(mediaItems.mediaType, mediaType), ), ) .limit(1); return row?.mediaItemId ?? null; } /** * The media item for this catalog entry, creating it if it does not exist. * * The media type is part of the lookup because TMDB numbers films and series in * separate spaces: id 1399 is both a film and a series, and matching on the * number alone would hand somebody's film the series' row. */ export async function ensureMediaItem(ref: CatalogRef) { const existing = await findMediaItemByTmdbId(ref.mediaType, ref.tmdbId); if (existing) return existing; const [created] = await db .insert(mediaItems) .values({ mediaType: ref.mediaType, title: ref.title, sortTitle: ref.title.toLocaleLowerCase("en-US"), overview: ref.overview ?? null, year: ref.year, posterPath: ref.posterPath ?? null, }) .returning({ id: mediaItems.id }); if (ref.mediaType === "movie") { await db.insert(movies).values({ id: created.id, releaseDate: ref.releaseDate ?? null }); } else { await db.insert(series).values({ id: created.id, firstAirDate: ref.releaseDate ?? null }); } // Verified on arrival. Everywhere else the TMDB id is a guess made by // matching a folder name against search results; here somebody clicked a // specific TMDB entry and asked for THAT. The id is the request, not an // inference about it, so there is nothing left for a human to check. // // ON CONFLICT DO NOTHING, because the unique index is on (source, id) alone // and losing that race must not fail the action the person actually asked // for. The row is still found by the lookup above on the next attempt. await db .insert(externalIds) .values({ mediaItemId: created.id, source: "tmdb", externalId: ref.tmdbId, verifiedAt: new Date() }) .onConflictDoNothing(); return created.id; }