The media chain's stale copies leave the catalogue
bazarr, bookshelf, lidarr, nzbget, ombi, plex, qbittorrent, radarr, sonarr and tautulli live in novox/mesh-media-catalog (#195 moved jackett and left these behind). A build of this repository at a commit today registered plex and nzbget from these copies, which carry no provides, and ace's plan stopped resolving. Nothing here depends on the directories: home-assistant consumes their provisions by name.
This commit is contained in:
@@ -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
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ?? [] }));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user