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:
Ryan
2026-08-15 12:12:08 +02:00
commit acdc25c797
138 changed files with 70946 additions and 0 deletions
+64
View File
@@ -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" };
}
}