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:
2026-09-04 02:46:24 +02:00
parent 81dc74734b
commit 8f8e0153b1
16 changed files with 548 additions and 1 deletions
+100
View File
@@ -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,
}));
}
}
+40
View File
@@ -0,0 +1,40 @@
// photos' events. The tool runtime imports this once the broker is bound. It watches the library and
// announces newly added assets.
//
// Emits (novox/hq ADR 0046/0047):
// module.photos.item.added — a new asset appeared in the library
//
// New assets are found by diffing the recent-assets slice by asset id. Primed silently on the first
// look, so a restart does not re-announce the whole recent list as freshly added.
import { emit } from "@novox/mesh-sdk/events";
import { PhotosClient } from "./client.js";
const photos = PhotosClient.fromEnv();
const seen = new Set<string>();
let primed = false;
async function pollRecent(): Promise<void> {
const items = await photos.getRecentAssets(50);
for (const asset of items) {
if (!seen.has(asset.id)) {
if (primed) {
await emit("module.photos.item.added", {
id: asset.id,
fileName: asset.fileName,
kind: asset.type,
createdAt: asset.createdAt,
});
}
seen.add(asset.id);
}
}
primed = true;
}
const run = (): void => void pollRecent().catch((err) => console.error(`[photos] ${err}`));
setInterval(run, 60_000);
run();
console.log("[photos] watching for newly added assets");
+6
View File
@@ -15,6 +15,12 @@
"secrets": {
"s3-bucket": "/etc/photos/store.secret"
},
"emits": [
"module.photos.item.added"
],
"own-secrets": {
"broker": "/etc/photos/broker"
},
"resources": [
{
"id": "config",
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-photos",
"version": "0.1.0",
"description": "photos — self-hosted photo library. Its API client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
// photos' tools (novox/hq ADR 0044), importing photos' own client.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { PhotosClient } from "../client.js";
export function getPhotosTools(photos: PhotosClient): ToolDefinition[] {
return [
{
name: "photos_status",
description: "Photo library status: server version and, where the key allows, photo/video counts and storage used.",
input: {},
run: async () => {
const [server, albums] = await Promise.all([photos.getServerInfo(), photos.getAlbums()]);
return { server, albumCount: albums.length };
},
},
{
name: "photos_albums",
description: "List the albums in the photo library, each with its asset count and whether it is shared.",
input: {},
run: async () => {
const albums = await photos.getAlbums();
return { count: albums.length, albums };
},
},
{
name: "photos_recent",
description: "Most recently added assets in the photo library, newest first.",
input: { limit: { type: "number", description: "how many assets (default 20)" } },
run: async (args) => ({ items: await photos.getRecentAssets(args.limit ? Number(args.limit) : 20) }),
},
];
}
// The tools exist only when an API key is configured; without one, photos contributes none rather
// than reaching an unauthenticated endpoint or failing the whole runtime.
registerModuleTools("photos", (env) => {
try {
return getPhotosTools(PhotosClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}