Fail loudly on subtitle download and write sidecar with retry (media-node 1.3.2).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e2a9155105
commit
93bbd07487
14 changed files with 455 additions and 59 deletions
|
|
@ -55,6 +55,10 @@ interface JobRow {
|
||||||
movieYear: number | null;
|
movieYear: number | null;
|
||||||
posterUrl: string | null;
|
posterUrl: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
hasSubtitle?: boolean;
|
||||||
|
subtitlePath?: string | null;
|
||||||
|
subtitleError?: string | null;
|
||||||
|
importedPath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
function formatBytes(n: number): string {
|
||||||
|
|
@ -317,6 +321,18 @@ export default function DownloadsPage() {
|
||||||
loadJobs();
|
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");
|
||||||
|
}
|
||||||
|
loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|
@ -453,14 +469,27 @@ export default function DownloadsPage() {
|
||||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="muted downloads-source">{j.sourceTitle}</div>
|
<div className="muted downloads-source">{j.sourceTitle}</div>
|
||||||
{j.error && <div className="danger-text">{j.error}</div>}
|
{j.subtitlePath && (
|
||||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
<div className="ok-text">Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}</div>
|
||||||
<div className="action-row">
|
)}
|
||||||
|
{j.hasSubtitle && !j.subtitlePath && (
|
||||||
|
<div className="danger-text">
|
||||||
|
{j.subtitleError || j.error || "Ondertitel ontbreekt in map"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{j.error && !j.hasSubtitle && <div className="danger-text">{j.error}</div>}
|
||||||
|
<div className="action-row">
|
||||||
|
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||||
Opnieuw importeren
|
Opnieuw importeren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
|
||||||
|
<button type="button" onClick={() => void retrySubtitle(j.id)}>
|
||||||
|
Ondertitel opnieuw
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -484,7 +513,17 @@ export default function DownloadsPage() {
|
||||||
<td>
|
<td>
|
||||||
{j.movieTitle}
|
{j.movieTitle}
|
||||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||||
{j.error && <div className="danger-text">{j.error}</div>}
|
{j.subtitlePath && (
|
||||||
|
<div className="ok-text">
|
||||||
|
Sub: {j.subtitlePath.split(/[/\\]/).pop()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{j.hasSubtitle && !j.subtitlePath && (
|
||||||
|
<div className="danger-text">
|
||||||
|
{j.subtitleError || j.error || "Ondertitel ontbreekt"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{j.error && !j.hasSubtitle && <div className="danger-text">{j.error}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{j.sourceTitle}</td>
|
<td className="muted">{j.sourceTitle}</td>
|
||||||
<td>{j.nodeName}</td>
|
<td>{j.nodeName}</td>
|
||||||
|
|
@ -494,6 +533,11 @@ export default function DownloadsPage() {
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
|
||||||
|
<button type="button" onClick={() => void retrySubtitle(j.id)}>
|
||||||
|
Sub opnieuw
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_content" TEXT;
|
||||||
|
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_error" TEXT;
|
||||||
|
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" TEXT;
|
||||||
|
|
@ -418,6 +418,10 @@ model DownloadJob {
|
||||||
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")
|
subtitleSource String? @map("subtitle_source")
|
||||||
|
/** Cached subtitle body (downloaded at job create) */
|
||||||
|
subtitleContent String? @map("subtitle_content") @db.Text
|
||||||
|
subtitleError String? @map("subtitle_error")
|
||||||
|
subtitlePath String? @map("subtitle_path")
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,15 @@ async function main() {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT`
|
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT`
|
||||||
);
|
);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_content" TEXT`
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_error" TEXT`
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" 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,
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
destFolderName: j.destFolderName,
|
destFolderName: j.destFolderName,
|
||||||
releaseTags: j.releaseTags,
|
releaseTags: j.releaseTags,
|
||||||
subtitleLang: j.subtitleLang,
|
subtitleLang: j.subtitleLang,
|
||||||
|
subtitleSource: j.subtitleSource,
|
||||||
|
subtitlePath: j.subtitlePath,
|
||||||
|
subtitleError: j.subtitleError,
|
||||||
|
hasSubtitle: !!(j.subtitleFileId || j.subtitleContent),
|
||||||
importedPath: j.importedPath,
|
importedPath: j.importedPath,
|
||||||
error: j.error,
|
error: j.error,
|
||||||
createdAt: j.createdAt,
|
createdAt: j.createdAt,
|
||||||
|
|
@ -148,5 +152,11 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
return { job };
|
return { job };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
return { job };
|
||||||
|
});
|
||||||
|
|
||||||
return downloads;
|
return downloads;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,26 @@ export class DownloadService {
|
||||||
return new OpenSubtitlesOrgClient();
|
return new OpenSubtitlesOrgClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async downloadSubtitleFile(
|
||||||
|
fileId: number,
|
||||||
|
source: SubtitleSource
|
||||||
|
): Promise<{ content: Buffer; fileName: string; source: SubtitleSource }> {
|
||||||
|
if (source === "org") {
|
||||||
|
const file = await this.orgClient().downloadFile(fileId);
|
||||||
|
return { ...file, source: "org" };
|
||||||
|
}
|
||||||
|
const osClient = await this.opensubtitles();
|
||||||
|
if (!osClient) {
|
||||||
|
throw new AppError(
|
||||||
|
"OPENSUBTITLES",
|
||||||
|
"OpenSubtitles.com niet geconfigureerd — kies een OS.org-hit of vul API-key + login in",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const file = await osClient.downloadFile(fileId);
|
||||||
|
return { ...file, source: "com" };
|
||||||
|
}
|
||||||
|
|
||||||
async hasOpenSubtitles(): Promise<boolean> {
|
async hasOpenSubtitles(): Promise<boolean> {
|
||||||
return isOpenSubtitlesEnabled();
|
return isOpenSubtitlesEnabled();
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +413,28 @@ export class DownloadService {
|
||||||
: "com"
|
: "com"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Download ondertitel meteen — anders start de film zonder zichtbare fout
|
||||||
|
let subtitleContent: string | null = null;
|
||||||
|
let subtitleFileName: string | null = null;
|
||||||
|
if (subtitleFileId && subtitleSource) {
|
||||||
|
try {
|
||||||
|
const file = await this.downloadSubtitleFile(subtitleFileId, subtitleSource);
|
||||||
|
subtitleContent = file.content.toString("utf8");
|
||||||
|
subtitleFileName = file.fileName;
|
||||||
|
if (!subtitleContent.trim()) {
|
||||||
|
throw new AppError("OPENSUBTITLES", "Ondertitelbestand is leeg", 400);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof AppError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Ondertitel downloaden mislukt";
|
||||||
|
throw new AppError("OPENSUBTITLES", message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let beforeIds = new Set<string>();
|
let beforeIds = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const before = await client.listTasks();
|
const before = await client.listTasks();
|
||||||
|
|
@ -436,9 +478,36 @@ export class DownloadService {
|
||||||
subtitleFileId,
|
subtitleFileId,
|
||||||
subtitleLang,
|
subtitleLang,
|
||||||
subtitleSource,
|
subtitleSource,
|
||||||
|
subtitleContent,
|
||||||
|
subtitleError: null,
|
||||||
|
subtitlePath: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (subtitleContent) {
|
||||||
|
try {
|
||||||
|
let movieId: string | null = null;
|
||||||
|
if (tmdbId) {
|
||||||
|
const movie = await prisma.movie.findUnique({ where: { tmdbId } });
|
||||||
|
movieId = movie?.id ?? null;
|
||||||
|
}
|
||||||
|
await prisma.librarySubtitle.create({
|
||||||
|
data: {
|
||||||
|
movieId,
|
||||||
|
tmdbId: tmdbId ?? null,
|
||||||
|
imdbId,
|
||||||
|
language: subtitleLang || "nl",
|
||||||
|
format: subtitleFileName?.toLowerCase().endsWith(".ass") ? "ass" : "srt",
|
||||||
|
content: subtitleContent,
|
||||||
|
fileName: subtitleFileName,
|
||||||
|
downloadJobId: job.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[downloads] early library subtitle:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -575,32 +644,35 @@ export class DownloadService {
|
||||||
let subtitle:
|
let subtitle:
|
||||||
| { language: string; extension?: string; contentBase64: string }
|
| { language: string; extension?: string; contentBase64: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
let subtitleText: string | null = null;
|
let subtitleText: string | null = job.subtitleContent;
|
||||||
let subtitleFileName: string | null = null;
|
let subtitleFileName: string | null = null;
|
||||||
if (job.subtitleFileId) {
|
let subtitleError: string | null = null;
|
||||||
|
|
||||||
|
if (job.subtitleFileId || job.subtitleContent) {
|
||||||
try {
|
try {
|
||||||
const source = job.subtitleSource === "org" ? "org" : "com";
|
if (!subtitleText?.trim() && job.subtitleFileId) {
|
||||||
const file =
|
const source = job.subtitleSource === "org" ? "org" : "com";
|
||||||
source === "org"
|
const file = await this.downloadSubtitleFile(job.subtitleFileId, source);
|
||||||
? await this.orgClient().downloadFile(job.subtitleFileId)
|
subtitleText = file.content.toString("utf8");
|
||||||
: await (async () => {
|
subtitleFileName = file.fileName;
|
||||||
const osClient = await this.opensubtitles();
|
await prisma.downloadJob.update({
|
||||||
if (!osClient) {
|
where: { id: jobId },
|
||||||
throw new Error("OpenSubtitles.com niet geconfigureerd");
|
data: { subtitleContent: subtitleText, subtitleError: null },
|
||||||
}
|
});
|
||||||
return osClient.downloadFile(job.subtitleFileId!);
|
}
|
||||||
})();
|
if (!subtitleText?.trim()) {
|
||||||
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
throw new Error("Geen ondertitelinhoud beschikbaar");
|
||||||
subtitleText = file.content.toString("utf8");
|
}
|
||||||
subtitleFileName = file.fileName;
|
const ext = subtitleFileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
||||||
subtitle = {
|
subtitle = {
|
||||||
language: job.subtitleLang || "nl",
|
language: job.subtitleLang || "nl",
|
||||||
extension: ext,
|
extension: ext,
|
||||||
contentBase64: file.content.toString("base64"),
|
contentBase64: Buffer.from(subtitleText, "utf8").toString("base64"),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Subtitle download mislukt";
|
subtitleError =
|
||||||
console.warn(`[downloads] subtitle job=${jobId}:`, message);
|
err instanceof Error ? err.message : "Subtitle download mislukt";
|
||||||
|
console.warn(`[downloads] subtitle job=${jobId}:`, subtitleError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,44 +702,127 @@ export class DownloadService {
|
||||||
data: {
|
data: {
|
||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
error: ack.error || "Import mislukt of timeout",
|
error: ack.error || "Import mislukt of timeout",
|
||||||
|
subtitleError,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subtitle && !ack.subtitlePath) {
|
||||||
|
subtitleError =
|
||||||
|
subtitleError ||
|
||||||
|
"Film geïmporteerd, maar node schreef geen .srt — update media-node (1.3.2+) of gebruik ‘Ondertitel opnieuw’";
|
||||||
|
} else if (!subtitle && job.subtitleFileId) {
|
||||||
|
subtitleError =
|
||||||
|
subtitleError || "Ondertitel niet meegestuurd naar node (download mislukt)";
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.downloadJob.update({
|
await prisma.downloadJob.update({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
data: {
|
data: {
|
||||||
status: "IMPORTED",
|
status: "IMPORTED",
|
||||||
importedPath: ack.importedPath ?? null,
|
importedPath: ack.importedPath ?? null,
|
||||||
error: null,
|
subtitlePath: ack.subtitlePath ?? null,
|
||||||
|
subtitleError,
|
||||||
|
error: subtitleError,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subtitleText) {
|
if (subtitleText && !subtitleError) {
|
||||||
try {
|
try {
|
||||||
let movieId: string | null = null;
|
let movieId: string | null = null;
|
||||||
if (job.tmdbId) {
|
if (job.tmdbId) {
|
||||||
const movie = await prisma.movie.findUnique({ where: { tmdbId: job.tmdbId } });
|
const movie = await prisma.movie.findUnique({ where: { tmdbId: job.tmdbId } });
|
||||||
movieId = movie?.id ?? null;
|
movieId = movie?.id ?? null;
|
||||||
}
|
}
|
||||||
await prisma.librarySubtitle.create({
|
const existing = await prisma.librarySubtitle.findFirst({
|
||||||
data: {
|
where: { downloadJobId: job.id },
|
||||||
movieId,
|
|
||||||
tmdbId: job.tmdbId,
|
|
||||||
imdbId: job.imdbId,
|
|
||||||
language: job.subtitleLang || "nl",
|
|
||||||
format: subtitle?.extension?.replace(/^\./, "") || "srt",
|
|
||||||
content: subtitleText,
|
|
||||||
fileName: subtitleFileName,
|
|
||||||
downloadJobId: job.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.librarySubtitle.create({
|
||||||
|
data: {
|
||||||
|
movieId,
|
||||||
|
tmdbId: job.tmdbId,
|
||||||
|
imdbId: job.imdbId,
|
||||||
|
language: job.subtitleLang || "nl",
|
||||||
|
format: subtitle?.extension?.replace(/^\./, "") || "srt",
|
||||||
|
content: subtitleText,
|
||||||
|
fileName: subtitleFileName,
|
||||||
|
downloadJobId: job.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[downloads] save library subtitle job=${jobId}:`, err);
|
console.warn(`[downloads] save library subtitle job=${jobId}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-download (if needed) and write sidecar next to already-imported video. */
|
||||||
|
async retrySubtitle(jobId: string) {
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
content = file.content.toString("utf8");
|
||||||
|
fileName = file.fileName;
|
||||||
|
await prisma.downloadJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { subtitleContent: content, subtitleError: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!content?.trim()) {
|
||||||
|
throw new AppError("OPENSUBTITLES", "Geen ondertitelinhoud beschikbaar", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = fileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
||||||
|
const ack = await nodeConnectionManager.writeSubtitleAcked(
|
||||||
|
job.nodeId,
|
||||||
|
{
|
||||||
|
jobId: job.id,
|
||||||
|
videoPath: job.importedPath,
|
||||||
|
subtitle: {
|
||||||
|
language: job.subtitleLang || "nl",
|
||||||
|
extension: ext,
|
||||||
|
contentBase64: Buffer.from(content, "utf8").toString("base64"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ack.ok || !ack.subtitlePath) {
|
||||||
|
const message = ack.error || "Node schreef geen ondertitel (update media-node?)";
|
||||||
|
await prisma.downloadJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { subtitleError: message, error: message },
|
||||||
|
});
|
||||||
|
throw new AppError("SUBTITLE", message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.downloadJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
subtitlePath: ack.subtitlePath,
|
||||||
|
subtitleError: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return prisma.downloadJob.findUniqueOrThrow({
|
||||||
|
where: { id: jobId },
|
||||||
|
include: { node: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSubtitleHits(hits: SubtitleHit[]): SubtitleHit[] {
|
function mergeSubtitleHits(hits: SubtitleHit[]): SubtitleHit[] {
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,16 @@ 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();
|
const token = await this.ensureLogin();
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError(
|
||||||
|
"OPENSUBTITLES",
|
||||||
|
"OpenSubtitles.com download vereist gebruikersnaam + wachtwoord (Instellingen)",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
const headers = this.headers({
|
const headers = this.headers({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
Authorization: `Bearer ${token}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${this.root()}/api/v1/download`, {
|
const res = await fetch(`${this.root()}/api/v1/download`, {
|
||||||
|
|
@ -158,19 +165,22 @@ export class OpenSubtitlesClient {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"OPENSUBTITLES",
|
"OPENSUBTITLES",
|
||||||
`OpenSubtitles download mislukt (${res.status}): ${text.slice(0, 200)}`,
|
`OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`,
|
||||||
400
|
400
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as { link?: string; file_name?: string };
|
const data = (await res.json()) as { link?: string; file_name?: string };
|
||||||
if (!data.link) {
|
if (!data.link) {
|
||||||
throw new AppError("OPENSUBTITLES", "OpenSubtitles: geen download-link", 400);
|
throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: geen download-link", 400);
|
||||||
}
|
}
|
||||||
const fileRes = await fetch(data.link);
|
const fileRes = await fetch(data.link);
|
||||||
if (!fileRes.ok) {
|
if (!fileRes.ok) {
|
||||||
throw new AppError("OPENSUBTITLES", `Subtitle ophalen mislukt (${fileRes.status})`, 400);
|
throw new AppError("OPENSUBTITLES", `Subtitle ophalen mislukt (${fileRes.status})`, 400);
|
||||||
}
|
}
|
||||||
const buf = Buffer.from(await fileRes.arrayBuffer());
|
const buf = Buffer.from(await fileRes.arrayBuffer());
|
||||||
|
if (!buf.length) {
|
||||||
|
throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: leeg ondertitelbestand", 400);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: buf,
|
content: buf,
|
||||||
fileName: data.file_name || `subtitle-${fileId}.srt`,
|
fileName: data.file_name || `subtitle-${fileId}.srt`,
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@ export class OpenSubtitlesOrgClient {
|
||||||
fileName = fileName.replace(/\.gz$/i, "");
|
fileName = fileName.replace(/\.gz$/i, "");
|
||||||
if (!/\.(srt|ass|ssa|vtt)$/i.test(fileName)) fileName = `${fileName}.srt`;
|
if (!/\.(srt|ass|ssa|vtt)$/i.test(fileName)) fileName = `${fileName}.srt`;
|
||||||
}
|
}
|
||||||
|
if (!buf.length) {
|
||||||
|
throw new AppError("OPENSUBTITLES", "OpenSubtitles.org: leeg ondertitelbestand", 400);
|
||||||
|
}
|
||||||
return { content, fileName };
|
return { content, fileName };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import type {
|
||||||
CreatePlaybackSessionAckPayload,
|
CreatePlaybackSessionAckPayload,
|
||||||
ImportMediaAckPayload,
|
ImportMediaAckPayload,
|
||||||
ImportMediaPayload,
|
ImportMediaPayload,
|
||||||
|
WriteSubtitleAckPayload,
|
||||||
|
WriteSubtitlePayload,
|
||||||
} from "@media-cluster/protocol";
|
} from "@media-cluster/protocol";
|
||||||
|
|
||||||
interface NodeConnection {
|
interface NodeConnection {
|
||||||
|
|
@ -36,6 +38,11 @@ interface PendingImportAck {
|
||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingWriteSubtitleAck {
|
||||||
|
resolve: (result: WriteSubtitleAckPayload) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
interface SyncState {
|
interface SyncState {
|
||||||
syncId: string;
|
syncId: string;
|
||||||
fileIds: Set<string>;
|
fileIds: Set<string>;
|
||||||
|
|
@ -48,6 +55,7 @@ class NodeConnectionManager {
|
||||||
private offlineCheckInterval: ReturnType<typeof setInterval> | null = null;
|
private offlineCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private pendingSessionAcks = new Map<string, PendingAck>();
|
private pendingSessionAcks = new Map<string, PendingAck>();
|
||||||
private pendingImportAcks = new Map<string, PendingImportAck>();
|
private pendingImportAcks = new Map<string, PendingImportAck>();
|
||||||
|
private pendingWriteSubtitleAcks = new Map<string, PendingWriteSubtitleAck>();
|
||||||
private syncState = new Map<string, SyncState>();
|
private syncState = new Map<string, SyncState>();
|
||||||
private syncQueues = new Map<string, Promise<void>>();
|
private syncQueues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
|
@ -146,6 +154,9 @@ class NodeConnectionManager {
|
||||||
case "IMPORT_MEDIA_ACK":
|
case "IMPORT_MEDIA_ACK":
|
||||||
this.handleImportMediaAck(message.payload as ImportMediaAckPayload);
|
this.handleImportMediaAck(message.payload as ImportMediaAckPayload);
|
||||||
break;
|
break;
|
||||||
|
case "WRITE_SUBTITLE_ACK":
|
||||||
|
this.handleWriteSubtitleAck(message.payload as WriteSubtitleAckPayload);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.send(socket, createMessage("ERROR", { message: `Unknown type: ${message.type}` }));
|
this.send(socket, createMessage("ERROR", { message: `Unknown type: ${message.type}` }));
|
||||||
}
|
}
|
||||||
|
|
@ -432,6 +443,14 @@ class NodeConnectionManager {
|
||||||
pending.resolve(payload);
|
pending.resolve(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleWriteSubtitleAck(payload: WriteSubtitleAckPayload): void {
|
||||||
|
const pending = this.pendingWriteSubtitleAcks.get(payload.jobId);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingWriteSubtitleAcks.delete(payload.jobId);
|
||||||
|
pending.resolve(payload);
|
||||||
|
}
|
||||||
|
|
||||||
private async handlePlaybackEnded(payload: PlaybackSessionEndedPayload): Promise<void> {
|
private async handlePlaybackEnded(payload: PlaybackSessionEndedPayload): Promise<void> {
|
||||||
await prisma.playbackSession.updateMany({
|
await prisma.playbackSession.updateMany({
|
||||||
where: { id: payload.sessionId },
|
where: { id: payload.sessionId },
|
||||||
|
|
@ -530,6 +549,36 @@ class NodeConnectionManager {
|
||||||
return acked;
|
return acked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async writeSubtitleAcked(
|
||||||
|
nodeId: string,
|
||||||
|
payload: WriteSubtitlePayload,
|
||||||
|
timeoutMs = 60_000
|
||||||
|
): Promise<WriteSubtitleAckPayload> {
|
||||||
|
if (!this.isOnline(nodeId)) {
|
||||||
|
return { jobId: payload.jobId, ok: false, error: "Node offline" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const acked = new Promise<WriteSubtitleAckPayload>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingWriteSubtitleAcks.delete(payload.jobId);
|
||||||
|
resolve({ jobId: payload.jobId, ok: false, error: "Subtitle write timeout" });
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pendingWriteSubtitleAcks.set(payload.jobId, { resolve, timer });
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushed = this.sendToNode(nodeId, "WRITE_SUBTITLE", payload);
|
||||||
|
if (!pushed) {
|
||||||
|
const pending = this.pendingWriteSubtitleAcks.get(payload.jobId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingWriteSubtitleAcks.delete(payload.jobId);
|
||||||
|
}
|
||||||
|
return { jobId: payload.jobId, ok: false, error: "Kan niet naar node sturen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return acked;
|
||||||
|
}
|
||||||
|
|
||||||
createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
|
createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
|
||||||
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
|
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ WORKDIR /app
|
||||||
|
|
||||||
FROM golang:1.22-alpine AS node-build
|
FROM golang:1.22-alpine AS node-build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
ARG MEDIA_NODE_VERSION=1.3.1
|
ARG MEDIA_NODE_VERSION=1.3.2
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
COPY node/media-node/ ./
|
COPY node/media-node/ ./
|
||||||
RUN go mod tidy \
|
RUN go mod tidy \
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
"github.com/sthmedia/media-node/internal/streaming"
|
"github.com/sthmedia/media-node/internal/streaming"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.3.1"
|
var version = "1.3.2"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,8 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
|
||||||
go c.doRestart()
|
go c.doRestart()
|
||||||
case "IMPORT_MEDIA":
|
case "IMPORT_MEDIA":
|
||||||
go c.handleImportMedia(payload)
|
go c.handleImportMedia(payload)
|
||||||
|
case "WRITE_SUBTITLE":
|
||||||
|
go c.handleWriteSubtitle(payload)
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
log.Printf("Master error: %s", string(payload))
|
log.Printf("Master error: %s", string(payload))
|
||||||
}
|
}
|
||||||
|
|
@ -460,10 +462,78 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
|
||||||
"jobId": p.JobID,
|
"jobId": p.JobID,
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
"importedPath": imported,
|
"importedPath": imported,
|
||||||
|
"subtitlePath": "",
|
||||||
|
"error": errMsg,
|
||||||
|
})
|
||||||
|
if ok && result != nil && result.SubtitlePath != "" {
|
||||||
|
ack = controlMessage("IMPORT_MEDIA_ACK", map[string]interface{}{
|
||||||
|
"jobId": p.JobID,
|
||||||
|
"ok": true,
|
||||||
|
"importedPath": imported,
|
||||||
|
"subtitlePath": result.SubtitlePath,
|
||||||
|
"error": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if werr := c.write(ack); werr != nil {
|
||||||
|
log.Printf("failed to send IMPORT_MEDIA_ACK: %v", werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleWriteSubtitle(payload json.RawMessage) {
|
||||||
|
var p struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
|
VideoPath string `json:"videoPath"`
|
||||||
|
Subtitle *struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
Extension string `json:"extension"`
|
||||||
|
ContentBase64 string `json:"contentBase64"`
|
||||||
|
} `json:"subtitle"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &p); err != nil || p.JobID == "" || p.VideoPath == "" {
|
||||||
|
log.Printf("WRITE_SUBTITLE: invalid payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Subtitle == nil || p.Subtitle.ContentBase64 == "" {
|
||||||
|
ack := controlMessage("WRITE_SUBTITLE_ACK", map[string]interface{}{
|
||||||
|
"jobId": p.JobID,
|
||||||
|
"ok": false,
|
||||||
|
"error": "ondertitel ontbreekt",
|
||||||
|
})
|
||||||
|
_ = c.write(ack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := decodeBase64(p.Subtitle.ContentBase64)
|
||||||
|
if err != nil {
|
||||||
|
ack := controlMessage("WRITE_SUBTITLE_ACK", map[string]interface{}{
|
||||||
|
"jobId": p.JobID,
|
||||||
|
"ok": false,
|
||||||
|
"error": "ondertitel base64 ongeldig",
|
||||||
|
})
|
||||||
|
_ = c.write(ack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subPath, err := importmedia.WriteSidecar(p.VideoPath, &importmedia.SubtitleFile{
|
||||||
|
Language: p.Subtitle.Language,
|
||||||
|
Ext: p.Subtitle.Extension,
|
||||||
|
Content: raw,
|
||||||
|
})
|
||||||
|
ok := err == nil
|
||||||
|
errMsg := ""
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
log.Printf("WRITE_SUBTITLE failed job=%s: %v", p.JobID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("WRITE_SUBTITLE ok job=%s path=%s", p.JobID, subPath)
|
||||||
|
c.scanner.IndexPath(subPath)
|
||||||
|
}
|
||||||
|
ack := controlMessage("WRITE_SUBTITLE_ACK", map[string]interface{}{
|
||||||
|
"jobId": p.JobID,
|
||||||
|
"ok": ok,
|
||||||
|
"subtitlePath": subPath,
|
||||||
"error": errMsg,
|
"error": errMsg,
|
||||||
})
|
})
|
||||||
if werr := c.write(ack); werr != nil {
|
if werr := c.write(ack); werr != nil {
|
||||||
log.Printf("failed to send IMPORT_MEDIA_ACK: %v", werr)
|
log.Printf("failed to send WRITE_SUBTITLE_ACK: %v", werr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,21 +86,9 @@ func ImportMovie(opts Options) (*Result, error) {
|
||||||
result := &Result{ImportedPath: destPath}
|
result := &Result{ImportedPath: destPath}
|
||||||
|
|
||||||
if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 {
|
if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 {
|
||||||
lang := strings.ToLower(strings.TrimSpace(opts.Subtitle.Language))
|
subPath, err := WriteSidecar(destPath, opts.Subtitle)
|
||||||
if lang == "" {
|
if err != nil {
|
||||||
lang = "nl"
|
return nil, err
|
||||||
}
|
|
||||||
subExt := strings.ToLower(strings.TrimSpace(opts.Subtitle.Ext))
|
|
||||||
if subExt == "" {
|
|
||||||
subExt = ".srt"
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(subExt, ".") {
|
|
||||||
subExt = "." + subExt
|
|
||||||
}
|
|
||||||
stem := strings.TrimSuffix(destName, ext)
|
|
||||||
subPath := filepath.Join(destDir, fmt.Sprintf("%s.%s%s", stem, lang, subExt))
|
|
||||||
if err := os.WriteFile(subPath, opts.Subtitle.Content, 0o644); err != nil {
|
|
||||||
return nil, fmt.Errorf("ondertitel schrijven: %w", err)
|
|
||||||
}
|
}
|
||||||
result.SubtitlePath = subPath
|
result.SubtitlePath = subPath
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +97,35 @@ func ImportMovie(opts Options) (*Result, error) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteSidecar writes Title.nl.srt next to an existing video file.
|
||||||
|
func WriteSidecar(videoPath string, sub *SubtitleFile) (string, error) {
|
||||||
|
if sub == nil || len(sub.Content) == 0 {
|
||||||
|
return "", fmt.Errorf("ondertitel leeg")
|
||||||
|
}
|
||||||
|
videoPath = filepath.Clean(strings.TrimSpace(videoPath))
|
||||||
|
ext := strings.ToLower(filepath.Ext(videoPath))
|
||||||
|
if ext == "" {
|
||||||
|
return "", fmt.Errorf("ongeldig videopad")
|
||||||
|
}
|
||||||
|
lang := strings.ToLower(strings.TrimSpace(sub.Language))
|
||||||
|
if lang == "" {
|
||||||
|
lang = "nl"
|
||||||
|
}
|
||||||
|
subExt := strings.ToLower(strings.TrimSpace(sub.Ext))
|
||||||
|
if subExt == "" {
|
||||||
|
subExt = ".srt"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(subExt, ".") {
|
||||||
|
subExt = "." + subExt
|
||||||
|
}
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(videoPath), ext)
|
||||||
|
subPath := filepath.Join(filepath.Dir(videoPath), fmt.Sprintf("%s.%s%s", stem, lang, subExt))
|
||||||
|
if err := os.WriteFile(subPath, sub.Content, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("ondertitel schrijven: %w", err)
|
||||||
|
}
|
||||||
|
return subPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractReleaseTags keeps quality/group tokens from a release name.
|
// ExtractReleaseTags keeps quality/group tokens from a release name.
|
||||||
func ExtractReleaseTags(release, title string, year *int) string {
|
func ExtractReleaseTags(release, title string, year *int) string {
|
||||||
s := release
|
s := release
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export type ControlMessageType =
|
||||||
| "RESTART"
|
| "RESTART"
|
||||||
| "IMPORT_MEDIA"
|
| "IMPORT_MEDIA"
|
||||||
| "IMPORT_MEDIA_ACK"
|
| "IMPORT_MEDIA_ACK"
|
||||||
|
| "WRITE_SUBTITLE"
|
||||||
|
| "WRITE_SUBTITLE_ACK"
|
||||||
| "PLAYBACK_SESSION_ENDED"
|
| "PLAYBACK_SESSION_ENDED"
|
||||||
| "PLAYBACK_SESSION_PROGRESS"
|
| "PLAYBACK_SESSION_PROGRESS"
|
||||||
| "ERROR";
|
| "ERROR";
|
||||||
|
|
@ -137,6 +139,26 @@ export interface ImportMediaAckPayload {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
importedPath?: string;
|
importedPath?: string;
|
||||||
|
/** Absolute path of written sidecar, if any */
|
||||||
|
subtitlePath?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Master → node: write sidecar next to an already-imported video */
|
||||||
|
export interface WriteSubtitlePayload {
|
||||||
|
jobId: string;
|
||||||
|
videoPath: string;
|
||||||
|
subtitle: {
|
||||||
|
language: string;
|
||||||
|
extension?: string;
|
||||||
|
contentBase64: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteSubtitleAckPayload {
|
||||||
|
jobId: string;
|
||||||
|
ok: boolean;
|
||||||
|
subtitlePath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue