diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1cd3ffa --- /dev/null +++ b/CLAUDE.md @@ -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 + + diff --git a/media.db b/media.db index f6177db..2b38a25 100644 Binary files a/media.db and b/media.db differ diff --git a/screenshot/image copy.png b/screenshot/image copy.png deleted file mode 100644 index 011dca8..0000000 Binary files a/screenshot/image copy.png and /dev/null differ diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts index ea53142..19da4f3 100644 --- a/src/app/api/files/route.ts +++ b/src/app/api/files/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import fs from "fs"; 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) { const { searchParams } = new URL(request.url); @@ -13,16 +17,39 @@ export async function GET(request: Request) { try { 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 filePath = path.join(dirPath, file); 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 { name: file, path: filePath, isDirectory: stats.isDirectory(), size: stats.size, + type: mediaFile?.type || type, + thumbnail: mediaFile?.thumbnail, }; }); + return NextResponse.json(result); } catch (error: any) { return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts new file mode 100644 index 0000000..d1019a9 --- /dev/null +++ b/src/app/api/photos/route.ts @@ -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); +} \ No newline at end of file diff --git a/src/app/folder-viewer/page.tsx b/src/app/folder-viewer/page.tsx index 0397071..1fc8a62 100644 --- a/src/app/folder-viewer/page.tsx +++ b/src/app/folder-viewer/page.tsx @@ -3,15 +3,17 @@ import { useState, useEffect, Suspense } from "react"; 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 { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; interface FileSystemItem { name: string; path: string; isDirectory: boolean; size: number; + thumbnail?: string; + type?: string; } const FolderViewerPage = () => { @@ -31,32 +33,88 @@ const FolderViewerPage = () => { 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 ; + if (item.type === 'photo') return ; + if (item.type === 'video') return ; + return ; + }; + + const isMediaFile = (item: FileSystemItem) => { + return item.type === 'video' || item.type === 'photo'; + }; + if (!path) { - return
Select a library from the sidebar
; + return ( +
+
+

Select a library from the sidebar

+

Choose a media library above to browse its contents

+
+
+ ); } return ( -
-

{path}

-
+
+

{path}

+
{items.map((item) => ( - + {item.isDirectory ? ( - - - {item.name} + +
+ +
+ ) : isMediaFile(item) ? ( +
+ {item.name} { + (e.target as HTMLImageElement).src = '/placeholder.svg'; + }} + /> +
+ {item.type === 'video' ? ( + + ) : ( + + )} +
+
) : ( -
- - {item.name} +
+ {getFileIcon(item)}
)} + + {item.name} + +
Size: {formatFileSize(item.size)}
+ {!item.isDirectory &&
Type: {item.type || 'File'}
} +
+
))}
+ {items.length === 0 && ( +
+

No items found in this directory.

+
+ )}
); }; diff --git a/src/app/photos/page.tsx b/src/app/photos/page.tsx new file mode 100644 index 0000000..29be2ac --- /dev/null +++ b/src/app/photos/page.tsx @@ -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([]); + 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 ( +
+
Loading photos...
+
+ ); + } + + return ( +
+

Photos

+
+ {photos.map((photo) => ( + + + {photo.title} { + (e.target as HTMLImageElement).src = '/placeholder-image.jpg'; + }} + /> + + + {photo.title} + +
Size: {formatFileSize(photo.size)}
+
Path: {photo.path}
+
+
+
+ ))} +
+ {photos.length === 0 && ( +
+

No photos found. Add media libraries to scan for photos.

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index b7ef136..3a980cb 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -4,8 +4,10 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; 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 { Alert, AlertDescription } from "@/components/ui/alert"; +import { Trash2, Plus, Folder, HardDrive } from "lucide-react"; interface Library { id: number; @@ -15,38 +17,65 @@ interface Library { const SettingsPage = () => { const [libraries, setLibraries] = useState([]); const [newLibraryPath, setNewLibraryPath] = useState(""); + const [error, setError] = useState(null); + const [scanStatus, setScanStatus] = useState(""); useEffect(() => { fetchLibraries(); }, []); const fetchLibraries = async () => { - const res = await fetch("/api/libraries"); - const data = await res.json(); - setLibraries(data); + try { + const res = await fetch("/api/libraries"); + const data = await res.json(); + setLibraries(data); + } catch (error) { + console.error('Error fetching libraries:', error); + } }; const addLibrary = async () => { - if (!newLibraryPath) return; - const res = await fetch("/api/libraries", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ path: newLibraryPath }), - }); - if (res.ok) { - setNewLibraryPath(""); - fetchLibraries(); + if (!newLibraryPath.trim()) { + setError("Please enter a valid path"); + return; + } + + try { + const res = await fetch("/api/libraries", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ path: newLibraryPath.trim() }), + }); + + if (res.ok) { + setNewLibraryPath(""); + setError(null); + 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 res = await fetch(`/api/libraries/${id}`, { - method: "DELETE", - }); - if (res.ok) { - fetchLibraries(); + 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}`, { + method: "DELETE", + }); + if (res.ok) { + fetchLibraries(); + } + } catch (error) { + console.error('Error deleting library:', error); } }; @@ -54,56 +83,146 @@ const SettingsPage = () => { const scanLibraries = async () => { setIsScanning(true); - const res = await fetch("/api/scan", { - method: "POST", - }); - if (res.ok) { - alert("Scan complete"); + setScanStatus("Scanning libraries..."); + + try { + const res = await fetch("/api/scan", { + method: "POST", + }); + + if (res.ok) { + 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 ( -
+
+ + {error && ( + + {error} + + )} + + {scanStatus && ( + + {scanStatus} + + )} +
- Manage Media Libraries + + + Manage Media Libraries + + + Add or remove media library paths to scan for videos and photos + -
+
setNewLibraryPath(e.target.value)} + onChange={(e) => { + setNewLibraryPath(e.target.value); + setError(null); + }} + onKeyPress={(e) => e.key === 'Enter' && addLibrary()} + className="flex-1" /> - +
-
-

Existing Libraries

-
    + + {libraries.length > 0 ? ( +
    +

    Existing Libraries ({libraries.length})

    {libraries.map((lib) => ( -
  • - {lib.path} - -
  • +
    ))} -
-
+
+ ) : ( +
+ +

No media libraries configured yet.

+

Add your first library above to get started.

+
+ )}
+ - Media Scanner + Media Scanner + + Scan your libraries for new videos and photos + - +

+ {libraries.length === 0 + ? "Add at least one library to enable scanning" + : "This will scan all configured libraries for new media files"} +

+
+
+ + + + System Information + + +
+ Libraries: + {libraries.length} +
+
+ Database: + SQLite +
diff --git a/src/app/videos/page.tsx b/src/app/videos/page.tsx index d2e8a94..f2860d1 100644 --- a/src/app/videos/page.tsx +++ b/src/app/videos/page.tsx @@ -5,7 +5,7 @@ import { useState, useEffect } from "react"; import { Card, CardContent, - CardFooter, + CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; @@ -21,35 +21,71 @@ interface Video { const VideosPage = () => { const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { fetchVideos(); }, []); const fetchVideos = async () => { - const res = await fetch("/api/videos"); - const data = await res.json(); - setVideos(data); + try { + const res = await fetch("/api/videos"); + const data = await res.json(); + 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 ( +
+
Loading videos...
+
+ ); + } + return ( -
+
-
+
{videos.map((video) => ( - {video.title} + {video.title} { + (e.target as HTMLImageElement).src = '/placeholder.svg'; + }} + /> - {video.title} + {video.title} + +
Size: {formatFileSize(video.size)}
+
Path: {video.path}
+
- -

{video.path}

-
))}
+ {videos.length === 0 && ( +
+

No videos found. Add media libraries to scan for videos.

+
+ )}
); }; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..fc218ce --- /dev/null +++ b/src/components/ui/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index e4cc83e..eaff9a1 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -4,10 +4,10 @@ import path from "path"; import fs from "fs"; import ffmpeg from "fluent-ffmpeg"; -const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv"]; -const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif"]; +const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"]; +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) => { ffmpeg(videoPath) .on("end", () => resolve(thumbnailPath)) @@ -17,19 +17,44 @@ const generateThumbnail = (videoPath: string, thumbnailPath: string) => { folder: path.dirname(thumbnailPath), filename: path.basename(thumbnailPath), 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 mediaFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, { + // Scan videos + const videoFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, { 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 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 thumbnailUrl = `/thumbnails/${thumbnailFileName}`; @@ -39,12 +64,22 @@ const scanLibrary = async (library: { id: number; path: string }) => { 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 = { library_id: library.id, path: file, - type: "video", + type: mediaType, title: title, size: stats.size, thumbnail: thumbnailUrl,