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:
parent
93bbd07487
commit
afcc2e759f
8 changed files with 140 additions and 33 deletions
|
|
@ -254,8 +254,11 @@ export default function DownloadsPage() {
|
|||
const results: SubtitleHit[] = data.results ?? [];
|
||||
setSubs(results);
|
||||
if (results[0]) {
|
||||
setSelectedSubId(results[0].fileId);
|
||||
setSelectedSubSource(results[0].source === "org" ? "org" : "com");
|
||||
// Voorkeur OS.org — werkt zonder .com-login
|
||||
const preferred =
|
||||
results.find((r) => r.source === "org") || results[0];
|
||||
setSelectedSubId(preferred.fileId);
|
||||
setSelectedSubSource(preferred.source === "org" ? "org" : "com");
|
||||
} else {
|
||||
setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export default function SettingsPage() {
|
|||
const [osApiKey, setOsApiKey] = useState("");
|
||||
const [osUsername, setOsUsername] = useState("");
|
||||
const [osPassword, setOsPassword] = useState("");
|
||||
const [osUserAgent, setOsUserAgent] = useState("MediaCluster v1.3");
|
||||
const [osEnabled, setOsEnabled] = useState(true);
|
||||
const [osHasKey, setOsHasKey] = useState(false);
|
||||
const [osHasPassword, setOsHasPassword] = useState(false);
|
||||
|
|
@ -96,6 +97,7 @@ export default function SettingsPage() {
|
|||
const [osBusy, setOsBusy] = useState(false);
|
||||
const [osMsg, setOsMsg] = useState<string | null>(null);
|
||||
const [osErr, setOsErr] = useState<string | null>(null);
|
||||
const [osWarn, setOsWarn] = useState<string | null>(null);
|
||||
|
||||
const loadShelves = useCallback(() => {
|
||||
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
|
||||
|
|
@ -131,8 +133,10 @@ export default function SettingsPage() {
|
|||
setOsConfigured(!!s.configured);
|
||||
setOsEnabled(s.enabled !== false);
|
||||
setOsUsername(s.username ?? "");
|
||||
setOsUserAgent(s.userAgent || "MediaCluster v1.3");
|
||||
setOsHasKey(!!s.hasApiKey);
|
||||
setOsHasPassword(!!s.hasPassword);
|
||||
setOsWarn(s.warning || null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
|
@ -217,6 +221,7 @@ export default function SettingsPage() {
|
|||
setOsBusy(true);
|
||||
setOsMsg(null);
|
||||
setOsErr(null);
|
||||
setOsWarn(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/settings/opensubtitles", {
|
||||
method: "PUT",
|
||||
|
|
@ -226,6 +231,7 @@ export default function SettingsPage() {
|
|||
apiKey: osApiKey || undefined,
|
||||
username: osUsername,
|
||||
password: osPassword || undefined,
|
||||
userAgent: osUserAgent.trim() || "MediaCluster v1.3",
|
||||
enabled: osEnabled,
|
||||
}),
|
||||
});
|
||||
|
|
@ -234,7 +240,13 @@ export default function SettingsPage() {
|
|||
setOsErr(data.error?.message ?? "Opslaan mislukt");
|
||||
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("");
|
||||
setOsPassword("");
|
||||
loadOpenSubtitles();
|
||||
|
|
@ -491,8 +503,10 @@ export default function SettingsPage() {
|
|||
<div className="section-head">
|
||||
<h2 className="section-title">OpenSubtitles</h2>
|
||||
<p className="muted">
|
||||
Zoekt via OpenSubtitles.com (API-key) én OpenSubtitles.org. Key optioneel maar
|
||||
verhoogt de kans — aanmaken via opensubtitles.com → API consumers.
|
||||
Zoekt via OpenSubtitles.com én .org. Account/login moet van{" "}
|
||||
<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>
|
||||
</div>
|
||||
<div className="card">
|
||||
|
|
@ -508,7 +522,16 @@ export default function SettingsPage() {
|
|||
/>
|
||||
</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
|
||||
value={osUsername}
|
||||
onChange={(e) => setOsUsername(e.target.value)}
|
||||
|
|
@ -516,7 +539,7 @@ export default function SettingsPage() {
|
|||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wachtwoord {osHasPassword ? "(leeg = behouden)" : ""}
|
||||
Wachtwoord (.com) {osHasPassword ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={osPassword}
|
||||
|
|
@ -538,9 +561,10 @@ export default function SettingsPage() {
|
|||
{osBusy ? "Bezig…" : "Opslaan + testen"}
|
||||
</button>
|
||||
<span className="muted">
|
||||
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"}
|
||||
Status: {osConfigured ? "aan" : "uit"}
|
||||
</span>
|
||||
{osMsg && <span className="ok-text">{osMsg}</span>}
|
||||
{osWarn && <span className="danger-text">{osWarn}</span>}
|
||||
{osErr && <span className="danger-text">{osErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT;
|
||||
|
|
@ -439,6 +439,8 @@ model IntegrationSetting {
|
|||
apiKeyEnc String? @map("api_key_enc")
|
||||
username String?
|
||||
passwordEnc String? @map("password_enc")
|
||||
/** Must match OpenSubtitles.com API Consumer / User-Agent exactly */
|
||||
userAgent String? @map("user_agent")
|
||||
enabled Boolean @default(true)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
|
|
|||
|
|
@ -265,12 +265,16 @@ async function main() {
|
|||
"api_key_enc" TEXT,
|
||||
"username" TEXT,
|
||||
"password_enc" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS "library_subtitles" (
|
||||
"id" TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -16,23 +16,29 @@ export type SubtitleHit = {
|
|||
|
||||
type OsLogin = { token: string; baseUrl: string; expiresAt: number };
|
||||
|
||||
const DEFAULT_UA = "MediaCluster v1.3";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private loginCache: OsLogin | null = null;
|
||||
private readonly userAgent: string;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: 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> {
|
||||
return {
|
||||
"Api-Key": this.apiKey,
|
||||
"User-Agent": "MediaCluster v1.3",
|
||||
"User-Agent": this.userAgent,
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
|
@ -45,6 +51,18 @@ export class OpenSubtitlesClient {
|
|||
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> {
|
||||
if (!this.username?.trim() || !this.password?.trim()) return null;
|
||||
if (this.loginCache && Date.now() < this.loginCache.expiresAt) {
|
||||
|
|
@ -60,14 +78,10 @@ export class OpenSubtitlesClient {
|
|||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new AppError(
|
||||
"OPENSUBTITLES",
|
||||
`OpenSubtitles login mislukt (${res.status}): ${text.slice(0, 160)}`,
|
||||
400
|
||||
);
|
||||
throw new AppError("OPENSUBTITLES", this.loginHint(res.status, text), 400);
|
||||
}
|
||||
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 = {
|
||||
token: data.token,
|
||||
baseUrl: data.base_url || "api.opensubtitles.com",
|
||||
|
|
@ -76,6 +90,7 @@ export class OpenSubtitlesClient {
|
|||
return data.token;
|
||||
}
|
||||
|
||||
/** Search — login optional; failures are ignored so API-key-only still works. */
|
||||
async search(opts: {
|
||||
imdbId?: string;
|
||||
languages?: string;
|
||||
|
|
@ -101,6 +116,13 @@ export class OpenSubtitlesClient {
|
|||
const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers });
|
||||
if (!res.ok) {
|
||||
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(
|
||||
"OPENSUBTITLES",
|
||||
`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 }> {
|
||||
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) {
|
||||
throw new AppError(
|
||||
"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
|
||||
);
|
||||
}
|
||||
|
||||
const headers = this.headers({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
|
@ -163,6 +194,13 @@ export class OpenSubtitlesClient {
|
|||
});
|
||||
if (!res.ok) {
|
||||
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(
|
||||
"OPENSUBTITLES",
|
||||
`OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { AppError } from "../security/errors";
|
|||
import { OpenSubtitlesClient } from "../opensubtitles/client";
|
||||
|
||||
const OS_ID = "opensubtitles";
|
||||
const DEFAULT_UA = "MediaCluster v1.3";
|
||||
|
||||
export async function isOpenSubtitlesEnabled(): Promise<boolean> {
|
||||
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
|
||||
// Geen rij = standaard aan (.org werkt zonder key)
|
||||
if (!row) return true;
|
||||
return row.enabled;
|
||||
}
|
||||
|
|
@ -16,8 +16,12 @@ export type OpenSubtitlesSettingsPublic = {
|
|||
configured: boolean;
|
||||
enabled: boolean;
|
||||
username: string;
|
||||
userAgent: string;
|
||||
hasApiKey: boolean;
|
||||
hasPassword: boolean;
|
||||
comOk: boolean;
|
||||
orgOk: boolean;
|
||||
warning: string | null;
|
||||
};
|
||||
|
||||
export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> {
|
||||
|
|
@ -27,8 +31,12 @@ export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSet
|
|||
configured: enabled,
|
||||
enabled,
|
||||
username: row?.username ?? "",
|
||||
userAgent: row?.userAgent?.trim() || DEFAULT_UA,
|
||||
hasApiKey: !!row?.apiKeyEnc,
|
||||
hasPassword: !!row?.passwordEnc,
|
||||
comOk: false,
|
||||
orgOk: false,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -38,12 +46,12 @@ export async function upsertOpenSubtitlesSettings(
|
|||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
userAgent?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
): Promise<OpenSubtitlesSettingsPublic> {
|
||||
const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
|
||||
const apiKeyTrim = input.apiKey?.trim();
|
||||
// API-key optioneel: zonder key blijft OpenSubtitles.org beschikbaar
|
||||
const apiKeyEnc = apiKeyTrim
|
||||
? encryptSecret(apiKeyTrim, sessionSecret)
|
||||
: existing?.apiKeyEnc ?? null;
|
||||
|
|
@ -55,6 +63,10 @@ export async function upsertOpenSubtitlesSettings(
|
|||
: existing?.passwordEnc ?? null;
|
||||
const username =
|
||||
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({
|
||||
where: { id: OS_ID },
|
||||
|
|
@ -63,31 +75,48 @@ export async function upsertOpenSubtitlesSettings(
|
|||
apiKeyEnc,
|
||||
username,
|
||||
passwordEnc,
|
||||
userAgent,
|
||||
enabled: input.enabled ?? true,
|
||||
},
|
||||
update: {
|
||||
apiKeyEnc,
|
||||
username,
|
||||
passwordEnc,
|
||||
userAgent,
|
||||
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);
|
||||
try {
|
||||
if (client) {
|
||||
if (client) {
|
||||
try {
|
||||
await client.search({ imdbId: "tt1375666", languages: "nl" });
|
||||
} else {
|
||||
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client");
|
||||
await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" });
|
||||
comOk = true;
|
||||
} catch (err) {
|
||||
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(
|
||||
|
|
@ -97,5 +126,10 @@ export async function createOpenSubtitlesClientFromDb(
|
|||
if (!row?.enabled || !row.apiKeyEnc) return null;
|
||||
const apiKey = decryptSecret(row.apiKeyEnc, sessionSecret);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export function registerSettingsRoutes(app: FastifyInstance, config: Config) {
|
|||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
userAgent?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
const settings = await upsertOpenSubtitlesSettings(config.SESSION_SECRET, body);
|
||||
|
|
|
|||
Loading…
Reference in a new issue