diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts index 6db161e..2463f30 100644 --- a/apps/master-api/src/downloads/service.ts +++ b/apps/master-api/src/downloads/service.ts @@ -256,7 +256,7 @@ export class DownloadService { return { imdbId, results: scored.slice(0, 40), configured }; } - /** TV/client: resolve IMDb (+ episode query) from media file, then search OS.com/OS.org. */ + /** TV/client: resolve IMDb (+ episode query) from media file, then search OS.com/OS.org + local sidecars. */ async searchSubtitlesForMediaFile(mediaFileId: string, languages = "nl,en") { const file = await prisma.mediaFile.findUnique({ where: { id: mediaFileId }, @@ -293,28 +293,65 @@ export class DownloadService { query = `${name} S${s}E${e}`; } - const base = await this.searchSubtitles({ - imdbId, - tmdbId: tmdbId ?? undefined, - languages, - releaseHint: file.releaseName, - movieTitle, - movieYear, - query, - }); + const [{ discoverSidecarSubtitles }] = await Promise.all([ + import("./sidecars").then((m) => m), + ]); + + const [base, localHits] = await Promise.all([ + this.searchSubtitles({ + imdbId, + tmdbId: tmdbId ?? undefined, + languages, + releaseHint: file.releaseName, + movieTitle, + movieYear, + query, + mediaKind: file.episode ? "episode" : "movie", + seasonNumber: file.episode?.seasonNumber, + episodeNumber: file.episode?.episodeNumber, + }), + discoverSidecarSubtitles(mediaFileId).catch((err) => { + console.warn("[downloads] sidecar discover:", err instanceof Error ? err.message : err); + return [] as SubtitleHit[]; + }), + ]); + + // Local first, then online hits + const merged = mergeSubtitleHits([...localHits, ...base.results]); return { ...base, + results: merged.slice(0, 50), + // Always true so UI can show local hits even if OS is off + configured: base.configured || localHits.length > 0, title: movieTitle || null, seasonNumber: file.episode?.seasonNumber ?? null, episodeNumber: file.episode?.episodeNumber ?? null, }; } - async downloadSubtitleForClient(fileId: number, source: SubtitleSource) { + async downloadSubtitleForClient(fileId: number, source: SubtitleSource | string) { if (!Number.isFinite(fileId) || fileId <= 0) { throw new AppError("INVALID_REQUEST", "fileId ongeldig", 400); } + if (source === "local") { + const { getLocalSubtitleCache } = await import("./sidecars"); + const hit = getLocalSubtitleCache(fileId); + if (!hit) { + throw new AppError( + "NOT_FOUND", + "Lokale ondertitel verlopen โ€” zoek opnieuw in de speler", + 404 + ); + } + return { + fileId, + source: "local" as const, + fileName: hit.fileName, + format: hit.format, + contentBase64: hit.contentBase64, + }; + } const src: SubtitleSource = source === "com" ? "com" : "org"; const file = await this.downloadSubtitleFile(fileId, src); const lower = file.fileName.toLowerCase(); diff --git a/apps/master-api/src/downloads/sidecars.ts b/apps/master-api/src/downloads/sidecars.ts new file mode 100644 index 0000000..2410b3e --- /dev/null +++ b/apps/master-api/src/downloads/sidecars.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "crypto"; +import { prisma } from "../database/client"; +import { nodeConnectionManager } from "../websocket/manager"; +import type { SubtitleHit } from "../opensubtitles/client"; + +type LocalSubCacheEntry = { + contentBase64: string; + fileName: string; + format: string; + language: string; + mediaFileId: string; + expiresAt: number; +}; + +const localSubCache = new Map(); +const CACHE_TTL_MS = 30 * 60_000; + +export function stableLocalFileId(key: string): number { + let h = 2166136261; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 16777619); + } + const n = Math.abs(h) % 2_000_000_000; + return n === 0 ? 1 : n; +} + +function putLocalCache(fileId: number, entry: Omit) { + localSubCache.set(fileId, { ...entry, expiresAt: Date.now() + CACHE_TTL_MS }); +} + +export function getLocalSubtitleCache(fileId: number): LocalSubCacheEntry | null { + const hit = localSubCache.get(fileId); + if (!hit) return null; + if (Date.now() > hit.expiresAt) { + localSubCache.delete(fileId); + return null; + } + return hit; +} + +/** Ask node for sidecar .srt/.ass next to the video; cache + upsert library_subtitles. */ +export async function discoverSidecarSubtitles(mediaFileId: string): Promise { + const file = await prisma.mediaFile.findUnique({ + where: { id: mediaFileId }, + include: { + movie: { select: { id: true, tmdbId: true, imdbId: true } }, + episode: { + select: { + imdbId: true, + series: { select: { imdbId: true, tmdbId: true } }, + }, + }, + }, + }); + if (!file || !nodeConnectionManager.isOnline(file.nodeId)) return []; + + const requestId = randomUUID(); + const ack = await nodeConnectionManager.findSidecarSubtitlesAcked( + file.nodeId, + { requestId, localFileId: file.localFileId }, + 15_000 + ); + if (!ack.ok || !ack.subtitles?.length) return []; + + const hits: SubtitleHit[] = []; + for (const sub of ack.subtitles) { + const format = (sub.format || "srt").toLowerCase(); + const language = (sub.language || "nl").toLowerCase(); + const fileName = sub.fileName || `local.${format}`; + const fileId = stableLocalFileId(`${mediaFileId}:${fileName}`); + putLocalCache(fileId, { + contentBase64: sub.contentBase64, + fileName, + format, + language, + mediaFileId, + }); + + // Persist for Stremio stream.subtitles + try { + const content = Buffer.from(sub.contentBase64, "base64").toString("utf8"); + if (content.trim()) { + const movieId = file.movieId ?? null; + const tmdbId = file.movie?.tmdbId ?? file.episode?.series?.tmdbId ?? null; + const imdbId = + file.movie?.imdbId || file.episode?.imdbId || file.episode?.series?.imdbId || null; + const linkFilter = [ + ...(movieId ? [{ movieId }] : []), + ...(tmdbId != null ? [{ tmdbId }] : []), + ...(imdbId ? [{ imdbId }] : []), + ]; + const existing = + linkFilter.length > 0 + ? await prisma.librarySubtitle.findFirst({ + where: { fileName, OR: linkFilter }, + }) + : await prisma.librarySubtitle.findFirst({ + where: { fileName, content }, + }); + if (existing) { + await prisma.librarySubtitle.update({ + where: { id: existing.id }, + data: { content, language, format, updatedAt: new Date() }, + }); + } else { + await prisma.librarySubtitle.create({ + data: { + movieId, + tmdbId, + imdbId, + language, + format, + content, + fileName, + }, + }); + } + } + } catch (err) { + console.warn("[sidecars] library_subtitles upsert:", err); + } + + hits.push({ + fileId, + language, + release: `Lokaal ยท ${fileName}`, + fileName, + downloadCount: 9_999_999, + hearingImpaired: /\b(sdh|hi|hearing)\b/i.test(fileName), + fps: null, + uploader: "lokaal", + source: "local", + }); + } + return hits; +} diff --git a/apps/master-api/src/opensubtitles/client.ts b/apps/master-api/src/opensubtitles/client.ts index a60043a..f701e7d 100644 --- a/apps/master-api/src/opensubtitles/client.ts +++ b/apps/master-api/src/opensubtitles/client.ts @@ -1,6 +1,6 @@ import { AppError } from "../security/errors"; -export type SubtitleSource = "com" | "org"; +export type SubtitleSource = "com" | "org" | "local"; export type SubtitleHit = { fileId: number; diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 13c6e17..6c06bf3 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -964,6 +964,22 @@ async function getStreams( // Never fall back to unavailable legacy paths (/media/movies) โ€” those break playback. const pool = movie.mediaFiles.filter((f) => f.available); + // Pull sidecar .srt from disk into library_subtitles (no app changes needed) + try { + const { discoverSidecarSubtitles } = await import("../downloads/sidecars"); + const online = pool.filter( + (f) => + f.node.status === "ONLINE" && + !f.node.revoked && + nodeConnectionManager.isOnline(f.nodeId) + ); + await Promise.all( + online.slice(0, 6).map((f) => discoverSidecarSubtitles(f.id).catch(() => [])) + ); + } catch (err) { + log?.warn?.({ err }, "sidecar subtitle discover failed"); + } + const librarySubs = await prisma.librarySubtitle.findMany({ where: { OR: [ diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 0a95fe8..e164041 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -632,7 +632,9 @@ export function registerViewerRoutes( if (!Number.isFinite(fileId) || fileId <= 0) { throw new AppError("INVALID_REQUEST", "fileId verplicht", 400); } - const source: SubtitleSource = body.source === "com" ? "com" : "org"; + const raw = (body.source || "org").toLowerCase(); + const source: SubtitleSource | "local" = + raw === "com" ? "com" : raw === "local" ? "local" : "org"; return viewers.fetchSubtitle(fileId, source); }); } diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index fff5f80..80246ca 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -1559,7 +1559,7 @@ export class ViewerService { return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages); } - async fetchSubtitle(fileId: number, source: SubtitleSource) { + async fetchSubtitle(fileId: number, source: SubtitleSource | "local" | string) { if (!this.downloads) { throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503); } diff --git a/apps/master-api/src/websocket/manager.ts b/apps/master-api/src/websocket/manager.ts index dd1b134..e2ca02d 100644 --- a/apps/master-api/src/websocket/manager.ts +++ b/apps/master-api/src/websocket/manager.ts @@ -19,6 +19,8 @@ import type { ImportMediaPayload, WriteSubtitleAckPayload, WriteSubtitlePayload, + FindSidecarSubtitlesAckPayload, + FindSidecarSubtitlesPayload, UpgradeAckPayload, UpgradePayload, } from "@media-cluster/protocol"; @@ -45,6 +47,11 @@ interface PendingWriteSubtitleAck { timer: ReturnType; } +interface PendingSidecarAck { + resolve: (result: FindSidecarSubtitlesAckPayload) => void; + timer: ReturnType; +} + interface PendingUpgradeAck { resolve: (result: UpgradeAckPayload) => void; timer: ReturnType; @@ -63,6 +70,7 @@ class NodeConnectionManager { private pendingSessionAcks = new Map(); private pendingImportAcks = new Map(); private pendingWriteSubtitleAcks = new Map(); + private pendingSidecarAcks = new Map(); private pendingUpgradeAcks = new Map(); private syncState = new Map(); private syncQueues = new Map>(); @@ -165,6 +173,9 @@ class NodeConnectionManager { case "WRITE_SUBTITLE_ACK": this.handleWriteSubtitleAck(message.payload as WriteSubtitleAckPayload); break; + case "FIND_SIDECAR_SUBTITLES_ACK": + this.handleFindSidecarAck(message.payload as FindSidecarSubtitlesAckPayload); + break; case "UPGRADE_ACK": this.handleUpgradeAck(message.payload as UpgradeAckPayload); break; @@ -462,6 +473,14 @@ class NodeConnectionManager { pending.resolve(payload); } + private handleFindSidecarAck(payload: FindSidecarSubtitlesAckPayload): void { + const pending = this.pendingSidecarAcks.get(payload.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingSidecarAcks.delete(payload.requestId); + pending.resolve(payload); + } + private handleUpgradeAck(payload: UpgradeAckPayload): void { const pending = this.pendingUpgradeAcks.get(payload.upgradeId); if (!pending) return; @@ -628,6 +647,36 @@ class NodeConnectionManager { return acked; } + async findSidecarSubtitlesAcked( + nodeId: string, + payload: FindSidecarSubtitlesPayload, + timeoutMs = 20_000 + ): Promise { + if (!this.isOnline(nodeId)) { + return { requestId: payload.requestId, ok: false, error: "Node offline" }; + } + + const acked = new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingSidecarAcks.delete(payload.requestId); + resolve({ requestId: payload.requestId, ok: false, error: "Sidecar timeout" }); + }, timeoutMs); + this.pendingSidecarAcks.set(payload.requestId, { resolve, timer }); + }); + + const pushed = this.sendToNode(nodeId, "FIND_SIDECAR_SUBTITLES", payload); + if (!pushed) { + const pending = this.pendingSidecarAcks.get(payload.requestId); + if (pending) { + clearTimeout(pending.timer); + this.pendingSidecarAcks.delete(payload.requestId); + } + return { requestId: payload.requestId, 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/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go index d095c15..748f152 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.4" +var version = "1.3.5" 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 dc92727..4523bd3 100644 --- a/node/media-node/internal/control/client.go +++ b/node/media-node/internal/control/client.go @@ -396,6 +396,8 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) { go c.handleImportMedia(payload) case "WRITE_SUBTITLE": go c.handleWriteSubtitle(payload) + case "FIND_SIDECAR_SUBTITLES": + go c.handleFindSidecarSubtitles(payload) case "ERROR": log.Printf("Master error: %s", string(payload)) } @@ -612,6 +614,55 @@ func (c *Client) handleWriteSubtitle(payload json.RawMessage) { } } +func (c *Client) handleFindSidecarSubtitles(payload json.RawMessage) { + var p struct { + RequestID string `json:"requestId"` + LocalFileID string `json:"localFileId"` + } + if err := json.Unmarshal(payload, &p); err != nil || p.RequestID == "" || p.LocalFileID == "" { + log.Printf("FIND_SIDECAR_SUBTITLES: invalid payload") + return + } + file, err := c.store.GetFile(p.LocalFileID) + if err != nil { + ack := controlMessage("FIND_SIDECAR_SUBTITLES_ACK", map[string]interface{}{ + "requestId": p.RequestID, + "ok": false, + "error": "bestand niet gevonden op node", + }) + _ = c.write(ack) + return + } + hits, err := importmedia.FindSidecarsBesideVideo(file.Path) + if err != nil { + ack := controlMessage("FIND_SIDECAR_SUBTITLES_ACK", map[string]interface{}{ + "requestId": p.RequestID, + "ok": false, + "error": err.Error(), + }) + _ = c.write(ack) + return + } + subs := make([]map[string]interface{}, 0, len(hits)) + for _, h := range hits { + subs = append(subs, map[string]interface{}{ + "fileName": h.FileName, + "language": h.Language, + "format": h.Format, + "contentBase64": importmedia.EncodeSidecarBase64(h.Content), + }) + } + ack := controlMessage("FIND_SIDECAR_SUBTITLES_ACK", map[string]interface{}{ + "requestId": p.RequestID, + "ok": true, + "subtitles": subs, + "error": "", + }) + if werr := c.write(ack); werr != nil { + log.Printf("failed to send FIND_SIDECAR_SUBTITLES_ACK: %v", werr) + } +} + func decodeBase64(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } diff --git a/node/media-node/internal/importmedia/sidecars.go b/node/media-node/internal/importmedia/sidecars.go new file mode 100644 index 0000000..cce08a7 --- /dev/null +++ b/node/media-node/internal/importmedia/sidecars.go @@ -0,0 +1,117 @@ +package importmedia + +import ( + "encoding/base64" + "os" + "path/filepath" + "regexp" + "strings" +) + +var langSuffixRe = regexp.MustCompile(`(?i)\.(nl|nld|dut|dutch|en|eng|english|forced|sdh|hi)(?:\.(forced|sdh|hi))?$`) + +var subtitleExt = map[string]string{ + ".srt": "srt", + ".vtt": "vtt", + ".ass": "ass", + ".ssa": "ass", +} + +const maxSidecarBytes = 2 * 1024 * 1024 + +type SidecarHit struct { + FileName string + Language string + Format string + Content []byte +} + +// FindSidecarsBesideVideo returns subtitle files in the same folder that match the video stem. +func FindSidecarsBesideVideo(videoPath string) ([]SidecarHit, error) { + videoPath = filepath.Clean(strings.TrimSpace(videoPath)) + if videoPath == "" { + return nil, nil + } + info, err := os.Stat(videoPath) + if err != nil || info.IsDir() { + return nil, err + } + + dir := filepath.Dir(videoPath) + stem := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath)) + stemLower := strings.ToLower(stem) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var out []SidecarHit + for _, ent := range entries { + if ent.IsDir() { + continue + } + name := ent.Name() + ext := strings.ToLower(filepath.Ext(name)) + format, ok := subtitleExt[ext] + if !ok { + continue + } + base := strings.TrimSuffix(name, filepath.Ext(name)) + baseLower := strings.ToLower(base) + if !sidecarMatchesStem(stemLower, baseLower) { + continue + } + full := filepath.Join(dir, name) + fi, err := ent.Info() + if err != nil || fi.Size() <= 0 || fi.Size() > maxSidecarBytes { + continue + } + raw, err := os.ReadFile(full) + if err != nil || len(raw) == 0 { + continue + } + out = append(out, SidecarHit{ + FileName: name, + Language: guessLangFromName(baseLower, stemLower), + Format: format, + Content: raw, + }) + } + return out, nil +} + +func sidecarMatchesStem(stemLower, subBaseLower string) bool { + if subBaseLower == stemLower { + return true + } + if strings.HasPrefix(subBaseLower, stemLower+".") { + return true + } + // Strip known lang tags and compare + stripped := langSuffixRe.ReplaceAllString(subBaseLower, "") + return stripped == stemLower +} + +func guessLangFromName(subBaseLower, stemLower string) string { + rest := strings.TrimPrefix(subBaseLower, stemLower) + rest = strings.TrimPrefix(rest, ".") + if rest == "" { + return "nl" + } + parts := strings.Split(rest, ".") + for _, p := range parts { + switch p { + case "nl", "nld", "dut", "dutch": + return "nl" + case "en", "eng", "english": + return "en" + } + } + return "nl" +} + +// EncodeSidecarBase64 helper for control payloads. +func EncodeSidecarBase64(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 254170b..8f8123c 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -29,6 +29,8 @@ export type ControlMessageType = | "IMPORT_MEDIA_ACK" | "WRITE_SUBTITLE" | "WRITE_SUBTITLE_ACK" + | "FIND_SIDECAR_SUBTITLES" + | "FIND_SIDECAR_SUBTITLES_ACK" | "PLAYBACK_SESSION_ENDED" | "PLAYBACK_SESSION_PROGRESS" | "ERROR"; @@ -195,6 +197,26 @@ export interface WriteSubtitleAckPayload { error?: string; } +/** Master โ†’ node: list/read sidecar subtitle files next to a video. */ +export interface FindSidecarSubtitlesPayload { + requestId: string; + localFileId: string; +} + +export interface SidecarSubtitleFile { + fileName: string; + language: string; + format: string; + contentBase64: string; +} + +export interface FindSidecarSubtitlesAckPayload { + requestId: string; + ok: boolean; + subtitles?: SidecarSubtitleFile[]; + error?: string; +} + export interface PlaybackSessionEndedPayload { sessionId: string; reason: string;