mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
215 lines • 9.61 kB
JavaScript
;
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