feat: add media library management features, including scanning and displaying photos and videos; implement database integration for media files
This commit is contained in:
parent
31e27d4214
commit
e248613abb
|
|
@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <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) {
|
||||
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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4 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="p-6">
|
||||
<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-6">
|
||||
{items.map((item) => (
|
||||
<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 ? (
|
||||
<Link href={`/folder-viewer?path=${item.path}`} className="flex items-center w-full">
|
||||
<Folder className="mr-4 text-blue-500" size={48} />
|
||||
<span className="truncate font-semibold">{item.name}</span>
|
||||
<Link href={`/folder-viewer?path=${item.path}`} className="block">
|
||||
<div className="flex items-center justify-center h-48 bg-gray-100">
|
||||
<Folder className="text-blue-500" size={64} />
|
||||
</div>
|
||||
</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">
|
||||
<File className="mr-4 text-gray-500" size={48} />
|
||||
<span className="truncate">{item.name}</span>
|
||||
<Image className="text-white bg-black bg-opacity-50 rounded-full p-1" size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 bg-gray-100">
|
||||
{getFileIcon(item)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No items found in this directory.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,95 +17,212 @@ interface Library {
|
|||
const SettingsPage = () => {
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
const [newLibraryPath, setNewLibraryPath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanStatus, setScanStatus] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, []);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
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;
|
||||
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 }),
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const scanLibraries = async () => {
|
||||
setIsScanning(true);
|
||||
setScanStatus("Scanning libraries...");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/scan", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalStorage = () => {
|
||||
return libraries.reduce((total, lib) => {
|
||||
// Rough estimation based on path length
|
||||
return total + (lib.path.length * 100); // Placeholder calculation
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<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">
|
||||
<Card>
|
||||
<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>
|
||||
<CardContent>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2 mb-4">
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/mnt/media"
|
||||
placeholder="/mnt/media or /path/to/media"
|
||||
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>
|
||||
<h2 className="text-xl font-semibold mb-2">Existing Libraries</h2>
|
||||
<ul className="space-y-2">
|
||||
|
||||
{libraries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold mb-3">Existing Libraries ({libraries.length})</h3>
|
||||
{libraries.map((lib) => (
|
||||
<li key={lib.id} className="flex items-center justify-between p-2 border rounded-lg">
|
||||
<span className="truncate">{lib.path}</span>
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteLibrary(lib.id)}>
|
||||
<div key={lib.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<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
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Media Scanner</CardTitle>
|
||||
<CardTitle className="text-2xl">Media Scanner</CardTitle>
|
||||
<CardDescription>
|
||||
Scan your libraries for new videos and photos
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={scanLibraries} disabled={isScanning}>
|
||||
<Button
|
||||
onClick={scanLibraries}
|
||||
disabled={isScanning || libraries.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Scan Libraries"}
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
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 (
|
||||
<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" />
|
||||
<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) => (
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<p className="text-sm text-gray-500 truncate">{video.path}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue