// The Bookshelf API client — bookshelf's own code, living in the module (novox/hq ADR 0039). // Ported from the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own // copy, so a change to Bookshelf's API rebuilds only bookshelf and nothing else. Both this module's // tools and its events entrypoint import it, and nothing outside bookshelf does. // // Bookshelf is a Readarr fork (ghcr.io/pennydreadful/bookshelf). It speaks the Servarr v1 API; its // content is "book". Unlike Sonarr/Radarr it exposes no calendar endpoint, so there is no calendar // tool here — matching hal, which excluded bookshelf from its calendar-capable apps. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; // Bookshelf speaks the v1 API; its content is "book". const API_VERSION = "v1"; const CONTENT_ENDPOINT = "book"; const APP_NAME = "Bookshelf"; export interface BookshelfQueueItem { /** The queue record id — stable while the item is in the queue, so events can diff on it. */ id: number; title: string; status: string; size: string; sizeleft: string; timeleft?: string; } export interface BookshelfContentItem { title: string; author?: string; year?: number; status?: string; monitored: boolean; } export class BookshelfClient { readonly baseUrl: string; constructor( url: string, private readonly apiKey: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** * Build from the module's resolved environment. The URL defaults to the server on this node (the * runtime shares its network), and the API key is read from MESH_BOOKSHELF_API_KEY or, failing * that, discovered from the server's own config.xml under MESH_BOOKSHELF_CONFIG_DIR — the same * file Bookshelf writes it to, so a running server needs nothing configured by hand. Throws when * no key can be found, so the tools/events simply do not load (the harness treats the throw as * "exposes nothing"). */ static fromEnv(env: NodeJS.ProcessEnv = process.env): BookshelfClient { const url = env.MESH_BOOKSHELF_URL ?? `http://127.0.0.1:${env.MESH_BOOKSHELF_PORT ?? "8787"}`; const configDir = env.MESH_BOOKSHELF_CONFIG_DIR ?? "/config"; const apiKey = env.MESH_BOOKSHELF_API_KEY ?? BookshelfClient.detectApiKey(configDir); if (!apiKey) { throw new Error("Bookshelf not configured — set MESH_BOOKSHELF_API_KEY or make the config dir readable"); } return new BookshelfClient(url, apiKey); } /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app * writes into config.xml at the root of its config directory. */ static detectApiKey(configDir: string): string | null { const config = join(configDir, "config.xml"); if (existsSync(config)) { const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); if (match) return match[1]; } return null; } private async get(endpoint: string, params?: Record): Promise { const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); if (params) { for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); } const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); return res.json(); } async getStatus(): Promise<{ appName: string; version: string }> { const data = (await this.get("system/status")) as { appName?: string; version?: string }; return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; } async getContent(limit?: number): Promise { const data = await this.get(CONTENT_ENDPOINT); const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); const mapped = items.map((item) => ({ title: item.title ?? "Unknown", author: item.author?.authorName ?? item.authorName, year: item.releaseDate ? new Date(item.releaseDate).getFullYear() : item.year, status: item.status, monitored: item.monitored ?? true, })); return limit ? mapped.slice(0, limit) : mapped; } /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ async searchContent(term: string): Promise { const all = await this.getContent(); const lower = term.toLowerCase(); return all.filter( (item) => item.title.toLowerCase().includes(lower) || (item.author?.toLowerCase().includes(lower) ?? false), ); } async getQueue(): Promise<{ totalRecords: number; items: BookshelfQueueItem[] }> { const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; const records = data.records ?? []; return { totalRecords: data.totalRecords ?? records.length, items: records.map((r) => ({ id: r.id, title: r.title ?? r.book?.title ?? r.author?.authorName ?? "Unknown", status: r.status ?? "unknown", size: formatBytes(r.size ?? 0), sizeleft: formatBytes(r.sizeleft ?? 0), timeleft: r.timeleft, })), }; } } function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; }