searxng: a search tool — tools-only, a stateless search has nothing to observe. icecast: status tool, emits stream.started/.stopped by diffing live mountpoints. photos: status/albums/recent tools, emits item.added (immich-shaped API, coded defensively since the stub ships a placeholder image — flagged in the code). Typecheck; manifests parse.
101 lines
3.6 KiB
TypeScript
101 lines
3.6 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.
|
|
|
|
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;
|
|
}
|
|
|
|
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 url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`;
|
|
const key = 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,
|
|
}));
|
|
}
|
|
}
|