- bookshelf: Servarr v1 fork on the radarr template (4 tools). - unifi: portainer-shaped tooled app (7 tools, 9 ports), settings-merged config. - fail2ban: host-level security module mirroring firewall (service + restart-on, no container); ban actions preserved as source ufw/iptables and FLAGGED to be rewritten nftables-native before it actually bans. - marrytts: manifest-only plain container (no tools), like resolv-conf. All typecheck against the built @novox/mesh-sdk; service images digest-pinned. Held from merge pending the hq initialization reconciliation. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
// The Bookshelf API client — bookshelf's own code, living in the module (novox/hq ADR 0044).
|
|
// 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]}`;
|
|
}
|