Clarify OpenSubtitles.com 403 (User-Agent) and prefer OS.org downloads without .com login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 21:39:47 +02:00
parent 93bbd07487
commit afcc2e759f
8 changed files with 140 additions and 33 deletions

View file

@ -254,8 +254,11 @@ export default function DownloadsPage() {
const results: SubtitleHit[] = data.results ?? []; const results: SubtitleHit[] = data.results ?? [];
setSubs(results); setSubs(results);
if (results[0]) { if (results[0]) {
setSelectedSubId(results[0].fileId); // Voorkeur OS.org — werkt zonder .com-login
setSelectedSubSource(results[0].source === "org" ? "org" : "com"); const preferred =
results.find((r) => r.source === "org") || results[0];
setSelectedSubId(preferred.fileId);
setSelectedSubSource(preferred.source === "org" ? "org" : "com");
} else { } else {
setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)"); setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)");
} }

View file

@ -89,6 +89,7 @@ export default function SettingsPage() {
const [osApiKey, setOsApiKey] = useState(""); const [osApiKey, setOsApiKey] = useState("");
const [osUsername, setOsUsername] = useState(""); const [osUsername, setOsUsername] = useState("");
const [osPassword, setOsPassword] = useState(""); const [osPassword, setOsPassword] = useState("");
const [osUserAgent, setOsUserAgent] = useState("MediaCluster v1.3");
const [osEnabled, setOsEnabled] = useState(true); const [osEnabled, setOsEnabled] = useState(true);
const [osHasKey, setOsHasKey] = useState(false); const [osHasKey, setOsHasKey] = useState(false);
const [osHasPassword, setOsHasPassword] = useState(false); const [osHasPassword, setOsHasPassword] = useState(false);
@ -96,6 +97,7 @@ export default function SettingsPage() {
const [osBusy, setOsBusy] = useState(false); const [osBusy, setOsBusy] = useState(false);
const [osMsg, setOsMsg] = useState<string | null>(null); const [osMsg, setOsMsg] = useState<string | null>(null);
const [osErr, setOsErr] = useState<string | null>(null); const [osErr, setOsErr] = useState<string | null>(null);
const [osWarn, setOsWarn] = useState<string | null>(null);
const loadShelves = useCallback(() => { const loadShelves = useCallback(() => {
fetch("/api/v1/admin/library/shelves", { credentials: "include" }) fetch("/api/v1/admin/library/shelves", { credentials: "include" })
@ -131,8 +133,10 @@ export default function SettingsPage() {
setOsConfigured(!!s.configured); setOsConfigured(!!s.configured);
setOsEnabled(s.enabled !== false); setOsEnabled(s.enabled !== false);
setOsUsername(s.username ?? ""); setOsUsername(s.username ?? "");
setOsUserAgent(s.userAgent || "MediaCluster v1.3");
setOsHasKey(!!s.hasApiKey); setOsHasKey(!!s.hasApiKey);
setOsHasPassword(!!s.hasPassword); setOsHasPassword(!!s.hasPassword);
setOsWarn(s.warning || null);
}) })
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
@ -217,6 +221,7 @@ export default function SettingsPage() {
setOsBusy(true); setOsBusy(true);
setOsMsg(null); setOsMsg(null);
setOsErr(null); setOsErr(null);
setOsWarn(null);
try { try {
const res = await fetch("/api/v1/admin/settings/opensubtitles", { const res = await fetch("/api/v1/admin/settings/opensubtitles", {
method: "PUT", method: "PUT",
@ -226,6 +231,7 @@ export default function SettingsPage() {
apiKey: osApiKey || undefined, apiKey: osApiKey || undefined,
username: osUsername, username: osUsername,
password: osPassword || undefined, password: osPassword || undefined,
userAgent: osUserAgent.trim() || "MediaCluster v1.3",
enabled: osEnabled, enabled: osEnabled,
}), }),
}); });
@ -234,7 +240,13 @@ export default function SettingsPage() {
setOsErr(data.error?.message ?? "Opslaan mislukt"); setOsErr(data.error?.message ?? "Opslaan mislukt");
return; return;
} }
setOsMsg("Opgeslagen — OpenSubtitles OK"); const s = data.settings;
const parts: string[] = [];
if (s?.orgOk) parts.push("OS.org OK");
if (s?.comOk) parts.push("OS.com OK");
else if (s?.hasApiKey !== false) parts.push("OS.com niet OK");
setOsMsg(`Opgeslagen — ${parts.join(" · ") || "OK"}`);
setOsWarn(s?.warning || null);
setOsApiKey(""); setOsApiKey("");
setOsPassword(""); setOsPassword("");
loadOpenSubtitles(); loadOpenSubtitles();
@ -491,8 +503,10 @@ export default function SettingsPage() {
<div className="section-head"> <div className="section-head">
<h2 className="section-title">OpenSubtitles</h2> <h2 className="section-title">OpenSubtitles</h2>
<p className="muted"> <p className="muted">
Zoekt via OpenSubtitles.com (API-key) én OpenSubtitles.org. Key optioneel maar Zoekt via OpenSubtitles.com én .org. Account/login moet van{" "}
verhoogt de kans — aanmaken via opensubtitles.com → API consumers. <strong>opensubtitles.com</strong> zijn (niet .org). User-Agent moet exact gelijk
zijn aan de naam van je API Consumer. Lukt .com niet: kies bij Downloads een{" "}
<strong>OS.org</strong>-hit.
</p> </p>
</div> </div>
<div className="card"> <div className="card">
@ -508,7 +522,16 @@ export default function SettingsPage() {
/> />
</label> </label>
<label> <label>
Gebruikersnaam User-Agent (API Consumer-naam)
<input
value={osUserAgent}
onChange={(e) => setOsUserAgent(e.target.value)}
placeholder="MediaCluster v1.3"
autoComplete="off"
/>
</label>
<label>
Gebruikersnaam (.com)
<input <input
value={osUsername} value={osUsername}
onChange={(e) => setOsUsername(e.target.value)} onChange={(e) => setOsUsername(e.target.value)}
@ -516,7 +539,7 @@ export default function SettingsPage() {
/> />
</label> </label>
<label> <label>
Wachtwoord {osHasPassword ? "(leeg = behouden)" : ""} Wachtwoord (.com) {osHasPassword ? "(leeg = behouden)" : ""}
<input <input
type="password" type="password"
value={osPassword} value={osPassword}
@ -538,9 +561,10 @@ export default function SettingsPage() {
{osBusy ? "Bezig…" : "Opslaan + testen"} {osBusy ? "Bezig…" : "Opslaan + testen"}
</button> </button>
<span className="muted"> <span className="muted">
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"} Status: {osConfigured ? "aan" : "uit"}
</span> </span>
{osMsg && <span className="ok-text">{osMsg}</span>} {osMsg && <span className="ok-text">{osMsg}</span>}
{osWarn && <span className="danger-text">{osWarn}</span>}
{osErr && <span className="danger-text">{osErr}</span>} {osErr && <span className="danger-text">{osErr}</span>}
</div> </div>
</div> </div>

View file

@ -0,0 +1 @@
ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT;

View file

@ -439,6 +439,8 @@ model IntegrationSetting {
apiKeyEnc String? @map("api_key_enc") apiKeyEnc String? @map("api_key_enc")
username String? username String?
passwordEnc String? @map("password_enc") passwordEnc String? @map("password_enc")
/** Must match OpenSubtitles.com API Consumer / User-Agent exactly */
userAgent String? @map("user_agent")
enabled Boolean @default(true) enabled Boolean @default(true)
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

View file

@ -265,12 +265,16 @@ async function main() {
"api_key_enc" TEXT, "api_key_enc" TEXT,
"username" TEXT, "username" TEXT,
"password_enc" TEXT, "password_enc" TEXT,
"user_agent" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true, "enabled" BOOLEAN NOT NULL DEFAULT true,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "integration_settings_pkey" PRIMARY KEY ("id") CONSTRAINT "integration_settings_pkey" PRIMARY KEY ("id")
) )
`); `);
await prisma.$executeRawUnsafe(
`ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT`
);
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "library_subtitles" ( CREATE TABLE IF NOT EXISTS "library_subtitles" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,

View file

@ -16,23 +16,29 @@ export type SubtitleHit = {
type OsLogin = { token: string; baseUrl: string; expiresAt: number }; type OsLogin = { token: string; baseUrl: string; expiresAt: number };
const DEFAULT_UA = "MediaCluster v1.3";
/** /**
* OpenSubtitles.com REST API (v1). * OpenSubtitles.com REST API (v1).
* Requires OPENSUBTITLES_API_KEY; login optional but needed for higher download quotas. * User-Agent must match the registered API Consumer name exactly.
*/ */
export class OpenSubtitlesClient { export class OpenSubtitlesClient {
private loginCache: OsLogin | null = null; private loginCache: OsLogin | null = null;
private readonly userAgent: string;
constructor( constructor(
private readonly apiKey: string, private readonly apiKey: string,
private readonly username?: string, private readonly username?: string,
private readonly password?: string private readonly password?: string,
) {} userAgent?: string | null
) {
this.userAgent = userAgent?.trim() || DEFAULT_UA;
}
private headers(extra?: Record<string, string>): Record<string, string> { private headers(extra?: Record<string, string>): Record<string, string> {
return { return {
"Api-Key": this.apiKey, "Api-Key": this.apiKey,
"User-Agent": "MediaCluster v1.3", "User-Agent": this.userAgent,
Accept: "application/json", Accept: "application/json",
...extra, ...extra,
}; };
@ -45,6 +51,18 @@ export class OpenSubtitlesClient {
return "https://api.opensubtitles.com"; return "https://api.opensubtitles.com";
} }
private loginHint(status: number, body: string): string {
if (status === 403 || /cannot consume/i.test(body)) {
return (
`OpenSubtitles.com weigert toegang (403). Check: ` +
`1) account op opensubtitles.com (niet .org), ` +
`2) User-Agent exact gelijk aan je API Consumer-naam (nu: “${this.userAgent}”), ` +
`3) of kies een OS.org-ondertitel (geen .com-login nodig).`
);
}
return `OpenSubtitles.com login mislukt (${status}): ${body.slice(0, 160)}`;
}
async ensureLogin(): Promise<string | null> { async ensureLogin(): Promise<string | null> {
if (!this.username?.trim() || !this.password?.trim()) return null; if (!this.username?.trim() || !this.password?.trim()) return null;
if (this.loginCache && Date.now() < this.loginCache.expiresAt) { if (this.loginCache && Date.now() < this.loginCache.expiresAt) {
@ -60,14 +78,10 @@ export class OpenSubtitlesClient {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new AppError( throw new AppError("OPENSUBTITLES", this.loginHint(res.status, text), 400);
"OPENSUBTITLES",
`OpenSubtitles login mislukt (${res.status}): ${text.slice(0, 160)}`,
400
);
} }
const data = (await res.json()) as { token?: string; base_url?: string }; const data = (await res.json()) as { token?: string; base_url?: string };
if (!data.token) throw new AppError("OPENSUBTITLES", "OpenSubtitles: geen token", 400); if (!data.token) throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: geen token", 400);
this.loginCache = { this.loginCache = {
token: data.token, token: data.token,
baseUrl: data.base_url || "api.opensubtitles.com", baseUrl: data.base_url || "api.opensubtitles.com",
@ -76,6 +90,7 @@ export class OpenSubtitlesClient {
return data.token; return data.token;
} }
/** Search — login optional; failures are ignored so API-key-only still works. */
async search(opts: { async search(opts: {
imdbId?: string; imdbId?: string;
languages?: string; languages?: string;
@ -101,6 +116,13 @@ export class OpenSubtitlesClient {
const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers }); const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
if (res.status === 403 || /cannot consume/i.test(text)) {
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles.com zoeken geweigerd (403). User-Agent “${this.userAgent}” moet exact je API Consumer-naam zijn, of gebruik alleen OS.org-hits.`,
400
);
}
throw new AppError( throw new AppError(
"OPENSUBTITLES", "OPENSUBTITLES",
`OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`, `OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
@ -143,14 +165,23 @@ export class OpenSubtitlesClient {
} }
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> { async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
const token = await this.ensureLogin(); let token: string | null = null;
let loginError: string | null = null;
try {
token = await this.ensureLogin();
} catch (err) {
loginError = err instanceof Error ? err.message : "Login mislukt";
}
if (!token) { if (!token) {
throw new AppError( throw new AppError(
"OPENSUBTITLES", "OPENSUBTITLES",
"OpenSubtitles.com download vereist gebruikersnaam + wachtwoord (Instellingen)", loginError ||
"OpenSubtitles.com download vereist werkende login (opensubtitles.com-account). Kies liever een OS.org-hit.",
400 400
); );
} }
const headers = this.headers({ const headers = this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@ -163,6 +194,13 @@ export class OpenSubtitlesClient {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
if (res.status === 403 || /cannot consume/i.test(text)) {
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles.com download geweigerd (403). Kies een OS.org-ondertitel, of fix API Consumer User-Agent (“${this.userAgent}”).`,
400
);
}
throw new AppError( throw new AppError(
"OPENSUBTITLES", "OPENSUBTITLES",
`OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`, `OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`,

View file

@ -4,10 +4,10 @@ import { AppError } from "../security/errors";
import { OpenSubtitlesClient } from "../opensubtitles/client"; import { OpenSubtitlesClient } from "../opensubtitles/client";
const OS_ID = "opensubtitles"; const OS_ID = "opensubtitles";
const DEFAULT_UA = "MediaCluster v1.3";
export async function isOpenSubtitlesEnabled(): Promise<boolean> { export async function isOpenSubtitlesEnabled(): Promise<boolean> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } }); const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
// Geen rij = standaard aan (.org werkt zonder key)
if (!row) return true; if (!row) return true;
return row.enabled; return row.enabled;
} }
@ -16,8 +16,12 @@ export type OpenSubtitlesSettingsPublic = {
configured: boolean; configured: boolean;
enabled: boolean; enabled: boolean;
username: string; username: string;
userAgent: string;
hasApiKey: boolean; hasApiKey: boolean;
hasPassword: boolean; hasPassword: boolean;
comOk: boolean;
orgOk: boolean;
warning: string | null;
}; };
export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> { export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> {
@ -27,8 +31,12 @@ export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSet
configured: enabled, configured: enabled,
enabled, enabled,
username: row?.username ?? "", username: row?.username ?? "",
userAgent: row?.userAgent?.trim() || DEFAULT_UA,
hasApiKey: !!row?.apiKeyEnc, hasApiKey: !!row?.apiKeyEnc,
hasPassword: !!row?.passwordEnc, hasPassword: !!row?.passwordEnc,
comOk: false,
orgOk: false,
warning: null,
}; };
} }
@ -38,12 +46,12 @@ export async function upsertOpenSubtitlesSettings(
apiKey?: string; apiKey?: string;
username?: string; username?: string;
password?: string; password?: string;
userAgent?: string;
enabled?: boolean; enabled?: boolean;
} }
): Promise<OpenSubtitlesSettingsPublic> { ): Promise<OpenSubtitlesSettingsPublic> {
const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } }); const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
const apiKeyTrim = input.apiKey?.trim(); const apiKeyTrim = input.apiKey?.trim();
// API-key optioneel: zonder key blijft OpenSubtitles.org beschikbaar
const apiKeyEnc = apiKeyTrim const apiKeyEnc = apiKeyTrim
? encryptSecret(apiKeyTrim, sessionSecret) ? encryptSecret(apiKeyTrim, sessionSecret)
: existing?.apiKeyEnc ?? null; : existing?.apiKeyEnc ?? null;
@ -55,6 +63,10 @@ export async function upsertOpenSubtitlesSettings(
: existing?.passwordEnc ?? null; : existing?.passwordEnc ?? null;
const username = const username =
input.username !== undefined ? input.username.trim() || null : existing?.username ?? null; input.username !== undefined ? input.username.trim() || null : existing?.username ?? null;
const userAgent =
input.userAgent !== undefined
? input.userAgent.trim() || DEFAULT_UA
: existing?.userAgent?.trim() || DEFAULT_UA;
await prisma.integrationSetting.upsert({ await prisma.integrationSetting.upsert({
where: { id: OS_ID }, where: { id: OS_ID },
@ -63,31 +75,48 @@ export async function upsertOpenSubtitlesSettings(
apiKeyEnc, apiKeyEnc,
username, username,
passwordEnc, passwordEnc,
userAgent,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
}, },
update: { update: {
apiKeyEnc, apiKeyEnc,
username, username,
passwordEnc, passwordEnc,
userAgent,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
}, },
}); });
// Validate: .com key if present, else .org search smoke-test let comOk = false;
let orgOk = false;
let warning: string | null = null;
const client = await createOpenSubtitlesClientFromDb(sessionSecret); const client = await createOpenSubtitlesClientFromDb(sessionSecret);
try { if (client) {
if (client) { try {
await client.search({ imdbId: "tt1375666", languages: "nl" }); await client.search({ imdbId: "tt1375666", languages: "nl" });
} else { comOk = true;
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client"); } catch (err) {
await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" }); warning = err instanceof Error ? err.message : "OpenSubtitles.com test mislukt";
} }
} catch (err) {
const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt";
throw new AppError("OPENSUBTITLES", message, 400);
} }
return getOpenSubtitlesSettingsPublic(); try {
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client");
await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" });
orgOk = true;
} catch (err) {
const msg = err instanceof Error ? err.message : "OpenSubtitles.org test mislukt";
warning = warning ? `${warning} · ${msg}` : msg;
}
if (!comOk && !orgOk) {
throw new AppError("OPENSUBTITLES", warning || "Geen OpenSubtitles-bron bereikbaar", 400);
}
// Opslaan mag als .org OK is, ook als .com login/API 403 geeft
const pub = await getOpenSubtitlesSettingsPublic();
return { ...pub, comOk, orgOk, warning };
} }
export async function createOpenSubtitlesClientFromDb( export async function createOpenSubtitlesClientFromDb(
@ -97,5 +126,10 @@ export async function createOpenSubtitlesClientFromDb(
if (!row?.enabled || !row.apiKeyEnc) return null; if (!row?.enabled || !row.apiKeyEnc) return null;
const apiKey = decryptSecret(row.apiKeyEnc, sessionSecret); const apiKey = decryptSecret(row.apiKeyEnc, sessionSecret);
const password = row.passwordEnc ? decryptSecret(row.passwordEnc, sessionSecret) : undefined; const password = row.passwordEnc ? decryptSecret(row.passwordEnc, sessionSecret) : undefined;
return new OpenSubtitlesClient(apiKey, row.username ?? undefined, password); return new OpenSubtitlesClient(
apiKey,
row.username ?? undefined,
password,
row.userAgent
);
} }

View file

@ -17,6 +17,7 @@ export function registerSettingsRoutes(app: FastifyInstance, config: Config) {
apiKey?: string; apiKey?: string;
username?: string; username?: string;
password?: string; password?: string;
userAgent?: string;
enabled?: boolean; enabled?: boolean;
}; };
const settings = await upsertOpenSubtitlesSettings(config.SESSION_SECRET, body); const settings = await upsertOpenSubtitlesSettings(config.SESSION_SECRET, body);