Allow re-picking OS.org subtitles on imported jobs; fix OpenSubtitles.com User-Agent format.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
afcc2e759f
commit
942da3a58c
6 changed files with 273 additions and 37 deletions
|
|
@ -54,10 +54,13 @@ interface JobRow {
|
|||
movieTitle: string;
|
||||
movieYear: number | null;
|
||||
posterUrl: string | null;
|
||||
tmdbId?: number | null;
|
||||
imdbId?: string | null;
|
||||
error: string | null;
|
||||
hasSubtitle?: boolean;
|
||||
subtitlePath?: string | null;
|
||||
subtitleError?: string | null;
|
||||
subtitleSource?: string | null;
|
||||
importedPath?: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +132,8 @@ export default function DownloadsPage() {
|
|||
const [selectedSubId, setSelectedSubId] = useState<number | null>(null);
|
||||
const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null);
|
||||
const [subsMsg, setSubsMsg] = useState<string | null>(null);
|
||||
const [subPickJob, setSubPickJob] = useState<JobRow | null>(null);
|
||||
const [subPickBusy, setSubPickBusy] = useState(false);
|
||||
|
||||
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||
|
||||
|
|
@ -324,16 +329,83 @@ export default function DownloadsPage() {
|
|||
loadJobs();
|
||||
}
|
||||
|
||||
async function retrySubtitle(id: string) {
|
||||
const res = await fetch(`/api/v1/admin/downloads/jobs/${id}/retry-subtitle`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error?.message ?? "Ondertitel schrijven mislukt");
|
||||
async function openSubtitlePicker(job: JobRow) {
|
||||
setSubPickJob(job);
|
||||
setSubs([]);
|
||||
setSelectedSubId(null);
|
||||
setSelectedSubSource(null);
|
||||
setSubsMsg(null);
|
||||
setSubsBusy(true);
|
||||
setSubsConfigured(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
languages: "nl",
|
||||
releaseHint: job.sourceTitle || job.movieTitle,
|
||||
movieTitle: job.movieTitle,
|
||||
});
|
||||
if (job.movieYear) params.set("movieYear", String(job.movieYear));
|
||||
if (job.imdbId) params.set("imdbId", job.imdbId);
|
||||
else if (job.tmdbId) params.set("tmdbId", String(job.tmdbId));
|
||||
else {
|
||||
setSubsMsg("Geen IMDb/TMDB op deze job — kan niet zoeken");
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt");
|
||||
return;
|
||||
}
|
||||
setSubsConfigured(data.configured !== false);
|
||||
const results: SubtitleHit[] = data.results ?? [];
|
||||
// Toon .org eerst — werkt zonder .com-login
|
||||
results.sort((a, b) => {
|
||||
if (a.source === "org" && b.source !== "org") return -1;
|
||||
if (b.source === "org" && a.source !== "org") return 1;
|
||||
return (b.downloadCount || 0) - (a.downloadCount || 0);
|
||||
});
|
||||
setSubs(results);
|
||||
const preferred = results.find((r) => r.source === "org") || results[0];
|
||||
if (preferred) {
|
||||
setSelectedSubId(preferred.fileId);
|
||||
setSelectedSubSource(preferred.source === "org" ? "org" : "com");
|
||||
} else {
|
||||
setSubsMsg("Geen NL-ondertitels gevonden");
|
||||
}
|
||||
} finally {
|
||||
setSubsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSubtitlePick() {
|
||||
if (!subPickJob || selectedSubId == null) {
|
||||
alert("Kies een ondertitel (bij voorkeur OS.org)");
|
||||
return;
|
||||
}
|
||||
setSubPickBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/downloads/jobs/${subPickJob.id}/retry-subtitle`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
subtitleFileId: selectedSubId,
|
||||
subtitleSource: selectedSubSource || "org",
|
||||
subtitleLang: "nl",
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error?.message ?? "Ondertitel schrijven mislukt");
|
||||
return;
|
||||
}
|
||||
setSubPickJob(null);
|
||||
loadJobs();
|
||||
} finally {
|
||||
setSubPickBusy(false);
|
||||
}
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -487,9 +559,9 @@ export default function DownloadsPage() {
|
|||
Opnieuw importeren
|
||||
</button>
|
||||
)}
|
||||
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void retrySubtitle(j.id)}>
|
||||
Ondertitel opnieuw
|
||||
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
||||
Ondertitel kiezen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -536,9 +608,9 @@ export default function DownloadsPage() {
|
|||
Retry
|
||||
</button>
|
||||
)}
|
||||
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void retrySubtitle(j.id)}>
|
||||
Sub opnieuw
|
||||
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
||||
Sub kiezen
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -676,6 +748,73 @@ export default function DownloadsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subPickJob && (
|
||||
<div className="modal-backdrop" onClick={() => setSubPickJob(null)}>
|
||||
<div className="modal-panel downloads-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="page-kicker">Ondertitel opnieuw</div>
|
||||
<h2 className="downloads-modal-title">
|
||||
{subPickJob.movieTitle}
|
||||
{subPickJob.movieYear ? ` (${subPickJob.movieYear})` : ""}
|
||||
</h2>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Kies bij voorkeur <strong>OS.org</strong> — OpenSubtitles.com login faalt vaak (403 /
|
||||
User-Agent).
|
||||
</p>
|
||||
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
||||
{subsMsg && <p className="danger-text">{subsMsg}</p>}
|
||||
{!subsBusy && (
|
||||
<div className="match-list">
|
||||
{subs.map((s) => (
|
||||
<button
|
||||
key={`${s.source || "com"}-${s.fileId}`}
|
||||
type="button"
|
||||
className={`match-item${
|
||||
selectedSubId === s.fileId &&
|
||||
(selectedSubSource || "com") === (s.source || "com")
|
||||
? " selected"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSubId(s.fileId);
|
||||
setSelectedSubSource(s.source === "org" ? "org" : "com");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>
|
||||
{(s.language || "nl").toUpperCase()} · {s.downloadCount} downloads
|
||||
{s.hearingImpaired ? " · HI" : ""}
|
||||
{" · "}
|
||||
{s.source === "org" ? "OS.org" : "OS.com"}
|
||||
</strong>
|
||||
<small className="muted">
|
||||
{s.release || s.fileName}
|
||||
{s.uploader ? ` · ${s.uploader}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="downloads-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={subPickBusy || selectedSubId == null}
|
||||
onClick={() => void confirmSubtitlePick()}
|
||||
>
|
||||
{subPickBusy
|
||||
? "Schrijven…"
|
||||
: selectedSubSource === "org"
|
||||
? "Schrijf OS.org-ondertitel"
|
||||
: "Schrijf ondertitel (.com kan falen)"}
|
||||
</button>
|
||||
<button type="button" className="btn-logout" onClick={() => setSubPickJob(null)}>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +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 [osUserAgent, setOsUserAgent] = useState("MediaCluster v1.3.2");
|
||||
const [osEnabled, setOsEnabled] = useState(true);
|
||||
const [osHasKey, setOsHasKey] = useState(false);
|
||||
const [osHasPassword, setOsHasPassword] = useState(false);
|
||||
|
|
@ -133,7 +133,7 @@ export default function SettingsPage() {
|
|||
setOsConfigured(!!s.configured);
|
||||
setOsEnabled(s.enabled !== false);
|
||||
setOsUsername(s.username ?? "");
|
||||
setOsUserAgent(s.userAgent || "MediaCluster v1.3");
|
||||
setOsUserAgent(s.userAgent || "MediaCluster v1.3.2");
|
||||
setOsHasKey(!!s.hasApiKey);
|
||||
setOsHasPassword(!!s.hasPassword);
|
||||
setOsWarn(s.warning || null);
|
||||
|
|
@ -231,7 +231,7 @@ export default function SettingsPage() {
|
|||
apiKey: osApiKey || undefined,
|
||||
username: osUsername,
|
||||
password: osPassword || undefined,
|
||||
userAgent: osUserAgent.trim() || "MediaCluster v1.3",
|
||||
userAgent: osUserAgent.trim() || "MediaCluster v1.3.2",
|
||||
enabled: osEnabled,
|
||||
}),
|
||||
});
|
||||
|
|
@ -526,7 +526,7 @@ export default function SettingsPage() {
|
|||
<input
|
||||
value={osUserAgent}
|
||||
onChange={(e) => setOsUserAgent(e.target.value)}
|
||||
placeholder="MediaCluster v1.3"
|
||||
placeholder="MediaCluster v1.3.2"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -154,7 +154,16 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
|
||||
app.post("/api/v1/admin/downloads/jobs/:id/retry-subtitle", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const job = await downloads.retrySubtitle(id);
|
||||
const body = (request.body || {}) as {
|
||||
subtitleFileId?: number | null;
|
||||
subtitleSource?: "com" | "org" | null;
|
||||
subtitleLang?: string | null;
|
||||
};
|
||||
const job = await downloads.retrySubtitle(id, {
|
||||
subtitleFileId: body.subtitleFileId,
|
||||
subtitleSource: body.subtitleSource,
|
||||
subtitleLang: body.subtitleLang,
|
||||
});
|
||||
return { job };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -758,33 +758,82 @@ export class DownloadService {
|
|||
}
|
||||
}
|
||||
|
||||
/** Re-download (if needed) and write sidecar next to already-imported video. */
|
||||
async retrySubtitle(jobId: string) {
|
||||
/** Re-download / re-pick and write sidecar next to already-imported video. */
|
||||
async retrySubtitle(
|
||||
jobId: string,
|
||||
opts?: {
|
||||
subtitleFileId?: number | null;
|
||||
subtitleSource?: SubtitleSource | null;
|
||||
subtitleLang?: string | null;
|
||||
}
|
||||
) {
|
||||
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
|
||||
if (!job.importedPath) {
|
||||
throw new AppError("INVALID_REQUEST", "Nog geen geïmporteerd pad — importeer eerst de film", 400);
|
||||
}
|
||||
if (!job.subtitleFileId && !job.subtitleContent) {
|
||||
throw new AppError("INVALID_REQUEST", "Deze job heeft geen ondertitel gekozen", 400);
|
||||
}
|
||||
|
||||
let content = job.subtitleContent;
|
||||
const pickId =
|
||||
opts?.subtitleFileId != null && Number.isFinite(opts.subtitleFileId)
|
||||
? Math.floor(opts.subtitleFileId)
|
||||
: null;
|
||||
const pickSource: SubtitleSource | null = pickId
|
||||
? opts?.subtitleSource === "org"
|
||||
? "org"
|
||||
: "com"
|
||||
: null;
|
||||
|
||||
let content = pickId ? null : job.subtitleContent;
|
||||
let fileName: string | null = null;
|
||||
if (!content?.trim() && job.subtitleFileId) {
|
||||
const source = job.subtitleSource === "org" ? "org" : "com";
|
||||
const file = await this.downloadSubtitleFile(job.subtitleFileId, source);
|
||||
let source: SubtitleSource | null = pickSource || (job.subtitleSource === "org" ? "org" : job.subtitleFileId ? "com" : null);
|
||||
let fileId = pickId || job.subtitleFileId;
|
||||
const lang = (opts?.subtitleLang || job.subtitleLang || "nl").trim().toLowerCase() || "nl";
|
||||
|
||||
// Nieuwe keuze of opnieuw downloaden
|
||||
if (pickId && pickSource) {
|
||||
const file = await this.downloadSubtitleFile(pickId, pickSource);
|
||||
content = file.content.toString("utf8");
|
||||
fileName = file.fileName;
|
||||
await prisma.downloadJob.update({
|
||||
where: { id: jobId },
|
||||
data: { subtitleContent: content, subtitleError: null },
|
||||
});
|
||||
source = pickSource;
|
||||
fileId = pickId;
|
||||
} else if (!content?.trim() && fileId && source) {
|
||||
try {
|
||||
const file = await this.downloadSubtitleFile(fileId, source);
|
||||
content = file.content.toString("utf8");
|
||||
fileName = file.fileName;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Download mislukt";
|
||||
// Als .com faalt: forceer opnieuw kiezen via UI (OS.org)
|
||||
if (source === "com") {
|
||||
throw new AppError(
|
||||
"OPENSUBTITLES",
|
||||
`${message} — kies opnieuw een OS.org-ondertitel bij deze job.`,
|
||||
400
|
||||
);
|
||||
}
|
||||
throw err instanceof AppError ? err : new AppError("OPENSUBTITLES", message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content?.trim()) {
|
||||
throw new AppError("OPENSUBTITLES", "Geen ondertitelinhoud beschikbaar", 400);
|
||||
throw new AppError(
|
||||
"OPENSUBTITLES",
|
||||
"Geen ondertitelinhoud — kies een OS.org-hit bij deze job",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.downloadJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
subtitleFileId: fileId,
|
||||
subtitleSource: source,
|
||||
subtitleLang: lang,
|
||||
subtitleContent: content,
|
||||
subtitleError: null,
|
||||
},
|
||||
});
|
||||
|
||||
const ext = fileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
||||
const ack = await nodeConnectionManager.writeSubtitleAcked(
|
||||
job.nodeId,
|
||||
|
|
@ -792,7 +841,7 @@ export class DownloadService {
|
|||
jobId: job.id,
|
||||
videoPath: job.importedPath,
|
||||
subtitle: {
|
||||
language: job.subtitleLang || "nl",
|
||||
language: lang,
|
||||
extension: ext,
|
||||
contentBase64: Buffer.from(content, "utf8").toString("base64"),
|
||||
},
|
||||
|
|
@ -818,6 +867,43 @@ export class DownloadService {
|
|||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let movieId: string | null = null;
|
||||
if (job.tmdbId) {
|
||||
const movie = await prisma.movie.findUnique({ where: { tmdbId: job.tmdbId } });
|
||||
movieId = movie?.id ?? null;
|
||||
}
|
||||
const existing = await prisma.librarySubtitle.findFirst({
|
||||
where: { downloadJobId: job.id },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.librarySubtitle.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
content,
|
||||
fileName,
|
||||
language: lang,
|
||||
format: ext.replace(/^\./, ""),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.librarySubtitle.create({
|
||||
data: {
|
||||
movieId,
|
||||
tmdbId: job.tmdbId,
|
||||
imdbId: job.imdbId,
|
||||
language: lang,
|
||||
format: ext.replace(/^\./, ""),
|
||||
content,
|
||||
fileName,
|
||||
downloadJobId: job.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[downloads] library subtitle retry job=${jobId}:`, err);
|
||||
}
|
||||
|
||||
return prisma.downloadJob.findUniqueOrThrow({
|
||||
where: { id: jobId },
|
||||
include: { node: { select: { id: true, name: true } } },
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type SubtitleHit = {
|
|||
|
||||
type OsLogin = { token: string; baseUrl: string; expiresAt: number };
|
||||
|
||||
const DEFAULT_UA = "MediaCluster v1.3";
|
||||
const DEFAULT_UA = "MediaCluster v1.3.2";
|
||||
|
||||
/**
|
||||
* OpenSubtitles.com REST API (v1).
|
||||
|
|
@ -38,7 +38,9 @@ export class OpenSubtitlesClient {
|
|||
private headers(extra?: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
"Api-Key": this.apiKey,
|
||||
// Docs: descriptive app name + parseable version, e.g. "MyApp v1.2.3"
|
||||
"User-Agent": this.userAgent,
|
||||
"X-User-Agent": this.userAgent,
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AppError } from "../security/errors";
|
|||
import { OpenSubtitlesClient } from "../opensubtitles/client";
|
||||
|
||||
const OS_ID = "opensubtitles";
|
||||
const DEFAULT_UA = "MediaCluster v1.3";
|
||||
const DEFAULT_UA = "MediaCluster v1.3.2";
|
||||
|
||||
export async function isOpenSubtitlesEnabled(): Promise<boolean> {
|
||||
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue