import crypto from 'crypto' import fs from 'fs' import path from 'path' import { spawn } from 'child_process' import sharp from 'sharp' const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails') const THUMBNAIL_WIDTH = 400 const JPEG_QUALITY = 75 /** Ensure the cache directory exists. */ function ensureCacheDir(): void { if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR, { recursive: true }) } } /** Compute a stable cache filename from libraryId + absolute file path. */ function cacheKey(libraryId: string, absoluteFilePath: string): string { return crypto .createHash('sha1') .update(libraryId + ':' + absoluteFilePath) .digest('hex') } /** Return the cache path for a given source file, or null if the cache is stale/missing. */ function getCachedPath(cacheFile: string, sourcePath: string): string | null { try { const cacheStat = fs.statSync(cacheFile) const sourceStat = fs.statSync(sourcePath) if (cacheStat.mtimeMs >= sourceStat.mtimeMs) { return cacheFile } } catch { // Cache miss } return null } /** Generate a thumbnail from an image using sharp. */ async function generateImageThumbnail(src: string, dest: string): Promise { const tmp = dest + '.tmp' await sharp(src) .resize(THUMBNAIL_WIDTH) .jpeg({ quality: JPEG_QUALITY }) .toFile(tmp) fs.renameSync(tmp, dest) } /** Run a child process and collect stderr. Resolves on exit code 0, rejects otherwise. */ function run(bin: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = '' child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) child.on('error', (err) => reject(new Error(`Failed to spawn ${bin}: ${err.message}`))) child.on('close', (code) => { if (code === 0) resolve() else reject(new Error(`${bin} exited with code ${code}: ${stderr.slice(0, 200)}`)) }) }) } /** Get video duration in seconds via ffprobe. */ async function getVideoDuration(src: string): Promise { return new Promise((resolve, reject) => { const args = [ '-v', 'quiet', '-print_format', 'json', '-show_format', src, ] const child = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'ignore'] }) let stdout = '' child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) child.on('error', (err) => reject(new Error(`Failed to spawn ffprobe: ${err.message}`))) child.on('close', (code) => { if (code !== 0) return reject(new Error(`ffprobe exited with code ${code}`)) try { const json = JSON.parse(stdout) as { format?: { duration?: string } } const duration = parseFloat(json.format?.duration ?? '0') resolve(isNaN(duration) ? 0 : duration) } catch { reject(new Error('Failed to parse ffprobe output')) } }) }) } /** Extract a single frame from a video at the given offset (seconds) and write to dest. */ async function generateVideoFrameAtOffset(src: string, dest: string, offsetSeconds: number): Promise { const tmp = dest + '.tmp' const args = [ '-y', // overwrite output '-ss', String(offsetSeconds), // seek before input (fast) '-i', src, '-frames:v', '1', '-q:v', '5', '-vf', `scale=${THUMBNAIL_WIDTH}:-1`, '-f', 'image2', // explicit output format (avoids ffmpeg guessing from .tmp extension) tmp, ] await run('ffmpeg', args) fs.renameSync(tmp, dest) } /** Generate a thumbnail from a video using ffmpeg (seeks to 10% of duration). */ async function generateVideoThumbnail(src: string, dest: string): Promise { let offset = 0 try { const duration = await getVideoDuration(src) offset = Math.max(0, duration * 0.1) } catch { // If ffprobe fails, fall back to seeking to 0 } await generateVideoFrameAtOffset(src, dest, offset) } /** * Extract frames from a video at each given percentage of its duration. * Returns the absolute paths to the cached frame JPEGs, in the same order as `percentages`. * Uses a per-frame cache key so each frame is cached independently. */ export async function getVideoFramePaths( absoluteFilePath: string, libraryId: string, percentages: number[] ): Promise { ensureCacheDir() let duration = 0 try { duration = await getVideoDuration(absoluteFilePath) } catch { // Fall back to 0; all frames will seek to position 0 } const framePaths: string[] = [] for (const pct of percentages) { const offset = Math.max(0, duration * pct) const key = crypto .createHash('sha1') .update(libraryId + ':' + absoluteFilePath + ':' + pct) .digest('hex') const cacheFile = path.join(CACHE_DIR, key + '.jpg') const cached = getCachedPath(cacheFile, absoluteFilePath) if (!cached) { await generateVideoFrameAtOffset(absoluteFilePath, cacheFile, offset) } framePaths.push(cacheFile) } return framePaths } /** * Returns the absolute path to a cached thumbnail JPEG for the given file. * Generates it on first call (or when the source has been modified). * Throws on failure — callers should map this to a 404. */ export async function getThumbnailPath( absoluteFilePath: string, libraryId: string, mediaType: 'image' | 'video' ): Promise { ensureCacheDir() const key = cacheKey(libraryId, absoluteFilePath) const cacheFile = path.join(CACHE_DIR, key + '.jpg') // Return from cache if fresh const cached = getCachedPath(cacheFile, absoluteFilePath) if (cached) return cached // Generate if (mediaType === 'image') { await generateImageThumbnail(absoluteFilePath, cacheFile) } else { await generateVideoThumbnail(absoluteFilePath, cacheFile) } return cacheFile }