diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx
index b03620e..03e0b56 100644
--- a/apps/admin-ui/src/app/downloads/page.tsx
+++ b/apps/admin-ui/src/app/downloads/page.tsx
@@ -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 (
<>
@@ -453,14 +469,27 @@ export default function DownloadsPage() {
{j.movieYear ? ` (${j.movieYear})` : ""}
{j.sourceTitle}
- {j.error && {j.error}
}
- {(j.status === "FAILED" || j.status === "FINISHED") && (
-
+ {j.subtitlePath && (
+
Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}
+ )}
+ {j.hasSubtitle && !j.subtitlePath && (
+
+ {j.subtitleError || j.error || "Ondertitel ontbreekt in map"}
+
+ )}
+ {j.error && !j.hasSubtitle &&
{j.error}
}
+
+ {(j.status === "FAILED" || j.status === "FINISHED") && (
-
- )}
+ )}
+ {j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
+
+ )}
+
))}
@@ -484,7 +513,17 @@ export default function DownloadsPage() {
{j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""}
- {j.error && {j.error} }
+ {j.subtitlePath && (
+
+ Sub: {j.subtitlePath.split(/[/\\]/).pop()}
+
+ )}
+ {j.hasSubtitle && !j.subtitlePath && (
+
+ {j.subtitleError || j.error || "Ondertitel ontbreekt"}
+
+ )}
+ {j.error && !j.hasSubtitle && {j.error} }
|
{j.sourceTitle} |
{j.nodeName} |
@@ -494,6 +533,11 @@ export default function DownloadsPage() {
Retry
)}
+ {j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && (
+
+ )}
))}
diff --git a/apps/master-api/prisma/migrations/20260830210000_subtitle_content/migration.sql b/apps/master-api/prisma/migrations/20260830210000_subtitle_content/migration.sql
new file mode 100644
index 0000000..e7f812a
--- /dev/null
+++ b/apps/master-api/prisma/migrations/20260830210000_subtitle_content/migration.sql
@@ -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;
diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma
index d4fb049..9f48c2f 100644
--- a/apps/master-api/prisma/schema.prisma
+++ b/apps/master-api/prisma/schema.prisma
@@ -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")
diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts
index 72d8f2e..f2b7c9a 100644
--- a/apps/master-api/src/app.ts
+++ b/apps/master-api/src/app.ts
@@ -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,
diff --git a/apps/master-api/src/downloads/routes.ts b/apps/master-api/src/downloads/routes.ts
index a7ec1c9..a3a8910 100644
--- a/apps/master-api/src/downloads/routes.ts
+++ b/apps/master-api/src/downloads/routes.ts
@@ -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;
}
diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts
index 194c2b2..f9b6845 100644
--- a/apps/master-api/src/downloads/service.ts
+++ b/apps/master-api/src/downloads/service.ts
@@ -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 {
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();
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 {
- 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";
- subtitleText = file.content.toString("utf8");
- subtitleFileName = file.fileName;
+ if (!subtitleText?.trim() && job.subtitleFileId) {
+ const source = job.subtitleSource === "org" ? "org" : "com";
+ 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,44 +702,127 @@ 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;
}
- 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,
- },
+ const existing = await prisma.librarySubtitle.findFirst({
+ where: { 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) {
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[] {
diff --git a/apps/master-api/src/opensubtitles/client.ts b/apps/master-api/src/opensubtitles/client.ts
index df077c1..92b0a2b 100644
--- a/apps/master-api/src/opensubtitles/client.ts
+++ b/apps/master-api/src/opensubtitles/client.ts
@@ -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`,
diff --git a/apps/master-api/src/opensubtitles/org-client.ts b/apps/master-api/src/opensubtitles/org-client.ts
index 7b9ce00..1e55637 100644
--- a/apps/master-api/src/opensubtitles/org-client.ts
+++ b/apps/master-api/src/opensubtitles/org-client.ts
@@ -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 };
}
}
diff --git a/apps/master-api/src/websocket/manager.ts b/apps/master-api/src/websocket/manager.ts
index 3b5e8bc..8b9460c 100644
--- a/apps/master-api/src/websocket/manager.ts
+++ b/apps/master-api/src/websocket/manager.ts
@@ -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;
}
+interface PendingWriteSubtitleAck {
+ resolve: (result: WriteSubtitleAckPayload) => void;
+ timer: ReturnType;
+}
+
interface SyncState {
syncId: string;
fileIds: Set;
@@ -48,6 +55,7 @@ class NodeConnectionManager {
private offlineCheckInterval: ReturnType | null = null;
private pendingSessionAcks = new Map();
private pendingImportAcks = new Map();
+ private pendingWriteSubtitleAcks = new Map();
private syncState = new Map();
private syncQueues = new Map>();
@@ -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 {
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 {
+ if (!this.isOnline(nodeId)) {
+ return { jobId: payload.jobId, ok: false, error: "Node offline" };
+ }
+
+ const acked = new Promise((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);
}
diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api
index 7f34c67..8594fa8 100644
--- a/deploy/docker/Dockerfile.master-api
+++ b/deploy/docker/Dockerfile.master-api
@@ -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 \
diff --git a/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go
index e31c821..fc45feb 100644
--- a/node/media-node/cmd/media-node/main.go
+++ b/node/media-node/cmd/media-node/main.go
@@ -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 {
diff --git a/node/media-node/internal/control/client.go b/node/media-node/internal/control/client.go
index db01546..406bf3a 100644
--- a/node/media-node/internal/control/client.go
+++ b/node/media-node/internal/control/client.go
@@ -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)
}
}
diff --git a/node/media-node/internal/importmedia/import.go b/node/media-node/internal/importmedia/import.go
index 5b7fdc8..831ab88 100644
--- a/node/media-node/internal/importmedia/import.go
+++ b/node/media-node/internal/importmedia/import.go
@@ -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
diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts
index 53b43e2..fb27bb8 100644
--- a/packages/protocol/src/index.ts
+++ b/packages/protocol/src/index.ts
@@ -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;
}