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), ] );