mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Migrate web app from Next.js to Vite + TanStack Router SPA (#6)
* Convert to Turborepo monorepo with shared API contract package Restructure the repository as a monorepo in preparation for adding future clients (mobile app, CLI). Extract the oRPC contract and Zod schemas into `@sofa/api` (packages/api/) as a JIT internal package, and relocate the Next.js app to `@sofa/web` (apps/web/). - Add Turborepo with Bun workspaces for task orchestration and caching - Extract `contract.ts` and `schemas.ts` into `@sofa/api` package - Move all app code, configs, tests, and migrations to `apps/web/` - Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas` - Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts - Rewrite Dockerfile with `turbo prune --docker` for efficient builds - Update CI workflows to use `turbo run` for lint/check-types/test - Update CLAUDE.md with monorepo structure and commands Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract standalone Hono API server and split shared packages Separate all server-side concerns from the Next.js frontend into a new `apps/server/` Hono app and dedicated shared packages, making `@sofa/web` a frontend-only app with no direct DB or service access. - Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting oRPC procedures, Better Auth, cron jobs, and non-RPC routes - Add `@sofa/core` (`packages/core/`) — All 15 business logic services moved from `apps/web/lib/services/`; tests moved to `packages/core/test/` - Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations, constants, and logger extracted from `apps/web/lib/db/` and `lib/` - Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers moved from `apps/web/lib/tmdb/` - Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved from `apps/web/lib/auth/` - Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/` - Move Hono route handlers (avatars, backups, images, lists, webhooks, health) to `apps/server/src/routes/`; delete equivalent Next.js API routes - Strip `apps/web` to frontend-only: no DB imports, no service imports, all data via oRPC client calls to the API server - Add `entrypoint.sh` to start API server, wait for health, then Next.js - Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to `INTERNAL_API_URL` (default `http://localhost:3001`) - Update Dockerfile and CLAUDE.md for the new structure * Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing, removing all SSR complexity. The API server (Hono) now serves both API routes and SPA static files in production, simplifying Docker to a single-process container. Key changes: - Vite 7 + @tanstack/react-router with file-based routing via plugin - Route guards via beforeLoad + authClient.getSession() (replaces server-side auth) - Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching) - Self-hosted fonts via @fontsource (replaces next/font/google) - Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss) - Single oRPC client (removed SSR client and server-side session helper) - Hono serves SPA static files in production (single port 3000) - Single-process Dockerfile (removed entrypoint.sh) - Bun workspace catalog for centralized dependency version management Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract @sofa/logger and @sofa/config shared packages - Add `@sofa/logger` (`packages/logger/`) — standalone logger package extracted from `@sofa/db/logger`; update all imports across server, core, auth, db, and tmdb packages - Add `@sofa/config` (`packages/config/`) — standalone config/constants package extracted from `@sofa/db/constants`; exports `DATA_DIR`, `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR` - Move `.env.example` from `apps/web/` to repo root; update server dev scripts to load it via `--env-file=../../.env` - Move image serving from `/api/images` to `/images`; add `serveStatic` fast path in `index.ts` for cached files before falling back to the TMDB fetch route; add `/images` proxy to Vite dev config - Fix `Sparkline` component: replace `ResponsiveContainer` with `ResizeObserver` to avoid SSR/hydration issues with recharts - Replace `VITE_SERVER_URL` env var with `window.location.origin` in the oRPC client (always same-origin in both dev and production) * Fix asset caching, SPA 404 fallback, and DATA_DIR resolution - Add `Cache-Control: immutable` header for hashed `/assets/*` files; return 404 for missing asset paths instead of falling back to `index.html` (prevents serving stale chunks after deploy) - Wrap `query.invalidate` in an arrow function in the oRPC QueryClient error handler to avoid illegal invocation errors - Resolve `DATA_DIR` to an absolute path via `path.resolve()` so relative paths work regardless of the process working directory * Migrate @sofa/logger to pino for structured logging - Replace custom logger implementation in `packages/logger/` with pino + pino-pretty; add both as workspace catalog dependencies - Add `pino` and `pino-pretty` to the workspace catalog in `package.json` - Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the error directly instead of wrapping it in `{ error }` to match pino's serializer expectations * Rename discoverProcedure/statsProcedure exports to discover/stats * Add TanStackDevtools unified panel and VS Code workspace config - Replace separate Router/Query devtools with unified `TanStackDevtools` from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin - Wrap app in `<StrictMode>` in `main.tsx` - Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions) * Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle, etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts so DB test utilities live alongside the schema they depend on. Use import.meta.dir for CWD-independent migration path resolution. Add root bunfig.toml so `bun test` works from the repo root in addition to `bun run test` (turbo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix devtools plugin order and whitespace-only TMDB token check Move devtools() to first position in Vite plugins array per TanStack docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so whitespace-only values are treated as unconfigured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@sofa/tmdb",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./client": "./src/client.ts",
|
||||
"./image": "./src/image.ts",
|
||||
"./config": "./src/config.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"check-types": "tsc --noEmit",
|
||||
"generate-schema": "bunx openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./src/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sofa/db": "workspace:*",
|
||||
"@sofa/logger": "workspace:*",
|
||||
"openapi-fetch": "0.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import createClient, { type Middleware } from "openapi-fetch";
|
||||
import type { operations, paths } from "./schema";
|
||||
|
||||
const log = createLogger("tmdb");
|
||||
|
||||
// ─── Schema-derived types ───────────────────────────────────────────
|
||||
|
||||
/** Extract the 200 JSON response body from an operation */
|
||||
type OpResponse<K extends keyof operations> = operations[K] extends {
|
||||
responses: {
|
||||
200: { content: { "application/json": infer T } };
|
||||
};
|
||||
}
|
||||
? T
|
||||
: never;
|
||||
|
||||
// Search
|
||||
export type TmdbSearchResponse = OpResponse<"search-multi">;
|
||||
export type TmdbSearchResult = NonNullable<
|
||||
TmdbSearchResponse["results"]
|
||||
>[number];
|
||||
|
||||
// Movie details — augmented with append_to_response data
|
||||
export type TmdbMovieDetails = OpResponse<"movie-details"> & {
|
||||
release_dates?: {
|
||||
results: NonNullable<OpResponse<"movie-release-dates">["results"]>;
|
||||
};
|
||||
};
|
||||
|
||||
// TV details — augmented with append_to_response data
|
||||
export type TmdbTvDetails = OpResponse<"tv-series-details"> & {
|
||||
content_ratings?: OpResponse<"tv-series-content-ratings">;
|
||||
external_ids?: OpResponse<"tv-series-external-ids">;
|
||||
};
|
||||
|
||||
// Watch providers — manually defined because the schema types `results` as an
|
||||
// object with literal country-code keys rather than Record<string, ...>, which
|
||||
// makes dynamic lookups (e.g. results["US"]) impractical.
|
||||
export interface TmdbProvider {
|
||||
logo_path?: string;
|
||||
provider_id: number;
|
||||
provider_name?: string;
|
||||
display_priority: number;
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderRegion {
|
||||
link?: string;
|
||||
flatrate?: TmdbProvider[];
|
||||
rent?: TmdbProvider[];
|
||||
buy?: TmdbProvider[];
|
||||
free?: TmdbProvider[];
|
||||
ads?: TmdbProvider[];
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderResponse {
|
||||
id: number;
|
||||
results?: Record<string, TmdbWatchProviderRegion>;
|
||||
}
|
||||
|
||||
// Recommendations — movie-recommendations is Record<string, never> in schema,
|
||||
// so we base on tv-series-recommendations and add movie fields TMDB returns.
|
||||
type TvRecResult = NonNullable<
|
||||
OpResponse<"tv-series-recommendations">["results"]
|
||||
>[number];
|
||||
export type TmdbRecommendationResponse = Omit<
|
||||
OpResponse<"tv-series-recommendations">,
|
||||
"results"
|
||||
> & {
|
||||
results?: (TvRecResult & {
|
||||
title?: string;
|
||||
original_title?: string;
|
||||
release_date?: string;
|
||||
})[];
|
||||
};
|
||||
|
||||
// Find — schema types tv_results and tv_episode_results as unknown[]
|
||||
export type TmdbFindResult = Omit<
|
||||
OpResponse<"find-by-id">,
|
||||
"tv_results" | "tv_episode_results"
|
||||
> & {
|
||||
tv_results?: TmdbSearchResult[];
|
||||
tv_episode_results?: {
|
||||
id: number;
|
||||
episode_number: number;
|
||||
name: string;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Videos
|
||||
export type TmdbVideo = NonNullable<
|
||||
OpResponse<"movie-videos">["results"]
|
||||
>[number];
|
||||
|
||||
// Genres
|
||||
export type TmdbGenre = NonNullable<
|
||||
OpResponse<"genre-movie-list">["genres"]
|
||||
>[number];
|
||||
|
||||
// Person — schema types deathday as unknown
|
||||
export type TmdbPersonDetails = Omit<
|
||||
OpResponse<"person-details">,
|
||||
"deathday"
|
||||
> & {
|
||||
deathday?: string | null;
|
||||
};
|
||||
|
||||
// ─── Client setup ───────────────────────────────────────────────────
|
||||
|
||||
function getApiKey() {
|
||||
const key = process.env.TMDB_API_READ_ACCESS_TOKEN;
|
||||
if (!key) throw new Error("TMDB_API_READ_ACCESS_TOKEN is not set");
|
||||
return key;
|
||||
}
|
||||
|
||||
// The default TMDB base URL ends with /3, matching the schema's /3/… paths.
|
||||
// Custom proxy URLs (e.g. https://tmdb.internal) may omit /3 — those worked
|
||||
// before because the old client built paths like /search/multi against the
|
||||
// custom base. To preserve that, we always strip /3 from the configured URL
|
||||
// and use the schema paths as-is (they already include /3/).
|
||||
const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3";
|
||||
const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE;
|
||||
const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE;
|
||||
const baseUrl = configuredBase.replace(/\/3\/?$/, "");
|
||||
|
||||
const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
|
||||
? {
|
||||
async onRequest({ request }) {
|
||||
// Custom proxy: strip the /3 prefix from schema paths so requests
|
||||
// go to e.g. https://tmdb.internal/search/multi instead of /3/search/multi
|
||||
const url = new URL(request.url);
|
||||
url.pathname = url.pathname.replace(/^\/3\//, "/");
|
||||
return new Request(url.toString(), request);
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const authMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
request.headers.set("Authorization", `Bearer ${getApiKey()}`);
|
||||
request.headers.set("Accept", "application/json");
|
||||
return request;
|
||||
},
|
||||
};
|
||||
|
||||
const requestTimings = new WeakMap<Request, number>();
|
||||
|
||||
const loggingMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
requestTimings.set(request, performance.now());
|
||||
return request;
|
||||
},
|
||||
async onResponse({ request, response }) {
|
||||
const start = requestTimings.get(request);
|
||||
const elapsed = start ? Math.round(performance.now() - start) : 0;
|
||||
if (!response.ok) {
|
||||
log.warn(
|
||||
`${request.url} -> ${response.status} ${response.statusText} (${elapsed}ms)`,
|
||||
);
|
||||
} else {
|
||||
log.debug(`${request.url} -> ${response.status} (${elapsed}ms)`);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const client = createClient<paths>({ baseUrl });
|
||||
if (baseUrlRewriteMiddleware) client.use(baseUrlRewriteMiddleware);
|
||||
client.use(authMiddleware, loggingMiddleware);
|
||||
|
||||
// ─── Search ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function searchMulti(query: string, page = 1) {
|
||||
const { data, error } = await client.GET("/3/search/multi", {
|
||||
params: { query: { query, page, include_adult: false } },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: search/multi");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function searchMovies(query: string, page = 1) {
|
||||
const { data, error } = await client.GET("/3/search/movie", {
|
||||
params: { query: { query, page } },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: search/movie");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function searchTv(query: string, page = 1) {
|
||||
const { data, error } = await client.GET("/3/search/tv", {
|
||||
params: { query: { query, page } },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: search/tv");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Details ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getMovieDetails(tmdbId: number) {
|
||||
const { data, error } = await client.GET("/3/movie/{movie_id}", {
|
||||
params: {
|
||||
path: { movie_id: tmdbId },
|
||||
query: { append_to_response: "release_dates" },
|
||||
},
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: movie/${tmdbId}`);
|
||||
return data as TmdbMovieDetails;
|
||||
}
|
||||
|
||||
export async function getTvDetails(tmdbId: number) {
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}", {
|
||||
params: {
|
||||
path: { series_id: tmdbId },
|
||||
query: { append_to_response: "content_ratings,external_ids" },
|
||||
},
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}`);
|
||||
return data as TmdbTvDetails;
|
||||
}
|
||||
|
||||
export async function getTvExternalIds(tmdbId: number) {
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/external_ids", {
|
||||
params: { path: { series_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/external_ids`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/season/{season_number}",
|
||||
{
|
||||
params: {
|
||||
path: { series_id: tmdbId, season_number: seasonNumber },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: tv/${tmdbId}/season/${seasonNumber}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Watch Providers ────────────────────────────────────────────────
|
||||
|
||||
export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/movie/{movie_id}/watch/providers",
|
||||
{ params: { path: { movie_id: tmdbId } } },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: movie/${tmdbId}/watch/providers`);
|
||||
return data as TmdbWatchProviderResponse;
|
||||
}
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/watch/providers",
|
||||
{ params: { path: { series_id: tmdbId } } },
|
||||
);
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/watch/providers`);
|
||||
return data as TmdbWatchProviderResponse;
|
||||
}
|
||||
|
||||
// ─── Recommendations & Similar ──────────────────────────────────────
|
||||
|
||||
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/movie/{movie_id}/recommendations",
|
||||
{ params: { path: { movie_id: tmdbId } } },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: movie/${tmdbId}/recommendations`);
|
||||
return data as TmdbRecommendationResponse;
|
||||
}
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/recommendations",
|
||||
{ params: { path: { series_id: tmdbId } } },
|
||||
);
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/recommendations`);
|
||||
return data as TmdbRecommendationResponse;
|
||||
}
|
||||
|
||||
export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET("/3/movie/{movie_id}/similar", {
|
||||
params: { path: { movie_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/similar`);
|
||||
return data as TmdbRecommendationResponse;
|
||||
}
|
||||
// Schema incorrectly types series_id as string here (number everywhere else)
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/similar", {
|
||||
params: { path: { series_id: String(tmdbId) } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/similar`);
|
||||
return data as TmdbRecommendationResponse;
|
||||
}
|
||||
|
||||
// ─── Trending & Popular ─────────────────────────────────────────────
|
||||
|
||||
export async function getTrending(
|
||||
mediaType: "all" | "movie" | "tv",
|
||||
timeWindow: "day" | "week" = "day",
|
||||
) {
|
||||
const opts = {
|
||||
params: { path: { time_window: timeWindow } },
|
||||
} as const;
|
||||
|
||||
if (mediaType === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/movie/{time_window}",
|
||||
opts,
|
||||
);
|
||||
if (error) throw new Error("TMDB API error: trending/movie");
|
||||
return data;
|
||||
}
|
||||
if (mediaType === "tv") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/tv/{time_window}",
|
||||
opts,
|
||||
);
|
||||
if (error) throw new Error("TMDB API error: trending/tv");
|
||||
return data;
|
||||
}
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/all/{time_window}",
|
||||
opts,
|
||||
);
|
||||
if (error) throw new Error("TMDB API error: trending/all");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getPopular(type: "movie" | "tv", page = 1) {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET("/3/movie/popular", {
|
||||
params: { query: { page } },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: movie/popular");
|
||||
return data;
|
||||
}
|
||||
const { data, error } = await client.GET("/3/tv/popular", {
|
||||
params: { query: { page } },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: tv/popular");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Genres ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getGenres(type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET("/3/genre/movie/list", {});
|
||||
if (error) throw new Error("TMDB API error: genre/movie/list");
|
||||
return data;
|
||||
}
|
||||
const { data, error } = await client.GET("/3/genre/tv/list", {});
|
||||
if (error) throw new Error("TMDB API error: genre/tv/list");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Discover ───────────────────────────────────────────────────────
|
||||
|
||||
export async function discover(
|
||||
type: "movie" | "tv",
|
||||
params: Record<string, string>,
|
||||
page = 1,
|
||||
) {
|
||||
// Discover accepts many dynamic filter params (with_genres, vote_count.gte, etc.)
|
||||
// that aren't individually typed in the schema, so widen to Record<string, unknown>.
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET("/3/discover/movie", {
|
||||
params: { query: { ...params, page } as Record<string, unknown> },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: discover/movie");
|
||||
return data;
|
||||
}
|
||||
const { data, error } = await client.GET("/3/discover/tv", {
|
||||
params: { query: { ...params, page } as Record<string, unknown> },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: discover/tv");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Videos ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getVideos(tmdbId: number, type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET("/3/movie/{movie_id}/videos", {
|
||||
params: { path: { movie_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/videos`);
|
||||
return data;
|
||||
}
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/videos", {
|
||||
params: { path: { series_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/videos`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Find by External ID ───────────────────────────────────────────
|
||||
|
||||
export async function findByExternalId(
|
||||
externalId: string,
|
||||
source: "imdb_id" | "tvdb_id",
|
||||
) {
|
||||
const { data, error } = await client.GET("/3/find/{external_id}", {
|
||||
params: {
|
||||
path: { external_id: externalId },
|
||||
query: { external_source: source },
|
||||
},
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: find/${externalId}`);
|
||||
return data as TmdbFindResult;
|
||||
}
|
||||
|
||||
// ─── Person / Credits ───────────────────────────────────────────────
|
||||
|
||||
export async function getMovieCredits(tmdbId: number) {
|
||||
const { data, error } = await client.GET("/3/movie/{movie_id}/credits", {
|
||||
params: { path: { movie_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/credits`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTvAggregateCredits(tmdbId: number) {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/aggregate_credits",
|
||||
{ params: { path: { series_id: tmdbId } } },
|
||||
);
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/aggregate_credits`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getPersonDetails(tmdbId: number) {
|
||||
const { data, error } = await client.GET("/3/person/{person_id}", {
|
||||
params: { path: { person_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: person/${tmdbId}`);
|
||||
return data as TmdbPersonDetails;
|
||||
}
|
||||
|
||||
export async function getPersonCombinedCredits(tmdbId: number) {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/person/{person_id}/combined_credits",
|
||||
// Schema incorrectly types person_id as string here (number everywhere else)
|
||||
{ params: { path: { person_id: String(tmdbId) } } },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: person/${tmdbId}/combined_credits`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function searchPerson(query: string, page = 1) {
|
||||
const { data, error } = await client.GET("/3/search/person", {
|
||||
params: { query: { query, page } },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: search/person");
|
||||
return data;
|
||||
}
|
||||
|
||||
export { tmdbImageUrl } from "./image";
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Server-side configuration checks.
|
||||
* Call these in route handlers or server components — never on the client.
|
||||
*/
|
||||
|
||||
export function isTmdbConfigured(): boolean {
|
||||
return !!process.env.TMDB_API_READ_ACCESS_TOKEN?.trim();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type ImageCategory =
|
||||
| "posters"
|
||||
| "backdrops"
|
||||
| "stills"
|
||||
| "logos"
|
||||
| "profiles";
|
||||
|
||||
const IMAGE_BASE_URL =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
|
||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
posters: "w500",
|
||||
backdrops: "w1280",
|
||||
stills: "w1280",
|
||||
logos: "w92",
|
||||
profiles: "w185",
|
||||
};
|
||||
|
||||
export function tmdbImageUrl(
|
||||
path: string | null,
|
||||
category: ImageCategory,
|
||||
sizeOverride?: string,
|
||||
) {
|
||||
if (!path) return null;
|
||||
|
||||
const size = sizeOverride ?? CATEGORY_SIZES[category];
|
||||
|
||||
if (process.env.IMAGE_CACHE_ENABLED === "false") {
|
||||
return `${IMAGE_BASE_URL}/${size}${path}`;
|
||||
}
|
||||
|
||||
const filename = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `/images/${category}/${filename}`;
|
||||
}
|
||||
Vendored
+22844
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user