1dfa2c0a04
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
9.9 KiB
TypeScript
351 lines
9.9 KiB
TypeScript
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;
|
|
}
|
|
|
|
/**
|
|
* A usable poster URL from whatever media_items.poster_path happens to hold.
|
|
*
|
|
* Two writers disagree about that column. The metadata refresh stores TMDB's
|
|
* bare path (`/abc.jpg`, 2,802 rows) and the catalog board stores the absolute
|
|
* URL it had already built (1 row). Both forms are in the table right now, so
|
|
* anything rendering a stored poster has to cope with both -- and anything
|
|
* handing one to ANOTHER SERVICE has to resolve it, because a bare TMDB path
|
|
* means nothing on accounts.sticknife.com.
|
|
*/
|
|
export function posterUrlFromStored(path?: string | null) {
|
|
if (!path) return null;
|
|
return /^https?:\/\//.test(path) ? path : imageUrl(path);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|