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" | "IMPORT_MEDIA" | "IMPORT_MEDIA_ACK" | "PLAYBACK_SESSION_ENDED" | "PLAYBACK_SESSION_PROGRESS" | "ERROR"; export interface ControlMessage { 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; } /** Master → node: move finished Download Station output into 4K library folder. */ export interface ImportMediaPayload { jobId: string; /** Absolute host path of the finished download folder/file */ sourcePath: string; /** Absolute host path for destination movie folder (Title (Year)) */ 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; /** Optional sidecar subtitle written next to the video */ subtitle?: { language: string; extension?: string; contentBase64: string; }; } export interface ImportMediaAckPayload { jobId: string; ok: boolean; importedPath?: string; error?: string; } export interface PlaybackSessionEndedPayload { sessionId: string; reason: string; } export interface PlaybackSessionProgressPayload { sessionId: string; lastActivity: string; bytesSent: number; lastByteOffset: number; fileSizeBytes: number; } export interface CreatePlaybackSessionAckPayload { sessionId: string; ok: boolean; error?: string; } export function createMessage( type: ControlMessageType, payload: T, messageId?: string ): ControlMessage { return { protocolVersion: PROTOCOL_VERSION, type, messageId: messageId ?? randomUUID(), timestamp: new Date().toISOString(), payload, }; } export type CreatePlaybackSessionMessage = ControlMessage; export type RevokePlaybackSessionMessage = ControlMessage<{ sessionId: string }>;