feat: add media library management features, including scanning and displaying photos and videos; implement database integration for media files

This commit is contained in:
tigeren 2025-08-25 08:11:03 +00:00
parent 31e27d4214
commit e248613abb
11 changed files with 525 additions and 75 deletions

25
CLAUDE.md Normal file
View File

@ -0,0 +1,25 @@
Project Description:
This is a nextjs project, basically a youtube like video sites.
Feature requirement:
1. Has a youtube like UI
2. has a concept of media library. each library is a path that mounted in the /mnt
3. has the ability to scan the media libaries
4. the quntitiy of media files can be vast, tens of thousands videos
5. the media can work with photos and videos
6. user can manage(add/remove) the media libraries.
7. there should be a database(sqlite) to save the media informations
8. there should be at least two tables in the database, one to keep the video informations, such as path, size, thumbnails, title, etc(not limit to these); one to keep the media libraries. videos should be linked to the library
9. thumbnail generate feature
UI:
1. two main areas, left is the left sidebar, right is the main video area
2. side bar can be expanded and collapsed
3. sidebar should have these sections: Settings, Videos, Photos, Folder Viewer
4. Settings section: open manage page in the main area, allow user to add media library path; delete library.
5. Videos section: open video cards page in the main area, each card has thumbnail pic displayed. the card lower part display video path, size.
6. photo section: TBD
7. folder viewer: list libraries in the side bar folder viewer section. once one of the folder is selected, display the folder sturcture in the main area. the vdieo or photo display thunbnail and information as the video card would do. for the folder, display the folder icon, which can be entered in

BIN
media.db

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

View File

@ -2,6 +2,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import db from '@/db';
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -13,16 +17,39 @@ export async function GET(request: Request) {
try { try {
const files = fs.readdirSync(dirPath); const files = fs.readdirSync(dirPath);
// Get media files from database for this path
const mediaFiles = db.prepare(`
SELECT path, type, thumbnail
FROM media
WHERE path LIKE ?
`).all(`${dirPath}%`) as Array<{path: string, type: string, thumbnail: string | null}>;
const result = files.map((file) => { const result = files.map((file) => {
const filePath = path.join(dirPath, file); const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
const ext = path.extname(file).toLowerCase();
let type = 'file';
if (VIDEO_EXTENSIONS.some(v => ext.includes(v))) {
type = 'video';
} else if (PHOTO_EXTENSIONS.some(p => ext.includes(p))) {
type = 'photo';
}
// Find matching media file in database
const mediaFile = mediaFiles.find((m: any) => m.path === filePath);
return { return {
name: file, name: file,
path: filePath, path: filePath,
isDirectory: stats.isDirectory(), isDirectory: stats.isDirectory(),
size: stats.size, size: stats.size,
type: mediaFile?.type || type,
thumbnail: mediaFile?.thumbnail,
}; };
}); });
return NextResponse.json(result); return NextResponse.json(result);
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });

View File

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import db from '@/db';
export async function GET() {
const photos = db.prepare('SELECT * FROM media WHERE type = ?').all('photo');
return NextResponse.json(photos);
}

View File

@ -3,15 +3,17 @@
import { useState, useEffect, Suspense } from "react"; import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Folder, File } from "lucide-react"; import { Folder, File, Image, Film } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface FileSystemItem { interface FileSystemItem {
name: string; name: string;
path: string; path: string;
isDirectory: boolean; isDirectory: boolean;
size: number; size: number;
thumbnail?: string;
type?: string;
} }
const FolderViewerPage = () => { const FolderViewerPage = () => {
@ -31,32 +33,88 @@ const FolderViewerPage = () => {
setItems(data); setItems(data);
}; };
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (item: FileSystemItem) => {
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
if (item.type === 'photo') return <Image className="text-green-500" size={48} />;
if (item.type === 'video') return <Film className="text-red-500" size={48} />;
return <File className="text-gray-500" size={48} />;
};
const isMediaFile = (item: FileSystemItem) => {
return item.type === 'video' || item.type === 'photo';
};
if (!path) { if (!path) {
return <div>Select a library from the sidebar</div>; return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-500 text-lg mb-4">Select a library from the sidebar</p>
<p className="text-gray-400">Choose a media library above to browse its contents</p>
</div>
</div>
);
} }
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="p-6">
<h1 className="text-2xl font-bold mb-4 truncate">{path}</h1> <h1 className="text-3xl font-bold mb-6 truncate">{path}</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{items.map((item) => ( {items.map((item) => (
<Card key={item.name} className="overflow-hidden rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out"> <Card key={item.name} className="overflow-hidden rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out">
<CardContent className="p-4 flex items-center"> <CardContent className="p-0">
{item.isDirectory ? ( {item.isDirectory ? (
<Link href={`/folder-viewer?path=${item.path}`} className="flex items-center w-full"> <Link href={`/folder-viewer?path=${item.path}`} className="block">
<Folder className="mr-4 text-blue-500" size={48} /> <div className="flex items-center justify-center h-48 bg-gray-100">
<span className="truncate font-semibold">{item.name}</span> <Folder className="text-blue-500" size={64} />
</div>
</Link> </Link>
) : isMediaFile(item) ? (
<div className="relative">
<img
src={item.thumbnail || '/placeholder.svg'}
alt={item.name}
className="w-full h-48 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder.svg';
}}
/>
<div className="absolute top-2 right-2">
{item.type === 'video' ? (
<Film className="text-white bg-black bg-opacity-50 rounded-full p-1" size={20} />
) : ( ) : (
<div className="flex items-center w-full"> <Image className="text-white bg-black bg-opacity-50 rounded-full p-1" size={20} />
<File className="mr-4 text-gray-500" size={48} /> )}
<span className="truncate">{item.name}</span> </div>
</div>
) : (
<div className="flex items-center justify-center h-48 bg-gray-100">
{getFileIcon(item)}
</div> </div>
)} )}
</CardContent> </CardContent>
<CardHeader className="p-4">
<CardTitle className="text-sm font-semibold truncate">{item.name}</CardTitle>
<CardDescription className="text-xs">
<div>Size: {formatFileSize(item.size)}</div>
{!item.isDirectory && <div className="truncate">Type: {item.type || 'File'}</div>}
</CardDescription>
</CardHeader>
</Card> </Card>
))} ))}
</div> </div>
{items.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No items found in this directory.</p>
</div>
)}
</div> </div>
); );
}; };

85
src/app/photos/page.tsx Normal file
View File

@ -0,0 +1,85 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
interface Photo {
id: number;
path: string;
title: string;
size: number;
thumbnail: string;
type: string;
}
export default function PhotosPage() {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPhotos();
}, []);
const fetchPhotos = async () => {
try {
const response = await fetch('/api/photos');
const data = await response.json();
setPhotos(data);
} catch (error) {
console.error('Error fetching photos:', error);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-lg">Loading photos...</div>
</div>
);
}
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">Photos</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{photos.map((photo) => (
<Card key={photo.id} className="overflow-hidden">
<CardContent className="p-0">
<img
src={photo.thumbnail}
alt={photo.title}
className="w-full h-48 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-image.jpg';
}}
/>
</CardContent>
<CardHeader className="p-4">
<CardTitle className="text-sm truncate">{photo.title}</CardTitle>
<CardDescription className="text-xs">
<div>Size: {formatFileSize(photo.size)}</div>
<div className="truncate">Path: {photo.path}</div>
</CardDescription>
</CardHeader>
</Card>
))}
</div>
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No photos found. Add media libraries to scan for photos.</p>
</div>
)}
</div>
);
}

View File

@ -4,8 +4,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Header } from "@/components/ui/header"; import { Header } from "@/components/ui/header";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Trash2, Plus, Folder, HardDrive } from "lucide-react";
interface Library { interface Library {
id: number; id: number;
@ -15,95 +17,212 @@ interface Library {
const SettingsPage = () => { const SettingsPage = () => {
const [libraries, setLibraries] = useState<Library[]>([]); const [libraries, setLibraries] = useState<Library[]>([]);
const [newLibraryPath, setNewLibraryPath] = useState(""); const [newLibraryPath, setNewLibraryPath] = useState("");
const [error, setError] = useState<string | null>(null);
const [scanStatus, setScanStatus] = useState<string>("");
useEffect(() => { useEffect(() => {
fetchLibraries(); fetchLibraries();
}, []); }, []);
const fetchLibraries = async () => { const fetchLibraries = async () => {
try {
const res = await fetch("/api/libraries"); const res = await fetch("/api/libraries");
const data = await res.json(); const data = await res.json();
setLibraries(data); setLibraries(data);
} catch (error) {
console.error('Error fetching libraries:', error);
}
}; };
const addLibrary = async () => { const addLibrary = async () => {
if (!newLibraryPath) return; if (!newLibraryPath.trim()) {
setError("Please enter a valid path");
return;
}
try {
const res = await fetch("/api/libraries", { const res = await fetch("/api/libraries", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ path: newLibraryPath }), body: JSON.stringify({ path: newLibraryPath.trim() }),
}); });
if (res.ok) { if (res.ok) {
setNewLibraryPath(""); setNewLibraryPath("");
setError(null);
fetchLibraries(); fetchLibraries();
} else {
const data = await res.json();
setError(data.error || "Failed to add library");
}
} catch (error) {
setError("Network error occurred");
} }
}; };
const deleteLibrary = async (id: number) => { const deleteLibrary = async (id: number) => {
if (!confirm("Are you sure you want to delete this library? This will not delete the actual files.")) {
return;
}
try {
const res = await fetch(`/api/libraries/${id}`, { const res = await fetch(`/api/libraries/${id}`, {
method: "DELETE", method: "DELETE",
}); });
if (res.ok) { if (res.ok) {
fetchLibraries(); fetchLibraries();
} }
} catch (error) {
console.error('Error deleting library:', error);
}
}; };
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
const scanLibraries = async () => { const scanLibraries = async () => {
setIsScanning(true); setIsScanning(true);
setScanStatus("Scanning libraries...");
try {
const res = await fetch("/api/scan", { const res = await fetch("/api/scan", {
method: "POST", method: "POST",
}); });
if (res.ok) { if (res.ok) {
alert("Scan complete"); setScanStatus("Scan completed successfully!");
setTimeout(() => setScanStatus(""), 3000);
} else {
setScanStatus("Scan failed. Please try again.");
} }
} catch (error) {
setScanStatus("Network error during scan");
} finally {
setIsScanning(false); setIsScanning(false);
}
};
const getTotalStorage = () => {
return libraries.reduce((total, lib) => {
// Rough estimation based on path length
return total + (lib.path.length * 100); // Placeholder calculation
}, 0);
}; };
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="p-6 max-w-4xl mx-auto">
<Header title="Settings" /> <Header title="Settings" />
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{scanStatus && (
<Alert className="mb-4">
<AlertDescription>{scanStatus}</AlertDescription>
</Alert>
)}
<div className="space-y-8"> <div className="space-y-8">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Manage Media Libraries</CardTitle> <CardTitle className="text-2xl flex items-center gap-2">
<Folder className="h-6 w-6" />
Manage Media Libraries
</CardTitle>
<CardDescription>
Add or remove media library paths to scan for videos and photos
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex w-full max-w-sm items-center space-x-2 mb-4"> <div className="flex gap-2 mb-6">
<Input <Input
type="text" type="text"
placeholder="/mnt/media" placeholder="/mnt/media or /path/to/media"
value={newLibraryPath} value={newLibraryPath}
onChange={(e) => setNewLibraryPath(e.target.value)} onChange={(e) => {
setNewLibraryPath(e.target.value);
setError(null);
}}
onKeyPress={(e) => e.key === 'Enter' && addLibrary()}
className="flex-1"
/> />
<Button onClick={addLibrary}>Add Library</Button> <Button onClick={addLibrary} className="gap-2">
<Plus size={16} />
Add Library
</Button>
</div> </div>
<div>
<h2 className="text-xl font-semibold mb-2">Existing Libraries</h2> {libraries.length > 0 ? (
<ul className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-semibold mb-3">Existing Libraries ({libraries.length})</h3>
{libraries.map((lib) => ( {libraries.map((lib) => (
<li key={lib.id} className="flex items-center justify-between p-2 border rounded-lg"> <div key={lib.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
<span className="truncate">{lib.path}</span> <div className="flex items-center gap-3 flex-1 min-w-0">
<Button variant="destructive" size="sm" onClick={() => deleteLibrary(lib.id)}> <HardDrive className="h-5 w-5 text-gray-500 flex-shrink-0" />
<span className="font-mono text-sm truncate">{lib.path}</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteLibrary(lib.id)}
className="gap-2"
>
<Trash2 size={14} />
Delete Delete
</Button> </Button>
</li>
))}
</ul>
</div> </div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No media libraries configured yet.</p>
<p className="text-sm">Add your first library above to get started.</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Media Scanner</CardTitle> <CardTitle className="text-2xl">Media Scanner</CardTitle>
<CardDescription>
Scan your libraries for new videos and photos
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button onClick={scanLibraries} disabled={isScanning}> <Button
onClick={scanLibraries}
disabled={isScanning || libraries.length === 0}
className="gap-2"
>
{isScanning ? "Scanning..." : "Scan Libraries"} {isScanning ? "Scanning..." : "Scan Libraries"}
</Button> </Button>
<p className="text-sm text-gray-500 mt-2">
{libraries.length === 0
? "Add at least one library to enable scanning"
: "This will scan all configured libraries for new media files"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-2xl">System Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Libraries:</span>
<span className="font-semibold">{libraries.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Database:</span>
<span className="font-semibold">SQLite</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -5,7 +5,7 @@ import { useState, useEffect } from "react";
import { import {
Card, Card,
CardContent, CardContent,
CardFooter, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
@ -21,35 +21,71 @@ interface Video {
const VideosPage = () => { const VideosPage = () => {
const [videos, setVideos] = useState<Video[]>([]); const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchVideos(); fetchVideos();
}, []); }, []);
const fetchVideos = async () => { const fetchVideos = async () => {
try {
const res = await fetch("/api/videos"); const res = await fetch("/api/videos");
const data = await res.json(); const data = await res.json();
setVideos(data); setVideos(data);
} catch (error) {
console.error('Error fetching videos:', error);
} finally {
setLoading(false);
}
}; };
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (loading) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="flex items-center justify-center h-full">
<div className="text-lg">Loading videos...</div>
</div>
);
}
return (
<div className="p-6">
<Header title="Videos" /> <Header title="Videos" />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{videos.map((video) => ( {videos.map((video) => (
<Card key={video.id} className="overflow-hidden rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out"> <Card key={video.id} className="overflow-hidden rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out">
<CardContent className="p-0"> <CardContent className="p-0">
<img src={video.thumbnail || "/placeholder.svg"} alt={video.title} className="w-full h-48 object-cover"/> <img
src={video.thumbnail || "/placeholder.svg"}
alt={video.title}
className="w-full h-48 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder.svg';
}}
/>
</CardContent> </CardContent>
<CardHeader className="p-4"> <CardHeader className="p-4">
<CardTitle className="text-lg font-semibold truncate">{video.title}</CardTitle> <CardTitle className="text-sm font-semibold truncate">{video.title}</CardTitle>
<CardDescription className="text-xs">
<div>Size: {formatFileSize(video.size)}</div>
<div className="truncate">Path: {video.path}</div>
</CardDescription>
</CardHeader> </CardHeader>
<CardFooter className="p-4 pt-0">
<p className="text-sm text-gray-500 truncate">{video.path}</p>
</CardFooter>
</Card> </Card>
))} ))}
</div> </div>
{videos.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No videos found. Add media libraries to scan for videos.</p>
</div>
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -4,10 +4,10 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import ffmpeg from "fluent-ffmpeg"; import ffmpeg from "fluent-ffmpeg";
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv"]; const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif"]; const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
const generateThumbnail = (videoPath: string, thumbnailPath: string) => { const generateVideoThumbnail = (videoPath: string, thumbnailPath: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg(videoPath) ffmpeg(videoPath)
.on("end", () => resolve(thumbnailPath)) .on("end", () => resolve(thumbnailPath))
@ -17,19 +17,44 @@ const generateThumbnail = (videoPath: string, thumbnailPath: string) => {
folder: path.dirname(thumbnailPath), folder: path.dirname(thumbnailPath),
filename: path.basename(thumbnailPath), filename: path.basename(thumbnailPath),
size: "320x240", size: "320x240",
timemarks: ["10%"]
}); });
}); });
}; };
const generatePhotoThumbnail = (photoPath: string, thumbnailPath: string) => {
return new Promise((resolve, reject) => {
ffmpeg(photoPath)
.on("end", () => resolve(thumbnailPath))
.on("error", (err) => reject(err))
.outputOptions(["-vf", "scale=320:240:force_original_aspect_ratio=decrease", "-quality", "85"])
.output(thumbnailPath)
.run();
});
};
const scanLibrary = async (library: { id: number; path: string }) => { const scanLibrary = async (library: { id: number; path: string }) => {
const mediaFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, { // Scan videos
const videoFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, {
nodir: true, nodir: true,
}); });
for (const file of mediaFiles) { // Scan photos
const photoFiles = await glob(`${library.path}/**/*.{${PHOTO_EXTENSIONS.join(",")}}`, {
nodir: true,
});
const allFiles = [...videoFiles, ...photoFiles];
for (const file of allFiles) {
const stats = fs.statSync(file); const stats = fs.statSync(file);
const title = path.basename(file); const title = path.basename(file);
const thumbnailFileName = `${path.parse(title).name}.png`; const ext = path.extname(file).toLowerCase();
const isVideo = VIDEO_EXTENSIONS.some(v => ext.includes(v));
const isPhoto = PHOTO_EXTENSIONS.some(p => ext.includes(p));
const mediaType = isVideo ? "video" : "photo";
const thumbnailFileName = `${path.parse(title).name}_${Date.now()}.png`;
const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName); const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName);
const thumbnailUrl = `/thumbnails/${thumbnailFileName}`; const thumbnailUrl = `/thumbnails/${thumbnailFileName}`;
@ -39,12 +64,22 @@ const scanLibrary = async (library: { id: number; path: string }) => {
continue; continue;
} }
await generateThumbnail(file, thumbnailPath); // Ensure thumbnails directory exists
const thumbnailsDir = path.join(process.cwd(), "public", "thumbnails");
if (!fs.existsSync(thumbnailsDir)) {
fs.mkdirSync(thumbnailsDir, { recursive: true });
}
if (isVideo) {
await generateVideoThumbnail(file, thumbnailPath);
} else if (isPhoto) {
await generatePhotoThumbnail(file, thumbnailPath);
}
const media = { const media = {
library_id: library.id, library_id: library.id,
path: file, path: file,
type: "video", type: mediaType,
title: title, title: title,
size: stats.size, size: stats.size,
thumbnail: thumbnailUrl, thumbnail: thumbnailUrl,