From 738415710c71cfd542f6e85ce56e3b0ab3e455a2 Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 1 Oct 2026 14:53:35 +0200 Subject: [PATCH] The media chain's stale copies leave the catalogue bazarr, bookshelf, lidarr, nzbget, ombi, plex, qbittorrent, radarr, sonarr and tautulli live in novox/mesh-media-catalog (#195 moved jackett and left these behind). A build of this repository at a commit today registered plex and nzbget from these copies, which carry no provides, and ace's plan stopped resolving. Nothing here depends on the directories: home-assistant consumes their provisions by name. --- modules/bazarr/Dockerfile | 24 ---- modules/bazarr/client.ts | 173 -------------------------- modules/bazarr/index.ts | 47 -------- modules/bazarr/module.json | 145 ---------------------- modules/bazarr/package.json | 14 --- modules/bazarr/tools/index.ts | 57 --------- modules/bazarr/tsconfig.json | 12 -- modules/bookshelf/Dockerfile | 24 ---- modules/bookshelf/client.ts | 136 --------------------- modules/bookshelf/index.ts | 74 ------------ modules/bookshelf/module.json | 120 ------------------ modules/bookshelf/package.json | 14 --- modules/bookshelf/tools/index.ts | 69 ----------- modules/bookshelf/tsconfig.json | 12 -- modules/lidarr/Dockerfile | 24 ---- modules/lidarr/client.ts | 144 ---------------------- modules/lidarr/index.ts | 69 ----------- modules/lidarr/module.json | 119 ------------------ modules/lidarr/package.json | 14 --- modules/lidarr/tools/index.ts | 78 ------------ modules/lidarr/tsconfig.json | 12 -- modules/nzbget/Dockerfile | 24 ---- modules/nzbget/client.ts | 178 --------------------------- modules/nzbget/index.ts | 64 ---------- modules/nzbget/module.json | 117 ------------------ modules/nzbget/package.json | 14 --- modules/nzbget/tools/index.ts | 88 -------------- modules/nzbget/tsconfig.json | 12 -- modules/ombi/Dockerfile | 24 ---- modules/ombi/client.ts | 122 ------------------- modules/ombi/index.ts | 58 --------- modules/ombi/module.json | 120 ------------------ modules/ombi/package.json | 14 --- modules/ombi/tools/index.ts | 46 ------- modules/ombi/tsconfig.json | 12 -- modules/plex/Dockerfile | 24 ---- modules/plex/client.ts | 158 ------------------------ modules/plex/index.ts | 68 ----------- modules/plex/module.json | 137 --------------------- modules/plex/package.json | 14 --- modules/plex/tools/index.ts | 74 ------------ modules/plex/tsconfig.json | 12 -- modules/qbittorrent/Dockerfile | 24 ---- modules/qbittorrent/client.ts | 188 ----------------------------- modules/qbittorrent/index.ts | 52 -------- modules/qbittorrent/module.json | 118 ------------------ modules/qbittorrent/package.json | 14 --- modules/qbittorrent/tools/index.ts | 98 --------------- modules/qbittorrent/tsconfig.json | 12 -- modules/radarr/Dockerfile | 24 ---- modules/radarr/client.ts | 144 ---------------------- modules/radarr/index.ts | 69 ----------- modules/radarr/module.json | 119 ------------------ modules/radarr/package.json | 14 --- modules/radarr/tools/index.ts | 78 ------------ modules/radarr/tsconfig.json | 12 -- modules/sonarr/Dockerfile | 24 ---- modules/sonarr/client.ts | 144 ---------------------- modules/sonarr/index.ts | 69 ----------- modules/sonarr/module.json | 125 ------------------- modules/sonarr/package.json | 14 --- modules/sonarr/tools/index.ts | 78 ------------ modules/sonarr/tsconfig.json | 12 -- modules/tautulli/Dockerfile | 24 ---- modules/tautulli/client.ts | 108 ----------------- modules/tautulli/index.ts | 48 -------- modules/tautulli/module.json | 116 ------------------ modules/tautulli/package.json | 14 --- modules/tautulli/tools/index.ts | 44 ------- modules/tautulli/tsconfig.json | 12 -- 70 files changed, 4559 deletions(-) delete mode 100644 modules/bazarr/Dockerfile delete mode 100644 modules/bazarr/client.ts delete mode 100644 modules/bazarr/index.ts delete mode 100644 modules/bazarr/module.json delete mode 100644 modules/bazarr/package.json delete mode 100644 modules/bazarr/tools/index.ts delete mode 100644 modules/bazarr/tsconfig.json delete mode 100644 modules/bookshelf/Dockerfile delete mode 100644 modules/bookshelf/client.ts delete mode 100644 modules/bookshelf/index.ts delete mode 100644 modules/bookshelf/module.json delete mode 100644 modules/bookshelf/package.json delete mode 100644 modules/bookshelf/tools/index.ts delete mode 100644 modules/bookshelf/tsconfig.json delete mode 100644 modules/lidarr/Dockerfile delete mode 100644 modules/lidarr/client.ts delete mode 100644 modules/lidarr/index.ts delete mode 100644 modules/lidarr/module.json delete mode 100644 modules/lidarr/package.json delete mode 100644 modules/lidarr/tools/index.ts delete mode 100644 modules/lidarr/tsconfig.json delete mode 100644 modules/nzbget/Dockerfile delete mode 100644 modules/nzbget/client.ts delete mode 100644 modules/nzbget/index.ts delete mode 100644 modules/nzbget/module.json delete mode 100644 modules/nzbget/package.json delete mode 100644 modules/nzbget/tools/index.ts delete mode 100644 modules/nzbget/tsconfig.json delete mode 100644 modules/ombi/Dockerfile delete mode 100644 modules/ombi/client.ts delete mode 100644 modules/ombi/index.ts delete mode 100644 modules/ombi/module.json delete mode 100644 modules/ombi/package.json delete mode 100644 modules/ombi/tools/index.ts delete mode 100644 modules/ombi/tsconfig.json delete mode 100644 modules/plex/Dockerfile delete mode 100644 modules/plex/client.ts delete mode 100644 modules/plex/index.ts delete mode 100644 modules/plex/module.json delete mode 100644 modules/plex/package.json delete mode 100644 modules/plex/tools/index.ts delete mode 100644 modules/plex/tsconfig.json delete mode 100644 modules/qbittorrent/Dockerfile delete mode 100644 modules/qbittorrent/client.ts delete mode 100644 modules/qbittorrent/index.ts delete mode 100644 modules/qbittorrent/module.json delete mode 100644 modules/qbittorrent/package.json delete mode 100644 modules/qbittorrent/tools/index.ts delete mode 100644 modules/qbittorrent/tsconfig.json delete mode 100644 modules/radarr/Dockerfile delete mode 100644 modules/radarr/client.ts delete mode 100644 modules/radarr/index.ts delete mode 100644 modules/radarr/module.json delete mode 100644 modules/radarr/package.json delete mode 100644 modules/radarr/tools/index.ts delete mode 100644 modules/radarr/tsconfig.json delete mode 100644 modules/sonarr/Dockerfile delete mode 100644 modules/sonarr/client.ts delete mode 100644 modules/sonarr/index.ts delete mode 100644 modules/sonarr/module.json delete mode 100644 modules/sonarr/package.json delete mode 100644 modules/sonarr/tools/index.ts delete mode 100644 modules/sonarr/tsconfig.json delete mode 100644 modules/tautulli/Dockerfile delete mode 100644 modules/tautulli/client.ts delete mode 100644 modules/tautulli/index.ts delete mode 100644 modules/tautulli/module.json delete mode 100644 modules/tautulli/package.json delete mode 100644 modules/tautulli/tools/index.ts delete mode 100644 modules/tautulli/tsconfig.json diff --git a/modules/bazarr/Dockerfile b/modules/bazarr/Dockerfile deleted file mode 100644 index 8e9844a..0000000 --- a/modules/bazarr/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# bazarr's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/bazarr -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/bazarr/dist /app/modules/bazarr/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts deleted file mode 100644 index cead9ba..0000000 --- a/modules/bazarr/client.ts +++ /dev/null @@ -1,173 +0,0 @@ -// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0039). Bazarr -// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still -// missing subtitles, searches providers for them, and records what it downloaded. This client -// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it. - -import { readFileSync } from "node:fs"; - -export interface WantedSubtitle { - kind: "episode" | "movie"; - title: string; // series + episode, or movie title - path?: string; - seriesId?: number; // sonarr series id (episodes) - episodeId?: number; // sonarr episode id (episodes) - radarrId?: number; // radarr movie id (movies) - missing: string[]; // language names still missing -} - -export interface ProviderSubtitle { - provider: string; - language: string; - hearingImpaired: boolean; - forced: boolean; - score?: number; - release?: string; - subtitle: string; // the opaque token Bazarr uses to download this exact result -} - -export interface HistoryEntry { - kind: "episode" | "movie"; - id: string; // stable dedup key across polls - title: string; - language?: string; - provider?: string; - path?: string; - timestamp?: string; - description?: string; -} - -/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ -function meshConfig(file?: string): Record { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - catch { return {}; } -} - -/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); - * absent or unreadable yields undefined so callers fall back rather than crash. */ -function readSecret(file?: string): string | undefined { - if (!file) return undefined; - try { return readFileSync(file, "utf8").trim(); } - catch { return undefined; } -} - -export class BazarrClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key - * there is nothing to talk to, so this throws rather than run half-configured. */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { - const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE); - const url = cfg.url ?? env.MESH_BAZARR_URL; - const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? env.MESH_BAZARR_API_KEY; - if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL"); - if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY"); - return new BazarrClient(url, apiKey); - } - - private async request(method: string, path: string, params: Record = {}): Promise { - const url = new URL(`${this.baseUrl}/api${path}`); - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } }); - if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`); - // Downloads/patches return an empty body; only GETs carry JSON. - const text = await res.text(); - return text ? JSON.parse(text) : {}; - } - - private get(path: string, params?: Record): Promise { - return this.request("GET", path, params); - } - - private languageNames(missing: any[]): string[] { - return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean); - } - - /** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */ - async getWanted(limit = 50): Promise { - const [eps, movies] = await Promise.all([ - this.get("/episodes/wanted", { start: "0", length: String(limit) }), - this.get("/movies/wanted", { start: "0", length: String(limit) }), - ]); - const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({ - kind: "episode" as const, - title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(), - path: e.path, - seriesId: e.sonarrSeriesId, - episodeId: e.sonarrEpisodeId, - missing: this.languageNames(e.missing_subtitles), - })); - const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({ - kind: "movie" as const, - title: m.title ?? "Unknown", - path: m.path, - radarrId: m.radarrId, - missing: this.languageNames(m.missing_subtitles), - })); - return [...episodes, ...films]; - } - - /** Ask providers what subtitles are available for one wanted episode — a manual search. */ - async searchEpisode(episodeId: number): Promise { - const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) }); - return this.mapProviderResults(raw); - } - - /** Ask providers what subtitles are available for one movie — a manual search. */ - async searchMovie(radarrId: number): Promise { - const raw = await this.get("/providers/movies", { radarrid: String(radarrId) }); - return this.mapProviderResults(raw); - } - - private mapProviderResults(raw: any): ProviderSubtitle[] { - const list = Array.isArray(raw) ? raw : (raw?.data ?? []); - return list.map((r: any) => ({ - provider: r.provider, - language: r.language?.name ?? r.language ?? "unknown", - hearingImpaired: Boolean(r.hearing_impaired ?? r.hi), - forced: Boolean(r.forced), - score: r.score, - release: r.release_info?.[0] ?? r.release_info, - subtitle: r.subtitle, - })); - } - - /** Recent subtitle-download history, episodes and movies together, newest first. Each entry - * carries a stable id so the events poller can tell a fresh download from one already seen. */ - async getHistory(limit = 40): Promise { - const [eps, movies] = await Promise.all([ - this.get("/episodes/history", { start: "0", length: String(limit) }), - this.get("/movies/history", { start: "0", length: String(limit) }), - ]); - const key = (kind: string, r: any): string => - `${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`; - const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({ - kind: "episode" as const, - id: key("episode", r), - title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(), - language: r.language?.name ?? r.language, - provider: r.provider, - path: r.subtitles_path, - timestamp: r.timestamp, - description: r.description, - })); - const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({ - kind: "movie" as const, - id: key("movie", r), - title: r.title ?? "Unknown", - language: r.language?.name ?? r.language, - provider: r.provider, - path: r.subtitles_path, - timestamp: r.timestamp, - description: r.description, - })); - return [...episodes, ...films]; - } -} diff --git a/modules/bazarr/index.ts b/modules/bazarr/index.ts deleted file mode 100644 index 6c07d0c..0000000 --- a/modules/bazarr/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely -// observable thing is a subtitle arriving: it works away in the background, searching providers for -// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth -// announcing to the mesh. -// -// Emits (novox/hq ADR 0041/0042): -// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie -// -// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's -// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter. -// -// The event is observation-based: poll history and diff. Primed silently on the first look, or a -// restart would re-announce the whole recent history as freshly downloaded. - -import { emit } from "@novox/mesh-sdk/events"; -import { BazarrClient } from "./client.js"; - -const bazarr = BazarrClient.fromEnv(); - -const seen = new Set(); -let primed = false; -async function pollHistory(): Promise { - const entries = await bazarr.getHistory(40); - for (const entry of entries) { - if (seen.has(entry.id)) continue; - if (primed) { - await emit("subtitle.downloaded", { - kind: entry.kind, - title: entry.title, - language: entry.language, - provider: entry.provider, - path: entry.path, - }); - } - seen.add(entry.id); - } - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`)); - setInterval(run, everyMs); - run(); -}; -tick(pollHistory, 60_000); - -console.log("[bazarr] watching subtitle-download history"); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json deleted file mode 100644 index 7f454c6..0000000 --- a/modules/bazarr/module.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "module": "bazarr", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "subtitle.downloaded" - ], - "own-secrets": { - "broker": "${dir:mesh-state}/broker", - "api-key": "${dir:mesh-state}/api-key" - }, - "listens": [ - { - "name": "web", - "port": 6767, - "protocol": "tcp", - "from": "mesh", - "why": "managing subtitles" - } - ], - "accesses": [ - { - "id": "movies", - "path": "/services/media/movies", - "mode": "read-write" - }, - { - "id": "series", - "path": "/services/media/series", - "mode": "read-write" - }, - { - "id": "anime", - "path": "/services/media/anime", - "mode": "read-write" - }, - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/bazarr/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "bazarr", - "image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "6767" - ], - "volumes": [ - "/services/bazarr/config:/config", - "${access:movies}:/movies", - "${access:series}:/series", - "${access:anime}:/anime", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime-config", - "type": "file", - "path": "${dir:mesh-state}/config.json", - "mode": "0600", - "content": "{}\n", - "merge": "json" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-bazarr", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}/api-key:/run/secrets/api-key:ro", - "${dir:mesh-state}/config.json:/run/config/config.json:ro", - "/services/bazarr/config:/var/lib/bazarr/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_BAZARR_URL": "http://127.0.0.1:6767", - "MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key", - "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", - "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" - }, - "restart-on": [ - "runtime-config" - ], - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "subs", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/bazarr/package.json b/modules/bazarr/package.json deleted file mode 100644 index de4a2cf..0000000 --- a/modules/bazarr/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-bazarr", - "version": "0.1.0", - "description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/bazarr/tools/index.ts b/modules/bazarr/tools/index.ts deleted file mode 100644 index 1cc571d..0000000 --- a/modules/bazarr/tools/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -// bazarr's tools — its own code (novox/hq ADR 0039), importing bazarr's client. They return -// structured data; the mesh serves them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { BazarrClient } from "../client.js"; - -export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] { - return [ - { - name: "bazarr_wanted", - description: "Episodes and movies still missing subtitles, with the languages each still needs.", - input: { limit: { type: "number", description: "max items per kind (default 50)" } }, - run: async (args) => { - const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50); - return { count: wanted.length, wanted }; - }, - }, - { - name: "bazarr_search_subtitles", - description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.", - input: { - episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" }, - radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" }, - }, - run: async (args) => { - if (args.episodeId !== undefined) { - const results = await bazarr.searchEpisode(Number(args.episodeId)); - return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results }; - } - if (args.radarrId !== undefined) { - const results = await bazarr.searchMovie(Number(args.radarrId)); - return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results }; - } - throw new Error("pass either episodeId or radarrId"); - }, - }, - { - name: "bazarr_history", - description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.", - input: { limit: { type: "number", description: "max entries per kind (default 40)" } }, - run: async (args) => { - const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40); - return { count: history.length, history }; - }, - }, - ]; -} - -// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than -// failing the whole runtime. -registerModuleTools("bazarr", (env) => { - try { - return getBazarrTools(BazarrClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/bazarr/tsconfig.json b/modules/bazarr/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/bazarr/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/bookshelf/Dockerfile b/modules/bookshelf/Dockerfile deleted file mode 100644 index 2b0f05f..0000000 --- a/modules/bookshelf/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# bookshelf's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/bookshelf -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/bookshelf/dist /app/modules/bookshelf/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/bookshelf/dist/index.js,/app/modules/bookshelf/dist/tools/index.js diff --git a/modules/bookshelf/client.ts b/modules/bookshelf/client.ts deleted file mode 100644 index 14f63e4..0000000 --- a/modules/bookshelf/client.ts +++ /dev/null @@ -1,136 +0,0 @@ -// The Bookshelf API client — bookshelf's own code, living in the module (novox/hq ADR 0039). -// Ported from the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own -// copy, so a change to Bookshelf's API rebuilds only bookshelf and nothing else. Both this module's -// tools and its events entrypoint import it, and nothing outside bookshelf does. -// -// Bookshelf is a Readarr fork (ghcr.io/pennydreadful/bookshelf). It speaks the Servarr v1 API; its -// content is "book". Unlike Sonarr/Radarr it exposes no calendar endpoint, so there is no calendar -// tool here — matching hal, which excluded bookshelf from its calendar-capable apps. - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -// Bookshelf speaks the v1 API; its content is "book". -const API_VERSION = "v1"; -const CONTENT_ENDPOINT = "book"; -const APP_NAME = "Bookshelf"; - -export interface BookshelfQueueItem { - /** The queue record id — stable while the item is in the queue, so events can diff on it. */ - id: number; - title: string; - status: string; - size: string; - sizeleft: string; - timeleft?: string; -} - -export interface BookshelfContentItem { - title: string; - author?: string; - year?: number; - status?: string; - monitored: boolean; -} - -export class BookshelfClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. The URL defaults to the server on this node (the - * runtime shares its network), and the API key is read from MESH_BOOKSHELF_API_KEY or, failing - * that, discovered from the server's own config.xml under MESH_BOOKSHELF_CONFIG_DIR — the same - * file Bookshelf writes it to, so a running server needs nothing configured by hand. Throws when - * no key can be found, so the tools/events simply do not load (the harness treats the throw as - * "exposes nothing"). - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): BookshelfClient { - const url = env.MESH_BOOKSHELF_URL ?? `http://127.0.0.1:${env.MESH_BOOKSHELF_PORT ?? "8787"}`; - const configDir = env.MESH_BOOKSHELF_CONFIG_DIR ?? "/config"; - const apiKey = env.MESH_BOOKSHELF_API_KEY ?? BookshelfClient.detectApiKey(configDir); - if (!apiKey) { - throw new Error("Bookshelf not configured — set MESH_BOOKSHELF_API_KEY or make the config dir readable"); - } - return new BookshelfClient(url, apiKey); - } - - /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app - * writes into config.xml at the root of its config directory. */ - static detectApiKey(configDir: string): string | null { - const config = join(configDir, "config.xml"); - if (existsSync(config)) { - const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); - if (match) return match[1]; - } - return null; - } - - private async get(endpoint: string, params?: Record): Promise { - const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); - if (params) { - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); - if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); - return res.json(); - } - - async getStatus(): Promise<{ appName: string; version: string }> { - const data = (await this.get("system/status")) as { appName?: string; version?: string }; - return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; - } - - async getContent(limit?: number): Promise { - const data = await this.get(CONTENT_ENDPOINT); - const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); - const mapped = items.map((item) => ({ - title: item.title ?? "Unknown", - author: item.author?.authorName ?? item.authorName, - year: item.releaseDate ? new Date(item.releaseDate).getFullYear() : item.year, - status: item.status, - monitored: item.monitored ?? true, - })); - return limit ? mapped.slice(0, limit) : mapped; - } - - /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ - async searchContent(term: string): Promise { - const all = await this.getContent(); - const lower = term.toLowerCase(); - return all.filter( - (item) => - item.title.toLowerCase().includes(lower) || - (item.author?.toLowerCase().includes(lower) ?? false), - ); - } - - async getQueue(): Promise<{ totalRecords: number; items: BookshelfQueueItem[] }> { - const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; - const records = data.records ?? []; - return { - totalRecords: data.totalRecords ?? records.length, - items: records.map((r) => ({ - id: r.id, - title: r.title ?? r.book?.title ?? r.author?.authorName ?? "Unknown", - status: r.status ?? "unknown", - size: formatBytes(r.size ?? 0), - sizeleft: formatBytes(r.sizeleft ?? 0), - timeleft: r.timeleft, - })), - }; - } -} - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; -} diff --git a/modules/bookshelf/index.ts b/modules/bookshelf/index.ts deleted file mode 100644 index a7543fa..0000000 --- a/modules/bookshelf/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -// bookshelf's events. The tool runtime imports this once the broker is bound. It watches the -// download queue and turns its comings and goings into mesh events — the same mechanism radarr uses, -// applied to a Servarr book manager. -// -// Emits (novox/hq ADR 0041/0042): -// module.bookshelf.book.grabbed — a release entered the queue (Bookshelf grabbed it) -// module.bookshelf.download.completed — a release left the queue, imported. This routing key is -// what the plex module consumes (module.*.download.completed) -// to rescan, so a new audiobook becomes a visible item. -// Consumes: none. -// -// NOTE: the hal bookshelf module emitted no events (its hooks only did install-time provisioning). -// This queue watcher is new in nox, modelled exactly on radarr's — bookshelf is a Servarr app with -// the same queue semantics, so the diff-and-emit pattern carries over unchanged. -// -// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a -// restart mid-download does not re-announce everything already in flight as freshly grabbed. - -import { emit } from "@novox/mesh-sdk/events"; -import { BookshelfClient, type BookshelfQueueItem } from "./client.js"; - -// Building the client throws when Bookshelf has no URL/key yet. Like the tools (see tools/index.ts), -// the events entrypoint must not crash the runtime for that — it stays idle until configured. -function buildClient(): BookshelfClient | null { - try { - return BookshelfClient.fromEnv(); - } catch { - return null; - } -} - -const bookshelf = buildClient(); - -// Bookshelf removes an item from the queue once it has been imported; a "warning"/"failed" status is -// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. -const FAILED_STATUSES = new Set(["failed", "warning"]); - -const inQueue = new Map(); -let primed = false; - -async function pollQueue(bookshelf: BookshelfClient): Promise { - const { items } = await bookshelf.getQueue(); - const now = new Map(items.map((i) => [i.id, i])); - - if (primed) { - // Entered the queue since last look — Bookshelf grabbed a release. - for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status }); - } - // Left the queue — imported and done, unless it was last seen failing. - for (const [id, item] of inQueue) { - if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("download.completed", { title: item.title }); - } - } - } - - inQueue.clear(); - for (const [id, item] of now) inQueue.set(id, item); - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[bookshelf] ${err}`)); - setInterval(run, everyMs); - run(); -}; - -if (bookshelf) { - tick(() => pollQueue(bookshelf), 30_000); - console.log("[bookshelf] watching the download queue, emitting grabs and completions"); -} else { - console.log("[bookshelf] not configured — events idle until an API key is available"); -} diff --git a/modules/bookshelf/module.json b/modules/bookshelf/module.json deleted file mode 100644 index 2b9003b..0000000 --- a/modules/bookshelf/module.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "module": "bookshelf", - "version": "1", - "slug": "books", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "book.grabbed", - "download.completed" - ], - "consumes": [], - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "listens": [ - { - "name": "web", - "port": 8787, - "protocol": "tcp", - "from": "mesh", - "why": "managing the ebook/audiobook library" - } - ], - "accesses": [ - { - "id": "books", - "path": "/services/media/books", - "mode": "read-write" - }, - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read-write" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/bookshelf/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "bookshelf", - "image": "ghcr.io/pennydreadful/bookshelf@sha256:388eecc94362580eae31ee0a454be6af516f8a311f8432a521c202fb475f4359", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "8787" - ], - "volumes": [ - "/services/bookshelf/config:/config", - "${access:books}:/books", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-bookshelf", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "/services/bookshelf/config:/var/lib/bookshelf/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_BOOKSHELF_URL": "http://127.0.0.1:8787", - "MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config" - }, - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "books", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/bookshelf/package.json b/modules/bookshelf/package.json deleted file mode 100644 index 5328aaa..0000000 --- a/modules/bookshelf/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-bookshelf", - "version": "0.1.0", - "description": "bookshelf — ebook/audiobook management (Readarr fork). Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/bookshelf/tools/index.ts b/modules/bookshelf/tools/index.ts deleted file mode 100644 index c8a4e5a..0000000 --- a/modules/bookshelf/tools/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -// bookshelf's tools — ported from the shared hal `arr` sdk (novox/hq ADR 0039), importing -// bookshelf's own client. They return structured data (not the pre-formatted text hal returned); the -// mesh serves them through the sdk's tool harness. Bookshelf has no calendar endpoint, so there is -// no calendar tool — matching hal, which excluded it from its calendar-capable apps. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { BookshelfClient } from "../client.js"; - -export function getBookshelfTools(bookshelf: BookshelfClient): ToolDefinition[] { - return [ - { - name: "bookshelf_status", - description: "Bookshelf status overview: version, book count, monitored count, queue size.", - input: {}, - run: async () => { - const [status, content, queue] = await Promise.all([ - bookshelf.getStatus(), - bookshelf.getContent(), - bookshelf.getQueue(), - ]); - return { - app: status.appName, - version: status.version, - books: content.length, - monitored: content.filter((c) => c.monitored).length, - queue: queue.totalRecords, - }; - }, - }, - { - name: "bookshelf_library", - description: "List books from the Bookshelf library.", - input: { limit: { type: "number", description: "max items to return (default 50)" } }, - run: async (args) => { - const items = await bookshelf.getContent(args.limit ? Number(args.limit) : 50); - return { count: items.length, books: items }; - }, - }, - { - name: "bookshelf_search", - description: - "Search the Bookshelf library for books by title or author (filters existing content, not indexers).", - input: { query: { type: "string", description: "the search term" } }, - run: async (args) => { - const query = String(args.query); - return { query, results: await bookshelf.searchContent(query) }; - }, - }, - { - name: "bookshelf_queue", - description: "Show the Bookshelf download queue — what is downloading and how far along.", - input: {}, - run: async () => { - const queue = await bookshelf.getQueue(); - return { count: queue.totalRecords, items: queue.items }; - }, - }, - ]; -} - -// The tools exist only when Bookshelf is configured; without a URL and key, bookshelf contributes -// none rather than failing the whole runtime. -registerModuleTools("bookshelf", (env) => { - try { - return getBookshelfTools(BookshelfClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/bookshelf/tsconfig.json b/modules/bookshelf/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/bookshelf/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/lidarr/Dockerfile b/modules/lidarr/Dockerfile deleted file mode 100644 index 83c0375..0000000 --- a/modules/lidarr/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# lidarr's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/lidarr -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/lidarr/dist /app/modules/lidarr/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/lidarr/dist/index.js,/app/modules/lidarr/dist/tools/index.js diff --git a/modules/lidarr/client.ts b/modules/lidarr/client.ts deleted file mode 100644 index 8122bb2..0000000 --- a/modules/lidarr/client.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The Lidarr API client — lidarr's own code, living in the module (novox/hq ADR 0039). Ported from -// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a -// change to Lidarr's API rebuilds only lidarr and nothing else. Both this module's tools and its -// events entrypoint import it, and nothing outside lidarr does. - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -// Lidarr speaks the v1 API (Radarr/Sonarr are v3); its content is the "artist". -const API_VERSION = "v1"; -const CONTENT_ENDPOINT = "artist"; -const APP_NAME = "Lidarr"; - -export interface LidarrQueueItem { - /** The queue record id — stable while the item is in the queue, so events can diff on it. */ - id: number; - title: string; - status: string; - size: string; - sizeleft: string; - timeleft?: string; -} - -export interface LidarrCalendarItem { - title: string; - date: string; - overview?: string; -} - -export interface LidarrContentItem { - title: string; - status?: string; - monitored: boolean; -} - -export class LidarrClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. The URL defaults to the server on this node (the - * runtime shares its network), and the API key is read from MESH_LIDARR_API_KEY or, failing that, - * discovered from the server's own config.xml under MESH_LIDARR_CONFIG_DIR — the same file Lidarr - * writes it to, so a running server needs nothing configured by hand. Throws when no key can be - * found, so the tools/events simply do not load (the harness treats the throw as "exposes - * nothing"). - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): LidarrClient { - const url = env.MESH_LIDARR_URL ?? `http://127.0.0.1:${env.MESH_LIDARR_PORT ?? "8686"}`; - const configDir = env.MESH_LIDARR_CONFIG_DIR ?? "/config"; - const apiKey = env.MESH_LIDARR_API_KEY ?? LidarrClient.detectApiKey(configDir); - if (!apiKey) { - throw new Error("Lidarr not configured — set MESH_LIDARR_API_KEY or make the config dir readable"); - } - return new LidarrClient(url, apiKey); - } - - /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app - * writes into config.xml at the root of its config directory. */ - static detectApiKey(configDir: string): string | null { - const config = join(configDir, "config.xml"); - if (existsSync(config)) { - const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); - if (match) return match[1]; - } - return null; - } - - private async get(endpoint: string, params?: Record): Promise { - const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); - if (params) { - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); - if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); - return res.json(); - } - - async getStatus(): Promise<{ appName: string; version: string }> { - const data = (await this.get("system/status")) as { appName?: string; version?: string }; - return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; - } - - async getContent(limit?: number): Promise { - const data = await this.get(CONTENT_ENDPOINT); - const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); - const mapped = items.map((item) => ({ - // Lidarr's content is an artist; its display name is artistName, not title. - title: item.artistName ?? item.title ?? "Unknown", - status: item.status, - monitored: item.monitored ?? true, - })); - return limit ? mapped.slice(0, limit) : mapped; - } - - /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ - async searchContent(term: string): Promise { - const all = await this.getContent(); - const lower = term.toLowerCase(); - return all.filter((item) => item.title.toLowerCase().includes(lower)); - } - - async getQueue(): Promise<{ totalRecords: number; items: LidarrQueueItem[] }> { - const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; - const records = data.records ?? []; - return { - totalRecords: data.totalRecords ?? records.length, - items: records.map((r) => ({ - id: r.id, - title: r.title ?? r.artist?.artistName ?? r.album?.title ?? "Unknown", - status: r.status ?? "unknown", - size: formatBytes(r.size ?? 0), - sizeleft: formatBytes(r.sizeleft ?? 0), - timeleft: r.timeleft, - })), - }; - } - - async getCalendar(days = 7): Promise { - const start = new Date().toISOString().split("T")[0]; - const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; - const data = await this.get("calendar", { start, end }); - const items: any[] = Array.isArray(data) ? data : []; - return items.map((item) => ({ - // A Lidarr calendar entry is an album release. - title: item.title ?? item.artist?.artistName ?? "Unknown", - date: item.releaseDate ?? "", - overview: item.overview?.slice(0, 150), - })); - } -} - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; -} diff --git a/modules/lidarr/index.ts b/modules/lidarr/index.ts deleted file mode 100644 index a19c88b..0000000 --- a/modules/lidarr/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -// lidarr's events. The tool runtime imports this once the broker is bound. It watches the download -// queue and turns its comings and goings into mesh events. -// -// Emits (novox/hq ADR 0041/0042): -// module.lidarr.album.grabbed — a release entered the queue (Lidarr grabbed it) -// module.lidarr.download.completed — a release left the queue, imported. The download.completed -// routing key matches what a media consumer subscribes to -// (module.*.download.completed) to rescan its library. -// Consumes: none. -// -// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a -// restart mid-download does not re-announce everything already in flight as freshly grabbed. - -import { emit } from "@novox/mesh-sdk/events"; -import { LidarrClient, type LidarrQueueItem } from "./client.js"; - -// Building the client throws when Lidarr has no URL/key yet. Like the tools (see tools/index.ts), -// the events entrypoint must not crash the runtime for that — it stays idle until configured. -function buildClient(): LidarrClient | null { - try { - return LidarrClient.fromEnv(); - } catch { - return null; - } -} - -const lidarr = buildClient(); - -// Lidarr removes an item from the queue once it has been imported; a "warning"/"failed" status is -// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. -const FAILED_STATUSES = new Set(["failed", "warning"]); - -const inQueue = new Map(); -let primed = false; - -async function pollQueue(lidarr: LidarrClient): Promise { - const { items } = await lidarr.getQueue(); - const now = new Map(items.map((i) => [i.id, i])); - - if (primed) { - // Entered the queue since last look — Lidarr grabbed a release. - for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("album.grabbed", { title: item.title, status: item.status }); - } - // Left the queue — imported and done, unless it was last seen failing. - for (const [id, item] of inQueue) { - if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("download.completed", { title: item.title }); - } - } - } - - inQueue.clear(); - for (const [id, item] of now) inQueue.set(id, item); - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[lidarr] ${err}`)); - setInterval(run, everyMs); - run(); -}; - -if (lidarr) { - tick(() => pollQueue(lidarr), 30_000); - console.log("[lidarr] watching the download queue, emitting grabs and completions"); -} else { - console.log("[lidarr] not configured — events idle until an API key is available"); -} diff --git a/modules/lidarr/module.json b/modules/lidarr/module.json deleted file mode 100644 index a74d4b6..0000000 --- a/modules/lidarr/module.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "module": "lidarr", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "album.grabbed", - "download.completed" - ], - "consumes": [], - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "listens": [ - { - "name": "web", - "port": 8686, - "protocol": "tcp", - "from": "mesh", - "why": "managing music" - } - ], - "accesses": [ - { - "id": "music", - "path": "/services/media/music", - "mode": "read-write" - }, - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read-write" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/lidarr/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "lidarr", - "image": "lscr.io/linuxserver/lidarr@sha256:6b38dd330b0c653351c2e23c8b962ea51c95683dd7acace9d106c922baf85f75", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "8686" - ], - "volumes": [ - "/services/lidarr/config:/config", - "${access:music}:/music", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-lidarr", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "/services/lidarr/config:/var/lib/lidarr/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_LIDARR_URL": "http://127.0.0.1:8686", - "MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config" - }, - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "lidarr", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/lidarr/package.json b/modules/lidarr/package.json deleted file mode 100644 index deb03cf..0000000 --- a/modules/lidarr/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-lidarr", - "version": "0.1.0", - "description": "lidarr — music management. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/lidarr/tools/index.ts b/modules/lidarr/tools/index.ts deleted file mode 100644 index a0e78f3..0000000 --- a/modules/lidarr/tools/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -// lidarr's tools — ported from the shared hal sdk (novox/hq ADR 0039), importing lidarr's own -// client. They return structured data (not pre-formatted text as hal did); the mesh serves them -// through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { LidarrClient } from "../client.js"; - -export function getLidarrTools(lidarr: LidarrClient): ToolDefinition[] { - return [ - { - name: "lidarr_status", - description: "Lidarr status overview: version, artist count, monitored count, queue size.", - input: {}, - run: async () => { - const [status, content, queue] = await Promise.all([ - lidarr.getStatus(), - lidarr.getContent(), - lidarr.getQueue(), - ]); - return { - app: status.appName, - version: status.version, - artists: content.length, - monitored: content.filter((c) => c.monitored).length, - queue: queue.totalRecords, - }; - }, - }, - { - name: "lidarr_library", - description: "List artists from the Lidarr library.", - input: { limit: { type: "number", description: "max items to return (default 50)" } }, - run: async (args) => { - const items = await lidarr.getContent(args.limit ? Number(args.limit) : 50); - return { count: items.length, artists: items }; - }, - }, - { - name: "lidarr_search", - description: "Search the Lidarr library for artists by name (filters existing content, not indexers).", - input: { query: { type: "string", description: "the search term" } }, - run: async (args) => { - const query = String(args.query); - return { query, results: await lidarr.searchContent(query) }; - }, - }, - { - name: "lidarr_queue", - description: "Show the Lidarr download queue — what is downloading and how far along.", - input: {}, - run: async () => { - const queue = await lidarr.getQueue(); - return { count: queue.totalRecords, items: queue.items }; - }, - }, - { - name: "lidarr_calendar", - description: "Upcoming album releases from the Lidarr calendar.", - input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, - run: async (args) => { - const days = args.days ? Number(args.days) : 7; - const items = await lidarr.getCalendar(days); - items.sort((a, b) => a.date.localeCompare(b.date)); - return { days, count: items.length, items }; - }, - }, - ]; -} - -// The tools exist only when Lidarr is configured; without a URL and key, lidarr contributes none -// rather than failing the whole runtime. -registerModuleTools("lidarr", (env) => { - try { - return getLidarrTools(LidarrClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/lidarr/tsconfig.json b/modules/lidarr/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/lidarr/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/nzbget/Dockerfile b/modules/nzbget/Dockerfile deleted file mode 100644 index 338f493..0000000 --- a/modules/nzbget/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# nzbget's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/nzbget -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/nzbget/dist /app/modules/nzbget/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/nzbget/dist/index.js,/app/modules/nzbget/dist/tools/index.js diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts deleted file mode 100644 index f4d82f4..0000000 --- a/modules/nzbget/client.ts +++ /dev/null @@ -1,178 +0,0 @@ -// The NZBGet API client — nzbget's own code, living in the module (novox/hq ADR 0039). Ported from -// hal's shared nzbget tools, but self-contained: a change to NZBGet's JSON-RPC now rebuilds only -// nzbget and nothing else. Both this module's tools and its events entrypoint import it, and -// nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. - -import { readFileSync } from "node:fs"; - -export interface NzbgetStatus { - /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ - speedBytesPerSec: number; - remainingMB: number; - downloadedTodayMB: number; - downloadedMonthMB: number; - freeDiskMB: number; - paused: boolean; - postJobs: number; - uptimeSec: number; -} - -export interface NzbgetQueueItem { - /** The NZBID — stable while the item is queued, so events can diff on it. */ - id: number; - name: string; - status: string; - category: string; - sizeMB: number; - remainingMB: number; - percent: number; -} - -export interface NzbgetHistoryItem { - /** The NZBID — the same id the item carried in the queue. */ - id: number; - name: string; - /** NZBGet's own status string, e.g. "SUCCESS/ALL", "FAILURE/PAR", "DELETED/MANUAL". */ - status: string; - category: string; - sizeMB: number; - /** A genuine completion (status starts "SUCCESS") vs a failed or deleted entry — the difference - * between something to announce as done and something that merely left the queue. */ - success: boolean; -} - -/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ -function meshConfig(file?: string): Record { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - catch { return {}; } -} - -/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); - * absent or unreadable yields undefined so callers fall back rather than crash. */ -function readSecret(file?: string): string | undefined { - if (!file) return undefined; - try { return readFileSync(file, "utf8").trim(); } - catch { return undefined; } -} - -export class NzbgetClient { - readonly rpcUrl: string; - private readonly auth: string; - - constructor(url: string, user: string, password: string) { - this.rpcUrl = `${url.replace(/\/$/, "")}/jsonrpc`; - this.auth = Buffer.from(`${user}:${password}`).toString("base64"); - } - - /** - * Build from the module's resolved environment. URL and password are read from MESH_NZBGET_URL - * and MESH_NZBGET_PASSWORD; both must be present — an unconfigured NZBGet throws rather than - * pretend to be reachable, so the tools/events simply do not load (the harness treats the throw - * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { - const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); - const url = cfg.url ?? env.MESH_NZBGET_URL; - const password = cfg.password ?? readSecret(env.MESH_NZBGET_PASSWORD_FILE) ?? env.MESH_NZBGET_PASSWORD; - if (!url || !password) { - throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); - } - const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget"; - return new NzbgetClient(url, user, password); - } - - private async rpc(method: string, params: unknown[] = []): Promise { - const res = await fetch(this.rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Basic ${this.auth}` }, - body: JSON.stringify({ method, params, id: 1 }), - }); - if (!res.ok) throw new Error(`NZBGet API ${method}: ${res.status} ${await res.text()}`); - const data = (await res.json()) as { result?: T; error?: unknown }; - if (data.error) throw new Error(`NZBGet RPC ${method}: ${JSON.stringify(data.error)}`); - return data.result as T; - } - - async getVersion(): Promise { - return this.rpc("version"); - } - - async getStatus(): Promise { - const s = await this.rpc>("status"); - const lo = Number(s.DownloadRateLo ?? 0); - const hi = Number(s.DownloadRateHi ?? 0); - return { - speedBytesPerSec: lo + hi * 4294967296, - remainingMB: Number(s.RemainingSizeMB ?? 0), - downloadedTodayMB: Number(s.DaySizeMB ?? 0), - downloadedMonthMB: Number(s.MonthSizeMB ?? 0), - freeDiskMB: Number(s.FreeDiskSpaceMB ?? 0), - paused: Boolean(s.DownloadPaused), - postJobs: Number(s.PostJobCount ?? 0), - uptimeSec: Number(s.UpTimeSec ?? 0), - }; - } - - async getQueue(): Promise { - const groups = await this.rpc[]>("listgroups", [0]); - return groups.map((g) => { - const size = Number(g.FileSizeMB ?? 0); - const remaining = Number(g.RemainingSizeMB ?? 0); - return { - id: Number(g.NZBID), - name: String(g.NZBName ?? "Unknown"), - status: String(g.Status ?? "unknown"), - category: String(g.Category ?? ""), - sizeMB: size, - remainingMB: remaining, - percent: size > 0 ? Math.round(((size - remaining) / size) * 100) : 0, - }; - }); - } - - async getHistory(limit = 20): Promise { - const history = await this.rpc[]>("history", [false]); - return history.slice(0, limit).map((h) => { - const status = String(h.Status ?? ""); - return { - id: Number(h.NZBID), - name: String(h.Name ?? "Unknown"), - status, - category: String(h.Category ?? ""), - sizeMB: Number(h.FileSizeMB ?? 0), - success: status.startsWith("SUCCESS"), - }; - }); - } - - /** Queue an NZB by URL. Returns the new NZBID; a non-positive id means NZBGet refused it. */ - async add(url: string, category = "", priority = 0, paused = false): Promise { - const id = await this.rpc("append", [ - "", url, category, priority, false, paused, "", 0, "SCORE", false, [], - ]); - if (!id || id <= 0) throw new Error("NZBGet refused the NZB (append returned 0)"); - return id; - } - - async pauseAll(): Promise { - await this.rpc("pausedownload"); - } - - async resumeAll(): Promise { - await this.rpc("resumedownload"); - } - - async pauseItem(id: number): Promise { - await this.rpc("editqueue", ["GroupPause", "", [id]]); - } - - async resumeItem(id: number): Promise { - await this.rpc("editqueue", ["GroupResume", "", [id]]); - } - - /** Delete an item from the queue or from history. */ - async delete(id: number, from: "queue" | "history" = "queue"): Promise { - await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]); - } -} diff --git a/modules/nzbget/index.ts b/modules/nzbget/index.ts deleted file mode 100644 index fa14e0b..0000000 --- a/modules/nzbget/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -// nzbget's events. The tool runtime imports this once the broker is bound. It watches the download -// queue and the history and turns their comings and goings into mesh events. -// -// Emits (novox/hq ADR 0041/0042): -// module.nzbget.download.added — an NZB entered the queue -// module.nzbget.download.completed — an NZB finished successfully (left the queue, landed in -// history as SUCCESS). This exact routing key is what the -// plex module consumes (module.*.download.completed) to -// rescan, so the new file becomes a visible item. -// Consumes: none. -// -// Two diffs, each primed silently on the first look (like plex's and sonarr's index.ts) so a -// restart mid-download does not re-announce everything already in flight or already finished. The -// queue tells us what was grabbed; history — not the queue's disappearance — tells us what actually -// succeeded, since a failed or deleted download also leaves the queue. - -import { emit } from "@novox/mesh-sdk/events"; -import { NzbgetClient } from "./client.js"; - -const nzbget = NzbgetClient.fromEnv(); - -const inQueue = new Set(); -let queuePrimed = false; -async function pollQueue(): Promise { - const items = await nzbget.getQueue(); - const now = new Set(items.map((i) => i.id)); - if (queuePrimed) { - for (const item of items) { - if (!inQueue.has(item.id)) { - await emit("download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB }); - } - } - } - inQueue.clear(); - for (const id of now) inQueue.add(id); - queuePrimed = true; -} - -const seenHistory = new Set(); -let historyPrimed = false; -async function pollHistory(): Promise { - const items = await nzbget.getHistory(50); - for (const item of items) { - if (!seenHistory.has(item.id)) { - // A newly-appeared history entry is a completion only if it actually succeeded; a failure or - // a manual delete lands in history too, and neither is a "download.completed". - if (historyPrimed && item.success) { - await emit("download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB }); - } - seenHistory.add(item.id); - } - } - historyPrimed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[nzbget] ${err}`)); - setInterval(run, everyMs); - run(); -}; -tick(pollQueue, 20_000); -tick(pollHistory, 30_000); - -console.log("[nzbget] watching the queue and history, emitting adds and completions"); diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json deleted file mode 100644 index 7f27951..0000000 --- a/modules/nzbget/module.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "module": "nzbget", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "download.added", - "download.completed" - ], - "consumes": [], - "own-secrets": { - "broker": "${dir:mesh-state}/broker", - "password": "${dir:mesh-state}/password" - }, - "listens": [ - { - "name": "web", - "port": 6789, - "protocol": "tcp", - "from": "mesh", - "why": "the download client's pages" - } - ], - "accesses": [ - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read-write" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/nzbget/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "nzbget", - "image": "lscr.io/linuxserver/nzbget@sha256:5f3d3fa71029004156eff2cbf4ef4455ce4ce59517cf13fa7d1d7c8a4cd2c8a4", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "6789" - ], - "volumes": [ - "/services/nzbget/config:/config", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime-config", - "type": "file", - "path": "${dir:mesh-state}/config.json", - "mode": "0600", - "content": "{}\n", - "merge": "json" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-nzbget", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}/password:/run/secrets/password:ro", - "${dir:mesh-state}/config.json:/run/config/config.json:ro", - "/services/nzbget/config:/var/lib/nzbget/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_NZBGET_URL": "http://127.0.0.1:6789", - "MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password", - "MESH_NZBGET_CONFIG_FILE": "/run/config/config.json", - "MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config" - }, - "restart-on": [ - "runtime-config" - ], - "artifact": "runtime" - } - ], - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/nzbget/package.json b/modules/nzbget/package.json deleted file mode 100644 index 353da8a..0000000 --- a/modules/nzbget/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-nzbget", - "version": "0.1.0", - "description": "nzbget — Usenet download client. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/nzbget/tools/index.ts b/modules/nzbget/tools/index.ts deleted file mode 100644 index 148a8f5..0000000 --- a/modules/nzbget/tools/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -// nzbget's tools — ported from the shared hal sdk (novox/hq ADR 0039), importing nzbget's own -// client. They return structured data (not the pre-formatted text hal returned); the mesh serves -// them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { NzbgetClient } from "../client.js"; - -export function getNzbgetTools(nzbget: NzbgetClient): ToolDefinition[] { - return [ - { - name: "nzbget_status", - description: "NZBGet server status: download speed, queue remaining, disk free, paused state.", - input: {}, - run: async () => nzbget.getStatus(), - }, - { - name: "nzbget_queue", - description: "List the current NZBGet download queue — what is downloading and how far along.", - input: {}, - run: async () => { - const items = await nzbget.getQueue(); - return { count: items.length, items }; - }, - }, - { - name: "nzbget_history", - description: "Recent NZBGet download history, newest first — completed, failed and deleted items.", - input: { limit: { type: "number", description: "how many entries (default 20)" } }, - run: async (args) => { - const items = await nzbget.getHistory(args.limit ? Number(args.limit) : 20); - return { count: items.length, items }; - }, - }, - { - name: "nzbget_add", - description: "Queue an NZB download by URL, optionally into a category.", - input: { - url: { type: "string", description: "URL to the NZB file" }, - category: { type: "string", description: "category name (determines download directory)" }, - priority: { type: "number", description: "-100 very low … 0 normal … 100 very high (default 0)" }, - paused: { type: "boolean", description: "add in paused state (default false)" }, - }, - run: async (args) => { - const id = await nzbget.add( - String(args.url), - args.category ? String(args.category) : "", - args.priority ? Number(args.priority) : 0, - args.paused === true || args.paused === "true", - ); - return { added: id, category: args.category ? String(args.category) : null }; - }, - }, - { - name: "nzbget_pause", - description: "Pause or resume all NZBGet downloads.", - input: { resume: { type: "boolean", description: "true to resume, false to pause (default false)" } }, - run: async (args) => { - const resume = args.resume === true || args.resume === "true"; - if (resume) await nzbget.resumeAll(); - else await nzbget.pauseAll(); - return { paused: !resume }; - }, - }, - { - name: "nzbget_delete", - description: "Delete an NZB from the queue or from history by its NZBID.", - input: { - id: { type: "number", description: "the NZBID to delete" }, - from: { type: "string", description: "'queue' (default) or 'history'" }, - }, - run: async (args) => { - const from = args.from === "history" ? "history" : "queue"; - await nzbget.delete(Number(args.id), from); - return { deleted: Number(args.id), from }; - }, - }, - ]; -} - -// The tools exist only when NZBGet is configured; without a URL and password, nzbget contributes -// none rather than failing the whole runtime. -registerModuleTools("nzbget", (env) => { - try { - return getNzbgetTools(NzbgetClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/nzbget/tsconfig.json b/modules/nzbget/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/nzbget/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/ombi/Dockerfile b/modules/ombi/Dockerfile deleted file mode 100644 index fb96f72..0000000 --- a/modules/ombi/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# ombi's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/ombi -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/ombi/dist /app/modules/ombi/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts deleted file mode 100644 index 6bfd82d..0000000 --- a/modules/ombi/client.ts +++ /dev/null @@ -1,122 +0,0 @@ -// The Ombi API client — ombi's own code, living in the module (novox/hq ADR 0039). Ombi is the -// request front-end: viewers ask for movies and shows, and an operator approves them. This client -// talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. - -import { readFileSync } from "node:fs"; - -export interface OmbiRequest { - kind: "movie" | "tv"; - id: number; - title: string; - requestedBy?: string; - requestedDate?: string; - approved: boolean; - available: boolean; - denied: boolean; - tmdbId?: number; -} - -export interface RequestCounts { - pending: number; - approved: number; - available: number; -} - -/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ -function meshConfig(file?: string): Record { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - catch { return {}; } -} - -/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); - * absent or unreadable yields undefined so callers fall back rather than crash. */ -function readSecret(file?: string): string | undefined { - if (!file) return undefined; - try { return readFileSync(file, "utf8").trim(); } - catch { return undefined; } -} - -export class OmbiClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there - * is nothing to talk to, so this throws rather than run half-configured. */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { - const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE); - const url = cfg.url ?? env.MESH_OMBI_URL; - const apiKey = cfg.apiKey ?? readSecret(env.MESH_OMBI_API_KEY_FILE) ?? env.MESH_OMBI_API_KEY; - if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); - if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); - return new OmbiClient(url, apiKey); - } - - private async request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(`${this.baseUrl}/api/v1${path}`, { - method, - headers: { - ApiKey: this.apiKey, - Accept: "application/json", - ...(body !== undefined ? { "Content-Type": "application/json" } : {}), - }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - if (!res.ok) throw new Error(`Ombi API ${method} ${path}: ${res.status} ${await res.text()}`); - const text = await res.text(); - return text ? JSON.parse(text) : {}; - } - - /** All requests, movies and TV together — who asked for what, and where each stands. */ - async getRequests(): Promise { - const [movies, tv] = await Promise.all([ - this.request("GET", "/Request/movie"), - this.request("GET", "/Request/tv"), - ]); - const films: OmbiRequest[] = (Array.isArray(movies) ? movies : []).map((r: any) => ({ - kind: "movie" as const, - id: r.id, - title: r.title ?? "Unknown", - requestedBy: r.requestedUser?.userName ?? r.requestedUser?.userAlias, - requestedDate: r.requestedDate, - approved: Boolean(r.approved), - available: Boolean(r.available), - denied: Boolean(r.denied), - tmdbId: r.theMovieDbId, - })); - // TV requests carry per-season child requests; the top-level record is approved when all its - // children are, which is the grain an operator acts on. - const shows: OmbiRequest[] = (Array.isArray(tv) ? tv : []).map((r: any) => { - const children: any[] = r.childRequests ?? []; - return { - kind: "tv" as const, - id: r.id, - title: r.title ?? "Unknown", - requestedBy: children[0]?.requestedUser?.userName, - requestedDate: children[0]?.requestedDate, - approved: children.length > 0 && children.every((c) => c.approved), - available: children.length > 0 && children.every((c) => c.available), - denied: children.some((c) => c.denied), - tmdbId: r.theMovieDbId, - }; - }); - return [...films, ...shows]; - } - - /** Live pending/approved/available counts — a one-line health read without listing everything. */ - async getCounts(): Promise { - const c = await this.request("GET", "/Request/count"); - return { pending: c.pending ?? 0, approved: c.approved ?? 0, available: c.available ?? 0 }; - } - - /** Approve a request. TV approval fans out to the request's child (per-season) requests. */ - async approve(kind: "movie" | "tv", id: number): Promise { - await this.request("POST", `/Request/${kind}/approve`, { id }); - } -} diff --git a/modules/ombi/index.ts b/modules/ombi/index.ts deleted file mode 100644 index 479c08c..0000000 --- a/modules/ombi/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -// ombi's events. The tool runtime imports this once the broker is bound. Ombi's timeline is the -// request lifecycle: a viewer files a request, and later an operator approves it. Both transitions -// are worth announcing — the mesh can notify on a new request, and act on an approval (that is when -// a downloader should start looking). -// -// Emits (novox/hq ADR 0041/0042): -// module.ombi.request.created — a viewer filed a new request -// module.ombi.request.approved — a request was approved -// -// Ombi is the origin of these decisions, not a reactor to the mesh, so it consumes nothing. -// -// Both events are observation-based: poll the request list and diff. Creation is diffed on the set -// of request ids; approval on each request's approved flag flipping true. Primed silently on the -// first look, or a restart would re-announce every existing request and approval. - -import { emit } from "@novox/mesh-sdk/events"; -import { OmbiClient, type OmbiRequest } from "./client.js"; - -const ombi = OmbiClient.fromEnv(); - -// Remember each seen request and whether it was approved last time, keyed by kind+id (ids are only -// unique within a kind). -const approvedState = new Map(); -let primed = false; - -const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`; - -async function pollRequests(): Promise { - const requests = await ombi.getRequests(); - for (const r of requests) { - const key = keyOf(r); - const known = approvedState.has(key); - if (primed && !known) { - await emit("request.created", { - kind: r.kind, - id: r.id, - title: r.title, - requestedBy: r.requestedBy, - tmdbId: r.tmdbId, - }); - } - // Approval: the flag went from false to true for a request we already knew about. - if (primed && known && r.approved && approvedState.get(key) === false) { - await emit("request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId }); - } - approvedState.set(key, r.approved); - } - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[ombi] ${err}`)); - setInterval(run, everyMs); - run(); -}; -tick(pollRequests, 30_000); - -console.log("[ombi] watching requests for new filings and approvals"); diff --git a/modules/ombi/module.json b/modules/ombi/module.json deleted file mode 100644 index 929dddb..0000000 --- a/modules/ombi/module.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "module": "ombi", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "request.created", - "request.approved" - ], - "own-secrets": { - "broker": "${dir:mesh-state}/broker", - "api-key": "${dir:mesh-state}/api-key" - }, - "listens": [ - { - "name": "web", - "port": 3579, - "protocol": "tcp", - "from": "mesh", - "why": "requests from viewers" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/ombi/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "ombi", - "image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "3579" - ], - "volumes": [ - "/services/ombi/config:/config" - ] - }, - { - "id": "runtime-config", - "type": "file", - "path": "${dir:mesh-state}/config.json", - "mode": "0600", - "content": "{}\n", - "merge": "json" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-ombi", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}/api-key:/run/secrets/api-key:ro", - "${dir:mesh-state}/config.json:/run/config/config.json:ro", - "/services/ombi/config:/var/lib/ombi/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_OMBI_URL": "http://127.0.0.1:3579", - "MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key", - "MESH_OMBI_CONFIG_FILE": "/run/config/config.json", - "MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config" - }, - "restart-on": [ - "runtime-config" - ], - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "ombi", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/ombi/package.json b/modules/ombi/package.json deleted file mode 100644 index a1f9d58..0000000 --- a/modules/ombi/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-ombi", - "version": "0.1.0", - "description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/ombi/tools/index.ts b/modules/ombi/tools/index.ts deleted file mode 100644 index 70d38c3..0000000 --- a/modules/ombi/tools/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -// ombi's tools — its own code (novox/hq ADR 0039), importing ombi's client. They return structured -// data; the mesh serves them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { OmbiClient } from "../client.js"; - -export function getOmbiTools(ombi: OmbiClient): ToolDefinition[] { - return [ - { - name: "ombi_requests", - description: "List media requests — movies and shows — with who asked and whether each is approved or available.", - input: { pending: { type: "boolean", description: "only requests not yet approved (default false)" } }, - run: async (args) => { - let requests = await ombi.getRequests(); - if (args.pending) requests = requests.filter((r) => !r.approved && !r.denied); - const counts = await ombi.getCounts(); - return { counts, count: requests.length, requests }; - }, - }, - { - name: "ombi_approve", - description: "Approve a media request by its kind and id (from ombi_requests).", - input: { - kind: { type: "string", description: '"movie" or "tv"' }, - id: { type: "number", description: "the request id" }, - }, - run: async (args) => { - const kind = String(args.kind); - if (kind !== "movie" && kind !== "tv") throw new Error('kind must be "movie" or "tv"'); - const id = Number(args.id); - await ombi.approve(kind, id); - return { approved: { kind, id } }; - }, - }, - ]; -} - -// Exposed only when Ombi is configured; otherwise ombi contributes no tools rather than failing the -// whole runtime. -registerModuleTools("ombi", (env) => { - try { - return getOmbiTools(OmbiClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/ombi/tsconfig.json b/modules/ombi/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/ombi/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/plex/Dockerfile b/modules/plex/Dockerfile deleted file mode 100644 index 31f7958..0000000 --- a/modules/plex/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# plex's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/plex -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/plex/dist /app/modules/plex/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/plex/dist/index.js,/app/modules/plex/dist/tools/index.js diff --git a/modules/plex/client.ts b/modules/plex/client.ts deleted file mode 100644 index 5aa7e6f..0000000 --- a/modules/plex/client.ts +++ /dev/null @@ -1,158 +0,0 @@ -// The Plex API client — plex's own code, living in the module (novox/hq ADR 0039). Moved out of the -// shared hal sdk, where a change to Plex's API rebuilt everything; here it rebuilds only plex. Both -// this module's tools and its events entrypoint import it, and nothing outside plex does. - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -/** Read a secret the mesh mounted at a file path (an own-secret); absent or unreadable yields - * undefined, so callers can fall back rather than crash. */ -function readSecret(path: string | undefined): string | undefined { - if (!path) return undefined; - try { - return readFileSync(path, "utf8").trim(); - } catch { - return undefined; - } -} - -export interface PlexLibrary { - key: string; - title: string; - type: string; - count?: number; -} - -export interface PlexSession { - key: string; - title: string; - user: string; - player: string; - state: string; - type: string; -} - -export interface PlexItem { - title: string; - type: string; - year?: number; - summary?: string; - addedAt?: string; -} - -export class PlexClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly token: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. The token is read from MESH_PLEX_TOKEN, or - * discovered from the server's own Preferences.xml under the data directory — the same file Plex - * writes it to, so a running server needs nothing configured by hand. - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexClient { - const url = env.MESH_PLEX_URL ?? `http://127.0.0.1:${env.PLEX_PORT ?? "32400"}`; - const dataDir = env.MESH_PLEX_DATA_DIR ?? "/var/lib/plex"; - // The operator-provided token is an own-secret the mesh mounts at MESH_PLEX_TOKEN_FILE (delivered - // by `secret accept`); prefer it, fall back to a bare env var, then to discovery from the data dir. - const token = readSecret(env.MESH_PLEX_TOKEN_FILE) ?? env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir); - if (!token) throw new Error("no Plex token — set MESH_PLEX_TOKEN or make the data dir readable"); - return new PlexClient(url, token); - } - - /** Discover the token from the server's Preferences.xml, falling back to null. */ - static detectToken(dataDir: string): string | null { - const prefs = join(dataDir, "config", "Library", "Application Support", "Plex Media Server", "Preferences.xml"); - if (existsSync(prefs)) { - const match = readFileSync(prefs, "utf8").match(/PlexOnlineToken="([^"]+)"/); - if (match) return match[1]; - } - return null; - } - - private async get(path: string): Promise { - const url = `${this.baseUrl}${path}`; - const sep = url.includes("?") ? "&" : "?"; - const res = await fetch(`${url}${sep}X-Plex-Token=${this.token}`, { headers: { Accept: "application/json" } }); - if (!res.ok) throw new Error(`Plex API ${path}: ${res.status} ${await res.text()}`); - return res.json(); - } - - async getServerInfo(): Promise<{ name: string; version: string; platform: string }> { - const mc = (await this.get("/")).MediaContainer; - return { name: mc.friendlyName || mc.machineIdentifier, version: mc.version, platform: mc.platform }; - } - - async getLibraries(): Promise { - const dirs = (await this.get("/library/sections")).MediaContainer?.Directory ?? []; - return dirs.map((d: any) => ({ key: d.key, title: d.title, type: d.type, count: d.count })); - } - - async getSessions(): Promise { - const sessions = (await this.get("/status/sessions")).MediaContainer?.Metadata ?? []; - return sessions.map((s: any) => ({ - key: s.sessionKey ?? s.ratingKey, - title: s.title + (s.grandparentTitle ? ` (${s.grandparentTitle})` : ""), - user: s.User?.title ?? "unknown", - player: s.Player?.title ?? s.Player?.product ?? "unknown", - state: s.Player?.state ?? "unknown", - type: s.type, - })); - } - - async search(query: string): Promise { - const hubs = (await this.get(`/hubs/search?query=${encodeURIComponent(query)}&limit=20`)).MediaContainer?.Hub ?? []; - const results: PlexItem[] = []; - for (const hub of hubs) { - for (const m of hub.Metadata ?? []) { - results.push({ - title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), - type: m.type, - year: m.year, - summary: m.summary?.slice(0, 200), - }); - } - } - return results; - } - - async getRecentlyAdded(limit = 20): Promise { - const items = (await this.get(`/library/recentlyAdded?X-Plex-Container-Size=${limit}`)).MediaContainer?.Metadata ?? []; - return items.map((m: any) => ({ - title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), - type: m.type, - year: m.year, - summary: m.summary?.slice(0, 200), - addedAt: m.addedAt ? new Date(m.addedAt * 1000).toISOString() : undefined, - })); - } - - /** Ask Plex to rescan a library section — how a "new media arrived" event becomes a visible item. */ - async refreshLibrary(key: string): Promise { - await this.get(`/library/sections/${key}/refresh`); - } - - /** Rescan every library, for when what arrived is not known to belong to one. */ - async refreshAll(): Promise { - for (const library of await this.getLibraries()) await this.refreshLibrary(library.key); - } - - /** - * A health probe that never throws: report whether the Plex server this client is pointed at - * answers, and identify it when it does. Every other call assumes the server is up; this is the - * one that tells the mesh whether it is, so a diagnosis does not start from a stack trace. - */ - async reachable(): Promise<{ reachable: boolean; url: string; server?: { name: string; version: string }; error?: string }> { - try { - const info = await this.getServerInfo(); - return { reachable: true, url: this.baseUrl, server: { name: info.name, version: info.version } }; - } catch (err) { - return { reachable: false, url: this.baseUrl, error: err instanceof Error ? err.message : String(err) }; - } - } -} diff --git a/modules/plex/index.ts b/modules/plex/index.ts deleted file mode 100644 index 66aebdb..0000000 --- a/modules/plex/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -// plex's events. The tool runtime imports this once the broker is bound, and it does two things: -// it watches the server and emits what happened, and it reacts to the mesh's media events. -// -// Emits (novox/hq ADR 0041/0042): -// module.plex.playback.started / .stopped — someone began or ended watching -// module.plex.item.added — a new item appeared in a library -// Consumes: -// module.*.download.completed — a downloader finished; rescan so the file shows up -// -// The polling is deliberately unhurried: Plex is a neighbour on the same node, and an event a few -// seconds late is an event, whereas hammering the server for immediacy nobody asked for is not. - -import { emit, on } from "@novox/mesh-sdk/events"; -import { PlexClient, type PlexSession } from "./client.js"; - -const plex = PlexClient.fromEnv(); - -// Playback, by diffing the set of active sessions. Primed silently on the first look so a server -// that was already streaming when this started does not announce it as freshly begun. -const active = new Map(); -let playbackPrimed = false; -async function pollSessions(): Promise { - const sessions = await plex.getSessions(); - const now = new Map(sessions.map((s) => [s.key, s])); - if (playbackPrimed) { - for (const [key, s] of now) { - if (!active.has(key)) await emit("playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type }); - } - for (const [key, s] of active) { - if (!now.has(key)) await emit("playback.stopped", { title: s.title, user: s.user, player: s.player }); - } - } - active.clear(); - for (const [key, s] of now) active.set(key, s); - playbackPrimed = true; -} - -// New items, by diffing recently-added. Primed silently too, or a restart would re-announce the -// whole recent list as new. -const seen = new Set(); -let itemsPrimed = false; -async function pollRecent(): Promise { - const items = await plex.getRecentlyAdded(20); - for (const item of items) { - const id = `${item.title}@${item.addedAt ?? ""}`; - if (!seen.has(id)) { - if (itemsPrimed) await emit("item.added", item); - seen.add(id); - } - } - itemsPrimed = true; -} - -// A downloader finished somewhere on the mesh: rescan, so what it fetched becomes a visible item -// rather than a file Plex has not noticed. Idempotent — a rescan too many costs a little disk I/O. -await on("*.download.completed", async () => { - await plex.refreshAll(); -}); - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[plex] ${err}`)); - setInterval(run, everyMs); - run(); -}; -tick(pollSessions, 15_000); -tick(pollRecent, 60_000); - -console.log("[plex] watching sessions and recently-added, reacting to downloads"); diff --git a/modules/plex/module.json b/modules/plex/module.json deleted file mode 100644 index b126cf7..0000000 --- a/modules/plex/module.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "module": "plex", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "playback.started", - "playback.stopped", - "item.added" - ], - "consumes": [ - "*.download.completed" - ], - "own-secrets": { - "broker": "${dir:mesh-state}/broker", - "token": "${dir:mesh-state}/token" - }, - "listens": [ - { - "name": "stream", - "port": 32400, - "protocol": "tcp", - "from": "mesh", - "why": "streaming and the app; reaching it from outside is a route grant later" - } - ], - "accesses": [ - { - "id": "movies", - "path": "/services/media/movies", - "mode": "read" - }, - { - "id": "series", - "path": "/services/media/series", - "mode": "read" - }, - { - "id": "anime", - "path": "/services/media/anime", - "mode": "read" - }, - { - "id": "music", - "path": "/services/media/music", - "mode": "read" - }, - { - "id": "audiobooks", - "path": "/services/media/audiobooks", - "mode": "read" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/plex/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "transcode", - "type": "directory", - "path": "/services/plex/transcode", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "plex", - "image": "plexinc/pms-docker@sha256:83a425ae9e133b1cb2cc3b809556e01c61cd8ff65c582e41b4374bc2210bac9e", - "network": "host", - "env": { - "PLEX_UID": "1000", - "PLEX_GID": "1000", - "TZ": "Etc/UTC" - }, - "volumes": [ - "/services/plex/config:/config", - "/services/plex/transcode:/transcode", - "${access:movies}:/movies", - "${access:series}:/series", - "${access:anime}:/anime", - "${access:music}:/music", - "${access:audiobooks}:/audiobooks" - ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-plex", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}/token:/run/secrets/token:ro", - "/services/plex/config:/var/lib/plex/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_PLEX_URL": "http://127.0.0.1:32400", - "MESH_PLEX_TOKEN_FILE": "/run/secrets/token", - "MESH_PLEX_DATA_DIR": "/var/lib/plex" - }, - "artifact": "runtime" - } - ], - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/plex/package.json b/modules/plex/package.json deleted file mode 100644 index de67698..0000000 --- a/modules/plex/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-plex", - "version": "0.1.0", - "description": "plex — media server. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/plex/tools/index.ts b/modules/plex/tools/index.ts deleted file mode 100644 index 7cf344c..0000000 --- a/modules/plex/tools/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -// plex's tools — moved here from the shared sdk (novox/hq ADR 0039), importing plex's own client. -// They return structured data; the mesh serves them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { PlexClient } from "../client.js"; - -export function getPlexTools(plex: PlexClient): ToolDefinition[] { - return [ - { - name: "plex_status", - description: "Plex server status: server info, libraries, active sessions, recently added.", - input: {}, - run: async () => { - const [server, libraries, sessions, recent] = await Promise.all([ - plex.getServerInfo(), - plex.getLibraries(), - plex.getSessions(), - plex.getRecentlyAdded(10), - ]); - return { server, libraries, sessions, recentlyAdded: recent }; - }, - }, - { - name: "plex_reachable", - description: "Health probe: whether the Plex server answers, and which server it is. Never fails.", - input: {}, - run: async () => plex.reachable(), - }, - { - name: "plex_search", - description: "Search across all Plex libraries — movies, shows, episodes, music.", - input: { query: { type: "string", description: "the search query" } }, - run: async (args) => ({ query: String(args.query), results: await plex.search(String(args.query)) }), - }, - { - name: "plex_sessions", - description: "Active Plex playback sessions — who is watching what, and where.", - input: {}, - run: async () => { - const sessions = await plex.getSessions(); - return { count: sessions.length, sessions }; - }, - }, - { - name: "plex_recently_added", - description: "Recently added media in Plex.", - input: { limit: { type: "number", description: "how many items (default 20)" } }, - run: async (args) => ({ items: await plex.getRecentlyAdded(args.limit ? Number(args.limit) : 20) }), - }, - { - name: "plex_refresh", - description: "Ask Plex to rescan its libraries so new files on disk become visible items.", - input: { library: { type: "string", description: "a library section key; omitted rescans all" } }, - run: async (args) => { - if (args.library) { - await plex.refreshLibrary(String(args.library)); - return { refreshed: String(args.library) }; - } - await plex.refreshAll(); - return { refreshed: "all" }; - }, - }, - ]; -} - -// The tools exist only when a token can be found; without one, plex contributes none rather than -// failing the whole runtime. -registerModuleTools("plex", (env) => { - try { - return getPlexTools(PlexClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/plex/tsconfig.json b/modules/plex/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/plex/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/qbittorrent/Dockerfile b/modules/qbittorrent/Dockerfile deleted file mode 100644 index 91832e0..0000000 --- a/modules/qbittorrent/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# qbittorrent's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/qbittorrent -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/qbittorrent/dist /app/modules/qbittorrent/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/qbittorrent/dist/index.js,/app/modules/qbittorrent/dist/tools/index.js diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts deleted file mode 100644 index a5c9e80..0000000 --- a/modules/qbittorrent/client.ts +++ /dev/null @@ -1,188 +0,0 @@ -// The qBittorrent API client — qbittorrent's own code, living in the module (novox/hq ADR 0039). -// Written against the WebUI API (/api/v2/...), self-contained so a change to it rebuilds only -// qbittorrent. Both this module's tools and its events entrypoint import it, and nothing outside -// qbittorrent does. -// -// The WebUI authenticates with a session cookie (SID) obtained by POSTing credentials, and guards -// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is -// captured on login and carried by hand on every later call, with a single re-login on expiry. - -import { readFileSync } from "node:fs"; - -export interface QbTransferInfo { - dlSpeedBytesPerSec: number; - upSpeedBytesPerSec: number; - dlData: number; - upData: number; - connectionStatus: string; -} - -export interface QbTorrent { - hash: string; - name: string; - /** qBittorrent's state, e.g. "downloading", "stalledUP", "uploading", "pausedUP", "error". */ - state: string; - /** 0..1 — 1 means the download is complete. */ - progress: number; - sizeBytes: number; - dlSpeed: number; - upSpeed: number; - category: string; - ratio: number; - savePath: string; -} - -/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ -function meshConfig(file?: string): Record { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - catch { return {}; } -} - -/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); - * absent or unreadable yields undefined so callers fall back rather than crash. */ -function readSecret(file?: string): string | undefined { - if (!file) return undefined; - try { return readFileSync(file, "utf8").trim(); } - catch { return undefined; } -} - -export class QbittorrentClient { - readonly baseUrl: string; - private sid: string | null = null; - - constructor( - baseUrl: string, - private readonly user: string, - private readonly password: string, - ) { - this.baseUrl = baseUrl.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. URL and password are read from - * MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD; both must be present — an unconfigured - * qBittorrent throws rather than pretend to be reachable, so the tools/events simply do not load - * (the harness treats the throw as "exposes nothing"). The user defaults to "admin". - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { - const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); - const url = cfg.url ?? env.MESH_QBITTORRENT_URL; - const password = cfg.password ?? readSecret(env.MESH_QBITTORRENT_PASSWORD_FILE) ?? env.MESH_QBITTORRENT_PASSWORD; - if (!url || !password) { - throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); - } - const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin"; - return new QbittorrentClient(url, user, password); - } - - private async login(): Promise { - const res = await fetch(`${this.baseUrl}/api/v2/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: this.baseUrl }, - body: new URLSearchParams({ username: this.user, password: this.password }), - }); - if (!res.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`); - if ((await res.text()).trim() !== "Ok.") { - throw new Error("qBittorrent login rejected — check credentials"); - } - const match = res.headers.get("set-cookie")?.match(/SID=([^;]+)/); - if (!match) throw new Error("qBittorrent login returned no SID cookie"); - this.sid = match[1]; - } - - private async call(method: "GET" | "POST", path: string, form?: Record): Promise { - if (!this.sid) await this.login(); - const doFetch = (): Promise => { - const headers: Record = { Referer: this.baseUrl, Cookie: `SID=${this.sid}` }; - const init: RequestInit = { method, headers }; - if (form) { - headers["Content-Type"] = "application/x-www-form-urlencoded"; - init.body = new URLSearchParams(form); - } - return fetch(`${this.baseUrl}/api/v2/${path}`, init); - }; - let res = await doFetch(); - if (res.status === 403) { - // The SID expired — re-authenticate once and retry, rather than fail a routine call. - await this.login(); - res = await doFetch(); - } - return res; - } - - private async getJson(path: string): Promise { - const res = await this.call("GET", path); - if (!res.ok) throw new Error(`qBittorrent GET ${path}: ${res.status} ${await res.text()}`); - return (await res.json()) as T; - } - - async getVersion(): Promise { - const res = await this.call("GET", "app/version"); - if (!res.ok) throw new Error(`qBittorrent app/version: ${res.status}`); - return (await res.text()).trim(); - } - - async getTransferInfo(): Promise { - const d = await this.getJson>("transfer/info"); - return { - dlSpeedBytesPerSec: Number(d.dl_info_speed ?? 0), - upSpeedBytesPerSec: Number(d.up_info_speed ?? 0), - dlData: Number(d.dl_info_data ?? 0), - upData: Number(d.up_info_data ?? 0), - connectionStatus: String(d.connection_status ?? "unknown"), - }; - } - - async getTorrents(filter?: string): Promise { - const path = filter ? `torrents/info?filter=${encodeURIComponent(filter)}` : "torrents/info"; - const list = await this.getJson[]>(path); - return list.map((t) => ({ - hash: String(t.hash), - name: String(t.name ?? "Unknown"), - state: String(t.state ?? "unknown"), - progress: Number(t.progress ?? 0), - sizeBytes: Number(t.size ?? 0), - dlSpeed: Number(t.dlspeed ?? 0), - upSpeed: Number(t.upspeed ?? 0), - category: String(t.category ?? ""), - ratio: Number(t.ratio ?? 0), - savePath: String(t.save_path ?? ""), - })); - } - - /** Add a torrent by magnet or http(s) .torrent URL, optionally into a category / save path. */ - async add(url: string, category = "", savepath = "", paused = false): Promise { - const form: Record = { urls: url, paused: paused ? "true" : "false" }; - if (category) form.category = category; - if (savepath) form.savepath = savepath; - const res = await this.call("POST", "torrents/add", form); - const text = (await res.text()).trim(); - if (!res.ok || text.toLowerCase() === "fails.") { - throw new Error(`qBittorrent refused the torrent: ${res.status} ${text}`); - } - } - - // qBittorrent 5.x renamed pause/resume to stop/start; try the modern name and fall back to the - // legacy one on a 404, so the client works against both. - private async command(modern: string, legacy: string, hashes: string): Promise { - let res = await this.call("POST", `torrents/${modern}`, { hashes }); - if (res.status === 404) res = await this.call("POST", `torrents/${legacy}`, { hashes }); - if (!res.ok) throw new Error(`qBittorrent torrents/${modern}: ${res.status} ${await res.text()}`); - } - - /** Pause torrents — a pipe-separated hash list, or "all" (the default). */ - async pause(hashes = "all"): Promise { - await this.command("stop", "pause", hashes); - } - - /** Resume torrents — a pipe-separated hash list, or "all" (the default). */ - async resume(hashes = "all"): Promise { - await this.command("start", "resume", hashes); - } - - async delete(hashes: string, deleteFiles = false): Promise { - const res = await this.call("POST", "torrents/delete", { hashes, deleteFiles: deleteFiles ? "true" : "false" }); - if (!res.ok) throw new Error(`qBittorrent torrents/delete: ${res.status} ${await res.text()}`); - } -} diff --git a/modules/qbittorrent/index.ts b/modules/qbittorrent/index.ts deleted file mode 100644 index 5f136d1..0000000 --- a/modules/qbittorrent/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -// qbittorrent's events. The tool runtime imports this once the broker is bound. It watches the -// torrent list and turns its comings and goings into mesh events. -// -// Emits (novox/hq ADR 0041/0042): -// module.qbittorrent.download.added — a torrent was added -// module.qbittorrent.download.completed — a torrent finished downloading (progress reached 1). -// This exact routing key is what the plex module -// consumes (module.*.download.completed) to rescan, so -// the new file becomes a visible item. -// Consumes: none. -// -// The torrent list is polled and diffed by hash, primed silently on the first look (like plex's and -// sonarr's index.ts) so a restart does not re-announce everything already present. Completion is a -// progress crossing from below 1 to exactly 1 — a torrent added already-complete is announced only -// as added, never as freshly completed, since nothing was downloaded. - -import { emit } from "@novox/mesh-sdk/events"; -import { QbittorrentClient } from "./client.js"; - -const qb = QbittorrentClient.fromEnv(); - -const progressByHash = new Map(); -let primed = false; - -async function pollTorrents(): Promise { - const torrents = await qb.getTorrents(); - const now = new Map(torrents.map((t) => [t.hash, t])); - - if (primed) { - for (const [hash, t] of now) { - const before = progressByHash.get(hash); - if (before === undefined) { - await emit("download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); - } else if (before < 1 && t.progress >= 1) { - await emit("download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); - } - } - } - - progressByHash.clear(); - for (const [hash, t] of now) progressByHash.set(hash, t.progress); - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[qbittorrent] ${err}`)); - setInterval(run, everyMs); - run(); -}; -tick(pollTorrents, 20_000); - -console.log("[qbittorrent] watching torrents, emitting adds and completions"); diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json deleted file mode 100644 index d36a672..0000000 --- a/modules/qbittorrent/module.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "module": "qbittorrent", - "version": "1", - "slug": "qbt", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "download.added", - "download.completed" - ], - "consumes": [], - "own-secrets": { - "broker": "${dir:mesh-state}/broker", - "password": "${dir:mesh-state}/password" - }, - "listens": [ - { - "name": "web", - "port": 8080, - "protocol": "tcp", - "from": "mesh", - "why": "the download client's pages" - } - ], - "accesses": [ - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read-write" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/qbittorrent/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "qbittorrent", - "image": "lscr.io/linuxserver/qbittorrent@sha256:a00b6a597a3832a1814cde0ef60abc55c94644f3f80902c3432f6af6de8d4a96", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "8080" - ], - "volumes": [ - "/services/qbittorrent/config:/config", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime-config", - "type": "file", - "path": "${dir:mesh-state}/config.json", - "mode": "0600", - "content": "{}\n", - "merge": "json" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-qbittorrent", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}/password:/run/secrets/password:ro", - "${dir:mesh-state}/config.json:/run/config/config.json:ro", - "/services/qbittorrent/config:/var/lib/qbittorrent/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", - "MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password", - "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", - "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" - }, - "restart-on": [ - "runtime-config" - ], - "artifact": "runtime" - } - ], - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/qbittorrent/package.json b/modules/qbittorrent/package.json deleted file mode 100644 index d49151a..0000000 --- a/modules/qbittorrent/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-qbittorrent", - "version": "0.1.0", - "description": "qbittorrent — BitTorrent download client. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/qbittorrent/tools/index.ts b/modules/qbittorrent/tools/index.ts deleted file mode 100644 index 3e36611..0000000 --- a/modules/qbittorrent/tools/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -// qbittorrent's tools — living in the module (novox/hq ADR 0039), importing qbittorrent's own -// client. They return structured data; the mesh serves them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { QbittorrentClient } from "../client.js"; - -export function getQbittorrentTools(qb: QbittorrentClient): ToolDefinition[] { - return [ - { - name: "qbittorrent_status", - description: "qBittorrent status: version, global transfer rates, and how many torrents are active.", - input: {}, - run: async () => { - const [version, transfer, torrents] = await Promise.all([ - qb.getVersion(), - qb.getTransferInfo(), - qb.getTorrents(), - ]); - const downloading = torrents.filter((t) => t.progress < 1).length; - return { version, transfer, torrents: torrents.length, downloading, seeding: torrents.length - downloading }; - }, - }, - { - name: "qbittorrent_torrents", - description: "List torrents — name, state, progress and speed. Optional filter narrows the set.", - input: { - filter: { type: "string", description: "one of all|downloading|seeding|completed|paused|active|inactive|stalled" }, - }, - run: async (args) => { - const items = await qb.getTorrents(args.filter ? String(args.filter) : undefined); - return { count: items.length, torrents: items }; - }, - }, - { - name: "qbittorrent_add", - description: "Add a torrent by magnet link or .torrent URL, optionally into a category.", - input: { - url: { type: "string", description: "magnet link or http(s) URL to a .torrent" }, - category: { type: "string", description: "category name (determines save directory)" }, - savepath: { type: "string", description: "explicit save path (overrides the category default)" }, - paused: { type: "boolean", description: "add in paused state (default false)" }, - }, - run: async (args) => { - await qb.add( - String(args.url), - args.category ? String(args.category) : "", - args.savepath ? String(args.savepath) : "", - args.paused === true || args.paused === "true", - ); - return { added: String(args.url), category: args.category ? String(args.category) : null }; - }, - }, - { - name: "qbittorrent_pause", - description: "Pause torrents — a pipe-separated hash list, or 'all' (the default).", - input: { hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all' (default)" } }, - run: async (args) => { - const hashes = args.hashes ? String(args.hashes) : "all"; - await qb.pause(hashes); - return { paused: hashes }; - }, - }, - { - name: "qbittorrent_resume", - description: "Resume torrents — a pipe-separated hash list, or 'all' (the default).", - input: { hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all' (default)" } }, - run: async (args) => { - const hashes = args.hashes ? String(args.hashes) : "all"; - await qb.resume(hashes); - return { resumed: hashes }; - }, - }, - { - name: "qbittorrent_delete", - description: "Remove torrents by hash, optionally deleting their files on disk.", - input: { - hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all'" }, - deleteFiles: { type: "boolean", description: "also delete downloaded files (default false)" }, - }, - run: async (args) => { - const hashes = String(args.hashes); - const deleteFiles = args.deleteFiles === true || args.deleteFiles === "true"; - await qb.delete(hashes, deleteFiles); - return { deleted: hashes, deleteFiles }; - }, - }, - ]; -} - -// The tools exist only when qBittorrent is configured; without a URL and password, qbittorrent -// contributes none rather than failing the whole runtime. -registerModuleTools("qbittorrent", (env) => { - try { - return getQbittorrentTools(QbittorrentClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/qbittorrent/tsconfig.json b/modules/qbittorrent/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/qbittorrent/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/radarr/Dockerfile b/modules/radarr/Dockerfile deleted file mode 100644 index 4a04bde..0000000 --- a/modules/radarr/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# radarr's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/radarr -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/radarr/dist /app/modules/radarr/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/radarr/dist/index.js,/app/modules/radarr/dist/tools/index.js diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts deleted file mode 100644 index 528d837..0000000 --- a/modules/radarr/client.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The Radarr API client — radarr's own code, living in the module (novox/hq ADR 0039). Ported from -// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a -// change to Radarr's API rebuilds only radarr and nothing else. Both this module's tools and its -// events entrypoint import it, and nothing outside radarr does. - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -// Radarr speaks the v3 API; its content is "movie". -const API_VERSION = "v3"; -const CONTENT_ENDPOINT = "movie"; -const APP_NAME = "Radarr"; - -export interface RadarrQueueItem { - /** The queue record id — stable while the item is in the queue, so events can diff on it. */ - id: number; - title: string; - status: string; - size: string; - sizeleft: string; - timeleft?: string; -} - -export interface RadarrCalendarItem { - title: string; - date: string; - overview?: string; -} - -export interface RadarrContentItem { - title: string; - year?: number; - status?: string; - monitored: boolean; -} - -export class RadarrClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. The URL defaults to the server on this node (the - * runtime shares its network), and the API key is read from MESH_RADARR_API_KEY or, failing that, - * discovered from the server's own config.xml under MESH_RADARR_CONFIG_DIR — the same file Radarr - * writes it to, so a running server needs nothing configured by hand. Throws when no key can be - * found, so the tools/events simply do not load (the harness treats the throw as "exposes - * nothing"). - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): RadarrClient { - const url = env.MESH_RADARR_URL ?? `http://127.0.0.1:${env.MESH_RADARR_PORT ?? "7878"}`; - const configDir = env.MESH_RADARR_CONFIG_DIR ?? "/config"; - const apiKey = env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir); - if (!apiKey) { - throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable"); - } - return new RadarrClient(url, apiKey); - } - - /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app - * writes into config.xml at the root of its config directory. */ - static detectApiKey(configDir: string): string | null { - const config = join(configDir, "config.xml"); - if (existsSync(config)) { - const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); - if (match) return match[1]; - } - return null; - } - - private async get(endpoint: string, params?: Record): Promise { - const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); - if (params) { - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); - if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); - return res.json(); - } - - async getStatus(): Promise<{ appName: string; version: string }> { - const data = (await this.get("system/status")) as { appName?: string; version?: string }; - return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; - } - - async getContent(limit?: number): Promise { - const data = await this.get(CONTENT_ENDPOINT); - const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); - const mapped = items.map((item) => ({ - title: item.title ?? "Unknown", - year: item.year, - status: item.status, - monitored: item.monitored ?? true, - })); - return limit ? mapped.slice(0, limit) : mapped; - } - - /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ - async searchContent(term: string): Promise { - const all = await this.getContent(); - const lower = term.toLowerCase(); - return all.filter((item) => item.title.toLowerCase().includes(lower)); - } - - async getQueue(): Promise<{ totalRecords: number; items: RadarrQueueItem[] }> { - const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; - const records = data.records ?? []; - return { - totalRecords: data.totalRecords ?? records.length, - items: records.map((r) => ({ - id: r.id, - title: r.title ?? r.movie?.title ?? "Unknown", - status: r.status ?? "unknown", - size: formatBytes(r.size ?? 0), - sizeleft: formatBytes(r.sizeleft ?? 0), - timeleft: r.timeleft, - })), - }; - } - - async getCalendar(days = 7): Promise { - const start = new Date().toISOString().split("T")[0]; - const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; - const data = await this.get("calendar", { start, end }); - const items: any[] = Array.isArray(data) ? data : []; - return items.map((item) => ({ - title: item.title ?? item.movie?.title ?? "Unknown", - date: item.inCinemas ?? item.digitalRelease ?? "", - overview: item.overview?.slice(0, 150), - })); - } -} - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; -} diff --git a/modules/radarr/index.ts b/modules/radarr/index.ts deleted file mode 100644 index 801e727..0000000 --- a/modules/radarr/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -// radarr's events. The tool runtime imports this once the broker is bound. It watches the download -// queue and turns its comings and goings into mesh events. -// -// Emits (novox/hq ADR 0041/0042): -// module.radarr.movie.grabbed — a release entered the queue (Radarr grabbed it) -// module.radarr.download.completed — a release left the queue, imported. This exact routing key -// is what the plex module consumes (module.*.download.completed) -// to rescan, so the new movie becomes a visible item. -// Consumes: none. -// -// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a -// restart mid-download does not re-announce everything already in flight as freshly grabbed. - -import { emit } from "@novox/mesh-sdk/events"; -import { RadarrClient, type RadarrQueueItem } from "./client.js"; - -// Building the client throws when Radarr has no URL/key yet. Like the tools (see tools/index.ts), -// the events entrypoint must not crash the runtime for that — it stays idle until configured. -function buildClient(): RadarrClient | null { - try { - return RadarrClient.fromEnv(); - } catch { - return null; - } -} - -const radarr = buildClient(); - -// Radarr removes an item from the queue once it has been imported; a "warning"/"failed" status is -// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. -const FAILED_STATUSES = new Set(["failed", "warning"]); - -const inQueue = new Map(); -let primed = false; - -async function pollQueue(radarr: RadarrClient): Promise { - const { items } = await radarr.getQueue(); - const now = new Map(items.map((i) => [i.id, i])); - - if (primed) { - // Entered the queue since last look — Radarr grabbed a release. - for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("movie.grabbed", { title: item.title, status: item.status }); - } - // Left the queue — imported and done, unless it was last seen failing. - for (const [id, item] of inQueue) { - if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("download.completed", { title: item.title }); - } - } - } - - inQueue.clear(); - for (const [id, item] of now) inQueue.set(id, item); - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[radarr] ${err}`)); - setInterval(run, everyMs); - run(); -}; - -if (radarr) { - tick(() => pollQueue(radarr), 30_000); - console.log("[radarr] watching the download queue, emitting grabs and completions"); -} else { - console.log("[radarr] not configured — events idle until an API key is available"); -} diff --git a/modules/radarr/module.json b/modules/radarr/module.json deleted file mode 100644 index 2e5c2b3..0000000 --- a/modules/radarr/module.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "module": "radarr", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "movie.grabbed", - "download.completed" - ], - "consumes": [], - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "listens": [ - { - "name": "web", - "port": 7878, - "protocol": "tcp", - "from": "mesh", - "why": "managing films" - } - ], - "accesses": [ - { - "id": "movies", - "path": "/services/media/movies", - "mode": "read-write" - }, - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read-write" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/radarr/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "radarr", - "image": "lscr.io/linuxserver/radarr@sha256:119aaa4a4f7349bcd2a136c5373a0d7925b5479915c7dfe0c0ad352db2a6d438", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "7878" - ], - "volumes": [ - "/services/radarr/config:/config", - "${access:movies}:/movies", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-radarr", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "/services/radarr/config:/var/lib/radarr/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RADARR_URL": "http://127.0.0.1:7878", - "MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config" - }, - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "movies", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/radarr/package.json b/modules/radarr/package.json deleted file mode 100644 index af01eac..0000000 --- a/modules/radarr/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-radarr", - "version": "0.1.0", - "description": "radarr — movie management. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/radarr/tools/index.ts b/modules/radarr/tools/index.ts deleted file mode 100644 index db0544a..0000000 --- a/modules/radarr/tools/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -// radarr's tools — ported from the shared hal sdk (novox/hq ADR 0039), importing radarr's own -// client. They return structured data (not pre-formatted text as hal did); the mesh serves them -// through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { RadarrClient } from "../client.js"; - -export function getRadarrTools(radarr: RadarrClient): ToolDefinition[] { - return [ - { - name: "radarr_status", - description: "Radarr status overview: version, movie count, monitored count, queue size.", - input: {}, - run: async () => { - const [status, content, queue] = await Promise.all([ - radarr.getStatus(), - radarr.getContent(), - radarr.getQueue(), - ]); - return { - app: status.appName, - version: status.version, - movies: content.length, - monitored: content.filter((c) => c.monitored).length, - queue: queue.totalRecords, - }; - }, - }, - { - name: "radarr_library", - description: "List movies from the Radarr library.", - input: { limit: { type: "number", description: "max items to return (default 50)" } }, - run: async (args) => { - const items = await radarr.getContent(args.limit ? Number(args.limit) : 50); - return { count: items.length, movies: items }; - }, - }, - { - name: "radarr_search", - description: "Search the Radarr library for movies by title (filters existing content, not indexers).", - input: { query: { type: "string", description: "the search term" } }, - run: async (args) => { - const query = String(args.query); - return { query, results: await radarr.searchContent(query) }; - }, - }, - { - name: "radarr_queue", - description: "Show the Radarr download queue — what is downloading and how far along.", - input: {}, - run: async () => { - const queue = await radarr.getQueue(); - return { count: queue.totalRecords, items: queue.items }; - }, - }, - { - name: "radarr_calendar", - description: "Upcoming movie releases from the Radarr calendar.", - input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, - run: async (args) => { - const days = args.days ? Number(args.days) : 7; - const items = await radarr.getCalendar(days); - items.sort((a, b) => a.date.localeCompare(b.date)); - return { days, count: items.length, items }; - }, - }, - ]; -} - -// The tools exist only when Radarr is configured; without a URL and key, radarr contributes none -// rather than failing the whole runtime. -registerModuleTools("radarr", (env) => { - try { - return getRadarrTools(RadarrClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/radarr/tsconfig.json b/modules/radarr/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/radarr/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/sonarr/Dockerfile b/modules/sonarr/Dockerfile deleted file mode 100644 index 9dcd59f..0000000 --- a/modules/sonarr/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# sonarr's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/sonarr -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/sonarr/dist /app/modules/sonarr/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/sonarr/dist/index.js,/app/modules/sonarr/dist/tools/index.js diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts deleted file mode 100644 index 47ff70b..0000000 --- a/modules/sonarr/client.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The Sonarr API client — sonarr's own code, living in the module (novox/hq ADR 0039). Ported from -// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a -// change to Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its -// events entrypoint import it, and nothing outside sonarr does. - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -// Sonarr speaks the v3 API; its content is "series". -const API_VERSION = "v3"; -const CONTENT_ENDPOINT = "series"; -const APP_NAME = "Sonarr"; - -export interface SonarrQueueItem { - /** The queue record id — stable while the item is in the queue, so events can diff on it. */ - id: number; - title: string; - status: string; - size: string; - sizeleft: string; - timeleft?: string; -} - -export interface SonarrCalendarItem { - title: string; - date: string; - overview?: string; -} - -export interface SonarrContentItem { - title: string; - year?: number; - status?: string; - monitored: boolean; -} - -export class SonarrClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. The URL defaults to the server on this node - * (the runtime shares its network), and the API key is read from MESH_SONARR_API_KEY or, failing - * that, discovered from the server's own config.xml under MESH_SONARR_CONFIG_DIR — the same file - * Sonarr writes it to, so a running server needs nothing configured by hand (as plex does with - * its token). Throws when no key can be found, so the tools/events simply do not load (the harness - * treats the throw as "exposes nothing"). - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): SonarrClient { - const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`; - const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config"; - const apiKey = env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir); - if (!apiKey) { - throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable"); - } - return new SonarrClient(url, apiKey); - } - - /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app - * writes into config.xml at the root of its config directory. */ - static detectApiKey(configDir: string): string | null { - const config = join(configDir, "config.xml"); - if (existsSync(config)) { - const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); - if (match) return match[1]; - } - return null; - } - - private async get(endpoint: string, params?: Record): Promise { - const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); - if (params) { - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); - if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); - return res.json(); - } - - async getStatus(): Promise<{ appName: string; version: string }> { - const data = (await this.get("system/status")) as { appName?: string; version?: string }; - return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; - } - - async getContent(limit?: number): Promise { - const data = await this.get(CONTENT_ENDPOINT); - const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); - const mapped = items.map((item) => ({ - title: item.title ?? "Unknown", - year: item.year, - status: item.status, - monitored: item.monitored ?? true, - })); - return limit ? mapped.slice(0, limit) : mapped; - } - - /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ - async searchContent(term: string): Promise { - const all = await this.getContent(); - const lower = term.toLowerCase(); - return all.filter((item) => item.title.toLowerCase().includes(lower)); - } - - async getQueue(): Promise<{ totalRecords: number; items: SonarrQueueItem[] }> { - const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; - const records = data.records ?? []; - return { - totalRecords: data.totalRecords ?? records.length, - items: records.map((r) => ({ - id: r.id, - title: r.title ?? r.series?.title ?? "Unknown", - status: r.status ?? "unknown", - size: formatBytes(r.size ?? 0), - sizeleft: formatBytes(r.sizeleft ?? 0), - timeleft: r.timeleft, - })), - }; - } - - async getCalendar(days = 7): Promise { - const start = new Date().toISOString().split("T")[0]; - const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; - const data = await this.get("calendar", { start, end }); - const items: any[] = Array.isArray(data) ? data : []; - return items.map((item) => ({ - title: item.title ?? item.series?.title ?? "Unknown", - date: item.airDateUtc ?? "", - overview: item.overview?.slice(0, 150), - })); - } -} - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; -} diff --git a/modules/sonarr/index.ts b/modules/sonarr/index.ts deleted file mode 100644 index e062ddf..0000000 --- a/modules/sonarr/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -// sonarr's events. The tool runtime imports this once the broker is bound. It watches the download -// queue and turns its comings and goings into mesh events. -// -// Emits (novox/hq ADR 0041/0042): -// module.sonarr.episode.grabbed — a release entered the queue (Sonarr grabbed it) -// module.sonarr.download.completed — a release left the queue, imported. This exact routing key -// is what the plex module consumes (module.*.download.completed) -// to rescan, so the new episode becomes a visible item. -// Consumes: none. -// -// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a -// restart mid-download does not re-announce everything already in flight as freshly grabbed. - -import { emit } from "@novox/mesh-sdk/events"; -import { SonarrClient, type SonarrQueueItem } from "./client.js"; - -// Building the client throws when Sonarr has no URL/key yet. Like the tools (see tools/index.ts), -// the events entrypoint must not crash the runtime for that — it stays idle until configured. -function buildClient(): SonarrClient | null { - try { - return SonarrClient.fromEnv(); - } catch { - return null; - } -} - -const sonarr = buildClient(); - -// Sonarr removes an item from the queue once it has been imported; a "warning"/"failed" status is -// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. -const FAILED_STATUSES = new Set(["failed", "warning"]); - -const inQueue = new Map(); -let primed = false; - -async function pollQueue(sonarr: SonarrClient): Promise { - const { items } = await sonarr.getQueue(); - const now = new Map(items.map((i) => [i.id, i])); - - if (primed) { - // Entered the queue since last look — Sonarr grabbed a release. - for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("episode.grabbed", { title: item.title, status: item.status }); - } - // Left the queue — imported and done, unless it was last seen failing. - for (const [id, item] of inQueue) { - if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("download.completed", { title: item.title }); - } - } - } - - inQueue.clear(); - for (const [id, item] of now) inQueue.set(id, item); - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[sonarr] ${err}`)); - setInterval(run, everyMs); - run(); -}; - -if (sonarr) { - tick(() => pollQueue(sonarr), 30_000); - console.log("[sonarr] watching the download queue, emitting grabs and completions"); -} else { - console.log("[sonarr] not configured — events idle until an API key is available"); -} diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json deleted file mode 100644 index 7dfee83..0000000 --- a/modules/sonarr/module.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "module": "sonarr", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "episode.grabbed", - "download.completed" - ], - "consumes": [], - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "listens": [ - { - "name": "web", - "port": 8989, - "protocol": "tcp", - "from": "mesh", - "why": "managing series" - } - ], - "accesses": [ - { - "id": "series", - "path": "/services/media/series", - "mode": "read-write" - }, - { - "id": "anime", - "path": "/services/media/anime", - "mode": "read-write" - }, - { - "id": "downloads", - "path": "/services/media/downloads", - "mode": "read-write" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/sonarr/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "sonarr", - "image": "lscr.io/linuxserver/sonarr@sha256:c19aa4ecdf03d73e1d5c901da33744cb7eb4d921f89bafed1ca264601d7fa224", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "8989" - ], - "volumes": [ - "/services/sonarr/config:/config", - "${access:series}:/series", - "${access:anime}:/anime", - "${access:downloads}:/downloads" - ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-sonarr", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "/services/sonarr/config:/var/lib/sonarr/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_SONARR_URL": "http://127.0.0.1:8989", - "MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config" - }, - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "series", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/sonarr/package.json b/modules/sonarr/package.json deleted file mode 100644 index fec0933..0000000 --- a/modules/sonarr/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-sonarr", - "version": "0.1.0", - "description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/sonarr/tools/index.ts b/modules/sonarr/tools/index.ts deleted file mode 100644 index fc8ce85..0000000 --- a/modules/sonarr/tools/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -// sonarr's tools — ported from the shared hal sdk (novox/hq ADR 0039), importing sonarr's own -// client. They return structured data (not pre-formatted text as hal did); the mesh serves them -// through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { SonarrClient } from "../client.js"; - -export function getSonarrTools(sonarr: SonarrClient): ToolDefinition[] { - return [ - { - name: "sonarr_status", - description: "Sonarr status overview: version, series count, monitored count, queue size.", - input: {}, - run: async () => { - const [status, content, queue] = await Promise.all([ - sonarr.getStatus(), - sonarr.getContent(), - sonarr.getQueue(), - ]); - return { - app: status.appName, - version: status.version, - series: content.length, - monitored: content.filter((c) => c.monitored).length, - queue: queue.totalRecords, - }; - }, - }, - { - name: "sonarr_library", - description: "List series from the Sonarr library.", - input: { limit: { type: "number", description: "max items to return (default 50)" } }, - run: async (args) => { - const items = await sonarr.getContent(args.limit ? Number(args.limit) : 50); - return { count: items.length, series: items }; - }, - }, - { - name: "sonarr_search", - description: "Search the Sonarr library for series by title (filters existing content, not indexers).", - input: { query: { type: "string", description: "the search term" } }, - run: async (args) => { - const query = String(args.query); - return { query, results: await sonarr.searchContent(query) }; - }, - }, - { - name: "sonarr_queue", - description: "Show the Sonarr download queue — what is downloading and how far along.", - input: {}, - run: async () => { - const queue = await sonarr.getQueue(); - return { count: queue.totalRecords, items: queue.items }; - }, - }, - { - name: "sonarr_calendar", - description: "Upcoming episode releases from the Sonarr calendar.", - input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, - run: async (args) => { - const days = args.days ? Number(args.days) : 7; - const items = await sonarr.getCalendar(days); - items.sort((a, b) => a.date.localeCompare(b.date)); - return { days, count: items.length, items }; - }, - }, - ]; -} - -// The tools exist only when Sonarr is configured; without a URL and key, sonarr contributes none -// rather than failing the whole runtime. -registerModuleTools("sonarr", (env) => { - try { - return getSonarrTools(SonarrClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/sonarr/tsconfig.json b/modules/sonarr/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/sonarr/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} diff --git a/modules/tautulli/Dockerfile b/modules/tautulli/Dockerfile deleted file mode 100644 index 13f4e9a..0000000 --- a/modules/tautulli/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# tautulli's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -WORKDIR /app/modules/tautulli -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/tautulli/dist /app/modules/tautulli/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/tautulli/dist/index.js,/app/modules/tautulli/dist/tools/index.js diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts deleted file mode 100644 index 69c71d5..0000000 --- a/modules/tautulli/client.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Tautulli's API client — tautulli's own code, living in the module (novox/hq ADR 0039). Both this -// module's tools and its events entrypoint import it, and nothing outside tautulli does. -// -// Tautulli speaks one endpoint: GET /api/v2?apikey=…&cmd=…&, answering -// { response: { result: "success" | "error", message, data } }. This client unwraps that envelope -// and hands back only the data. - -import { readFileSync } from "node:fs"; - -export interface TautulliSession { - user: string; - title: string; - mediaType: string; - state: string; - progressPercent: number; - player: string; -} - -export interface TautulliWatch { - /** Tautulli's history row id — the stable identity a recorded watch is diffed on. */ - id: number; - user: string; - title: string; - mediaType: string; - /** "watched" | "watching" | ... — Tautulli's own watched_status label. */ - watchedStatus: string; - percentComplete: number; - /** Unix seconds the play started, as Tautulli reports it. */ - date?: number; -} - -export interface TautulliHomeStat { - statId: string; - rows: Array>; -} - -/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ -function meshConfig(file?: string): Record { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - catch { return {}; } -} - -export class TautulliClient { - readonly baseUrl: string; - - constructor( - url: string, - private readonly apiKey: string, - ) { - this.baseUrl = url.replace(/\/$/, ""); - } - - /** - * Build from the module's resolved environment. The API key is read from MESH_TAUTULLI_APIKEY - * (Tautulli mints it in Settings → Web Interface); the base URL defaults to the local container. - * Throws when no key is configured — the module then contributes nothing rather than failing. - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { - const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE); - const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`); - const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY; - if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY"); - return new TautulliClient(url, apiKey); - } - - /** Call one Tautulli command and return its unwrapped data, throwing on a non-success result. */ - private async cmd(command: string, params: Record = {}): Promise { - const q = new URLSearchParams({ apikey: this.apiKey, cmd: command, ...params }); - const res = await fetch(`${this.baseUrl}/api/v2?${q.toString()}`); - if (!res.ok) throw new Error(`Tautulli ${command}: ${res.status} ${await res.text()}`); - const body = (await res.json()).response ?? {}; - if (body.result !== "success") throw new Error(`Tautulli ${command}: ${body.message ?? "error"}`); - return body.data; - } - - async getActivity(): Promise<{ streamCount: number; sessions: TautulliSession[] }> { - const data = await this.cmd("get_activity"); - const sessions = ((data?.sessions ?? []) as any[]).map((s) => ({ - user: s.friendly_name ?? s.user ?? "unknown", - title: s.full_title ?? s.title ?? "unknown", - mediaType: s.media_type ?? "unknown", - state: s.state ?? "unknown", - progressPercent: Number(s.progress_percent ?? 0), - player: s.player ?? "unknown", - })); - return { streamCount: Number(data?.stream_count ?? sessions.length), sessions }; - } - - async getHistory(length = 25): Promise { - const data = await this.cmd("get_history", { length: String(length), order_column: "date", order_dir: "desc" }); - return ((data?.data ?? []) as any[]).map((r) => ({ - id: Number(r.row_id ?? r.id ?? r.reference_id ?? 0), - user: r.friendly_name ?? r.user ?? "unknown", - title: r.full_title ?? r.title ?? "unknown", - mediaType: r.media_type ?? "unknown", - watchedStatus: String(r.watched_status ?? ""), - percentComplete: Number(r.percent_complete ?? 0), - date: r.date != null ? Number(r.date) : undefined, - })); - } - - /** The home-page statistics blocks — most-watched shows, most-active users, and so on. */ - async getHomeStats(): Promise { - const data = (await this.cmd("get_home_stats")) as any[]; - return (data ?? []).map((s) => ({ statId: s.stat_id, rows: s.rows ?? [] })); - } -} diff --git a/modules/tautulli/index.ts b/modules/tautulli/index.ts deleted file mode 100644 index 40aba14..0000000 --- a/modules/tautulli/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -// tautulli's events. The tool runtime imports this once the broker is bound. It watches Tautulli's -// history and announces each newly recorded watch. -// -// Emits (novox/hq ADR 0041/0042): -// module.tautulli.watch.recorded — a play appeared in Tautulli's history -// -// Diffed on the history row id and primed silently on the first look, so a restart does not -// re-announce the whole existing history as freshly watched. - -import { emit } from "@novox/mesh-sdk/events"; -import { TautulliClient, type TautulliWatch } from "./client.js"; - -// Constructed lazily so an unconfigured node (no API key) loads this entrypoint without crashing -// the events host — it simply watches nothing. -let tautulli: TautulliClient | undefined; -try { - tautulli = TautulliClient.fromEnv(); -} catch (err) { - console.log(`[tautulli] not configured, not watching history: ${err}`); -} - -const seen = new Set(); -let primed = false; - -async function pollHistory(client: TautulliClient): Promise { - const history = await client.getHistory(25); - for (const w of history) { - if (w.id === 0 || seen.has(w.id)) continue; - if (primed) await emitWatch(w); - seen.add(w.id); - } - primed = true; -} - -async function emitWatch(w: TautulliWatch): Promise { - await emit("watch.recorded", { - title: w.title, user: w.user, mediaType: w.mediaType, - watchedStatus: w.watchedStatus, percentComplete: w.percentComplete, at: w.date, - }); -} - -if (tautulli) { - const client = tautulli; - const run = (): void => void pollHistory(client).catch((err) => console.error(`[tautulli] ${err}`)); - setInterval(run, 60_000); - run(); - console.log("[tautulli] watching watch history"); -} diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json deleted file mode 100644 index c0b955b..0000000 --- a/modules/tautulli/module.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "module": "tautulli", - "version": "1", - "emits": [ - "watch.recorded" - ], - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "capabilities": [ - "container-runtime" - ], - "listens": [ - { - "name": "web", - "port": 8181, - "protocol": "tcp", - "from": "mesh", - "why": "watch statistics" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "config", - "type": "directory", - "path": "/services/tautulli/config", - "mode": "0700", - "owner": "1000:1000" - }, - { - "id": "server", - "type": "container", - "name": "tautulli", - "image": "lscr.io/linuxserver/tautulli@sha256:13f03ecfc61a7af89d492677389771ac29682a72153ce08a5cd4faffbc0197e8", - "env": { - "PUID": "1000", - "PGID": "1000", - "TZ": "Etc/UTC" - }, - "ports": [ - "8181" - ], - "volumes": [ - "/services/tautulli/config:/config" - ] - }, - { - "id": "runtime-config", - "type": "file", - "path": "${dir:mesh-state}/config.json", - "mode": "0600", - "content": "{}\n", - "merge": "json" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-tautulli", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}/config.json:/run/config/config.json:ro", - "/services/tautulli/config:/var/lib/tautulli/config:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_TAUTULLI_URL": "http://127.0.0.1:8181", - "MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json", - "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config" - }, - "restart-on": [ - "runtime-config" - ], - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "tautulli", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:mesh-state}/route.json" - }, - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/tautulli/package.json b/modules/tautulli/package.json deleted file mode 100644 index b8e3718..0000000 --- a/modules/tautulli/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-tautulli", - "version": "0.1.0", - "description": "tautulli — Plex watch statistics. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/tautulli/tools/index.ts b/modules/tautulli/tools/index.ts deleted file mode 100644 index 1ef4eed..0000000 --- a/modules/tautulli/tools/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -// tautulli's tools — importing tautulli's own client (novox/hq ADR 0039). They return structured -// data; the mesh serves them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { TautulliClient } from "../client.js"; - -export function getTautulliTools(tautulli: TautulliClient): ToolDefinition[] { - return [ - { - name: "tautulli_activity", - description: "Current Plex activity as Tautulli sees it — who is streaming what, and progress.", - input: {}, - run: async () => tautulli.getActivity(), - }, - { - name: "tautulli_history", - description: "Recent Plex watch history — who watched what, and whether they finished.", - input: { length: { type: "number", description: "how many rows (default 25)" } }, - run: async (args) => { - const history = await tautulli.getHistory(args.length ? Number(args.length) : 25); - return { count: history.length, history }; - }, - }, - { - name: "tautulli_stats", - description: "Tautulli home statistics — most-watched media, most-active users, and platforms.", - input: {}, - run: async () => { - const stats = await tautulli.getHomeStats(); - return { count: stats.length, stats }; - }, - }, - ]; -} - -// The tools exist only when an API key can be resolved; without one, tautulli contributes none -// rather than failing the whole tool runtime. -registerModuleTools("tautulli", (env) => { - try { - return getTautulliTools(TautulliClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/tautulli/tsconfig.json b/modules/tautulli/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/tautulli/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -}