refactor: remove thumbnail API and update thumbnail management

- Deleted the thumbnail API route to streamline the application structure.
- Integrated ThumbnailManager for improved thumbnail path generation and directory management.
- Updated media scanning logic to utilize hashed thumbnail paths and ensure directory existence.
- Enhanced error handling for thumbnail generation with fallback options based on media type.
This commit is contained in:
tigeren 2025-08-31 09:23:05 +00:00
parent 407c702e88
commit f6a02d9328
5 changed files with 179 additions and 47 deletions

Binary file not shown.

View File

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: pathParts } = await params;
if (!pathParts || pathParts.length < 3) {
return NextResponse.json(
{ error: 'Invalid thumbnail path structure' },
{ status: 400 }
);
}
// Reconstruct the full path
// pathParts = ['ff', '01', 'ff0170402c7f75dbc1cd53128943832c_320.png']
const relativePath = path.join(...pathParts);
// Construct the full file path
const thumbnailPath = path.join(
process.cwd(),
'public',
'thumbnails',
relativePath
);
// Validate file exists and is within thumbnails directory
if (!thumbnailPath.startsWith(path.join(process.cwd(), 'public', 'thumbnails'))) {
return NextResponse.json(
{ error: 'Invalid path' },
{ status: 403 }
);
}
// Check if the file exists
if (!fs.existsSync(thumbnailPath)) {
return NextResponse.json(
{ error: 'Thumbnail not found' },
{ status: 404 }
);
}
// Check if it's actually a file (not directory)
const stats = fs.statSync(thumbnailPath);
if (!stats.isFile()) {
return NextResponse.json(
{ error: 'Thumbnail not found' },
{ status: 404 }
);
}
// Read the file
const fileBuffer = fs.readFileSync(thumbnailPath);
// Return the image with appropriate headers
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error) {
console.error('Error serving thumbnail:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Construct the path to the thumbnail file
const thumbnailPath = path.join(process.cwd(), 'public', 'thumbnails', filename);
// Check if the file exists
if (!fs.existsSync(thumbnailPath)) {
return NextResponse.json({ error: 'Thumbnail not found' }, { status: 404 });
}
// Read the file
const fileBuffer = fs.readFileSync(thumbnailPath);
// Return the image with appropriate headers
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error) {
console.error('Error serving thumbnail:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -3,6 +3,7 @@ import { glob } from "glob";
import path from "path";
import fs from "fs";
import ffmpeg from "fluent-ffmpeg";
import { ThumbnailManager } from "./thumbnails";
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
@ -67,37 +68,34 @@ const scanLibrary = async (library: { id: number; path: string }) => {
const isText = TEXT_EXTENSIONS.some(t => t.toLowerCase() === cleanExt);
const mediaType = isVideo ? "video" : isPhoto ? "photo" : "text";
const thumbnailFileName = `${path.parse(title).name}_${Date.now()}.png`;
const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName);
const thumbnailUrl = `/thumbnails/${thumbnailFileName}`;
// Generate hashed thumbnail path
const { folderPath, fullPath, url } = ThumbnailManager.getThumbnailPath(file);
try {
const existingMedia = db.prepare("SELECT * FROM media WHERE path = ?").get(file);
if (existingMedia) {
continue;
}
// Ensure thumbnails directory exists
const thumbnailsDir = path.join(process.cwd(), "public", "thumbnails");
if (!fs.existsSync(thumbnailsDir)) {
fs.mkdirSync(thumbnailsDir, { recursive: true });
}
// Ensure hashed directory structure exists
ThumbnailManager.ensureDirectory(folderPath);
let finalThumbnailUrl = thumbnailUrl;
let finalThumbnailUrl = url;
let thumbnailGenerated = false;
try {
if (isVideo) {
await generateVideoThumbnail(file, thumbnailPath);
await generateVideoThumbnail(file, fullPath);
thumbnailGenerated = true;
} else if (isPhoto) {
await generatePhotoThumbnail(file, thumbnailPath);
await generatePhotoThumbnail(file, fullPath);
thumbnailGenerated = true;
}
} catch (thumbnailError) {
console.warn(`Thumbnail generation failed for ${file}:`, thumbnailError);
// Use fallback thumbnail based on media type
finalThumbnailUrl = isVideo ? "/placeholder-video.svg" : isPhoto ? "/placeholder-photo.svg" : "/placeholder.svg";
finalThumbnailUrl = ThumbnailManager.getFallbackThumbnailUrl(mediaType);
}
const media = {

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

@ -0,0 +1,95 @@
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
export class ThumbnailManager {
private static readonly THUMBNAIL_WIDTH = 320;
private static readonly HASH_ALGORITHM = 'md5';
private static readonly THUMBNAIL_EXTENSION = '.png';
/**
* Generate MD5 hash for a file path
*/
static generateHash(filePath: string): string {
return crypto
.createHash(this.HASH_ALGORITHM)
.update(filePath)
.digest('hex');
}
/**
* Generate thumbnail filename based on hash and width
*/
static getThumbnailFilename(hash: string, width: number = this.THUMBNAIL_WIDTH): string {
return `${hash}_${width}${this.THUMBNAIL_EXTENSION}`;
}
/**
* Get the folder structure for a given hash
*/
static getFolderStructure(hash: string): { folder1: string; folder2: string } {
return {
folder1: hash.substring(0, 2),
folder2: hash.substring(2, 4)
};
}
/**
* Get the complete thumbnail path information
*/
static getThumbnailPath(filePath: string): {
folderPath: string;
filename: string;
fullPath: string;
url: string;
} {
const hash = this.generateHash(filePath);
const { folder1, folder2 } = this.getFolderStructure(hash);
const filename = this.getThumbnailFilename(hash);
const folderPath = path.join('thumbnails', folder1, folder2);
const fullPath = path.join(process.cwd(), 'public', folderPath, filename);
const url = `/${folderPath}/${filename}`;
return { folderPath, filename, fullPath, url };
}
/**
* Ensure thumbnail directory exists
*/
static ensureDirectory(folderPath: string): void {
const fullPath = path.join(process.cwd(), 'public', folderPath);
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
}
}
/**
* Get the base thumbnails directory
*/
static getThumbnailsBaseDir(): string {
return path.join(process.cwd(), 'public', 'thumbnails');
}
/**
* Check if a thumbnail exists
*/
static thumbnailExists(filePath: string): boolean {
const { fullPath } = this.getThumbnailPath(filePath);
return fs.existsSync(fullPath);
}
/**
* Get fallback thumbnail URL based on media type
*/
static getFallbackThumbnailUrl(mediaType: 'video' | 'photo' | 'text'): string {
switch (mediaType) {
case 'video':
return '/placeholder-video.svg';
case 'photo':
return '/placeholder-photo.svg';
default:
return '/placeholder.svg';
}
}
}