Compare commits

..

4 Commits

Author SHA1 Message Date
tigeren 6f938243ad feat: add auto-play functionality to inline video player on load 2025-08-26 02:18:18 +00:00
tigeren a752ce964a feat: implement inline video player component for enhanced video playback experience
- Replaced modal video player with an inline video player that renders as a portal.
- Added state management for video playback, volume control, and fullscreen functionality.
- Introduced new component for inline video playback with customizable controls and metadata display.
2025-08-25 17:38:02 +00:00
tigeren 5014434717 feat: enhance thumbnail generation in media scanning process
- Added error handling for thumbnail generation, providing fallback thumbnails for videos and photos if generation fails.
- Updated database insertion logic to reflect the final thumbnail URL used, improving media management reliability.
2025-08-25 16:55:22 +00:00
tigeren 50deee7f2a fix: update Tailwind CSS version to v3 in project documentation and ensure compliance with v3 standards 2025-08-25 16:54:17 +00:00
62 changed files with 615 additions and 23 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
public/thumbnails

View File

@ -1,5 +1,6 @@
Project Description:
This is a nextjs project, basically a youtube like video sites.
the tailwindcss is v3 version. must ensure all the css related tailwind sytling code should comply with the v3 standard
Feature requirement:
1. Has a youtube like UI

View File

@ -1,5 +1,6 @@
Project Description:
This is a nextjs project, basically a youtube like video sites.
the tailwindcss is v3 version. must ensure all the css related tailwind sytling code should comply with the v3 standard
Feature requirement:
1. Has a youtube like UI

2
PRD.md
View File

@ -1,6 +1,6 @@
Project Description:
This is a nextjs project, basically a youtube like video sites.
the tailwindcss is v4 version. must ensure all the css related tailwind sytling code should comply with the v4 standard
the tailwindcss is v3 version. must ensure all the css related tailwind sytling code should comply with the v3 standard
Feature requirement:
1. Has a youtube like UI

BIN
media.db

Binary file not shown.

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240" fill="none">
<rect width="320" height="240" fill="#1f2937"/>
<rect x="90" y="80" width="140" height="80" rx="4" stroke="#6b7280" stroke-width="2" fill="none"/>
<circle cx="160" cy="120" r="25" stroke="#6b7280" stroke-width="2" fill="none"/>
<circle cx="160" cy="120" r="12" stroke="#6b7280" stroke-width="1" fill="none"/>
<text x="160" y="180" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#9ca3af">Photo</text>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240" fill="none">
<rect width="320" height="240" fill="#1f2937"/>
<circle cx="160" cy="120" r="40" stroke="#6b7280" stroke-width="2" fill="none"/>
<polygon points="140,100 180,120 140,140" fill="#6b7280"/>
<text x="160" y="180" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#9ca3af">Video</text>
</svg>

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import db from "@/db";
import fs from "fs";
import path from "path";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const videoId = parseInt(id);
const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { path: string } | undefined;
if (!video) {
return NextResponse.json({ error: "Video not found" }, { status: 404 });
}
const videoPath = video.path;
if (!fs.existsSync(videoPath)) {
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
}
const stat = fs.statSync(videoPath);
const fileSize = stat.size;
const range = request.headers.get("range");
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = end - start + 1;
const file = fs.createReadStream(videoPath, { start, end });
const headers = new Headers({
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunksize.toString(),
"Content-Type": "video/mp4",
});
return new Response(file as any, {
status: 206,
headers,
});
} else {
const headers = new Headers({
"Content-Length": fileSize.toString(),
"Content-Type": "video/mp4",
});
const file = fs.createReadStream(videoPath);
return new Response(file as any, {
status: 200,
headers,
});
}
} catch (error) {
console.error("Error streaming video:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -1,4 +1,3 @@
"use client";
import { useState, useEffect } from "react";
@ -7,6 +6,8 @@ import { Film, Play, Clock, HardDrive, Search, Filter } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import InlineVideoPlayer from "@/components/inline-video-player";
import { createPortal } from "react-dom";
interface Video {
id: number;
@ -20,6 +21,9 @@ const VideosPage = () => {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
useEffect(() => {
fetchVideos();
@ -50,6 +54,16 @@ const VideosPage = () => {
video.path.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleVideoClick = (video: Video) => {
setSelectedVideo(video);
setIsPlayerOpen(true);
};
const handleClosePlayer = () => {
setIsPlayerOpen(false);
setSelectedVideo(null);
};
if (loading) {
return (
<div className="min-h-screen p-6">
@ -68,8 +82,9 @@ const VideosPage = () => {
}
return (
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
<>
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
@ -108,7 +123,11 @@ const VideosPage = () => {
{filteredVideos.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
{filteredVideos.map((video) => (
<Card key={video.id} className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden">
<Card
key={video.id}
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden"
onClick={() => handleVideoClick(video)}
>
<div className="aspect-video relative overflow-hidden bg-muted">
<img
src={video.thumbnail || "/placeholder.svg"}
@ -158,12 +177,8 @@ const VideosPage = () => {
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
No videos found
</h3>
<p className="text-muted-foreground mb-4">
Try adjusting your search terms
</p>
<h3 className="text-xl font-semibold text-foreground mb-2">No videos found</h3>
<p className="text-muted-foreground mb-4">Try adjusting your search terms</p>
<Button
variant="outline"
onClick={() => setSearchTerm("")}
@ -178,12 +193,8 @@ const VideosPage = () => {
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Film className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
No Videos Found
</h3>
<p className="text-muted-foreground mb-6">
Add media libraries and scan for videos to get started
</p>
<h3 className="text-xl font-semibold text-foreground mb-2">No Videos Found</h3>
<p className="text-muted-foreground mb-6">Add media libraries and scan for videos to get started</p>
<Link href="/settings">
<Button>
<Film className="h-4 w-4 mr-2" />
@ -195,7 +206,19 @@ const VideosPage = () => {
)}
</div>
</div>
{/* Inline Video Player - Rendered as Portal */}
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
<InlineVideoPlayer
video={selectedVideo}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
scrollPosition={scrollPosition}
/>,
document.body
)}
</>
);
};
export default VideosPage;
export default VideosPage;

View File

@ -0,0 +1,258 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
interface InlineVideoPlayerProps {
video: {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
};
isOpen: boolean;
onClose: () => void;
scrollPosition?: number;
}
export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPosition }: InlineVideoPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const [showControls, setShowControls] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
} else {
setIsVisible(false);
}
}, [isOpen]);
useEffect(() => {
if (isOpen && videoRef.current) {
videoRef.current.src = `/api/stream/${video.id}`;
videoRef.current.load();
// Auto-play when video is loaded
videoRef.current.addEventListener('loadeddata', () => {
if (videoRef.current) {
videoRef.current.play().then(() => {
setIsPlaying(true);
}).catch((error) => {
console.log('Auto-play prevented by browser:', error);
// Auto-play might be blocked by browser, that's okay
});
}
});
}
}, [isOpen, video.id]);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current) {
const newVolume = parseFloat(e.target.value);
videoRef.current.volume = newVolume;
setVolume(newVolume);
setIsMuted(newVolume === 0);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (videoRef.current) {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const newTime = (clickX / rect.width) * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === ' ') {
e.preventDefault();
handlePlayPause();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll when player is open
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore body scroll when player is closed
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className={`fixed inset-0 z-50 bg-background transition-opacity duration-300 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Header */}
<div className="sticky top-0 z-10 bg-background border-b border-border">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={onClose}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
<span>Back to videos</span>
</button>
</div>
<h1 className="text-xl font-semibold text-foreground truncate max-w-md">
{video.title}
</h1>
</div>
</div>
</div>
{/* Video Player */}
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="aspect-video bg-black rounded-lg overflow-hidden relative group">
<video
ref={videoRef}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onMouseMove={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
controls={false}
>
<source src={`/api/stream/${video.id}`} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Video Overlay Controls */}
<div className={`absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
{/* Center Play Button */}
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={handlePlayPause}
className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-all duration-200"
>
{isPlaying ? (
<Pause className="h-8 w-8 text-white" />
) : (
<Play className="h-8 w-8 text-white ml-1" />
)}
</button>
</div>
{/* Bottom Controls */}
<div className="absolute bottom-0 left-0 right-0 p-4">
{/* Progress Bar */}
<div
className="relative h-2 bg-white/20 rounded-full cursor-pointer mb-4"
onClick={handleProgressClick}
>
<div
className="absolute h-full bg-white rounded-full"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
/>
</div>
{/* Control Buttons */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={handlePlayPause}
className="p-2 hover:bg-white/20 rounded-full transition-colors"
>
{isPlaying ? <Pause className="h-5 w-5 text-white" /> : <Play className="h-5 w-5 text-white" />}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleMute}
className="p-2 hover:bg-white/20 rounded-full transition-colors"
>
{isMuted ? <VolumeX className="h-5 w-5 text-white" /> : <Volume2 className="h-5 w-5 text-white" />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-24 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
/>
</div>
<span className="text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<span className="text-sm text-white">
{Math.round(video.size / 1024 / 1024)} MB
</span>
</div>
</div>
</div>
</div>
{/* Video info below player */}
<div className="mt-6 space-y-2">
<h2 className="text-2xl font-bold text-foreground">{video.title}</h2>
<p className="text-muted-foreground font-mono text-sm break-all">
{video.path}
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,218 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
interface VideoPlayerProps {
video: {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
};
isOpen: boolean;
onClose: () => void;
}
export default function VideoPlayer({ video, isOpen, onClose }: VideoPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (isOpen && videoRef.current) {
videoRef.current.src = `/api/stream/${video.id}`;
videoRef.current.load();
}
}, [isOpen, video.id]);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleFullscreen = () => {
if (videoRef.current) {
if (!isFullscreen) {
videoRef.current.requestFullscreen();
} else {
document.exitFullscreen();
}
}
};
const handleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current) {
const newVolume = parseFloat(e.target.value);
videoRef.current.volume = newVolume;
setVolume(newVolume);
setIsMuted(newVolume === 0);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (videoRef.current) {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const newTime = (clickX / rect.width) * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === ' ') {
e.preventDefault();
handlePlayPause();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
>
<X className="h-6 w-6" />
</button>
{/* Video container */}
<div className="relative w-full h-full bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source src={`/api/stream/${video.id}`} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Title overlay */}
<div className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/60 to-transparent p-4">
<h2 className="text-white text-lg font-semibold">{video.title}</h2>
</div>
{/* Controls overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent">
<div className="p-4 space-y-2">
{/* Progress bar */}
<div
className="relative h-1 bg-white/20 rounded-full cursor-pointer"
onClick={handleProgressClick}
>
<div
className="absolute h-full bg-white rounded-full"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
/>
</div>
{/* Controls */}
<div className="flex items-center justify-between text-white">
<div className="flex items-center space-x-4">
<button
onClick={handlePlayPause}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
<div className="flex items-center space-x-2">
<button
onClick={handleMute}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isMuted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
/>
</div>
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<button
onClick={handleFullscreen}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -70,10 +70,21 @@ const scanLibrary = async (library: { id: number; path: string }) => {
fs.mkdirSync(thumbnailsDir, { recursive: true });
}
if (isVideo) {
await generateVideoThumbnail(file, thumbnailPath);
} else if (isPhoto) {
await generatePhotoThumbnail(file, thumbnailPath);
let finalThumbnailUrl = thumbnailUrl;
let thumbnailGenerated = false;
try {
if (isVideo) {
await generateVideoThumbnail(file, thumbnailPath);
thumbnailGenerated = true;
} else if (isPhoto) {
await generatePhotoThumbnail(file, thumbnailPath);
thumbnailGenerated = true;
}
} catch (thumbnailError) {
console.warn(`Thumbnail generation failed for ${file}:`, thumbnailError);
// Use fallback thumbnail based on media type
finalThumbnailUrl = isVideo ? "/placeholder-video.svg" : "/placeholder-photo.svg";
}
const media = {
@ -82,12 +93,14 @@ const scanLibrary = async (library: { id: number; path: string }) => {
type: mediaType,
title: title,
size: stats.size,
thumbnail: thumbnailUrl,
thumbnail: finalThumbnailUrl,
};
db.prepare(
"INSERT INTO media (library_id, path, type, title, size, thumbnail) VALUES (?, ?, ?, ?, ?, ?)"
).run(media.library_id, media.path, media.type, media.title, media.size, media.thumbnail);
console.log(`Successfully inserted ${mediaType}: ${title}${thumbnailGenerated ? ' with thumbnail' : ' with fallback thumbnail'}`);
} catch (error: any) {
if (error.code !== "SQLITE_CONSTRAINT_UNIQUE") {
console.error(`Error inserting media: ${file}`, error);