Files
ampelos-dashboard/src/app/(admin)/admin/manual-search.tsx
T
Ryan acdc25c797 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>
2026-08-15 12:12:08 +02:00

242 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}