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>
141 lines
6.2 KiB
TypeScript
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),
|
|
]
|
|
);
|