fix(tmdb): keep the /3 prefix for custom base URLs and trailing slashes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 16:21:18 -04:00
1 parent 8b10b5cb45
commit 57bac67b36
4 files changed
+69 -12

No files matched your search

+3 -2
View File
@@ -1,8 +1,9 @@
import { access, constants, readdir } from "node:fs/promises";
import path from "node:path";
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
import { CACHE_DIR, DATA_DIR, DATABASE_URL } from "@sofa/config";
import { getLatestCronRuns, getTableCounts } from "@sofa/db/queries/system-health";
import { tmdbApiUrl } from "@sofa/tmdb/config";
import { listBackups } from "./backup";
import { imageCacheEnabled } from "./image-cache";
@@ -98,7 +99,7 @@ async function getTmdbHealth(): Promise<SystemHealthData["tmdb"]> {
try {
const start = performance.now();
const res = await fetch(`${TMDB_API_BASE_URL}/configuration`, {
const res = await fetch(tmdbApiUrl("/configuration"), {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { resolveTmdbBase, tmdbApiUrl } from "@sofa/tmdb/config";
describe("resolveTmdbBase", () => {
test.each([
[undefined, "https://api.themoviedb.org", false],
["", "https://api.themoviedb.org", false],
["https://api.themoviedb.org/3", "https://api.themoviedb.org", false],
["https://api.themoviedb.org/3/", "https://api.themoviedb.org", false],
["https://proxy.example/3", "https://proxy.example", false],
["https://proxy.example/tmdb/3/", "https://proxy.example/tmdb", false],
["https://tmdb.internal", "https://tmdb.internal", true],
["https://tmdb.internal/", "https://tmdb.internal", true],
["https://proxy.example/v3", "https://proxy.example/v3", true],
])("resolves %j", (input, baseUrl, stripVersionPrefix) => {
expect(resolveTmdbBase(input)).toEqual({ baseUrl, stripVersionPrefix });
});
});
describe("tmdbApiUrl", () => {
test.each([
[undefined, "https://api.themoviedb.org/3/configuration"],
["", "https://api.themoviedb.org/3/configuration"],
["https://proxy.example/3/", "https://proxy.example/3/configuration"],
["https://tmdb.internal", "https://tmdb.internal/configuration"],
])("builds the URL for %j", (input, expected) => {
// An explicit undefined would fall back to process.env.TMDB_API_BASE_URL,
// so use "" (treated as unset) to stay independent of the environment.
expect(tmdbApiUrl("/configuration", input ?? "")).toBe(expected);
});
});
+6 -10
View File
@@ -2,6 +2,7 @@ import createClient, { type Middleware } from "openapi-fetch";
import { createLogger } from "@sofa/logger";
import { resolveTmdbBase } from "./config";
import type { operations, paths } from "./schema";
const log = createLogger("tmdb");
@@ -103,18 +104,13 @@ function getApiKey() {
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;
// Schema paths start with /3/…. resolveTmdbBase decides whether the configured
// base already carries /3 (keep paths as-is) or is a proxy mounted at the API
// root (strip /3 from each request path). See ./config.
const TMDB_VERSION_PREFIX_RE = /^\/3\//;
const baseUrl = configuredBase.replace(/\/3\/?$/, "");
const { baseUrl, stripVersionPrefix } = resolveTmdbBase(process.env.TMDB_API_BASE_URL);
const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
const baseUrlRewriteMiddleware: Middleware | null = stripVersionPrefix
? {
async onRequest({ request }) {
// Custom proxy: strip the /3 prefix from schema paths so requests
+28
View File
@@ -6,3 +6,31 @@
export function isTmdbConfigured(): boolean {
return !!process.env.TMDB_API_READ_ACCESS_TOKEN?.trim();
}
export const DEFAULT_TMDB_API_BASE_URL = "https://api.themoviedb.org/3";
/**
* Work out how to reach the TMDB API from TMDB_API_BASE_URL. Schema paths
* already start with `/3/`, so:
* - a base ending in `/3` (the default, or a proxy mirroring TMDB's layout)
* drops `/3` from the base and uses schema paths as-is;
* - a base without `/3` (a proxy mounted at the API root, e.g.
* https://tmdb.internal) keeps the base and strips `/3` from each path.
* Trailing slashes are ignored.
*/
export function resolveTmdbBase(configured?: string): {
baseUrl: string;
stripVersionPrefix: boolean;
} {
const base = (configured?.trim() || DEFAULT_TMDB_API_BASE_URL).replace(/\/+$/, "");
if (base.endsWith("/3")) {
return { baseUrl: base.slice(0, -2), stripVersionPrefix: false };
}
return { baseUrl: base, stripVersionPrefix: true };
}
/** Absolute URL for a TMDB API path such as "/configuration". */
export function tmdbApiUrl(path: string, configured = process.env.TMDB_API_BASE_URL): string {
const { baseUrl, stripVersionPrefix } = resolveTmdbBase(configured);
return `${baseUrl}${stripVersionPrefix ? "" : "/3"}${path}`;
}