// The photo app's API client — photos' own code, living in the module (novox/hq ADR 0039). The // module packages a self-hosted photo library (immich-shaped: a REST API under `/api`, authenticated // by an API key sent as the `x-api-key` header). The client speaks only what the tools and the // item-added event need: server version and statistics, albums, and recent assets. import { readFileSync } from "node:fs"; export interface PhotosServerInfo { version: string; photos?: number; videos?: number; usageBytes?: number; } export interface PhotosAlbum { id: string; name: string; assetCount: number; shared: boolean; } export interface PhotosAsset { id: string; fileName?: string; type?: string; createdAt?: string; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } export class PhotosClient { readonly baseUrl: string; constructor( url: string, private readonly apiKey: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** Build from the module's environment. Unlike an open service, a photo library holds private data: * the API key is required, and without it the module contributes nothing rather than reaching an * unauthenticated endpoint. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { const cfg = meshConfig(env.MESH_PHOTOS_CONFIG_FILE); const url = cfg.url ?? env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; const key = cfg.apiKey ?? env.MESH_PHOTOS_API_KEY; if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); return new PhotosClient(url, key); } private async request(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers: { Accept: "application/json", "x-api-key": this.apiKey, ...(init.body ? { "Content-Type": "application/json" } : {}), ...(init.headers ?? {}), }, }); if (!res.ok) throw new Error(`photos API ${path}: ${res.status} ${await res.text()}`); return (await res.json()) as T; } async getServerInfo(): Promise { const version = await this.request<{ major: number; minor: number; patch: number }>("/api/server/version"); const info: PhotosServerInfo = { version: `${version.major}.${version.minor}.${version.patch}` }; // Statistics needs an admin key; a scoped key still gives version, so treat stats as best-effort. try { const stats = await this.request<{ photos: number; videos: number; usage: number }>("/api/server/statistics"); info.photos = stats.photos; info.videos = stats.videos; info.usageBytes = stats.usage; } catch { // leave the counts unset } return info; } async getAlbums(): Promise { const albums = await this.request< { id: string; albumName: string; assetCount: number; shared: boolean }[] >("/api/albums"); return albums.map((a) => ({ id: a.id, name: a.albumName, assetCount: a.assetCount, shared: a.shared })); } /** Recent assets, newest first — via the metadata search, which is how this API returns a bounded, * ordered slice of the library. The poll that emits item.added builds on this. */ async getRecentAssets(limit = 20): Promise { const data = await this.request<{ assets?: { items?: { id: string; originalFileName?: string; type?: string; fileCreatedAt?: string }[] }; }>("/api/search/metadata", { method: "POST", body: JSON.stringify({ size: limit, order: "desc" }), }); const items = data.assets?.items ?? []; return items.map((a) => ({ id: a.id, fileName: a.originalFileName, type: a.type, createdAt: a.fileCreatedAt, })); } }