acdc25c797
Split out of the single Ampelos repository. Next.js app, Drizzle schema and migrations, brand art, planning notes. What left: scripts/, which was the agent's job library misfiled under web/ and imported nothing from src/; and deploy/truenas, whose broadcast posts to the scan listener on :3427 -- an agent script -- so it belongs beside the thing it talks to. This repository keeps the schema. The agent speaks raw SQL against the same tables and holds no copy of it, so a rename here can break it silently where it used to be one commit. The README says so, and the agent carries a snapshot to check against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
3.6 KiB
TypeScript
97 lines
3.6 KiB
TypeScript
// 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;
|
|
}
|