Search OpenSubtitles.com and .org in parallel for more NL subtitle hits.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
213faa15ea
commit
e2a9155105
10 changed files with 320 additions and 48 deletions
|
|
@ -42,6 +42,7 @@ interface SubtitleHit {
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
hearingImpaired: boolean;
|
hearingImpaired: boolean;
|
||||||
uploader: string | null;
|
uploader: string | null;
|
||||||
|
source?: "com" | "org";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JobRow {
|
interface JobRow {
|
||||||
|
|
@ -122,6 +123,7 @@ export default function DownloadsPage() {
|
||||||
const [subsBusy, setSubsBusy] = useState(false);
|
const [subsBusy, setSubsBusy] = useState(false);
|
||||||
const [subsConfigured, setSubsConfigured] = useState(true);
|
const [subsConfigured, setSubsConfigured] = useState(true);
|
||||||
const [selectedSubId, setSelectedSubId] = useState<number | null>(null);
|
const [selectedSubId, setSelectedSubId] = useState<number | null>(null);
|
||||||
|
const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null);
|
||||||
const [subsMsg, setSubsMsg] = useState<string | null>(null);
|
const [subsMsg, setSubsMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const [jobs, setJobs] = useState<JobRow[]>([]);
|
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||||
|
|
@ -194,6 +196,7 @@ export default function DownloadsPage() {
|
||||||
setJobErr(null);
|
setJobErr(null);
|
||||||
setSubs([]);
|
setSubs([]);
|
||||||
setSelectedSubId(null);
|
setSelectedSubId(null);
|
||||||
|
setSelectedSubSource(null);
|
||||||
setSubsMsg(null);
|
setSubsMsg(null);
|
||||||
setMetaQ(hit.suggestQuery || hit.title);
|
setMetaQ(hit.suggestQuery || hit.title);
|
||||||
setMetaHits([]);
|
setMetaHits([]);
|
||||||
|
|
@ -221,8 +224,14 @@ export default function DownloadsPage() {
|
||||||
setSubsMsg(null);
|
setSubsMsg(null);
|
||||||
setSubs([]);
|
setSubs([]);
|
||||||
setSelectedSubId(null);
|
setSelectedSubId(null);
|
||||||
|
setSelectedSubSource(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ languages: "nl", releaseHint });
|
const params = new URLSearchParams({
|
||||||
|
languages: "nl",
|
||||||
|
releaseHint,
|
||||||
|
movieTitle: meta.title,
|
||||||
|
});
|
||||||
|
if (meta.year) params.set("movieYear", String(meta.year));
|
||||||
if (meta.imdbId) params.set("imdbId", meta.imdbId);
|
if (meta.imdbId) params.set("imdbId", meta.imdbId);
|
||||||
else params.set("tmdbId", String(meta.tmdbId));
|
else params.set("tmdbId", String(meta.tmdbId));
|
||||||
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
||||||
|
|
@ -240,8 +249,12 @@ export default function DownloadsPage() {
|
||||||
}
|
}
|
||||||
const results: SubtitleHit[] = data.results ?? [];
|
const results: SubtitleHit[] = data.results ?? [];
|
||||||
setSubs(results);
|
setSubs(results);
|
||||||
if (results[0]) setSelectedSubId(results[0].fileId);
|
if (results[0]) {
|
||||||
else setSubsMsg("Geen NL-ondertitels gevonden");
|
setSelectedSubId(results[0].fileId);
|
||||||
|
setSelectedSubSource(results[0].source === "org" ? "org" : "com");
|
||||||
|
} else {
|
||||||
|
setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubsBusy(false);
|
setSubsBusy(false);
|
||||||
}
|
}
|
||||||
|
|
@ -275,6 +288,7 @@ export default function DownloadsPage() {
|
||||||
posterUrl: selectedMeta.posterUrl,
|
posterUrl: selectedMeta.posterUrl,
|
||||||
subtitleFileId: selectedSubId,
|
subtitleFileId: selectedSubId,
|
||||||
subtitleLang: selectedSubId ? "nl" : null,
|
subtitleLang: selectedSubId ? "nl" : null,
|
||||||
|
subtitleSource: selectedSubId ? selectedSubSource || "com" : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
@ -288,6 +302,7 @@ export default function DownloadsPage() {
|
||||||
setSearchErr(null);
|
setSearchErr(null);
|
||||||
setSubs([]);
|
setSubs([]);
|
||||||
setSelectedSubId(null);
|
setSelectedSubId(null);
|
||||||
|
setSelectedSubSource(null);
|
||||||
loadJobs();
|
loadJobs();
|
||||||
} finally {
|
} finally {
|
||||||
setJobBusy(false);
|
setJobBusy(false);
|
||||||
|
|
@ -544,6 +559,9 @@ export default function DownloadsPage() {
|
||||||
{selectedMeta && (
|
{selectedMeta && (
|
||||||
<div className="downloads-subs">
|
<div className="downloads-subs">
|
||||||
<h3 className="card-subtitle">Ondertitel (NL)</h3>
|
<h3 className="card-subtitle">Ondertitel (NL)</h3>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
Bronnen: OpenSubtitles.com + OpenSubtitles.org
|
||||||
|
</p>
|
||||||
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
||||||
{subsMsg && <p className="muted">{subsMsg}</p>}
|
{subsMsg && <p className="muted">{subsMsg}</p>}
|
||||||
{!subsBusy && subsConfigured && (
|
{!subsBusy && subsConfigured && (
|
||||||
|
|
@ -551,7 +569,10 @@ export default function DownloadsPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`match-item${selectedSubId == null ? " selected" : ""}`}
|
className={`match-item${selectedSubId == null ? " selected" : ""}`}
|
||||||
onClick={() => setSelectedSubId(null)}
|
onClick={() => {
|
||||||
|
setSelectedSubId(null);
|
||||||
|
setSelectedSubSource(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong>Geen ondertitel</strong>
|
<strong>Geen ondertitel</strong>
|
||||||
|
|
@ -560,15 +581,25 @@ export default function DownloadsPage() {
|
||||||
</button>
|
</button>
|
||||||
{subs.map((s) => (
|
{subs.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.fileId}
|
key={`${s.source || "com"}-${s.fileId}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={`match-item${selectedSubId === s.fileId ? " selected" : ""}`}
|
className={`match-item${
|
||||||
onClick={() => setSelectedSubId(s.fileId)}
|
selectedSubId === s.fileId &&
|
||||||
|
(selectedSubSource || "com") === (s.source || "com")
|
||||||
|
? " selected"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubId(s.fileId);
|
||||||
|
setSelectedSubSource(s.source === "org" ? "org" : "com");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong>
|
<strong>
|
||||||
{s.language.toUpperCase()} · {s.downloadCount} downloads
|
{(s.language || "nl").toUpperCase()} · {s.downloadCount} downloads
|
||||||
{s.hearingImpaired ? " · HI" : ""}
|
{s.hearingImpaired ? " · HI" : ""}
|
||||||
|
{" · "}
|
||||||
|
{s.source === "org" ? "OS.org" : "OS.com"}
|
||||||
</strong>
|
</strong>
|
||||||
<small className="muted">
|
<small className="muted">
|
||||||
{s.release || s.fileName}
|
{s.release || s.fileName}
|
||||||
|
|
|
||||||
|
|
@ -491,7 +491,8 @@ 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">
|
||||||
NL-ondertitels bij Downloads. Key via opensubtitles.com → API consumers.
|
Zoekt via OpenSubtitles.com (API-key) én OpenSubtitles.org. Key optioneel maar
|
||||||
|
verhoogt de kans — aanmaken via opensubtitles.com → API consumers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT;
|
||||||
|
|
@ -417,6 +417,7 @@ model DownloadJob {
|
||||||
releaseTags String? @map("release_tags")
|
releaseTags String? @map("release_tags")
|
||||||
subtitleFileId Int? @map("subtitle_file_id")
|
subtitleFileId Int? @map("subtitle_file_id")
|
||||||
subtitleLang String? @map("subtitle_lang")
|
subtitleLang String? @map("subtitle_lang")
|
||||||
|
subtitleSource String? @map("subtitle_source")
|
||||||
error String?
|
error String?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,9 @@ async function main() {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT`
|
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT`
|
||||||
);
|
);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT`
|
||||||
|
);
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
CREATE TABLE IF NOT EXISTS "integration_settings" (
|
CREATE TABLE IF NOT EXISTS "integration_settings" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -63,16 +63,21 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
tmdbId?: string;
|
tmdbId?: string;
|
||||||
languages?: string;
|
languages?: string;
|
||||||
releaseHint?: string;
|
releaseHint?: string;
|
||||||
|
movieTitle?: string;
|
||||||
|
movieYear?: string;
|
||||||
};
|
};
|
||||||
const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined;
|
const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined;
|
||||||
if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId))) {
|
if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId))) {
|
||||||
throw new AppError("INVALID_REQUEST", "imdbId of tmdbId verplicht", 400);
|
throw new AppError("INVALID_REQUEST", "imdbId of tmdbId verplicht", 400);
|
||||||
}
|
}
|
||||||
|
const movieYear = q.movieYear ? parseInt(q.movieYear, 10) : undefined;
|
||||||
const out = await downloads.searchSubtitles({
|
const out = await downloads.searchSubtitles({
|
||||||
imdbId: q.imdbId,
|
imdbId: q.imdbId,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
languages: q.languages || "nl",
|
languages: q.languages || "nl",
|
||||||
releaseHint: q.releaseHint,
|
releaseHint: q.releaseHint,
|
||||||
|
movieTitle: q.movieTitle,
|
||||||
|
movieYear: movieYear && Number.isFinite(movieYear) ? movieYear : null,
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
@ -116,6 +121,7 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
posterUrl?: string | null;
|
posterUrl?: string | null;
|
||||||
subtitleFileId?: number | null;
|
subtitleFileId?: number | null;
|
||||||
subtitleLang?: string | null;
|
subtitleLang?: string | null;
|
||||||
|
subtitleSource?: "com" | "org" | null;
|
||||||
};
|
};
|
||||||
if (!body.nodeId || !body.uri || !body.movieTitle) {
|
if (!body.nodeId || !body.uri || !body.movieTitle) {
|
||||||
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
|
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
|
||||||
|
|
@ -131,6 +137,7 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
posterUrl: body.posterUrl,
|
posterUrl: body.posterUrl,
|
||||||
subtitleFileId: body.subtitleFileId,
|
subtitleFileId: body.subtitleFileId,
|
||||||
subtitleLang: body.subtitleLang,
|
subtitleLang: body.subtitleLang,
|
||||||
|
subtitleSource: body.subtitleSource === "org" ? "org" : body.subtitleFileId ? "com" : null,
|
||||||
});
|
});
|
||||||
return { job };
|
return { job };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ import {
|
||||||
import {
|
import {
|
||||||
OpenSubtitlesClient,
|
OpenSubtitlesClient,
|
||||||
extractReleaseTags,
|
extractReleaseTags,
|
||||||
|
stripQualityTokens,
|
||||||
type SubtitleHit,
|
type SubtitleHit,
|
||||||
|
type SubtitleSource,
|
||||||
} from "../opensubtitles/client";
|
} from "../opensubtitles/client";
|
||||||
import { createOpenSubtitlesClientFromDb } from "../settings/opensubtitles";
|
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
|
||||||
|
import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles";
|
||||||
|
|
||||||
function normalizeBaseUrl(raw: string): string {
|
function normalizeBaseUrl(raw: string): string {
|
||||||
let u = raw.trim().replace(/\/+$/, "");
|
let u = raw.trim().replace(/\/+$/, "");
|
||||||
|
|
@ -48,8 +51,12 @@ export class DownloadService {
|
||||||
return createOpenSubtitlesClientFromDb(this.sessionSecret);
|
return createOpenSubtitlesClientFromDb(this.sessionSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private orgClient(): OpenSubtitlesOrgClient {
|
||||||
|
return new OpenSubtitlesOrgClient();
|
||||||
|
}
|
||||||
|
|
||||||
async hasOpenSubtitles(): Promise<boolean> {
|
async hasOpenSubtitles(): Promise<boolean> {
|
||||||
return !!(await this.opensubtitles());
|
return isOpenSubtitlesEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchSubtitles(opts: {
|
async searchSubtitles(opts: {
|
||||||
|
|
@ -57,40 +64,91 @@ export class DownloadService {
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
languages?: string;
|
languages?: string;
|
||||||
releaseHint?: string;
|
releaseHint?: string;
|
||||||
}): Promise<{ imdbId: string | null; results: SubtitleHit[]; configured: boolean }> {
|
movieTitle?: string;
|
||||||
const client = await this.opensubtitles();
|
movieYear?: number | null;
|
||||||
if (!client) {
|
}): Promise<{
|
||||||
|
imdbId: string | null;
|
||||||
|
results: SubtitleHit[];
|
||||||
|
configured: boolean;
|
||||||
|
}> {
|
||||||
|
const enabled = await isOpenSubtitlesEnabled();
|
||||||
|
if (!enabled) {
|
||||||
return { imdbId: null, results: [], configured: false };
|
return { imdbId: null, results: [], configured: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const comClient = await this.opensubtitles();
|
||||||
|
const orgClient = this.orgClient();
|
||||||
|
const configured = true;
|
||||||
|
|
||||||
let imdbId = opts.imdbId?.trim() || null;
|
let imdbId = opts.imdbId?.trim() || null;
|
||||||
if (!imdbId && opts.tmdbId) {
|
if (!imdbId && opts.tmdbId) {
|
||||||
imdbId = await this.metadata.resolveMovieImdbId(opts.tmdbId);
|
imdbId = await this.metadata.resolveMovieImdbId(opts.tmdbId);
|
||||||
}
|
}
|
||||||
if (!imdbId) {
|
|
||||||
return { imdbId: null, results: [], configured: true };
|
|
||||||
}
|
|
||||||
const languages = opts.languages?.trim() || "nl";
|
const languages = opts.languages?.trim() || "nl";
|
||||||
const results = await client.search({
|
const title =
|
||||||
imdbId,
|
opts.movieTitle?.trim() ||
|
||||||
languages,
|
stripQualityTokens(opts.releaseHint || "").replace(/\b(19|20)\d{2}\b/g, "").trim() ||
|
||||||
query: opts.releaseHint,
|
"";
|
||||||
});
|
const year = opts.movieYear ?? null;
|
||||||
const hint = (opts.releaseHint || "").toLowerCase();
|
const titleQuery = title
|
||||||
const scored = results
|
? year && year > 1900
|
||||||
|
? `${title} ${year}`
|
||||||
|
: title
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const tasks: Array<Promise<SubtitleHit[]>> = [];
|
||||||
|
|
||||||
|
const safe = (label: string, p: Promise<SubtitleHit[]>) =>
|
||||||
|
p.catch((err) => {
|
||||||
|
console.warn(`[downloads] subtitle search ${label}:`, err instanceof Error ? err.message : err);
|
||||||
|
return [] as SubtitleHit[];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (comClient && imdbId) {
|
||||||
|
tasks.push(safe("com/imdb", comClient.search({ imdbId, languages })));
|
||||||
|
}
|
||||||
|
if (comClient && titleQuery) {
|
||||||
|
tasks.push(safe("com/title", comClient.search({ query: titleQuery, languages })));
|
||||||
|
}
|
||||||
|
// OpenSubtitles.org = aparte catalogus (rest.opensubtitles.org)
|
||||||
|
if (imdbId) {
|
||||||
|
tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages })));
|
||||||
|
}
|
||||||
|
if (titleQuery) {
|
||||||
|
tasks.push(safe("org/title", orgClient.search({ query: titleQuery, languages })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return { imdbId, results: [], configured };
|
||||||
|
}
|
||||||
|
|
||||||
|
const batches = await Promise.all(tasks);
|
||||||
|
const merged = mergeSubtitleHits(batches.flat());
|
||||||
|
|
||||||
|
const hintRaw = opts.releaseHint || "";
|
||||||
|
const hint = stripQualityTokens(hintRaw).toLowerCase();
|
||||||
|
const scored = merged
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
let score = r.downloadCount;
|
let score = r.downloadCount;
|
||||||
if (r.language === "nl" || r.language.startsWith("nl")) score += 1_000_000;
|
if (r.language === "nl" || r.language.startsWith("nl") || r.language === "dut") {
|
||||||
if (hint && r.release) {
|
score += 1_000_000;
|
||||||
|
}
|
||||||
|
if (r.source === "org") score += 8; // .org vaak rijker; niet onder .com laten verdwijnen
|
||||||
|
if (r.source === "com") score += 5;
|
||||||
|
if (hint) {
|
||||||
|
const hay = `${r.release} ${r.fileName}`.toLowerCase();
|
||||||
const tokens = hint.split(/[^a-z0-9]+/).filter((t) => t.length > 2);
|
const tokens = hint.split(/[^a-z0-9]+/).filter((t) => t.length > 2);
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
if (r.release.toLowerCase().includes(t)) score += 50;
|
if (hay.includes(t)) score += 50;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { r, score };
|
return { r, score };
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.map((x) => x.r);
|
.map((x) => x.r);
|
||||||
return { imdbId, results: scored, configured: true };
|
|
||||||
|
return { imdbId, results: scored.slice(0, 40), configured };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clientForNode(nodeId: string): Promise<{
|
private async clientForNode(nodeId: string): Promise<{
|
||||||
|
|
@ -262,6 +320,7 @@ export class DownloadService {
|
||||||
posterUrl?: string | null;
|
posterUrl?: string | null;
|
||||||
subtitleFileId?: number | null;
|
subtitleFileId?: number | null;
|
||||||
subtitleLang?: string | null;
|
subtitleLang?: string | null;
|
||||||
|
subtitleSource?: SubtitleSource | null;
|
||||||
}) {
|
}) {
|
||||||
const uri = input.uri.trim();
|
const uri = input.uri.trim();
|
||||||
if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400);
|
if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400);
|
||||||
|
|
@ -328,6 +387,11 @@ export class DownloadService {
|
||||||
const subtitleLang = subtitleFileId
|
const subtitleLang = subtitleFileId
|
||||||
? (input.subtitleLang?.trim().toLowerCase() || "nl")
|
? (input.subtitleLang?.trim().toLowerCase() || "nl")
|
||||||
: null;
|
: null;
|
||||||
|
const subtitleSource: SubtitleSource | null = subtitleFileId
|
||||||
|
? input.subtitleSource === "org"
|
||||||
|
? "org"
|
||||||
|
: "com"
|
||||||
|
: null;
|
||||||
|
|
||||||
let beforeIds = new Set<string>();
|
let beforeIds = new Set<string>();
|
||||||
try {
|
try {
|
||||||
|
|
@ -371,6 +435,7 @@ export class DownloadService {
|
||||||
releaseTags,
|
releaseTags,
|
||||||
subtitleFileId,
|
subtitleFileId,
|
||||||
subtitleLang,
|
subtitleLang,
|
||||||
|
subtitleSource,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -512,10 +577,19 @@ export class DownloadService {
|
||||||
| undefined;
|
| undefined;
|
||||||
let subtitleText: string | null = null;
|
let subtitleText: string | null = null;
|
||||||
let subtitleFileName: string | null = null;
|
let subtitleFileName: string | null = null;
|
||||||
const osClient = await this.opensubtitles();
|
if (job.subtitleFileId) {
|
||||||
if (job.subtitleFileId && osClient) {
|
|
||||||
try {
|
try {
|
||||||
const file = await osClient.downloadFile(job.subtitleFileId);
|
const source = job.subtitleSource === "org" ? "org" : "com";
|
||||||
|
const file =
|
||||||
|
source === "org"
|
||||||
|
? await this.orgClient().downloadFile(job.subtitleFileId)
|
||||||
|
: await (async () => {
|
||||||
|
const osClient = await this.opensubtitles();
|
||||||
|
if (!osClient) {
|
||||||
|
throw new Error("OpenSubtitles.com niet geconfigureerd");
|
||||||
|
}
|
||||||
|
return osClient.downloadFile(job.subtitleFileId!);
|
||||||
|
})();
|
||||||
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
||||||
subtitleText = file.content.toString("utf8");
|
subtitleText = file.content.toString("utf8");
|
||||||
subtitleFileName = file.fileName;
|
subtitleFileName = file.fileName;
|
||||||
|
|
@ -596,6 +670,19 @@ export class DownloadService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSubtitleHits(hits: SubtitleHit[]): SubtitleHit[] {
|
||||||
|
// Bewaar hits van .com én .org apart (andere catalogs), dedupe alleen binnen dezelfde bron.
|
||||||
|
const byId = new Map<string, SubtitleHit>();
|
||||||
|
for (const hit of hits) {
|
||||||
|
const idKey = `${hit.source}:${hit.fileId}`;
|
||||||
|
const prev = byId.get(idKey);
|
||||||
|
if (!prev || hit.downloadCount > prev.downloadCount) {
|
||||||
|
byId.set(idKey, hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
function isTaskDone(status: string): boolean {
|
function isTaskDone(status: string): boolean {
|
||||||
return status === "finished" || status === "seeding";
|
return status === "finished" || status === "seeding";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
|
|
||||||
|
export type SubtitleSource = "com" | "org";
|
||||||
|
|
||||||
export type SubtitleHit = {
|
export type SubtitleHit = {
|
||||||
fileId: number;
|
fileId: number;
|
||||||
language: string;
|
language: string;
|
||||||
|
|
@ -9,6 +11,7 @@ export type SubtitleHit = {
|
||||||
hearingImpaired: boolean;
|
hearingImpaired: boolean;
|
||||||
fps: number | null;
|
fps: number | null;
|
||||||
uploader: string | null;
|
uploader: string | null;
|
||||||
|
source: SubtitleSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OsLogin = { token: string; baseUrl: string; expiresAt: number };
|
type OsLogin = { token: string; baseUrl: string; expiresAt: number };
|
||||||
|
|
@ -74,22 +77,21 @@ export class OpenSubtitlesClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(opts: {
|
async search(opts: {
|
||||||
imdbId: string;
|
imdbId?: string;
|
||||||
languages?: string;
|
languages?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
}): Promise<SubtitleHit[]> {
|
}): Promise<SubtitleHit[]> {
|
||||||
const imdb = opts.imdbId.trim().replace(/^tt/i, "");
|
|
||||||
if (!/^\d+$/.test(imdb)) return [];
|
|
||||||
|
|
||||||
const languages = (opts.languages || "nl").trim() || "nl";
|
const languages = (opts.languages || "nl").trim() || "nl";
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
imdb_id: imdb,
|
|
||||||
languages,
|
languages,
|
||||||
type: "movie",
|
type: "movie",
|
||||||
order_by: "download_count",
|
order_by: "download_count",
|
||||||
order_direction: "desc",
|
order_direction: "desc",
|
||||||
});
|
});
|
||||||
|
const imdb = opts.imdbId?.trim().replace(/^tt/i, "");
|
||||||
|
if (imdb && /^\d+$/.test(imdb)) params.set("imdb_id", imdb);
|
||||||
if (opts.query?.trim()) params.set("query", opts.query.trim());
|
if (opts.query?.trim()) params.set("query", opts.query.trim());
|
||||||
|
if (!params.has("imdb_id") && !params.has("query")) return [];
|
||||||
|
|
||||||
const token = await this.ensureLogin().catch(() => null);
|
const token = await this.ensureLogin().catch(() => null);
|
||||||
const headers = this.headers(
|
const headers = this.headers(
|
||||||
|
|
@ -101,7 +103,7 @@ export class OpenSubtitlesClient {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"OPENSUBTITLES",
|
"OPENSUBTITLES",
|
||||||
`OpenSubtitles zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
|
`OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
|
||||||
400
|
400
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -134,9 +136,10 @@ export class OpenSubtitlesClient {
|
||||||
hearingImpaired: !!a?.hearing_impaired,
|
hearingImpaired: !!a?.hearing_impaired,
|
||||||
fps: a?.fps ?? null,
|
fps: a?.fps ?? null,
|
||||||
uploader: a?.uploader?.name ?? null,
|
uploader: a?.uploader?.name ?? null,
|
||||||
|
source: "com",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out.slice(0, 25);
|
return out.slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
|
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
|
||||||
|
|
@ -197,6 +200,18 @@ export function extractReleaseTags(
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip resolution/codec noise so subtitle search can fall back to title match. */
|
||||||
|
export function stripQualityTokens(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/[._]+/g, " ")
|
||||||
|
.replace(
|
||||||
|
/\b(2160p|1080p|720p|480p|4k|uhd|hdr10\+?|hdr|dv|dovi|atmos|truehd|dts(?:-hd)?(?:\s*ma)?|flac|aac|ac3|eac3|ddp5\.1|bluray|blu-ray|remux|web-?dl|webrip|hdtv|x265|x264|hevc|avc|h\.?265|h\.?264|proper|repack|extended|directors?\s*cut)\b/gi,
|
||||||
|
" "
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
function escapeRegExp(s: string): string {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
apps/master-api/src/opensubtitles/org-client.ts
Normal file
117
apps/master-api/src/opensubtitles/org-client.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { gunzipSync } from "zlib";
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import type { SubtitleHit } from "./client";
|
||||||
|
|
||||||
|
type OrgRow = {
|
||||||
|
IDSubtitleFile?: string;
|
||||||
|
SubFileName?: string;
|
||||||
|
MovieReleaseName?: string;
|
||||||
|
SubDownloadsCnt?: string;
|
||||||
|
SubHearingImpaired?: string;
|
||||||
|
MovieFPS?: string;
|
||||||
|
UserNickName?: string | null;
|
||||||
|
ISO639?: string;
|
||||||
|
SubLanguageID?: string;
|
||||||
|
SubDownloadLink?: string;
|
||||||
|
SubFormat?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy OpenSubtitles.org REST search (still live).
|
||||||
|
* Download via stable dl.opensubtitles.org/file/{id} (plain .srt).
|
||||||
|
*/
|
||||||
|
export class OpenSubtitlesOrgClient {
|
||||||
|
private readonly ua = "MediaCluster v1.3";
|
||||||
|
|
||||||
|
async search(opts: {
|
||||||
|
imdbId?: string;
|
||||||
|
query?: string;
|
||||||
|
languages?: string;
|
||||||
|
}): Promise<SubtitleHit[]> {
|
||||||
|
const lang = toOrgLang(opts.languages || "nl");
|
||||||
|
const parts: string[] = [`sublanguageid-${lang}`];
|
||||||
|
const imdb = opts.imdbId?.trim().replace(/^tt/i, "");
|
||||||
|
if (imdb && /^\d+$/.test(imdb)) {
|
||||||
|
parts.unshift(`imdbid-${imdb}`);
|
||||||
|
} else if (opts.query?.trim()) {
|
||||||
|
parts.unshift(`query-${encodeURIComponent(opts.query.trim()).replace(/%20/g, "+")}`);
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://rest.opensubtitles.org/search/${parts.join("/")}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": this.ua,
|
||||||
|
"X-User-Agent": this.ua,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new AppError(
|
||||||
|
"OPENSUBTITLES",
|
||||||
|
`OpenSubtitles.org zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as OrgRow[] | { data?: OrgRow[] };
|
||||||
|
const rows = Array.isArray(body) ? body : body.data ?? [];
|
||||||
|
const out: SubtitleHit[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const fileId = parseInt(String(row.IDSubtitleFile || ""), 10);
|
||||||
|
if (!Number.isFinite(fileId) || fileId <= 0) continue;
|
||||||
|
const fps = row.MovieFPS ? parseFloat(row.MovieFPS) : null;
|
||||||
|
out.push({
|
||||||
|
fileId,
|
||||||
|
language: (row.ISO639 || lang || "nl").toLowerCase(),
|
||||||
|
release: (row.MovieReleaseName || row.SubFileName || "").trim(),
|
||||||
|
fileName: row.SubFileName || `sub-${fileId}.srt`,
|
||||||
|
downloadCount: parseInt(String(row.SubDownloadsCnt || "0"), 10) || 0,
|
||||||
|
hearingImpaired: row.SubHearingImpaired === "1",
|
||||||
|
fps: fps && Number.isFinite(fps) ? fps : null,
|
||||||
|
uploader: row.UserNickName || null,
|
||||||
|
source: "org",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
|
||||||
|
const url = `https://dl.opensubtitles.org/en/download/file/${fileId}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": this.ua, Accept: "*/*" },
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new AppError(
|
||||||
|
"OPENSUBTITLES",
|
||||||
|
`OpenSubtitles.org download mislukt (${res.status})`,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
const disposition = res.headers.get("content-disposition") || "";
|
||||||
|
const nameMatch = /filename="?([^";]+)"?/i.exec(disposition);
|
||||||
|
let fileName = nameMatch?.[1]?.trim() || `subtitle-${fileId}.srt`;
|
||||||
|
let content = buf;
|
||||||
|
if (fileName.toLowerCase().endsWith(".gz") || isGzip(buf)) {
|
||||||
|
content = gunzipSync(buf);
|
||||||
|
fileName = fileName.replace(/\.gz$/i, "");
|
||||||
|
if (!/\.(srt|ass|ssa|vtt)$/i.test(fileName)) fileName = `${fileName}.srt`;
|
||||||
|
}
|
||||||
|
return { content, fileName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOrgLang(languages: string): string {
|
||||||
|
const first = languages.split(",")[0]?.trim().toLowerCase() || "nl";
|
||||||
|
if (first === "nl" || first === "nld" || first === "dut") return "dut";
|
||||||
|
if (first.length === 3) return first;
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGzip(buf: Buffer): boolean {
|
||||||
|
return buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,13 @@ import { OpenSubtitlesClient } from "../opensubtitles/client";
|
||||||
|
|
||||||
const OS_ID = "opensubtitles";
|
const OS_ID = "opensubtitles";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export type OpenSubtitlesSettingsPublic = {
|
export type OpenSubtitlesSettingsPublic = {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -15,9 +22,10 @@ export type OpenSubtitlesSettingsPublic = {
|
||||||
|
|
||||||
export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> {
|
export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> {
|
||||||
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
|
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
|
||||||
|
const enabled = row?.enabled ?? true;
|
||||||
return {
|
return {
|
||||||
configured: !!(row?.apiKeyEnc && row.enabled),
|
configured: enabled,
|
||||||
enabled: row?.enabled ?? true,
|
enabled,
|
||||||
username: row?.username ?? "",
|
username: row?.username ?? "",
|
||||||
hasApiKey: !!row?.apiKeyEnc,
|
hasApiKey: !!row?.apiKeyEnc,
|
||||||
hasPassword: !!row?.passwordEnc,
|
hasPassword: !!row?.passwordEnc,
|
||||||
|
|
@ -35,13 +43,10 @@ export async function upsertOpenSubtitlesSettings(
|
||||||
): 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();
|
||||||
if (!apiKeyTrim && !existing?.apiKeyEnc) {
|
// API-key optioneel: zonder key blijft OpenSubtitles.org beschikbaar
|
||||||
throw new AppError("INVALID_REQUEST", "API-key is verplicht", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKeyEnc = apiKeyTrim
|
const apiKeyEnc = apiKeyTrim
|
||||||
? encryptSecret(apiKeyTrim, sessionSecret)
|
? encryptSecret(apiKeyTrim, sessionSecret)
|
||||||
: existing!.apiKeyEnc;
|
: existing?.apiKeyEnc ?? null;
|
||||||
const passwordEnc =
|
const passwordEnc =
|
||||||
input.password !== undefined
|
input.password !== undefined
|
||||||
? input.password.trim()
|
? input.password.trim()
|
||||||
|
|
@ -68,11 +73,15 @@ export async function upsertOpenSubtitlesSettings(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate by searching a known movie (optional light check: login if creds)
|
// Validate: .com key if present, else .org search smoke-test
|
||||||
const client = await createOpenSubtitlesClientFromDb(sessionSecret);
|
const client = await createOpenSubtitlesClientFromDb(sessionSecret);
|
||||||
if (!client) throw new AppError("OPENSUBTITLES", "Kon client niet maken", 400);
|
|
||||||
try {
|
try {
|
||||||
await client.search({ imdbId: "tt1375666", languages: "nl" });
|
if (client) {
|
||||||
|
await client.search({ imdbId: "tt1375666", languages: "nl" });
|
||||||
|
} else {
|
||||||
|
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client");
|
||||||
|
await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt";
|
const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt";
|
||||||
throw new AppError("OPENSUBTITLES", message, 400);
|
throw new AppError("OPENSUBTITLES", message, 400);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue