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
+44
View File
@@ -0,0 +1,44 @@
"use client";
// The sidebar. A client component for one reason: the current path is the only
// thing here that cannot be known on the server, and without it every nav item
// looks identical no matter which page you are on.
//
// It sets aria-current="page", which is both the accessible signal and what the
// selected styling in globals.css keys off, so the two cannot disagree.
import Link from "next/link";
import { usePathname } from "next/navigation";
export type NavItem = { href: string; label: string };
function isCurrent(pathname: string, href: string) {
// /admin is a prefix of every other admin route, so it only matches exactly.
// Everything else matches its subtree, so /admin/inventory/series/<id> still
// highlights Inventory.
if (href === "/admin") return pathname === "/admin";
return pathname === href || pathname.startsWith(href + "/");
}
export function AdminNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
return (
<ul className="space-y-1 text-sm">
{items.map((item) => {
const current = isCurrent(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={current ? "page" : undefined}
className="admin-nav-button block px-3 py-2 font-medium"
>
{item.label}
</Link>
</li>
);
})}
</ul>
);
}
+585
View File
@@ -0,0 +1,585 @@
import { db } from "@/db/client";
import { sql } from "drizzle-orm";
import Link from "next/link";
import { ManualSearch } from "../manual-search";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
type CalendarRow = {
kind: "episode" | "movie";
media_item_id: string;
title: string;
season_number: number | null;
episode_number: number | null;
episode_title: string | null;
date: string;
has_file: boolean;
grab_status: string | null;
wanted: boolean;
};
// The agenda view's window. Backwards matters as much as forwards: the useful
// question is rarely "what airs next month", it is "what aired last week that I
// still do not have".
const DAYS_BEHIND = 14;
const DAYS_AHEAD = 28;
// Weeks start on Monday, so a weekend reads as the block it is rather than
// being split across two rows.
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
// Beyond this a cell would grow tall enough to break the grid's rhythm, so the
// rest are counted and reachable in the agenda.
const MAX_PER_CELL = 4;
function singleParam(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
// ---------------------------------------------------------------------------
// Dates
//
// All of this is done in UTC on 'YYYY-MM-DD' strings. The database hands back
// days, not instants, and dragging them through a local-timezone Date is how
// an episode ends up on the wrong side of midnight.
const DAY_MS = 86_400_000;
function toYmd(value: number) {
return new Date(value).toISOString().slice(0, 10);
}
function fromYmd(value: string) {
return Date.parse(value + "T00:00:00Z");
}
/** Monday-based index: Monday 0 … Sunday 6. */
function weekdayIndex(value: number) {
return (new Date(value).getUTCDay() + 6) % 7;
}
function addDays(value: number, days: number) {
return value + days * DAY_MS;
}
function monthKey(value: string) {
return value.slice(0, 7);
}
/** Shift a 'YYYY-MM' key by whole months, without touching day-of-month. */
function shiftMonth(key: string, delta: number) {
const [year, month] = key.split("-").map(Number);
const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
return shifted.toISOString().slice(0, 7);
}
function parseMonthParam(value: string | undefined, fallback: string) {
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : fallback;
}
/**
* The full grid for a month: whole weeks, so it starts on the Monday on or
* before the 1st and ends on the Sunday on or after the last day.
*/
function monthGridRange(key: string) {
const [year, month] = key.split("-").map(Number);
const first = Date.UTC(year, month - 1, 1);
const last = Date.UTC(year, month, 0);
return {
from: toYmd(addDays(first, -weekdayIndex(first))),
to: toYmd(addDays(last, 6 - weekdayIndex(last))),
};
}
// ---------------------------------------------------------------------------
// Data
/**
* Everything dated in a range, and what the library has of it.
*
* Presence, grab state and wantedness are scalar subqueries rather than joins.
* A title can easily have several live files, several desired_states rows (a
* series-wide rule plus an episode override) and more than one grab, and any of
* those as a join would silently duplicate calendar entries.
*/
function calendarQuery(from: string, to: string) {
return sql`
with in_window as (
select
'episode'::text as kind,
mi.id as media_item_id,
mi.title,
s.season_number,
e.episode_number,
e.title as episode_title,
-- Formatted in SQL, not in JS. node-postgres hands back a Date object
-- for a date column, and its string form ("Wed Jul 30 2026 ...") sorts
-- and compares as nonsense against an ISO day -- which quietly labelled
-- every past episode as still upcoming.
to_char(e.air_date, 'YYYY-MM-DD') as date,
exists (
select 1 from storage_files sf
join storage_tiers t on t.id = sf.tier_id
where sf.episode_id = e.id and t.tier = 'live' and sf.missing_at is null
) as has_file,
(
select g.status from grabs g
where g.media_item_id = mi.id
and coalesce(g.season_number, s.season_number) = s.season_number
and coalesce(g.episode_number, e.episode_number) = e.episode_number
and g.status in ('queued','downloading','completed','importing')
order by g.created_at desc
limit 1
) as grab_status,
exists (
select 1 from desired_states ds
where ds.media_item_id = mi.id
and (ds.season_number is null or ds.season_number = s.season_number)
and (ds.episode_number is null or ds.episode_number = e.episode_number)
and ds.wanted
-- An episode that has not aired is ALWAYS unmonitored: the
-- classifier sets monitored = false so the fetcher does not chase
-- a release that cannot exist yet. Requiring monitored here read
-- that as "we do not care about it" and emptied the calendar of
-- everything after today -- 123 upcoming episodes, every one of
-- them wanted. Monitored still decides what shows for episodes
-- that HAVE aired, where false really does mean stopped caring.
and (ds.monitored or e.air_date > current_date)
) as wanted
from episodes e
join seasons s on s.id = e.season_id
join series se on se.id = s.series_id
join media_items mi on mi.id = se.id
where e.air_date between ${from}::date and ${to}::date
union all
select
'movie'::text,
mi.id,
mi.title,
null::int,
null::int,
null::text,
to_char(m.release_date, 'YYYY-MM-DD'),
exists (
select 1 from storage_files sf
join storage_tiers t on t.id = sf.tier_id
where sf.media_item_id = mi.id and t.tier = 'live' and sf.missing_at is null
),
(
select g.status from grabs g
where g.media_item_id = mi.id
and g.status in ('queued','downloading','completed','importing')
order by g.created_at desc
limit 1
),
exists (
select 1 from desired_states ds
where ds.media_item_id = mi.id and ds.wanted
-- Same rule for a film that is not out yet.
and (ds.monitored or m.release_date > current_date)
)
from movies m
join media_items mi on mi.id = m.id
where m.release_date between ${from}::date and ${to}::date
)
select * from in_window
order by date asc, title asc, season_number asc, episode_number asc
`;
}
// ---------------------------------------------------------------------------
// Presentation
// "Missing" is only alarming once the thing has actually aired, which is why
// the state is decided against today rather than from presence alone.
function stateOf(row: CalendarRow, today: string) {
if (row.has_file) return "have" as const;
if (row.grab_status) return "grabbing" as const;
if (row.date > today) return "upcoming" as const;
return "missing" as const;
}
type EntryState = ReturnType<typeof stateOf>;
const STATE_BADGE: Record<EntryState, string> = {
have: "border-admin-good text-admin-good",
grabbing: "border-admin-accent text-admin-accent",
upcoming: "border-admin-line text-admin-muted",
missing: "border-admin-warn text-admin-warn",
};
// In the grid there is no room for a badge, so state is carried by a colour bar
// down the leading edge of each chip.
const STATE_BAR: Record<EntryState, string> = {
have: "border-l-2 border-admin-good",
grabbing: "border-l-2 border-admin-accent",
upcoming: "border-l-2 border-admin-line",
missing: "border-l-2 border-admin-warn",
};
const STATE_LABEL: Record<EntryState, string> = {
have: "On live",
grabbing: "Downloading",
upcoming: "Expected",
missing: "Missing",
};
function entryCode(row: CalendarRow) {
return row.kind === "episode"
? "S" + String(row.season_number ?? 0).padStart(2, "0") +
"E" + String(row.episode_number ?? 0).padStart(2, "0")
: "Film";
}
function entryHref(row: CalendarRow) {
return row.kind === "episode"
? "/admin/inventory/series/" + row.media_item_id
: "/admin/inventory?q=" + encodeURIComponent(row.title);
}
function dayHeading(date: string, today: string) {
const label = new Date(date + "T00:00:00Z").toLocaleDateString("en-GB", {
weekday: "long", day: "numeric", month: "long", timeZone: "UTC",
});
return date === today ? label + " — today" : label;
}
function monthLabel(key: string) {
return new Date(key + "-01T00:00:00Z").toLocaleDateString("en-GB", {
month: "long", year: "numeric", timeZone: "UTC",
});
}
function hrefFor(params: { view?: string; month?: string; filter?: string }) {
const search = new URLSearchParams();
if (params.view && params.view !== "month") search.set("view", params.view);
if (params.month) search.set("month", params.month);
if (params.filter === "all") search.set("filter", "all");
const value = search.toString();
return value ? "/admin/calendar?" + value : "/admin/calendar";
}
// ---------------------------------------------------------------------------
export default async function AdminCalendarPage({ searchParams }: PageProps) {
const resolved = (await searchParams) ?? {};
const monitoredOnly = singleParam(resolved.filter) !== "all";
const view = singleParam(resolved.view) === "agenda" ? "agenda" : "month";
// Today comes from the database's clock, not the server's, so the "today"
// marker agrees with the air dates the same database supplied.
const { rows: [{ today }] } = await db.execute<{ today: string }>(
sql`select to_char(current_date, 'YYYY-MM-DD') as today`,
);
const month = parseMonthParam(singleParam(resolved.month), monthKey(today));
const range = view === "month"
? monthGridRange(month)
: {
from: toYmd(addDays(fromYmd(today), -DAYS_BEHIND)),
to: toYmd(addDays(fromYmd(today), DAYS_AHEAD)),
};
const { rows } = await db.execute<CalendarRow>(calendarQuery(range.from, range.to));
// "Monitored" keeps anything we want, already hold, or are fetching. A title
// that is present but no longer monitored still belongs on the calendar --
// hiding it would make the library look emptier than it is.
const visible = monitoredOnly
? rows.filter((row) => row.wanted || row.has_file || row.grab_status)
: rows;
const byDay = new Map<string, CalendarRow[]>();
for (const row of visible) {
const list = byDay.get(row.date);
if (list) list.push(row);
else byDay.set(row.date, [row]);
}
const missingCount = visible.filter((row) => stateOf(row, today) === "missing").length;
const upcomingCount = visible.filter((row) => stateOf(row, today) === "upcoming").length;
const filter = monitoredOnly ? "monitored" : "all";
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Schedule</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Release Calendar</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
When episodes and films are due, and what the library actually holds of each.{" "}
{upcomingCount} still to come,{" "}
<span className={missingCount ? "text-admin-warn" : undefined}>
{missingCount} aired and missing
</span>
{view === "month" ? " this month" : " in the next " + DAYS_AHEAD + " days"}.
</p>
</div>
<Link href="/admin/downloads" className="admin-nav-button px-3 py-2 text-sm font-medium">
Downloads
</Link>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">View</span>
{[
{ value: "month", label: "Month", blurb: "a calendar grid, one cell per day" },
{ value: "agenda", label: "Agenda", blurb: "a dated list with search buttons" },
].map((option) => (
<Link
key={option.value}
prefetch={false}
href={hrefFor({ view: option.value, month: option.value === "month" ? month : undefined, filter })}
aria-current={option.value === view ? "true" : undefined}
title={"Show " + option.blurb}
className={
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
(option.value === view ? "" : "text-admin-muted")
}
>
{option.label}
</Link>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">Show</span>
{[
{ value: "monitored", label: "Monitored", blurb: "only what is wanted, held, or downloading" },
{ value: "all", label: "Everything dated", blurb: "every episode and film with a date" },
].map((option) => (
<Link
key={option.value}
prefetch={false}
href={hrefFor({ view, month: view === "month" ? month : undefined, filter: option.value })}
aria-current={option.value === filter ? "true" : undefined}
title={"Show " + option.blurb}
className={
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
(option.value === filter ? "" : "text-admin-muted")
}
>
{option.label}
</Link>
))}
</div>
{/* The legend is what makes the grid readable at all: in a cell there is
no room to spell out a state, so the colour bar has to be decodable. */}
<div className="flex flex-wrap items-center gap-3 text-xs text-admin-muted">
{(Object.keys(STATE_LABEL) as EntryState[]).map((state) => (
<span key={state} className="flex items-center gap-1.5">
<span className={"inline-block h-3 w-0 " + STATE_BAR[state]} aria-hidden />
{STATE_LABEL[state]}
</span>
))}
</div>
</div>
{view === "month" ? (
<MonthGrid month={month} today={today} byDay={byDay} filter={filter} />
) : (
<Agenda byDay={byDay} today={today} />
)}
</div>
);
}
// ---------------------------------------------------------------------------
function MonthGrid({
month,
today,
byDay,
filter,
}: {
month: string;
today: string;
byDay: Map<string, CalendarRow[]>;
filter: string;
}) {
const { from, to } = monthGridRange(month);
const start = fromYmd(from);
const cellCount = Math.round((fromYmd(to) - start) / DAY_MS) + 1;
const days = Array.from({ length: cellCount }, (_, index) => toYmd(addDays(start, index)));
return (
<section className="admin-panel overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-5 py-3">
<h2 className="font-serif text-xl font-semibold">{monthLabel(month)}</h2>
<div className="flex items-center gap-2 text-xs">
<Link prefetch={false} href={hrefFor({ month: shiftMonth(month, -1), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
Previous
</Link>
<Link prefetch={false} href={hrefFor({ month: monthKey(today), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
Today
</Link>
<Link prefetch={false} href={hrefFor({ month: shiftMonth(month, 1), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
Next
</Link>
</div>
</div>
{/* Scrolls rather than squeezing: seven readable columns matter more than
fitting a narrow window, and the page body must not scroll sideways. */}
<div className="overflow-x-auto">
<div className="min-w-[56rem]">
<div className="grid grid-cols-7 border-b border-admin-line bg-admin-subpanel">
{WEEKDAYS.map((day) => (
<div key={day} className="px-2 py-2 text-center text-[10px] font-semibold uppercase tracking-[0.18em] text-admin-muted">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{days.map((date, index) => {
const entries = byDay.get(date) ?? [];
const inMonth = monthKey(date) === month;
const isToday = date === today;
return (
<div
key={date}
className={
"min-h-28 border-b border-r border-admin-line p-1.5 " +
// The last column has the panel edge; the last row has the
// panel bottom. Both would otherwise double up.
(index % 7 === 6 ? "border-r-0 " : "") +
(inMonth ? "" : "opacity-45 ") +
(isToday ? "bg-admin-missing" : "")
}
>
<div className="mb-1 flex items-baseline justify-between">
<span className={"text-xs " + (isToday ? "font-bold text-admin-accent" : "text-admin-muted")}>
{Number(date.slice(8, 10))}
</span>
{entries.length > MAX_PER_CELL ? (
<span className="text-[10px] text-admin-muted">{entries.length}</span>
) : null}
</div>
<div className="space-y-1">
{entries.slice(0, MAX_PER_CELL).map((row) => {
const state = stateOf(row, today);
return (
<Link
key={row.kind + row.media_item_id + entryCode(row)}
href={entryHref(row)}
title={row.title + " " + entryCode(row) + " — " + STATE_LABEL[state] +
(row.episode_title ? "\n" + row.episode_title : "")}
className={
"block rounded-sm bg-admin-subpanel px-1.5 py-1 leading-tight hover:bg-[#20363a] " +
STATE_BAR[state]
}
>
<span className="block truncate text-[11px] text-admin-text">{row.title}</span>
<span className="block truncate text-[10px] text-admin-muted">{entryCode(row)}</span>
</Link>
);
})}
{entries.length > MAX_PER_CELL ? (
<Link
href={hrefFor({ view: "agenda", filter })}
className="block px-1.5 text-[10px] text-admin-accent hover:underline"
>
+{entries.length - MAX_PER_CELL} more
</Link>
) : null}
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
);
}
// ---------------------------------------------------------------------------
/**
* The dated list. This is the view that can act: a grid cell has no room for a
* search panel, so grabbing something lives here.
*/
function Agenda({ byDay, today }: { byDay: Map<string, CalendarRow[]>; today: string }) {
const days = [...byDay.entries()];
if (!days.length) {
return (
<section className="admin-panel">
<p className="p-5 text-sm text-admin-muted">
Nothing is dated in this window. Try Everything dated titles that are not monitored are hidden.
</p>
</section>
);
}
return (
<section className="admin-panel overflow-hidden">
<div className="divide-y divide-admin-line">
{days.map(([date, entries]) => (
<div key={date} className={date === today ? "bg-admin-subpanel" : ""}>
<div className="flex items-baseline justify-between border-b border-admin-line px-5 py-2">
<h2 className={"font-serif text-lg font-semibold " + (date === today ? "text-admin-accent" : "")}>
{dayHeading(date, today)}
</h2>
<span className="text-xs text-admin-muted">{entries.length} scheduled</span>
</div>
<div className="divide-y divide-admin-line">
{entries.map((row) => {
const state = stateOf(row, today);
const code = entryCode(row);
return (
<div
key={row.kind + row.media_item_id + code}
className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 text-sm"
>
<span className="w-14 shrink-0 font-mono text-xs text-admin-muted">{code}</span>
<Link href={entryHref(row)} className="font-medium text-admin-text hover:text-admin-accent">
{row.title}
</Link>
{row.episode_title ? (
<span className="min-w-0 truncate text-admin-muted">{row.episode_title}</span>
) : null}
<span className={"ml-auto rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide " + STATE_BADGE[state]}>
{STATE_LABEL[state]}
</span>
{/* Searching for something unaired would ask indexers for a
release that does not exist, so the button only appears
once there is something to find. */}
{state === "missing" ? (
<div className="w-full">
<ManualSearch
mediaItemId={row.media_item_id}
seasonNumber={row.season_number}
episodeNumber={row.episode_number}
label={row.title + " " + code}
compact
/>
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
</div>
</section>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/db/client";
import { corruptFiles } from "@/db/schema";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireAdmin() {
const session = await auth();
if (!session) redirect("/login");
if (!session.user.isAdmin) redirect("/");
return session;
}
// Approving does not delete anything here. It records the human decision; the
// reporting agent picks the row up and performs the removal, because Ampelos
// mounts these tiers read-only on purpose.
export async function approveCorruptFile(formData: FormData) {
const session = await requireAdmin();
const id = String(formData.get("id") ?? "");
if (!id) return;
await db
.update(corruptFiles)
.set({ status: "approved", reviewedBy: session.user.id, reviewedAt: new Date() })
.where(and(eq(corruptFiles.id, id), inArray(corruptFiles.status, ["pending", "dismissed"])));
revalidatePath("/admin/corrupt");
}
export async function dismissCorruptFile(formData: FormData) {
const session = await requireAdmin();
const id = String(formData.get("id") ?? "");
if (!id) return;
// Dismissed rows are not re-flagged by later agent reports, so a file judged
// fine stays quiet instead of reappearing every run.
await db
.update(corruptFiles)
.set({ status: "dismissed", reviewedBy: session.user.id, reviewedAt: new Date() })
.where(and(eq(corruptFiles.id, id), inArray(corruptFiles.status, ["pending", "approved", "failed"])));
revalidatePath("/admin/corrupt");
}
export async function approveAllPending() {
const session = await requireAdmin();
// Deliberately scoped to rows that are pending right now. Anything an agent
// reports after this click still needs its own approval.
await db
.update(corruptFiles)
.set({ status: "approved", reviewedBy: session.user.id, reviewedAt: new Date() })
.where(eq(corruptFiles.status, "pending"));
revalidatePath("/admin/corrupt");
}
export async function retryFailedDeletion(formData: FormData) {
await requireAdmin();
const id = String(formData.get("id") ?? "");
if (!id) return;
await db
.update(corruptFiles)
.set({ status: "approved", deleteError: null })
.where(and(eq(corruptFiles.id, id), eq(corruptFiles.status, "failed")));
revalidatePath("/admin/corrupt");
}
+165
View File
@@ -0,0 +1,165 @@
import { db } from "@/db/client";
import { corruptFiles, users } from "@/db/schema";
import { desc, eq, sql } from "drizzle-orm";
import { approveAllPending, approveCorruptFile, dismissCorruptFile, retryFailedDeletion } from "./actions";
export const dynamic = "force-dynamic";
function formatBytes(value: bigint | null) {
const bytes = Number(value ?? 0);
if (!Number.isFinite(bytes) || bytes <= 0) return "unknown";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}
function formatDate(value: Date | null) {
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "—";
}
const STATUS_STYLE: Record<string, string> = {
pending: "border-admin-warn text-admin-warn",
approved: "border-admin-accent text-admin-accent",
deleted: "border-admin-line text-admin-muted",
dismissed: "border-admin-line text-admin-muted",
failed: "border-admin-warn text-admin-warn",
};
export default async function AdminCorruptPage() {
const rows = await db
.select({
id: corruptFiles.id,
fullPath: corruptFiles.fullPath,
agent: corruptFiles.agent,
tier: corruptFiles.tier,
reason: corruptFiles.reason,
sizeBytes: corruptFiles.sizeBytes,
status: corruptFiles.status,
firstDetectedAt: corruptFiles.firstDetectedAt,
lastDetectedAt: corruptFiles.lastDetectedAt,
reviewedAt: corruptFiles.reviewedAt,
deletedAt: corruptFiles.deletedAt,
deleteError: corruptFiles.deleteError,
reviewerName: users.displayName,
})
.from(corruptFiles)
.leftJoin(users, eq(users.id, corruptFiles.reviewedBy))
.orderBy(desc(corruptFiles.lastDetectedAt))
.limit(500);
const [counts] = await db
.select({
pending: sql<number>`count(*) filter (where ${corruptFiles.status} = 'pending')::int`,
approved: sql<number>`count(*) filter (where ${corruptFiles.status} = 'approved')::int`,
deleted: sql<number>`count(*) filter (where ${corruptFiles.status} = 'deleted')::int`,
failed: sql<number>`count(*) filter (where ${corruptFiles.status} = 'failed')::int`,
reclaimable: sql<string>`coalesce(sum(${corruptFiles.sizeBytes}) filter (where ${corruptFiles.status} in ('pending','approved')), 0)::text`,
})
.from(corruptFiles);
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Integrity</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Corrupt Files</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
Files an agent could not read either the container will not decode, or ffprobe cannot open it at all.
Approving records your decision; the agent that reported the file performs the deletion, because Ampelos
mounts these tiers read-only so no scan can damage the backup. Nothing is removed without an approval here.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{[
{ label: "Awaiting review", value: counts?.pending ?? 0 },
{ label: "Approved, not yet deleted", value: counts?.approved ?? 0 },
{ label: "Deleted", value: counts?.deleted ?? 0 },
{ label: "Delete failed", value: counts?.failed ?? 0 },
{ label: "Space reclaimable", value: formatBytes(BigInt(counts?.reclaimable ?? "0")) },
].map((tile) => (
<div key={tile.label} className="admin-panel p-4">
<p className="text-xs text-admin-muted">{tile.label}</p>
<p className="mt-1 text-2xl font-semibold">{tile.value}</p>
</div>
))}
</div>
{counts?.pending ? (
<form action={approveAllPending} className="admin-panel flex flex-wrap items-center gap-3 p-4">
<p className="text-sm text-admin-muted">
Approve all {counts.pending} files currently awaiting review. Anything reported after this still needs its own approval.
</p>
<button type="submit" className="admin-nav-button px-4 py-2 text-sm font-medium">
Approve all pending
</button>
</form>
) : null}
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Reported files</h2>
</div>
<div className="divide-y divide-admin-line">
{rows.length ? rows.map((row) => (
<div key={row.id} className="grid gap-3 px-5 py-4 xl:grid-cols-[1fr_auto]">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={"rounded-md border px-2 py-0.5 text-xs font-semibold uppercase " + (STATUS_STYLE[row.status] ?? "border-admin-line text-admin-muted")}>
{row.status}
</span>
<span className="text-xs text-admin-muted">{row.agent}{row.tier ? ` · ${row.tier}` : ""}</span>
<span className="text-xs text-admin-muted">{formatBytes(row.sizeBytes)}</span>
</div>
<p className="mt-2 break-all text-sm">{row.fullPath}</p>
<p className="mt-1 break-all text-xs text-admin-muted">{row.reason}</p>
<p className="mt-1 text-xs text-admin-muted">
first seen {formatDate(row.firstDetectedAt)} · last seen {formatDate(row.lastDetectedAt)}
{row.reviewedAt ? ` · reviewed ${formatDate(row.reviewedAt)}${row.reviewerName ? " by " + row.reviewerName : ""}` : ""}
{row.deletedAt ? ` · deleted ${formatDate(row.deletedAt)}` : ""}
</p>
{row.deleteError ? (
<p className="mt-1 break-all text-xs text-admin-warn">delete failed: {row.deleteError}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-start gap-2">
{row.status === "pending" ? (
<>
<form action={approveCorruptFile}>
<input type="hidden" name="id" value={row.id} />
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Approve deletion</button>
</form>
<form action={dismissCorruptFile}>
<input type="hidden" name="id" value={row.id} />
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Keep</button>
</form>
</>
) : null}
{row.status === "approved" ? (
<form action={dismissCorruptFile}>
<input type="hidden" name="id" value={row.id} />
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Cancel approval</button>
</form>
) : null}
{row.status === "failed" ? (
<form action={retryFailedDeletion}>
<input type="hidden" name="id" value={row.id} />
<button type="submit" className="admin-nav-button px-3 py-1.5 text-xs font-medium">Retry deletion</button>
</form>
) : null}
</div>
</div>
)) : (
<p className="p-5 text-sm text-admin-muted">No agent has reported an unreadable file. Good.</p>
)}
</div>
</section>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { db } from "@/db/client";
import { mediaItems, users, watchingNowItems, watchlistItems } from "@/db/schema";
import { desc, eq, isNull } from "drizzle-orm";
export const dynamic = "force-dynamic";
function formatDate(value: Date) {
return value.toISOString().slice(0, 16).replace("T", " ");
}
export default async function AdminDemandPage() {
const [watchingNow, watchlist] = await Promise.all([
db
.select({
id: watchingNowItems.id,
title: mediaItems.title,
mediaType: mediaItems.mediaType,
year: mediaItems.year,
displayName: users.displayName,
scope: watchingNowItems.scope,
seasonNumber: watchingNowItems.seasonNumber,
slotNumber: watchingNowItems.slotNumber,
addedAt: watchingNowItems.addedAt,
})
.from(watchingNowItems)
.innerJoin(users, eq(users.id, watchingNowItems.userId))
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
.where(isNull(watchingNowItems.removedAt))
.orderBy(desc(watchingNowItems.addedAt))
.limit(100),
db
.select({
id: watchlistItems.id,
title: mediaItems.title,
mediaType: mediaItems.mediaType,
year: mediaItems.year,
displayName: users.displayName,
source: watchlistItems.source,
addedAt: watchlistItems.addedAt,
})
.from(watchlistItems)
.innerJoin(users, eq(users.id, watchlistItems.userId))
.innerJoin(mediaItems, eq(mediaItems.id, watchlistItems.mediaItemId))
.where(isNull(watchlistItems.removedAt))
.orderBy(desc(watchlistItems.addedAt))
.limit(100),
]);
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Intent</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Demand</h1>
</div>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Watching Now</h2>
</div>
<div className="divide-y divide-admin-line">
{watchingNow.length ? watchingNow.map((item) => (
<div key={item.id} className="grid gap-3 px-5 py-4 md:grid-cols-[1fr_0.8fr_0.7fr_0.5fr_0.8fr] md:items-center">
<div>
<p className="font-medium">{item.title}{item.year ? " (" + item.year + ")" : ""}</p>
<p className="text-sm text-admin-muted">{item.mediaType === "movie" ? "Movie" : "TV"}</p>
</div>
<p className="text-sm text-admin-muted">{item.displayName}</p>
<p className="text-sm text-admin-muted">{item.scope}{item.seasonNumber ? " season " + item.seasonNumber : ""}</p>
<p className="text-sm text-admin-muted">{item.slotNumber ? "Slot " + item.slotNumber : "No slot"}</p>
<p className="text-sm text-admin-muted">{formatDate(item.addedAt)}</p>
</div>
)) : <p className="p-5 text-sm text-admin-muted">No active Watching Now demand.</p>}
</div>
</section>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Watchlist</h2>
</div>
<div className="divide-y divide-admin-line">
{watchlist.length ? watchlist.map((item) => (
<div key={item.id} className="grid gap-3 px-5 py-4 md:grid-cols-[1fr_0.8fr_0.6fr_0.8fr] md:items-center">
<div>
<p className="font-medium">{item.title}{item.year ? " (" + item.year + ")" : ""}</p>
<p className="text-sm text-admin-muted">{item.mediaType === "movie" ? "Movie" : "TV"}</p>
</div>
<p className="text-sm text-admin-muted">{item.displayName}</p>
<p className="text-sm text-admin-muted">{item.source}</p>
<p className="text-sm text-admin-muted">{formatDate(item.addedAt)}</p>
</div>
)) : <p className="p-5 text-sm text-admin-muted">No active watchlist demand.</p>}
</div>
</section>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use server";
// Server actions for the Downloads page.
//
// Every one of these re-checks the session. Server Actions are reachable by
// direct POST, not only through the buttons on the page, so the layout's admin
// guard is not sufficient protection on its own -- it only governs rendering.
import { auth } from "@/auth";
import { db } from "@/db/client";
import { grabs, releaseBlocklist } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { pauseTorrent, resumeTorrent, removeTorrent } from "@/lib/qbittorrent";
async function requireAdmin() {
const session = await auth();
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
return session;
}
/**
* Only act on torrents Ampelos grabbed.
*
* The page can only render our own grabs, so a hash arriving here that has no
* row is either a stale form or someone posting by hand. Either way it must not
* reach qBittorrent: /Niflheim/Downloads also holds torrents the user added
* themselves, and this endpoint is not going to be the thing that deletes one.
*/
async function requireOwnGrab(infoHash: string) {
const [grab] = await db.select().from(grabs).where(eq(grabs.infoHash, infoHash)).limit(1);
if (!grab) throw new Error("No grab recorded for that torrent");
return grab;
}
export async function pauseAction(formData: FormData) {
await requireAdmin();
const infoHash = String(formData.get("infoHash") ?? "");
await requireOwnGrab(infoHash);
await pauseTorrent(infoHash);
revalidatePath("/admin/downloads");
}
export async function resumeAction(formData: FormData) {
await requireAdmin();
const infoHash = String(formData.get("infoHash") ?? "");
await requireOwnGrab(infoHash);
await resumeTorrent(infoHash);
revalidatePath("/admin/downloads");
}
/**
* Stop seeding and drop the download-side copy.
*
* Safe after import: that copy is a hardlink, so deleting it leaves the library
* entry pointing at the same inode. Before import it is the only copy, and the
* page labels the button accordingly rather than relying on the user to
* remember which state the grab is in.
*/
export async function removeAction(formData: FormData) {
await requireAdmin();
const infoHash = String(formData.get("infoHash") ?? "");
const grab = await requireOwnGrab(infoHash);
const imported = grab.status === "imported";
await removeTorrent(infoHash, true);
await db
.update(grabs)
.set({
// An imported grab has done its job; the seed is simply released early.
// A grab removed before import has been abandoned.
status: imported ? "imported" : "failed",
statusDetail: imported
? "seed released from the dashboard"
: "removed from the dashboard before import",
seedReleasedAt: sql`now() at time zone 'utc'`,
seedReleaseReason: "manual",
updatedAt: sql`now() at time zone 'utc'`,
})
.where(eq(grabs.id, grab.id));
revalidatePath("/admin/downloads");
}
/**
* Refuse this release from now on.
*
* Without it, the next auto search finds the same broken release, scores it the
* same way, and grabs it again -- forever.
*/
export async function blocklistAction(formData: FormData) {
await requireAdmin();
const infoHash = String(formData.get("infoHash") ?? "");
const grab = await requireOwnGrab(infoHash);
await db
.insert(releaseBlocklist)
.values({
infoHash,
releaseTitle: grab.releaseTitle,
mediaItemId: grab.mediaItemId,
reason: "blocked from the dashboard",
})
.onConflictDoNothing();
revalidatePath("/admin/downloads");
}
/**
* Refuse an entire release group.
*
* Possible only because the organizer keeps the group legible in the filename.
* A group that reliably ships desynced audio or mislabelled quality has to be
* refusable wholesale, or every new release from it must be caught by hand.
*/
export async function blockGroupAction(formData: FormData) {
await requireAdmin();
const group = String(formData.get("group") ?? "").trim();
if (!group) throw new Error("No release group to block");
await db
.insert(releaseBlocklist)
.values({
releaseGroup: group.toLowerCase(),
releaseTitle: `group: ${group}`,
reason: "group blocked from the dashboard",
permanent: true,
})
.onConflictDoNothing();
revalidatePath("/admin/downloads");
}
+417
View File
@@ -0,0 +1,417 @@
import Link from "next/link";
import { db } from "@/db/client";
import { sql } from "drizzle-orm";
import {
torrentStates,
transferInfo,
formatBytes,
formatSpeed,
formatEta,
type TorrentState,
} from "@/lib/qbittorrent";
import { pauseAction, resumeAction, removeAction, blocklistAction, blockGroupAction } from "./actions";
export const dynamic = "force-dynamic";
// What qBittorrent shows is a torrent name. What this page shows is what the
// torrent is FOR -- which title, which episode, why this release was chosen and
// where it ended up. That join is the reason this exists rather than an iframe
// of qBittorrent's own UI, which structurally cannot know any of it.
type GrabRow = {
id: string;
info_hash: string;
release_title: string;
status: string;
status_detail: string | null;
progress: number | null;
size_bytes: string | null;
indexer_name: string;
origin: string | null;
score: number | null;
score_reasons: string[] | null;
source: string;
imported_path: string | null;
imported_at: Date | null;
seed_released_at: Date | null;
created_at: Date;
title: string;
media_type: string;
season_number: number | null;
episode_number: number | null;
file_count: number;
};
const STATUS_STYLE: Record<string, string> = {
queued: "border-admin-line text-admin-muted",
downloading: "border-admin-accent text-admin-accent",
completed: "border-admin-accent text-admin-accent",
importing: "border-admin-accent text-admin-accent",
imported: "border-admin-good text-admin-good",
failed: "border-admin-warn text-admin-warn",
orphaned: "border-admin-warn text-admin-warn",
};
function episodeLabel(row: GrabRow) {
if (row.season_number === null) return row.media_type === "movie" ? "Movie" : "Series";
const season = String(row.season_number).padStart(2, "0");
if (row.episode_number === null) return `Season ${season}`;
return `S${season}E${String(row.episode_number).padStart(2, "0")}`;
}
// The group is the last dash-delimited token of a scene name. Extracting it
// here is what makes a one-click group ban possible from this page.
function releaseGroup(title: string): string | null {
return title.match(/-([A-Za-z0-9]+)$/)?.[1] ?? null;
}
// The page answers two questions that do not belong in one list: what is still
// coming, and what is still seeding. Sorted together by date they interleave,
// and the in-flight work -- the only part anyone can act on -- ends up buried.
//
// Every view but `done` hides grabs whose seed has been released. That is the
// end of a grab's life: the file is in the library and qBittorrent holds
// nothing, so the row is a record, not a job. They stay reachable under Done
// rather than being deleted.
const IN_FLIGHT = sql`g.status in ('queued','downloading','completed','importing')`;
const VIEWS = {
active: {
label: "Downloading",
blurb: "queued, downloading, or waiting to be imported",
where: IN_FLIGHT,
// Nearly-finished first. Percentage is what makes one of these worth
// looking at, and a date sort hides it behind whatever was grabbed last.
order: sql`coalesce(g.progress, 0) desc, g.created_at desc`,
empty: "Nothing is downloading. Searches from the Inventory page appear here.",
},
seeding: {
label: "Seeding",
blurb: "imported, still seeding, still holding a qBittorrent slot",
where: sql`g.status = 'imported' and g.seed_released_at is null`,
order: sql`g.imported_at desc nulls last`,
empty: "Nothing is seeding. Imports release their seed as soon as the copy lands.",
},
problems: {
label: "Problems",
blurb: "failed or orphaned, and not yet cleared",
where: sql`g.status in ('failed','orphaned') and g.seed_released_at is null`,
order: sql`g.updated_at desc`,
empty: "Nothing has failed.",
},
done: {
label: "Done",
blurb: "seed released — finished, kept for the record",
where: sql`g.seed_released_at is not null`,
order: sql`g.seed_released_at desc`,
empty: "Nothing has finished yet.",
},
} as const;
type ViewKey = keyof typeof VIEWS;
function isViewKey(value: unknown): value is ViewKey {
return typeof value === "string" && value in VIEWS;
}
type CountRow = {
active: number;
seeding: number;
problems: number;
done: number;
seeding_bytes: string | null;
};
type PageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
export default async function AdminDownloadsPage({ searchParams }: PageProps) {
const resolvedSearchParams = (await searchParams) ?? {};
const requestedView = Array.isArray(resolvedSearchParams.view)
? resolvedSearchParams.view[0]
: resolvedSearchParams.view;
const view: ViewKey = isViewKey(requestedView) ? requestedView : "active";
const [{ rows }, { rows: countRows }] = await Promise.all([
db.execute<GrabRow>(sql`
select g.id, g.info_hash, g.release_title, g.status, g.status_detail, g.progress,
g.size_bytes::text, g.indexer_name, g.origin, g.score, g.score_reasons,
g.source, g.imported_path, g.imported_at, g.seed_released_at, g.created_at,
mi.title, mi.media_type, g.season_number, g.episode_number,
(select count(*)::int from grab_files gf where gf.grab_id = g.id) as file_count
from grabs g
join media_items mi on mi.id = g.media_item_id
where ${VIEWS[view].where}
order by ${VIEWS[view].order}
limit 200`),
// Counted from the same fragments the views filter on, so a tab can never
// promise a number the list then fails to show.
db.execute<CountRow>(sql`
select count(*) filter (where ${VIEWS.active.where})::int as active,
count(*) filter (where ${VIEWS.seeding.where})::int as seeding,
count(*) filter (where ${VIEWS.problems.where})::int as problems,
count(*) filter (where ${VIEWS.done.where})::int as done,
coalesce(sum(g.size_bytes) filter (where ${VIEWS.seeding.where}), 0)::text as seeding_bytes
from grabs g`),
]);
const counts: CountRow = countRows[0] ?? {
active: 0, seeding: 0, problems: 0, done: 0, seeding_bytes: "0",
};
// Ask qBittorrent only about torrents we grabbed. Enumerating the client
// would also surface hand-added torrents, which are deliberately not this
// page's business.
const liveHashes = rows
.filter((row) => row.seed_released_at === null)
.map((row) => row.info_hash);
const [{ states, error: qbtError }, transfer] = await Promise.all([
torrentStates(liveHashes),
transferInfo(),
]);
const reclaimable = Number(counts.seeding_bytes ?? 0);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-serif text-3xl font-semibold">Downloads</h1>
<p className="mt-1 text-sm text-admin-muted">
Torrents Ampelos grabbed, and what each one is for. Anything added by hand in
qBittorrent is not shown and is never touched from here.
</p>
</div>
<dl className="flex gap-6 text-sm">
<div>
<dt className="text-xs uppercase tracking-wider text-admin-muted">Down</dt>
<dd className="font-mono">{transfer ? formatSpeed(transfer.dlSpeed) : "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider text-admin-muted">Up</dt>
<dd className="font-mono">{transfer ? formatSpeed(transfer.upSpeed) : "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider text-admin-muted">Active</dt>
<dd className="font-mono">{counts.active}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider text-admin-muted">Seeding</dt>
<dd className="font-mono">{counts.seeding}</dd>
</div>
<div>
{/* Under copy-mode imports this is real disk, not a hardlink: the
download copy is separate data from the library copy, so every
byte here is reclaimed by releasing the seed. */}
<dt className="text-xs uppercase tracking-wider text-admin-muted">Held by seeds</dt>
<dd className="font-mono">{formatBytes(reclaimable)}</dd>
</div>
</dl>
</header>
<div className="flex flex-wrap items-center gap-2">
{(Object.keys(VIEWS) as ViewKey[]).map((key) => {
const count =
key === "active" ? counts.active
: key === "seeding" ? counts.seeding
: key === "problems" ? counts.problems
: counts.done;
return (
<Link
key={key}
prefetch={false}
href={key === "active" ? "/admin/downloads" : `/admin/downloads?view=${key}`}
aria-current={key === view ? "page" : undefined}
title={VIEWS[key].blurb}
// The selected fill comes from aria-current in globals.css, so
// only the unselected state needs a class here.
className={
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
(key === view ? "" : "text-admin-muted")
}
>
{VIEWS[key].label}
<span className="ml-2 font-mono opacity-70">{count}</span>
</Link>
);
})}
</div>
{qbtError && (
<p className="rounded border border-admin-warn px-4 py-3 text-sm text-admin-warn">
qBittorrent is not reachable ({qbtError}). The records below are from the database;
live progress and the controls will not work until it is back.
</p>
)}
{rows.length === 0 ? (
<p className="rounded border border-admin-line px-4 py-6 text-sm text-admin-muted">
{VIEWS[view].empty}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[64rem] border-collapse text-sm">
<thead>
<tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Release</th>
<th className="py-2 pr-4">State</th>
<th className="py-2 pr-4 text-right">Size</th>
<th className="py-2 pr-4">Source</th>
<th className="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const live: TorrentState | undefined = states.get(row.info_hash.toLowerCase());
const group = releaseGroup(row.release_title);
const progress = live?.progress ?? row.progress ?? 0;
const inFlight = ["queued", "downloading", "completed", "importing"].includes(row.status);
return (
<tr key={row.id} className="border-b border-admin-line/50 align-top">
<td className="py-3 pr-4">
<div className="font-medium">{row.title}</div>
<div className="text-xs text-admin-muted">{episodeLabel(row)}</div>
</td>
<td className="py-3 pr-4">
<div className="font-mono text-xs break-all">{row.release_title}</div>
{row.score !== null && (
// Why this release, in the terms the engine actually used.
// An automatic choice that cannot explain itself is
// indistinguishable from a random one.
<details className="mt-1">
<summary className="cursor-pointer text-xs text-admin-muted">
score {row.score}
</summary>
<div className="mt-1 font-mono text-[11px] text-admin-muted">
{(row.score_reasons ?? []).join(" ")}
</div>
</details>
)}
{row.imported_path && (
<div className="mt-1 font-mono text-[11px] text-admin-good break-all">
{row.imported_path}
{row.file_count > 1 && ` (+${row.file_count - 1} more)`}
</div>
)}
{row.status_detail && (
<div className="mt-1 text-[11px] text-admin-muted">{row.status_detail}</div>
)}
</td>
<td className="py-3 pr-4">
<span
className={`inline-block rounded border px-2 py-0.5 text-[11px] uppercase tracking-wide ${
STATUS_STYLE[row.status] ?? "border-admin-line text-admin-muted"
}`}
>
{row.status}
</span>
{inFlight && (
<div className="mt-1 text-xs text-admin-muted">
{progress}%
{live && !live.isComplete && (
<>
{" · "}
{formatSpeed(live.dlSpeed)}
{" · "}
{formatEta(live.eta)}
</>
)}
</div>
)}
{row.seed_released_at && (
<div className="mt-1 text-[11px] text-admin-muted">seed released</div>
)}
{row.status === "imported" && !row.seed_released_at && live && (
<div className="mt-1 text-[11px] text-admin-muted">
seeding · ratio {live.ratio?.toFixed(2) ?? "—"}
</div>
)}
</td>
<td className="py-3 pr-4 text-right font-mono text-xs">
{formatBytes(row.size_bytes ? Number(row.size_bytes) : live?.sizeBytes ?? null)}
</td>
<td className="py-3 pr-4 text-xs">
<div>{row.indexer_name}</div>
{/* Knaben aggregates other trackers, so its name is not
where the release actually lives. */}
{row.origin && row.origin !== row.indexer_name && (
<div className="text-admin-muted">via {row.origin}</div>
)}
<div className="text-admin-muted">{row.source}</div>
</td>
<td className="py-3 pr-4">
<div className="flex flex-wrap gap-1">
{live && !live.isComplete && (
<form action={live.isPaused ? resumeAction : pauseAction}>
<input type="hidden" name="infoHash" value={row.info_hash} />
<button type="submit" className="admin-nav-button px-2 py-1 text-xs">
{live.isPaused ? "Resume" : "Pause"}
</button>
</form>
)}
{!row.seed_released_at && (
<form action={removeAction}>
<input type="hidden" name="infoHash" value={row.info_hash} />
<button
type="submit"
className="admin-nav-button px-2 py-1 text-xs"
// The label differs before and after import
// because the consequence does. Imports copy
// rather than hardlink, so after import there
// are two real files and this deletes the
// download one; before import there is only one.
title={
row.status === "imported"
? "Stops seeding and deletes the download copy, freeing its disk. The library has its own copy."
: "Deletes the partial download. Nothing has been imported yet, so this discards it."
}
>
{row.status === "imported" ? "Release seed" : "Cancel"}
</button>
</form>
)}
<form action={blocklistAction}>
<input type="hidden" name="infoHash" value={row.info_hash} />
<button
type="submit"
className="admin-nav-button px-2 py-1 text-xs"
title="Never choose this exact release again."
>
Block
</button>
</form>
{group && (
<form action={blockGroupAction}>
<input type="hidden" name="group" value={group} />
<button
type="submit"
className="admin-nav-button px-2 py-1 text-xs"
title={`Never choose any release from ${group} again.`}
>
Block {group}
</button>
</form>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,83 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/db/client";
import { classics, externalIds, watchingNowItems } from "@/db/schema";
import { and, eq, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
function optionalString(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
async function requireAdmin() {
const session = await auth();
if (!session) redirect("/login");
if (!session.user.isAdmin) redirect("/");
return session;
}
export async function addTimelessClassic(formData: FormData) {
const session = await requireAdmin();
const mediaItemId = optionalString(formData.get("mediaItemId"));
const note = optionalString(formData.get("note"));
if (!mediaItemId) return;
await db
.insert(classics)
.values({ mediaItemId, note, addedBy: session.user.id })
.onConflictDoUpdate({
target: classics.mediaItemId,
set: { note, addedBy: session.user.id, addedAt: new Date() },
});
await db
.update(watchingNowItems)
.set({ removedAt: new Date() })
.where(and(eq(watchingNowItems.mediaItemId, mediaItemId), isNull(watchingNowItems.removedAt)));
revalidatePath("/admin/inventory");
revalidatePath("/");
}
/**
* Confirm, or withdraw confirmation, that a title's TMDB link is the right one.
*
* Deliberately a person's act. The link was originally derived by matching a
* folder name against search results, and no automatic pass over those links
* can settle the question -- believing a machine's second guess is how the
* first one went unnoticed. Everything that reads the flag (the renamer, most
* of all) is trusting a human, which is the only reason it is worth trusting.
*/
export async function setTmdbLinkVerified(formData: FormData) {
const session = await requireAdmin();
const mediaItemId = optionalString(formData.get("mediaItemId"));
const verified = formData.get("verified") === "1";
if (!mediaItemId) return;
await db
.update(externalIds)
.set(
verified
? { verifiedAt: new Date(), verifiedBy: session.user.id }
: { verifiedAt: null, verifiedBy: null },
)
.where(and(eq(externalIds.mediaItemId, mediaItemId), eq(externalIds.source, "tmdb")));
revalidatePath("/admin/inventory");
}
export async function removeTimelessClassic(formData: FormData) {
await requireAdmin();
const mediaItemId = optionalString(formData.get("mediaItemId"));
if (!mediaItemId) return;
await db.delete(classics).where(eq(classics.mediaItemId, mediaItemId));
revalidatePath("/admin/inventory");
revalidatePath("/");
}
@@ -0,0 +1,56 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
type BulkAction = "add" | "remove";
function selectedMediaIds() {
return Array.from(document.querySelectorAll<HTMLInputElement>('input[data-inventory-select="true"]:checked'))
.map((input) => input.value)
.filter(Boolean);
}
export function InventoryBulkActions() {
const router = useRouter();
const [message, setMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function run(action: BulkAction) {
const mediaItemIds = selectedMediaIds();
if (!mediaItemIds.length) {
setMessage("Select at least one title first.");
return;
}
setMessage(null);
startTransition(async () => {
const response = await fetch("/api/admin/inventory/classics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action, mediaItemIds }),
});
if (!response.ok) {
setMessage("Bulk update failed.");
return;
}
setMessage(action === "add" ? "Marked selected titles as Timeless." : "Removed Timeless from selected titles.");
router.refresh();
});
}
return (
<div className="admin-panel flex flex-wrap items-center gap-2 p-4">
<p className="mr-2 text-sm text-admin-muted">Bulk</p>
<button disabled={isPending} onClick={() => run("add")} className="admin-nav-button px-3 py-2 text-sm font-medium disabled:opacity-60" type="button">
Mark Timeless
</button>
<button disabled={isPending} onClick={() => run("remove")} className="admin-nav-button px-3 py-2 text-sm font-medium disabled:opacity-60" type="button">
Remove Timeless
</button>
{message ? <p className="basis-full text-xs text-admin-muted">{message}</p> : null}
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
// Live search. Typing rewrites the `q` param so the server re-queries the whole
// table rather than filtering only the rows on the current page — otherwise
// "sou" would miss South Park whenever it sits on a later page.
export function InventorySearch({
kind,
tier,
initialQuery,
placeholder,
label,
}: {
kind: string;
tier: string;
initialQuery: string;
placeholder: string;
label: string;
}) {
const router = useRouter();
const [value, setValue] = useState(initialQuery);
const [isPending, startTransition] = useTransition();
const latest = useRef(initialQuery);
useEffect(() => {
// Only navigate when the debounced value differs from what the URL holds,
// so re-renders coming back from the server do not loop.
if (value === latest.current) return;
const timer = setTimeout(() => {
latest.current = value;
const params = new URLSearchParams();
if (kind !== "movie") params.set("kind", kind);
// Searching must not silently drop the tier filter back to live.
if (tier !== "live") params.set("tier", tier);
if (value.trim()) params.set("q", value.trim());
const queryString = params.toString();
startTransition(() => {
router.replace(queryString ? "/admin/inventory?" + queryString : "/admin/inventory", {
scroll: false,
});
});
}, 250);
return () => clearTimeout(timer);
}, [value, kind, tier, router]);
return (
<div className="admin-panel flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<label className="sr-only" htmlFor="inventory-q">{label}</label>
<input
id="inventory-q"
name="q"
type="search"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={placeholder}
autoComplete="off"
className="min-h-10 flex-1 rounded-md border border-admin-line bg-admin-subpanel px-3 text-sm text-admin-text outline-none focus:border-admin-accent"
/>
<span className={"text-xs " + (isPending ? "text-admin-accent" : "text-admin-muted")}>
{isPending ? "Searching…" : value ? "Filtered" : "All"}
</span>
{value ? (
<button
type="button"
onClick={() => setValue("")}
className="px-3 py-2 text-sm font-medium text-admin-accent"
>
Clear
</button>
) : null}
</div>
);
}
@@ -0,0 +1,491 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { ManualSearch } from "../manual-search";
import { setTmdbLinkVerified } from "./actions";
import { cancelReplacement, requestReplacement } from "./series/replace-actions";
export type InventoryRow = {
id: string;
title: string;
year: number | null;
overview: string | null;
seasonCount: string;
episodeCount: string;
fileCount: string;
totalBytes: string;
qualities: string;
codecs: string;
isClassic: boolean;
// Every tier this title has files on, regardless of the active tier filter.
tiers: { tier: string; files: number }[];
// 0 means nothing here has been read by ffprobe, so quality/codec are
// filename guesses rather than measurements.
probedFiles: number;
// The TMDB id this title is linked to, and whether a person has confirmed
// that link is the right one. Null id means nothing is linked at all.
tmdbId: string | null;
tmdbVerified: boolean;
};
// "ok" mirrored at the same size · "stale" present but a different size ·
// "missing" no backup copy · "n/a" archive, which is not mirrored.
export type BackupState = "ok" | "stale" | "missing" | "n/a";
export type FileEntry = {
id: string;
relativePath: string;
sizeBytes: number;
quality: string | null;
codec: string | null;
width: number | null;
height: number | null;
tier: string | null;
edition: string | null;
probed: boolean;
backup: BackupState;
replaceRequested: boolean;
};
export type EpisodeEntry = {
id: string;
number: number;
title: string | null;
airDate: string | null;
files: FileEntry[];
};
export type SeasonEntry = {
id: string;
number: number;
episodes: EpisodeEntry[];
};
function formatBytes(value: number | string | null) {
const bytes = Number(value ?? 0);
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}
function seasonLabel(seasonNumber: number) {
return seasonNumber === 0 ? "Specials" : "Season " + String(seasonNumber).padStart(2, "0");
}
// Live is the tier that matters most, so it reads as good; archive is merely
// informational; backup missing is what a mirror problem looks like.
const TIER_BADGE: Record<string, string> = {
live: "border-admin-good text-admin-text",
backup: "border-admin-line text-admin-muted",
archive: "border-admin-accent text-admin-accent",
};
// Backup coverage for a whole episode: worst state of its live files wins, so
// a season that is half-mirrored never reads as fully backed up.
function episodeBackupState(files: FileEntry[]): BackupState {
const live = files.filter((file) => file.tier === "live");
if (!live.length) return "n/a";
if (live.some((file) => file.backup === "missing")) return "missing";
if (live.some((file) => file.backup === "stale")) return "stale";
return "ok";
}
function BackupFlair({ state }: { state: BackupState }) {
if (state === "n/a") return null;
if (state === "ok") {
return (
<span title="Mirrored to backup at the same size" className="text-xs font-semibold text-admin-good">
backed up
</span>
);
}
if (state === "stale") {
return (
<span title="A backup copy exists at this path but its size differs from live" className="text-xs font-semibold text-admin-warn">
! backup differs
</span>
);
}
return (
<span title="No backup copy of this file" className="text-xs font-semibold text-admin-warn">
not backed up
</span>
);
}
export function InventoryTable({
kind,
rows,
query,
page,
tier,
link,
expandedId,
seasons,
movieFiles,
}: {
kind: "movie" | "tv";
rows: InventoryRow[];
query: string;
page: number;
tier: string;
link: string;
expandedId: string | null;
seasons: SeasonEntry[];
movieFiles: FileEntry[];
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const columnCount = kind === "tv" ? 11 : 10;
function hrefFor(nextExpanded: string | null) {
const params = new URLSearchParams();
if (kind !== "movie") params.set("kind", kind);
if (tier !== "live") params.set("tier", tier);
if (link !== "any") params.set("md", link);
if (query) params.set("q", query);
if (page > 1) params.set("page", String(page));
if (nextExpanded) params.set("expanded", nextExpanded);
const value = params.toString();
return value ? "/admin/inventory?" + value : "/admin/inventory";
}
function toggleRow(id: string) {
const next = id === expandedId ? null : id;
startTransition(() => router.replace(hrefFor(next), { scroll: false }));
}
return (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-admin-line bg-admin-subpanel text-xs uppercase tracking-[0.18em] text-admin-muted">
<tr>
<th className="w-10 px-3 py-3">Pick</th>
<th className="min-w-72 px-3 py-3">Title</th>
{kind === "tv" ? (
<>
<th className="px-3 py-3">Seasons</th>
<th className="px-3 py-3">Episodes</th>
</>
) : (
<th className="px-3 py-3">Year</th>
)}
<th className="px-3 py-3">Files</th>
<th className="px-3 py-3">Size</th>
<th className="px-3 py-3">Quality</th>
<th className="px-3 py-3">Codec</th>
<th className="px-3 py-3">Tiers</th>
<th className="px-3 py-3">TMDB</th>
<th className="px-3 py-3">State</th>
</tr>
</thead>
{rows.length ? rows.map((item) => {
const isExpanded = item.id === expandedId;
return (
<tbody key={item.id} className={"border-b border-admin-line " + (item.isClassic ? "bg-admin-subpanel shadow-[inset_4px_0_0_rgba(204,177,95,0.95)]" : "")}>
<tr
onClick={() => toggleRow(item.id)}
aria-expanded={isExpanded}
className={"cursor-pointer align-top hover:bg-admin-subpanel " + (isExpanded ? "bg-admin-subpanel" : "")}
>
<td className="px-3 py-3">
{/* Selecting for bulk actions must not toggle the row. */}
<input
data-inventory-select="true"
type="checkbox"
value={item.id}
onClick={(event) => event.stopPropagation()}
className="h-4 w-4 accent-[var(--admin-accent)]"
aria-label={"Select " + item.title}
/>
</td>
<td className="px-3 py-3">
<p className="font-medium text-admin-text">
<span aria-hidden className="mr-2 inline-block w-3 text-admin-muted">{isExpanded ? "▾" : "▸"}</span>
{item.title}
{/* The series page exists and nothing linked to it, so the
only way in was to know the URL. The row itself is the
expander, so this has to be its own target and has to
stop the click reaching the row. */}
{kind === "tv" ? (
<Link
href={`/admin/inventory/series/${item.id}`}
onClick={(event) => event.stopPropagation()}
className="ml-2 text-xs font-normal text-admin-muted underline decoration-dotted underline-offset-2 transition hover:text-admin-accent"
>
full detail
</Link>
) : null}
</p>
<p className="mt-1 line-clamp-1 max-w-2xl pl-5 text-xs text-admin-muted">
{item.overview ?? "No canonical overview stored yet."}
</p>
</td>
{kind === "tv" ? (
<>
<td className="whitespace-nowrap px-3 py-3">{item.seasonCount}</td>
<td className="whitespace-nowrap px-3 py-3">{item.episodeCount}</td>
</>
) : (
<td className="whitespace-nowrap px-3 py-3 text-admin-muted">{item.year ?? "Unknown"}</td>
)}
<td className="whitespace-nowrap px-3 py-3">{item.fileCount}</td>
<td className="whitespace-nowrap px-3 py-3">{formatBytes(item.totalBytes)}</td>
<td className="whitespace-nowrap px-3 py-3 text-admin-muted">
{item.qualities || "Unknown"}
{item.probedFiles === 0 ? (
<span title="No file here has been read by ffprobe — quality is guessed from the filename" className="ml-2 text-xs text-admin-warn">
unverified
</span>
) : null}
</td>
<td className="whitespace-nowrap px-3 py-3 text-admin-muted">{item.codecs || "Unknown"}</td>
<td className="whitespace-nowrap px-3 py-3">
<div className="flex flex-wrap gap-1">
{item.tiers.length ? item.tiers.map((entry) => (
<span
key={entry.tier}
title={entry.files + " file(s) on " + entry.tier}
className={
"rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide " +
(TIER_BADGE[entry.tier] ?? "border-admin-line text-admin-muted")
}
>
{entry.tier} {entry.files}
</span>
)) : <span className="text-xs text-admin-muted"></span>}
</div>
</td>
{/* The button lives inside the row, and the row is a toggle.
Stopping the click here is what keeps confirming a link
from also expanding the record underneath it. */}
<td className="whitespace-nowrap px-3 py-3" onClick={(event) => event.stopPropagation()}>
{item.tmdbId ? (
<form action={setTmdbLinkVerified} className="flex items-center gap-2">
<input type="hidden" name="mediaItemId" value={item.id} />
<input type="hidden" name="verified" value={item.tmdbVerified ? "0" : "1"} />
<a
href={"https://www.themoviedb.org/" + (kind === "tv" ? "tv" : "movie") + "/" + item.tmdbId}
target="_blank"
rel="noreferrer"
title="Open this title on TMDB to check the link"
className="text-xs text-admin-accent underline-offset-2 hover:underline"
>
{item.tmdbId}
</a>
<button
type="submit"
title={item.tmdbVerified ? "Withdraw confirmation of this link" : "Confirm this link is the right title"}
className={
"rounded-md border px-2 py-1 text-xs font-semibold " +
(item.tmdbVerified
? "border-admin-good text-admin-text"
: "border-admin-line text-admin-muted hover:border-admin-accent hover:text-admin-accent")
}
>
{item.tmdbVerified ? "Verified" : "Verify"}
</button>
</form>
) : (
<span title="No TMDB id is linked to this title at all" className="text-xs text-admin-warn">none</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-3">
{item.isClassic
? <span className="rounded-md border border-admin-accent bg-admin-subpanel px-2 py-1 text-xs font-semibold text-admin-accent">Timeless</span>
: <span className="text-admin-muted">Managed</span>}
</td>
</tr>
{isExpanded ? (
<tr>
<td colSpan={columnCount} className="px-3 pb-3">
{isPending ? <p className="px-2 py-3 text-xs text-admin-muted">Loading</p> : null}
{kind === "tv" ? (
<div className="space-y-2">
<div className="flex justify-end">
<Link
href={"/admin/inventory/series/" + item.id}
onClick={(event) => event.stopPropagation()}
className="admin-nav-button px-2 py-1.5 text-xs font-medium"
>
Open full series view
</Link>
</div>
{/* Indent level 1: seasons */}
{seasons.map((season) => {
const missing = season.episodes.filter((episode) => !episode.files.length).length;
const held = season.episodes.filter((episode) => episode.files.length);
const unbacked = held.filter((episode) => episodeBackupState(episode.files) !== "ok" && episodeBackupState(episode.files) !== "n/a").length;
return (
<div key={season.id} className="ml-6 rounded-md border border-admin-line bg-admin-subpanel">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-3 py-2">
<p className="text-sm font-semibold">{seasonLabel(season.number)}</p>
<p className="text-xs text-admin-muted">
{season.episodes.length} episodes
{missing ? <span className="text-admin-warn"> · {missing} missing</span> : null}
{unbacked ? <span className="text-admin-warn"> · {unbacked} not backed up</span> : null}
{held.length && !unbacked ? <span className="text-admin-good"> · fully backed up</span> : null}
</p>
</div>
{/* Indent level 2: episodes */}
<div className="divide-y divide-admin-line">
{season.episodes.map((episode) => {
const hasFile = episode.files.length > 0;
return (
<div key={episode.id} className={"ml-6 px-3 py-2 " + (hasFile ? "" : "bg-admin-missing")}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono text-xs text-admin-muted">
{"E" + String(episode.number).padStart(2, "0")}
</span>
<span className="text-sm">{episode.title ?? "Untitled episode"}</span>
{episode.airDate ? (
<span className="text-xs text-admin-muted">{episode.airDate}</span>
) : null}
{hasFile ? (
<BackupFlair state={episodeBackupState(episode.files)} />
) : (
<span className="text-xs font-semibold uppercase tracking-wide text-admin-muted">
Not in collection
</span>
)}
{/* Offered on every episode, not only the missing ones: the
other reason to search is replacing a copy that is present
but bad. */}
<span className="ml-auto">
<ManualSearch
mediaItemId={item.id}
seasonNumber={season.number}
episodeNumber={episode.number}
label={item.title + " S" + String(season.number).padStart(2, "0") + "E" + String(episode.number).padStart(2, "0")}
compact
/>
</span>
</div>
{episode.files.map((file) => (
<div key={file.id} className="mt-1 flex flex-wrap items-center gap-2 text-xs text-admin-muted">
{file.tier ? (
<span className={"rounded border px-1.5 py-0.5 uppercase " + (TIER_BADGE[file.tier] ?? "border-admin-line")}>
{file.tier}
</span>
) : null}
{file.edition ? (
<span className="rounded border border-admin-accent px-1.5 py-0.5 font-semibold uppercase text-admin-accent">
{file.edition === "bw" ? "B&W" : file.edition}
</span>
) : null}
<span className="font-semibold text-admin-text">{file.quality ?? "Unknown"}</span>
{file.width && file.height ? <span>{file.width}x{file.height}</span> : null}
<span>{file.codec ?? "Unknown"}</span>
{file.probed ? null : (
<span title="Not read by ffprobe — quality and codec are guessed from the filename" className="text-admin-warn">
unverified
</span>
)}
<span>{formatBytes(file.sizeBytes)}</span>
<span className="break-all">{file.relativePath}</span>
{/* Live only: a replacement request
is about the copy being served,
and the reaper never looks at any
other tier. */}
{file.tier === "live" ? (
file.replaceRequested ? (
<form action={cancelReplacement} className="contents">
<input type="hidden" name="fileId" value={file.id} />
<span className="font-semibold text-admin-warn">replacing</span>
<button
type="submit"
className="rounded border border-admin-line px-1.5 py-0.5 transition hover:text-admin-text"
>
cancel
</button>
</form>
) : (
<form action={requestReplacement} className="contents">
<input type="hidden" name="fileId" value={file.id} />
<button
type="submit"
title="Look for a better copy, and delete this one once it arrives"
className="rounded border border-admin-line px-1.5 py-0.5 transition hover:border-admin-accent hover:text-admin-accent"
>
replace
</button>
</form>
)
) : null}
</div>
))}
</div>
);
})}
</div>
</div>
);
})}
{!seasons.length && !isPending ? (
<p className="ml-6 px-3 py-2 text-xs text-admin-muted">No seasons recorded for this series.</p>
) : null}
</div>
) : (
<div className="ml-6 space-y-2">
<div className="flex justify-end">
<ManualSearch mediaItemId={item.id} label={item.title} compact />
</div>
<div className="divide-y divide-admin-line rounded-md border border-admin-line bg-admin-subpanel">
{movieFiles.map((file) => (
<div key={file.id} className="grid gap-2 px-3 py-2 text-xs lg:grid-cols-[5rem_1fr_7rem_6rem_6rem_6rem]">
<span>
{file.tier ? (
<span className={"rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase " + (TIER_BADGE[file.tier] ?? "border-admin-line text-admin-muted")}>
{file.tier}
</span>
) : null}
</span>
<span className="break-all text-admin-muted">{file.relativePath}</span>
<span>{formatBytes(file.sizeBytes)}</span>
<span>{file.quality ?? "Unknown"}{file.edition ? ` (${file.edition === "bw" ? "B&W" : file.edition})` : ""}</span>
<span>{file.width && file.height ? `${file.width}x${file.height}` : "—"}</span>
<span>{file.codec ?? "Unknown"}</span>
</div>
))}
{!movieFiles.length && !isPending ? (
<p className="px-3 py-2 text-xs text-admin-muted">
{tier === "all" ? "No files on any tier." : "No files on the " + tier + " tier."}
</p>
) : null}
</div>
</div>
)}
</td>
</tr>
) : null}
</tbody>
);
}) : (
<tbody>
<tr>
<td colSpan={columnCount} className="p-5 text-sm text-admin-muted">
{kind === "tv" ? "No main television inventory records matched." : "No main movie inventory records matched."}
</td>
</tr>
</tbody>
)}
</table>
</div>
);
}
+482
View File
@@ -0,0 +1,482 @@
import { db } from "@/db/client";
import { classics, episodes, mediaItems, seasons, storageFiles, storageTiers } from "@/db/schema";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import Link from "next/link";
import { InventoryBulkActions } from "./bulk-actions";
import { InventorySearch } from "./inventory-search";
import { InventoryTable, type FileEntry, type SeasonEntry } from "./inventory-table";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
type InventoryKind = "movie" | "tv";
// Which tier's files decide whether a title is listed at all, and what the
// Files/Size/Quality columns describe. Defaults to live so the page keeps
// meaning what it used to mean.
const TIER_FILTERS = [
{ value: "live", label: "Live", blurb: "titles with files on the live tier" },
{ value: "backup", label: "Backup", blurb: "titles with files on the backup mirror" },
{ value: "archive", label: "Archive", blurb: "titles with files on the archive tier" },
{ value: "all", label: "All tiers", blurb: "titles with files anywhere" },
] as const;
type TierFilter = (typeof TIER_FILTERS)[number]["value"];
// Whether the TMDB link behind a title has been checked by a person.
//
// Almost every id here was derived by matching a folder name against TMDB
// search, and that has been wrong in ways nothing downstream could see. This
// filter exists so the checking can be done a few at a time rather than as one
// impossible sitting -- ampelos-agent scripts/check-tmdb-links.mjs ranks which ones deserve
// the attention first.
const LINK_FILTERS = [
{ value: "any", label: "Any", blurb: "every title" },
{ value: "unverified", label: "Unverified", blurb: "titles whose TMDB link nobody has confirmed" },
{ value: "verified", label: "Verified", blurb: "titles whose TMDB link has been confirmed" },
] as const;
type LinkFilter = (typeof LINK_FILTERS)[number]["value"];
const KIND_COPY = {
movie: {
label: "Movies",
heading: "Main Movie Inventory",
mediaType: "movie" as const,
searchLabel: "Search movies",
searchPlaceholder: "Search main movies",
blurb:
"Dense canonical movie inventory backed by live storage files. Click a row to inspect its files, or select rows for bulk policy changes.",
},
tv: {
label: "Television",
heading: "Main Television Inventory",
mediaType: "tv_series" as const,
searchLabel: "Search series",
searchPlaceholder: "Search main series",
blurb:
"Canonical series inventory backed by live storage files and TMDB metadata. Click a row to expand its seasons and episodes; episodes with no file on any tier are highlighted.",
},
} satisfies Record<InventoryKind, unknown>;
function singleParam(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function parseKind(value: string | string[] | undefined): InventoryKind {
return singleParam(value) === "tv" ? "tv" : "movie";
}
function parseTier(value: string | string[] | undefined): TierFilter {
const raw = singleParam(value);
const match = TIER_FILTERS.find((entry) => entry.value === raw);
return match ? match.value : "live";
}
function parseLink(value: string | string[] | undefined): LinkFilter {
const raw = singleParam(value);
const match = LINK_FILTERS.find((entry) => entry.value === raw);
return match ? match.value : "any";
}
function parsePage(value: string | string[] | undefined) {
const page = Number.parseInt(singleParam(value) ?? "1", 10);
return Number.isInteger(page) && page > 0 ? page : 1;
}
function inventoryHref(kind: InventoryKind, query: string, page: number, tier: TierFilter, link: LinkFilter = "any") {
const params = new URLSearchParams();
if (kind !== "movie") params.set("kind", kind);
if (tier !== "live") params.set("tier", tier);
if (link !== "any") params.set("md", link);
if (query) params.set("q", query);
if (page > 1) params.set("page", String(page));
const value = params.toString();
return value ? "/admin/inventory?" + value : "/admin/inventory";
}
export default async function AdminInventoryPage({ searchParams }: PageProps) {
const resolvedSearchParams = (await searchParams) ?? {};
const kind = parseKind(resolvedSearchParams.kind);
const copy = KIND_COPY[kind];
const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
const expandedId = singleParam(resolvedSearchParams.expanded) ?? null;
const tier = parseTier(resolvedSearchParams.tier);
const link = parseLink(resolvedSearchParams.md);
const page = parsePage(resolvedSearchParams.page);
const pageSize = 100;
// "all" means every tier, so it contributes no predicate at all.
const tierFilter = tier === "all" ? undefined : eq(storageTiers.tier, tier);
// Season/episode counts come from the files themselves, so the numbers
// describe what is actually on the selected tier.
const rows = await db
.select({
id: mediaItems.id,
title: mediaItems.title,
year: mediaItems.year,
overview: mediaItems.overview,
seasonCount: sql<string>`count(distinct ${seasons.id})::text`,
episodeCount: sql<string>`count(distinct ${episodes.id})::text`,
fileCount: sql<string>`count(${storageFiles.id})::text`,
totalBytes: sql<string>`coalesce(sum(${storageFiles.sizeBytes}), 0)::text`,
qualities: sql<string>`coalesce(string_agg(distinct ${storageFiles.quality}, ', ' order by ${storageFiles.quality}) filter (where ${storageFiles.quality} is not null), '')`,
codecs: sql<string>`coalesce(string_agg(distinct ${storageFiles.codec}, ', ' order by ${storageFiles.codec}) filter (where ${storageFiles.codec} is not null), '')`,
// Quality/codec are only trustworthy once ffprobe has read the file. Where
// nothing is probed they are filename guesses, and this library's
// filenames are demonstrably wrong — so say so rather than showing a
// guess next to a measurement as if they were peers.
probedFiles: sql<number>`count(*) filter (where ${storageFiles.probedAt} is not null)::int`,
isClassic: sql<boolean>`bool_or(${classics.id} is not null)`,
// Scalar subqueries rather than another join: external_ids has no unique
// constraint per (item, source), and a second tmdb row would silently
// double every count in this query.
tmdbId: sql<string | null>`(select ei.external_id from external_ids ei
where ei.media_item_id = ${mediaItems.id} and ei.source = 'tmdb'
limit 1)`,
tmdbVerified: sql<boolean>`exists (select 1 from external_ids ei
where ei.media_item_id = ${mediaItems.id}
and ei.source = 'tmdb'
and ei.verified_at is not null)`,
})
.from(mediaItems)
.innerJoin(storageFiles, eq(storageFiles.mediaItemId, mediaItems.id))
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.leftJoin(episodes, eq(episodes.id, storageFiles.episodeId))
.leftJoin(seasons, eq(seasons.id, episodes.seasonId))
.leftJoin(classics, eq(classics.mediaItemId, mediaItems.id))
.where(
and(
eq(mediaItems.mediaType, copy.mediaType),
tierFilter,
isNull(storageFiles.missingAt),
query ? ilike(mediaItems.title, "%" + query + "%") : undefined,
// A title with no tmdb row at all counts as unverified: nobody has
// confirmed anything about it, which is exactly what the filter asks.
link === "unverified"
? sql`not exists (select 1 from external_ids ei
where ei.media_item_id = ${mediaItems.id}
and ei.source = 'tmdb'
and ei.verified_at is not null)`
: link === "verified"
? sql`exists (select 1 from external_ids ei
where ei.media_item_id = ${mediaItems.id}
and ei.source = 'tmdb'
and ei.verified_at is not null)`
: undefined,
),
)
.groupBy(mediaItems.id)
.orderBy(desc(sql`bool_or(${classics.id} is not null)`), mediaItems.title)
.limit(pageSize)
.offset((page - 1) * pageSize);
// Deliberately NOT tier-filtered: the point of this column is to show where a
// title lives across every tier even while the table is filtered to one of
// them, so "on archive but not live" is visible at a glance.
const visibleIds = rows.map((row) => row.id);
const tierCountRows = visibleIds.length
? await db
.select({
mediaItemId: storageFiles.mediaItemId,
tier: storageTiers.tier,
files: sql<number>`count(*)::int`,
})
.from(storageFiles)
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.where(and(inArray(storageFiles.mediaItemId, visibleIds), isNull(storageFiles.missingAt)))
.groupBy(storageFiles.mediaItemId, storageTiers.tier)
: [];
const tierCounts = new Map<string, { tier: string; files: number }[]>();
for (const row of tierCountRows) {
if (!row.mediaItemId) continue;
const list = tierCounts.get(row.mediaItemId) ?? [];
list.push({ tier: row.tier, files: row.files });
tierCounts.set(row.mediaItemId, list);
}
const tierOrder = ["live", "backup", "archive"];
const tableRows = rows.map((row) => ({
...row,
tiers: (tierCounts.get(row.id) ?? []).sort(
(a, b) => tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier),
),
}));
const expandedItem = expandedId ? rows.find((row) => row.id === expandedId) : null;
// Television expansion lists every episode TMDB knows about, not just the
// ones with files, so gaps in the collection are visible.
let seasonTree: SeasonEntry[] = [];
let movieFiles: FileEntry[] = [];
if (expandedItem && kind === "tv") {
const treeRows = await db
.select({
seasonId: seasons.id,
seasonNumber: seasons.seasonNumber,
episodeId: episodes.id,
episodeNumber: episodes.episodeNumber,
episodeTitle: episodes.title,
airDate: episodes.airDate,
fileId: storageFiles.id,
relativePath: storageFiles.relativePath,
sizeBytes: storageFiles.sizeBytes,
quality: storageFiles.quality,
codec: storageFiles.codec,
width: storageFiles.width,
height: storageFiles.height,
edition: storageFiles.edition,
replaceRequestedAt: storageFiles.replaceRequestedAt,
probedAt: storageFiles.probedAt,
tier: storageTiers.tier,
})
.from(seasons)
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
.leftJoin(storageFiles, and(eq(storageFiles.episodeId, episodes.id), isNull(storageFiles.missingAt)))
.leftJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.where(eq(seasons.seriesId, expandedItem.id))
.orderBy(desc(seasons.seasonNumber), asc(episodes.episodeNumber), asc(storageFiles.relativePath));
// Backup coverage is judged per file: same relative path AND same byte
// size. A path that exists at a different size is a stale mirror, which is
// worth surfacing separately from "not backed up at all".
const backupRows = await db
.select({ relativePath: storageFiles.relativePath, sizeBytes: storageFiles.sizeBytes })
.from(storageFiles)
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.where(
and(
eq(storageTiers.tier, "backup"),
isNull(storageFiles.missingAt),
eq(storageFiles.mediaItemId, expandedItem.id),
),
);
const backupIndex = new Map(backupRows.map((row) => [row.relativePath, String(row.sizeBytes ?? "")]));
const seasonIndex = new Map<string, SeasonEntry>();
const episodeIndex = new Map<string, SeasonEntry["episodes"][number]>();
for (const row of treeRows) {
let season = seasonIndex.get(row.seasonId);
if (!season) {
season = { id: row.seasonId, number: row.seasonNumber, episodes: [] };
seasonIndex.set(row.seasonId, season);
seasonTree.push(season);
}
let episode = episodeIndex.get(row.episodeId);
if (!episode) {
episode = {
id: row.episodeId,
number: row.episodeNumber,
title: row.episodeTitle,
airDate: row.airDate,
files: [],
};
episodeIndex.set(row.episodeId, episode);
season.episodes.push(episode);
}
// Backup is a mirror of live, not a separate copy anyone would ever play.
// Listing it duplicated every episode with a byte-identical row, and
// because backup is unprobed its quality/codec are filename guesses — so
// the two rows disagreed on files that are the same bytes. Report backup
// as coverage on the live file instead of as a file of its own.
if (row.fileId && row.tier !== "backup") {
const backupSize = backupIndex.get(row.relativePath ?? "");
episode.files.push({
id: row.fileId,
relativePath: row.relativePath ?? "",
// bigint is not serialisable across the server/client boundary.
sizeBytes: Number(row.sizeBytes ?? 0),
quality: row.quality,
codec: row.codec,
width: row.width,
height: row.height,
edition: row.edition,
tier: row.tier,
probed: row.probedAt !== null,
replaceRequested: row.replaceRequestedAt !== null,
// Only live is mirrored to backup; archive is itself the long-term
// copy, so "not backed up" is not a finding there.
backup:
row.tier !== "live"
? "n/a"
: backupSize === undefined
? "missing"
: backupSize === String(row.sizeBytes ?? "")
? "ok"
: "stale",
});
}
}
// Newest season first, Specials last.
seasonTree = seasonTree.sort((a, b) => {
if (a.number === 0) return 1;
if (b.number === 0) return -1;
return b.number - a.number;
});
}
if (expandedItem && kind === "movie") {
const fileRows = await db
.select({
id: storageFiles.id,
relativePath: storageFiles.relativePath,
sizeBytes: storageFiles.sizeBytes,
quality: storageFiles.quality,
codec: storageFiles.codec,
width: storageFiles.width,
height: storageFiles.height,
edition: storageFiles.edition,
replaceRequestedAt: storageFiles.replaceRequestedAt,
probedAt: storageFiles.probedAt,
tier: storageTiers.tier,
})
.from(storageFiles)
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.where(
and(
tierFilter,
isNull(storageFiles.missingAt),
eq(storageFiles.mediaItemId, expandedItem.id),
),
)
.orderBy(asc(storageTiers.tier), asc(storageFiles.relativePath));
movieFiles = fileRows.map((row) => ({
id: row.id,
relativePath: row.relativePath,
sizeBytes: Number(row.sizeBytes ?? 0),
quality: row.quality,
codec: row.codec,
width: row.width,
height: row.height,
edition: row.edition,
tier: row.tier,
probed: row.probedAt !== null,
replaceRequested: row.replaceRequestedAt !== null,
backup: "n/a" as const,
}));
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Inventory</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">{copy.heading}</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">{copy.blurb}</p>
</div>
<Link href="/admin/storage" className="admin-nav-button px-3 py-2 text-sm font-medium">Storage tiers</Link>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-wrap gap-2">
{(Object.keys(KIND_COPY) as InventoryKind[]).map((value) => (
<Link
key={value}
prefetch={false}
href={inventoryHref(value, query, 1, tier, link)}
aria-current={value === kind ? "page" : undefined}
// The selected fill comes from aria-current in globals.css, so
// only the unselected state needs a class here.
className={
"admin-nav-button px-4 py-2 text-sm font-medium " +
(value === kind ? "" : "text-admin-muted")
}
>
{KIND_COPY[value].label}
</Link>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">Tier</span>
{TIER_FILTERS.map((entry) => (
<Link
key={entry.value}
prefetch={false}
href={inventoryHref(kind, query, 1, entry.value, link)}
aria-current={entry.value === tier ? "true" : undefined}
title={"Show " + entry.blurb}
className={
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
(entry.value === tier ? "" : "text-admin-muted")
}
>
{entry.label}
</Link>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-[0.18em] text-admin-muted">TMDB link</span>
{LINK_FILTERS.map((entry) => (
<Link
key={entry.value}
prefetch={false}
href={inventoryHref(kind, query, 1, tier, entry.value)}
aria-current={entry.value === link ? "true" : undefined}
title={"Show " + entry.blurb}
className={
"admin-nav-button px-3 py-1.5 text-xs font-medium " +
(entry.value === link ? "" : "text-admin-muted")
}
>
{entry.label}
</Link>
))}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[1fr_auto]">
<InventorySearch
key={kind + ":" + tier}
kind={kind}
tier={tier}
initialQuery={query}
label={copy.searchLabel}
placeholder={copy.searchPlaceholder}
/>
<InventoryBulkActions />
</div>
<section className="admin-panel overflow-hidden">
<div className="flex flex-col gap-2 border-b border-admin-line px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-serif text-xl font-semibold">{copy.label}</h2>
<p className="mt-1 text-sm text-admin-muted">
Showing page {page} with {rows.length} records {TIER_FILTERS.find((entry) => entry.value === tier)?.blurb}.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-admin-muted">
{page > 1 ? <Link prefetch={false} href={inventoryHref(kind, query, page - 1, tier, link)} className="text-admin-accent">Previous</Link> : <span>Previous</span>}
<span>Page {page}</span>
{rows.length === pageSize ? <Link prefetch={false} href={inventoryHref(kind, query, page + 1, tier, link)} className="text-admin-accent">Next</Link> : <span>Next</span>}
</div>
</div>
<InventoryTable
kind={kind}
rows={tableRows}
query={query}
page={page}
tier={tier}
link={link}
expandedId={expandedItem?.id ?? null}
seasons={seasonTree}
movieFiles={movieFiles}
/>
</section>
</div>
);
}
@@ -0,0 +1,359 @@
import { db } from "@/db/client";
import { classics, episodes, mediaItems, seasons, storageFiles, storageTiers } from "@/db/schema";
import { cancelReplacement, requestReplacement } from "../replace-actions";
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { addTimelessClassic, removeTimelessClassic } from "../../actions";
export const dynamic = "force-dynamic";
type PageProps = {
params: Promise<{ id: string }>;
};
function formatBytes(value: string | number | bigint | null) {
const bytes = typeof value === "bigint" ? Number(value) : Number(value ?? 0);
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}
function formatDuration(seconds: number | null) {
if (!seconds || seconds <= 0) return null;
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function seasonLabel(seasonNumber: number) {
return seasonNumber === 0 ? "Specials" : "Season " + String(seasonNumber).padStart(2, "0");
}
const TIER_STYLES: Record<string, string> = {
live: "border-admin-good text-admin-good",
backup: "border-admin-line text-admin-muted",
archive: "border-admin-accent text-admin-accent",
};
function TierBadge({ tier }: { tier: string | null }) {
if (!tier) return null;
return (
<span className={"rounded-md border px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide " + (TIER_STYLES[tier] ?? "border-admin-line text-admin-muted")}>
{tier}
</span>
);
}
export default async function AdminSeriesPage({ params }: PageProps) {
const { id } = await params;
const [item] = await db
.select({
id: mediaItems.id,
title: mediaItems.title,
year: mediaItems.year,
overview: mediaItems.overview,
isClassic: sql<boolean>`${classics.id} is not null`,
classicNote: classics.note,
})
.from(mediaItems)
.leftJoin(classics, eq(classics.mediaItemId, mediaItems.id))
.where(and(eq(mediaItems.id, id), eq(mediaItems.mediaType, "tv_series")))
.limit(1);
if (!item) notFound();
// Left join the files so an episode with no copy on any tier still renders.
const rows = await db
.select({
seasonId: seasons.id,
seasonNumber: seasons.seasonNumber,
episodeId: episodes.id,
episodeNumber: episodes.episodeNumber,
episodeTitle: episodes.title,
fileId: storageFiles.id,
relativePath: storageFiles.relativePath,
sizeBytes: storageFiles.sizeBytes,
quality: storageFiles.quality,
replaceRequestedAt: storageFiles.replaceRequestedAt,
codec: storageFiles.codec,
width: storageFiles.width,
height: storageFiles.height,
edition: storageFiles.edition,
audioCodec: storageFiles.audioCodec,
audioChannels: storageFiles.audioChannels,
durationSeconds: storageFiles.durationSeconds,
probedAt: storageFiles.probedAt,
tier: storageTiers.tier,
})
.from(seasons)
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
.leftJoin(storageFiles, and(eq(storageFiles.episodeId, episodes.id), isNull(storageFiles.missingAt)))
.leftJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.where(eq(seasons.seriesId, item.id))
.orderBy(desc(seasons.seasonNumber), asc(episodes.episodeNumber), asc(storageFiles.relativePath));
// Backup mirrors live, so a backup row is byte-identical to a live one at the
// same path. Listing it doubled every episode — and because backup is not
// probed, its quality/codec are filename guesses that disagreed with live's
// measured values on files that are the same bytes. Report it as coverage.
const backupRows = await db
.select({ relativePath: storageFiles.relativePath, sizeBytes: storageFiles.sizeBytes })
.from(storageFiles)
.innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
.where(
and(
eq(storageTiers.tier, "backup"),
isNull(storageFiles.missingAt),
eq(storageFiles.mediaItemId, item.id),
),
);
const backupIndex = new Map(backupRows.map((row) => [row.relativePath, String(row.sizeBytes ?? "")]));
type Row = (typeof rows)[number];
type Episode = { id: string; number: number; title: string | null; files: Row[] };
type Season = { id: string; number: number; episodes: Episode[] };
const seasonList: Season[] = [];
const seasonIndex = new Map<string, Season>();
const episodeIndex = new Map<string, Episode>();
for (const row of rows) {
let season = seasonIndex.get(row.seasonId);
if (!season) {
season = { id: row.seasonId, number: row.seasonNumber, episodes: [] };
seasonIndex.set(row.seasonId, season);
seasonList.push(season);
}
let episode = episodeIndex.get(row.episodeId);
if (!episode) {
episode = { id: row.episodeId, number: row.episodeNumber, title: row.episodeTitle, files: [] };
episodeIndex.set(row.episodeId, episode);
season.episodes.push(episode);
}
if (row.fileId && row.tier !== "backup") episode.files.push(row);
}
// "ok" mirrored at the same size · "stale" present at a different size ·
// "missing" no backup copy · "n/a" archive, which is not mirrored to backup.
function backupState(files: Row[]): "ok" | "stale" | "missing" | "n/a" {
const live = files.filter((file) => file.tier === "live");
if (!live.length) return "n/a";
let worst: "ok" | "stale" | "missing" = "ok";
for (const file of live) {
const mirrored = backupIndex.get(file.relativePath ?? "");
if (mirrored === undefined) return "missing";
if (mirrored !== String(file.sizeBytes ?? "")) worst = "stale";
}
return worst;
}
// Newest season first, but Specials belong at the bottom rather than the top.
seasonList.sort((a, b) => {
if (a.number === 0) return 1;
if (b.number === 0) return -1;
return b.number - a.number;
});
const totalEpisodes = seasonList.reduce((sum, season) => sum + season.episodes.length, 0);
const totalFiles = rows.filter((row) => row.fileId).length;
const totalBytes = rows.reduce((sum, row) => sum + (row.fileId ? Number(row.sizeBytes ?? 0) : 0), 0);
const missingEpisodes = seasonList.reduce(
(sum, season) => sum + season.episodes.filter((episode) => !episode.files.length).length,
0,
);
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<Link href="/admin/inventory?kind=tv" className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">
&larr; Television inventory
</Link>
<h1 className="mt-2 font-serif text-3xl font-semibold">{item.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-md border border-admin-line bg-admin-subpanel px-2 py-1 text-admin-muted">
{seasonList.length} seasons
</span>
<span className="rounded-md border border-admin-line bg-admin-subpanel px-2 py-1 text-admin-muted">
{totalEpisodes} episodes
</span>
<span className="rounded-md border border-admin-line bg-admin-subpanel px-2 py-1 text-admin-muted">
{totalFiles} files / {formatBytes(totalBytes)}
</span>
{missingEpisodes ? (
<span className="rounded-md border border-admin-warn bg-admin-subpanel px-2 py-1 font-semibold text-admin-warn">
{missingEpisodes} without a file
</span>
) : null}
{item.isClassic ? (
<span className="rounded-md border border-admin-accent bg-admin-subpanel px-2 py-1 font-semibold text-admin-accent">
Timeless Classic
</span>
) : null}
</div>
<p className="mt-3 max-w-4xl text-sm text-admin-muted">{item.overview ?? "No canonical overview stored yet."}</p>
</div>
<div className="w-full shrink-0 rounded-md border border-admin-line bg-admin-subpanel p-4 lg:w-72">
{item.isClassic ? (
<form action={removeTimelessClassic} className="space-y-3">
<input type="hidden" name="mediaItemId" value={item.id} />
<p className="text-sm text-admin-muted">Locked in main and blocked from Watch Now.</p>
{item.classicNote ? <p className="text-sm">{item.classicNote}</p> : null}
<button type="submit" className="admin-nav-button w-full px-3 py-2 text-sm font-medium">Remove Classic Lock</button>
</form>
) : (
<form action={addTimelessClassic} className="space-y-3">
<label className="block text-sm font-medium" htmlFor="series-note">Timeless note</label>
<input type="hidden" name="mediaItemId" value={item.id} />
<textarea id="series-note" name="note" rows={3} className="w-full rounded-md border border-admin-line bg-admin-subpanel p-2 text-sm text-admin-text outline-none focus:border-admin-accent" placeholder="Why this stays on main forever" />
<button type="submit" className="admin-nav-button w-full px-3 py-2 text-sm font-medium">Mark Timeless Classic</button>
</form>
)}
</div>
</div>
<div className="space-y-3">
{seasonList.map((season, index) => {
const seasonFiles = season.episodes.flatMap((episode) => episode.files);
const seasonBytes = seasonFiles.reduce((sum, file) => sum + Number(file.sizeBytes ?? 0), 0);
const seasonMissing = season.episodes.filter((episode) => !episode.files.length).length;
const tiers = Array.from(new Set(seasonFiles.map((file) => file.tier).filter(Boolean))) as string[];
return (
<details key={season.id} open={index === 0} className="admin-panel overflow-hidden">
<summary className="flex cursor-pointer flex-wrap items-center justify-between gap-3 px-5 py-4 hover:bg-admin-subpanel">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-serif text-xl font-semibold">{seasonLabel(season.number)}</h2>
{tiers.map((tier) => <TierBadge key={tier} tier={tier} />)}
{seasonMissing ? (
<span className="rounded-md border border-admin-warn px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-admin-warn">
{seasonMissing} missing
</span>
) : null}
</div>
<p className="text-xs text-admin-muted">
{season.episodes.length} episodes &middot; {seasonFiles.length} files &middot; {formatBytes(seasonBytes)}
</p>
</summary>
<div className="divide-y divide-admin-line border-t border-admin-line">
{season.episodes.map((episode) => {
const hasFile = episode.files.length > 0;
return (
<div
key={episode.id}
className={hasFile ? "px-5 py-3" : "bg-admin-missing px-5 py-3"}
>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono text-sm text-admin-muted">
{"E" + String(episode.number).padStart(2, "0")}
</span>
<span className="text-sm font-medium">{episode.title ?? "Untitled episode"}</span>
{hasFile ? (
(() => {
const state = backupState(episode.files);
if (state === "n/a") return null;
if (state === "ok") {
return <span title="Mirrored to backup at the same size" className="text-xs font-semibold text-admin-good"> backed up</span>;
}
if (state === "stale") {
return <span title="A backup copy exists at this path but its size differs from live" className="text-xs font-semibold text-admin-warn">! backup differs</span>;
}
return <span title="No backup copy of this file" className="text-xs font-semibold text-admin-warn"> not backed up</span>;
})()
) : (
<span className="text-xs font-semibold uppercase tracking-wide text-admin-muted">Not in collection</span>
)}
</div>
{episode.files.map((file) => {
const resolution = file.width && file.height ? `${file.width}x${file.height}` : null;
const duration = formatDuration(file.durationSeconds);
const audio = file.audioCodec
? file.audioCodec + (file.audioChannels ? ` ${file.audioChannels}ch` : "")
: null;
return (
<div key={file.fileId} className="mt-2 rounded-md border border-admin-line bg-admin-subpanel px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<TierBadge tier={file.tier} />
{file.edition ? (
<span className="rounded-md border border-admin-accent px-1.5 py-0.5 font-semibold uppercase text-admin-accent">
{file.edition === "bw" ? "B&W" : file.edition}
</span>
) : null}
<span className="font-semibold">{file.quality ?? "Unknown"}</span>
{resolution ? <span className="text-admin-muted">{resolution}</span> : null}
<span className="text-admin-muted">{file.codec ?? "Unknown"}</span>
{audio ? <span className="text-admin-muted">{audio}</span> : null}
{duration ? <span className="text-admin-muted">{duration}</span> : null}
<span className="text-admin-muted">{formatBytes(file.sizeBytes)}</span>
{file.probedAt ? null : (
<span className="text-admin-muted italic">from filename, not probed</span>
)}
</div>
<p className="mt-1 break-all text-xs text-admin-muted">{file.relativePath}</p>
{/* Live only. A request is about the copy being
served, and flagging a backup or archive file
would mark something the reaper never looks at
and so do nothing at all. */}
{file.tier === "live" ? (
<div className="mt-2 flex items-center gap-2">
{file.replaceRequestedAt ? (
<>
<span className="text-xs font-semibold text-admin-warn">
Replacement wanted at the front of the queue
</span>
<form action={cancelReplacement}>
<input type="hidden" name="fileId" value={file.fileId ?? ""} />
<input type="hidden" name="seriesId" value={id} />
<button
type="submit"
className="rounded border border-admin-line px-2 py-0.5 text-xs text-admin-muted transition hover:text-admin-text"
>
Cancel
</button>
</form>
</>
) : (
<form action={requestReplacement}>
<input type="hidden" name="fileId" value={file.fileId ?? ""} />
<input type="hidden" name="seriesId" value={id} />
<button
type="submit"
title="Look for a better copy, and delete this one once it arrives"
className="rounded border border-admin-line px-2 py-0.5 text-xs text-admin-muted transition hover:border-admin-accent hover:text-admin-accent"
>
Replace this copy
</button>
</form>
)}
</div>
) : null}
</div>
);
})}
</div>
);
})}
</div>
</details>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,78 @@
"use server";
// Ask for a better copy of one episode.
//
// Three things happen, and they are deliberately three rather than one big
// one: the file is flagged, the episode's search cooldown is cleared, and the
// page is revalidated. What does NOT happen here is the download. The fetch
// loop owns searching and grabbing, it applies the identity gate and the size
// rules, and reproducing any of that in a server action would mean two places
// deciding what is worth grabbing.
//
// So the button's promise is: this copy is marked, the episode is at the front
// of the queue, and the next fetch cycle will go looking. Not "it is
// downloading now".
import { auth } from "@/auth";
import { db } from "@/db/client";
import { searchRuns, storageFiles, storageTiers } from "@/db/schema";
import { and, eq, isNull, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireAdmin() {
const session = await auth();
if (!session) redirect("/login");
if (!session.user.isAdmin) redirect("/");
return session;
}
function optionalString(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export async function requestReplacement(formData: FormData) {
const session = await requireAdmin();
const fileId = optionalString(formData.get("fileId"));
const seriesId = optionalString(formData.get("seriesId"));
if (!fileId) return;
// Live only. A request is about the copy being served; flagging a backup or
// archive file would mark something the reaper will never touch and quietly
// do nothing at all.
const [flagged] = await db
.update(storageFiles)
.set({ replaceRequestedAt: new Date(), replaceRequestedBy: session.user.id })
.where(and(
eq(storageFiles.id, fileId),
isNull(storageFiles.missingAt),
sql`${storageFiles.tierId} in (select id from ${storageTiers} where tier = 'live')`,
))
.returning({ episodeId: storageFiles.episodeId, mediaItemId: storageFiles.mediaItemId });
// Clear the search cooldown so the next cycle actually looks. Without this a
// title searched in the last six hours would be skipped, and the button
// would appear to do nothing for most of a working day.
if (flagged?.mediaItemId) {
await db.delete(searchRuns).where(eq(searchRuns.mediaItemId, flagged.mediaItemId));
}
if (seriesId) revalidatePath(`/admin/inventory/series/${seriesId}`);
revalidatePath("/admin/inventory");
}
/** Change your mind. Clears the flag, and with it the queue entry and the reaper's licence. */
export async function cancelReplacement(formData: FormData) {
await requireAdmin();
const fileId = optionalString(formData.get("fileId"));
const seriesId = optionalString(formData.get("seriesId"));
if (!fileId) return;
await db
.update(storageFiles)
.set({ replaceRequestedAt: null, replaceRequestedBy: null })
.where(eq(storageFiles.id, fileId));
if (seriesId) revalidatePath(`/admin/inventory/series/${seriesId}`);
revalidatePath("/admin/inventory");
}
+121
View File
@@ -0,0 +1,121 @@
import { db } from "@/db/client";
import { maintenanceJobs } from "@/db/schema";
import { desc, sql } from "drizzle-orm";
export const dynamic = "force-dynamic";
function formatWhen(value: Date | null) {
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "—";
}
function formatDuration(ms: number | null) {
if (ms === null) return "—";
if (ms < 1000) return ms + " ms";
if (ms < 60_000) return (ms / 1000).toFixed(1) + " s";
return Math.round(ms / 60_000) + " min";
}
// "skipped" is deliberately neutral rather than a warning. Backup and archive
// live on a host that is powered off most of the time, so a scan declining to
// run is the system behaving correctly, not a fault.
const STATUS_STYLE: Record<string, string> = {
running: "border-admin-accent text-admin-accent",
succeeded: "border-admin-good text-admin-good",
skipped: "border-admin-line text-admin-muted",
failed: "border-admin-warn text-admin-warn",
};
export default async function AdminJobsPage() {
const runs = await db
.select()
.from(maintenanceJobs)
.orderBy(desc(maintenanceJobs.startedAt))
.limit(100);
// Latest outcome per job, so the health of the whole schedule reads at a glance.
const latest = await db.execute<{
job: string;
status: string;
started_at: Date;
duration_ms: number | null;
detail: string | null;
runs: number;
failures: number;
}>(sql`
select distinct on (job)
job, status, started_at, duration_ms, detail,
count(*) over (partition by job)::int runs,
count(*) filter (where status = 'failed') over (partition by job)::int failures
from maintenance_jobs
order by job, started_at desc`);
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Maintenance</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Jobs</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
Everything that touches the filesystem runs in the <code>ampelos-maintenance</code> container: scanning,
probing, metadata, and the placement classifier. This dashboard only reads storage write access lives
in that container so nothing reachable from the network can modify media.
</p>
</div>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Current State</h2>
</div>
<div className="divide-y divide-admin-line">
{latest.rows.length ? latest.rows.map((row) => (
<div key={row.job} className="grid gap-2 px-5 py-3 lg:grid-cols-[1fr_7rem_9rem_6rem_1fr] lg:items-center">
<p className="font-mono text-sm">{row.job}</p>
<span className={"justify-self-start rounded-md border px-2 py-0.5 text-xs font-semibold uppercase " + (STATUS_STYLE[row.status] ?? "border-admin-line")}>
{row.status}
</span>
<p className="text-xs text-admin-muted">{formatWhen(row.started_at)}</p>
<p className="text-xs text-admin-muted">{formatDuration(row.duration_ms)}</p>
<p className="truncate text-xs text-admin-muted" title={row.detail ?? ""}>
{row.detail ?? (row.failures ? row.failures + " of " + row.runs + " runs failed" : "")}
</p>
</div>
)) : (
<p className="p-5 text-sm text-admin-muted">
No maintenance job has reported yet. Start the container with
<code className="mx-1">docker compose -f maintenance/docker-compose.yml up -d</code>.
</p>
)}
</div>
</section>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Recent Runs</h2>
</div>
<div className="divide-y divide-admin-line">
{runs.map((run) => (
<details key={run.id} className="px-5 py-3">
<summary className="flex cursor-pointer flex-wrap items-center gap-3">
<span className={"rounded-md border px-2 py-0.5 text-xs font-semibold uppercase " + (STATUS_STYLE[run.status] ?? "border-admin-line")}>
{run.status}
</span>
<span className="font-mono text-sm">{run.job}</span>
<span className="text-xs text-admin-muted">{formatWhen(run.startedAt)}</span>
<span className="text-xs text-admin-muted">{formatDuration(run.durationMs)}</span>
<span className="text-xs text-admin-muted">{run.trigger}</span>
{run.detail ? <span className="text-xs text-admin-warn">{run.detail}</span> : null}
</summary>
{run.output ? (
<pre className="mt-2 max-h-80 overflow-auto rounded-md border border-admin-line bg-admin-subpanel p-3 text-xs text-admin-muted">
{run.output}
</pre>
) : (
<p className="mt-2 text-xs text-admin-muted">No output captured.</p>
)}
</details>
))}
{!runs.length ? <p className="p-5 text-sm text-admin-muted">Nothing recorded yet.</p> : null}
</div>
</section>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
// The manual search panel: what Sonarr's magnifying glass does.
//
// The design principle here is that a rejected release is information, not
// noise. When the automatic pass reports "nothing found", the useful answer is
// almost never "the internet is empty" -- it is "eleven results, and every one
// of them was a telesync" or "the only copy is 40MB, which is a fake". So this
// lists everything the fan-out saw, marks what the rules refused and why, and
// still lets a person take it anyway. They can see the reason; they can
// overrule it.
import { useState, useTransition } from "react";
import {
runManualSearch,
grabCandidate,
type SearchState,
type GrabState,
} from "./search-actions";
import type { SearchCandidate } from "@/lib/indexer";
function formatBytes(value: number | null) {
if (value === null || !Number.isFinite(value) || value <= 0) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}
// The rejection reasons are machine strings from decide.mjs. They are readable
// enough to keep as a tooltip, but the badge should say the thing in English.
function rejectionLabel(reason: string | null) {
if (!reason) return "Rejected";
const [kind, detail] = reason.split(":");
switch (kind) {
case "blocklisted": return "Blocklisted";
case "blocked-group": return `Blocked group ${detail ?? ""}`.trim();
case "rejected": return detail === "cam" ? "Camcorder rip" : `Rejected (${detail})`;
case "below-min-quality": return `Below minimum quality`;
case "too-few-seeders": return `Only ${detail} seeders`;
case "seeders-unknown-and-required": return "Swarm size unknown";
case "too-small": return "Suspiciously small";
case "too-large": return "Larger than wanted";
case "season-pack-not-wanted": return "Season pack";
case "no-download-link": return "No usable link";
default: return reason;
}
}
function Candidate({
candidate,
searchId,
onGrabbed,
}: {
candidate: SearchCandidate;
searchId: string;
onGrabbed: (state: GrabState) => void;
}) {
const [isPending, startTransition] = useTransition();
function grab() {
if (!candidate.infoHash) return;
startTransition(async () => {
onGrabbed(await grabCandidate({ searchId, infoHash: candidate.infoHash! }));
});
}
return (
<div className={"grid gap-2 px-3 py-2 text-xs lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]" + (candidate.accepted ? "" : " bg-admin-missing")}>
<div className="min-w-0">
{/* The full release name, unabbreviated and selectable. A group that
ships broken files is identified by this string and no other. */}
<p className="break-all font-mono text-admin-text">{candidate.title}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-admin-muted">
<span>{candidate.indexerName}</span>
{candidate.origin ? <span>via {candidate.origin}</span> : null}
{candidate.indexerCount > 1 ? (
<span title="Found on more than one indexer, which makes a fake less likely" className="text-admin-good">
×{candidate.indexerCount} indexers
</span>
) : null}
{candidate.hasAtmos ? <span className="text-admin-accent">Atmos</span> : null}
{candidate.isSeasonPack ? <span>season pack</span> : null}
{!candidate.accepted ? (
<span title={candidate.rejection ?? ""} className="font-semibold text-admin-warn">
{rejectionLabel(candidate.rejection)}
</span>
) : null}
</p>
</div>
<span className="text-admin-muted">{candidate.quality ?? "unknown"}</span>
<span className="text-admin-muted">{candidate.source ?? "—"}</span>
<span className={candidate.seeders === null ? "text-admin-warn" : "text-admin-text"}>
{candidate.seeders === null ? "?" : candidate.seeders}
</span>
<span className="text-admin-muted">{formatBytes(candidate.size)}</span>
<span title={candidate.reasons.join("\n")} className="cursor-help font-semibold">
{candidate.accepted ? candidate.score : "—"}
</span>
<span>
<button
type="button"
onClick={grab}
disabled={isPending || !candidate.infoHash}
title={
candidate.accepted
? "Send to qBittorrent"
: "Send to qBittorrent anyway, overruling: " + (candidate.rejection ?? "")
}
className={
"admin-nav-button w-full px-2 py-1 text-xs font-medium " +
(candidate.accepted ? "" : "text-admin-warn")
}
>
{isPending ? "…" : candidate.accepted ? "Grab" : "Grab anyway"}
</button>
</span>
</div>
);
}
export function ManualSearch({
mediaItemId,
seasonNumber = null,
episodeNumber = null,
label,
compact = false,
}: {
mediaItemId: string;
seasonNumber?: number | null;
episodeNumber?: number | null;
label?: string;
compact?: boolean;
}) {
const [state, setState] = useState<SearchState | null>(null);
const [notice, setNotice] = useState<GrabState | null>(null);
const [showRejected, setShowRejected] = useState(false);
const [isPending, startTransition] = useTransition();
function search() {
setNotice(null);
startTransition(async () => {
setState(await runManualSearch({ mediaItemId, seasonNumber, episodeNumber }));
});
}
const result = state?.ok ? state.result : null;
const accepted = result?.candidates.filter((c) => c.accepted) ?? [];
const rejected = result?.candidates.filter((c) => !c.accepted) ?? [];
const shown = showRejected ? [...accepted, ...rejected] : accepted;
return (
<div className={compact ? "" : "space-y-2"}>
<button
type="button"
onClick={(event) => { event.stopPropagation(); search(); }}
disabled={isPending}
className="admin-nav-button px-2 py-1 text-xs font-medium"
title={"Search indexers for " + (label ?? "this title")}
>
{isPending ? "Searching…" : state ? "Search again" : "Search"}
</button>
{state && !state.ok ? (
<p className="mt-2 rounded-md border border-admin-warn px-3 py-2 text-xs text-admin-warn">
{state.error}
</p>
) : null}
{notice ? (
<p className={"mt-2 text-xs " + (notice.ok ? "text-admin-good" : "text-admin-warn")}>
{notice.ok ? notice.message : notice.error}
</p>
) : null}
{result ? (
<div className="mt-2 rounded-md border border-admin-line bg-admin-subpanel" onClick={(event) => event.stopPropagation()}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-3 py-2">
<p className="text-xs text-admin-muted">
<span className="font-semibold text-admin-text">{result.label}</span>
{" — "}
{result.candidates.length} results from {result.indexersQueried} indexers
{" · "}{accepted.length} usable
{" · "}{(result.durationMs / 1000).toFixed(1)}s
</p>
{rejected.length ? (
<button
type="button"
onClick={() => setShowRejected((value) => !value)}
className="admin-nav-button px-2 py-1 text-xs font-medium"
>
{showRejected ? "Hide" : "Show"} {rejected.length} rejected
</button>
) : null}
</div>
{/* Why an indexer contributed nothing. Without this, a thin result
set reads as "nothing exists" when the real answer is that EZTV
was skipped because this series has no IMDB id recorded. */}
{result.skipped.length || result.errors.length ? (
<p className="border-b border-admin-line px-3 py-2 text-xs text-admin-muted">
{result.skipped.map((s) => `${s.indexer}: ${s.reason}`).join(" · ")}
{result.skipped.length && result.errors.length ? " · " : ""}
{result.errors.map((e) => `${e.indexer} failed: ${e.error}`).join(" · ")}
</p>
) : null}
<div className="hidden border-b border-admin-line px-3 py-1.5 text-[10px] uppercase tracking-[0.18em] text-admin-muted lg:grid lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]">
<span>Release</span><span>Quality</span><span>Source</span>
<span>Seed</span><span>Size</span><span>Score</span><span />
</div>
<div className="divide-y divide-admin-line">
{shown.map((candidate, index) => (
<Candidate
key={(candidate.infoHash ?? "no-hash") + ":" + index}
candidate={candidate}
searchId={result.searchId}
onGrabbed={setNotice}
/>
))}
</div>
{!shown.length ? (
<p className="px-3 py-3 text-xs text-admin-muted">
{rejected.length
? `No usable release. All ${rejected.length} results were rejected — show them to see why.`
: "No indexer returned anything for this."}
</p>
) : null}
</div>
) : null}
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { db } from "@/db/client";
import { desiredStates, mediaItems, storageAvailability, storageTiers, users, watchingNowItems, watchlistItems } from "@/db/schema";
import { count, desc, eq, isNull } from "drizzle-orm";
import Link from "next/link";
export const dynamic = "force-dynamic";
function formatDate(value: Date | null) {
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
}
export default async function AdminDashboard() {
const [
[{ userCount }],
[{ watchingNowCount }],
[{ watchlistCount }],
[{ desiredStateCount }],
recentDemand,
tiers,
availabilityRows,
] = await Promise.all([
db.select({ userCount: count() }).from(users),
db.select({ watchingNowCount: count() }).from(watchingNowItems).where(isNull(watchingNowItems.removedAt)),
db.select({ watchlistCount: count() }).from(watchlistItems).where(isNull(watchlistItems.removedAt)),
db.select({ desiredStateCount: count() }).from(desiredStates),
db
.select({
id: watchingNowItems.id,
title: mediaItems.title,
mediaType: mediaItems.mediaType,
displayName: users.displayName,
addedAt: watchingNowItems.addedAt,
})
.from(watchingNowItems)
.innerJoin(users, eq(users.id, watchingNowItems.userId))
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
.where(isNull(watchingNowItems.removedAt))
.orderBy(desc(watchingNowItems.addedAt))
.limit(5),
db.select().from(storageTiers).orderBy(storageTiers.tier),
db.select().from(storageAvailability).orderBy(desc(storageAvailability.detectedAt)).limit(20),
]);
const latestAvailability = new Map<string, (typeof availabilityRows)[number]>();
for (const row of availabilityRows) {
if (!latestAvailability.has(row.tierId)) {
latestAvailability.set(row.tierId, row);
}
}
const stats = [
{ label: "Users", value: userCount, href: "/admin/users" },
{ label: "Watching Now", value: watchingNowCount, href: "/admin/demand" },
{ label: "Watchlist", value: watchlistCount, href: "/admin/demand" },
{ label: "Desired states", value: desiredStateCount, href: "/admin/policy" },
];
return (
<div className="space-y-8">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Control plane</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Admin Dashboard</h1>
</div>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{stats.map((stat) => (
<Link key={stat.label} href={stat.href} className="admin-card block p-4">
<p className="text-sm text-admin-muted">{stat.label}</p>
<p className="mt-2 text-3xl font-bold">{stat.value}</p>
</Link>
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<section className="admin-panel p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="font-serif text-xl font-semibold">Recent Watching Now</h2>
<Link href="/admin/demand" className="text-sm font-medium text-admin-accent hover:text-admin-text">View demand</Link>
</div>
<div className="divide-y divide-admin-line">
{recentDemand.length ? recentDemand.map((item) => (
<div key={item.id} className="grid gap-2 py-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div>
<p className="font-medium">{item.title}</p>
<p className="text-sm text-admin-muted">{item.displayName} · {item.mediaType === "movie" ? "Movie" : "TV"}</p>
</div>
<p className="text-sm text-admin-muted">{formatDate(item.addedAt)}</p>
</div>
)) : <p className="text-sm text-admin-muted">No active Watching Now items yet.</p>}
</div>
</section>
<section className="admin-panel p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="font-serif text-xl font-semibold">Storage Tiers</h2>
<Link href="/admin/storage" className="text-sm font-medium text-admin-accent hover:text-admin-text">View storage</Link>
</div>
<div className="space-y-3">
{tiers.length ? tiers.map((tier) => {
const latest = latestAvailability.get(tier.id);
const online = tier.alwaysOnline || latest?.online;
return (
<div key={tier.id} className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{tier.label}</p>
<span className={online ? "text-sm font-medium text-admin-good" : "text-sm font-medium text-admin-muted"}>{online ? "Online" : "Offline"}</span>
</div>
<p className="mt-1 truncate text-sm text-admin-muted">{tier.basePath}</p>
</div>
);
}) : <p className="text-sm text-admin-muted">No storage tiers configured yet.</p>}
</div>
</section>
</div>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import Link from "next/link";
import { db } from "@/db/client";
import { desiredStates, policyRuns } from "@/db/schema";
import { desc, sql } from "drizzle-orm";
export const dynamic = "force-dynamic";
function formatDate(value: Date | null) {
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
}
// Desired state is only half the picture; the work it implies comes from
// comparing it to where files actually are.
//
// Backup is excluded because it mirrors live rather than being a placement of
// its own. Unaired episodes are excluded from the work queue because they
// cannot be acquired yet — they stay in desired_states so gaps stay visible.
//
// This is expressed as a CTE and a hash join on purpose. The obvious version, a
// correlated subquery per row, measured 91 SECONDS for a single evaluation of
// 4,000 rows; this covers the whole library in about 600ms.
const PLACEMENT = sql`
with actual as (
select sf.media_item_id mid, se.season_number sn, e.episode_number en,
array_agg(distinct t.tier) tiers
from storage_files sf
join storage_tiers t on t.id = sf.tier_id
left join episodes e on e.id = sf.episode_id
left join seasons se on se.id = e.season_id
where sf.missing_at is null and t.tier <> 'backup'
group by 1, 2, 3
),
joined as (
select ds.id, mi.title, mi.media_type, ds.season_number sn, ds.episode_number en,
ds.storage_tier, ds.reason_codes,
case
when a.tiers is null then 'acquire'
when ds.storage_tier = 'live' and not ('live' = any(a.tiers)) then 'restore'
when ds.storage_tier = 'archive' and ('live' = any(a.tiers)) then 'demote'
else 'settled'
end action
from desired_states ds
join media_items mi on mi.id = ds.media_item_id
left join actual a
on a.mid = ds.media_item_id
and a.sn is not distinct from ds.season_number
and a.en is not distinct from ds.episode_number
where ds.wanted
and ds.storage_tier is not null
and not ('unaired' = any(ds.reason_codes))
)`;
type ActionRow = { id: string; title: string; sn: number | null; en: number | null; action: string };
export default async function AdminPolicyPage() {
const runs = await db.select().from(policyRuns).orderBy(desc(policyRuns.startedAt)).limit(10);
const counts = await db.execute<{ action: string; n: number }>(
sql`${PLACEMENT} select action, count(*)::int n from joined group by 1`,
);
// Ranked per action, not a flat LIMIT: a plain "order by action limit 240"
// is swallowed whole by whichever action sorts first, leaving the other
// columns rendering empty.
const samples = await db.execute<ActionRow>(
sql`${PLACEMENT},
ranked as (
select id, title, sn, en, action,
row_number() over (partition by action order by title, sn, en) rank
from joined where action <> 'settled'
)
select id, title, sn, en, action from ranked where rank <= 10`,
);
const reasonTally = await db.execute<{ code: string; n: number }>(
sql`select code, count(*)::int n
from desired_states, unnest(reason_codes) code
group by 1 order by 2 desc`,
);
const [totals] = await db
.select({
total: sql<number>`count(*)::int`,
wanted: sql<number>`count(*) filter (where ${desiredStates.wanted})::int`,
monitored: sql<number>`count(*) filter (where ${desiredStates.monitored})::int`,
live: sql<number>`count(*) filter (where ${desiredStates.storageTier} = 'live')::int`,
archive: sql<number>`count(*) filter (where ${desiredStates.storageTier} = 'archive')::int`,
})
.from(desiredStates);
const countFor = (action: string) => Number(counts.rows.find((row) => row.action === action)?.n ?? 0);
const ACTIONS = [
{ key: "restore", label: "Restore", blurb: "archive → live", tone: "text-admin-accent" },
{ key: "demote", label: "Demote", blurb: "live → archive", tone: "text-admin-muted" },
{ key: "acquire", label: "Acquire", blurb: "not held on any tier", tone: "text-admin-warn" },
] as const;
function label(row: ActionRow) {
if (row.sn === null) return row.title;
return row.title + " S" + String(row.sn).padStart(2, "0") +
(row.en !== null ? "E" + String(row.en).padStart(2, "0") : "");
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Policy</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Desired State</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
Where every episode and movie <em>should</em> live, computed from the retention rules, compared to where it
actually is. This is a plan, not an instruction nothing here moves a file. The mover stays disarmed until
Ampelos takes over from Sonarr and Radarr.
</p>
</div>
<Link href="/admin/policy/sizes" className="admin-nav-button px-3 py-2 text-sm font-medium">
Release size limits
</Link>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{[
{ label: "Evaluated", value: totals?.total ?? 0 },
{ label: "Wanted", value: totals?.wanted ?? 0 },
{ label: "Monitored for upgrades", value: totals?.monitored ?? 0 },
{ label: "Belongs on live", value: totals?.live ?? 0 },
{ label: "Belongs on archive", value: totals?.archive ?? 0 },
].map((tile) => (
<div key={tile.label} className="admin-panel p-4">
<p className="text-xs text-admin-muted">{tile.label}</p>
<p className="mt-1 text-2xl font-semibold">{tile.value}</p>
</div>
))}
</div>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Placement Work Implied</h2>
<p className="mt-1 text-sm text-admin-muted">
{countFor("settled")} items are already where they belong.
</p>
</div>
<div className="grid gap-4 p-5 lg:grid-cols-3">
{ACTIONS.map((action) => {
const rows = samples.rows.filter((row) => row.action === action.key);
const total = countFor(action.key);
return (
<div key={action.key} className="rounded-md border border-admin-line bg-admin-subpanel p-4">
<p className={"text-sm font-semibold " + action.tone}>{action.label}</p>
<p className="text-xs text-admin-muted">{action.blurb}</p>
<p className="mt-2 text-3xl font-semibold">{total}</p>
<ul className="mt-3 space-y-1 text-xs text-admin-muted">
{rows.slice(0, 10).map((row) => (
<li key={row.id} className="truncate">{label(row)}</li>
))}
{total > rows.slice(0, 10).length ? (
<li>and {total - rows.slice(0, 10).length} more</li>
) : null}
</ul>
</div>
);
})}
</div>
</section>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Why</h2>
<p className="mt-1 text-sm text-admin-muted">Reason codes across every computed decision.</p>
</div>
<div className="divide-y divide-admin-line">
{reasonTally.rows.map((row) => (
<div key={row.code} className="flex items-center justify-between px-5 py-2 text-sm">
<span className={row.code === "metadata_missing" ? "text-admin-warn" : ""}>{row.code}</span>
<span className="text-admin-muted">{row.n}</span>
</div>
))}
{!reasonTally.rows.length ? (
<p className="p-5 text-sm text-admin-muted">No desired state computed yet.</p>
) : null}
</div>
</section>
<section className="admin-panel overflow-hidden">
<div className="border-b border-admin-line px-5 py-4">
<h2 className="font-serif text-xl font-semibold">Recent Policy Runs</h2>
</div>
<div className="divide-y divide-admin-line">
{runs.length ? runs.map((run) => (
<div key={run.id} className="grid gap-3 px-5 py-4 md:grid-cols-[0.8fr_1fr_1fr_0.7fr_0.7fr] md:items-center">
<p className="font-medium">{run.trigger}</p>
<p className="text-sm text-admin-muted">Started {formatDate(run.startedAt)}</p>
<p className="text-sm text-admin-muted">Completed {formatDate(run.completedAt)}</p>
<p className="text-sm text-admin-muted">Evaluated {run.itemsEvaluated ?? 0}</p>
<p className="text-sm text-admin-muted">Changed {run.itemsChanged ?? 0}</p>
</div>
)) : <p className="p-5 text-sm text-admin-muted">No policy runs recorded yet.</p>}
</div>
</section>
</div>
);
}
@@ -0,0 +1,82 @@
"use server";
// Server action for the release size rules.
//
// Re-checks the session like every other action here: Server Actions are
// reachable by direct POST, not only through the form on the page, so the
// layout's admin guard governs rendering and nothing more.
import { auth } from "@/auth";
import { db } from "@/db/client";
import { releaseSizeRules } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
const QUALITIES = ["sd", "720p", "1080p", "4k"] as const;
type Quality = (typeof QUALITIES)[number];
/**
* Read one rate off the form.
*
* A blank or unparseable field is rejected rather than coerced. Number("") is
* 0, and a silent 0 here is not a small mistake -- a floor of 0 accepts every
* fake and a ceiling of 0 rejects everything ever offered.
*/
function rate(formData: FormData, name: string, { optional = false } = {}) {
const raw = String(formData.get(name) ?? "").trim();
if (raw === "") {
if (optional) return null;
throw new Error(`${name} is required`);
}
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number`);
}
// numeric(6,1): four digits before the point, one after.
if (value > 9999) throw new Error(`${name} is implausibly large`);
return Math.round(value * 10) / 10;
}
export async function saveSizeRulesAction(formData: FormData) {
const session = await auth();
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
for (const quality of QUALITIES) {
const min = rate(formData, `${quality}-min`);
const max = rate(formData, `${quality}-max`);
const atmosMax = rate(formData, `${quality}-atmos`, { optional: true });
const timelessMax = rate(formData, `${quality}-timeless`, { optional: true });
// Checked per band rather than trusted: an inverted pair silently rejects
// every release of that quality, and the symptom -- nothing is ever
// grabbed -- looks nothing like the cause.
if (min !== null && max !== null && min >= max) {
throw new Error(`${quality}: the minimum must be below the maximum`);
}
if (atmosMax !== null && max !== null && atmosMax < max) {
throw new Error(
`${quality}: the Atmos allowance cannot be below the ordinary maximum — ` +
"it is an allowance, not a second ceiling",
);
}
if (timelessMax !== null && max !== null && timelessMax < max) {
throw new Error(
`${quality}: the timeless allowance cannot be below the ordinary maximum`,
);
}
await db
.update(releaseSizeRules)
.set({
minMbPerMinute: String(min),
maxMbPerMinute: String(max),
atmosMaxMbPerMinute: atmosMax === null ? null : String(atmosMax),
timelessMaxMbPerMinute: timelessMax === null ? null : String(timelessMax),
updatedAt: sql`now()`,
})
.where(eq(releaseSizeRules.quality, quality));
}
revalidatePath("/admin/policy/sizes");
}
+243
View File
@@ -0,0 +1,243 @@
import Link from "next/link";
import { db } from "@/db/client";
import { releaseSizeRules } from "@/db/schema";
import { sql } from "drizzle-orm";
import { saveSizeRulesAction } from "./actions";
export const dynamic = "force-dynamic";
// The runtimes the preview is drawn at. Deliberately not round numbers only:
// MB per minute is an abstract unit and nobody can judge it directly, but
// everyone knows what a half-hour episode and a two-hour film should weigh.
const PREVIEW_RUNTIMES = [
{ minutes: 22, label: "22 min", note: "half-hour episode" },
{ minutes: 45, label: "45 min", note: "drama episode" },
{ minutes: 110, label: "110 min", note: "typical film" },
{ minutes: 180, label: "180 min", note: "epic" },
];
const QUALITY_ORDER = ["sd", "720p", "1080p", "4k"] as const;
type Rule = {
quality: string;
min: number;
max: number;
atmosMax: number | null;
timelessMax: number | null;
observed: { n: number; p05: number; p50: number; p95: number } | null;
};
function gb(mbPerMinute: number, minutes: number) {
return ((mbPerMinute * minutes) / 1024).toFixed(2);
}
export default async function AdminSizeRulesPage() {
const rows = await db.select().from(releaseSizeRules);
// What the library actually holds, alongside what the rules allow. A limit
// set without the distribution in front of you is a guess, and the whole
// point of these numbers is that they were measured rather than guessed.
//
// Probed files only, and only those with a real duration: filenames in this
// library are wrong about quality on 47% of files, so an unprobed row cannot
// say what a 1080p file weighs.
const { rows: observed } = await db.execute<{
quality: string;
n: number;
p05: number;
p50: number;
p95: number;
}>(sql`
select sf.quality::text as quality,
count(*)::int as n,
round(percentile_cont(0.05) within group (
order by sf.size_bytes / 1048576.0 / (sf.duration_seconds / 60.0))::numeric, 1)::float8 as p05,
round(percentile_cont(0.50) within group (
order by sf.size_bytes / 1048576.0 / (sf.duration_seconds / 60.0))::numeric, 1)::float8 as p50,
round(percentile_cont(0.95) within group (
order by sf.size_bytes / 1048576.0 / (sf.duration_seconds / 60.0))::numeric, 1)::float8 as p95
from storage_files sf
where sf.missing_at is null
and sf.probed_at is not null
and sf.duration_seconds > 300
and sf.size_bytes > 0
and sf.quality is not null
group by 1`);
const observedByQuality = new Map(observed.map((row) => [row.quality, row]));
const rules: Rule[] = QUALITY_ORDER.flatMap((quality) => {
const row = rows.find((entry) => entry.quality === quality);
if (!row) return [];
const stat = observedByQuality.get(quality);
return [{
quality,
min: Number(row.minMbPerMinute),
max: Number(row.maxMbPerMinute),
atmosMax: row.atmosMaxMbPerMinute === null ? null : Number(row.atmosMaxMbPerMinute),
timelessMax: row.timelessMaxMbPerMinute === null ? null : Number(row.timelessMaxMbPerMinute),
observed: stat ? { n: stat.n, p05: stat.p05, p50: stat.p50, p95: stat.p95 } : null,
}];
});
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Policy</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Release size limits</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
How large a release may be, 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. These apply to what gets <em>downloaded</em> nothing here
filters or removes anything already in the library.
</p>
</div>
<Link href="/admin/policy" className="admin-nav-button px-3 py-2 text-sm font-medium">
Policy overview
</Link>
</div>
<form action={saveSizeRulesAction} className="space-y-6">
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] border-collapse text-sm">
<thead>
<tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
<th className="py-2 pr-4">Quality</th>
<th className="py-2 pr-4 text-right">Min MB/min</th>
<th className="py-2 pr-4 text-right">Max MB/min</th>
<th className="py-2 pr-4 text-right">Atmos max</th>
<th className="py-2 pr-4 text-right">Timeless max</th>
<th className="py-2 pr-4">What the library holds</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.quality} className="border-b border-admin-line/50">
<td className="py-3 pr-4 font-medium uppercase">{rule.quality}</td>
<td className="py-3 pr-4 text-right">
<input
type="number" step="0.1" min="0.1" required
name={`${rule.quality}-min`} defaultValue={rule.min}
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
/>
</td>
<td className="py-3 pr-4 text-right">
<input
type="number" step="0.1" min="0.1" required
name={`${rule.quality}-max`} defaultValue={rule.max}
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
/>
</td>
<td className="py-3 pr-4 text-right">
<input
type="number" step="0.1" min="0.1"
name={`${rule.quality}-atmos`}
defaultValue={rule.atmosMax ?? ""}
placeholder="—"
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
/>
</td>
<td className="py-3 pr-4 text-right">
<input
type="number" step="0.1" min="0.1"
name={`${rule.quality}-timeless`}
defaultValue={rule.timelessMax ?? ""}
placeholder="—"
className="w-24 rounded border border-admin-line bg-transparent px-2 py-1 text-right font-mono"
/>
</td>
<td className="py-3 pr-4 text-xs text-admin-muted">
{rule.observed ? (
<span className="font-mono">
n={rule.observed.n} · p05 {rule.observed.p05} · median {rule.observed.p50} · p95 {rule.observed.p95}
</span>
) : (
<span className="font-mono">nothing probed</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="max-w-3xl text-xs text-admin-muted">
The Atmos column is an <em>allowance</em>, not a second ceiling: leave it blank and
the ordinary maximum applies whatever the release calls itself. Only 4K carries one,
because that is where it was measured to matter on 4K files, probed TrueHD runs
about 1.8&times; E-AC-3 and 4&times; AAC per minute. It is read from the release
title, which is trustworthy in a way our own filenames are not: an encoder advertises
&ldquo;TrueHD 7.1 Atmos&rdquo; because it sells the release.
</p>
<p className="max-w-3xl text-xs text-admin-muted">
The <strong>Timeless</strong> column is the only ceiling that admits a remux the
disc&rsquo;s own streams repackaged without re-encoding, three to four times the size
of a good WEB-DL. It applies to nothing except titles marked timeless, and that is
the point: as a global ceiling it would stop being an allowance for the films worth
keeping losslessly and simply become the size everything arrives at. It outranks the
Atmos allowance, since a lossless copy carries its object-based track anyway. There
is no 720p remux the format is a re-encode by definition so leaving those blank
is correct.
</p>
<button type="submit" className="admin-nav-button px-4 py-2 text-sm font-medium">
Save limits
</button>
</form>
<section className="space-y-3">
<h2 className="font-serif text-xl font-semibold">What these allow</h2>
<p className="max-w-3xl text-sm text-admin-muted">
The same limits in gigabytes, which is the unit anyone can actually judge. Saved
values only edit and save to see this change.
</p>
<div className="overflow-x-auto">
<table className="w-full min-w-[48rem] border-collapse text-sm">
<thead>
<tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
<th className="py-2 pr-4">Runtime</th>
{rules.map((rule) => (
<th key={rule.quality} className="py-2 pr-4 text-right uppercase">{rule.quality}</th>
))}
<th className="py-2 pr-4 text-right">4K + Atmos</th>
<th className="py-2 pr-4 text-right">4K timeless</th>
</tr>
</thead>
<tbody>
{PREVIEW_RUNTIMES.map((runtime) => {
const fourK = rules.find((rule) => rule.quality === "4k");
return (
<tr key={runtime.minutes} className="border-b border-admin-line/50">
<td className="py-3 pr-4">
<div>{runtime.label}</div>
<div className="text-xs text-admin-muted">{runtime.note}</div>
</td>
{rules.map((rule) => (
<td key={rule.quality} className="py-3 pr-4 text-right font-mono text-xs">
{gb(rule.min, runtime.minutes)}{gb(rule.max, runtime.minutes)} GB
</td>
))}
<td className="py-3 pr-4 text-right font-mono text-xs">
{fourK?.atmosMax
? `${gb(fourK.min, runtime.minutes)}${gb(fourK.atmosMax, runtime.minutes)} GB`
: "—"}
</td>
<td className="py-3 pr-4 text-right font-mono text-xs">
{fourK?.timelessMax
? `${gb(fourK.min, runtime.minutes)}${gb(fourK.timelessMax, runtime.minutes)} GB`
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use server";
// Manual search, the Sonarr-style "show me what is out there" button.
//
// Both actions re-check the session. Server Actions are reachable by direct
// POST, not only through the buttons on the page, so the layout's admin guard
// governs rendering and nothing else -- and the second of these adds torrents.
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
import {
grabRelease,
searchReleases,
IndexerUnavailableError,
type SearchResult,
} from "@/lib/indexer";
async function requireAdmin() {
const session = await auth();
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
}
// Errors are returned rather than thrown: an indexer being down, or the fetch
// stack being stopped, is an ordinary condition that belongs in the panel next
// to the results, not an error boundary that blanks the inventory page.
export type SearchState =
| { ok: true; result: SearchResult }
| { ok: false; error: string };
export async function runManualSearch(target: {
mediaItemId: string;
seasonNumber?: number | null;
episodeNumber?: number | null;
}): Promise<SearchState> {
await requireAdmin();
try {
return { ok: true, result: await searchReleases(target) };
} catch (error) {
if (error instanceof IndexerUnavailableError) return { ok: false, error: error.message };
return { ok: false, error: error instanceof Error ? error.message : "Search failed" };
}
}
export type GrabState = { ok: true; message: string } | { ok: false; error: string };
export async function grabCandidate(choice: {
searchId: string;
infoHash: string;
}): Promise<GrabState> {
await requireAdmin();
try {
const result = await grabRelease(choice);
// Sending it to qBittorrent changes what the Downloads page shows, and it
// changes the inventory row's "in flight" state.
revalidatePath("/admin/downloads");
revalidatePath("/admin/inventory");
revalidatePath("/admin/calendar");
return result.grabbed
? { ok: true, message: `Sent to qBittorrent: ${result.title}` }
: { ok: false, error: result.reason ?? "Not grabbed" };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "Grab failed" };
}
}
+181
View File
@@ -0,0 +1,181 @@
import { db } from "@/db/client";
import { storageAvailability, storageFiles, storageTiers } from "@/db/schema";
import { AGENT_TIMEOUT_SECONDS, formatSince, getAgentStatuses } from "@/lib/agents";
import { desc, sql } from "drizzle-orm";
export const dynamic = "force-dynamic";
function formatBytes(value: number | null) {
if (!value || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}
type AgentPath = { path: string; exists: boolean; mounted: boolean; readable: boolean; freeBytes: number };
function agentPaths(details: unknown): AgentPath[] {
const paths = (details as { paths?: unknown } | null)?.paths;
return Array.isArray(paths) ? (paths as AgentPath[]) : [];
}
function formatDate(value: Date | null) {
return value ? value.toISOString().slice(0, 16).replace("T", " ") : "Open";
}
export default async function AdminStoragePage() {
const agents = await getAgentStatuses();
const agentByName = new Map(agents.map((agent) => [agent.agent, agent]));
const [tiers, availabilityRows, inventoryRows] = await Promise.all([
db.select().from(storageTiers).orderBy(storageTiers.tier),
db.select().from(storageAvailability).orderBy(desc(storageAvailability.detectedAt)).limit(100),
db
.select({
tierId: storageFiles.tierId,
fileCount: sql<string>`count(*)::text`,
movieFileCount: sql<string>`count(*) filter (where ${storageFiles.relativePath} like 'Movies/%' or ${storageFiles.relativePath} like 'Movies-EN/%')::text`,
tvFileCount: sql<string>`count(*) filter (where ${storageFiles.relativePath} like 'Television/%')::text`,
totalSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}), 0))`,
movieSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}) filter (where ${storageFiles.relativePath} like 'Movies/%' or ${storageFiles.relativePath} like 'Movies-EN/%'), 0))`,
tvSize: sql<string>`pg_size_pretty(coalesce(sum(${storageFiles.sizeBytes}) filter (where ${storageFiles.relativePath} like 'Television/%'), 0))`,
})
.from(storageFiles)
.groupBy(storageFiles.tierId),
]);
const availabilityByTier = new Map<string, typeof availabilityRows>();
for (const row of availabilityRows) {
availabilityByTier.set(row.tierId, [...(availabilityByTier.get(row.tierId) ?? []), row]);
}
const inventoryByTier = new Map(inventoryRows.map((row) => [row.tierId, row]));
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Storage</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Storage Tiers</h1>
</div>
<section className="admin-panel p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="font-serif text-xl font-semibold">Agents</h2>
<p className="text-xs text-admin-muted">Considered offline after {AGENT_TIMEOUT_SECONDS}s of silence</p>
</div>
{agents.length ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{agents.map((agent) => {
const paths = agentPaths(agent.details);
return (
<div key={agent.agent} className="rounded-md border border-admin-line bg-admin-subpanel p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{agent.agent}</p>
<p className="text-xs text-admin-muted">{agent.hostname ?? "unknown host"}</p>
</div>
<span className={agent.online ? "text-sm font-semibold text-admin-good" : "text-sm font-semibold text-admin-warn"}>
{agent.online ? "Online" : "Offline"}
</span>
</div>
<p className="mt-2 text-xs text-admin-muted">Last beat {formatSince(agent.secondsSinceSeen)}</p>
{paths.length ? (
<ul className="mt-3 space-y-1 text-xs">
{paths.map((path) => (
<li key={path.path} className="flex items-center justify-between gap-2">
<span className="break-all text-admin-muted">{path.path}</span>
<span className={path.readable ? "shrink-0 text-admin-good" : "shrink-0 text-admin-warn"}>
{path.readable ? formatBytes(path.freeBytes) + " free" : "unreachable"}
</span>
</li>
))}
</ul>
) : null}
</div>
);
})}
</div>
) : (
<p className="mt-3 text-sm text-admin-muted">
No agents have reported yet. See <span className="font-mono">agent/README.md</span> to install the herald on silenus or edda.
</p>
)}
</section>
<div className="grid gap-5 xl:grid-cols-3">
{tiers.length ? tiers.map((tier) => {
const events = availabilityByTier.get(tier.id) ?? [];
const latest = events[0];
// When a tier names an agent, that agent's heartbeat is authoritative:
// storage on a powered-off host is not reachable no matter what the
// last scan observed.
const agent = tier.agentName ? agentByName.get(tier.agentName) : undefined;
const online = tier.agentName
? Boolean(agent?.online)
: tier.alwaysOnline || latest?.online;
const inventory = inventoryByTier.get(tier.id);
return (
<section key={tier.id} className="admin-panel p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-admin-muted">{tier.tier}</p>
<h2 className="mt-2 font-serif text-2xl font-semibold">{tier.label}</h2>
</div>
<span className={online ? "text-sm font-semibold text-admin-good" : "text-sm font-semibold text-admin-muted"}>{online ? "Online" : "Offline"}</span>
</div>
{tier.agentName ? (
<p className="mt-2 text-xs text-admin-muted">
Reachability from agent <span className="font-semibold">{tier.agentName}</span>
{agent ? ` — last beat ${formatSince(agent.secondsSinceSeen)}` : " — never reported"}
</p>
) : null}
<p className="mt-4 break-all text-sm text-admin-muted">{tier.basePath}</p>
{tier.notes ? <p className="mt-3 text-sm text-admin-muted">{tier.notes}</p> : null}
<div className="mt-5 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<p className="text-admin-muted">Movie files</p>
<p className="mt-1 text-xl font-semibold">{inventory?.movieFileCount ?? "0"}</p>
</div>
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<p className="text-admin-muted">Movie size</p>
<p className="mt-1 text-xl font-semibold">{inventory?.movieSize ?? "0 bytes"}</p>
</div>
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<p className="text-admin-muted">TV files</p>
<p className="mt-1 text-xl font-semibold">{inventory?.tvFileCount ?? "0"}</p>
</div>
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<p className="text-admin-muted">TV size</p>
<p className="mt-1 text-xl font-semibold">{inventory?.tvSize ?? "0 bytes"}</p>
</div>
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<p className="text-admin-muted">All files</p>
<p className="mt-1 text-xl font-semibold">{inventory?.fileCount ?? "0"}</p>
</div>
<div className="rounded-md border border-admin-line bg-admin-subpanel p-3">
<p className="text-admin-muted">All size</p>
<p className="mt-1 text-xl font-semibold">{inventory?.totalSize ?? "0 bytes"}</p>
</div>
</div>
<div className="mt-5 space-y-2">
<h3 className="text-sm font-semibold">Recent availability</h3>
{events.length ? events.slice(0, 5).map((event) => (
<div key={event.id} className="rounded-md border border-admin-line bg-admin-subpanel p-3 text-sm">
<div className="flex justify-between gap-3">
<span>{event.online ? "Online" : "Offline"}</span>
<span className="text-admin-muted">{formatDate(event.detectedAt)}</span>
</div>
<p className="mt-1 text-admin-muted">Resolved: {formatDate(event.resolvedAt)}</p>
</div>
)) : <p className="text-sm text-admin-muted">No availability observations yet.</p>}
</div>
</section>
);
}) : <section className="admin-panel p-5 text-sm text-admin-muted">No storage tiers configured yet.</section>}
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { db } from "@/db/client";
import { users, watchingNowItems, watchlistItems } from "@/db/schema";
import { count, desc, isNull } from "drizzle-orm";
export const dynamic = "force-dynamic";
function formatDate(value: Date) {
return value.toISOString().slice(0, 10);
}
export default async function AdminUsersPage() {
const [userRows, watchingCounts, watchlistCounts] = await Promise.all([
db.select().from(users).orderBy(desc(users.createdAt)),
db
.select({ userId: watchingNowItems.userId, activeCount: count() })
.from(watchingNowItems)
.where(isNull(watchingNowItems.removedAt))
.groupBy(watchingNowItems.userId),
db
.select({ userId: watchlistItems.userId, activeCount: count() })
.from(watchlistItems)
.where(isNull(watchlistItems.removedAt))
.groupBy(watchlistItems.userId),
]);
const watchingByUser = new Map(watchingCounts.map((row) => [row.userId, row.activeCount]));
const watchlistByUser = new Map(watchlistCounts.map((row) => [row.userId, row.activeCount]));
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">People</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Users</h1>
</div>
<section className="admin-panel overflow-hidden">
<div className="grid grid-cols-[1.4fr_1fr_0.6fr_0.6fr_0.7fr] gap-4 border-b border-admin-line px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-admin-muted">
<span>User</span>
<span>Email</span>
<span>Watch Now</span>
<span>Watchlist</span>
<span>Joined</span>
</div>
<div className="divide-y divide-admin-line">
{userRows.length ? userRows.map((user) => (
<div key={user.id} className="grid grid-cols-[1.4fr_1fr_0.6fr_0.6fr_0.7fr] gap-4 px-4 py-4 text-sm">
<div>
<p className="font-medium">{user.displayName}</p>
<p className="text-xs text-admin-muted">{user.isAdmin ? "Admin" : "User"}</p>
</div>
<p className="truncate text-admin-muted">{user.email}</p>
<p>{watchingByUser.get(user.id) ?? 0}/{user.watchingNowTvSlots + user.watchingNowMovieSlots}</p>
<p>{watchlistByUser.get(user.id) ?? 0}</p>
<p className="text-admin-muted">{formatDate(user.createdAt)}</p>
</div>
)) : <p className="p-5 text-sm text-admin-muted">No users found.</p>}
</div>
</section>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/db/client";
import { externalIds } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
/**
* Server Actions are reachable by a direct POST, not only through the buttons
* on the page, so the check belongs in here rather than in the layout that
* happens to render them.
*/
async function requireAdmin() {
const session = await auth();
if (!session) redirect("/login");
if (!session.user.isAdmin) redirect("/");
return session;
}
function optionalString(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
/**
* Record a judgement on a title's TMDB link.
*
* The three states are mutually exclusive, so each verdict clears the other:
* confirming a link that had been rejected has to actually un-reject it, or
* the title stays in the rejected queue forever and the reviewer's second look
* counts for nothing.
*
* `mediaItemId` rather than the external_ids row id, because the reviewer is
* answering a question about a TITLE -- is this the right film? -- and the row
* carrying the answer is an implementation detail they never see.
*/
async function recordVerdict(formData: FormData, verdict: "confirm" | "reject" | "reset") {
const session = await requireAdmin();
const mediaItemId = optionalString(formData.get("mediaItemId"));
if (!mediaItemId) return;
const now = new Date();
const set =
verdict === "confirm"
? { verifiedAt: now, verifiedBy: session.user.id, rejectedAt: null, rejectedBy: null }
: verdict === "reject"
? { verifiedAt: null, verifiedBy: null, rejectedAt: now, rejectedBy: session.user.id }
: { verifiedAt: null, verifiedBy: null, rejectedAt: null, rejectedBy: null };
await db
.update(externalIds)
.set(set)
.where(and(eq(externalIds.mediaItemId, mediaItemId), eq(externalIds.source, "tmdb")));
revalidatePath("/admin/verify");
revalidatePath("/admin/inventory");
}
export async function confirmTmdbLink(formData: FormData) {
await recordVerdict(formData, "confirm");
}
export async function rejectTmdbLink(formData: FormData) {
await recordVerdict(formData, "reject");
}
/** Put a title back in the queue -- for a verdict entered by mistake. */
export async function resetTmdbLink(formData: FormData) {
await recordVerdict(formData, "reset");
}
+402
View File
@@ -0,0 +1,402 @@
import { db } from "@/db/client";
import { externalIds, mediaItems } from "@/db/schema";
import { and, asc, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import Image from "next/image";
import Link from "next/link";
import { confirmTmdbLink, rejectTmdbLink, resetTmdbLink } from "./actions";
export const dynamic = "force-dynamic";
const PAGE_SIZE = 24;
const STATES = [
{ value: "needs", label: "Needs review", blurb: "nobody has looked at these yet" },
{ value: "rejected", label: "Rejected", blurb: "someone said the link is wrong; these need resolving" },
{ value: "confirmed", label: "Confirmed", blurb: "a person checked the poster against the files" },
] as const;
type ReviewState = (typeof STATES)[number]["value"];
const KINDS = [
{ value: "all", label: "Everything" },
{ value: "movie", label: "Films" },
{ value: "tv_series", label: "Series" },
] as const;
type Kind = (typeof KINDS)[number]["value"];
/**
* Why the evidence could not settle this link.
*
* The same three facts verify-tmdb-links.mjs tests, expressed as filters, so a
* reviewer can spend their attention where judgement is actually worth
* something. "The name disagrees" is 105 real decisions; "TMDB publishes no
* runtime" is three hundred titles where the third fact does not exist and
* clicking through them means re-checking by eye what the machine already
* checked better.
*/
const FAULTS = [
{ value: "any", label: "Any fault" },
{ value: "name", label: "Name disagrees" },
{ value: "runtime", label: "Runtime disagrees" },
{ value: "unmeasurable", label: "Nothing to measure" },
] as const;
type Fault = (typeof FAULTS)[number]["value"];
function parseState(raw: string | undefined): ReviewState {
return STATES.find((entry) => entry.value === raw)?.value ?? "needs";
}
function parseKind(raw: string | undefined): Kind {
return KINDS.find((entry) => entry.value === raw)?.value ?? "all";
}
function parseFault(raw: string | undefined): Fault {
return FAULTS.find((entry) => entry.value === raw)?.value ?? "any";
}
function parsePage(raw: string | undefined) {
const value = Number.parseInt(raw ?? "1", 10);
return Number.isFinite(value) && value > 0 ? value : 1;
}
function href(params: { state: ReviewState; kind: Kind; fault: Fault; page?: number }) {
const query = new URLSearchParams();
if (params.state !== "needs") query.set("state", params.state);
if (params.kind !== "all") query.set("kind", params.kind);
if (params.fault !== "any") query.set("fault", params.fault);
if (params.page && params.page > 1) query.set("page", String(params.page));
const search = query.toString();
return search ? `/admin/verify?${search}` : "/admin/verify";
}
/**
* The condition each review state stands for.
*
* Unreviewed is the ABSENCE of both marks rather than a state of its own, so a
* link that has never been looked at and one whose verdict was undone are the
* same thing -- which is what a reviewer means by "back in the queue".
*/
function stateFilter(state: ReviewState) {
if (state === "confirmed") return isNotNull(externalIds.verifiedAt);
if (state === "rejected") return isNotNull(externalIds.rejectedAt);
return and(isNull(externalIds.verifiedAt), isNull(externalIds.rejectedAt));
}
export default async function AdminVerifyPage({
searchParams,
}: {
searchParams: Promise<{ state?: string; kind?: string; fault?: string; page?: string }>;
}) {
const params = await searchParams;
const state = parseState(params.state);
const kind = parseKind(params.kind);
const fault = parseFault(params.fault);
const page = parsePage(params.page);
const kindFilter = kind === "all" ? undefined : eq(mediaItems.mediaType, kind);
// How far the files disagree with the runtime TMDB claims for this title.
// Not a verdict -- a reviewer still decides -- but it points the eye at the
// rows worth slowing down for, which is the difference between reviewing a
// queue and scrolling past one.
const runtimeGap = sql<number | null>`(
case ${mediaItems.mediaType}
when 'movie' then (
select round(min(abs(sf.duration_seconds / 60.0 - m.runtime_minutes)))
from storage_files sf, movies m
where sf.media_item_id = ${mediaItems.id} and m.id = ${mediaItems.id}
and sf.missing_at is null and sf.duration_seconds > 0 and m.runtime_minutes > 0)
else (
-- The median episode length has to be aggregated in its OWN subquery.
-- Subtracting the series' runtime from an aggregate in the same select
-- leaves that column ungrouped, which Postgres rejects at execution --
-- long after anything static would have caught it.
select round(abs((
select percentile_cont(0.5) within group (order by sf.duration_seconds / 60.0)
from storage_files sf
where sf.media_item_id = ${mediaItems.id}
and sf.missing_at is null and sf.duration_seconds > 0
) - s.episode_runtime_minutes))
from series s
where s.id = ${mediaItems.id} and s.episode_runtime_minutes > 0)
end)`;
// Both names reduced to letters and digits before comparing, because the
// folder is where punctuation goes to die: "Mission- Impossible", "The Man
// from U N C L E". Those are the same film written by a filesystem.
const nameAgrees = sql<boolean>`(
${externalIds.remoteTitle} is not null
and regexp_replace(lower(${mediaItems.title}), '[^a-z0-9]', '', 'g')
= regexp_replace(lower(${externalIds.remoteTitle}), '[^a-z0-9]', '', 'g'))`;
const unmeasurable = sql<boolean>`(${runtimeGap} is null)`;
const faultFilter =
fault === "name"
? sql`${externalIds.remoteTitle} is not null and not ${nameAgrees}`
: fault === "runtime"
? sql`${nameAgrees} and ${runtimeGap} is not null and ${runtimeGap} > 3`
: fault === "unmeasurable"
? sql`${unmeasurable}`
: undefined;
const where = and(eq(externalIds.source, "tmdb"), stateFilter(state), kindFilter, faultFilter);
const [rows, [totals]] = await Promise.all([
db
.select({
id: mediaItems.id,
title: mediaItems.title,
year: mediaItems.year,
kind: mediaItems.mediaType,
posterPath: mediaItems.posterPath,
tmdbId: externalIds.externalId,
remoteTitle: externalIds.remoteTitle,
nameAgrees,
verifiedAt: externalIds.verifiedAt,
rejectedAt: externalIds.rejectedAt,
runtimeGap,
fileCount: sql<number>`(select count(*)::int from storage_files sf
where sf.media_item_id = ${mediaItems.id} and sf.missing_at is null)`,
// Series carry up to a thousand files; nobody reviews a link by reading
// all of them. Four names from the largest files say what the folder
// holds, which is the question the poster is being checked against.
filenames: sql<string[]>`(select coalesce(array_agg(f.filename order by f.size_bytes desc), '{}')
from (select sf.filename, sf.size_bytes from storage_files sf
where sf.media_item_id = ${mediaItems.id} and sf.missing_at is null
order by sf.size_bytes desc limit 4) f)`,
})
.from(mediaItems)
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
.where(where)
.orderBy(desc(sql`coalesce(${runtimeGap}, -1)`), asc(mediaItems.sortTitle), asc(mediaItems.title))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE),
db
.select({
matching: sql<number>`count(*)::int`,
needs: sql<number>`count(*) filter (where ${externalIds.verifiedAt} is null and ${externalIds.rejectedAt} is null)::int`,
rejected: sql<number>`count(*) filter (where ${externalIds.rejectedAt} is not null)::int`,
confirmed: sql<number>`count(*) filter (where ${externalIds.verifiedAt} is not null)::int`,
})
.from(mediaItems)
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
// Scoped to the same fault, so the tallies on the state chips describe
// the pile actually being worked rather than the whole collection.
.where(and(eq(externalIds.source, "tmdb"), kindFilter, faultFilter)),
]);
const inState =
state === "needs" ? totals.needs : state === "rejected" ? totals.rejected : totals.confirmed;
const pages = Math.max(1, Math.ceil(inState / PAGE_SIZE));
const counts: Record<ReviewState, number> = {
needs: totals.needs,
rejected: totals.rejected,
confirmed: totals.confirmed,
};
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Metadata</p>
<h1 className="mt-2 font-serif text-3xl font-semibold">Verify Links</h1>
<p className="mt-2 max-w-3xl text-sm text-admin-muted">
Each title below was matched to TMDB by comparing a folder name against search results, which
has been wrong in ways nothing downstream can detect. Check that the poster and title are the
film the files hold. A tick records that a person confirmed it; a cross sets it aside to be
resolved. Titles the runtime disagrees with come first.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{STATES.map((entry) => (
<Link
key={entry.value}
href={href({ state: entry.value, kind, fault })}
title={entry.blurb}
className={
"rounded-full border px-3 py-1 text-xs font-medium transition " +
(entry.value === state
? "border-admin-accent text-admin-accent"
: "border-admin-line text-admin-muted hover:text-admin-text")
}
>
{entry.label}
<span className="ml-2 tabular-nums opacity-70">{counts[entry.value]}</span>
</Link>
))}
<span className="mx-1 h-4 w-px bg-admin-line" aria-hidden="true" />
{KINDS.map((entry) => (
<Link
key={entry.value}
href={href({ state, kind: entry.value, fault })}
className={
"rounded-full border px-3 py-1 text-xs font-medium transition " +
(entry.value === kind
? "border-admin-accent text-admin-accent"
: "border-admin-line text-admin-muted hover:text-admin-text")
}
>
{entry.label}
</Link>
))}
<span className="mx-1 h-4 w-px bg-admin-line" aria-hidden="true" />
{FAULTS.map((entry) => (
<Link
key={entry.value}
href={href({ state, kind, fault: entry.value })}
className={
"rounded-full border px-3 py-1 text-xs font-medium transition " +
(entry.value === fault
? "border-admin-accent text-admin-accent"
: "border-admin-line text-admin-muted hover:text-admin-text")
}
>
{entry.label}
</Link>
))}
</div>
{rows.length === 0 ? (
<p className="rounded-lg border border-admin-line px-4 py-8 text-center text-sm text-admin-muted">
{state === "needs"
? "Nothing left to review here."
: state === "rejected"
? "No rejected links."
: "Nothing confirmed yet."}
</p>
) : (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((row) => (
<li
key={row.id}
className="flex gap-3 rounded-lg border border-admin-line p-3"
>
<div className="relative h-[138px] w-[92px] shrink-0 overflow-hidden rounded bg-admin-line/40">
{row.posterPath ? (
<Image
src={`https://image.tmdb.org/t/p/w185${row.posterPath}`}
alt=""
fill
sizes="92px"
className="object-cover"
/>
) : (
<span className="flex h-full items-center justify-center text-[10px] text-admin-muted">
no art
</span>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate font-medium" title={row.title}>
{row.title}
</p>
{/* TMDB's own name for the id, shown only when it differs.
Repeating an identical name would add a line of noise to
every card and bury the handful that actually clash. */}
{row.remoteTitle && !row.nameAgrees ? (
<p className="truncate text-xs font-medium text-admin-warn" title={row.remoteTitle}>
tmdb: {row.remoteTitle}
</p>
) : null}
<p className="text-xs text-admin-muted">
{row.year ?? "year unknown"} · {row.kind === "movie" ? "film" : "series"} ·{" "}
<a
href={`https://www.themoviedb.org/${row.kind === "movie" ? "movie" : "tv"}/${row.tmdbId}`}
target="_blank"
rel="noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-admin-text"
>
tmdb:{row.tmdbId}
</a>
</p>
{row.runtimeGap != null && row.runtimeGap > 10 ? (
<p className="mt-1 text-xs text-admin-warn">
runtime off by {row.runtimeGap}m
</p>
) : null}
<ul className="mt-2 min-w-0 flex-1 space-y-0.5 text-[11px] leading-snug text-admin-muted">
{row.filenames.length === 0 ? (
<li className="italic">holds no files</li>
) : (
row.filenames.map((name) => (
<li key={name} className="truncate" title={name}>
{name}
</li>
))
)}
{row.fileCount > row.filenames.length ? (
<li className="opacity-70">and {row.fileCount - row.filenames.length} more</li>
) : null}
</ul>
<div className="mt-2 flex items-center gap-2">
{state === "needs" ? (
<>
<form action={confirmTmdbLink}>
<input type="hidden" name="mediaItemId" value={row.id} />
<button
type="submit"
title="This is the right title"
className="rounded border border-admin-line px-3 py-1 text-sm font-semibold text-admin-accent transition hover:border-admin-accent"
>
</button>
</form>
<form action={rejectTmdbLink}>
<input type="hidden" name="mediaItemId" value={row.id} />
<button
type="submit"
title="This is not the right title — set aside to resolve"
className="rounded border border-admin-line px-3 py-1 text-sm font-semibold text-admin-warn transition hover:border-admin-warn"
>
</button>
</form>
</>
) : (
<form action={resetTmdbLink}>
<input type="hidden" name="mediaItemId" value={row.id} />
<button
type="submit"
title="Put this back in the review queue"
className="rounded border border-admin-line px-3 py-1 text-xs text-admin-muted transition hover:text-admin-text"
>
{state === "confirmed" ? "Unconfirm" : "Back to queue"}
</button>
</form>
)}
</div>
</div>
</li>
))}
</ul>
)}
{pages > 1 ? (
<div className="flex items-center justify-between text-xs text-admin-muted">
<span>
Page {Math.min(page, pages)} of {pages} · {inState} title{inState === 1 ? "" : "s"}
</span>
<div className="flex gap-2">
{page > 1 ? (
<Link href={href({ state, kind, fault, page: page - 1 })} className="underline underline-offset-2">
Previous
</Link>
) : null}
{page < pages ? (
<Link href={href({ state, kind, fault, page: page + 1 })} className="underline underline-offset-2">
Next
</Link>
) : null}
</div>
</div>
) : null}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { auth, signOut } from "@/auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { AdminNav } from "./admin-nav";
const navItems = [
{ href: "/admin", label: "Dashboard" },
{ href: "/admin/users", label: "Users" },
{ href: "/admin/demand", label: "Demand" },
{ href: "/admin/policy", label: "Policy" },
{ href: "/admin/inventory", label: "Inventory" },
{ href: "/admin/verify", label: "Verify Links" },
{ href: "/admin/calendar", label: "Calendar" },
{ href: "/admin/downloads", label: "Downloads" },
{ href: "/admin/corrupt", label: "Corrupt Files" },
{ href: "/admin/storage", label: "Storage" },
{ href: "/admin/jobs", label: "Jobs" },
];
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) redirect("/login");
if (!session.user.isAdmin) redirect("/");
return (
<div className="ampelos-admin flex min-h-screen text-admin-text">
<nav className="ampelos-admin-sidebar flex w-60 shrink-0 flex-col px-4 py-6">
<Link href="/" className="mb-7 block">
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Sticknife</p>
<p className="mt-2 font-serif text-2xl font-semibold">Ampelos Admin</p>
</Link>
<AdminNav items={navItems} />
<div className="mt-auto pt-6">
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }}>
<button type="submit" className="admin-nav-button w-full px-3 py-2 text-left text-sm font-medium">
Sign out
</button>
</form>
</div>
</nav>
<main className="min-w-0 flex-1 overflow-x-auto px-6 py-8 lg:px-10">{children}</main>
</div>
);
}