diff --git a/src/vlc.ts b/src/vlc.ts index 15cb34b..a043ad1 100644 --- a/src/vlc.ts +++ b/src/vlc.ts @@ -13,7 +13,7 @@ interface VlcHttp { // { "vlcHttp": { "host": "127.0.0.1", "port": 8080, "password": "..." } } // Returns null if the file is missing or has no password — the caller then // treats VLC as unreachable rather than crashing. -function vlcHttpConfig(): VlcHttp | null { +export function vlcHttpConfig(): VlcHttp | null { try { const file = join(homedir(), ".config", "portable-net-tv", "config.json"); const raw = JSON.parse(readFileSync(file, "utf8")) as { diff --git a/src/watch.ts b/src/watch.ts index 8b14017..c55ae30 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -12,12 +12,12 @@ // touching VLC. import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; -import { currentPath, enqueuePath, playlistPaths } from "./vlc.ts"; +import { currentPath, enqueuePath, isRunning, playlistPaths, vlcHttpConfig } from "./vlc.ts"; import { parseEpisode, recordWatch } from "./watchlog.ts"; -import { freeBytes } from "./rsync.ts"; +import { freeBytes, listRemote, pullOne, remoteSize } from "./rsync.ts"; import { log } from "./log.ts"; const POLL_MS = 30_000; @@ -29,6 +29,10 @@ interface BufferConfig { dir: string; ahead: number; minFreeGB: number; + // SSH source: "user@host:/path/to/season" — when set, episodes are fetched + // via rsync-over-ssh rather than read from a local/NFS path. + src?: string; + episodeGlob?: string; } function bufferConfig(): BufferConfig { @@ -44,10 +48,13 @@ function bufferConfig(): BufferConfig { }; const b = raw.buffer; if (!b) return fallback; + const raw2 = raw as { buffer?: unknown; src?: unknown; episodeGlob?: unknown }; return { dir: typeof b.dir === "string" && b.dir.length > 0 ? b.dir : fallback.dir, ahead: typeof b.ahead === "number" && b.ahead > 0 ? Math.floor(b.ahead) : fallback.ahead, minFreeGB: typeof b.minFreeGB === "number" && b.minFreeGB >= 0 ? b.minFreeGB : fallback.minFreeGB, + src: typeof raw2.src === "string" && raw2.src.length > 0 ? raw2.src : undefined, + episodeGlob: typeof raw2.episodeGlob === "string" && raw2.episodeGlob.length > 0 ? raw2.episodeGlob : "*.mp4", }; } catch { return fallback; @@ -161,12 +168,56 @@ function isCompleteLocal(remotePath: string, localPath: string): boolean { return sizeOf(localPath) === rs; } +// Launch VLC with HTTP interface + TV fullscreen positioning. +function launchVlcHttp(files: string[]): void { + if (files.length === 0) return; + const cfg = vlcHttpConfig(); + if (!cfg) return; + const args = [ + "--extraintf", "http", + "--http-host", cfg.host, + "--http-port", String(cfg.port), + "--http-password", cfg.password, + "--no-loop", "--no-repeat", + "--file-caching=10000", + ...files, + ]; + const child = spawn("/Applications/VLC.app/Contents/MacOS/VLC", args, { + stdio: "ignore", + detached: true, + }); + child.unref(); + // Wait for VLC to open, then move to TV and fullscreen. + spawnSync("sleep", ["4"]); + spawnSync("osascript", ["-e", ` + tell application "VLC" + activate + set bounds of window 1 to {1810, 127, 3530, 1007} + delay 0.5 + set fullscreen mode to true + delay 0.3 + play + end tell + `]); +} + // Last filename appended to the watch log, so each change is logged once. let lastLogged: string | null = null; function tick(cfg: BufferConfig): void { const path = currentPath(); - if (path === null) return; // VLC not running / nothing playing + + // VLC stopped or not running — try to restart from buffer if we have files. + if (path === null) { + if (!isRunning()) { + const buffered = readdirSync(cfg.dir).filter(n => VIDEO.test(n)).sort(); + if (buffered.length > 0) { + log.info(`VLC stopped — restarting with ${buffered[0]}`); + launchVlcHttp(buffered.map(f => join(cfg.dir, f))); + } + } + return; + } const file = basename(path); const cur = parseEpisode(file); @@ -185,19 +236,57 @@ function tick(cfg: BufferConfig): void { lastLogged = file; } - const upcoming = findUpcomingEpisodes(path, cfg.ahead); - - // Prefetch the window AND auto-queue interleaved — each episode is - // enqueued into VLC's playlist the moment its prefetch finishes, instead - // of waiting for the entire window. Critical on a slow link where one - // rsync can take minutes; otherwise nothing gets queued until the whole - // window finishes (~ahead × per-ep time later). - // - // `--inplace` so a partial file is a true byte prefix (clean resume) and - // so VLC can read a copy as it grows. mkdirSync(cfg.dir, { recursive: true }); const playlist = playlistPaths(); + if (cfg.src) { + // SSH-source mode: list remote episodes, rsync via SSH. + const glob = cfg.episodeGlob ?? "*.mp4"; + const allRemote = listRemote(cfg.src, glob); + const curIdx = allRemote.indexOf(file); + const window = curIdx >= 0 + ? allRemote.slice(curIdx + 1, curIdx + 1 + cfg.ahead) + : allRemote.slice(0, cfg.ahead); + + for (const epFile of window) { + const dest = join(cfg.dir, epFile); + const rSize = remoteSize(cfg.src, epFile); + const complete = rSize !== null && existsSync(dest) && sizeOf(dest) === rSize; + if (!complete) { + if (freeBytes(cfg.dir) < cfg.minFreeGB * GB) { + log.warn(`free disk below ${cfg.minFreeGB}GB floor — holding prefetch`); + break; + } + log.info(`prefetch ${epFile}`); + if (!pullOne(cfg.src, epFile, cfg.dir)) { + log.warn(`rsync failed for ${epFile} — will retry next tick`); + break; + } + } + if (playlist !== null && existsSync(dest) && !playlist.has(dest)) { + if (enqueuePath(dest)) { + log.info(`queued in VLC: ${epFile}`); + playlist.add(dest); + } + } + } + + // GC: keep current + window + anything already queued in VLC so we don't + // delete files that are in the playlist but haven't been played yet. + const keep = new Set([file, ...window]); + if (playlist !== null) { + for (const p of playlist) keep.add(basename(p)); + } + for (const name of readdirSync(cfg.dir)) { + if (!VIDEO.test(name) || keep.has(name)) continue; + try { unlinkSync(join(cfg.dir, name)); log.info(`gc ${name}`); } catch { /* gone */ } + } + return; + } + + // Local/NFS-source mode (original behaviour). + const upcoming = findUpcomingEpisodes(path, cfg.ahead); + for (const ep of upcoming) { const dest = join(cfg.dir, ep.file); if (!isCompleteLocal(ep.path, dest)) { @@ -220,8 +309,7 @@ function tick(cfg: BufferConfig): void { } } - // GC: keep the currently-playing file AND the prefetch window — without - // the current entry, GC would happily delete whatever VLC is reading. + // GC: keep the currently-playing file AND the prefetch window. const keep = new Set([file, ...upcoming.map(e => e.file)]); let inBuffer: string[]; try {