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