feat(@applications/plum-control-mcp): add http bridge endpoint for tvanarchy

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 19:50:44 -07:00
parent af60797fbd
commit 4c8b5702f9
14 changed files with 776 additions and 2 deletions

View file

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

20
deploy/bridge.env.example Normal file
View file

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

35
deploy/install-bridge.sh Executable file
View file

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

View file

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

View file

@ -10,6 +10,7 @@
},
"scripts": {
"start": "bun run src/index.ts",
"bridge": "bun run src/http.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

52
src/bridge/artwork.ts Normal file
View file

@ -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" },
});
}

141
src/bridge/library.ts Normal file
View file

@ -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<string, IndexedEpisode>; byShow: Map<string, IndexedEpisode[]> } | null = null;
function rebuild(): NonNullable<typeof cache> {
const raw = scanLibrary();
const shows = raw.map(toWireShow);
const index = new Map<string, IndexedEpisode>();
const byShow = new Map<string, IndexedEpisode[]>();
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<typeof cache> {
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;
}

58
src/bridge/remote.ts Normal file
View file

@ -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 (0130)");
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) };
}

75
src/bridge/stream.ts Normal file
View file

@ -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<string, string> = {
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",
},
});
}

40
src/bridge/torrents.ts Normal file
View file

@ -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) };
}

131
src/bridge/watch.ts Normal file
View file

@ -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<string, WatchEvent> {
const out = new Map<string, WatchEvent>();
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 };
}

171
src/http.ts Normal file
View file

@ -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<Record<string, unknown>> {
try {
const body = await req.json();
return (body ?? {}) as Record<string, unknown>;
} 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<Response> {
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" : ""})`);

View file

@ -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")];

Binary file not shown.