Blame Raw
Cohee · e3f41666 · · 741 lines (26.4 KB)
1 contributor
1/**
2 * Generic image metadata service.
3 * Provides on-demand metadata generation with file mtime-based caching.
4 */
5
6import * as fs from 'node:fs/promises';
7import path from 'node:path';
8import crypto from 'node:crypto';
9import { imageSize } from 'image-size';
10import writeFileAtomic from 'write-file-atomic';
11import express from 'express';
12import { Jimp } from '../jimp.js';
13import { getConfigValue, isPathUnderParent, uuidv4 } from '../util.js';
14
15export const METADATA_FILE = 'image-metadata.json';
16
17/**
18 * @typedef {Object} ImageMetadata
19 * @property {string} [hash] - SHA-256 hash of the image file.
20 * @property {number} [aspectRatio] - Aspect ratio (width / height) of the image.
21 * @property {boolean} [isAnimated] - Whether the image is animated.
22 * @property {string} [dominantColor] - Dominant color in hex format (e.g., '#RRGGBB').
23 * @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.
24 * @property {number} [addedTimestamp] - Timestamp when the image was added.
25 * @property {number} [thumbnailResolution] - Thumbnail resolution (width * height) for cache invalidation.
26 * @property {number} [mtime] - File modification time for cache invalidation (internal use).
27 */
28
29/**
30 * @typedef {Object} MetadataIndex
31 * @property {number} version - Metadata version.
32 * @property {Object.<string, ImageMetadata>} images - Mapping of relative paths to their metadata.
33 * @property {Array<{id: string, name: string, thumbnailFile: string}>} folders - Virtual folders.
34 */
35
36/**
37 * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
38 */
39
40/** @type {Record<string, number[]>} */
41export const thumbnailDimensions = {
42 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
43 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
44 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
45};
46
47/**
48 * Gets the configured resolution for a given thumbnail type.
49 * @param {ThumbnailType} type Thumbnail type
50 * @returns {number} Resolution (width * height)
51 */
52export function getThumbnailResolution(type) {
53 const dims = thumbnailDimensions[type];
54 if (Array.isArray(dims) && dims.length >= 2) {
55 return Number(dims[0]) * Number(dims[1]);
56 }
57 return 0;
58}
59
60/**
61 * Checks if a buffer contains an animated PNG (APNG) by looking for the 'acTL' chunk.
62 * @param {Buffer} buffer The file buffer.
63 * @returns {boolean}
64 */
65export function isAnimatedApng(buffer) {
66 return buffer.subarray(0, 200).includes('acTL');
67}
68
69/**
70 * Checks if a WebP buffer is animated by looking for 'ANIM' or 'ANMF' chunks.
71 * @param {Buffer} buffer The WebP file buffer (can be full file or header)
72 * @returns {boolean} True if the WebP is animated
73 */
74export function isAnimatedWebP(buffer) {
75 const headerBuffer = buffer.length > 200 ? buffer.subarray(0, 200) : buffer;
76 return headerBuffer.includes('ANIM') || headerBuffer.includes('ANMF');
77}
78
79/**
80 * Calculate average color using Jimp.
81 * Resizes the image to 1x1 to efficiently get the average color.
82 * @param {Buffer} buffer The image buffer.
83 * @returns {Promise<string>} The average color as a hex string (e.g., '#RRGGBB').
84 */
85async function getAverageColorWithJimp(buffer) {
86 try {
87 const image = await Jimp.read(buffer);
88 image.resize({ w: 1, h: 1 });
89
90 const colorInt = image.getPixelColor(0, 0);
91 const r = (colorInt >> 24) & 255;
92 const g = (colorInt >> 16) & 255;
93 const b = (colorInt >> 8) & 255;
94
95 const toHex = (c) => c.toString(16).padStart(2, '0');
96 return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
97 } catch (error) {
98 console.warn('[Jimp] Failed to calculate average color:', error.message);
99 return '#808080';
100 }
101}
102
103/**
104 * Generates metadata for a single image file.
105 * @param {string} filePath - The full path to the image file.
106 * @param {ThumbnailType} type - The thumbnail type for resolution calculation.
107 * @returns {Promise<ImageMetadata>} A metadata object. Throws an error if processing fails.
108 */
109export async function generateImageMetadata(filePath, type) {
110 const buffer = await fs.readFile(filePath);
111 const hash = crypto.createHash('sha256').update(buffer).digest('hex');
112 const dimensions = imageSize(buffer);
113
114 if (!dimensions || !dimensions.width || !dimensions.height) {
115 throw new Error('Could not determine image dimensions.');
116 }
117
118 const aspectRatio = dimensions.width / dimensions.height;
119 let isAnimated = false;
120
121 switch (dimensions.type) {
122 case 'gif':
123 isAnimated = true;
124 break;
125 case 'png':
126 isAnimated = isAnimatedApng(buffer);
127 break;
128 case 'webp':
129 isAnimated = isAnimatedWebP(buffer);
130 break;
131 }
132
133 let dominantColor;
134 if (isAnimated) {
135 dominantColor = '#808080';
136 } else {
137 dominantColor = await getAverageColorWithJimp(buffer);
138 }
139
140 let addedTimestamp;
141 try {
142 const stats = await fs.stat(filePath);
143 addedTimestamp = Math.floor(stats.birthtimeMs || stats.mtimeMs);
144 } catch {
145 addedTimestamp = Date.now();
146 }
147
148 return {
149 hash,
150 aspectRatio: parseFloat(aspectRatio.toFixed(4)),
151 isAnimated,
152 dominantColor,
153 folderIds: [],
154 addedTimestamp,
155 thumbnailResolution: getThumbnailResolution(type),
156 };
157}
158
159/**
160 * Reads the centralized metadata index from the user data root.
161 * @param {string} userDataRoot - Path to the user data directory root
162 * @returns {Promise<MetadataIndex>} The metadata index
163 */
164export async function readMetadataIndex(userDataRoot) {
165 const indexPath = path.join(userDataRoot, METADATA_FILE);
166 try {
167 const rawData = await fs.readFile(indexPath, 'utf8');
168 return JSON.parse(rawData);
169 } catch {
170 return { version: 1, images: {}, folders: [] };
171 }
172}
173
174/**
175 * Writes the centralized metadata index to the user data root.
176 * @param {string} userDataRoot - Path to the user data directory root
177 * @param {MetadataIndex} metadata - The metadata to write
178 */
179export async function writeMetadataIndex(userDataRoot, metadata) {
180 const indexPath = path.join(userDataRoot, METADATA_FILE);
181 const jsonString = JSON.stringify(metadata, null, 4);
182 await writeFileAtomic(indexPath, jsonString, 'utf8');
183}
184
185/**
186 * Gets metadata for multiple images, generating on-demand as needed.
187 * Uses relative paths from the user data root as keys in the centralized index.
188 * @param {string} userDataRoot - Path to the user data directory root
189 * @param {string[]} relativePaths - Array of relative paths from userDataRoot
190 * @param {ThumbnailType} type - The thumbnail type for resolution calculation.
191 * @returns {Promise<{results: Object.<string, ImageMetadata>, generatedCount: number}>} Results map and count of newly generated
192 */
193export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) {
194 /** @type {Object.<string, ImageMetadata>} */
195 const results = {};
196 const index = await readMetadataIndex(userDataRoot);
197 let indexModified = false;
198 let generatedCount = 0;
199
200 for (const relativePath of relativePaths) {
201 // Normalize the path to use forward slashes for consistent keys
202 const posixPath = relativePath.replaceAll(path.sep, path.posix.sep);
203 const fullPath = path.join(userDataRoot, relativePath);
204
205 let stats;
206 try {
207 stats = await fs.stat(fullPath);
208 } catch {
209 continue; // File doesn't exist, skip
210 }
211
212 const currentMtime = stats.mtimeMs;
213 const cached = index.images[posixPath];
214
215 // If cached and not modified, use cached
216 if (cached && cached.mtime === currentMtime) {
217 results[relativePath] = cached;
218 continue;
219 }
220
221 // Generate new metadata
222 try {
223 const metadata = await generateImageMetadata(fullPath, type);
224 metadata.mtime = currentMtime;
225
226 // Preserve folderIds if they existed
227 if (cached?.folderIds) {
228 metadata.folderIds = cached.folderIds;
229 }
230
231 index.images[posixPath] = metadata;
232 results[relativePath] = metadata;
233 indexModified = true;
234 generatedCount++;
235 } catch (error) {
236 console.warn(`[ImageMetadata] Failed to generate metadata for ${relativePath}:`, error.message);
237 }
238 }
239
240 // Write index if modified
241 if (indexModified) {
242 await writeMetadataIndex(userDataRoot, index);
243 }
244
245 return { results, generatedCount };
246}
247
248/**
249 * Removes metadata for an image from the centralized index.
250 * @param {string} userDataRoot - Path to the user data directory root
251 * @param {string} relativePath - The relative path to remove
252 */
253export async function removeMetadata(userDataRoot, relativePath) {
254 const posixPath = relativePath.replaceAll(path.sep, path.posix.sep);
255 const index = await readMetadataIndex(userDataRoot);
256 if (index.images[posixPath]) {
257 delete index.images[posixPath];
258
259 // Clear any folder thumbnailFile references that point to the deleted file
260 const deletedFileName = path.posix.basename(posixPath);
261 if (Array.isArray(index.folders)) {
262 for (const folder of index.folders) {
263 if (folder.thumbnailFile === deletedFileName) {
264 folder.thumbnailFile = '';
265 }
266 }
267 }
268
269 await writeMetadataIndex(userDataRoot, index);
270 }
271}
272
273/**
274 * Updates metadata for an image (e.g., after rename).
275 * @param {string} userDataRoot - Path to the user data directory root
276 * @param {string} oldRelativePath - The old relative path
277 * @param {string} newRelativePath - The new relative path
278 * @returns {Promise<ImageMetadata|null>} The updated metadata
279 */
280export async function renameMetadata(userDataRoot, oldRelativePath, newRelativePath) {
281 const posixOldPath = oldRelativePath.replaceAll(path.sep, path.posix.sep);
282 const posixNewPath = newRelativePath.replaceAll(path.sep, path.posix.sep);
283 const index = await readMetadataIndex(userDataRoot);
284 const data = index.images[posixOldPath];
285
286 if (!data) {
287 throw new Error(`Image '${oldRelativePath}' not found in metadata.`);
288 }
289
290 delete index.images[posixOldPath];
291 index.images[posixNewPath] = data;
292
293 // Update any folder thumbnailFile references that point to the old filename
294 const oldFileName = path.posix.basename(posixOldPath);
295 const newFileName = path.posix.basename(posixNewPath);
296 if (oldFileName !== newFileName && Array.isArray(index.folders)) {
297 for (const folder of index.folders) {
298 if (folder.thumbnailFile === oldFileName) {
299 folder.thumbnailFile = newFileName;
300 }
301 }
302 }
303
304 await writeMetadataIndex(userDataRoot, index);
305
306 return data;
307}
308
309/**
310 * Cleans up orphaned entries from the metadata index.
311 * Iterates over all entries and removes those whose files no longer exist.
312 * @param {string} userDataRoot - Path to the user data directory root
313 * @returns {Promise<string[]>} Array of removed paths
314 */
315export async function cleanupOrphanedMetadata(userDataRoot) {
316 const index = await readMetadataIndex(userDataRoot);
317 const orphanedPaths = [];
318
319 for (const relativePath of Object.keys(index.images)) {
320 const fullPath = path.resolve(userDataRoot, relativePath);
321
322 if (!isPathUnderParent(userDataRoot, fullPath)) {
323 orphanedPaths.push(relativePath);
324 delete index.images[relativePath];
325 continue;
326 }
327
328 try {
329 await fs.access(fullPath);
330 } catch {
331 // File doesn't exist, mark for removal
332 orphanedPaths.push(relativePath);
333 delete index.images[relativePath];
334 }
335 }
336
337 if (orphanedPaths.length > 0) {
338 await writeMetadataIndex(userDataRoot, index);
339 console.log(`[ImageMetadata] Cleaned up ${orphanedPaths.length} orphaned metadata entries`);
340 }
341
342 return orphanedPaths;
343}
344
345/**
346 * Creates a new virtual folder.
347 * @param {string} userDataRoot
348 * @param {string} name
349 * @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
350 */
351export async function createFolder(userDataRoot, name) {
352 const index = await readMetadataIndex(userDataRoot);
353 const id = uuidv4();
354 const folder = { id, name, thumbnailFile: '' };
355 index.folders.push(folder);
356 await writeMetadataIndex(userDataRoot, index);
357 return folder;
358}
359
360/**
361 * Sets thumbnail files for multiple folders in a single atomic read-modify-write.
362 * Folders not found in the index are silently skipped.
363 * @param {string} userDataRoot
364 * @param {{id: string, thumbnailFile: string}[]} updates
365 * @returns {Promise<void>}
366 */
367export async function setFolderThumbnailsBatch(userDataRoot, updates) {
368 const index = await readMetadataIndex(userDataRoot);
369 for (const { id, thumbnailFile } of updates) {
370 const folder = index.folders.find(f => f.id === id);
371 if (folder) {
372 folder.thumbnailFile = thumbnailFile;
373 }
374 }
375 await writeMetadataIndex(userDataRoot, index);
376}
377
378/**
379 * Renames or updates a virtual folder.
380 * @param {string} userDataRoot
381 * @param {string} folderId
382 * @param {{name?: string, thumbnailFile?: string}} updates
383 * @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
384 */
385export async function updateFolder(userDataRoot, folderId, updates) {
386 const index = await readMetadataIndex(userDataRoot);
387 const folder = index.folders.find(f => f.id === folderId);
388 if (!folder) throw new Error(`Folder '${folderId}' not found.`);
389 if (updates.name !== undefined) folder.name = updates.name;
390 if (updates.thumbnailFile !== undefined) folder.thumbnailFile = updates.thumbnailFile;
391 await writeMetadataIndex(userDataRoot, index);
392 return folder;
393}
394
395/**
396 * Deletes a virtual folder and removes its ID from all images.
397 * @param {string} userDataRoot
398 * @param {string} folderId
399 * @returns {Promise<void>}
400 */
401export async function deleteFolder(userDataRoot, folderId) {
402 const index = await readMetadataIndex(userDataRoot);
403 const idx = index.folders.findIndex(f => f.id === folderId);
404 if (idx === -1) throw new Error(`Folder '${folderId}' not found.`);
405 index.folders.splice(idx, 1);
406 // Remove folderId from all images
407 for (const meta of Object.values(index.images)) {
408 if (Array.isArray(meta.folderIds)) {
409 const fi = meta.folderIds.indexOf(folderId);
410 if (fi !== -1) meta.folderIds.splice(fi, 1);
411 }
412 }
413 await writeMetadataIndex(userDataRoot, index);
414}
415
416/**
417 * Assigns images to a folder.
418 * @param {string} userDataRoot
419 * @param {string} folderId
420 * @param {string[]} relativePaths
421 * @returns {Promise<void>}
422 */
423export async function assignImagesToFolder(userDataRoot, folderId, relativePaths) {
424 const index = await readMetadataIndex(userDataRoot);
425 if (!index.folders.some(f => f.id === folderId)) {
426 throw new Error(`Folder '${folderId}' not found.`);
427 }
428 for (const rp of relativePaths) {
429 const posixPath = rp.replaceAll(path.sep, path.posix.sep);
430
431 // Validate: must be a backgrounds/ path, and no path-traversal segments
432 const normalized = path.posix.normalize(posixPath);
433 if (!normalized.startsWith('backgrounds/') || normalized.split('/').some(seg => seg === '..')) {
434 throw new Error(`Invalid background path: '${posixPath}'`);
435 }
436
437 // Validate: skip silently on missing files
438 const absPath = path.join(userDataRoot, normalized);
439 try {
440 await fs.access(absPath);
441 } catch {
442 console.warn(`[ImageMetadata] Skipping missing background file: '${posixPath}'`);
443 continue;
444 }
445
446 let meta = index.images[normalized];
447 if (!meta) {
448 // Create a stub entry so folderIds can be stored even before full metadata generation
449 meta = { folderIds: [] };
450 index.images[normalized] = meta;
451 }
452 if (!Array.isArray(meta.folderIds)) meta.folderIds = [];
453 if (!meta.folderIds.includes(folderId)) {
454 meta.folderIds.push(folderId);
455 }
456 }
457 await writeMetadataIndex(userDataRoot, index);
458}
459
460/**
461 * Unassigns images from a folder.
462 * @param {string} userDataRoot
463 * @param {string} folderId
464 * @param {string[]} relativePaths
465 * @returns {Promise<void>}
466 */
467export async function unassignImagesFromFolder(userDataRoot, folderId, relativePaths) {
468 const index = await readMetadataIndex(userDataRoot);
469 for (const rp of relativePaths) {
470 const posixPath = rp.replaceAll(path.sep, path.posix.sep);
471 const meta = index.images[posixPath];
472 if (!meta || !Array.isArray(meta.folderIds)) continue;
473 const fi = meta.folderIds.indexOf(folderId);
474 if (fi !== -1) meta.folderIds.splice(fi, 1);
475 }
476 await writeMetadataIndex(userDataRoot, index);
477}
478
479export const router = express.Router();
480
481/**
482 * POST /api/image-metadata/folders/get
483 * List all virtual folders.
484 */
485router.post('/folders/get', async function (request, response) {
486 try {
487 const index = await readMetadataIndex(request.user.directories.root);
488 return response.json(index.folders || []);
489 } catch (error) {
490 console.error('[ImageMetadata] Folders list error:', error);
491 return response.status(500).json({ error: 'Internal server error.' });
492 }
493});
494
495/**
496 * POST /api/image-metadata/folders/create
497 * Create a new folder. Body: { name: string }
498 */
499router.post('/folders/create', async function (request, response) {
500 try {
501 const { name } = request.body;
502 if (!name || typeof name !== 'string') {
503 return response.status(400).json({ error: '"name" is required.' });
504 }
505 const folder = await createFolder(request.user.directories.root, name.trim());
506 return response.json(folder);
507 } catch (error) {
508 console.error('[ImageMetadata] Folder create error:', error);
509 return response.status(500).json({ error: 'Internal server error.' });
510 }
511});
512
513/**
514 * POST /api/image-metadata/folders/set-thumbnails
515 * Batch-set thumbnail files for multiple folders in one write. Body: { updates: [{id, thumbnailFile}] }
516 */
517router.post('/folders/set-thumbnails', async function (request, response) {
518 try {
519 const { updates } = request.body;
520 if (!Array.isArray(updates) || updates.some(u => !u.id || typeof u.thumbnailFile !== 'string')) {
521 return response.status(400).json({ error: '"updates" must be an array of {id, thumbnailFile}.' });
522 }
523 await setFolderThumbnailsBatch(request.user.directories.root, updates);
524 return response.json({ ok: true });
525 } catch (error) {
526 console.error('[ImageMetadata] Folder set-thumbnails error:', error);
527 return response.status(500).json({ error: 'Internal server error.' });
528 }
529});
530
531/**
532 * POST /api/image-metadata/folders/update
533 * Update a folder. Body: { id: string, name?: string, thumbnailFile?: string }
534 */
535router.post('/folders/update', async function (request, response) {
536 try {
537 const { id, ...updates } = request.body;
538 if (!id || typeof id !== 'string') {
539 return response.status(400).json({ error: '"id" is required.' });
540 }
541 const folder = await updateFolder(request.user.directories.root, id, updates);
542 return response.json(folder);
543 } catch (error) {
544 if (error.message.includes('not found')) {
545 return response.status(404).json({ error: error.message });
546 }
547 console.error('[ImageMetadata] Folder update error:', error);
548 return response.status(500).json({ error: 'Internal server error.' });
549 }
550});
551
552/**
553 * POST /api/image-metadata/folders/delete
554 * Delete a folder and unassign all images. Body: { id: string }
555 */
556router.post('/folders/delete', async function (request, response) {
557 try {
558 const { id } = request.body;
559 if (!id || typeof id !== 'string') {
560 return response.status(400).json({ error: '"id" is required.' });
561 }
562 await deleteFolder(request.user.directories.root, id);
563 return response.json({ ok: true });
564 } catch (error) {
565 if (error.message.includes('not found')) {
566 return response.status(404).json({ error: error.message });
567 }
568 console.error('[ImageMetadata] Folder delete error:', error);
569 return response.status(500).json({ error: 'Internal server error.' });
570 }
571});
572
573/**
574 * POST /api/image-metadata/folders/assign
575 * Assign images to a folder. Body: { id: string, paths: string[] }
576 */
577router.post('/folders/assign', async function (request, response) {
578 try {
579 const { id, paths } = request.body;
580 if (!id || typeof id !== 'string') {
581 return response.status(400).json({ error: '"id" is required.' });
582 }
583 if (!Array.isArray(paths)) {
584 return response.status(400).json({ error: '"paths" array is required.' });
585 }
586 await assignImagesToFolder(request.user.directories.root, id, paths);
587 return response.json({ ok: true });
588 } catch (error) {
589 if (error.message.includes('not found')) {
590 return response.status(404).json({ error: error.message });
591 }
592 console.error('[ImageMetadata] Folder assign error:', error);
593 return response.status(500).json({ error: 'Internal server error.' });
594 }
595});
596
597/**
598 * POST /api/image-metadata/folders/unassign
599 * Unassign images from a folder. Body: { id: string, paths: string[] }
600 */
601router.post('/folders/unassign', async function (request, response) {
602 try {
603 const { id, paths } = request.body;
604 if (!id || typeof id !== 'string') {
605 return response.status(400).json({ error: '"id" is required.' });
606 }
607 if (!Array.isArray(paths)) {
608 return response.status(400).json({ error: '"paths" array is required.' });
609 }
610 await unassignImagesFromFolder(request.user.directories.root, id, paths);
611 return response.json({ ok: true });
612 } catch (error) {
613 console.error('[ImageMetadata] Folder unassign error:', error);
614 return response.status(500).json({ error: 'Internal server error.' });
615 }
616});
617
618/**
619 * POST /api/image-metadata
620 * Get metadata for image(s) by path.
621 */
622router.post('/', async function (request, response) {
623 try {
624 const { path: singlePath, paths, type } = request.body;
625
626 if (!singlePath && !paths) {
627 return response.status(400).json({ error: 'Either "path" or "paths" is required.' });
628 }
629
630 const userDataRoot = request.user.directories.root;
631
632 // Helper to validate a path is under user data directory
633 const validatePath = (relativePath) => {
634 const fullPath = path.resolve(userDataRoot, relativePath);
635 if (!isPathUnderParent(userDataRoot, fullPath)) {
636 throw new Error(`Path "${relativePath}" is outside the user data directory.`);
637 }
638 return relativePath;
639 };
640
641 // Handle single path
642 if (singlePath && !paths) {
643 const relativePath = validatePath(singlePath);
644 const fullPath = path.join(userDataRoot, relativePath);
645
646 try {
647 await fs.access(fullPath);
648 } catch {
649 return response.status(404).json({ error: 'File not found.' });
650 }
651
652 const { results: metadataResults } = await getOrGenerateMetadataBatch(userDataRoot, [relativePath], type);
653 const metadata = metadataResults[relativePath];
654
655 if (!metadata) {
656 return response.status(404).json({ error: 'Could not generate metadata for file.' });
657 }
658
659 return response.json(metadata);
660 }
661
662 // Handle multiple paths
663 if (paths && Array.isArray(paths)) {
664 /** @type {Object.<string, ImageMetadata|{error: string}>} */
665 const results = {};
666 const validPaths = [];
667
668 // Validate all paths first
669 for (const relativePath of paths) {
670 try {
671 validatePath(relativePath);
672 validPaths.push(relativePath);
673 } catch (error) {
674 results[relativePath] = { error: error.message };
675 }
676 }
677
678 // Process all valid paths in a single batch
679 const { results: batchMetadata } = await getOrGenerateMetadataBatch(userDataRoot, validPaths, type);
680
681 for (const relativePath of validPaths) {
682 if (batchMetadata[relativePath]) {
683 results[relativePath] = batchMetadata[relativePath];
684 } else {
685 results[relativePath] = { error: 'File not found or could not process.' };
686 }
687 }
688
689 return response.json(results);
690 }
691
692 return response.status(400).json({ error: 'Invalid request format.' });
693 } catch (error) {
694 console.error('[ImageMetadata] API error:', error);
695 return response.status(500).json({ error: 'Internal server error.' });
696 }
697});
698
699/**
700 * POST /api/image-metadata/all
701 * Get all metadata from the index.
702 * @body {string} [prefix] - Optional path prefix to filter results
703 */
704router.post('/all', async function (request, response) {
705 try {
706 const userDataRoot = request.user.directories.root;
707 const prefix = String(request.body.prefix || '');
708 const index = await readMetadataIndex(userDataRoot);
709
710 // If prefix specified, filter to only matching paths
711 if (prefix) {
712 const filteredImages = {};
713 for (const [key, value] of Object.entries(index.images)) {
714 if (key.startsWith(prefix)) {
715 filteredImages[key] = value;
716 }
717 }
718 return response.json({ version: index.version, images: filteredImages });
719 }
720
721 return response.json(index);
722 } catch (error) {
723 console.error('[ImageMetadata] Failed to read metadata index:', error);
724 return response.status(500).json({ error: 'Internal server error.' });
725 }
726});
727
728/**
729 * POST /api/image-metadata/cleanup
730 * Clean up orphaned metadata entries (files that no longer exist).
731 */
732router.post('/cleanup', async function (request, response) {
733 try {
734 const userDataRoot = request.user.directories.root;
735 const removed = await cleanupOrphanedMetadata(userDataRoot);
736 return response.json({ removed, count: removed.length });
737 } catch (error) {
738 console.error('[ImageMetadata] Cleanup error:', error);
739 return response.status(500).json({ error: 'Internal server error.' });
740 }
741});