feat(apps): add ssh remote support for rsync

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-04 01:59:15 -07:00
parent d281580be5
commit 727ab47a7a
2 changed files with 105 additions and 17 deletions

View file

@ -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 {

View file

@ -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<string>([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<string>([file, ...upcoming.map(e => e.file)]);
let inBuffer: string[];
try {