"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 (
{/* The full release name, unabbreviated and selectable. A group that ships broken files is identified by this string and no other. */}

{candidate.title}

{candidate.indexerName} {candidate.origin ? via {candidate.origin} : null} {candidate.indexerCount > 1 ? ( ×{candidate.indexerCount} indexers ) : null} {candidate.hasAtmos ? Atmos : null} {candidate.isSeasonPack ? season pack : null} {!candidate.accepted ? ( {rejectionLabel(candidate.rejection)} ) : null}

{candidate.quality ?? "unknown"} {candidate.source ?? "—"} {candidate.seeders === null ? "?" : candidate.seeders} {formatBytes(candidate.size)} {candidate.accepted ? candidate.score : "—"}
); } 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(null); const [notice, setNotice] = useState(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 (
{state && !state.ok ? (

{state.error}

) : null} {notice ? (

{notice.ok ? notice.message : notice.error}

) : null} {result ? (
event.stopPropagation()}>

{result.label} {" — "} {result.candidates.length} results from {result.indexersQueried} indexers {" · "}{accepted.length} usable {" · "}{(result.durationMs / 1000).toFixed(1)}s

{rejected.length ? ( ) : null}
{/* 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 ? (

{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(" · ")}

) : null}
ReleaseQualitySource SeedSizeScore
{shown.map((candidate, index) => ( ))}
{!shown.length ? (

{rejected.length ? `No usable release. All ${rejected.length} results were rejected — show them to see why.` : "No indexer returned anything for this."}

) : null}
) : null}
); }