Ampelos dashboard: the web face, and the owner of the schema
Split out of the single Ampelos repository. Next.js app, Drizzle schema and migrations, brand art, planning notes. What left: scripts/, which was the agent's job library misfiled under web/ and imported nothing from src/; and deploy/truenas, whose broadcast posts to the scan listener on :3427 -- an agent script -- so it belongs beside the thing it talks to. This repository keeps the schema. The agent speaks raw SQL against the same tables and holds no copy of it, so a rename here can break it silently where it used to be one commit. The README says so, and the agent carries a snapshot to check against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 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 · {seasonFiles.length} files · {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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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× E-AC-3 and 4× AAC per minute. It is read from the release
|
||||
title, which is trustworthy in a way our own filenames are not: an encoder advertises
|
||||
“TrueHD 7.1 Atmos” 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’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>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user