add thumbnail generation
This commit is contained in:
62
src/app/api/thumbnail/route.ts
Normal file
62
src/app/api/thumbnail/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
<button
|
||||
onClick={navigateUp}
|
||||
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors aspect-square"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', color: 'var(--text-secondary)' }}
|
||||
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)', aspectRatio: '1 / 1' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||
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 }) {
|
||||
const icon = entry.type === 'directory'
|
||||
? '📁'
|
||||
: entry.mediaType === 'video'
|
||||
? '▶'
|
||||
: entry.mediaType === 'image'
|
||||
? '🖼'
|
||||
: '📄'
|
||||
type ImgState = 'loading' | 'loaded' | 'error'
|
||||
const [imgState, setImgState] = useState<ImgState>(
|
||||
entry.thumbnailUrl ? 'loading' : 'error'
|
||||
)
|
||||
// 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 (
|
||||
<button
|
||||
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"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||
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)', aspectRatio: '1 / 1' }}
|
||||
onMouseEnter={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-3xl"
|
||||
style={isVideo ? { color: 'var(--accent)' } : undefined}
|
||||
{/* Thumbnail image — hidden until loaded */}
|
||||
{entry.thumbnailUrl && (
|
||||
// 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="w-full truncate text-center"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span
|
||||
className="block w-full truncate"
|
||||
style={{ color: showThumbnail ? '#fff' : 'var(--text-primary)' }}
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -201,8 +254,8 @@ function LoadingSkeleton() {
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl aspect-square animate-pulse"
|
||||
style={{ backgroundColor: 'var(--surface)' }}
|
||||
className="rounded-xl animate-pulse"
|
||||
style={{ backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
145
src/lib/thumbnails.ts
Normal file
145
src/lib/thumbnails.ts
Normal 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
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface FileEntry {
|
||||
type: 'file' | 'directory'
|
||||
mediaType: MediaType | null
|
||||
url: string | null
|
||||
thumbnailUrl: string | null
|
||||
}
|
||||
|
||||
export interface DirectoryListing {
|
||||
|
||||
Reference in New Issue
Block a user