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

110 lines
4.8 KiB
TypeScript

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