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,140 @@
|
||||
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),
|
||||
]
|
||||
);
|
||||
Reference in New Issue
Block a user