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,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`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+14
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user