Files
ampelos-dashboard/src/lib/media-item.ts
T
Ryan acdc25c797 Ampelos dashboard: the web face, and the owner of the schema
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>
2026-08-15 12:12:08 +02:00

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;
}