UNPKG

mira-app-server

Version:

Mira Server - standalone server application using mira-app-core

215 lines 9.61 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MetadataService = exports.BUILTIN_METADATA_RULES = void 0; const child_process_1 = require("child_process"); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const util_1 = require("util"); const p_queue_1 = __importDefault(require("p-queue")); const which_1 = __importDefault(require("which")); const execFileAsync = (0, util_1.promisify)(child_process_1.execFile); const IMAGE_EXTENSIONS = [ 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tif', 'tiff', 'heic', 'heif', 'cr2', 'cr3', 'nef', 'arw', 'dng', 'orf', 'rw2', 'raf', 'jp2', 'exr', ]; const VIDEO_EXTENSIONS = [ 'mp4', 'mov', 'avi', 'mkv', 'flv', 'webm', 'wmv', 'm4v', 'mpg', 'mpeg', 'mts', 'm2ts', 'ts', '3gp', ]; const AUDIO_EXTENSIONS = ['mp3', 'm4a', 'wav', 'flac', 'aac', 'ogg', 'opus', 'wma', 'ape', 'alac']; function first(raw, ...keys) { return keys.map(key => raw[key]).find(value => value !== undefined && value !== null && value !== ''); } function gps(raw) { const latitude = first(raw, 'GPSLatitude', 'GPSPosition'); const longitude = first(raw, 'GPSLongitude'); return latitude !== undefined || longitude !== undefined ? compact({ latitude, longitude }) : undefined; } function compact(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== '')); } function imageMetadata(raw) { const make = first(raw, 'Make', 'CameraMake'); const model = first(raw, 'Model', 'CameraModelName'); return compact({ width: first(raw, 'ImageWidth', 'ExifImageWidth'), height: first(raw, 'ImageHeight', 'ExifImageHeight'), dateTimeOriginal: first(raw, 'DateTimeOriginal', 'CreateDate'), cameraModel: [make, model].filter(Boolean).join(' ') || undefined, gps: gps(raw), aperture: first(raw, 'Aperture', 'FNumber'), shutterSpeed: first(raw, 'ShutterSpeed', 'ExposureTime'), iso: first(raw, 'ISO', 'ISOSetting'), lensModel: first(raw, 'LensModel', 'LensID', 'Lens'), }); } function videoMetadata(raw) { return compact({ width: first(raw, 'ImageWidth', 'SourceImageWidth', 'VideoFrameWidth'), height: first(raw, 'ImageHeight', 'SourceImageHeight', 'VideoFrameHeight'), duration: first(raw, 'Duration', 'MediaDuration'), createdAt: first(raw, 'DateTimeOriginal', 'CreateDate', 'MediaCreateDate', 'TrackCreateDate'), frameRate: first(raw, 'VideoFrameRate', 'VideoAvgFrameRate', 'CaptureFrameRate'), deviceModel: first(raw, 'DeviceModelName', 'CameraModelName', 'Model'), gps: gps(raw), codec: first(raw, 'VideoCodec', 'CompressorID', 'CompressorName', 'CodecID'), }); } function audioMetadata(raw) { return compact({ title: first(raw, 'Title'), artist: first(raw, 'Artist', 'AlbumArtist'), album: first(raw, 'Album'), duration: first(raw, 'Duration'), sampleRate: first(raw, 'SampleRate', 'AudioSampleRate'), bitDepth: first(raw, 'BitsPerSample', 'BitDepth'), channels: first(raw, 'NumChannels', 'AudioChannels', 'ChannelMode'), year: first(raw, 'Year', 'Date'), genre: first(raw, 'Genre'), }); } exports.BUILTIN_METADATA_RULES = [ { name: 'builtin:image', supportedExtensions: IMAGE_EXTENSIONS, parse: imageMetadata }, { name: 'builtin:video', supportedExtensions: VIDEO_EXTENSIONS, parse: videoMetadata }, { name: 'builtin:audio', supportedExtensions: AUDIO_EXTENSIONS, parse: audioMetadata, extractCover: true }, ]; class MetadataService { constructor(exiftoolPath) { this.queue = new p_queue_1.default({ concurrency: 2 }); this.rules = []; this.extensionRules = new Map(); this.progress = new Map(); if (exiftoolPath !== undefined) { this.exiftoolPath = exiftoolPath; } else { try { this.exiftoolPath = process.env.EXIFTOOL_PATH || which_1.default.sync('exiftool'); } catch { this.exiftoolPath = null; } } for (const rule of exports.BUILTIN_METADATA_RULES) this.registerRule(rule); if (this.exiftoolPath) console.log('MetadataService: exiftool found at', this.exiftoolPath); else console.warn('MetadataService: exiftool not found. Set EXIFTOOL_PATH or install ExifTool.'); } registerRule(rule) { this.unregisterRule(rule.name); this.rules.push({ ...rule, supportedExtensions: rule.supportedExtensions.map(ext => ext.replace(/^\./, '').toLowerCase()), }); this.rebuildExtensionRules(); } unregisterRule(name) { const index = this.rules.findIndex(rule => rule.name === name); if (index === -1) return; this.rules.splice(index, 1); this.rebuildExtensionRules(); } enqueue(file, dbService, onSettled) { if (!this.exiftoolPath || !file?.id || !file?.path) return false; const rule = this.extensionRules.get(path_1.default.extname(file.path).slice(1).toLowerCase()); if (!rule) return false; void this.queue.add(async () => { try { const { stdout } = await execFileAsync(this.exiftoolPath, ['-json', file.path], { windowsHide: true, maxBuffer: 10 * 1024 * 1024, }); const raw = JSON.parse(stdout)[0] || {}; const metadata = compact(await rule.parse(raw, file.path)); if (rule.extractCover && await this.extractCover(file, dbService)) { metadata.cover = true; metadata.coverFile = `${file.hash || file.id}-cover.jpg`; } await dbService.updateFile(file.id, { metadata }); } catch (error) { console.error(`MetadataService: failed to parse ${file.path}:`, error); } finally { onSettled?.(); } }); return true; } async getStats(dbService) { const files = await this.getSupportedFiles(dbService); const withMetadata = files.filter(file => file.metadata !== null && file.metadata !== undefined && file.metadata !== '').length; const totalFiles = files.length; return { available: this.exiftoolPath !== null, totalFiles, withMetadata, withoutMetadata: totalFiles - withMetadata, metadataRate: totalFiles > 0 ? Math.round(withMetadata / totalFiles * 100) : 0, }; } async scanPending(libraryId, dbService) { if (!this.exiftoolPath) return { available: false, queued: 0 }; const pending = (await this.getSupportedFiles(dbService)) .filter(file => file.metadata === null || file.metadata === undefined || file.metadata === ''); const state = { total: pending.length, completed: 0 }; this.progress.set(libraryId, state); for (const file of pending) { this.enqueue(file, dbService, () => { state.completed++; }); } return { available: true, queued: pending.length }; } getProgressData(libraryId) { const state = this.progress.get(libraryId) || { total: 0, completed: 0 }; const remaining = Math.max(0, state.total - state.completed); return { totalPending: state.total, completed: state.completed, queueLength: remaining, processing: remaining > 0, progress: state.total > 0 ? Math.round(state.completed / state.total * 100) : 0, }; } clear() { this.queue.clear(); } rebuildExtensionRules() { this.extensionRules.clear(); for (const rule of this.rules) { for (const extension of rule.supportedExtensions) this.extensionRules.set(extension, rule); } } async getSupportedFiles(dbService) { const { result } = await dbService.getFiles({ filters: { limit: 9999999 }, isUrlFile: false }); return result.filter(file => this.extensionRules.has(path_1.default.extname(file.name || file.path || '').slice(1).toLowerCase())); } async extractCover(file, dbService) { const cover = await new Promise((resolve, reject) => { (0, child_process_1.execFile)(this.exiftoolPath, ['-b', '-Picture', file.path], { encoding: 'buffer', windowsHide: true, maxBuffer: 25 * 1024 * 1024, }, (error, stdout) => error ? reject(error) : resolve(stdout)); }); if (!cover.length) return false; const thumbnailPath = await dbService.getItemThumbPath(file, { isUrlFile: false }); const coverPath = path_1.default.join(path_1.default.dirname(thumbnailPath), `${file.hash || file.id}-cover.jpg`); await fs_1.default.promises.mkdir(path_1.default.dirname(coverPath), { recursive: true }); await fs_1.default.promises.writeFile(coverPath, cover); await dbService.updateFile(file.id, { thumb: 1 }); return true; } } exports.MetadataService = MetadataService; //# sourceMappingURL=MetadataService.js.map