Files
mesh-catalog/modules/photos/client.ts

111 lines
4.0 KiB
TypeScript

// 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<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,
}));
}
}