acdc25c797
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>
242 lines
9.3 KiB
TypeScript
242 lines
9.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|