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:
Jos Vooges | STH 2026-08-30 21:33:00 +02:00
parent e2a9155105
commit 93bbd07487
14 changed files with 455 additions and 59 deletions

View file

@ -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>
)}
{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"> <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>
))} ))}

View file

@ -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;

View file

@ -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")

View file

@ -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,

View file

@ -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;
} }

View file

@ -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 {
if (!subtitleText?.trim() && job.subtitleFileId) {
const source = job.subtitleSource === "org" ? "org" : "com"; const source = job.subtitleSource === "org" ? "org" : "com";
const file = const file = await this.downloadSubtitleFile(job.subtitleFileId, source);
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";
subtitleText = file.content.toString("utf8"); subtitleText = file.content.toString("utf8");
subtitleFileName = file.fileName; 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 = { 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,27 +702,43 @@ 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;
} }
const existing = await prisma.librarySubtitle.findFirst({
where: { downloadJobId: job.id },
});
if (!existing) {
await prisma.librarySubtitle.create({ await prisma.librarySubtitle.create({
data: { data: {
movieId, movieId,
@ -663,11 +751,78 @@ export class DownloadService {
downloadJobId: job.id, 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[] {

View file

@ -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`,

View file

@ -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 };
} }
} }

View file

@ -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);
} }

View file

@ -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 \

View file

@ -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 {

View file

@ -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)
} }
} }

View file

@ -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

View file

@ -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;
} }