Merge pull request 'The media chain's stale copies leave the catalogue' (#204) from chore/remove-stale-media-duplicates into main

This commit was merged in pull request #204.
This commit is contained in:
2026-10-01 12:54:00 +00:00
70 changed files with 0 additions and 4559 deletions
-24
View File
@@ -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
-173
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<string, string> = {}): Promise<any> {
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<string, string>): Promise<any> {
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<WantedSubtitle[]> {
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<ProviderSubtitle[]> {
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<ProviderSubtitle[]> {
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<HistoryEntry[]> {
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];
}
}
-47
View File
@@ -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<string>();
let primed = false;
async function pollHistory(): Promise<void> {
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<void>, 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");
-145
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-57
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-136
View File
@@ -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 <ApiKey> 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>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
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<BookshelfContentItem[]> {
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<BookshelfContentItem[]> {
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]}`;
}
-74
View File
@@ -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<number, BookshelfQueueItem>();
let primed = false;
async function pollQueue(bookshelf: BookshelfClient): Promise<void> {
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<void>, 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");
}
-120
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-69
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-144
View File
@@ -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 <ApiKey> 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>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
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<LidarrContentItem[]> {
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<LidarrContentItem[]> {
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<LidarrCalendarItem[]> {
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]}`;
}
-69
View File
@@ -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<number, LidarrQueueItem>();
let primed = false;
async function pollQueue(lidarr: LidarrClient): Promise<void> {
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<void>, 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");
}
-119
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-78
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-178
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<T>(method: string, params: unknown[] = []): Promise<T> {
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<string> {
return this.rpc<string>("version");
}
async getStatus(): Promise<NzbgetStatus> {
const s = await this.rpc<Record<string, number | boolean>>("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<NzbgetQueueItem[]> {
const groups = await this.rpc<Record<string, any>[]>("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<NzbgetHistoryItem[]> {
const history = await this.rpc<Record<string, any>[]>("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<number> {
const id = await this.rpc<number>("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<void> {
await this.rpc("pausedownload");
}
async resumeAll(): Promise<void> {
await this.rpc("resumedownload");
}
async pauseItem(id: number): Promise<void> {
await this.rpc("editqueue", ["GroupPause", "", [id]]);
}
async resumeItem(id: number): Promise<void> {
await this.rpc("editqueue", ["GroupResume", "", [id]]);
}
/** Delete an item from the queue or from history. */
async delete(id: number, from: "queue" | "history" = "queue"): Promise<void> {
await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]);
}
}
-64
View File
@@ -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<number>();
let queuePrimed = false;
async function pollQueue(): Promise<void> {
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<number>();
let historyPrimed = false;
async function pollHistory(): Promise<void> {
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<void>, 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");
-117
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-88
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-122
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<any> {
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<OmbiRequest[]> {
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<RequestCounts> {
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<void> {
await this.request("POST", `/Request/${kind}/approve`, { id });
}
}
-58
View File
@@ -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<string, boolean>();
let primed = false;
const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`;
async function pollRequests(): Promise<void> {
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<void>, 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");
-120
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-46
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-158
View File
@@ -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<any> {
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<PlexLibrary[]> {
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<PlexSession[]> {
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<PlexItem[]> {
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<PlexItem[]> {
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<void> {
await this.get(`/library/sections/${key}/refresh`);
}
/** Rescan every library, for when what arrived is not known to belong to one. */
async refreshAll(): Promise<void> {
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) };
}
}
}
-68
View File
@@ -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<string, PlexSession>();
let playbackPrimed = false;
async function pollSessions(): Promise<void> {
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<string>();
let itemsPrimed = false;
async function pollRecent(): Promise<void> {
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<void>, 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");
-137
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-74
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-188
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<void> {
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<string, string>): Promise<Response> {
if (!this.sid) await this.login();
const doFetch = (): Promise<Response> => {
const headers: Record<string, string> = { 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<T>(path: string): Promise<T> {
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<string> {
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<QbTransferInfo> {
const d = await this.getJson<Record<string, any>>("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<QbTorrent[]> {
const path = filter ? `torrents/info?filter=${encodeURIComponent(filter)}` : "torrents/info";
const list = await this.getJson<Record<string, any>[]>(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<void> {
const form: Record<string, string> = { 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<void> {
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<void> {
await this.command("stop", "pause", hashes);
}
/** Resume torrents — a pipe-separated hash list, or "all" (the default). */
async resume(hashes = "all"): Promise<void> {
await this.command("start", "resume", hashes);
}
async delete(hashes: string, deleteFiles = false): Promise<void> {
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()}`);
}
}
-52
View File
@@ -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<string, number>();
let primed = false;
async function pollTorrents(): Promise<void> {
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<void>, 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");
-118
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-98
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-144
View File
@@ -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 <ApiKey> 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>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
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<RadarrContentItem[]> {
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<RadarrContentItem[]> {
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<RadarrCalendarItem[]> {
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]}`;
}
-69
View File
@@ -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<number, RadarrQueueItem>();
let primed = false;
async function pollQueue(radarr: RadarrClient): Promise<void> {
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<void>, 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");
}
-119
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-78
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-144
View File
@@ -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 <ApiKey> 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>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
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<SonarrContentItem[]> {
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<SonarrContentItem[]> {
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<SonarrCalendarItem[]> {
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]}`;
}
-69
View File
@@ -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<number, SonarrQueueItem>();
let primed = false;
async function pollQueue(sonarr: SonarrClient): Promise<void> {
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<void>, 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");
}
-125
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-78
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}
-24
View File
@@ -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
-108
View File
@@ -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=…&<params>, 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<Record<string, unknown>>;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<string, string> = {}): Promise<any> {
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<TautulliWatch[]> {
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<TautulliHomeStat[]> {
const data = (await this.cmd("get_home_stats")) as any[];
return (data ?? []).map((s) => ({ statId: s.stat_id, rows: s.rows ?? [] }));
}
}
-48
View File
@@ -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<number>();
let primed = false;
async function pollHistory(client: TautulliClient): Promise<void> {
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<void> {
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");
}
-116
View File
@@ -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"
}
]
}
}
-14
View File
@@ -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"
}
}
-44
View File
@@ -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 [];
}
});
-12
View File
@@ -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"]
}