acdc25c797
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>
110 lines
4.8 KiB
TypeScript
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),
|
|
],
|
|
);
|