diff --git a/README.md b/README.md index dd472c9..4ce4161 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,38 @@ claude mcp add plum-control \ Or edit `~/.claude.json` directly with the same effect. +## HTTP bridge (`src/http.ts`) — for the TVAnarchy iOS app + +The same domain logic, exposed over plain HTTP/JSON for clients that can't speak +stdio JSON-RPC or shell out (the iOS app). `Bun.serve`, stderr logging. + +```sh +BRIDGE_PORT=8787 MEDIA_ROOTS=~/media bun run bridge # or: bun run src/http.ts +``` + +Routes: `GET /healthz`, `GET /library/shows`, `GET|HEAD /stream/:id` (HTTP Range), +`GET /artwork/:id` (ffmpeg frame-grab), `POST /watch/progress`, +`GET /watch/continue` (resume + next-episode for prefetch), `GET /watch/episode/:id`, +`GET /remote/status` + `POST /remote/command` + `POST /remote/play` (Black TV), +`GET /torrents`, `GET /torrents/search`, `POST /torrents`, `DELETE /torrents/:id`. + +Stream ids are base64url of the file path, re-validated under `MEDIA_ROOTS` on +every request (no arbitrary-file disclosure). Set `BRIDGE_TOKEN` on a +mesh-reachable host — it's required as a `Bearer` header (or `?token=` on media +URLs). Known limitation: `/remote/*` and `/torrents*` shell out to black over ssh +synchronously and briefly block the event loop; fine for personal single-user use. + +### Deploy on black + +```sh +# on black: +~/…/plum-control-mcp/deploy/install-bridge.sh # installs a systemd --user unit +# then edit ~/.config/tvanarchy-bridge/bridge.env (set BRIDGE_TOKEN) and: +systemctl --user restart tvanarchy-bridge +``` + +See `deploy/` for the unit, env template, and installer. + ## Env vars | Var | Default | Notes | diff --git a/deploy/bridge.env.example b/deploy/bridge.env.example new file mode 100644 index 0000000..4ef7721 --- /dev/null +++ b/deploy/bridge.env.example @@ -0,0 +1,20 @@ +# TVAnarchy bridge environment. Copy to ~/.config/tvanarchy-bridge/bridge.env +# (install-bridge.sh does this for you) and edit. + +# Bind to the WireGuard overlay IP so the iOS app reaches it off-LAN. +# Use 0.0.0.0 to also accept LAN connections. +BRIDGE_HOST=10.9.0.4 +BRIDGE_PORT=8787 + +# STRONGLY recommended on a mesh-reachable host. Set a long random value and put +# the same string in the iOS app: Settings -> Token. Without it, anyone who can +# reach this port can browse/stream the library. +BRIDGE_TOKEN= + +# Black's local media (no NFS hop when the bridge runs on black). +MEDIA_ROOTS=/bigdisk/_/media + +# The black-tv / transmission clients shell out over ssh. Running ON black, +# point them at localhost so they don't bounce through the overlay. +BLACK_SSH_HOST=lilith@localhost +BLACK_MEDIA_ROOT=/bigdisk/_/media diff --git a/deploy/install-bridge.sh b/deploy/install-bridge.sh new file mode 100755 index 0000000..830ac79 --- /dev/null +++ b/deploy/install-bridge.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Install the TVAnarchy bridge as a systemd --user service. Run this ON the host +# that should serve the bridge (black). Idempotent. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUN="$(command -v bun || true)" +[ -n "$BUN" ] || BUN="$HOME/.bun/bin/bun" +[ -x "$BUN" ] || { echo "bun not found (looked for 'bun' on PATH and $HOME/.bun/bin/bun)"; exit 1; } + +CFG_DIR="$HOME/.config/tvanarchy-bridge" +UNIT_DIR="$HOME/.config/systemd/user" +mkdir -p "$CFG_DIR" "$UNIT_DIR" + +ENV_FILE="$CFG_DIR/bridge.env" +if [ ! -f "$ENV_FILE" ]; then + cp "$REPO/deploy/bridge.env.example" "$ENV_FILE" + echo "Wrote $ENV_FILE — EDIT IT (set BRIDGE_TOKEN, confirm MEDIA_ROOTS) before relying on it." +fi + +sed -e "s#__BUN__#$BUN#g" \ + -e "s#__HTTP__#$REPO/src/http.ts#g" \ + -e "s#__ENV__#$ENV_FILE#g" \ + "$REPO/deploy/tvanarchy-bridge.service" > "$UNIT_DIR/tvanarchy-bridge.service" + +systemctl --user daemon-reload +systemctl --user enable --now tvanarchy-bridge.service +# Keep the service running when no user session is active. +loginctl enable-linger "$USER" >/dev/null 2>&1 || true + +echo +echo "Installed. Health check:" +echo " curl -s http://\$BRIDGE_HOST:\$BRIDGE_PORT/healthz" +echo "Point the iOS app (Settings) at this host's IP + port + token." +systemctl --user --no-pager status tvanarchy-bridge.service || true diff --git a/deploy/tvanarchy-bridge.service b/deploy/tvanarchy-bridge.service new file mode 100644 index 0000000..b5df6d4 --- /dev/null +++ b/deploy/tvanarchy-bridge.service @@ -0,0 +1,16 @@ +[Unit] +Description=TVAnarchy bridge — HTTP transport over plum-control-mcp for the iOS app +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=__ENV__ +ExecStart=__BUN__ run __HTTP__ +Restart=on-failure +RestartSec=5 +# Long video streams: don't let the watchdog reap a busy process. +TimeoutStopSec=20 + +[Install] +WantedBy=default.target diff --git a/package.json b/package.json index 16ffcd5..cdb151d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "start": "bun run src/index.ts", + "bridge": "bun run src/http.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/src/bridge/artwork.ts b/src/bridge/artwork.ts new file mode 100644 index 0000000..6ef95fb --- /dev/null +++ b/src/bridge/artwork.ts @@ -0,0 +1,52 @@ +// Poster/thumbnail for an episode: an ffmpeg frame-grab from the video itself. +// Self-contained (no TMDB / recommender dependency) — every episode gets art. +// Cached to disk keyed by stream id; ffmpeg runs once per episode. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveStreamId } from "./library.ts"; + +const CACHE_DIR = join(tmpdir(), "plum-control-bridge", "artwork"); + +function cachePath(id: string): string { + const hash = createHash("sha1").update(id).digest("hex"); + return join(CACHE_DIR, `${hash}.jpg`); +} + +function grabFrame(srcPath: string, outPath: string): boolean { + // Prefer a frame ~2 min in (past intros); fall back for short clips. + for (const ss of ["120", "20", "1"]) { + const r = spawnSync( + "ffmpeg", + ["-nostdin", "-y", "-ss", ss, "-i", srcPath, "-frames:v", "1", "-vf", "scale=480:-1", "-q:v", "3", outPath], + { timeout: 30_000 }, + ); + if (r.status === 0 && existsSync(outPath)) return true; + } + return false; +} + +function jsonError(message: string, status: number): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +export function artworkResponse(id: string): Response { + const real = resolveStreamId(id); + if (!real) return jsonError("not found", 404); + + mkdirSync(CACHE_DIR, { recursive: true }); + const out = cachePath(id); + if (!existsSync(out) && !grabFrame(real, out)) { + return jsonError("thumbnail generation failed", 500); + } + return new Response(Bun.file(out), { + headers: { "content-type": "image/jpeg", "cache-control": "max-age=86400" }, + }); +} diff --git a/src/bridge/library.ts b/src/bridge/library.ts new file mode 100644 index 0000000..b9971c8 --- /dev/null +++ b/src/bridge/library.ts @@ -0,0 +1,141 @@ +// Bridge library layer: wire models, opaque stream ids, the path guard, and an +// episode index keyed by id. Shared by the stream, artwork, and watch routes. + +import { realpathSync } from "node:fs"; +import { extname, sep } from "node:path"; + +import { scanLibrary, mediaRoots, VIDEO_EXT, type Show } from "../media/library.ts"; + +export interface WireEpisode { + id: string; + season: number; + episode: number; + label: string; + ext: string; +} + +export interface WireShow { + id: string; + name: string; + episodeCount: number; + seasons: number[]; + episodes: WireEpisode[]; +} + +/** A resolved episode: wire fields plus the on-disk path and owning show. */ +export interface IndexedEpisode { + id: string; + show: string; + showId: string; + season: number; + episode: number; + label: string; + path: string; +} + +/** Stream id = base64url of the absolute path. Opaque to clients, reversed only here. */ +export function encodeId(absPath: string): string { + return Buffer.from(absPath, "utf8").toString("base64url"); +} + +function toWireShow(s: Show): WireShow { + return { + id: encodeId(s.rootDir), + name: s.name, + episodeCount: s.episodes.length, + seasons: [...new Set(s.episodes.map(e => e.season))].sort((a, b) => a - b), + episodes: s.episodes.map(e => ({ + id: encodeId(e.path), + season: e.season, + episode: e.episode, + label: e.label, + ext: extname(e.path).slice(1).toLowerCase(), + })), + }; +} + +// ── cache ────────────────────────────────────────────────────────────────── +// scanLibrary() walks the NFS mount; cache briefly so browse / continue-watching +// / prefetch don't each re-walk. Both the wire form and the id index derive from +// the same snapshot so they never disagree. +const TTL_MS = 60_000; +let cache: { at: number; shows: WireShow[]; index: Map; byShow: Map } | null = null; + +function rebuild(): NonNullable { + const raw = scanLibrary(); + const shows = raw.map(toWireShow); + const index = new Map(); + const byShow = new Map(); + for (const s of raw) { + const showId = encodeId(s.rootDir); + const list: IndexedEpisode[] = []; + for (const e of s.episodes) { + const ie: IndexedEpisode = { + id: encodeId(e.path), + show: s.name, + showId, + season: e.season, + episode: e.episode, + label: e.label, + path: e.path, + }; + index.set(ie.id, ie); + list.push(ie); + } + byShow.set(s.name, list); + } + return { at: Date.now(), shows, index, byShow }; +} + +function ensure(force: boolean): NonNullable { + if (!force && cache && Date.now() - cache.at < TTL_MS) return cache; + cache = rebuild(); + return cache; +} + +export function libraryShows(force: boolean): WireShow[] { + return ensure(force).shows; +} + +/** Resolve a stream id to its indexed episode (library-derived), or null. */ +export function findEpisode(id: string): IndexedEpisode | null { + return ensure(false).index.get(id) ?? null; +} + +/** All episodes of a show by its display name, sorted (for resume/prefetch). */ +export function episodesOfShow(showName: string): IndexedEpisode[] { + return ensure(false).byShow.get(showName) ?? []; +} + +/** + * Decode a stream id to a real path, but only if it resolves (symlinks followed) + * to a video file under a configured media root. The sole guard between a public + * id and arbitrary-file disclosure — deliberately strict. + */ +export function resolveStreamId(id: string): string | null { + let decoded: string; + try { + decoded = Buffer.from(id, "base64url").toString("utf8"); + } catch { + return null; + } + if (decoded.length === 0) return null; + + let real: string; + try { + real = realpathSync(decoded); + } catch { + return null; + } + if (!VIDEO_EXT.test(real)) return null; + + const roots: string[] = []; + for (const r of mediaRoots()) { + try { + roots.push(realpathSync(r)); + } catch { + // a configured root that doesn't exist (mount down) just can't match + } + } + return roots.some(root => real === root || real.startsWith(root + sep)) ? real : null; +} diff --git a/src/bridge/remote.ts b/src/bridge/remote.ts new file mode 100644 index 0000000..466d8bf --- /dev/null +++ b/src/bridge/remote.ts @@ -0,0 +1,58 @@ +// Remote transport: control the Black TV (mpv on black's HDMI console) from the +// app. Thin wrapper over the existing black-tv SSH client — all playback +// intelligence stays in black-tv on black. + +import { + blackStatus, + blackTogglePause, + blackResume, + blackSetVolume, + blackSeekRelative, + blackNext, + blackPrevious, + blackStop, + blackPlayShow, + type BlackStatus, +} from "../blacktv/client.ts"; + +export interface RemoteCommand { + action: "playpause" | "resume" | "stop" | "next" | "prev" | "volume" | "seek"; + value?: number; +} + +export function remoteStatus(): BlackStatus { + return blackStatus(); +} + +export function remoteCommand(cmd: RemoteCommand): { ok: true; out: string } { + let out = ""; + switch (cmd.action) { + case "playpause": out = blackTogglePause(); break; + case "resume": out = blackResume(); break; + case "stop": out = blackStop(); break; + case "next": out = blackNext(); break; + case "prev": out = blackPrevious(); break; + case "volume": + if (cmd.value === undefined) throw new Error("volume requires a value (0–130)"); + out = blackSetVolume(cmd.value); + break; + case "seek": + if (cmd.value === undefined) throw new Error("seek requires a value (relative seconds)"); + out = blackSeekRelative(cmd.value); + break; + default: + throw new Error(`unknown action: ${(cmd as { action: string }).action}`); + } + return { ok: true, out }; +} + +export interface RemotePlay { + show: string; + season?: number; + episode?: number; +} + +export function remotePlay(p: RemotePlay): { ok: true; out: string } { + if (!p.show) throw new Error("show required"); + return { ok: true, out: blackPlayShow(p.show, p.season, p.episode) }; +} diff --git a/src/bridge/stream.ts b/src/bridge/stream.ts new file mode 100644 index 0000000..85c3e8e --- /dev/null +++ b/src/bridge/stream.ts @@ -0,0 +1,75 @@ +// Raw-file streaming with explicit HTTP Range handling. Deterministic and +// curl-testable rather than relying on Bun's implicit BunFile range behaviour. +// MobileVLCKit seeks by issuing byte ranges against this. + +import { extname } from "node:path"; + +const CONTENT_TYPES: Record = { + mkv: "video/x-matroska", + mp4: "video/mp4", + m4v: "video/x-m4v", + avi: "video/x-msvideo", + mov: "video/quicktime", + webm: "video/webm", +}; + +function contentTypeFor(path: string): string { + return CONTENT_TYPES[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream"; +} + +function rangeNotSatisfiable(size: number): Response { + return new Response(null, { + status: 416, + headers: { "content-range": `bytes */${size}`, "accept-ranges": "bytes" }, + }); +} + +export function streamResponse(req: Request, path: string): Response { + const file = Bun.file(path); + const size = file.size; + const type = contentTypeFor(path); + const isHead = req.method === "HEAD"; + const rangeHeader = req.headers.get("range"); + + if (!rangeHeader) { + return new Response(isHead ? null : file, { + headers: { + "content-type": type, + "content-length": String(size), + "accept-ranges": "bytes", + }, + }); + } + + const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!m) return rangeNotSatisfiable(size); + + const startRaw = m[1] ?? ""; + const endRaw = m[2] ?? ""; + let start: number; + let end: number; + if (startRaw === "") { + const n = parseInt(endRaw, 10); // suffix range: bytes=-N → last N bytes + if (!Number.isFinite(n) || n <= 0) return rangeNotSatisfiable(size); + start = Math.max(0, size - n); + end = size - 1; + } else { + start = parseInt(startRaw, 10); + end = endRaw === "" ? size - 1 : parseInt(endRaw, 10); + } + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) { + return rangeNotSatisfiable(size); + } + end = Math.min(end, size - 1); + + const chunk = file.slice(start, end + 1); + return new Response(isHead ? null : chunk, { + status: 206, + headers: { + "content-type": type, + "content-range": `bytes ${start}-${end}/${size}`, + "content-length": String(end - start + 1), + "accept-ranges": "bytes", + }, + }); +} diff --git a/src/bridge/torrents.ts b/src/bridge/torrents.ts new file mode 100644 index 0000000..feb453f --- /dev/null +++ b/src/bridge/torrents.ts @@ -0,0 +1,40 @@ +// Downloads: search + transmission management on black. Thin wrapper over the +// existing transmission client + torrent search. + +import { + transmissionListRich, + transmissionAdd, + transmissionRemove, + type RichTorrent, +} from "../transmission/client.ts"; +import { searchTorrents, type TorrentResult } from "../transmission/search.ts"; + +// Allowlisted so a stray category can't craft an arbitrary -w path on black. +const ALLOWED_CATEGORIES = ["tv", "anime", "movies", "cartoons", "collections", "unsorted", "misc", "porn"]; + +export function torrentList(): RichTorrent[] { + return transmissionListRich(); +} + +export function torrentSearch(query: string, limit: number): TorrentResult[] { + if (!query) throw new Error("query required"); + return searchTorrents(query, Number.isFinite(limit) && limit > 0 ? limit : 15); +} + +export function torrentAdd(magnet: string, category?: string): { ok: true; out: string } { + if (!magnet) throw new Error("magnet or URL required"); + let downloadDir: string | undefined; + if (category) { + if (!ALLOWED_CATEGORIES.includes(category)) { + throw new Error(`invalid category: ${category}. Allowed: ${ALLOWED_CATEGORIES.join(", ")}`); + } + const root = process.env["BLACK_MEDIA_ROOT"] ?? "/bigdisk/_/media"; + downloadDir = `${root}/${category}`; + } + return { ok: true, out: transmissionAdd(magnet, downloadDir) }; +} + +export function torrentRemove(id: number, deleteData: boolean): { ok: true; out: string } { + if (!Number.isFinite(id)) throw new Error("valid numeric id required"); + return { ok: true, out: transmissionRemove(id, deleteData) }; +} diff --git a/src/bridge/watch.ts b/src/bridge/watch.ts new file mode 100644 index 0000000..ff64de4 --- /dev/null +++ b/src/bridge/watch.ts @@ -0,0 +1,131 @@ +// Watch progress for in-app playback. Records into the same append-only watch +// log the rest of plum-control-mcp uses, and derives continue-watching + the +// next-episode target that drives the iOS app's prefetch-ahead policy. + +import { recordWatch, readWatchLog, type WatchEvent } from "../media/watchlog.ts"; +import { findEpisode, episodesOfShow } from "./library.ts"; + +/** At/above this fraction of the runtime an episode counts as finished. */ +const FINISHED_FRACTION = 0.92; + +export interface ProgressInput { + episodeId: string; + positionSeconds: number; + durationSeconds?: number; + finished?: boolean; +} + +function isFinished(positionSeconds: number, durationSeconds: number | undefined, explicit: boolean | undefined): boolean { + if (explicit !== undefined) return explicit; + if (durationSeconds && durationSeconds > 0) return positionSeconds >= durationSeconds * FINISHED_FRACTION; + return false; +} + +export function recordProgress(input: ProgressInput): { ok: true } { + const ep = findEpisode(input.episodeId); + if (!ep) throw new Error("unknown episodeId"); + const finished = isFinished(input.positionSeconds, input.durationSeconds, input.finished); + recordWatch({ + event: finished ? "play" : "resume", + show: ep.show, + season: ep.season, + episode: ep.episode, + label: ep.label, + path: ep.path, + resumeSeconds: Math.max(0, Math.floor(input.positionSeconds)), + durationSeconds: input.durationSeconds, + finished, + }); + return { ok: true }; +} + +export interface ResumePoint { + episodeId: string; + season: number; + episode: number; + label: string; + positionSeconds: number; + durationSeconds?: number; +} + +export interface ContinueItem { + show: string; + showId: string; + /** Where to drop the user back in: mid-episode if unfinished, else next from 0. */ + resume: ResumePoint | null; + /** The episode after `resume` — the app keeps this (and beyond) downloaded. */ + next: { episodeId: string; season: number; episode: number; label: string } | null; + lastWatched: string; +} + +function latestEventPerShow(events: WatchEvent[]): Map { + const out = new Map(); + for (const e of events) { + const cur = out.get(e.show); + if (!cur || e.ts > cur.ts) out.set(e.show, e); + } + return out; +} + +export function continueWatching(): ContinueItem[] { + const events = readWatchLog(); + const items: ContinueItem[] = []; + + for (const [show, ev] of latestEventPerShow(events)) { + const eps = episodesOfShow(show); + if (eps.length === 0) continue; // show no longer in the library + const idx = eps.findIndex(e => e.season === ev.season && e.episode === ev.episode); + if (idx < 0) continue; + + const finished = isFinished(ev.resumeSeconds ?? 0, ev.durationSeconds, ev.finished); + let resume: ResumePoint | null; + let nextForPrefetch: typeof eps[number] | undefined; + + if (!finished) { + const cur = eps[idx]!; + resume = { + episodeId: cur.id, + season: cur.season, + episode: cur.episode, + label: cur.label, + positionSeconds: ev.resumeSeconds ?? 0, + durationSeconds: ev.durationSeconds, + }; + nextForPrefetch = eps[idx + 1]; + } else { + const nxt = eps[idx + 1]; + resume = nxt + ? { episodeId: nxt.id, season: nxt.season, episode: nxt.episode, label: nxt.label, positionSeconds: 0 } + : null; + nextForPrefetch = eps[idx + 2]; + } + + items.push({ + show, + showId: eps[idx]!.showId, + resume, + next: nextForPrefetch + ? { episodeId: nextForPrefetch.id, season: nextForPrefetch.season, episode: nextForPrefetch.episode, label: nextForPrefetch.label } + : null, + lastWatched: ev.ts, + }); + } + + return items.sort((a, b) => (b.lastWatched > a.lastWatched ? 1 : -1)); +} + +/** Resume position for one episode (so opening it drops back in). */ +export function resumeFor(episodeId: string): { positionSeconds: number } { + const ep = findEpisode(episodeId); + if (!ep) return { positionSeconds: 0 }; + let latest: WatchEvent | null = null; + for (const e of readWatchLog()) { + if (e.show === ep.show && e.season === ep.season && e.episode === ep.episode) { + if (!latest || e.ts > latest.ts) latest = e; + } + } + if (!latest || isFinished(latest.resumeSeconds ?? 0, latest.durationSeconds, latest.finished)) { + return { positionSeconds: 0 }; + } + return { positionSeconds: latest.resumeSeconds ?? 0 }; +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..50e42bf --- /dev/null +++ b/src/http.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env bun +// plum-control-bridge — HTTP/JSON transport over the same domain logic the MCP +// server exposes, for the TVAnarchy iOS app (which can't speak stdio JSON-RPC +// or shell out). Reuses the library scanner, black-tv / VLC / transmission +// clients, and the watch log. Logs to stderr; stdout stays clean. +// +// Run: BRIDGE_PORT=8787 MEDIA_ROOTS=~/media bun run src/http.ts +// +// Routes +// GET /healthz reachability (always open) +// GET /library/shows[?refresh=1] shows + episodes (60s cache) +// GET|HEAD /stream/:id raw file, HTTP Range +// GET /artwork/:id ffmpeg frame-grab thumbnail (jpeg) +// POST /watch/progress record playback position +// GET /watch/continue continue-watching + next-episode (prefetch) +// GET /watch/episode/:id resume position for one episode +// GET /remote/status Black TV status +// POST /remote/command {action,value?} transport on Black TV +// POST /remote/play {show,season?,episode?} +// GET /torrents transmission list +// GET /torrents/search?q=&limit= torrent search +// POST /torrents {magnet,category?} add +// DELETE /torrents/:id[?delete=1] remove + +import { mediaRoots } from "./media/library.ts"; +import { libraryShows, resolveStreamId } from "./bridge/library.ts"; +import { streamResponse } from "./bridge/stream.ts"; +import { artworkResponse } from "./bridge/artwork.ts"; +import { recordProgress, continueWatching, resumeFor } from "./bridge/watch.ts"; +import { remoteStatus, remoteCommand, remotePlay } from "./bridge/remote.ts"; +import { torrentList, torrentSearch, torrentAdd, torrentRemove } from "./bridge/torrents.ts"; +import { log } from "./log.ts"; + +const PORT = Number(process.env["BRIDGE_PORT"] ?? 8787); +const HOST = process.env["BRIDGE_HOST"] ?? "0.0.0.0"; +const TOKEN = process.env["BRIDGE_TOKEN"] ?? ""; + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function fail(err: unknown, status = 500): Response { + return json({ error: err instanceof Error ? err.message : String(err) }, status); +} + +async function readJson(req: Request): Promise> { + try { + const body = await req.json(); + return (body ?? {}) as Record; + } catch { + return {}; + } +} + +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +function authorized(req: Request, url: URL): boolean { + if (TOKEN.length === 0) return true; + if (req.headers.get("authorization") === `Bearer ${TOKEN}`) return true; + return url.searchParams.get("token") === TOKEN; // stream/artwork URLs carry it as a query +} + +const server = Bun.serve({ + port: PORT, + hostname: HOST, + idleTimeout: 0, // long video streams must not be reaped mid-playback + async fetch(req): Promise { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + if (path === "/healthz") { + return json({ ok: true, service: "plum-control-bridge", roots: mediaRoots() }); + } + if (!authorized(req, url)) return json({ error: "unauthorized" }, 401); + + try { + // ── library + media ──────────────────────────────────────────────── + if (path === "/" && method === "GET") { + return json({ service: "plum-control-bridge", ok: true }); + } + if (path === "/library/shows" && method === "GET") { + return json({ shows: libraryShows(url.searchParams.get("refresh") === "1") }); + } + if (path.startsWith("/stream/") && (method === "GET" || method === "HEAD")) { + const real = resolveStreamId(decodeURIComponent(path.slice("/stream/".length))); + return real ? streamResponse(req, real) : json({ error: "not found" }, 404); + } + if (path.startsWith("/artwork/") && method === "GET") { + return artworkResponse(decodeURIComponent(path.slice("/artwork/".length))); + } + + // ── watch progress ───────────────────────────────────────────────── + if (path === "/watch/progress" && method === "POST") { + const b = await readJson(req); + const episodeId = str(b["episodeId"]); + const positionSeconds = num(b["positionSeconds"]); + if (!episodeId || positionSeconds === undefined) return fail("episodeId and positionSeconds required", 400); + return json(recordProgress({ + episodeId, + positionSeconds, + durationSeconds: num(b["durationSeconds"]), + finished: typeof b["finished"] === "boolean" ? (b["finished"] as boolean) : undefined, + })); + } + if (path === "/watch/continue" && method === "GET") { + return json({ items: continueWatching() }); + } + if (path.startsWith("/watch/episode/") && method === "GET") { + return json(resumeFor(decodeURIComponent(path.slice("/watch/episode/".length)))); + } + + // ── remote transport (Black TV) ──────────────────────────────────── + if (path === "/remote/status" && method === "GET") { + return json(remoteStatus()); + } + if (path === "/remote/command" && method === "POST") { + const b = await readJson(req); + const action = str(b["action"]); + if (!action) return fail("action required", 400); + return json(remoteCommand({ action: action as never, value: num(b["value"]) })); + } + if (path === "/remote/play" && method === "POST") { + const b = await readJson(req); + const show = str(b["show"]); + if (!show) return fail("show required", 400); + return json(remotePlay({ show, season: num(b["season"]), episode: num(b["episode"]) })); + } + + // ── downloads ────────────────────────────────────────────────────── + if (path === "/torrents/search" && method === "GET") { + const q = url.searchParams.get("q") ?? ""; + const limit = Number(url.searchParams.get("limit") ?? 15); + return json({ results: torrentSearch(q, limit) }); + } + if (path === "/torrents" && method === "GET") { + return json({ torrents: torrentList() }); + } + if (path === "/torrents" && method === "POST") { + const b = await readJson(req); + const magnet = str(b["magnet"]); + if (!magnet) return fail("magnet required", 400); + return json(torrentAdd(magnet, str(b["category"]))); + } + if (path.startsWith("/torrents/") && method === "DELETE") { + const id = Number(decodeURIComponent(path.slice("/torrents/".length))); + return json(torrentRemove(id, url.searchParams.get("delete") === "1")); + } + + return json({ error: "not found" }, 404); + } catch (err) { + log.error(`${method} ${path}: ${err instanceof Error ? err.message : String(err)}`); + return fail(err); + } + }, + error(err): Response { + log.error(`server error: ${err.message}`); + return fail(err); + }, +}); + +log.info(`bridge listening on http://${HOST}:${server.port} (roots: ${mediaRoots().join(", ")}${TOKEN ? ", auth: on" : ""})`); diff --git a/src/media/library.ts b/src/media/library.ts index 3821e03..7c87ed6 100644 --- a/src/media/library.ts +++ b/src/media/library.ts @@ -15,7 +15,8 @@ import { readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, sep } from "node:path"; -const VIDEO_EXT = /\.(mkv|mp4|m4v|avi|mov|webm)$/i; +/** Video extensions we index and stream. Exported for the HTTP bridge's path guard. */ +export const VIDEO_EXT = /\.(mkv|mp4|m4v|avi|mov|webm)$/i; const SXXEYY = /S(\d{1,2})E(\d{1,3})/i; export interface Episode { @@ -34,7 +35,8 @@ export interface Show { episodes: Episode[]; } -function mediaRoots(): string[] { +/** Configured media roots (colon-separated MEDIA_ROOTS, default ~/media). Exported for the bridge's path guard. */ +export function mediaRoots(): string[] { const env = process.env["MEDIA_ROOTS"]; if (env && env.length > 0) return env.split(":").filter(s => s.length > 0); return [join(homedir(), "media")]; diff --git a/src/media/watchlog.ts b/src/media/watchlog.ts index 0af3f08..dec9638 100644 Binary files a/src/media/watchlog.ts and b/src/media/watchlog.ts differ