feat: Implement file system API and folder viewer

- Added API endpoint to retrieve files from a specified directory.
- Created API for managing media libraries (GET, POST, DELETE).
- Implemented scanning functionality for media libraries.
- Developed video listing page with API integration.
- Introduced folder viewer component to navigate file system.
- Enhanced settings page for managing libraries and initiating scans.
- Built sidebar component for navigation with dynamic library links.
- Established UI components for buttons, cards, and inputs.
- Set up SQLite database schema for libraries and media.
- Integrated thumbnail generation for video files during scanning.
- Configured Tailwind CSS for styling and responsive design.
This commit is contained in:
tigeren 2025-08-25 06:15:49 +00:00
parent 20a8a74d32
commit 83dea7c651
25 changed files with 1982 additions and 39 deletions

25
GEMINI.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

25
PRD.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

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

BIN
media.db Normal file

Binary file not shown.

1053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,16 +8,28 @@
"start": "next start"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/glob": "^8.1.0",
"better-sqlite3": "^12.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fluent-ffmpeg": "^2.1.3",
"glob": "^11.0.3",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.0"
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"typescript": "^5",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4"
"tailwindcss": "^4",
"typescript": "^5"
}
}

BIN
screenshot/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const dirPath = searchParams.get("path");
if (!dirPath) {
return NextResponse.json({ error: "Path is required" }, { status: 400 });
}
try {
const files = fs.readdirSync(dirPath);
const result = files.map((file) => {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
path: filePath,
isDirectory: stats.isDirectory(),
size: stats.size,
};
});
return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import db from '@/db';
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
const id = parseInt(params.id, 10);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const info = db.prepare('DELETE FROM libraries WHERE id = ?').run(id);
if (info.changes === 0) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Library deleted' });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import db from '@/db';
export async function GET() {
const libraries = db.prepare('SELECT * FROM libraries').all();
return NextResponse.json(libraries);
}
export async function POST(request: Request) {
const { path } = await request.json();
if (!path) {
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
}
try {
const info = db.prepare('INSERT INTO libraries (path) VALUES (?)').run(path);
return NextResponse.json({ id: info.lastInsertRowid, path });
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return NextResponse.json({ error: 'Path already exists' }, { status: 409 });
}
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

12
src/app/api/scan/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { scanAllLibraries } from "@/lib/scanner";
export async function POST() {
try {
await scanAllLibraries();
return NextResponse.json({ message: "Scan complete" });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

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

View File

@ -0,0 +1,70 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Folder, File } from "lucide-react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
interface FileSystemItem {
name: string;
path: string;
isDirectory: boolean;
size: number;
}
const FolderViewerPage = () => {
const searchParams = useSearchParams();
const path = searchParams.get("path");
const [items, setItems] = useState<FileSystemItem[]>([]);
useEffect(() => {
if (path) {
fetchItems(path);
}
}, [path]);
const fetchItems = async (currentPath: string) => {
const res = await fetch(`/api/files?path=${currentPath}`);
const data = await res.json();
setItems(data);
};
if (!path) {
return <div>Select a library from the sidebar</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">
{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">
{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>
) : (
<div className="flex items-center w-full">
<File className="mr-4 text-gray-500" size={48} />
<span className="truncate">{item.name}</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
);
};
const FolderViewerPageWrapper = () => (
<Suspense fallback={<div>Loading...</div>}>
<FolderViewerPage />
</Suspense>
)
export default FolderViewerPageWrapper;

View File

@ -1,26 +1,76 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
@layer base {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/sidebar";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -25,9 +26,12 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-100 dark:bg-gray-900`}
>
{children}
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 p-4 overflow-y-auto">{children}</main>
</div>
</body>
</html>
);

113
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,113 @@
"use client";
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";
interface Library {
id: number;
path: string;
}
const SettingsPage = () => {
const [libraries, setLibraries] = useState<Library[]>([]);
const [newLibraryPath, setNewLibraryPath] = useState("");
useEffect(() => {
fetchLibraries();
}, []);
const fetchLibraries = async () => {
const res = await fetch("/api/libraries");
const data = await res.json();
setLibraries(data);
};
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();
}
};
const deleteLibrary = async (id: number) => {
const res = await fetch(`/api/libraries/${id}`, {
method: "DELETE",
});
if (res.ok) {
fetchLibraries();
}
};
const [isScanning, setIsScanning] = useState(false);
const scanLibraries = async () => {
setIsScanning(true);
const res = await fetch("/api/scan", {
method: "POST",
});
if (res.ok) {
alert("Scan complete");
}
setIsScanning(false);
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Manage Media Libraries</CardTitle>
</CardHeader>
<CardContent>
<div className="flex w-full max-w-sm items-center space-x-2 mb-4">
<Input
type="text"
placeholder="/mnt/media"
value={newLibraryPath}
onChange={(e) => setNewLibraryPath(e.target.value)}
/>
<Button onClick={addLibrary}>Add Library</Button>
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Existing Libraries</h2>
<ul className="space-y-2">
{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)}>
Delete
</Button>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Media Scanner</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={scanLibraries} disabled={isScanning}>
{isScanning ? "Scanning..." : "Scan Libraries"}
</Button>
</CardContent>
</Card>
</div>
</div>
);
};
export default SettingsPage;

56
src/app/videos/page.tsx Normal file
View File

@ -0,0 +1,56 @@
"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface Video {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
}
const VideosPage = () => {
const [videos, setVideos] = useState<Video[]>([]);
useEffect(() => {
fetchVideos();
}, []);
const fetchVideos = async () => {
const res = await fetch("/api/videos");
const data = await res.json();
setVideos(data);
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4">Videos</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">
{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"/>
</CardContent>
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold truncate">{video.title}</CardTitle>
</CardHeader>
<CardFooter className="p-4 pt-0">
<p className="text-sm text-gray-500 truncate">{video.path}</p>
</CardFooter>
</Card>
))}
</div>
</div>
);
};
export default VideosPage;

View File

@ -0,0 +1,89 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
Home,
Settings,
Video,
Image,
Folder,
} from "lucide-react";
import Link from "next/link";
const Sidebar = () => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
useEffect(() => {
fetchLibraries();
}, []);
const fetchLibraries = async () => {
const res = await fetch("/api/libraries");
const data = await res.json();
setLibraries(data);
};
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
};
return (
<div
className={`flex flex-col bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-r border-gray-200 dark:border-gray-800 transition-all duration-300 ${isCollapsed ? "w-20" : "w-64"}`}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
{!isCollapsed && <h1 className="text-xl font-bold">NextAV</h1>}
<Button onClick={toggleSidebar} variant="ghost" size="icon">
{isCollapsed ? <ChevronRight /> : <ChevronLeft />}
</Button>
</div>
<nav className="flex flex-col mt-4 space-y-2 px-4">
<Link href="/" passHref>
<Button variant="ghost" className="w-full justify-start">
<Home className="mr-4" />
{!isCollapsed && "Home"}
</Button>
</Link>
<Link href="/settings" passHref>
<Button variant="ghost" className="w-full justify-start">
<Settings className="mr-4" />
{!isCollapsed && "Settings"}
</Button>
</Link>
<Link href="/videos" passHref>
<Button variant="ghost" className="w-full justify-start">
<Video className="mr-4" />
{!isCollapsed && "Videos"}
</Button>
</Link>
<Link href="/photos" passHref>
<Button variant="ghost" className="w-full justify-start">
<Image className="mr-4" />
{!isCollapsed && "Photos"}
</Button>
</Link>
<div className="pt-4">
<h2 className={`text-lg font-semibold p-2 ${isCollapsed ? 'text-center' : ''}`}>
{!isCollapsed ? "Folder Viewer" : "Folders"}
</h2>
<div className="flex flex-col space-y-2">
{libraries.map((lib) => (
<Link href={`/folder-viewer?path=${lib.path}`} key={lib.id} passHref>
<Button variant="ghost" className="w-full justify-start">
<Folder className="mr-4" />
{!isCollapsed && <span className="truncate">{lib.path}</span>}
</Button>
</Link>
))}
</div>
</div>
</nav>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

29
src/db/index.ts Normal file
View File

@ -0,0 +1,29 @@
import Database from 'better-sqlite3';
import path from 'path';
const dbPath = path.join(process.cwd(), 'media.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER,
path TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
title TEXT,
size INTEGER,
thumbnail TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (library_id) REFERENCES libraries (id)
);
`);
export default db;

69
src/lib/scanner.ts Normal file
View File

@ -0,0 +1,69 @@
import db from "@/db";
import { glob } from "glob";
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 generateThumbnail = (videoPath: string, thumbnailPath: string) => {
return new Promise((resolve, reject) => {
ffmpeg(videoPath)
.on("end", () => resolve(thumbnailPath))
.on("error", (err) => reject(err))
.screenshots({
count: 1,
folder: path.dirname(thumbnailPath),
filename: path.basename(thumbnailPath),
size: "320x240",
});
});
};
const scanLibrary = async (library: { id: number; path: string }) => {
const mediaFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, {
nodir: true,
});
for (const file of mediaFiles) {
const stats = fs.statSync(file);
const title = path.basename(file);
const thumbnailFileName = `${path.parse(title).name}.png`;
const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName);
const thumbnailUrl = `/thumbnails/${thumbnailFileName}`;
try {
const existingMedia = db.prepare("SELECT * FROM media WHERE path = ?").get(file);
if (existingMedia) {
continue;
}
await generateThumbnail(file, thumbnailPath);
const media = {
library_id: library.id,
path: file,
type: "video",
title: title,
size: stats.size,
thumbnail: thumbnailUrl,
};
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);
} catch (error: any) {
if (error.code !== "SQLITE_CONSTRAINT_UNIQUE") {
console.error(`Error inserting media: ${file}`, error);
}
}
}
};
export const scanAllLibraries = async () => {
const libraries = db.prepare("SELECT * FROM libraries").all() as { id: number; path: string }[];
for (const library of libraries) {
await scanLibrary(library);
}
};

7
src/lib/utils.ts Normal file
View File

@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

81
tailwind.config.ts Normal file
View File

@ -0,0 +1,81 @@
import type { Config } from "tailwindcss"
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config
export default config