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
+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,
};
}