import { pgTable, uuid, text, boolean, timestamp, pgEnum, bigint, integer, index, uniqueIndex, } from "drizzle-orm/pg-core"; import { episodes, mediaItems } from "./media"; export const storageTierEnum = pgEnum("storage_tier", [ "live", "backup", "archive", ]); export const storageTiers = pgTable("storage_tiers", { id: uuid("id").primaryKey().defaultRandom(), tier: storageTierEnum("tier").notNull().unique(), label: text("label").notNull(), basePath: text("base_path").notNull(), // Whether Ampelos should treat this tier as continuously available. // Live is always expected online; backup and archive may be offline. alwaysOnline: boolean("always_online").notNull().default(false), // Name of the agent (agent_heartbeats.agent) whose host owns this storage. // When set, reachability is derived from that agent's heartbeat freshness // rather than assumed. Null means availability is unknown/manual. agentName: text("agent_name"), notes: text("notes"), }); // Tracks observed availability windows for tiers that are not always online. export const storageAvailability = pgTable("storage_availability", { id: uuid("id").primaryKey().defaultRandom(), tierId: uuid("tier_id").notNull().references(() => storageTiers.id), online: boolean("online").notNull(), detectedAt: timestamp("detected_at").notNull().defaultNow(), // Null if the tier is still in this state. resolvedAt: timestamp("resolved_at"), }); export const storageFiles = pgTable( "storage_files", { id: uuid("id").primaryKey().defaultRandom(), tierId: uuid("tier_id").notNull().references(() => storageTiers.id, { onDelete: "cascade" }), mediaItemId: uuid("media_item_id").references(() => mediaItems.id, { onDelete: "set null" }), episodeId: uuid("episode_id").references(() => episodes.id, { onDelete: "set null" }), relativePath: text("relative_path").notNull(), fullPath: text("full_path").notNull(), filename: text("filename").notNull(), extension: text("extension").notNull(), sizeBytes: bigint("size_bytes", { mode: "bigint" }).notNull(), modifiedAt: timestamp("modified_at").notNull(), observedAt: timestamp("observed_at").notNull().defaultNow(), missingAt: timestamp("missing_at"), inferredTitle: text("inferred_title"), inferredYear: text("inferred_year"), // Derived from the ffprobe columns below once a probe has run; until then // these hold the (unreliable) filename guess made by the scanners. quality: text("quality"), codec: text("codec"), // Alternate cut of the same episode/movie kept deliberately alongside the // standard one, e.g. the black-and-white release of Spider-Noir. Null means // the normal edition. Two files on one episode with different editions are // intentional and must not be treated as duplicates by dedup or policy. edition: text("edition"), // Ground truth read out of the container by ampelos-agent scripts/probe-media.mjs. // Filenames in this library are demonstrably wrong about both resolution // and codec, so anything user-facing should prefer these. width: integer("width"), height: integer("height"), videoCodec: text("video_codec"), audioCodec: text("audio_codec"), audioChannels: integer("audio_channels"), durationSeconds: integer("duration_seconds"), bitrate: bigint("bitrate", { mode: "bigint" }), probedAt: timestamp("probed_at"), // Last ffprobe failure for this file; cleared on a successful probe. probeError: text("probe_error"), // Somebody looked at this copy and asked for a better one. // // On the FILE rather than the episode, because that is what the request is // about: this copy, the one they were looking at, is the one to throw away // once something better has landed. An episode-level flag could not say // which of two copies was the offender. // // It does three things while it is set: the acquisition queue stops // treating the episode as already held, the queue puts it in the top band, // and the reaper is allowed to delete this file once a replacement is // actually on disk. Clearing it cancels all three. replaceRequestedAt: timestamp("replace_requested_at"), replaceRequestedBy: uuid("replace_requested_by"), }, (t) => [ uniqueIndex("storage_files_tier_relative_path_idx").on(t.tierId, t.relativePath), // "Which files does this title hold?" is the question almost every page // asks, and without this it was answered by scanning all 41,000 rows. The // link review queue asks it twice per title across 2,771 titles, which took // eighty seconds; the same scan was quietly taxing the inventory and title // pages too, just not enough for anyone to notice. index("storage_files_media_item_idx").on(t.mediaItemId), ], );