"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 (
{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}
{state.error}
) : null} {notice ? ({notice.ok ? notice.message : notice.error}
) : null} {result ? ({result.label} {" — "} {result.candidates.length} results from {result.indexersQueried} indexers {" · "}{accepted.length} usable {" · "}{(result.durationMs / 1000).toFixed(1)}s
{rejected.length ? ( ) : null}{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}{rejected.length ? `No usable release. All ${rejected.length} results were rejected — show them to see why.` : "No indexer returned anything for this."}
) : null}