mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
486 lines • 22.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ThumbnailService = void 0;
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const fast_glob_1 = __importDefault(require("fast-glob"));
const queue_1 = __importDefault(require("queue"));
const which_1 = __importDefault(require("which"));
const crypto_1 = __importDefault(require("crypto"));
const child_process_1 = require("child_process");
const util_1 = require("util");
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
const HLS_SEGMENT_DURATION = 4;
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'];
const IMAGE_MAGICK_EXTENSIONS = [
'tif', 'tiff', 'psd', 'psb', 'heic', 'heif', 'cr2', 'cr3', 'nef', 'arw', 'dng', 'orf', 'rw2', 'raf',
'jp2', 'j2k', 'jpc', 'exr', 'hdr', 'tga', 'pcx', 'dds', 'dcm', 'dpx', 'fits', 'pdf', 'eps', 'ai',
'ico', 'cur', 'xpm', 'xbm'
];
const GHOSTSCRIPT_EXTENSIONS = new Set(['pdf', 'eps', 'ai']);
class ImageMagickThumbnailGenerator {
constructor(binary, supportedExtensions) {
this.binary = binary;
this.name = 'imagemagick-image';
this.supportedExtensions = supportedExtensions;
}
async generate(srcPath, destPath) {
await runImageMagick(this.binary, srcPath, destPath, 200, 'png');
}
}
async function runImageMagick(binary, srcPath, destPath, maxDimension, format) {
fs_1.default.mkdirSync(path_1.default.dirname(destPath), { recursive: true });
const input = `${srcPath}[0]`;
const args = [input, '-auto-orient', '-flatten', '-resize', `${maxDimension}x${maxDimension}>`, '-strip'];
if (format === 'webp')
args.push('-quality', '88');
args.push(`${format}:${destPath}`);
await execFileAsync(binary, args, { windowsHide: true, maxBuffer: 1024 * 1024 * 4 });
}
class ImageThumbnailGenerator {
constructor() {
this.name = 'image';
this.supportedExtensions = ['jpg', 'jpeg', 'jfif', 'png', 'gif', 'bmp', 'webp', 'avif'];
}
async generate(srcPath, destPath) {
const thumbDir = path_1.default.dirname(destPath);
if (!fs_1.default.existsSync(thumbDir)) {
fs_1.default.mkdirSync(thumbDir, { recursive: true });
}
return new Promise((resolve) => {
(0, fluent_ffmpeg_1.default)(srcPath)
.outputOptions(['-vf', 'scale=200:-1:force_original_aspect_ratio=decrease', '-frames:v', '1'])
.output(destPath)
.on('end', () => resolve())
.on('error', (err) => {
console.error('Image thumbnail error:', srcPath, err.message);
resolve();
})
.run();
});
}
}
class VideoThumbnailGenerator {
constructor() {
this.name = 'video';
this.supportedExtensions = VIDEO_EXTENSIONS;
}
async generate(srcPath, destPath) {
return new Promise((resolve) => {
(0, fluent_ffmpeg_1.default)(srcPath)
.screenshots({
timestamps: ['00:00:01'],
filename: path_1.default.basename(destPath),
folder: path_1.default.dirname(destPath),
size: '200x?',
})
.on('end', () => resolve())
.on('error', (err) => {
console.error('Video thumbnail error:', srcPath, err.message);
resolve();
});
});
}
}
class AudioThumbnailGenerator {
constructor(binary) {
this.binary = binary;
this.name = 'audio';
this.supportedExtensions = AUDIO_EXTENSIONS;
}
async generate(srcPath, destPath) {
fs_1.default.mkdirSync(path_1.default.dirname(destPath), { recursive: true });
await execFileAsync(this.binary, [
'-nostdin', '-y', '-i', srcPath,
'-filter_complex', 'aformat=channel_layouts=mono,showwavespic=s=200x200:colors=0x4f46e5',
'-frames:v', '1', destPath,
], { windowsHide: true, maxBuffer: 1024 * 1024 * 4 });
}
}
class ThumbnailService {
constructor() {
this.generators = [];
this.extMap = new Map();
this.progress = new Map();
this.wsServer = null;
this.previewTasks = new Map();
this.hlsMetadataTasks = new Map();
this.hlsSegmentTasks = new Map();
this.taskQueue = new queue_1.default({ concurrency: 5, autostart: true });
try {
this.ffmpegPath = process.env.FFMPEG_PATH || which_1.default.sync('ffmpeg');
fluent_ffmpeg_1.default.setFfmpegPath(this.ffmpegPath);
console.log('ThumbnailService: ffmpeg found at', this.ffmpegPath);
}
catch {
this.ffmpegPath = null;
console.warn('ThumbnailService: ffmpeg not found. Set FFMPEG_PATH or install ffmpeg.');
}
try {
this.ffprobePath = process.env.FFPROBE_PATH || which_1.default.sync('ffprobe');
fluent_ffmpeg_1.default.setFfprobePath(this.ffprobePath);
console.log('ThumbnailService: ffprobe found at', this.ffprobePath);
}
catch {
this.ffprobePath = null;
console.warn('ThumbnailService: ffprobe not found. Set FFPROBE_PATH or install ffprobe.');
}
try {
this.imageMagickPath = process.env.IMAGEMAGICK_PATH || which_1.default.sync('magick');
console.log('ThumbnailService: ImageMagick found at', this.imageMagickPath);
}
catch {
this.imageMagickPath = null;
console.warn('ThumbnailService: ImageMagick not found. Install ImageMagick to preview RAW/PSD/TIFF files.');
}
this.ghostscriptPath = ['gswin64c', 'gs'].map(name => {
try {
return which_1.default.sync(name);
}
catch {
return null;
}
}).find(Boolean) || null;
if (!this.ghostscriptPath) {
console.warn('ThumbnailService: Ghostscript not found. PDF/EPS/AI thumbnails are disabled.');
}
this.registerGenerator(new ImageThumbnailGenerator());
this.registerGenerator(new VideoThumbnailGenerator());
if (this.ffmpegPath)
this.registerGenerator(new AudioThumbnailGenerator(this.ffmpegPath));
if (this.imageMagickPath) {
const supportedExtensions = this.ghostscriptPath
? IMAGE_MAGICK_EXTENSIONS
: IMAGE_MAGICK_EXTENSIONS.filter(ext => !GHOSTSCRIPT_EXTENSIONS.has(ext));
this.registerGenerator(new ImageMagickThumbnailGenerator(this.imageMagickPath, supportedExtensions));
}
}
setWebSocketServer(server) {
this.wsServer = server;
}
registerGenerator(generator) {
// Remove existing generator with same name
this.generators = this.generators.filter(g => g.name !== generator.name);
this.generators.push(generator);
for (const ext of generator.supportedExtensions) {
this.extMap.set(ext, generator);
}
console.log(`ThumbnailService: registered generator '${generator.name}' for [${generator.supportedExtensions.join(', ')}]`);
}
unregisterGenerator(name) {
const gen = this.generators.find(g => g.name === name);
if (!gen)
return;
for (const ext of gen.supportedExtensions) {
if (this.extMap.get(ext) === gen) {
this.extMap.delete(ext);
}
}
this.generators = this.generators.filter(g => g.name !== name);
}
getGenerators() {
return [...this.generators];
}
hasImageMagick() {
return this.imageMagickPath !== null;
}
async getPreviewPath(srcPath, cacheDir, cacheKey) {
if (!this.imageMagickPath)
throw new Error('ImageMagick is not installed');
const stat = await fs_1.default.promises.stat(srcPath);
const extension = path_1.default.extname(srcPath).toLowerCase().slice(1);
if (GHOSTSCRIPT_EXTENSIONS.has(extension) && !this.ghostscriptPath) {
throw new Error('Ghostscript is required to preview PDF/EPS/AI files');
}
const key = crypto_1.default.createHash('sha256').update(`${cacheKey}:${stat.size}:${stat.mtimeMs}`).digest('hex');
const destPath = path_1.default.join(cacheDir, `${key}.webp`);
const existing = await fs_1.default.promises.stat(destPath).catch(() => null);
if (existing)
return destPath;
const pending = this.previewTasks.get(destPath);
if (pending)
return pending;
const task = runImageMagick(this.imageMagickPath, srcPath, destPath, 4096, 'webp')
.then(() => destPath)
.finally(() => this.previewTasks.delete(destPath));
this.previewTasks.set(destPath, task);
return task;
}
async getHlsPlaylist(srcPath, cacheDir, cacheKey, segmentQuery = '') {
const meta = await this.getHlsMetadata(srcPath, cacheDir, cacheKey);
let playlist = `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${HLS_SEGMENT_DURATION}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n`;
for (let index = 0; index < meta.totalSegments; index++) {
const duration = Math.min(HLS_SEGMENT_DURATION, meta.duration - index * HLS_SEGMENT_DURATION);
playlist += `#EXTINF:${duration.toFixed(3)},\nsegment/${index}.ts${segmentQuery}\n`;
}
return `${playlist}#EXT-X-ENDLIST\n`;
}
async getHlsSegmentPath(srcPath, cacheDir, cacheKey, segmentIndex) {
if (!this.ffmpegPath)
throw new Error('FFmpeg is not installed');
const meta = await this.getHlsMetadata(srcPath, cacheDir, cacheKey);
if (!Number.isInteger(segmentIndex) || segmentIndex < 0 || segmentIndex >= meta.totalSegments) {
throw new RangeError('Invalid HLS segment');
}
const destPath = path_1.default.join(meta.cacheDir, `segment-${segmentIndex}.ts`);
if (await fs_1.default.promises.stat(destPath).catch(() => null))
return destPath;
const pending = this.hlsSegmentTasks.get(destPath);
if (pending)
return pending;
const start = segmentIndex * HLS_SEGMENT_DURATION;
const duration = Math.min(HLS_SEGMENT_DURATION, meta.duration - start);
const tempPath = `${destPath}.${process.pid}.${Date.now()}.tmp`;
const args = ['-nostdin', '-y', '-ss', String(start), '-i', srcPath, '-t', String(duration), '-map', '0:v:0?', '-map', '0:a:0?', '-sn', '-dn'];
if (meta.hasVideo) {
args.push('-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', '48', '-force_key_frames', `expr:gte(t,n_forced*${HLS_SEGMENT_DURATION})`);
}
args.push('-c:a', 'aac', '-b:a', '160k', '-output_ts_offset', String(start), '-fflags', '+genpts', '-muxdelay', '0', '-f', 'mpegts', tempPath);
const task = execFileAsync(this.ffmpegPath, args, { windowsHide: true, maxBuffer: 1024 * 1024 * 8 })
.then(async () => {
await fs_1.default.promises.rename(tempPath, destPath);
return destPath;
})
.catch(async (error) => {
await fs_1.default.promises.unlink(tempPath).catch(() => undefined);
throw error;
})
.finally(() => this.hlsSegmentTasks.delete(destPath));
this.hlsSegmentTasks.set(destPath, task);
return task;
}
async getHlsMetadata(srcPath, cacheDir, cacheKey) {
if (!this.ffmpegPath || !this.ffprobePath)
throw new Error('FFmpeg and ffprobe are required');
const stat = await fs_1.default.promises.stat(srcPath);
const key = crypto_1.default.createHash('sha256').update(`${cacheKey}:${stat.size}:${stat.mtimeMs}`).digest('hex');
const existing = this.hlsMetadataTasks.get(key);
if (existing)
return existing;
const task = execFileAsync(this.ffprobePath, [
'-v', 'error', '-show_entries', 'format=duration:stream=codec_type', '-of', 'json', srcPath,
], { windowsHide: true, maxBuffer: 1024 * 1024 * 4 }).then(async (result) => {
const data = JSON.parse(String(result.stdout));
const duration = Number(data.format?.duration || 0);
if (!Number.isFinite(duration) || duration <= 0)
throw new Error('Media duration is unavailable');
const streams = Array.isArray(data.streams) ? data.streams : [];
if (!streams.some((stream) => stream.codec_type === 'video' || stream.codec_type === 'audio')) {
throw new Error('No audio or video stream found');
}
const mediaCacheDir = path_1.default.join(cacheDir, 'hls', key);
await fs_1.default.promises.mkdir(mediaCacheDir, { recursive: true });
return {
duration,
totalSegments: Math.ceil(duration / HLS_SEGMENT_DURATION),
hasVideo: streams.some((stream) => stream.codec_type === 'video'),
cacheDir: mediaCacheDir,
};
}).catch(error => {
this.hlsMetadataTasks.delete(key);
throw error;
});
this.hlsMetadataTasks.set(key, task);
return task;
}
getGeneratorForFile(filePath) {
const ext = path_1.default.extname(filePath).toLowerCase().slice(1);
return this.extMap.get(ext) || null;
}
onFileCreated(libraryId, event, dbService) {
const { result } = event.args;
const filePath = result.path;
const generator = this.getGeneratorForFile(filePath);
if (!generator)
return;
this.taskQueue.push(async () => {
try {
const thumbPath = await dbService.getItemThumbPath(result);
await generator.generate(filePath, thumbPath);
if (fs_1.default.existsSync(thumbPath)) {
// WebSocket 事件与文件列表接口统一广播可直接加载的缩略图 URL。
result.thumb = await dbService.getItemThumbPath(result, { isUrlFile: true });
await dbService.updateFile(result.id, { thumb: 1 }); // return value unused
console.log('Thumbnail generated:', thumbPath);
this.wsServer?.broadcastLibraryEvent(libraryId, 'thumbnail::generated', result);
}
else {
console.warn('Thumbnail generation failed:', thumbPath);
}
}
catch (err) {
console.error('Failed to generate thumbnail:', err);
}
});
}
onFileDeleted(libraryId, item, dbService) {
(async () => {
try {
const thumbPath = path_1.default.join(await dbService.getItemPath(item), 'preview.png');
if (fs_1.default.existsSync(thumbPath)) {
fs_1.default.unlinkSync(thumbPath);
}
await dbService.updateFile(item.id, { thumb: 0 }); // return value unused
}
catch (err) {
console.error('Failed to delete thumbnail:', err);
}
})();
}
async scanPending(libraryId, dbService, reason = 'unknown') {
const pendingFiles = await this.getPendingFiles(libraryId, dbService);
const total = pendingFiles.length;
this.progress.set(libraryId, { total, completed: 0 });
console.log(`ThumbnailService: scanning ${total} pending files for library ${libraryId} (reason: ${reason})`);
for (const file of pendingFiles) {
this.taskQueue.push(async () => {
try {
const filePath = await dbService.getItemFilePath(file, { isUrlFile: false });
const generator = this.getGeneratorForFile(filePath);
if (!generator) {
this.incrementProgress(libraryId);
return;
}
const thumbPath = await dbService.getItemThumbPath(file);
await generator.generate(filePath, thumbPath);
if (fs_1.default.existsSync(thumbPath)) {
await dbService.updateFile(file.id, { thumb: 1 }); // return value unused
}
}
catch (err) {
console.error('Failed to process thumbnail:', err);
}
this.incrementProgress(libraryId);
});
}
}
incrementProgress(libraryId) {
const p = this.progress.get(libraryId);
if (p) {
p.completed++;
return p;
}
return { total: 0, completed: 0 };
}
cancelScan() {
this.taskQueue.stop();
this.taskQueue.splice(0, this.taskQueue.length);
this.taskQueue.start();
}
async getStats(libraryId, dbService) {
const allFiles = await dbService.getFiles({
select: 'id,thumb',
filters: { limit: 9999999 },
isUrlFile: false,
countFile: true,
});
const totalFiles = allFiles.result.length;
const withThumbs = allFiles.result.filter((f) => f.thumb === 1).length;
return {
totalFiles,
withThumbnails: withThumbs,
withoutThumbnails: totalFiles - withThumbs,
thumbnailRate: totalFiles > 0 ? Math.round((withThumbs / totalFiles) * 100) : 0,
};
}
async getProgressData(libraryId, dbService) {
const p = this.progress.get(libraryId) || { total: 0, completed: 0 };
const queueLength = this.taskQueue.length;
const totalPending = p.total;
const processing = queueLength > 0;
return {
totalPending,
queueLength,
processing,
completed: p.completed,
progress: totalPending > 0 ? Math.round((p.completed / totalPending) * 100) : 100,
};
}
async syncThumbStatus(libraryId, dbService, reason = 'manual-sync') {
const libraryPath = dbService.config?.customFields?.path || '';
const files = (await dbService.getFiles({
select: 'id,hash,thumb',
filters: { limit: 9999999 },
isUrlFile: false,
countFile: true,
})).result;
const total = files.length;
this.progress.set(libraryId, { total, completed: 0 });
console.log(`ThumbnailService: syncing ${total} files for library ${libraryId} (reason: ${reason})`);
// fast-glob 一次扫描 thumbs 目录
const thumbsDir = path_1.default.join(libraryPath, 'thumbs');
const existingThumbs = new Set();
if (fs_1.default.existsSync(thumbsDir)) {
const entries = await (0, fast_glob_1.default)('*.png', { cwd: thumbsDir, absolute: false });
for (const entry of entries) {
existingThumbs.add(path_1.default.basename(entry, '.png'));
}
}
console.log(`ThumbnailService: found ${existingThumbs.size} thumbnail files on disk`);
// 对比收集需要更新的记录
const toUpdate0to1 = [];
const toUpdate1to0 = [];
for (const file of files) {
const thumbKey = file.hash || String(file.id);
const diskHas = existingThumbs.has(thumbKey);
if (diskHas && file.thumb !== 1)
toUpdate0to1.push(file.id);
else if (!diskHas && file.thumb !== 0)
toUpdate1to0.push(file.id);
}
const mismatchCount = toUpdate0to1.length + toUpdate1to0.length;
console.log(`ThumbnailService: ${mismatchCount} mismatches (${toUpdate0to1.length} missing in DB, ${toUpdate1to0.length} missing on disk)`);
// 批量 SQL 更新
let synced = 0;
const query = dbService.query.bind(dbService);
const batchSize = 500;
const updateBatch = async (ids, thumbValue) => {
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const placeholders = batch.map(() => '?').join(',');
await query(`UPDATE files SET thumb = ? WHERE id IN (${placeholders})`, [thumbValue, ...batch]);
synced += batch.length;
const p = this.progress.get(libraryId);
p.completed = synced;
console.log(`ThumbnailService: sync progress ${synced}/${mismatchCount} (${Math.round(synced / mismatchCount * 100)}%)`);
}
};
if (toUpdate0to1.length > 0)
await updateBatch(toUpdate0to1, 1);
if (toUpdate1to0.length > 0)
await updateBatch(toUpdate1to0, 0);
const p = this.progress.get(libraryId);
p.completed = total;
return { total, synced };
}
async getPendingFiles(libraryId, dbService) {
const files = (await dbService.getFiles({
select: 'id,hash,folder_id,name',
filters: { thumb: 0, limit: 9999999 },
isUrlFile: false,
})).result;
const pending = [];
for (const file of files) {
try {
const thumbPath = await dbService.getItemThumbPath(file, { isUrlFile: false });
if (!fs_1.default.existsSync(thumbPath)) {
pending.push(file);
}
else {
await dbService.updateFile(file.id, { thumb: 1 });
}
}
catch {
continue;
}
}
return pending;
}
}
exports.ThumbnailService = ThumbnailService;
//# sourceMappingURL=ThumbnailService.js.map