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

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ data/
.next/ .next/
node_modules/ node_modules/
out/ out/
.thumbnails/

View File

@@ -5,7 +5,8 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem.
## Features ## 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. - **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`. - **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. - **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/ MediaLoreWeb/
├── libraries.json # Runtime library config (managed via UI, do not edit by hand) ├── 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) ├── data/ # Example media (not committed to production)
├── src/ ├── src/
│ ├── app/ │ ├── app/
@@ -26,7 +28,8 @@ MediaLoreWeb/
│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id │ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id
│ │ ├── games/route.ts # GET /api/games?libraryId= │ │ ├── games/route.ts # GET /api/games?libraryId=
│ │ ├── browse/route.ts # GET /api/browse?libraryId=&path= │ │ ├── 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/ │ ├── components/
│ │ ├── LibraryCard.tsx │ │ ├── LibraryCard.tsx
│ │ ├── NavLink.tsx │ │ ├── NavLink.tsx
@@ -40,14 +43,21 @@ MediaLoreWeb/
│ ├── lib/ │ ├── lib/
│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers │ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers
│ │ ├── games.ts # Games library scanner │ │ ├── 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/ │ └── types/
│ └── index.ts │ └── index.ts
``` ```
## Developer Setup ## 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 ```bash
# 1. Install dependencies # 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=<id>` | GET | Scans the games library and returns structured game entries | | `/api/games?libraryId=<id>` | GET | Scans the games library and returns structured game entries |
| `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library | | `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library |
| `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback | | `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback |
| `/api/thumbnail?libraryId=<id>&path=<relpath>` | GET | Returns a cached square thumbnail (JPEG) for an image or video file; `404` if generation fails or ffmpeg is unavailable |
## Tech Stack ## Tech Stack

7
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"next": "^15.5.14", "next": "^15.5.14",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
@@ -545,7 +546,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2906,7 +2906,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5780,7 +5779,6 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -5844,7 +5842,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",

View File

@@ -14,7 +14,8 @@
"dependencies": { "dependencies": {
"next": "^15.5.14", "next": "^15.5.14",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",

View File

@@ -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 })
}
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import type { DirectoryListing, FileEntry } from '@/types' import type { DirectoryListing, FileEntry } from '@/types'
import VideoPlayerModal from './VideoPlayerModal' import VideoPlayerModal from './VideoPlayerModal'
import ImageLightbox from './ImageLightbox' import ImageLightbox from './ImageLightbox'
@@ -126,8 +126,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
{breadcrumbs.length > 0 && ( {breadcrumbs.length > 0 && (
<button <button
onClick={navigateUp} onClick={navigateUp}
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors aspect-square" className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', color: 'var(--text-secondary)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', color: 'var(--text-secondary)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
> >
@@ -154,43 +154,96 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} }
function EntryTile({ entry, onOpen }: { entry: FileEntry; onOpen: (e: FileEntry) => void }) { function EntryTile({ entry, onOpen }: { entry: FileEntry; onOpen: (e: FileEntry) => void }) {
const icon = entry.type === 'directory' type ImgState = 'loading' | 'loaded' | 'error'
? '📁' const [imgState, setImgState] = useState<ImgState>(
: entry.mediaType === 'video' entry.thumbnailUrl ? 'loading' : 'error'
? '▶' )
: entry.mediaType === 'image' // Reset image state when the entry changes (e.g. navigating to a new folder)
? '🖼' const prevUrl = useRef(entry.thumbnailUrl)
: '📄' if (prevUrl.current !== entry.thumbnailUrl) {
prevUrl.current = entry.thumbnailUrl
if (entry.thumbnailUrl) setImgState('loading')
else setImgState('error')
}
const isVideo = entry.type === 'file' && entry.mediaType === 'video' const isDir = entry.type === 'directory'
const isVideo = entry.mediaType === 'video'
const showThumbnail = !isDir && imgState !== 'error' && entry.thumbnailUrl
// Icon shown when no thumbnail available or while loading
const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄'
return ( return (
<button <button
onClick={() => onOpen(entry)} onClick={() => onOpen(entry)}
className="group flex flex-col items-center gap-2 rounded-xl border p-3 text-xs transition-all text-center overflow-hidden" className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all focus:outline-none text-left"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)' ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}} }}
> >
<span {/* Thumbnail image — hidden until loaded */}
className="text-3xl" {entry.thumbnailUrl && (
style={isVideo ? { color: 'var(--accent)' } : undefined} // eslint-disable-next-line @next/next/no-img-element
<img
src={entry.thumbnailUrl}
alt=""
aria-hidden
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
style={{ opacity: imgState === 'loaded' ? 1 : 0 }}
onLoad={() => setImgState('loaded')}
onError={() => setImgState('error')}
/>
)}
{/* Skeleton pulse shown while image is loading */}
{imgState === 'loading' && (
<div
className="absolute inset-0 animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
/>
)}
{/* Icon fallback — shown for dirs, other files, and failed thumbnails */}
{!showThumbnail && imgState !== 'loading' && (
<div className="absolute inset-0 flex items-center justify-center text-3xl"
style={isVideo && imgState === 'error' ? { color: 'var(--accent)' } : undefined}>
{icon}
</div>
)}
{/* Bottom label — always shown */}
<div
className="absolute bottom-0 left-0 right-0 px-2 py-1.5"
style={{
background: showThumbnail
? 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, transparent 100%)'
: undefined,
}}
> >
{icon} <span
</span> className="block w-full truncate"
<span style={{ color: showThumbnail ? '#fff' : 'var(--text-primary)' }}
className="w-full truncate text-center" title={entry.name}
style={{ color: 'var(--text-primary)' }} >
title={entry.name} {entry.name}
> </span>
{entry.name} </div>
</span>
{/* Video play badge — top-right overlay */}
{isVideo && imgState === 'loaded' && (
<div
className="absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
>
</div>
)}
</button> </button>
) )
} }
@@ -201,8 +254,8 @@ function LoadingSkeleton() {
{Array.from({ length: 12 }).map((_, i) => ( {Array.from({ length: 12 }).map((_, i) => (
<div <div
key={i} key={i}
className="rounded-xl aspect-square animate-pulse" className="rounded-xl animate-pulse"
style={{ backgroundColor: 'var(--surface)' }} style={{ backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
/> />
))} ))}
</div> </div>

View File

@@ -18,6 +18,10 @@ function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` 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( export function scanDirectory(
libraryRoot: string, libraryRoot: string,
libraryId: string, libraryId: string,
@@ -43,17 +47,20 @@ export function scanDirectory(
type: 'directory', type: 'directory',
mediaType: null, mediaType: null,
url: null, url: null,
thumbnailUrl: null,
} }
} }
const relPath = subpath ? path.join(subpath, d.name) : d.name const relPath = subpath ? path.join(subpath, d.name) : d.name
const mediaType = getMediaType(d.name) const mediaType = getMediaType(d.name)
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
return { return {
name: d.name, name: d.name,
type: 'file', type: 'file',
mediaType, mediaType,
url: fileApiUrl(libraryId, relPath), url: fileApiUrl(libraryId, relPath),
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, relPath) : null,
} }
}) })

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
}

View File

@@ -22,6 +22,7 @@ export interface FileEntry {
type: 'file' | 'directory' type: 'file' | 'directory'
mediaType: MediaType | null mediaType: MediaType | null
url: string | null url: string | null
thumbnailUrl: string | null
} }
export interface DirectoryListing { export interface DirectoryListing {