From bf54b45fa176e72af1a3ee216627e1bdede6d802 Mon Sep 17 00:00:00 2001 From: Garret Patti Date: Wed, 25 Mar 2026 16:59:09 -0400 Subject: [PATCH] add thumbnail generation --- .gitignore | 1 + README.md | 19 +++- package-lock.json | 7 +- package.json | 3 +- src/app/api/thumbnail/route.ts | 62 ++++++++++++ src/components/mixed/MixedView.tsx | 111 ++++++++++++++++------ src/lib/files.ts | 7 ++ src/lib/thumbnails.ts | 145 +++++++++++++++++++++++++++++ src/types/index.ts | 1 + 9 files changed, 317 insertions(+), 39 deletions(-) create mode 100644 src/app/api/thumbnail/route.ts create mode 100644 src/lib/thumbnails.ts diff --git a/.gitignore b/.gitignore index b38c73a..aee5d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ data/ .next/ node_modules/ out/ +.thumbnails/ diff --git a/README.md b/README.md index 25b33a1..5d408e7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem. ## Features - **Games library** — displays a grid of game cover art scanned from folders. Each game folder is expected to contain a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link for the zip. -- **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab. +- **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Image and video files display auto-generated square thumbnails. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab. +- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated automatically when the source file changes. - **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`. - **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served. @@ -14,6 +15,7 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem. ``` MediaLoreWeb/ ├── libraries.json # Runtime library config (managed via UI, do not edit by hand) +├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored) ├── data/ # Example media (not committed to production) ├── src/ │ ├── app/ @@ -26,7 +28,8 @@ MediaLoreWeb/ │ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id │ │ ├── games/route.ts # GET /api/games?libraryId= │ │ ├── browse/route.ts # GET /api/browse?libraryId=&path= -│ │ └── file/route.ts # GET /api/file?libraryId=&path= +│ │ ├── file/route.ts # GET /api/file?libraryId=&path= +│ │ └── thumbnail/route.ts # GET /api/thumbnail?libraryId=&path= │ ├── components/ │ │ ├── LibraryCard.tsx │ │ ├── NavLink.tsx @@ -40,14 +43,21 @@ MediaLoreWeb/ │ ├── lib/ │ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers │ │ ├── games.ts # Games library scanner -│ │ └── files.ts # Mixed library directory scanner +│ │ ├── files.ts # Mixed library directory scanner +│ │ └── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg) │ └── types/ │ └── index.ts ``` ## Developer Setup -**Requirements:** Node.js 18+ +**Requirements:** Node.js 18+, `ffmpeg` and `ffprobe` (for video thumbnails) + +> Video thumbnails require `ffmpeg` and `ffprobe` to be installed and available on `$PATH`. If they are missing, video tiles gracefully fall back to a generic icon — no errors are thrown. +> +> **macOS:** `brew install ffmpeg` +> **Ubuntu/Debian:** `sudo apt install ffmpeg` +> **Windows:** Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to `PATH` ```bash # 1. Install dependencies @@ -121,6 +131,7 @@ All API routes are server-side. File paths are never exposed in client-side stat | `/api/games?libraryId=` | GET | Scans the games library and returns structured game entries | | `/api/browse?libraryId=&path=` | GET | Lists the contents of a directory within a mixed library | | `/api/file?libraryId=&path=` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback | +| `/api/thumbnail?libraryId=&path=` | GET | Returns a cached square thumbnail (JPEG) for an image or video file; `404` if generation fails or ffmpeg is unavailable | ## Tech Stack diff --git a/package-lock.json b/package-lock.json index 706763f..1eb28a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "next": "^15.5.14", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "sharp": "^0.34.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.2", @@ -545,7 +546,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2906,7 +2906,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5780,7 +5779,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5844,7 +5842,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 7afa7a9..801492e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dependencies": { "next": "^15.5.14", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "sharp": "^0.34.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.2", diff --git a/src/app/api/thumbnail/route.ts b/src/app/api/thumbnail/route.ts new file mode 100644 index 0000000..6a1c031 --- /dev/null +++ b/src/app/api/thumbnail/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import fs from 'fs' +import path from 'path' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { getThumbnailPath } from '@/lib/thumbnails' + +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v']) +const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) + +function getMediaType(filePath: string): 'image' | 'video' | null { + const ext = path.extname(filePath).toLowerCase() + if (IMAGE_EXTENSIONS.has(ext)) return 'image' + if (VIDEO_EXTENSIONS.has(ext)) return 'video' + return null +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const subpath = searchParams.get('path') + + if (!libraryId || !subpath) { + return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + + const root = resolveLibraryRoot(library) + + let filePath: string + try { + filePath = resolveAndJail(root, subpath) + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const mediaType = getMediaType(filePath) + if (!mediaType) { + return NextResponse.json({ error: 'Thumbnails are only supported for image and video files' }, { status: 400 }) + } + + try { + const thumbnailPath = await getThumbnailPath(filePath, libraryId, mediaType) + const stat = fs.statSync(thumbnailPath) + const stream = fs.createReadStream(thumbnailPath) + + return new NextResponse(stream as unknown as ReadableStream, { + status: 200, + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Length': String(stat.size), + 'Cache-Control': 'public, max-age=86400', + }, + }) + } catch (err) { + console.error(`Thumbnail generation failed for ${filePath}:`, err) + return new NextResponse(null, { status: 404 }) + } +} diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 3e6c76f..7d39c7a 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' import type { DirectoryListing, FileEntry } from '@/types' import VideoPlayerModal from './VideoPlayerModal' import ImageLightbox from './ImageLightbox' @@ -126,8 +126,8 @@ export default function MixedView({ libraryId, initialPath }: Props) { {breadcrumbs.length > 0 && ( ) } @@ -201,8 +254,8 @@ function LoadingSkeleton() { {Array.from({ length: 12 }).map((_, i) => (
))}
diff --git a/src/lib/files.ts b/src/lib/files.ts index a081b41..86477c6 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -18,6 +18,10 @@ function fileApiUrl(libraryId: string, relativePath: string): string { return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` } +function thumbnailApiUrl(libraryId: string, relativePath: string): string { + return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + export function scanDirectory( libraryRoot: string, libraryId: string, @@ -43,17 +47,20 @@ export function scanDirectory( type: 'directory', mediaType: null, url: null, + thumbnailUrl: null, } } const relPath = subpath ? path.join(subpath, d.name) : d.name const mediaType = getMediaType(d.name) + const hasThumbnail = mediaType === 'image' || mediaType === 'video' return { name: d.name, type: 'file', mediaType, url: fileApiUrl(libraryId, relPath), + thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, relPath) : null, } }) diff --git a/src/lib/thumbnails.ts b/src/lib/thumbnails.ts new file mode 100644 index 0000000..fe8c948 --- /dev/null +++ b/src/lib/thumbnails.ts @@ -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 { + 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')) + } + }) + }) +} + +/** Generate a thumbnail from a video using ffmpeg. */ +async function generateVideoThumbnail(src: string, dest: string): Promise { + 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 { + 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 +} diff --git a/src/types/index.ts b/src/types/index.ts index a0c5b76..65f6380 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,7 @@ export interface FileEntry { type: 'file' | 'directory' mediaType: MediaType | null url: string | null + thumbnailUrl: string | null } export interface DirectoryListing {