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:
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
|
||||
// Manual search, the Sonarr-style "show me what is out there" button.
|
||||
//
|
||||
// Both actions re-check the session. Server Actions are reachable by direct
|
||||
// POST, not only through the buttons on the page, so the layout's admin guard
|
||||
// governs rendering and nothing else -- and the second of these adds torrents.
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
grabRelease,
|
||||
searchReleases,
|
||||
IndexerUnavailableError,
|
||||
type SearchResult,
|
||||
} from "@/lib/indexer";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Errors are returned rather than thrown: an indexer being down, or the fetch
|
||||
// stack being stopped, is an ordinary condition that belongs in the panel next
|
||||
// to the results, not an error boundary that blanks the inventory page.
|
||||
export type SearchState =
|
||||
| { ok: true; result: SearchResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function runManualSearch(target: {
|
||||
mediaItemId: string;
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
}): Promise<SearchState> {
|
||||
await requireAdmin();
|
||||
try {
|
||||
return { ok: true, result: await searchReleases(target) };
|
||||
} catch (error) {
|
||||
if (error instanceof IndexerUnavailableError) return { ok: false, error: error.message };
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Search failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export type GrabState = { ok: true; message: string } | { ok: false; error: string };
|
||||
|
||||
export async function grabCandidate(choice: {
|
||||
searchId: string;
|
||||
infoHash: string;
|
||||
}): Promise<GrabState> {
|
||||
await requireAdmin();
|
||||
try {
|
||||
const result = await grabRelease(choice);
|
||||
// Sending it to qBittorrent changes what the Downloads page shows, and it
|
||||
// changes the inventory row's "in flight" state.
|
||||
revalidatePath("/admin/downloads");
|
||||
revalidatePath("/admin/inventory");
|
||||
revalidatePath("/admin/calendar");
|
||||
return result.grabbed
|
||||
? { ok: true, message: `Sent to qBittorrent: ${result.title}` }
|
||||
: { ok: false, error: result.reason ?? "Not grabbed" };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Grab failed" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user