feat(apps): ✨ add ssh remote support for rsync
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d281580be5
commit
727ab47a7a
2 changed files with 105 additions and 17 deletions
|
|
@ -13,7 +13,7 @@ interface VlcHttp {
|
||||||
// { "vlcHttp": { "host": "127.0.0.1", "port": 8080, "password": "..." } }
|
// { "vlcHttp": { "host": "127.0.0.1", "port": 8080, "password": "..." } }
|
||||||
// Returns null if the file is missing or has no password — the caller then
|
// Returns null if the file is missing or has no password — the caller then
|
||||||
// treats VLC as unreachable rather than crashing.
|
// treats VLC as unreachable rather than crashing.
|
||||||
function vlcHttpConfig(): VlcHttp | null {
|
export function vlcHttpConfig(): VlcHttp | null {
|
||||||
try {
|
try {
|
||||||
const file = join(homedir(), ".config", "portable-net-tv", "config.json");
|
const file = join(homedir(), ".config", "portable-net-tv", "config.json");
|
||||||
const raw = JSON.parse(readFileSync(file, "utf8")) as {
|
const raw = JSON.parse(readFileSync(file, "utf8")) as {
|
||||||
|
|
|
||||||
120
src/watch.ts
120
src/watch.ts
|
|
@ -12,12 +12,12 @@
|
||||||
// touching VLC.
|
// touching VLC.
|
||||||
|
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
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 { homedir } from "node:os";
|
||||||
import { basename, dirname, join } from "node:path";
|
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 { parseEpisode, recordWatch } from "./watchlog.ts";
|
||||||
import { freeBytes } from "./rsync.ts";
|
import { freeBytes, listRemote, pullOne, remoteSize } from "./rsync.ts";
|
||||||
import { log } from "./log.ts";
|
import { log } from "./log.ts";
|
||||||
|
|
||||||
const POLL_MS = 30_000;
|
const POLL_MS = 30_000;
|
||||||
|
|
@ -29,6 +29,10 @@ interface BufferConfig {
|
||||||
dir: string;
|
dir: string;
|
||||||
ahead: number;
|
ahead: number;
|
||||||
minFreeGB: 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 {
|
function bufferConfig(): BufferConfig {
|
||||||
|
|
@ -44,10 +48,13 @@ function bufferConfig(): BufferConfig {
|
||||||
};
|
};
|
||||||
const b = raw.buffer;
|
const b = raw.buffer;
|
||||||
if (!b) return fallback;
|
if (!b) return fallback;
|
||||||
|
const raw2 = raw as { buffer?: unknown; src?: unknown; episodeGlob?: unknown };
|
||||||
return {
|
return {
|
||||||
dir: typeof b.dir === "string" && b.dir.length > 0 ? b.dir : fallback.dir,
|
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,
|
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,
|
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 {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|
@ -161,12 +168,56 @@ function isCompleteLocal(remotePath: string, localPath: string): boolean {
|
||||||
return sizeOf(localPath) === rs;
|
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.
|
// Last filename appended to the watch log, so each change is logged once.
|
||||||
let lastLogged: string | null = null;
|
let lastLogged: string | null = null;
|
||||||
|
|
||||||
function tick(cfg: BufferConfig): void {
|
function tick(cfg: BufferConfig): void {
|
||||||
const path = currentPath();
|
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 file = basename(path);
|
||||||
const cur = parseEpisode(file);
|
const cur = parseEpisode(file);
|
||||||
|
|
@ -185,19 +236,57 @@ function tick(cfg: BufferConfig): void {
|
||||||
lastLogged = file;
|
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 });
|
mkdirSync(cfg.dir, { recursive: true });
|
||||||
const playlist = playlistPaths();
|
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) {
|
for (const ep of upcoming) {
|
||||||
const dest = join(cfg.dir, ep.file);
|
const dest = join(cfg.dir, ep.file);
|
||||||
if (!isCompleteLocal(ep.path, dest)) {
|
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
|
// GC: keep the currently-playing file AND the prefetch window.
|
||||||
// the current entry, GC would happily delete whatever VLC is reading.
|
|
||||||
const keep = new Set<string>([file, ...upcoming.map(e => e.file)]);
|
const keep = new Set<string>([file, ...upcoming.map(e => e.file)]);
|
||||||
let inBuffer: string[];
|
let inBuffer: string[];
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue