searxng, icecast, photos: full nox modules (ADR 0044/0046)
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.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user