feat(@applications/plum-control-mcp): ✨ add http bridge endpoint for tvanarchy
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
af60797fbd
commit
4c8b5702f9
14 changed files with 776 additions and 2 deletions
32
README.md
32
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 |
|
||||
|
|
|
|||
20
deploy/bridge.env.example
Normal file
20
deploy/bridge.env.example
Normal 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
35
deploy/install-bridge.sh
Executable 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
|
||||
16
deploy/tvanarchy-bridge.service
Normal file
16
deploy/tvanarchy-bridge.service
Normal 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
|
||||
|
|
@ -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
52
src/bridge/artwork.ts
Normal 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
141
src/bridge/library.ts
Normal 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
58
src/bridge/remote.ts
Normal 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 (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) };
|
||||
}
|
||||
75
src/bridge/stream.ts
Normal file
75
src/bridge/stream.ts
Normal 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
40
src/bridge/torrents.ts
Normal 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
131
src/bridge/watch.ts
Normal 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
171
src/http.ts
Normal 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" : ""})`);
|
||||
|
|
@ -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.
Loading…
Add table
Reference in a new issue