Files
ampelos-dashboard/src/db/schema/media.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

141 lines
6.2 KiB
TypeScript

import {
pgTable,
pgEnum,
uuid,
text,
integer,
boolean,
date,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const mediaTypeEnum = pgEnum("media_type", ["tv_series", "movie"]);
export const seriesStatusEnum = pgEnum("series_status", ["upcoming", "continuing", "ended"]);
export const seasonStatusEnum = pgEnum("season_status", ["upcoming", "airing", "complete"]);
export const externalIdSourceEnum = pgEnum("external_id_source", ["tvdb", "tmdb", "imdb", "plex"]);
// Shared identity record for both TV series and movies.
export const mediaItems = pgTable("media_items", {
id: uuid("id").primaryKey().defaultRandom(),
mediaType: mediaTypeEnum("media_type").notNull(),
title: text("title").notNull(),
sortTitle: text("sort_title"),
overview: text("overview"),
year: integer("year"),
posterPath: text("poster_path"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const series = pgTable("series", {
id: uuid("id").primaryKey().references(() => mediaItems.id, { onDelete: "cascade" }),
status: seriesStatusEnum("status").notNull().default("upcoming"),
network: text("network"),
firstAirDate: date("first_air_date"),
lastAirDate: date("last_air_date"),
// TMDB's episode_run_time, the typical length of an episode. Kept because it
// is the only cheap way to ask whether the series we think we have matches
// the files on disk: a show whose episodes probe at 45 minutes is not the
// 22-minute show TMDB describes, whatever the folder is called. TMDB leaves
// it empty for plenty of modern shows, so absence means nothing.
episodeRuntimeMinutes: integer("episode_runtime_minutes"),
// Whether the show is still airing OR ended less than one year ago.
// Recomputed on metadata refresh. See PLANNING.md "Currently Airing".
isCurrentlyRelevant: boolean("is_currently_relevant"),
metadataRefreshedAt: timestamp("metadata_refreshed_at"),
});
export const seasons = pgTable(
"seasons",
{
id: uuid("id").primaryKey().defaultRandom(),
seriesId: uuid("series_id").notNull().references(() => series.id, { onDelete: "cascade" }),
seasonNumber: integer("season_number").notNull(),
title: text("title"),
episodeCount: integer("episode_count"),
airDate: date("air_date"),
status: seasonStatusEnum("status").notNull().default("upcoming"),
},
// Required for the metadata refresh to upsert rather than duplicate.
(t) => [uniqueIndex("seasons_series_id_season_number_idx").on(t.seriesId, t.seasonNumber)],
);
export const episodes = pgTable(
"episodes",
{
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id").notNull().references(() => seasons.id, { onDelete: "cascade" }),
episodeNumber: integer("episode_number").notNull(),
title: text("title"),
overview: text("overview"),
airDate: date("air_date"),
},
(t) => [uniqueIndex("episodes_season_id_episode_number_idx").on(t.seasonId, t.episodeNumber)],
);
export const movies = pgTable("movies", {
id: uuid("id").primaryKey().references(() => mediaItems.id, { onDelete: "cascade" }),
releaseDate: date("release_date"),
runtimeMinutes: integer("runtime_minutes"),
status: text("status"),
metadataRefreshedAt: timestamp("metadata_refreshed_at"),
});
export const externalIds = pgTable(
"external_ids",
{
id: uuid("id").primaryKey().defaultRandom(),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
source: externalIdSourceEnum("source").notNull(),
externalId: text("external_id").notNull(),
// Has somebody established that this id is really this title?
//
// Most of these were derived by matching a FOLDER NAME against TMDB search,
// and that matcher has been wrong in ways nothing downstream could detect:
// Taskmaster (AU) carried the UK show's 227 episodes, Dune the wrong Dune,
// Kingdom the anime instead of the MMA drama. The link being plausible is
// not the same as it being right, and until it is checked, everything built
// on it -- what to fetch, what to rename a file to -- inherits the doubt.
//
// Set automatically when the id came from a person choosing a specific
// TMDB result (the catalog board, a Plex watchlist), because there the id
// IS the request rather than a guess about it. Set by hand for the rest.
verifiedAt: timestamp("verified_at"),
// Null for automatic verification: nobody claimed it, the provenance did.
verifiedBy: uuid("verified_by"),
// What the remote source calls this id, and when we last asked.
//
// Stored because it is the fact a reviewer actually needs and the only one
// not already here: the scanner writes the FOLDER's title and the metadata
// refresh deliberately never overwrites it, so the database knows what we
// call a thing and not what the id we linked it to names. Asking "is this
// the right film?" without it means asking somebody to hold two tabs open.
remoteTitle: text("remote_title"),
remoteCheckedAt: timestamp("remote_checked_at"),
// Somebody looked and said this id is NOT this title.
//
// A third state, distinct from unreviewed, and the reason it exists is
// that rejecting has to be as cheap as confirming. A reviewer who can only
// say yes will say yes to a link they are unsure of rather than lose their
// place, and the flag stops meaning anything. Saying no costs one click,
// moves the title out of the queue, and leaves it somewhere it can be
// found again.
//
// The wrong id stays attached on purpose. It is the evidence of what went
// wrong, and whoever resolves this needs to see what was rejected as much
// as what the title is.
rejectedAt: timestamp("rejected_at"),
rejectedBy: uuid("rejected_by"),
},
(t) => [
uniqueIndex("external_ids_source_external_id_idx").on(t.source, t.externalId),
// The unique index above is on (source, external_id), which answers "who
// holds tmdb:1726?" but not "what is this title linked to?" -- and the
// second question is the one the app actually asks, on every page that
// shows a poster or a link.
index("external_ids_media_item_idx").on(t.mediaItemId),
]
);