Serve local sidecar subtitles via existing client search/fetch and Stremio.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c8647a9e9d
commit
9de3ee62d7
11 changed files with 446 additions and 15 deletions
|
|
@ -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,7 +293,12 @@ 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([
|
||||||
|
import("./sidecars").then((m) => m),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [base, localHits] = await Promise.all([
|
||||||
|
this.searchSubtitles({
|
||||||
imdbId,
|
imdbId,
|
||||||
tmdbId: tmdbId ?? undefined,
|
tmdbId: tmdbId ?? undefined,
|
||||||
languages,
|
languages,
|
||||||
|
|
@ -301,20 +306,52 @@ export class DownloadService {
|
||||||
movieTitle,
|
movieTitle,
|
||||||
movieYear,
|
movieYear,
|
||||||
query,
|
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();
|
||||||
|
|
|
||||||
137
apps/master-api/src/downloads/sidecars.ts
Normal file
137
apps/master-api/src/downloads/sidecars.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
node/media-node/internal/importmedia/sidecars.go
Normal file
117
node/media-node/internal/importmedia/sidecars.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue