Ampelos dashboard: the web face, and the owner of the schema

Split out of the single Ampelos repository. Next.js app, Drizzle schema and
migrations, brand art, planning notes.

What left: scripts/, which was the agent's job library misfiled under web/ and
imported nothing from src/; and deploy/truenas, whose broadcast posts to the
scan listener on :3427 -- an agent script -- so it belongs beside the thing it
talks to.

This repository keeps the schema. The agent speaks raw SQL against the same
tables and holds no copy of it, so a rename here can break it silently where it
used to be one commit. The README says so, and the agent carries a snapshot to
check against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ryan
2026-08-15 12:12:08 +02:00
commit acdc25c797
138 changed files with 70946 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
"use client";
// The manual search panel: what Sonarr's magnifying glass does.
//
// The design principle here is that a rejected release is information, not
// noise. When the automatic pass reports "nothing found", the useful answer is
// almost never "the internet is empty" -- it is "eleven results, and every one
// of them was a telesync" or "the only copy is 40MB, which is a fake". So this
// lists everything the fan-out saw, marks what the rules refused and why, and
// still lets a person take it anyway. They can see the reason; they can
// overrule it.
import { useState, useTransition } from "react";
import {
runManualSearch,
grabCandidate,
type SearchState,
type GrabState,
} from "./search-actions";
import type { SearchCandidate } from "@/lib/indexer";
function formatBytes(value: number | null) {
if (value === null || !Number.isFinite(value) || value <= 0) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}
// The rejection reasons are machine strings from decide.mjs. They are readable
// enough to keep as a tooltip, but the badge should say the thing in English.
function rejectionLabel(reason: string | null) {
if (!reason) return "Rejected";
const [kind, detail] = reason.split(":");
switch (kind) {
case "blocklisted": return "Blocklisted";
case "blocked-group": return `Blocked group ${detail ?? ""}`.trim();
case "rejected": return detail === "cam" ? "Camcorder rip" : `Rejected (${detail})`;
case "below-min-quality": return `Below minimum quality`;
case "too-few-seeders": return `Only ${detail} seeders`;
case "seeders-unknown-and-required": return "Swarm size unknown";
case "too-small": return "Suspiciously small";
case "too-large": return "Larger than wanted";
case "season-pack-not-wanted": return "Season pack";
case "no-download-link": return "No usable link";
default: return reason;
}
}
function Candidate({
candidate,
searchId,
onGrabbed,
}: {
candidate: SearchCandidate;
searchId: string;
onGrabbed: (state: GrabState) => void;
}) {
const [isPending, startTransition] = useTransition();
function grab() {
if (!candidate.infoHash) return;
startTransition(async () => {
onGrabbed(await grabCandidate({ searchId, infoHash: candidate.infoHash! }));
});
}
return (
<div className={"grid gap-2 px-3 py-2 text-xs lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]" + (candidate.accepted ? "" : " bg-admin-missing")}>
<div className="min-w-0">
{/* The full release name, unabbreviated and selectable. A group that
ships broken files is identified by this string and no other. */}
<p className="break-all font-mono text-admin-text">{candidate.title}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-admin-muted">
<span>{candidate.indexerName}</span>
{candidate.origin ? <span>via {candidate.origin}</span> : null}
{candidate.indexerCount > 1 ? (
<span title="Found on more than one indexer, which makes a fake less likely" className="text-admin-good">
×{candidate.indexerCount} indexers
</span>
) : null}
{candidate.hasAtmos ? <span className="text-admin-accent">Atmos</span> : null}
{candidate.isSeasonPack ? <span>season pack</span> : null}
{!candidate.accepted ? (
<span title={candidate.rejection ?? ""} className="font-semibold text-admin-warn">
{rejectionLabel(candidate.rejection)}
</span>
) : null}
</p>
</div>
<span className="text-admin-muted">{candidate.quality ?? "unknown"}</span>
<span className="text-admin-muted">{candidate.source ?? "—"}</span>
<span className={candidate.seeders === null ? "text-admin-warn" : "text-admin-text"}>
{candidate.seeders === null ? "?" : candidate.seeders}
</span>
<span className="text-admin-muted">{formatBytes(candidate.size)}</span>
<span title={candidate.reasons.join("\n")} className="cursor-help font-semibold">
{candidate.accepted ? candidate.score : "—"}
</span>
<span>
<button
type="button"
onClick={grab}
disabled={isPending || !candidate.infoHash}
title={
candidate.accepted
? "Send to qBittorrent"
: "Send to qBittorrent anyway, overruling: " + (candidate.rejection ?? "")
}
className={
"admin-nav-button w-full px-2 py-1 text-xs font-medium " +
(candidate.accepted ? "" : "text-admin-warn")
}
>
{isPending ? "…" : candidate.accepted ? "Grab" : "Grab anyway"}
</button>
</span>
</div>
);
}
export function ManualSearch({
mediaItemId,
seasonNumber = null,
episodeNumber = null,
label,
compact = false,
}: {
mediaItemId: string;
seasonNumber?: number | null;
episodeNumber?: number | null;
label?: string;
compact?: boolean;
}) {
const [state, setState] = useState<SearchState | null>(null);
const [notice, setNotice] = useState<GrabState | null>(null);
const [showRejected, setShowRejected] = useState(false);
const [isPending, startTransition] = useTransition();
function search() {
setNotice(null);
startTransition(async () => {
setState(await runManualSearch({ mediaItemId, seasonNumber, episodeNumber }));
});
}
const result = state?.ok ? state.result : null;
const accepted = result?.candidates.filter((c) => c.accepted) ?? [];
const rejected = result?.candidates.filter((c) => !c.accepted) ?? [];
const shown = showRejected ? [...accepted, ...rejected] : accepted;
return (
<div className={compact ? "" : "space-y-2"}>
<button
type="button"
onClick={(event) => { event.stopPropagation(); search(); }}
disabled={isPending}
className="admin-nav-button px-2 py-1 text-xs font-medium"
title={"Search indexers for " + (label ?? "this title")}
>
{isPending ? "Searching…" : state ? "Search again" : "Search"}
</button>
{state && !state.ok ? (
<p className="mt-2 rounded-md border border-admin-warn px-3 py-2 text-xs text-admin-warn">
{state.error}
</p>
) : null}
{notice ? (
<p className={"mt-2 text-xs " + (notice.ok ? "text-admin-good" : "text-admin-warn")}>
{notice.ok ? notice.message : notice.error}
</p>
) : null}
{result ? (
<div className="mt-2 rounded-md border border-admin-line bg-admin-subpanel" onClick={(event) => event.stopPropagation()}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-3 py-2">
<p className="text-xs text-admin-muted">
<span className="font-semibold text-admin-text">{result.label}</span>
{" — "}
{result.candidates.length} results from {result.indexersQueried} indexers
{" · "}{accepted.length} usable
{" · "}{(result.durationMs / 1000).toFixed(1)}s
</p>
{rejected.length ? (
<button
type="button"
onClick={() => setShowRejected((value) => !value)}
className="admin-nav-button px-2 py-1 text-xs font-medium"
>
{showRejected ? "Hide" : "Show"} {rejected.length} rejected
</button>
) : null}
</div>
{/* Why an indexer contributed nothing. Without this, a thin result
set reads as "nothing exists" when the real answer is that EZTV
was skipped because this series has no IMDB id recorded. */}
{result.skipped.length || result.errors.length ? (
<p className="border-b border-admin-line px-3 py-2 text-xs text-admin-muted">
{result.skipped.map((s) => `${s.indexer}: ${s.reason}`).join(" · ")}
{result.skipped.length && result.errors.length ? " · " : ""}
{result.errors.map((e) => `${e.indexer} failed: ${e.error}`).join(" · ")}
</p>
) : null}
<div className="hidden border-b border-admin-line px-3 py-1.5 text-[10px] uppercase tracking-[0.18em] text-admin-muted lg:grid lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]">
<span>Release</span><span>Quality</span><span>Source</span>
<span>Seed</span><span>Size</span><span>Score</span><span />
</div>
<div className="divide-y divide-admin-line">
{shown.map((candidate, index) => (
<Candidate
key={(candidate.infoHash ?? "no-hash") + ":" + index}
candidate={candidate}
searchId={result.searchId}
onGrabbed={setNotice}
/>
))}
</div>
{!shown.length ? (
<p className="px-3 py-3 text-xs text-admin-muted">
{rejected.length
? `No usable release. All ${rejected.length} results were rejected — show them to see why.`
: "No indexer returned anything for this."}
</p>
) : null}
</div>
) : null}
</div>
);
}