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:
Ryan
2026-08-15 12:12:08 +02:00
commit acdc25c797
138 changed files with 70946 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
export const db = drizzle(process.env.DATABASE_URL!, { schema });
@@ -0,0 +1,41 @@
CREATE TYPE "public"."storage_tier" AS ENUM('live', 'backup', 'archive');--> statement-breakpoint
CREATE TABLE "user_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"provider" text NOT NULL,
"external_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"display_name" text NOT NULL,
"email" text NOT NULL,
"is_admin" boolean DEFAULT false NOT NULL,
"watching_now_tv_slots" integer DEFAULT 5 NOT NULL,
"watching_now_movie_slots" integer DEFAULT 10 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "storage_availability" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tier_id" uuid NOT NULL,
"online" boolean NOT NULL,
"detected_at" timestamp DEFAULT now() NOT NULL,
"resolved_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "storage_tiers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tier" "storage_tier" NOT NULL,
"label" text NOT NULL,
"base_path" text NOT NULL,
"always_online" boolean DEFAULT false NOT NULL,
"notes" text,
CONSTRAINT "storage_tiers_tier_unique" UNIQUE("tier")
);
--> statement-breakpoint
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "storage_availability" ADD CONSTRAINT "storage_availability_tier_id_storage_tiers_id_fk" FOREIGN KEY ("tier_id") REFERENCES "public"."storage_tiers"("id") ON DELETE no action ON UPDATE no action;
+146
View File
@@ -0,0 +1,146 @@
CREATE TYPE "public"."external_id_source" AS ENUM('tvdb', 'tmdb', 'imdb', 'plex');--> statement-breakpoint
CREATE TYPE "public"."media_type" AS ENUM('tv_series', 'movie');--> statement-breakpoint
CREATE TYPE "public"."season_status" AS ENUM('upcoming', 'airing', 'complete');--> statement-breakpoint
CREATE TYPE "public"."series_status" AS ENUM('upcoming', 'continuing', 'ended');--> statement-breakpoint
CREATE TYPE "public"."watching_now_scope" AS ENUM('show', 'season');--> statement-breakpoint
CREATE TYPE "public"."watchlist_source" AS ENUM('plex', 'manual');--> statement-breakpoint
CREATE TYPE "public"."override_type" AS ENUM('force_live', 'force_archive', 'force_quality', 'force_monitor', 'force_ignore', 'temporary_promotion');--> statement-breakpoint
CREATE TYPE "public"."policy_trigger" AS ENUM('scheduled', 'manual', 'demand_change', 'metadata_refresh');--> statement-breakpoint
CREATE TYPE "public"."quality" AS ENUM('sd', '720p', '1080p', '4k');--> statement-breakpoint
CREATE TABLE "episodes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"episode_number" integer NOT NULL,
"title" text,
"overview" text,
"air_date" date
);
--> statement-breakpoint
CREATE TABLE "external_ids" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_item_id" uuid NOT NULL,
"source" "external_id_source" NOT NULL,
"external_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "media_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_type" "media_type" NOT NULL,
"title" text NOT NULL,
"sort_title" text,
"overview" text,
"year" integer,
"poster_path" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "movies" (
"id" uuid PRIMARY KEY NOT NULL,
"release_date" date,
"runtime_minutes" integer,
"status" text,
"metadata_refreshed_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "seasons" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"series_id" uuid NOT NULL,
"season_number" integer NOT NULL,
"title" text,
"episode_count" integer,
"air_date" date,
"status" "season_status" DEFAULT 'upcoming' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "series" (
"id" uuid PRIMARY KEY NOT NULL,
"status" "series_status" DEFAULT 'upcoming' NOT NULL,
"network" text,
"first_air_date" date,
"last_air_date" date,
"is_currently_relevant" text,
"metadata_refreshed_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "classics" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_item_id" uuid NOT NULL,
"note" text,
"added_by" uuid,
"added_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "classics_media_item_id_unique" UNIQUE("media_item_id")
);
--> statement-breakpoint
CREATE TABLE "watching_now_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"media_item_id" uuid NOT NULL,
"scope" "watching_now_scope" DEFAULT 'show' NOT NULL,
"season_number" integer,
"added_at" timestamp DEFAULT now() NOT NULL,
"removed_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "watchlist_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"media_item_id" uuid NOT NULL,
"source" "watchlist_source" DEFAULT 'plex' NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL,
"removed_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "admin_overrides" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_item_id" uuid NOT NULL,
"season_number" integer,
"episode_number" integer,
"override_type" "override_type" NOT NULL,
"value" jsonb,
"reason" text,
"expires_at" timestamp,
"created_by" uuid,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "desired_states" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"policy_run_id" uuid,
"media_item_id" uuid NOT NULL,
"season_number" integer,
"episode_number" integer,
"wanted" boolean DEFAULT false NOT NULL,
"storage_tier" "storage_tier",
"min_quality" "quality",
"preferred_quality" "quality",
"monitored" boolean DEFAULT false NOT NULL,
"reason_codes" text[] DEFAULT '{}' NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "policy_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"trigger" "policy_trigger" NOT NULL,
"started_at" timestamp DEFAULT now() NOT NULL,
"completed_at" timestamp,
"items_evaluated" integer,
"items_changed" integer
);
--> statement-breakpoint
ALTER TABLE "episodes" ADD CONSTRAINT "episodes_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "external_ids" ADD CONSTRAINT "external_ids_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "movies" ADD CONSTRAINT "movies_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "seasons" ADD CONSTRAINT "seasons_series_id_series_id_fk" FOREIGN KEY ("series_id") REFERENCES "public"."series"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "series" ADD CONSTRAINT "series_id_media_items_id_fk" FOREIGN KEY ("id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "classics" ADD CONSTRAINT "classics_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "classics" ADD CONSTRAINT "classics_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "watching_now_items" ADD CONSTRAINT "watching_now_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "watching_now_items" ADD CONSTRAINT "watching_now_items_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "watchlist_items" ADD CONSTRAINT "watchlist_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "watchlist_items" ADD CONSTRAINT "watchlist_items_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "admin_overrides" ADD CONSTRAINT "admin_overrides_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "admin_overrides" ADD CONSTRAINT "admin_overrides_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "desired_states" ADD CONSTRAINT "desired_states_policy_run_id_policy_runs_id_fk" FOREIGN KEY ("policy_run_id") REFERENCES "public"."policy_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "desired_states" ADD CONSTRAINT "desired_states_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "external_ids_source_external_id_idx" ON "external_ids" USING btree ("source","external_id");
@@ -0,0 +1 @@
ALTER TABLE "watching_now_items" ADD COLUMN "slot_number" integer;
@@ -0,0 +1,21 @@
CREATE TABLE "storage_files" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tier_id" uuid NOT NULL,
"media_item_id" uuid,
"relative_path" text NOT NULL,
"full_path" text NOT NULL,
"filename" text NOT NULL,
"extension" text NOT NULL,
"size_bytes" bigint NOT NULL,
"modified_at" timestamp NOT NULL,
"observed_at" timestamp DEFAULT now() NOT NULL,
"missing_at" timestamp,
"inferred_title" text,
"inferred_year" text,
"quality" text,
"codec" text
);
--> statement-breakpoint
ALTER TABLE "storage_files" ADD CONSTRAINT "storage_files_tier_id_storage_tiers_id_fk" FOREIGN KEY ("tier_id") REFERENCES "public"."storage_tiers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "storage_files" ADD CONSTRAINT "storage_files_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "storage_files_tier_relative_path_idx" ON "storage_files" USING btree ("tier_id","relative_path");
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "storage_files" ADD COLUMN "episode_id" uuid;--> statement-breakpoint
ALTER TABLE "storage_files" ADD CONSTRAINT "storage_files_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE set null ON UPDATE no action;
@@ -0,0 +1,9 @@
ALTER TABLE "storage_files" ADD COLUMN "width" integer;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "height" integer;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "video_codec" text;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "audio_codec" text;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "audio_channels" integer;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "duration_seconds" integer;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "bitrate" bigint;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "probed_at" timestamp;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "probe_error" text;
@@ -0,0 +1,13 @@
CREATE TABLE "agent_heartbeats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent" text NOT NULL,
"hostname" text,
"version" text,
"first_seen_at" timestamp DEFAULT now() NOT NULL,
"last_seen_at" timestamp DEFAULT now() NOT NULL,
"details" jsonb,
CONSTRAINT "agent_heartbeats_agent_unique" UNIQUE("agent")
);
--> statement-breakpoint
ALTER TABLE "series" ALTER COLUMN "is_currently_relevant" SET DATA TYPE boolean USING "is_currently_relevant"::boolean;--> statement-breakpoint
ALTER TABLE "storage_tiers" ADD COLUMN "agent_name" text;
@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX "episodes_season_id_episode_number_idx" ON "episodes" USING btree ("season_id","episode_number");--> statement-breakpoint
CREATE UNIQUE INDEX "seasons_series_id_season_number_idx" ON "seasons" USING btree ("series_id","season_number");
@@ -0,0 +1 @@
ALTER TABLE "storage_files" ADD COLUMN "edition" text;
@@ -0,0 +1,19 @@
CREATE TYPE "public"."corrupt_file_status" AS ENUM('pending', 'approved', 'deleted', 'dismissed', 'failed');--> statement-breakpoint
CREATE TABLE "corrupt_files" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"full_path" text NOT NULL,
"agent" text NOT NULL,
"tier" text,
"reason" text NOT NULL,
"size_bytes" bigint,
"status" "corrupt_file_status" DEFAULT 'pending' NOT NULL,
"first_detected_at" timestamp DEFAULT now() NOT NULL,
"last_detected_at" timestamp DEFAULT now() NOT NULL,
"reviewed_by" uuid,
"reviewed_at" timestamp,
"deleted_at" timestamp,
"delete_error" text,
CONSTRAINT "corrupt_files_full_path_unique" UNIQUE("full_path")
);
--> statement-breakpoint
ALTER TABLE "corrupt_files" ADD CONSTRAINT "corrupt_files_reviewed_by_users_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
@@ -0,0 +1,17 @@
CREATE TYPE "public"."maintenance_job_status" AS ENUM('running', 'succeeded', 'failed', 'skipped');--> statement-breakpoint
CREATE TYPE "public"."maintenance_trigger" AS ENUM('scheduled', 'manual');--> statement-breakpoint
CREATE TABLE "maintenance_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"job" text NOT NULL,
"trigger" "maintenance_trigger" DEFAULT 'scheduled' NOT NULL,
"status" "maintenance_job_status" DEFAULT 'running' NOT NULL,
"started_at" timestamp DEFAULT now() NOT NULL,
"completed_at" timestamp,
"duration_ms" integer,
"exit_code" integer,
"detail" text,
"output" text,
"summary" jsonb
);
--> statement-breakpoint
CREATE INDEX "maintenance_jobs_job_started_idx" ON "maintenance_jobs" USING btree ("job","started_at");
+70
View File
@@ -0,0 +1,70 @@
CREATE TYPE "public"."grab_source" AS ENUM('auto', 'manual');--> statement-breakpoint
CREATE TYPE "public"."grab_status" AS ENUM('queued', 'downloading', 'completed', 'importing', 'imported', 'failed', 'orphaned');--> statement-breakpoint
CREATE TABLE "grabs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_item_id" uuid NOT NULL,
"season_number" integer,
"episode_number" integer,
"info_hash" text NOT NULL,
"release_title" text NOT NULL,
"size_bytes" bigint,
"magnet_url" text,
"download_url" text,
"indexer_name" text NOT NULL,
"origin" text,
"seeders" integer,
"quality" "quality",
"score" integer,
"score_reasons" jsonb DEFAULT '[]'::jsonb,
"source" "grab_source" NOT NULL,
"requested_by" uuid,
"status" "grab_status" DEFAULT 'queued' NOT NULL,
"status_detail" text,
"progress" integer DEFAULT 0,
"content_path" text,
"imported_path" text,
"imported_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "release_blocklist" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"info_hash" text NOT NULL,
"release_title" text,
"media_item_id" uuid,
"reason" text,
"permanent" boolean DEFAULT false NOT NULL,
"created_by" uuid,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "search_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_item_id" uuid,
"season_number" integer,
"episode_number" integer,
"source" "grab_source" NOT NULL,
"query" text,
"indexers_queried" integer DEFAULT 0 NOT NULL,
"results_found" integer DEFAULT 0 NOT NULL,
"results_accepted" integer DEFAULT 0 NOT NULL,
"skipped" jsonb DEFAULT '[]'::jsonb,
"errors" jsonb DEFAULT '[]'::jsonb,
"grab_id" uuid,
"decision" text,
"started_at" timestamp DEFAULT now() NOT NULL,
"duration_ms" integer
);
--> statement-breakpoint
ALTER TABLE "grabs" ADD CONSTRAINT "grabs_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grabs" ADD CONSTRAINT "grabs_requested_by_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "release_blocklist" ADD CONSTRAINT "release_blocklist_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "release_blocklist" ADD CONSTRAINT "release_blocklist_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "search_runs" ADD CONSTRAINT "search_runs_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "search_runs" ADD CONSTRAINT "search_runs_grab_id_grabs_id_fk" FOREIGN KEY ("grab_id") REFERENCES "public"."grabs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "grabs_info_hash_idx" ON "grabs" USING btree ("info_hash");--> statement-breakpoint
CREATE INDEX "grabs_status_idx" ON "grabs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "grabs_media_item_idx" ON "grabs" USING btree ("media_item_id","season_number","episode_number");--> statement-breakpoint
CREATE UNIQUE INDEX "release_blocklist_info_hash_idx" ON "release_blocklist" USING btree ("info_hash");--> statement-breakpoint
CREATE INDEX "search_runs_media_item_idx" ON "search_runs" USING btree ("media_item_id","started_at");
@@ -0,0 +1,17 @@
CREATE TABLE "grab_files" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"grab_id" uuid NOT NULL,
"storage_file_id" uuid,
"imported_path" text NOT NULL,
"season_number" integer,
"episode_number" integer,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "grabs" ADD COLUMN "seed_path" text;--> statement-breakpoint
ALTER TABLE "grabs" ADD COLUMN "seed_released_at" timestamp;--> statement-breakpoint
ALTER TABLE "grabs" ADD COLUMN "seed_release_reason" text;--> statement-breakpoint
ALTER TABLE "grab_files" ADD CONSTRAINT "grab_files_grab_id_grabs_id_fk" FOREIGN KEY ("grab_id") REFERENCES "public"."grabs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grab_files" ADD CONSTRAINT "grab_files_storage_file_id_storage_files_id_fk" FOREIGN KEY ("storage_file_id") REFERENCES "public"."storage_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "grab_files_grab_path_idx" ON "grab_files" USING btree ("grab_id","imported_path");--> statement-breakpoint
CREATE INDEX "grab_files_storage_file_idx" ON "grab_files" USING btree ("storage_file_id");
@@ -0,0 +1,2 @@
ALTER TABLE "release_blocklist" ALTER COLUMN "info_hash" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "release_blocklist" ADD COLUMN "release_group" text;
@@ -0,0 +1 @@
ALTER TABLE "grabs" ADD COLUMN "progress_changed_at" timestamp;
@@ -0,0 +1 @@
ALTER TYPE "public"."override_type" ADD VALUE 'purge';
@@ -0,0 +1,28 @@
CREATE TABLE "release_size_rules" (
"quality" "quality" PRIMARY KEY NOT NULL,
"min_mb_per_minute" numeric(6, 1) NOT NULL,
"max_mb_per_minute" numeric(6, 1) NOT NULL,
"atmos_max_mb_per_minute" numeric(6, 1),
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- Seeded from percentiles over every probed file in this library that has a
-- real duration, so the table starts out describing the collection it governs:
--
-- quality n p05 p50 p90 p95 p99 max
-- sd 3604 6.1 15.2 16.0 17.0 18.5 45.7
-- 720p 9958 3.4 7.7 18.0 19.1 25.5 55.3
-- 1080p 9184 8.3 19.6 48.7 61.9 104.8 237.9
-- 4k 449 30.3 92.2 154.4 184.1 233.4 264.5
--
-- 1080p is capped at 60 rather than the 40 the film numbers alone suggest:
-- television runs hotter than film at 1080p (median 24.1 against 15.9), and 40
-- would have refused 1,602 files already held, 1,569 of them television.
INSERT INTO "release_size_rules"
("quality", "min_mb_per_minute", "max_mb_per_minute", "atmos_max_mb_per_minute")
VALUES
('sd', 2, 20, NULL),
('720p', 2, 20, NULL),
('1080p', 4, 60, NULL),
('4k', 16, 160, 240)
ON CONFLICT ("quality") DO NOTHING;
@@ -0,0 +1,12 @@
ALTER TABLE "release_size_rules" ADD COLUMN "timeless_max_mb_per_minute" numeric(6, 1);--> statement-breakpoint
-- Sized to admit a genuine remux and nothing looser. A remux is the disc's own
-- streams, so its size is a property of the disc rather than a choice: a 1080p
-- one runs 20-30GB for a two-hour film (170-250 MB/min) and a 4K one 50-80GB
-- (420-670 MB/min).
--
-- Only 1080p and 4K carry one. There is no such thing as a 720p remux -- the
-- format is a re-encode by definition, so an allowance there would license
-- bloat with nothing lossless to show for it.
UPDATE "release_size_rules" SET "timeless_max_mb_per_minute" = 250 WHERE "quality" = '1080p';
--> statement-breakpoint
UPDATE "release_size_rules" SET "timeless_max_mb_per_minute" = 650 WHERE "quality" = '4k';
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "plex_accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"plex_user_id" text NOT NULL,
"plex_uuid" text,
"plex_username" text NOT NULL,
"plex_email" text,
"libraries_shared_at" timestamp,
"shared_section_ids" text[],
"linked_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "plex_accounts_user_id_unique" UNIQUE("user_id"),
CONSTRAINT "plex_accounts_plex_user_id_unique" UNIQUE("plex_user_id")
);
--> statement-breakpoint
ALTER TABLE "plex_accounts" ADD CONSTRAINT "plex_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,3 @@
ALTER TABLE "external_ids" ADD COLUMN "verified_at" timestamp;--> statement-breakpoint
ALTER TABLE "external_ids" ADD COLUMN "verified_by" uuid;--> statement-breakpoint
ALTER TABLE "series" ADD COLUMN "episode_runtime_minutes" integer;
@@ -0,0 +1,2 @@
ALTER TABLE "external_ids" ADD COLUMN "rejected_at" timestamp;--> statement-breakpoint
ALTER TABLE "external_ids" ADD COLUMN "rejected_by" uuid;
@@ -0,0 +1,2 @@
CREATE INDEX "storage_files_media_item_idx" ON "storage_files" USING btree ("media_item_id");--> statement-breakpoint
CREATE INDEX "external_ids_media_item_idx" ON "external_ids" USING btree ("media_item_id");
@@ -0,0 +1,2 @@
ALTER TABLE "external_ids" ADD COLUMN "remote_title" text;--> statement-breakpoint
ALTER TABLE "external_ids" ADD COLUMN "remote_checked_at" timestamp;
@@ -0,0 +1,2 @@
ALTER TABLE "storage_files" ADD COLUMN "replace_requested_at" timestamp;--> statement-breakpoint
ALTER TABLE "storage_files" ADD COLUMN "replace_requested_by" uuid;
+282
View File
@@ -0,0 +1,282 @@
{
"id": "d8b57dfe-223d-4124-8c4a-d1f05c6d62c1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.user_identities": {
"name": "user_identities",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_identities_user_id_users_id_fk": {
"name": "user_identities_user_id_users_id_fk",
"tableFrom": "user_identities",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_admin": {
"name": "is_admin",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"watching_now_tv_slots": {
"name": "watching_now_tv_slots",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"watching_now_movie_slots": {
"name": "watching_now_movie_slots",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.storage_availability": {
"name": "storage_availability",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tier_id": {
"name": "tier_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"online": {
"name": "online",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"detected_at": {
"name": "detected_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"storage_availability_tier_id_storage_tiers_id_fk": {
"name": "storage_availability_tier_id_storage_tiers_id_fk",
"tableFrom": "storage_availability",
"tableTo": "storage_tiers",
"columnsFrom": [
"tier_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.storage_tiers": {
"name": "storage_tiers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tier": {
"name": "tier",
"type": "storage_tier",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"base_path": {
"name": "base_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"always_online": {
"name": "always_online",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"storage_tiers_tier_unique": {
"name": "storage_tiers_tier_unique",
"nullsNotDistinct": false,
"columns": [
"tier"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.storage_tier": {
"name": "storage_tier",
"schema": "public",
"values": [
"live",
"backup",
"archive"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781818467932,
"tag": "0000_sudden_lila_cheney",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781820073445,
"tag": "0001_slow_molten_man",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1781864081697,
"tag": "0002_careful_sentinels",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1781937141943,
"tag": "0003_yummy_santa_claus",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1782289634052,
"tag": "0004_sloppy_gambit",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1786270380404,
"tag": "0005_square_sentinels",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1786273172759,
"tag": "0006_sleepy_silver_surfer",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1786273540007,
"tag": "0007_chemical_virginia_dare",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1786282914317,
"tag": "0008_nappy_lord_tyger",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1786287535313,
"tag": "0009_nebulous_falcon",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1786359123121,
"tag": "0010_friendly_shiver_man",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1786383962407,
"tag": "0011_acquisition",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1786385181240,
"tag": "0012_grab_seed_tracking",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1786385710589,
"tag": "0013_blocklist_groups",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1786387139515,
"tag": "0014_grab_progress_stall",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1786396608811,
"tag": "0015_purge_override",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1786432882168,
"tag": "0016_release_size_rules",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1786438564256,
"tag": "0017_timeless_remux_allowance",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1786442023657,
"tag": "0018_plex_accounts",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1786472234377,
"tag": "0019_verified_tmdb_links",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1786543985425,
"tag": "0020_link_review_rejection",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1786550874284,
"tag": "0021_media_item_lookup_indexes",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1786641926372,
"tag": "0022_external_id_remote_title",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1786655149441,
"tag": "0023_replacement_requests",
"breakpoints": true
}
]
}
+234
View File
@@ -0,0 +1,234 @@
import {
pgTable,
pgEnum,
uuid,
text,
integer,
bigint,
boolean,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { mediaItems } from "./media";
import { storageFiles } from "./storage";
import { users } from "./users";
import { qualityEnum } from "./policy";
// The life of a grab, from "we asked qBittorrent" to "the file is in the
// library". `imported` is terminal and happy; `failed` and `orphaned` are
// terminal and not.
//
// `orphaned` specifically means: we recorded this grab, and qBittorrent no
// longer knows about the torrent. That is a real state -- someone deleted it in
// the WebUI -- and it must not be confused with "still downloading".
export const grabStatusEnum = pgEnum("grab_status", [
"queued",
"downloading",
"completed",
"importing",
"imported",
"failed",
"orphaned",
]);
export const grabSourceEnum = pgEnum("grab_source", ["auto", "manual"]);
/**
* Every torrent Ampelos has handed to qBittorrent.
*
* This table is the safety mechanism, not bookkeeping. `/Niflheim/Downloads` is
* shared storage that the old Sonarr/Radarr/transmission stack on .96 still
* writes to and seeds from. An organizer that scanned that directory and moved
* what it found would eventually move a file the old stack was mid-import on,
* or delete something still seeding.
*
* So the organizer never scans. It reads this table, asks qBittorrent about
* those specific infohashes, and touches nothing else. A file with no row here
* is, by definition, not ours.
*/
export const grabs = pgTable(
"grabs",
{
id: uuid("id").primaryKey().defaultRandom(),
// What this grab is FOR. Null season/episode means the whole item, which is
// how movies and season packs are recorded.
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
seasonNumber: integer("season_number"),
episodeNumber: integer("episode_number"),
// The torrent itself. infoHash is the join key to qBittorrent and the
// reason this table can be authoritative about what is ours.
infoHash: text("info_hash").notNull(),
releaseTitle: text("release_title").notNull(),
sizeBytes: bigint("size_bytes", { mode: "bigint" }),
magnetUrl: text("magnet_url"),
downloadUrl: text("download_url"),
// Where it came from, for health attribution and for explaining a choice
// back to the user. `origin` is the tracker behind an aggregator: Knaben
// returning a RuTracker release is Knaben/RuTracker.
indexerName: text("indexer_name").notNull(),
origin: text("origin"),
seeders: integer("seeders"),
// Why this release and not another. Populated by the decision engine, and
// shown in the UI so an automatic choice is never a black box.
quality: qualityEnum("quality"),
score: integer("score"),
scoreReasons: jsonb("score_reasons").$type<string[]>().default([]),
source: grabSourceEnum("source").notNull(),
requestedBy: uuid("requested_by").references(() => users.id),
status: grabStatusEnum("status").notNull().default("queued"),
statusDetail: text("status_detail"),
progress: integer("progress").default(0),
// When progress last actually MOVED, not when the row was last touched.
// A torrent that stalls at 40% still gets polled every five minutes, so
// updated_at keeps advancing and cannot distinguish "downloading slowly"
// from "dead since March". This column is only written when the percentage
// changes, which makes stall age a straightforward subtraction.
progressChangedAt: timestamp("progress_changed_at"),
// Absolute path qBittorrent reported once the download finished, and where
// the organizer put it. Both recorded because a failed import needs to say
// what it was holding and where it tried to go.
contentPath: text("content_path"),
importedPath: text("imported_path"),
// Set when the organizer has hard-linked the file into the library. Kept
// separate from status so a re-import can be forced.
importedAt: timestamp("imported_at"),
// THE SEED, AND WHY IT MUST BE RELEASED.
//
// Import is a hardlink, so one inode carries two names: this path under
// /Niflheim/Downloads, and the library path. qBittorrent seeds from its
// name at no extra disk cost, which is the whole point.
//
// But it means archiving does not free anything on its own. Moving a file
// live -> archive copies it to edda and deletes the LIBRARY name; the
// download name still holds the inode, so the bytes stay on /Niflheim
// forever and the archive tier has bought nothing. Space comes back only
// when the last link goes.
//
// So the seed is tracked here and released deliberately, and `seedPath` is
// recorded rather than recomputed: qBittorrent is the authority on where it
// put things, and a path we derived could be wrong in exactly the case
// where being wrong deletes the only copy.
seedPath: text("seed_path"),
seedReleasedAt: timestamp("seed_released_at"),
seedReleaseReason: text("seed_release_reason"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
// One grab per torrent. Re-grabbing the same release is an update, not a
// second row, or the organizer would import it twice.
uniqueIndex("grabs_info_hash_idx").on(t.infoHash),
// The organizer's query: everything not yet finished.
index("grabs_status_idx").on(t.status),
index("grabs_media_item_idx").on(t.mediaItemId, t.seasonNumber, t.episodeNumber),
],
);
/**
* Which library files a grab produced.
*
* One grab is not one file. A season pack is a single torrent that hardlinks
* into ten episodes, and each of those is an independent thing that can be
* promoted, demoted or deleted on its own.
*
* This table is what makes seed release correct rather than approximate. The
* rule it enables: a grab's seed may be released once NONE of its files are on
* the live tier any more. Releasing when the first episode is archived would
* stop seeding a torrent whose other nine episodes are still live and still
* costing nothing to seed.
*/
export const grabFiles = pgTable(
"grab_files",
{
id: uuid("id").primaryKey().defaultRandom(),
grabId: uuid("grab_id").notNull().references(() => grabs.id, { onDelete: "cascade" }),
storageFileId: uuid("storage_file_id").references(() => storageFiles.id, { onDelete: "set null" }),
// Kept alongside the FK because the FK is nulled when a file is removed
// from the library, and a released seed still needs to say what it held.
importedPath: text("imported_path").notNull(),
seasonNumber: integer("season_number"),
episodeNumber: integer("episode_number"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
uniqueIndex("grab_files_grab_path_idx").on(t.grabId, t.importedPath),
index("grab_files_storage_file_idx").on(t.storageFileId),
],
);
/**
* A record of each search, so a failed acquisition can be explained.
*
* "Why is this episode still missing?" is otherwise unanswerable: the fan-out
* happened in a container, minutes ago, and left no trace. Storing the counts
* plus which indexers failed turns that into a question the UI can answer.
*/
export const searchRuns = pgTable(
"search_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
mediaItemId: uuid("media_item_id").references(() => mediaItems.id, { onDelete: "cascade" }),
seasonNumber: integer("season_number"),
episodeNumber: integer("episode_number"),
source: grabSourceEnum("source").notNull(),
query: text("query"),
indexersQueried: integer("indexers_queried").notNull().default(0),
resultsFound: integer("results_found").notNull().default(0),
// Releases that survived the quality and size rules. A large gap between
// this and resultsFound means the rules are the problem, not the indexers.
resultsAccepted: integer("results_accepted").notNull().default(0),
// [{ indexer, reason }] for skips, [{ indexer, error }] for failures. The
// difference between "nothing exists" and "nobody answered".
skipped: jsonb("skipped").$type<Array<{ indexer: string; reason: string }>>().default([]),
errors: jsonb("errors").$type<Array<{ indexer: string; error: string }>>().default([]),
grabId: uuid("grab_id").references(() => grabs.id, { onDelete: "set null" }),
decision: text("decision"),
startedAt: timestamp("started_at").notNull().defaultNow(),
durationMs: integer("duration_ms"),
},
(t) => [index("search_runs_media_item_idx").on(t.mediaItemId, t.startedAt)],
);
/**
* Releases a user or the engine has rejected, so they are never offered again.
*
* Without this, an auto search that picks a broken release will pick the same
* broken release on every subsequent run, forever.
*/
export const releaseBlocklist = pgTable(
"release_blocklist",
{
id: uuid("id").primaryKey().defaultRandom(),
// Nullable, because a block is not always about one torrent. A release
// GROUP that turns out to be consistently bad -- desynced audio, wrong
// aspect, mislabelled quality -- needs banning wholesale, and that is only
// possible because the organizer keeps the group legible in the filename.
// Exactly one of infoHash and releaseGroup is meaningful per row.
infoHash: text("info_hash"),
releaseGroup: text("release_group"),
releaseTitle: text("release_title"),
mediaItemId: uuid("media_item_id").references(() => mediaItems.id, { onDelete: "cascade" }),
reason: text("reason"),
permanent: boolean("permanent").notNull().default(false),
createdBy: uuid("created_by").references(() => users.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [uniqueIndex("release_blocklist_info_hash_idx").on(t.infoHash)],
);
+53
View File
@@ -0,0 +1,53 @@
import { pgTable, pgEnum, uuid, text, timestamp, jsonb, bigint } from "drizzle-orm/pg-core";
import { users } from "./users";
// Remote machines that report in so Ampelos knows whether their storage is
// reachable. Backup/archive media lives on hosts that are not always powered
// on, and probing an unmounted path would otherwise look like mass data loss.
// One row per agent, updated in place on every heartbeat.
export const agentHeartbeats = pgTable("agent_heartbeats", {
id: uuid("id").primaryKey().defaultRandom(),
// Stable agent name, e.g. "silenus". Matches storage_tiers.agent_name.
agent: text("agent").notNull().unique(),
hostname: text("hostname"),
version: text("version"),
firstSeenAt: timestamp("first_seen_at").notNull().defaultNow(),
lastSeenAt: timestamp("last_seen_at").notNull().defaultNow(),
// Whatever the agent chooses to report: mounted paths, free space, GPU
// presence, load. Shape is owned by the agent, not by this schema.
details: jsonb("details"),
});
export const corruptFileStatusEnum = pgEnum("corrupt_file_status", [
"pending",
"approved",
"deleted",
"dismissed",
"failed",
]);
// Files an agent found to be unreadable — a container that probes but will not
// decode, or one ffprobe cannot open at all.
//
// Deletion is deliberately split from detection. Ampelos owns the decision and
// records the human approval; the reporting agent performs the removal, because
// Ampelos mounts these tiers read-only so that no scan can ever damage the
// backup. Nothing is removed without an explicit approval recorded here.
export const corruptFiles = pgTable("corrupt_files", {
id: uuid("id").primaryKey().defaultRandom(),
// Absolute path as the reporting agent sees it; that is the path it will be
// asked to delete. Unique so repeated runs update rather than duplicate.
fullPath: text("full_path").notNull().unique(),
agent: text("agent").notNull(),
tier: text("tier"),
reason: text("reason").notNull(),
sizeBytes: bigint("size_bytes", { mode: "bigint" }),
status: corruptFileStatusEnum("status").notNull().default("pending"),
firstDetectedAt: timestamp("first_detected_at").notNull().defaultNow(),
lastDetectedAt: timestamp("last_detected_at").notNull().defaultNow(),
reviewedBy: uuid("reviewed_by").references(() => users.id),
reviewedAt: timestamp("reviewed_at"),
deletedAt: timestamp("deleted_at"),
// Last failure from the agent's delete attempt; cleared on success.
deleteError: text("delete_error"),
});
+47
View File
@@ -0,0 +1,47 @@
import {
pgTable,
pgEnum,
uuid,
text,
integer,
timestamp,
} from "drizzle-orm/pg-core";
import { users } from "./users";
import { mediaItems } from "./media";
export const watchlistSourceEnum = pgEnum("watchlist_source", ["plex", "manual"]);
export const watchingNowScopeEnum = pgEnum("watching_now_scope", ["show", "season"]);
// Items synced from Plex watchlists or added manually.
// Default behavior: archive-tier 720p.
export const watchlistItems = pgTable("watchlist_items", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
source: watchlistSourceEnum("source").notNull().default("plex"),
addedAt: timestamp("added_at").notNull().defaultNow(),
removedAt: timestamp("removed_at"),
});
// Active watching intent. Drives promotion to live storage.
// Each user has a slot quota (5 TV, 10 movies by default).
export const watchingNowItems = pgTable("watching_now_items", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
scope: watchingNowScopeEnum("scope").notNull().default("show"),
// Set when scope is "season".
seasonNumber: integer("season_number"),
slotNumber: integer("slot_number"),
addedAt: timestamp("added_at").notNull().defaultNow(),
removedAt: timestamp("removed_at"),
});
// Admin-managed list of movies that remain live permanently regardless of age.
export const classics = pgTable("classics", {
id: uuid("id").primaryKey().defaultRandom(),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }).unique(),
note: text("note"),
addedBy: uuid("added_by").references(() => users.id),
addedAt: timestamp("added_at").notNull().defaultNow(),
});
+8
View File
@@ -0,0 +1,8 @@
export * from "./users";
export * from "./agents";
export * from "./storage";
export * from "./media";
export * from "./demand";
export * from "./policy";
export * from "./maintenance";
export * from "./acquisition";
+46
View File
@@ -0,0 +1,46 @@
import { pgTable, pgEnum, uuid, text, integer, timestamp, jsonb, index } from "drizzle-orm/pg-core";
export const maintenanceJobStatusEnum = pgEnum("maintenance_job_status", [
"running",
"succeeded",
"failed",
"skipped",
]);
export const maintenanceTriggerEnum = pgEnum("maintenance_trigger", [
"scheduled",
"manual",
]);
// One row per attempt of a maintenance job, written by the ampelos-maintenance
// container. This is the durable job history the system has been missing: until
// now every scan, probe and metadata refresh was an ad-hoc `npm run` whose
// outcome existed only in someone's terminal scrollback.
//
// "skipped" is a first-class outcome, not a failure. Backup and archive live on
// a host that is powered off most of the time, and a scan that declines to run
// because its tier is unreachable is behaving correctly — recording it as failed
// would make a healthy system look broken every single night.
export const maintenanceJobs = pgTable(
"maintenance_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
// Registry key, e.g. "scan:tv:backup" or "policy:classify".
job: text("job").notNull(),
trigger: maintenanceTriggerEnum("trigger").notNull().default("scheduled"),
status: maintenanceJobStatusEnum("status").notNull().default("running"),
startedAt: timestamp("started_at").notNull().defaultNow(),
completedAt: timestamp("completed_at"),
durationMs: integer("duration_ms"),
exitCode: integer("exit_code"),
// Why a run was skipped, or the failure reason.
detail: text("detail"),
// Tail of the job's own output, kept small on purpose.
output: text("output"),
// Structured summary when the job emits one.
summary: jsonb("summary"),
},
(t) => [
index("maintenance_jobs_job_started_idx").on(t.job, t.startedAt),
],
);
+140
View File
@@ -0,0 +1,140 @@
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),
]
);
+117
View File
@@ -0,0 +1,117 @@
import {
pgTable,
pgEnum,
uuid,
text,
integer,
boolean,
timestamp,
jsonb,
numeric,
} from "drizzle-orm/pg-core";
import { storageTierEnum } from "./storage";
import { mediaItems } from "./media";
import { users } from "./users";
export const qualityEnum = pgEnum("quality", ["sd", "720p", "1080p", "4k"]);
export const overrideTypeEnum = pgEnum("override_type", [
"force_live",
"force_archive",
"force_quality",
"force_monitor",
"force_ignore",
"temporary_promotion",
// Purged: deliberately removed and not to come back on its own.
//
// Distinct from force_ignore, which outranks everything including someone
// actively watching. `purge` loses to Watching Now and beats every other
// demand source -- so a purged show can still be pulled back by watching it,
// and returns to purged once that stops, instead of being quietly re-archived
// because it is sitting on somebody's Plex watchlist.
"purge",
]);
export const policyTriggerEnum = pgEnum("policy_trigger", [
"scheduled",
"manual",
"demand_change",
"metadata_refresh",
]);
// How big a release is allowed to be, in MB per minute of runtime.
//
// Per MINUTE because runtime is the great leveller: a three-hour film and a
// half-hour episode are not the same file and never were, and the rule that
// preceded this -- one nominal size times four -- is how a 12GB "1080p" film
// got grabbed.
//
// These live in the database rather than in the indexer's source because they
// are a policy, not a constant: they were derived from percentiles over this
// library's own probed files, and the right numbers change as the library does.
// The indexer keeps the same values compiled in as a fallback for when this
// table cannot be read, so a database outage cannot silently remove the ceiling.
//
// atmosMaxMbPerMinute is the allowance for object-based audio, and only 4K
// carries one. It is measured: on 4K files, probed TrueHD runs about 1.8x
// E-AC-3 and 4x AAC per minute. Null means no allowance -- the ordinary maximum
// applies whatever the release calls itself.
export const releaseSizeRules = pgTable("release_size_rules", {
quality: qualityEnum("quality").primaryKey(),
minMbPerMinute: numeric("min_mb_per_minute", { precision: 6, scale: 1 }).notNull(),
maxMbPerMinute: numeric("max_mb_per_minute", { precision: 6, scale: 1 }).notNull(),
atmosMaxMbPerMinute: numeric("atmos_max_mb_per_minute", { precision: 6, scale: 1 }),
// The remux allowance, and the only ceiling that admits a lossless copy.
//
// Applies to nothing except titles marked timeless (the `classics` table). A
// remux is the disc's own streams repackaged without re-encoding, so it is
// three to four times the size of a good WEB-DL for a difference most
// watching does not resolve -- worth the disk for a film chosen to keep
// forever, and not worth it for anything else. That the allowance is opt-in
// per title is the whole point: as a global ceiling it would simply become
// the size everything arrives at.
timelessMaxMbPerMinute: numeric("timeless_max_mb_per_minute", { precision: 6, scale: 1 }),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Admin-level overrides that win over computed policy.
export const adminOverrides = pgTable("admin_overrides", {
id: uuid("id").primaryKey().defaultRandom(),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
seasonNumber: integer("season_number"),
episodeNumber: integer("episode_number"),
overrideType: overrideTypeEnum("override_type").notNull(),
value: jsonb("value"),
reason: text("reason"),
expiresAt: timestamp("expires_at"),
createdBy: uuid("created_by").references(() => users.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// Audit record of each policy computation run.
export const policyRuns = pgTable("policy_runs", {
id: uuid("id").primaryKey().defaultRandom(),
trigger: policyTriggerEnum("trigger").notNull(),
startedAt: timestamp("started_at").notNull().defaultNow(),
completedAt: timestamp("completed_at"),
itemsEvaluated: integer("items_evaluated"),
itemsChanged: integer("items_changed"),
});
// Computed desired state for each media item (and optionally each season/episode).
// Recomputed on each policy run. reason_codes explain why this state was chosen.
export const desiredStates = pgTable("desired_states", {
id: uuid("id").primaryKey().defaultRandom(),
policyRunId: uuid("policy_run_id").references(() => policyRuns.id),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
seasonNumber: integer("season_number"),
episodeNumber: integer("episode_number"),
wanted: boolean("wanted").notNull().default(false),
storageTier: storageTierEnum("storage_tier"),
minQuality: qualityEnum("min_quality"),
preferredQuality: qualityEnum("preferred_quality"),
monitored: boolean("monitored").notNull().default(false),
// Array of reason codes, e.g. ["watching_now", "currently_airing"]
reasonCodes: text("reason_codes").array().notNull().default([]),
computedAt: timestamp("computed_at").notNull().defaultNow(),
});
+109
View File
@@ -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),
],
);
+56
View File
@@ -0,0 +1,56 @@
import {
pgTable,
uuid,
text,
boolean,
timestamp,
integer,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
displayName: text("display_name").notNull(),
email: text("email").notNull().unique(),
isAdmin: boolean("is_admin").notNull().default(false),
watchingNowTvSlots: integer("watching_now_tv_slots").notNull().default(5),
watchingNowMovieSlots: integer("watching_now_movie_slots").notNull().default(10),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// A linked Plex account.
//
// Linking does two things at once, which is why it is worth a table of its own
// rather than another row in user_identities: it grants the person access to
// the Plex libraries, and it makes their Plex watchlist readable as demand.
// Neither is possible without knowing which Plex account belongs to which user.
//
// NO TOKEN IS STORED. The link is proved by the PIN flow -- the user signs in
// at plex.tv, we exchange the PIN for a token, ask Plex who it belongs to, and
// then throw the token away. Reading their watchlist needs the OWNER's token
// and the account uuid, both of which we already have, so keeping a second
// person's credential would buy nothing and be one more thing to leak.
export const plexAccounts = pgTable("plex_accounts", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
// Plex's numeric account id, and the uuid the community API keys watchlists on.
plexUserId: text("plex_user_id").notNull().unique(),
plexUuid: text("plex_uuid"),
plexUsername: text("plex_username").notNull(),
plexEmail: text("plex_email"),
// When the libraries were shared, and which ones. Recorded so a failed or
// partial share is visible rather than being assumed to have worked.
librariesSharedAt: timestamp("libraries_shared_at"),
sharedSectionIds: text("shared_section_ids").array(),
linkedAt: timestamp("linked_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Maps Authentik (or other OIDC) external identities to local users.
export const userIdentities = pgTable("user_identities", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(), // e.g. "authentik"
externalId: text("external_id").notNull(), // sub claim from OIDC
createdAt: timestamp("created_at").notNull().defaultNow(),
});