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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user