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
+49
View File
@@ -0,0 +1,49 @@
import { db } from "@/db/client";
import { agentHeartbeats } from "@/db/schema";
// How long an agent may stay silent before its storage is treated as
// unreachable. Agents are expected to report well inside this window.
export const AGENT_TIMEOUT_SECONDS = Number.parseInt(
process.env.AMPELOS_AGENT_TIMEOUT_SECONDS ?? "300",
10,
);
export type AgentStatus = {
agent: string;
hostname: string | null;
version: string | null;
lastSeenAt: Date;
firstSeenAt: Date;
details: unknown;
online: boolean;
secondsSinceSeen: number;
};
export function isFresh(lastSeenAt: Date, now = Date.now()) {
return (now - lastSeenAt.getTime()) / 1000 < AGENT_TIMEOUT_SECONDS;
}
export async function getAgentStatuses(): Promise<AgentStatus[]> {
const rows = await db.select().from(agentHeartbeats);
const now = Date.now();
return rows
.map((row) => ({
agent: row.agent,
hostname: row.hostname,
version: row.version,
lastSeenAt: row.lastSeenAt,
firstSeenAt: row.firstSeenAt,
details: row.details,
online: isFresh(row.lastSeenAt, now),
secondsSinceSeen: Math.max(0, Math.round((now - row.lastSeenAt.getTime()) / 1000)),
}))
.sort((a, b) => a.agent.localeCompare(b.agent));
}
export function formatSince(seconds: number) {
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
+5
View File
@@ -0,0 +1,5 @@
export const APP_URL = process.env.AUTH_URL ?? "https://ampelos.sticknife.com";
export function appUrl(path = "/") {
return new URL(path, APP_URL).toString();
}
+14
View File
@@ -0,0 +1,14 @@
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
groups: string[];
isAdmin: boolean;
};
}
}
+335
View File
@@ -0,0 +1,335 @@
import { db } from "@/db/client";
import { classics, externalIds, mediaItems } from "@/db/schema";
import { and, eq } from "drizzle-orm";
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w342";
const TMDB_BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w780";
const TMDB_PROFILE_BASE_URL = "https://image.tmdb.org/t/p/w185";
export type CatalogKind = "television" | "movies" | "music";
export type CatalogItem = {
id: string;
source: "tmdb";
kind: Exclude<CatalogKind, "music">;
title: string;
year: string | null;
overview: string | null;
posterUrl: string | null;
backdropUrl: string | null;
rating: number | null;
popularity: number | null;
releaseDate: string | null;
isClassic?: boolean;
};
export type CatalogPerson = {
id: string;
name: string;
role: string | null;
profileUrl: string | null;
};
export type CatalogDetails = CatalogItem & {
tagline: string | null;
status: string | null;
runtime: string | null;
genres: string[];
cast: CatalogPerson[];
creators: CatalogPerson[];
networks: string[];
seasonCount: number | null;
episodeCount: number | null;
homepage: string | null;
};
type TmdbMovie = {
id: number;
title?: string;
release_date?: string;
overview?: string;
poster_path?: string | null;
backdrop_path?: string | null;
vote_average?: number;
popularity?: number;
};
type TmdbTv = {
id: number;
name?: string;
first_air_date?: string;
overview?: string;
poster_path?: string | null;
backdrop_path?: string | null;
vote_average?: number;
popularity?: number;
};
type TmdbListResponse<T> = {
results?: T[];
};
type TmdbPerson = {
id?: number;
name?: string;
character?: string;
job?: string;
profile_path?: string | null;
};
type TmdbGenre = {
name?: string;
};
type TmdbNetwork = {
name?: string;
};
type TmdbMovieDetails = TmdbMovie & {
tagline?: string;
status?: string;
runtime?: number;
genres?: TmdbGenre[];
homepage?: string;
credits?: { cast?: TmdbPerson[]; crew?: TmdbPerson[] };
};
type TmdbTvDetails = TmdbTv & {
tagline?: string;
status?: string;
episode_run_time?: number[];
genres?: TmdbGenre[];
homepage?: string;
created_by?: TmdbPerson[];
networks?: TmdbNetwork[];
number_of_seasons?: number;
number_of_episodes?: number;
credits?: { cast?: TmdbPerson[]; crew?: TmdbPerson[] };
};
const tmdbCredential = process.env.MOVIEDB_API;
function tmdbHeaders(): HeadersInit {
if (tmdbCredential?.startsWith("eyJ")) {
return { Authorization: `Bearer ${tmdbCredential}` };
}
return {};
}
function withTmdbApiKey(url: URL) {
if (!tmdbCredential || tmdbCredential.startsWith("eyJ")) {
return url;
}
url.searchParams.set("api_key", tmdbCredential);
return url;
}
async function fetchTmdb<T>(path: string, searchParams: Record<string, string>) {
if (!tmdbCredential) {
throw new Error("Missing MOVIEDB_API in .env.local");
}
const url = withTmdbApiKey(new URL(`${TMDB_BASE_URL}${path}`));
for (const [key, value] of Object.entries(searchParams)) {
url.searchParams.set(key, value);
}
const response = await fetch(url, {
headers: tmdbHeaders(),
next: { revalidate: 900 },
});
if (!response.ok) {
throw new Error(`TMDB request failed: ${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
function imageUrl(path?: string | null) {
return path ? `${TMDB_IMAGE_BASE_URL}${path}` : null;
}
function backdropUrl(path?: string | null) {
return path ? `${TMDB_BACKDROP_BASE_URL}${path}` : null;
}
function profileUrl(path?: string | null) {
return path ? `${TMDB_PROFILE_BASE_URL}${path}` : null;
}
function yearFrom(date?: string) {
return date ? date.slice(0, 4) : null;
}
function normalizeMovie(movie: TmdbMovie): CatalogItem {
const releaseDate = movie.release_date ?? null;
return {
id: String(movie.id),
source: "tmdb",
kind: "movies",
title: movie.title ?? "Untitled movie",
year: yearFrom(movie.release_date),
overview: movie.overview || null,
posterUrl: imageUrl(movie.poster_path),
backdropUrl: backdropUrl(movie.backdrop_path),
rating: typeof movie.vote_average === "number" ? movie.vote_average : null,
popularity: typeof movie.popularity === "number" ? movie.popularity : null,
releaseDate,
};
}
function normalizeTv(show: TmdbTv): CatalogItem {
const releaseDate = show.first_air_date ?? null;
return {
id: String(show.id),
source: "tmdb",
kind: "television",
title: show.name ?? "Untitled series",
year: yearFrom(show.first_air_date),
overview: show.overview || null,
posterUrl: imageUrl(show.poster_path),
backdropUrl: backdropUrl(show.backdrop_path),
rating: typeof show.vote_average === "number" ? show.vote_average : null,
popularity: typeof show.popularity === "number" ? show.popularity : null,
releaseDate,
};
}
async function withClassicFlags(kind: Exclude<CatalogKind, "music">, items: CatalogItem[]) {
if (!items.length) {
return items;
}
const mediaType = kind === "movies" ? "movie" : "tv_series";
const rows = await db
.select({
title: mediaItems.title,
year: mediaItems.year,
externalId: externalIds.externalId,
})
.from(classics)
.innerJoin(mediaItems, eq(mediaItems.id, classics.mediaItemId))
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), eq(externalIds.source, "tmdb")))
.where(eq(mediaItems.mediaType, mediaType));
const externalIdSet = new Set(rows.map((row) => row.externalId).filter((value): value is string => Boolean(value)));
const titleYearSet = new Set(rows.map((row) => row.title.toLocaleLowerCase("en-US") + "::" + (row.year ?? "")));
return items.map((item) => {
const titleYearKey = item.title.toLocaleLowerCase("en-US") + "::" + (item.year ?? "");
return externalIdSet.has(item.id) || titleYearSet.has(titleYearKey)
? { ...item, isClassic: true }
: item;
});
}
export async function getCatalogItems(kind: CatalogKind, query: string) {
const trimmedQuery = query.trim();
if (kind === "music") {
return [];
}
if (kind === "movies") {
const path = trimmedQuery ? "/search/movie" : "/movie/popular";
const response = await fetchTmdb<TmdbListResponse<TmdbMovie>>(path, {
...(trimmedQuery ? { query: trimmedQuery } : {}),
include_adult: "false",
language: "en-US",
page: "1",
});
return withClassicFlags(kind, (response.results ?? []).slice(0, 18).map(normalizeMovie));
}
const path = trimmedQuery ? "/search/tv" : "/tv/popular";
const response = await fetchTmdb<TmdbListResponse<TmdbTv>>(path, {
...(trimmedQuery ? { query: trimmedQuery } : {}),
include_adult: "false",
language: "en-US",
page: "1",
});
return withClassicFlags(kind, (response.results ?? []).slice(0, 18).map(normalizeTv));
}
function normalizePerson(person: TmdbPerson): CatalogPerson | null {
if (!person.name) {
return null;
}
return {
id: String(person.id ?? person.name),
name: person.name,
role: person.character ?? person.job ?? null,
profileUrl: profileUrl(person.profile_path),
};
}
function peopleFrom(list: TmdbPerson[] | undefined, limit: number) {
return (list ?? [])
.map(normalizePerson)
.filter((person): person is CatalogPerson => person !== null)
.slice(0, limit);
}
function minutesToRuntime(minutes?: number) {
if (!minutes) {
return null;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return hours ? hours + "h " + remainingMinutes + "m" : remainingMinutes + "m";
}
export async function getCatalogDetails(kind: Exclude<CatalogKind, "music">, id: string): Promise<CatalogDetails> {
if (kind === "movies") {
const movie = await fetchTmdb<TmdbMovieDetails>("/movie/" + encodeURIComponent(id), {
append_to_response: "credits,external_ids",
language: "en-US",
});
const item = normalizeMovie(movie);
return {
...item,
tagline: movie.tagline || null,
status: movie.status || null,
runtime: minutesToRuntime(movie.runtime),
genres: (movie.genres ?? []).map((genre) => genre.name).filter((name): name is string => Boolean(name)),
cast: peopleFrom(movie.credits?.cast, 10),
creators: peopleFrom(movie.credits?.crew?.filter((person) => person.job === "Director"), 4),
networks: [],
seasonCount: null,
episodeCount: null,
homepage: movie.homepage || null,
};
}
const show = await fetchTmdb<TmdbTvDetails>("/tv/" + encodeURIComponent(id), {
append_to_response: "credits,external_ids",
language: "en-US",
});
const item = normalizeTv(show);
return {
...item,
tagline: show.tagline || null,
status: show.status || null,
runtime: minutesToRuntime(show.episode_run_time?.[0]),
genres: (show.genres ?? []).map((genre) => genre.name).filter((name): name is string => Boolean(name)),
cast: peopleFrom(show.credits?.cast, 10),
creators: peopleFrom(show.created_by, 4),
networks: (show.networks ?? []).map((network) => network.name).filter((name): name is string => Boolean(name)),
seasonCount: show.number_of_seasons ?? null,
episodeCount: show.number_of_episodes ?? null,
homepage: show.homepage || null,
};
}
+99
View File
@@ -0,0 +1,99 @@
// Client for the manual-search API in the fetch stack.
//
// The dashboard cannot search indexers itself: it runs on the host, outside the
// VPN, and every query would announce this house's real address. So it asks the
// indexer container, which lives in gluetun's network namespace, over loopback.
//
// Loopback is also why there is no TLS here and why that is fine -- the socket
// is published as 127.0.0.1:8081 and never leaves this machine.
const BASE_URL = process.env.AMPELOS_INDEXER_URL ?? "http://127.0.0.1:8081";
const TOKEN = process.env.AMPELOS_AGENT_TOKEN ?? "";
// A fan-out across five indexers, some of them slow, some of them behind a VPN
// hop. Long enough to be patient, short enough that a hung indexer does not
// hold a server action open indefinitely.
const SEARCH_TIMEOUT_MS = 60_000;
export type SearchCandidate = {
infoHash: string | null;
title: string;
size: number | null;
seeders: number | null;
leechers: number | null;
indexerName: string;
origin: string | null;
publishedAt: string | null;
indexerCount: number;
quality: string | null;
source: string | null;
group: string | null;
isSeasonPack: boolean;
hasAtmos: boolean;
accepted: boolean;
score: number;
reasons: string[];
rejection: string | null;
};
export type SearchResult = {
searchId: string;
label: string;
durationMs: number;
indexersQueried: number;
skipped: { indexer: string; reason: string }[];
errors: { indexer: string; error: string }[];
candidates: SearchCandidate[];
};
export class IndexerUnavailableError extends Error {}
async function call<T>(path: string, body: unknown): Promise<T> {
if (!TOKEN) {
throw new IndexerUnavailableError(
"AMPELOS_AGENT_TOKEN is not set, so the dashboard cannot authenticate to the search service.",
);
}
let response: Response;
try {
response = await fetch(BASE_URL + path, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer " + TOKEN,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
cache: "no-store",
});
} catch (error) {
// The common cause by far is the fetch stack being down, which is a
// perfectly ordinary state, so say that rather than surfacing ECONNREFUSED.
const detail = error instanceof Error ? error.message : String(error);
throw new IndexerUnavailableError(
`The search service is not answering on ${BASE_URL} (${detail}). Is the fetch stack running?`,
);
}
const payload = (await response.json().catch(() => ({}))) as { error?: string } & T;
if (!response.ok) {
throw new Error(payload.error ?? `Search service returned ${response.status}`);
}
return payload;
}
export function searchReleases(target: {
mediaItemId: string;
seasonNumber?: number | null;
episodeNumber?: number | null;
}) {
return call<SearchResult>("/search", target);
}
export function grabRelease(choice: { searchId: string; infoHash: string }) {
return call<{ grabbed: boolean; grabId?: string; title?: string; reason?: string }>(
"/grab",
choice,
);
}
+96
View File
@@ -0,0 +1,96 @@
// Turning a TMDB catalog entry into a row in the collection.
//
// The public board is a TMDB browser: almost everything on it is a title
// Ampelos has never heard of, so every action that expresses an opinion about
// one -- Watch Now, watchlist, timeless -- has to be able to bring it into
// being first. That was inline in the Watch Now action and is shared now
// because a second copy of it would drift, and the two copies disagreeing about
// what a new media item looks like is a bug nobody would see until the fetcher
// went looking for a series with no `series` row.
//
// Identity only. Overview and poster come along because the board already has
// them and a title with neither reads as broken in the admin lists, but
// runtime, release date, and a series' season and episode list are
// refresh-metadata's job -- and it trusts the TMDB id written here over any
// title search, which is what keeps a created row pointing at what the person
// actually clicked.
import { and, eq } from "drizzle-orm";
import { db } from "@/db/client";
import { externalIds, mediaItems, movies, series } from "@/db/schema";
export type MediaType = "tv_series" | "movie";
export type CatalogRef = {
mediaType: MediaType;
tmdbId: string;
title: string;
year: number | null;
overview?: string | null;
posterPath?: string | null;
releaseDate?: string | null;
};
/** The media item for a TMDB id, or null if the collection has never seen it. */
export async function findMediaItemByTmdbId(mediaType: MediaType, tmdbId: string) {
const [row] = await db
.select({ mediaItemId: externalIds.mediaItemId })
.from(externalIds)
.innerJoin(mediaItems, eq(mediaItems.id, externalIds.mediaItemId))
.where(
and(
eq(externalIds.source, "tmdb"),
eq(externalIds.externalId, tmdbId),
eq(mediaItems.mediaType, mediaType),
),
)
.limit(1);
return row?.mediaItemId ?? null;
}
/**
* The media item for this catalog entry, creating it if it does not exist.
*
* The media type is part of the lookup because TMDB numbers films and series in
* separate spaces: id 1399 is both a film and a series, and matching on the
* number alone would hand somebody's film the series' row.
*/
export async function ensureMediaItem(ref: CatalogRef) {
const existing = await findMediaItemByTmdbId(ref.mediaType, ref.tmdbId);
if (existing) return existing;
const [created] = await db
.insert(mediaItems)
.values({
mediaType: ref.mediaType,
title: ref.title,
sortTitle: ref.title.toLocaleLowerCase("en-US"),
overview: ref.overview ?? null,
year: ref.year,
posterPath: ref.posterPath ?? null,
})
.returning({ id: mediaItems.id });
if (ref.mediaType === "movie") {
await db.insert(movies).values({ id: created.id, releaseDate: ref.releaseDate ?? null });
} else {
await db.insert(series).values({ id: created.id, firstAirDate: ref.releaseDate ?? null });
}
// Verified on arrival. Everywhere else the TMDB id is a guess made by
// matching a folder name against search results; here somebody clicked a
// specific TMDB entry and asked for THAT. The id is the request, not an
// inference about it, so there is nothing left for a human to check.
//
// ON CONFLICT DO NOTHING, because the unique index is on (source, id) alone
// and losing that race must not fail the action the person actually asked
// for. The row is still found by the lookup above on the next attempt.
await db
.insert(externalIds)
.values({ mediaItemId: created.id, source: "tmdb", externalId: ref.tmdbId, verifiedAt: new Date() })
.onConflictDoNothing();
return created.id;
}
+208
View File
@@ -0,0 +1,208 @@
// Talking to Plex.
//
// Plex spreads what looks like one product over four hosts, and which one
// answers depends on what is being asked:
//
// <server>:32400 the library itself, on the LAN
// plex.tv accounts, sharing, and the PIN sign-in flow
// discover.provider… the owner's watchlist, with external ids attached
// community.plex.tv everybody else's watchlist, over GraphQL
//
// Only the owner's token is ever used. Linking a user proves who they are and
// then discards their token; see plexAccounts in the schema for why.
const PLEX_TOKEN = process.env.PLEX_AUTH_TOKEN ?? "";
const MACHINE_ID = process.env.PLEX_MACHINE_IDENTIFIER ?? "";
// Identifies Ampelos to Plex. Stable on purpose: the PIN flow ties a sign-in to
// the client that started it, so a value that changed per request would make
// every link fail at the last step.
export const CLIENT_IDENTIFIER = "ampelos";
function ownerHeaders(extra: Record<string, string> = {}) {
return {
"X-Plex-Token": PLEX_TOKEN,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
"X-Plex-Product": "Ampelos",
Accept: "application/json",
...extra,
};
}
/** The LAN address of the server, with the scheme the env may omit. */
export function serverUrl() {
let base = (process.env.PLEX_URL ?? "").replace(/\/$/, "");
if (!base) return null;
if (!/^https?:\/\//.test(base)) base = `http://${base}`;
const port = process.env.PLEX_PORT;
return /:\d+$/.test(base) || !port ? base : `${base}:${port}`;
}
export type PlexSection = {
/** plex.tv's id, which is what sharing takes -- NOT the local section key. */
id: string;
key: string;
type: string;
title: string;
};
/**
* Sections as plex.tv sees them.
*
* Sharing is keyed on plex.tv's section id and the local server calls the same
* library something else entirely: Movies is key 1 on the LAN and 140184388 to
* plex.tv. Reading them from the server would produce numbers the sharing API
* silently ignores.
*/
export async function listSections(): Promise<PlexSection[]> {
const response = await fetch(`https://plex.tv/api/servers/${MACHINE_ID}`, {
headers: ownerHeaders({ Accept: "application/xml" }),
signal: AbortSignal.timeout(20000),
});
if (!response.ok) throw new Error(`Plex returned HTTP ${response.status} listing sections`);
const xml = await response.text();
const sections = new Map<string, PlexSection>();
for (const element of xml.match(/<Section\b[^>]*>/g) ?? []) {
const attr = (name: string) => element.match(new RegExp(`${name}="([^"]*)"`))?.[1] ?? "";
const id = attr("id");
// The same section is repeated once per existing share; first wins.
if (id && !sections.has(id)) {
sections.set(id, { id, key: attr("key"), type: attr("type"), title: attr("title") });
}
}
return [...sections.values()];
}
/**
* What a linked user gets: everything except Adult and Pictures.
*
* Matched on what the section IS rather than on a list of ids, so a library
* added later is shared without anyone remembering to update a constant --
* and, more importantly, so a RENAMED adult library cannot quietly become
* shareable because its id was never on an exclusion list.
*/
export function isShareable(section: PlexSection) {
if (section.type === "photo") return false;
return !/\b(adult|xxx|porn)\b/i.test(section.title);
}
// --- linking ---------------------------------------------------------------
export type PlexPin = { id: number; code: string };
/** Start a sign-in. The user takes the code to plex.tv; we poll for the result. */
export async function createPin(): Promise<PlexPin> {
const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
method: "POST",
headers: {
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
"X-Plex-Product": "Ampelos",
Accept: "application/json",
},
signal: AbortSignal.timeout(20000),
});
if (!response.ok) throw new Error(`Plex would not issue a sign-in code (HTTP ${response.status})`);
const body = await response.json();
return { id: body.id, code: body.code };
}
/** Where to send the user. Plex returns them to `returnTo` when they are done. */
export function authUrl(pin: PlexPin, returnTo: string) {
const params = new URLSearchParams({
clientID: CLIENT_IDENTIFIER,
code: pin.code,
"context[device][product]": "Ampelos",
forwardUrl: returnTo,
});
return `https://app.plex.tv/auth#?${params}`;
}
export type PlexIdentity = {
plexUserId: string;
plexUuid: string | null;
username: string;
email: string | null;
};
/**
* Has the user finished signing in, and if so who are they?
*
* Returns null while the PIN is still unclaimed. The token that comes back is
* used once, to ask Plex whose it is, and is never stored or returned to the
* caller.
*/
export async function claimPin(pinId: number): Promise<PlexIdentity | null> {
const response = await fetch(`https://plex.tv/api/v2/pins/${pinId}`, {
headers: {
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
Accept: "application/json",
},
signal: AbortSignal.timeout(20000),
});
if (!response.ok) throw new Error(`Plex would not confirm the sign-in (HTTP ${response.status})`);
const body = await response.json();
const token: string | null = body.authToken ?? null;
if (!token) return null;
const who = await fetch("https://plex.tv/api/v2/user", {
headers: {
"X-Plex-Token": token,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
Accept: "application/json",
},
signal: AbortSignal.timeout(20000),
});
if (!who.ok) throw new Error("Plex accepted the sign-in but would not say who it was");
const account = await who.json();
return {
plexUserId: String(account.id),
plexUuid: account.uuid ?? null,
username: account.username ?? account.title ?? "unknown",
email: account.email ?? null,
};
}
// --- sharing ---------------------------------------------------------------
/**
* Invite an account to the shareable libraries.
*
* The v2 endpoint only accepts POST and rejects a read, so the shape below is
* the v1 one that Plex's own clients use. Sharing is idempotent from our side:
* re-inviting somebody who already has access is answered with a conflict,
* which is reported as success because the desired state is what matters.
*/
export async function shareLibraries(identity: { email: string | null; plexUserId: string }) {
const sections = (await listSections()).filter(isShareable);
if (!sections.length) throw new Error("no shareable Plex libraries found");
const invited = identity.email ?? identity.plexUserId;
const response = await fetch(`https://plex.tv/api/servers/${MACHINE_ID}/shared_servers`, {
method: "POST",
headers: ownerHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({
server_id: MACHINE_ID,
shared_server: {
library_section_ids: sections.map((s) => Number(s.id)),
invited_email: invited,
},
sharing_settings: {},
}),
signal: AbortSignal.timeout(30000),
});
// 400 with an "already sharing" body is the ordinary outcome of linking an
// account that was already invited by hand, and is not a failure.
const text = await response.text();
const alreadyShared = /already/i.test(text) && /shar/i.test(text);
if (!response.ok && !alreadyShared) {
throw new Error(`Plex refused the share (HTTP ${response.status}): ${text.slice(0, 200)}`);
}
return {
sectionIds: sections.map((s) => s.id),
sectionTitles: sections.map((s) => s.title),
alreadyShared,
};
}
+196
View File
@@ -0,0 +1,196 @@
// Server-side qBittorrent access for the dashboard.
//
// The browser never talks to qBittorrent. Ampelos is served over HTTPS and
// qBittorrent over plain HTTP on the LAN, so a browser would refuse the request
// as mixed content -- and qBittorrent additionally sends
// `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`, which is why
// embedding its WebUI in an iframe cannot work either. Going through the server
// sidesteps all of it: this process is on the same host, on loopback.
//
// Read-only helpers plus the few actions the Downloads page needs. Anything
// more exotic belongs in qBittorrent's own UI.
const QBT_URL = process.env.QBT_URL ?? "http://127.0.0.1:8080";
const TIMEOUT_MS = 8_000;
export type TorrentState = {
infoHash: string;
name: string;
state: string;
progress: number;
sizeBytes: number | null;
downloadedBytes: number | null;
dlSpeed: number | null;
upSpeed: number | null;
seeders: number | null;
leechers: number | null;
ratio: number | null;
eta: number | null;
category: string | null;
contentPath: string | null;
isComplete: boolean;
isFailed: boolean;
isPaused: boolean;
};
// qBittorrent's state vocabulary is large and mostly about how it got here.
// What the page needs is three questions: has the data arrived, is it stopped,
// is it broken.
const COMPLETE = new Set([
"uploading", "stalledUP", "queuedUP", "forcedUP", "pausedUP", "stoppedUP", "checkingUP",
]);
const PAUSED = new Set(["pausedDL", "pausedUP", "stoppedDL", "stoppedUP"]);
const FAILED = new Set(["error", "missingFiles"]);
async function qbt(path: string, init?: RequestInit) {
const response = await fetch(`${QBT_URL}${path}`, {
...init,
headers: {
...(init?.body ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
...init?.headers,
},
signal: AbortSignal.timeout(TIMEOUT_MS),
cache: "no-store",
});
if (!response.ok) {
throw new Error(`qBittorrent returned HTTP ${response.status} for ${path}`);
}
return response;
}
/**
* State for specific infohashes.
*
* Takes an explicit list rather than fetching everything: the dashboard shows
* what Ampelos grabbed, and enumerating the whole client would also surface
* torrents the user added by hand, which are deliberately none of its business.
*
* Returns an empty map rather than throwing when qBittorrent is unreachable --
* the page is still useful showing the database's view, and a VPN reconnect
* should not turn the page into an error screen.
*/
export async function torrentStates(infoHashes: string[]): Promise<{
states: Map<string, TorrentState>;
error: string | null;
}> {
if (infoHashes.length === 0) return { states: new Map(), error: null };
try {
const hashes = infoHashes.map((h) => h.toLowerCase()).join("|");
const response = await qbt(`/api/v2/torrents/info?hashes=${hashes}`);
const raw = (await response.json()) as Array<Record<string, unknown>>;
const states = new Map<string, TorrentState>();
for (const t of raw) {
const state = String(t.state ?? "");
const progress = Number(t.progress ?? 0);
const infoHash = String(t.hash ?? "").toLowerCase();
states.set(infoHash, {
infoHash,
name: String(t.name ?? ""),
state,
progress: Math.round(progress * 100),
sizeBytes: numberOrNull(t.size),
downloadedBytes: numberOrNull(t.downloaded),
dlSpeed: numberOrNull(t.dlspeed),
upSpeed: numberOrNull(t.upspeed),
seeders: numberOrNull(t.num_seeds),
leechers: numberOrNull(t.num_leechs),
ratio: numberOrNull(t.ratio),
eta: numberOrNull(t.eta),
category: t.category ? String(t.category) : null,
contentPath: t.content_path ? String(t.content_path) : null,
isComplete: COMPLETE.has(state) || progress >= 1,
isFailed: FAILED.has(state),
isPaused: PAUSED.has(state),
});
}
return { states, error: null };
} catch (error) {
return {
states: new Map(),
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Aggregate transfer info, for the header. Null when unreachable. */
export async function transferInfo() {
try {
const response = await qbt("/api/v2/transfer/info");
const raw = (await response.json()) as Record<string, unknown>;
return {
dlSpeed: numberOrNull(raw.dl_info_speed) ?? 0,
upSpeed: numberOrNull(raw.up_info_speed) ?? 0,
connectionStatus: String(raw.connection_status ?? "unknown"),
};
} catch {
return null;
}
}
export async function pauseTorrent(infoHash: string) {
// qBittorrent 5 renamed pause/resume to stop/start and keeps the old paths as
// aliases. Using the old names keeps this working on both.
await qbt("/api/v2/torrents/pause", {
method: "POST",
body: new URLSearchParams({ hashes: infoHash.toLowerCase() }),
});
}
export async function resumeTorrent(infoHash: string) {
await qbt("/api/v2/torrents/resume", {
method: "POST",
body: new URLSearchParams({ hashes: infoHash.toLowerCase() }),
});
}
/**
* Remove a torrent.
*
* `deleteFiles` deletes qBittorrent's own copy -- which, after import, is a
* hardlink. The library copy is a separate directory entry to the same inode
* and survives untouched. That is the whole reason import uses hardlinks: this
* button cannot take the library with it.
*/
export async function removeTorrent(infoHash: string, deleteFiles: boolean) {
await qbt("/api/v2/torrents/delete", {
method: "POST",
body: new URLSearchParams({
hashes: infoHash.toLowerCase(),
deleteFiles: String(deleteFiles),
}),
});
}
function numberOrNull(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function formatBytes(value: number | bigint | null | undefined): string {
if (value === null || value === undefined) return "—";
let size = Number(value);
if (!Number.isFinite(size)) return "—";
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export function formatSpeed(bytesPerSecond: number | null): string {
if (!bytesPerSecond) return "—";
return `${formatBytes(bytesPerSecond)}/s`;
}
export function formatEta(seconds: number | null): string {
// qBittorrent uses 8640000 (100 days) to mean "no estimate".
if (seconds === null || seconds <= 0 || seconds >= 8_640_000) return "—";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86_400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86_400)}d`;
}
+74
View File
@@ -0,0 +1,74 @@
import { db } from "@/db/client";
import { users, userIdentities } from "@/db/schema";
import { eq, and } from "drizzle-orm";
// Finds or creates a local user record for an authenticated OIDC identity.
// Returns the local user id.
export async function findOrCreateUser({
externalId,
provider,
name,
email,
isAdmin,
}: {
externalId: string;
provider: string;
name: string;
email: string;
isAdmin: boolean;
}): Promise<string> {
const existingIdentity = await db
.select({ userId: userIdentities.userId })
.from(userIdentities)
.where(
and(
eq(userIdentities.provider, provider),
eq(userIdentities.externalId, externalId)
)
)
.limit(1);
if (existingIdentity.length > 0) {
await db
.update(users)
.set({ displayName: name, email, isAdmin, updatedAt: new Date() })
.where(eq(users.id, existingIdentity[0].userId));
return existingIdentity[0].userId;
}
const existingUser = email
? await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1)
: [];
if (existingUser.length > 0) {
await db
.update(users)
.set({ displayName: name, isAdmin, updatedAt: new Date() })
.where(eq(users.id, existingUser[0].id));
await db.insert(userIdentities).values({
userId: existingUser[0].id,
provider,
externalId,
});
return existingUser[0].id;
}
const [newUser] = await db
.insert(users)
.values({ displayName: name, email, isAdmin })
.returning({ id: users.id });
await db.insert(userIdentities).values({
userId: newUser.id,
provider,
externalId,
});
return newUser.id;
}