Files
mesh-catalog/modules/photos/client.ts
T
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00

111 lines
4.0 KiB
TypeScript

// The photo app's API client — photos' own code, living in the module (novox/hq ADR 0044). 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 0051): { 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 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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<PhotosServerInfo> {
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<PhotosAlbum[]> {
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<PhotosAsset[]> {
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,
}));
}
}