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>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user