stremio/packages/protocol/src/index.ts
Jos Vooges | STH 6de2c767bf Show real subtitle languages by probing MKV track tags on the node.
Pass embedded track language/title through play ACK into clients so Android/iOS can label tracks instead of generic Ondertitel N when ExoPlayer/VLC miss tags.
2026-09-12 20:58:24 +02:00

263 lines
6.1 KiB
TypeScript

import { randomUUID } from "crypto";
import type {
CreatePlaybackSessionPayload,
LibraryEvent,
NodeHeartbeat,
} from "@media-cluster/shared-types";
import { PROTOCOL_VERSION } from "@media-cluster/shared-types";
export { PROTOCOL_VERSION };
export type ControlMessageType =
| "HELLO"
| "HELLO_ACK"
| "HEARTBEAT"
| "HEARTBEAT_ACK"
| "LIBRARY_EVENT"
| "LIBRARY_EVENT_ACK"
| "FULL_LIBRARY_SYNC"
| "FULL_LIBRARY_SYNC_ACK"
| "CREATE_PLAYBACK_SESSION"
| "CREATE_PLAYBACK_SESSION_ACK"
| "REVOKE_PLAYBACK_SESSION"
| "RESCAN"
| "CONFIG_UPDATE"
| "RESTART"
| "UPGRADE"
| "UPGRADE_ACK"
| "IMPORT_MEDIA"
| "IMPORT_MEDIA_ACK"
| "WRITE_SUBTITLE"
| "WRITE_SUBTITLE_ACK"
| "FIND_SIDECAR_SUBTITLES"
| "FIND_SIDECAR_SUBTITLES_ACK"
| "PLAYBACK_SESSION_ENDED"
| "PLAYBACK_SESSION_PROGRESS"
| "ERROR";
export interface ControlMessage<T = unknown> {
protocolVersion: number;
type: ControlMessageType;
messageId: string;
timestamp: string;
payload: T;
}
export interface HelloPayload {
nodeId: string;
apiKey: string;
softwareVersion: string;
architecture: string;
hostname: string;
publicStreamUrl: string;
/** Current scan roots on the node (seed master if empty) */
moviesPaths?: string[];
seriesPaths?: string[];
}
export interface HelloAckPayload {
accepted: boolean;
reason?: string;
heartbeatIntervalSeconds: number;
/** @deprecated prefer fullScanInterval + fullScanAt */
fullScanIntervalSeconds: number;
/** "off" or Go duration e.g. "6h", "24h" */
fullScanInterval: string;
/** Nightly catch-up HH:MM, or "off" */
fullScanAt: string;
moviesPaths?: string[];
seriesPaths?: string[];
scanRoots?: Array<{
path: string;
shelfId: string;
kind: "movie" | "series";
}>;
}
export interface HeartbeatPayload extends NodeHeartbeat {}
export interface LibraryEventPayload {
events: LibraryEvent[];
}
export interface FullLibrarySyncPayload {
nodeRevision: number;
files: LibraryEvent["file"][];
/** Present when sync is split across multiple WebSocket messages */
syncId?: string;
batchIndex?: number;
batchCount?: number;
/** True on the final batch (or when sending a single unbatched sync) */
isLast?: boolean;
}
export interface FullLibrarySyncAckPayload {
accepted: boolean;
syncId?: string;
batchIndex?: number;
}
export interface RescanPayload {
full: boolean;
}
export interface ConfigUpdatePayload {
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
/** Preferred: path + shelf binding for Stremio catalogs */
scanRoots?: Array<{
path: string;
shelfId: string;
kind: "movie" | "series";
}>;
}
export interface RestartPayload {
reason?: string;
}
export interface UpgradePayload {
upgradeId: string;
url: string;
}
export interface UpgradeAckPayload {
upgradeId: string;
ok: boolean;
version?: string;
error?: string;
}
/** Master → node: move finished Download Station output into library folder. */
export interface ImportMediaPayload {
jobId: string;
/** Absolute host path of the finished download folder/file */
sourcePath: string;
/**
* Movie: destination movie folder (Title (Year)).
* Series: destination show folder (Show (Year)); Season XX is created underneath.
*/
destDir: string;
movieTitle: string;
movieYear?: number | null;
/** Release tags kept from torrent/file name (UHD BluRay Atmos …) */
releaseTags?: string;
/** Fallback if releaseTags empty */
qualityHint?: string;
/** Default movie when omitted (backwards compatible). */
kind?: "movie" | "series";
seasonNumber?: number;
/** Expected episodes (required for series). */
episodes?: Array<{
episodeNumber: number;
episodeTitle?: string;
}>;
/** Optional sidecar subtitle written next to the video (single-episode series or movie). */
subtitle?: {
language: string;
extension?: string;
contentBase64: string;
};
}
export interface ImportMediaEpisodeResult {
episodeNumber: number;
importedPath: string;
subtitlePath?: string;
}
export interface ImportMediaAckPayload {
jobId: string;
ok: boolean;
importedPath?: string;
/** Absolute path of written sidecar, if any (movie / single episode) */
subtitlePath?: string;
/** Per-episode results for series imports */
episodes?: ImportMediaEpisodeResult[];
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;
}
/** 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;
}
export interface PlaybackSessionProgressPayload {
sessionId: string;
lastActivity: string;
bytesSent: number;
lastByteOffset: number;
fileSizeBytes: number;
}
export interface MediaTrackInfo {
type: string;
number?: number;
language?: string;
title?: string;
codec?: string;
}
export interface CreatePlaybackSessionAckPayload {
sessionId: string;
ok: boolean;
error?: string;
tracks?: MediaTrackInfo[];
}
export function createMessage<T>(
type: ControlMessageType,
payload: T,
messageId?: string
): ControlMessage<T> {
return {
protocolVersion: PROTOCOL_VERSION,
type,
messageId: messageId ?? randomUUID(),
timestamp: new Date().toISOString(),
payload,
};
}
export type CreatePlaybackSessionMessage = ControlMessage<CreatePlaybackSessionPayload>;
export type RevokePlaybackSessionMessage = ControlMessage<{ sessionId: string }>;