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 };
|
||||
}
|
||||
|
||||
/** 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,7 +293,12 @@ export class DownloadService {
|
|||
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,
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
languages,
|
||||
|
|
@ -301,20 +306,52 @@ export class DownloadService {
|
|||
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();
|
||||
|
|
|
|||
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";
|
||||
|
||||
export type SubtitleSource = "com" | "org";
|
||||
export type SubtitleSource = "com" | "org" | "local";
|
||||
|
||||
export type SubtitleHit = {
|
||||
fileId: number;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingSidecarAck {
|
||||
resolve: (result: FindSidecarSubtitlesAckPayload) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingUpgradeAck {
|
||||
resolve: (result: UpgradeAckPayload) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
|
|
@ -63,6 +70,7 @@ class NodeConnectionManager {
|
|||
private pendingSessionAcks = new Map<string, PendingAck>();
|
||||
private pendingImportAcks = new Map<string, PendingImportAck>();
|
||||
private pendingWriteSubtitleAcks = new Map<string, PendingWriteSubtitleAck>();
|
||||
private pendingSidecarAcks = new Map<string, PendingSidecarAck>();
|
||||
private pendingUpgradeAcks = new Map<string, PendingUpgradeAck>();
|
||||
private syncState = new Map<string, SyncState>();
|
||||
private syncQueues = new Map<string, Promise<void>>();
|
||||
|
|
@ -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<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 {
|
||||
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
| "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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue