Serve local sidecar subtitles via existing client search/fetch and Stremio.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 03:37:08 +02:00
parent c8647a9e9d
commit 9de3ee62d7
11 changed files with 446 additions and 15 deletions

View file

@ -256,7 +256,7 @@ export class DownloadService {
return { imdbId, results: scored.slice(0, 40), configured }; 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") { async searchSubtitlesForMediaFile(mediaFileId: string, languages = "nl,en") {
const file = await prisma.mediaFile.findUnique({ const file = await prisma.mediaFile.findUnique({
where: { id: mediaFileId }, where: { id: mediaFileId },
@ -293,28 +293,65 @@ export class DownloadService {
query = `${name} S${s}E${e}`; query = `${name} S${s}E${e}`;
} }
const base = await this.searchSubtitles({ const [{ discoverSidecarSubtitles }] = await Promise.all([
imdbId, import("./sidecars").then((m) => m),
tmdbId: tmdbId ?? undefined, ]);
languages,
releaseHint: file.releaseName, const [base, localHits] = await Promise.all([
movieTitle, this.searchSubtitles({
movieYear, imdbId,
query, 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 { return {
...base, ...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, title: movieTitle || null,
seasonNumber: file.episode?.seasonNumber ?? null, seasonNumber: file.episode?.seasonNumber ?? null,
episodeNumber: file.episode?.episodeNumber ?? 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) { if (!Number.isFinite(fileId) || fileId <= 0) {
throw new AppError("INVALID_REQUEST", "fileId ongeldig", 400); 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 src: SubtitleSource = source === "com" ? "com" : "org";
const file = await this.downloadSubtitleFile(fileId, src); const file = await this.downloadSubtitleFile(fileId, src);
const lower = file.fileName.toLowerCase(); const lower = file.fileName.toLowerCase();

View file

@ -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<number, LocalSubCacheEntry>();
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<LocalSubCacheEntry, "expiresAt">) {
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<SubtitleHit[]> {
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;
}

View file

@ -1,6 +1,6 @@
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
export type SubtitleSource = "com" | "org"; export type SubtitleSource = "com" | "org" | "local";
export type SubtitleHit = { export type SubtitleHit = {
fileId: number; fileId: number;

View file

@ -964,6 +964,22 @@ async function getStreams(
// Never fall back to unavailable legacy paths (/media/movies) — those break playback. // Never fall back to unavailable legacy paths (/media/movies) — those break playback.
const pool = movie.mediaFiles.filter((f) => f.available); 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({ const librarySubs = await prisma.librarySubtitle.findMany({
where: { where: {
OR: [ OR: [

View file

@ -632,7 +632,9 @@ export function registerViewerRoutes(
if (!Number.isFinite(fileId) || fileId <= 0) { if (!Number.isFinite(fileId) || fileId <= 0) {
throw new AppError("INVALID_REQUEST", "fileId verplicht", 400); 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); return viewers.fetchSubtitle(fileId, source);
}); });
} }

View file

@ -1559,7 +1559,7 @@ export class ViewerService {
return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages); return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages);
} }
async fetchSubtitle(fileId: number, source: SubtitleSource) { async fetchSubtitle(fileId: number, source: SubtitleSource | "local" | string) {
if (!this.downloads) { if (!this.downloads) {
throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503); throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503);
} }

View file

@ -19,6 +19,8 @@ import type {
ImportMediaPayload, ImportMediaPayload,
WriteSubtitleAckPayload, WriteSubtitleAckPayload,
WriteSubtitlePayload, WriteSubtitlePayload,
FindSidecarSubtitlesAckPayload,
FindSidecarSubtitlesPayload,
UpgradeAckPayload, UpgradeAckPayload,
UpgradePayload, UpgradePayload,
} from "@media-cluster/protocol"; } from "@media-cluster/protocol";
@ -45,6 +47,11 @@ interface PendingWriteSubtitleAck {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
interface PendingSidecarAck {
resolve: (result: FindSidecarSubtitlesAckPayload) => void;
timer: ReturnType<typeof setTimeout>;
}
interface PendingUpgradeAck { interface PendingUpgradeAck {
resolve: (result: UpgradeAckPayload) => void; resolve: (result: UpgradeAckPayload) => void;
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
@ -63,6 +70,7 @@ class NodeConnectionManager {
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 pendingWriteSubtitleAcks = new Map<string, PendingWriteSubtitleAck>();
private pendingSidecarAcks = new Map<string, PendingSidecarAck>();
private pendingUpgradeAcks = new Map<string, PendingUpgradeAck>(); private pendingUpgradeAcks = new Map<string, PendingUpgradeAck>();
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>>();
@ -165,6 +173,9 @@ class NodeConnectionManager {
case "WRITE_SUBTITLE_ACK": case "WRITE_SUBTITLE_ACK":
this.handleWriteSubtitleAck(message.payload as WriteSubtitleAckPayload); this.handleWriteSubtitleAck(message.payload as WriteSubtitleAckPayload);
break; break;
case "FIND_SIDECAR_SUBTITLES_ACK":
this.handleFindSidecarAck(message.payload as FindSidecarSubtitlesAckPayload);
break;
case "UPGRADE_ACK": case "UPGRADE_ACK":
this.handleUpgradeAck(message.payload as UpgradeAckPayload); this.handleUpgradeAck(message.payload as UpgradeAckPayload);
break; break;
@ -462,6 +473,14 @@ class NodeConnectionManager {
pending.resolve(payload); 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 { private handleUpgradeAck(payload: UpgradeAckPayload): void {
const pending = this.pendingUpgradeAcks.get(payload.upgradeId); const pending = this.pendingUpgradeAcks.get(payload.upgradeId);
if (!pending) return; if (!pending) return;
@ -628,6 +647,36 @@ class NodeConnectionManager {
return acked; return acked;
} }
async findSidecarSubtitlesAcked(
nodeId: string,
payload: FindSidecarSubtitlesPayload,
timeoutMs = 20_000
): Promise<FindSidecarSubtitlesAckPayload> {
if (!this.isOnline(nodeId)) {
return { requestId: payload.requestId, ok: false, error: "Node offline" };
}
const acked = new Promise<FindSidecarSubtitlesAckPayload>((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 { createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload); return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
} }

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.4" var version = "1.3.5"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {

View file

@ -396,6 +396,8 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
go c.handleImportMedia(payload) go c.handleImportMedia(payload)
case "WRITE_SUBTITLE": case "WRITE_SUBTITLE":
go c.handleWriteSubtitle(payload) go c.handleWriteSubtitle(payload)
case "FIND_SIDECAR_SUBTITLES":
go c.handleFindSidecarSubtitles(payload)
case "ERROR": case "ERROR":
log.Printf("Master error: %s", string(payload)) 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) { func decodeBase64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s) return base64.StdEncoding.DecodeString(s)
} }

View file

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

View file

@ -29,6 +29,8 @@ export type ControlMessageType =
| "IMPORT_MEDIA_ACK" | "IMPORT_MEDIA_ACK"
| "WRITE_SUBTITLE" | "WRITE_SUBTITLE"
| "WRITE_SUBTITLE_ACK" | "WRITE_SUBTITLE_ACK"
| "FIND_SIDECAR_SUBTITLES"
| "FIND_SIDECAR_SUBTITLES_ACK"
| "PLAYBACK_SESSION_ENDED" | "PLAYBACK_SESSION_ENDED"
| "PLAYBACK_SESSION_PROGRESS" | "PLAYBACK_SESSION_PROGRESS"
| "ERROR"; | "ERROR";
@ -195,6 +197,26 @@ export interface WriteSubtitleAckPayload {
error?: string; 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 { export interface PlaybackSessionEndedPayload {
sessionId: string; sessionId: string;
reason: string; reason: string;