initial version
This commit is contained in:
25
src/app/api/browse/route.ts
Normal file
25
src/app/api/browse/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||
import { scanDirectory } from '@/lib/files'
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const subpath = searchParams.get('path') ?? ''
|
||||
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
}
|
||||
if (library.type !== 'mixed') {
|
||||
return NextResponse.json({ error: 'Library is not a mixed library' }, { status: 400 })
|
||||
}
|
||||
|
||||
const root = resolveLibraryRoot(library)
|
||||
const listing = scanDirectory(root, libraryId, subpath)
|
||||
return NextResponse.json(listing)
|
||||
}
|
||||
118
src/app/api/file/route.ts
Normal file
118
src/app/api/file/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.mov': 'video/quicktime',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.webm': 'video/webm',
|
||||
'.m4v': 'video/x-m4v',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.tiff': 'image/tiff',
|
||||
'.tif': 'image/tiff',
|
||||
'.zip': 'application/zip',
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const subpath = searchParams.get('path')
|
||||
|
||||
if (!libraryId || !subpath) {
|
||||
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const root = resolveLibraryRoot(library)
|
||||
|
||||
let filePath: string
|
||||
try {
|
||||
filePath = resolveAndJail(root, subpath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.statSync(filePath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: 'Not a file' }, { status: 400 })
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(filePath)
|
||||
const fileSize = stat.size
|
||||
const rangeHeader = request.headers.get('range')
|
||||
|
||||
// Handle ZIP as a download
|
||||
const isZip = path.extname(filePath).toLowerCase() === '.zip'
|
||||
const contentDisposition = isZip
|
||||
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
|
||||
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"`
|
||||
|
||||
if (rangeHeader) {
|
||||
// Parse "bytes=start-end"
|
||||
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/)
|
||||
if (!match) {
|
||||
return new NextResponse('Invalid Range', { status: 416 })
|
||||
}
|
||||
|
||||
const start = match[1] ? parseInt(match[1], 10) : 0
|
||||
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1
|
||||
|
||||
if (start > end || end >= fileSize) {
|
||||
return new NextResponse('Range Not Satisfiable', {
|
||||
status: 416,
|
||||
headers: { 'Content-Range': `bytes */${fileSize}` },
|
||||
})
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1
|
||||
const stream = fs.createReadStream(filePath, { start, end })
|
||||
|
||||
return new NextResponse(stream as unknown as ReadableStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Content-Length': String(chunkSize),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Full file response
|
||||
const stream = fs.createReadStream(filePath)
|
||||
return new NextResponse(stream as unknown as ReadableStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(fileSize),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
24
src/app/api/games/route.ts
Normal file
24
src/app/api/games/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||
import { scanGamesLibrary } from '@/lib/games'
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
}
|
||||
if (library.type !== 'games') {
|
||||
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
|
||||
}
|
||||
|
||||
const root = resolveLibraryRoot(library)
|
||||
const games = scanGamesLibrary(root, libraryId)
|
||||
return NextResponse.json(games)
|
||||
}
|
||||
12
src/app/api/libraries/route.ts
Normal file
12
src/app/api/libraries/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getLibraries } from '@/lib/libraries'
|
||||
|
||||
export function GET() {
|
||||
try {
|
||||
const libraries = getLibraries()
|
||||
return NextResponse.json(libraries)
|
||||
} catch (err) {
|
||||
console.error('Failed to read libraries.json', err)
|
||||
return NextResponse.json({ error: 'Failed to load libraries' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
39
src/app/globals.css
Normal file
39
src/app/globals.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0f0f11;
|
||||
--surface: #1a1a1f;
|
||||
--surface-hover: #24242b;
|
||||
--border: #2e2e38;
|
||||
--text-primary: #f0f0f5;
|
||||
--text-secondary: #9090a8;
|
||||
--accent: #7c6aff;
|
||||
--accent-hover: #9583ff;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
31
src/app/layout.tsx
Normal file
31
src/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MediaLore',
|
||||
description: 'Your personal media library',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen">
|
||||
<header className="border-b sticky top-0 z-40" style={{ borderColor: 'var(--border)', backgroundColor: 'var(--background)' }}>
|
||||
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center gap-3">
|
||||
<a href="/" className="flex items-center gap-2 font-semibold text-lg tracking-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
<span style={{ color: 'var(--accent)' }}>◈</span>
|
||||
MediaLore
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
34
src/app/library/[id]/page.tsx
Normal file
34
src/app/library/[id]/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getLibrary } from '@/lib/libraries'
|
||||
import { notFound } from 'next/navigation'
|
||||
import GamesView from '@/components/games/GamesView'
|
||||
import MixedView from '@/components/mixed/MixedView'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ path?: string }>
|
||||
}
|
||||
|
||||
export default async function LibraryPage({ params, searchParams }: Props) {
|
||||
const { id } = await params
|
||||
const { path: subpath } = await searchParams
|
||||
|
||||
const library = getLibrary(id)
|
||||
if (!library) notFound()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
||||
Libraries
|
||||
</a>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{library.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{library.type === 'games' && <GamesView libraryId={id} />}
|
||||
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/app/page.tsx
Normal file
29
src/app/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getLibraries } from '@/lib/libraries'
|
||||
import LibraryCard from '@/components/LibraryCard'
|
||||
|
||||
export default function HomePage() {
|
||||
const libraries = getLibraries()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Libraries
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
|
||||
</p>
|
||||
{libraries.length === 0 ? (
|
||||
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
<p className="text-lg mb-2">No libraries configured</p>
|
||||
<p className="text-sm">Add entries to <code className="font-mono text-xs px-1 py-0.5 rounded" style={{ background: 'var(--surface)' }}>libraries.json</code> to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{libraries.map((lib) => (
|
||||
<LibraryCard key={lib.id} library={lib} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user