diff --git a/modules/photos/client.ts b/modules/photos/client.ts deleted file mode 100644 index ce968eb..0000000 --- a/modules/photos/client.ts +++ /dev/null @@ -1,110 +0,0 @@ -// 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 { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - 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(path: string, init: RequestInit = {}): Promise { - 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 { - 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 { - 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 { - 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, - })); - } -} diff --git a/modules/photos/index.ts b/modules/photos/index.ts deleted file mode 100644 index 747b171..0000000 --- a/modules/photos/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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 0041/0042): -// 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(); -let primed = false; - -async function pollRecent(): Promise { - const items = await photos.getRecentAssets(50); - for (const asset of items) { - if (!seen.has(asset.id)) { - if (primed) { - await emit("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"); diff --git a/modules/photos/module.json b/modules/photos/module.json deleted file mode 100644 index 75fc0d8..0000000 --- a/modules/photos/module.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "module": "photos", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "item.added" - ], - "requires": [ - "s3-bucket", - "mongodb-database", - "route" - ], - "contributes": { - "mongodb-database": { - "name": "photos" - }, - "route": { - "label": "photos", - "endpoint": "web" - } - }, - "binds": { - "s3-bucket": "${dir:state}/store.json", - "mongodb-database": "${dir:state}/database.json", - "route": "${dir:state}/route.json" - }, - "secrets": { - "s3-bucket": "${dir:state}/store.secret", - "mongodb-database": "${dir:state}/database.secret" - }, - "listens": [ - { - "name": "api", - "port": 9000, - "protocol": "tcp", - "from": "mesh", - "why": "the photos backend API; the client sites on the module network call it" - }, - { - "name": "web", - "port": 4001, - "protocol": "tcp", - "from": "mesh", - "why": "the admin client site over http; its public name is a route grant, and route-proxy reaches it on this published port" - } - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "server-env", - "type": "file", - "path": "${dir:state}/server.env", - "mode": "0600", - "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n" - }, - { - "id": "net", - "type": "network", - "name": "photos" - }, - { - "id": "server", - "type": "container", - "name": "photos-server", - "image": "registry-api.novox.be/novox/photos-server@sha256:3f165acbbd1fd731b12fe798c95879c081a44b00e2c569ef7f47165f6a527201", - "network": "photos", - "env-file": [ - "${dir:state}/server.env" - ], - "ports": [ - "9000" - ], - "secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (photos server/src/config.js); converting is that repository's change", - "names-on-purpose": { - "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" - } - }, - { - "id": "admin-client", - "type": "container", - "name": "photos-admin-client", - "image": "registry-api.novox.be/novox/photos-admin-client@sha256:f437fa9ed28b29a012f715fb8d9b809a15cff4a672794c620d5d400f57695580", - "network": "photos", - "ports": [ - "4001:80" - ], - "names-on-purpose": { - "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" - } - } - ] -} diff --git a/modules/photos/package.json b/modules/photos/package.json deleted file mode 100644 index 8667c81..0000000 --- a/modules/photos/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/photos/tools/index.ts b/modules/photos/tools/index.ts deleted file mode 100644 index 63eb37c..0000000 --- a/modules/photos/tools/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -// photos' tools (novox/hq ADR 0039), 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 []; - } -}); diff --git a/modules/photos/tsconfig.json b/modules/photos/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/photos/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -}