add thumbnail generation

This commit is contained in:
2026-03-25 16:59:09 -04:00
parent 90528c4768
commit bf54b45fa1
9 changed files with 317 additions and 39 deletions

145
src/lib/thumbnails.ts Normal file
View File

@@ -0,0 +1,145 @@
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<void> {
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<void> {
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<number> {
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'))
}
})
})
}
/** Generate a thumbnail from a video using ffmpeg. */
async function generateVideoThumbnail(src: string, dest: string): Promise<void> {
const tmp = dest + '.tmp'
// Seek to 10% of the video duration for a representative frame
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
}
const args = [
'-y', // overwrite output
'-ss', String(offset), // 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)
}
/**
* 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<string> {
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
}