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;
|
||||
posterUrl: string | null;
|
||||
error: string | null;
|
||||
hasSubtitle?: boolean;
|
||||
subtitlePath?: string | null;
|
||||
subtitleError?: string | null;
|
||||
importedPath?: string | null;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
|
|
@ -317,6 +321,18 @@ 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");
|
||||
}
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
|
|
@ -453,14 +469,27 @@ export default function DownloadsPage() {
|
|||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
</div>
|
||||
<div className="muted downloads-source">{j.sourceTitle}</div>
|
||||
{j.error && <div className="danger-text">{j.error}</div>}
|
||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||
{j.subtitlePath && (
|
||||
<div className="ok-text">Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}</div>
|
||||
)}
|
||||
{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)}>
|
||||
Opnieuw importeren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void retrySubtitle(j.id)}>
|
||||
Ondertitel opnieuw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -484,7 +513,17 @@ export default function DownloadsPage() {
|
|||
<td>
|
||||
{j.movieTitle}
|
||||
{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 className="muted">{j.sourceTitle}</td>
|
||||
<td>{j.nodeName}</td>
|
||||
|
|
@ -494,6 +533,11 @@ export default function DownloadsPage() {
|
|||
Retry
|
||||
</button>
|
||||
)}
|
||||
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void retrySubtitle(j.id)}>
|
||||
Sub opnieuw
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</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")
|
||||
subtitleLang String? @map("subtitle_lang")
|
||||
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?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
|
|||
|
|
@ -250,6 +250,15 @@ async function main() {
|
|||
await prisma.$executeRawUnsafe(
|
||||
`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(`
|
||||
CREATE TABLE IF NOT EXISTS "integration_settings" (
|
||||
"id" TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
destFolderName: j.destFolderName,
|
||||
releaseTags: j.releaseTags,
|
||||
subtitleLang: j.subtitleLang,
|
||||
subtitleSource: j.subtitleSource,
|
||||
subtitlePath: j.subtitlePath,
|
||||
subtitleError: j.subtitleError,
|
||||
hasSubtitle: !!(j.subtitleFileId || j.subtitleContent),
|
||||
importedPath: j.importedPath,
|
||||
error: j.error,
|
||||
createdAt: j.createdAt,
|
||||
|
|
@ -148,5 +152,11 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,26 @@ export class DownloadService {
|
|||
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> {
|
||||
return isOpenSubtitlesEnabled();
|
||||
}
|
||||
|
|
@ -393,6 +413,28 @@ export class DownloadService {
|
|||
: "com"
|
||||
: 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>();
|
||||
try {
|
||||
const before = await client.listTasks();
|
||||
|
|
@ -436,9 +478,36 @@ export class DownloadService {
|
|||
subtitleFileId,
|
||||
subtitleLang,
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -575,32 +644,35 @@ export class DownloadService {
|
|||
let subtitle:
|
||||
| { language: string; extension?: string; contentBase64: string }
|
||||
| undefined;
|
||||
let subtitleText: string | null = null;
|
||||
let subtitleText: string | null = job.subtitleContent;
|
||||
let subtitleFileName: string | null = null;
|
||||
if (job.subtitleFileId) {
|
||||
let subtitleError: string | null = null;
|
||||
|
||||
if (job.subtitleFileId || job.subtitleContent) {
|
||||
try {
|
||||
if (!subtitleText?.trim() && 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 file = await this.downloadSubtitleFile(job.subtitleFileId, source);
|
||||
subtitleText = file.content.toString("utf8");
|
||||
subtitleFileName = file.fileName;
|
||||
await prisma.downloadJob.update({
|
||||
where: { id: jobId },
|
||||
data: { subtitleContent: subtitleText, subtitleError: null },
|
||||
});
|
||||
}
|
||||
if (!subtitleText?.trim()) {
|
||||
throw new Error("Geen ondertitelinhoud beschikbaar");
|
||||
}
|
||||
const ext = subtitleFileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
||||
subtitle = {
|
||||
language: job.subtitleLang || "nl",
|
||||
extension: ext,
|
||||
contentBase64: file.content.toString("base64"),
|
||||
contentBase64: Buffer.from(subtitleText, "utf8").toString("base64"),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Subtitle download mislukt";
|
||||
console.warn(`[downloads] subtitle job=${jobId}:`, message);
|
||||
subtitleError =
|
||||
err instanceof Error ? err.message : "Subtitle download mislukt";
|
||||
console.warn(`[downloads] subtitle job=${jobId}:`, subtitleError);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -630,27 +702,43 @@ export class DownloadService {
|
|||
data: {
|
||||
status: "FAILED",
|
||||
error: ack.error || "Import mislukt of timeout",
|
||||
subtitleError,
|
||||
},
|
||||
});
|
||||
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({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: "IMPORTED",
|
||||
importedPath: ack.importedPath ?? null,
|
||||
error: null,
|
||||
subtitlePath: ack.subtitlePath ?? null,
|
||||
subtitleError,
|
||||
error: subtitleError,
|
||||
},
|
||||
});
|
||||
|
||||
if (subtitleText) {
|
||||
if (subtitleText && !subtitleError) {
|
||||
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.create({
|
||||
data: {
|
||||
movieId,
|
||||
|
|
@ -663,11 +751,78 @@ export class DownloadService {
|
|||
downloadJobId: job.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (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[] {
|
||||
|
|
|
|||
|
|
@ -144,9 +144,16 @@ export class OpenSubtitlesClient {
|
|||
|
||||
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
|
||||
const token = await this.ensureLogin();
|
||||
if (!token) {
|
||||
throw new AppError(
|
||||
"OPENSUBTITLES",
|
||||
"OpenSubtitles.com download vereist gebruikersnaam + wachtwoord (Instellingen)",
|
||||
400
|
||||
);
|
||||
}
|
||||
const headers = this.headers({
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
});
|
||||
|
||||
const res = await fetch(`${this.root()}/api/v1/download`, {
|
||||
|
|
@ -158,19 +165,22 @@ export class OpenSubtitlesClient {
|
|||
const text = await res.text().catch(() => "");
|
||||
throw new AppError(
|
||||
"OPENSUBTITLES",
|
||||
`OpenSubtitles download mislukt (${res.status}): ${text.slice(0, 200)}`,
|
||||
`OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { link?: string; file_name?: string };
|
||||
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);
|
||||
if (!fileRes.ok) {
|
||||
throw new AppError("OPENSUBTITLES", `Subtitle ophalen mislukt (${fileRes.status})`, 400);
|
||||
}
|
||||
const buf = Buffer.from(await fileRes.arrayBuffer());
|
||||
if (!buf.length) {
|
||||
throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: leeg ondertitelbestand", 400);
|
||||
}
|
||||
return {
|
||||
content: buf,
|
||||
fileName: data.file_name || `subtitle-${fileId}.srt`,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ export class OpenSubtitlesOrgClient {
|
|||
fileName = fileName.replace(/\.gz$/i, "");
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import type {
|
|||
CreatePlaybackSessionAckPayload,
|
||||
ImportMediaAckPayload,
|
||||
ImportMediaPayload,
|
||||
WriteSubtitleAckPayload,
|
||||
WriteSubtitlePayload,
|
||||
} from "@media-cluster/protocol";
|
||||
|
||||
interface NodeConnection {
|
||||
|
|
@ -36,6 +38,11 @@ interface PendingImportAck {
|
|||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingWriteSubtitleAck {
|
||||
resolve: (result: WriteSubtitleAckPayload) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface SyncState {
|
||||
syncId: string;
|
||||
fileIds: Set<string>;
|
||||
|
|
@ -48,6 +55,7 @@ class NodeConnectionManager {
|
|||
private offlineCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pendingSessionAcks = new Map<string, PendingAck>();
|
||||
private pendingImportAcks = new Map<string, PendingImportAck>();
|
||||
private pendingWriteSubtitleAcks = new Map<string, PendingWriteSubtitleAck>();
|
||||
private syncState = new Map<string, SyncState>();
|
||||
private syncQueues = new Map<string, Promise<void>>();
|
||||
|
||||
|
|
@ -146,6 +154,9 @@ class NodeConnectionManager {
|
|||
case "IMPORT_MEDIA_ACK":
|
||||
this.handleImportMediaAck(message.payload as ImportMediaAckPayload);
|
||||
break;
|
||||
case "WRITE_SUBTITLE_ACK":
|
||||
this.handleWriteSubtitleAck(message.payload as WriteSubtitleAckPayload);
|
||||
break;
|
||||
default:
|
||||
this.send(socket, createMessage("ERROR", { message: `Unknown type: ${message.type}` }));
|
||||
}
|
||||
|
|
@ -432,6 +443,14 @@ class NodeConnectionManager {
|
|||
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> {
|
||||
await prisma.playbackSession.updateMany({
|
||||
where: { id: payload.sessionId },
|
||||
|
|
@ -530,6 +549,36 @@ class NodeConnectionManager {
|
|||
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 {
|
||||
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ WORKDIR /app
|
|||
|
||||
FROM golang:1.22-alpine AS node-build
|
||||
WORKDIR /src
|
||||
ARG MEDIA_NODE_VERSION=1.3.1
|
||||
ARG MEDIA_NODE_VERSION=1.3.2
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
COPY node/media-node/ ./
|
||||
RUN go mod tidy \
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import (
|
|||
"github.com/sthmedia/media-node/internal/streaming"
|
||||
)
|
||||
|
||||
var version = "1.3.1"
|
||||
var version = "1.3.2"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
|
|
|
|||
|
|
@ -392,6 +392,8 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
|
|||
go c.doRestart()
|
||||
case "IMPORT_MEDIA":
|
||||
go c.handleImportMedia(payload)
|
||||
case "WRITE_SUBTITLE":
|
||||
go c.handleWriteSubtitle(payload)
|
||||
case "ERROR":
|
||||
log.Printf("Master error: %s", string(payload))
|
||||
}
|
||||
|
|
@ -460,10 +462,78 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
|
|||
"jobId": p.JobID,
|
||||
"ok": ok,
|
||||
"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,
|
||||
})
|
||||
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}
|
||||
|
||||
if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 {
|
||||
lang := strings.ToLower(strings.TrimSpace(opts.Subtitle.Language))
|
||||
if lang == "" {
|
||||
lang = "nl"
|
||||
}
|
||||
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)
|
||||
subPath, err := WriteSidecar(destPath, opts.Subtitle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.SubtitlePath = subPath
|
||||
}
|
||||
|
|
@ -109,6 +97,35 @@ func ImportMovie(opts Options) (*Result, error) {
|
|||
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.
|
||||
func ExtractReleaseTags(release, title string, year *int) string {
|
||||
s := release
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export type ControlMessageType =
|
|||
| "RESTART"
|
||||
| "IMPORT_MEDIA"
|
||||
| "IMPORT_MEDIA_ACK"
|
||||
| "WRITE_SUBTITLE"
|
||||
| "WRITE_SUBTITLE_ACK"
|
||||
| "PLAYBACK_SESSION_ENDED"
|
||||
| "PLAYBACK_SESSION_PROGRESS"
|
||||
| "ERROR";
|
||||
|
|
@ -137,6 +139,26 @@ export interface ImportMediaAckPayload {
|
|||
jobId: string;
|
||||
ok: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue