add library management

This commit is contained in:
2026-03-25 16:40:01 -04:00
parent ff3cfe7ec3
commit 90528c4768
9 changed files with 552 additions and 53 deletions

View File

@@ -6,27 +6,30 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem.
- **Games library** — displays a grid of game cover art scanned from folders. Each game folder is expected to contain a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link for the zip.
- **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab.
- **Configurable libraries** — libraries are registered in `libraries.json` at the project root; no database required.
- **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`.
- **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served.
## Project Structure
```
MediaLoreWeb/
├── libraries.json # Library configuration
├── libraries.json # Runtime library config (managed via UI, do not edit by hand)
├── data/ # Example media (not committed to production)
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # Home — library cards
│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty)
│ │ ├── manage/page.tsx # Library management — add/remove libraries
│ │ ├── library/[id]/page.tsx # Library view (games or mixed)
│ │ └── api/
│ │ ├── libraries/route.ts # GET /api/libraries
│ │ ├── libraries/route.ts # GET /api/libraries, POST /api/libraries
│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id
│ │ ├── games/route.ts # GET /api/games?libraryId=
│ │ ├── browse/route.ts # GET /api/browse?libraryId=&path=
│ │ └── file/route.ts # GET /api/file?libraryId=&path=
│ ├── components/
│ │ ├── LibraryCard.tsx
│ │ ├── NavLink.tsx
│ │ ├── games/
│ │ │ ├── GamesView.tsx
│ │ │ └── GameDetailModal.tsx
@@ -35,7 +38,7 @@ MediaLoreWeb/
│ │ ├── VideoPlayerModal.tsx
│ │ └── ImageLightbox.tsx
│ ├── lib/
│ │ ├── libraries.ts # Config parsing and path resolution
│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers
│ │ ├── games.ts # Games library scanner
│ │ └── files.ts # Mixed library directory scanner
│ └── types/
@@ -66,33 +69,21 @@ npm run lint # Run ESLint
## Library Configuration
Libraries are defined in `libraries.json` at the project root:
Libraries are managed through the **Manage Libraries** screen at `/manage` in the app. No manual file editing is required.
```json
[
{
"id": "games",
"name": "Games",
"path": "./data/Games",
"type": "games"
},
{
"id": "various",
"name": "Various Media",
"path": "./data/Various Media",
"type": "mixed"
}
]
```
When you add a library via the UI, you provide:
| Field | Description |
|--------|-------------|
| `id` | URL-safe unique identifier used in routes (`/library/<id>`) |
| `name` | Display name shown in the UI |
| `path` | Absolute or project-relative path to the library root folder |
| `type` | `"games"` or `"mixed"` |
| Name | Display name shown in the UI |
| Path | Absolute or project-relative path to the library root folder on disk |
| Type | `Games` or `Mixed Media` |
Paths can point anywhere on the filesystem — they do not need to be inside the project directory. Restart the dev server after editing `libraries.json`.
The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root and persists across restarts.
If no libraries are configured, navigating to `/` automatically redirects to `/manage`.
Paths can point anywhere on the filesystem — they do not need to be inside the project directory.
## Library Folder Conventions
@@ -124,7 +115,9 @@ All API routes are server-side. File paths are never exposed in client-side stat
| Route | Method | Description |
|-------|--------|-------------|
| `/api/libraries` | GET | Returns the full library list from `libraries.json` |
| `/api/libraries` | GET | Returns the full configured library list |
| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }`. Validates the path exists. |
| `/api/libraries/:id` | DELETE | Removes a library by id |
| `/api/games?libraryId=<id>` | GET | Scans the games library and returns structured game entries |
| `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library |
| `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback |

View File

@@ -7,7 +7,7 @@
},
{
"id": "various",
"name": "Various Media",
"name": "Various",
"path": "./data/Various Media",
"type": "mixed"
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, removeLibrary } from '@/lib/libraries'
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
removeLibrary(id)
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,12 +1,41 @@
import { NextResponse } from 'next/server'
import { getLibraries } from '@/lib/libraries'
import { NextRequest, NextResponse } from 'next/server'
import { getLibraries, addLibrary } from '@/lib/libraries'
import type { LibraryType } from '@/types'
export function GET() {
try {
const libraries = getLibraries()
return NextResponse.json(libraries)
} catch (err) {
console.error('Failed to read libraries.json', err)
console.error('Failed to read libraries', err)
return NextResponse.json({ error: 'Failed to load libraries' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
let body: { name?: string; path?: string; type?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { name, path, type } = body
if (!name || !path || !type) {
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
}
const validTypes: LibraryType[] = ['games', 'mixed']
if (!validTypes.includes(type as LibraryType)) {
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
}
try {
const library = addLibrary(name, path, type as LibraryType)
return NextResponse.json(library, { status: 201 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add library'
return NextResponse.json({ error: message }, { status: 400 })
}
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import NavLink from '@/components/NavLink'
import './globals.css'
export const metadata: Metadata = {
@@ -15,11 +16,14 @@ export default function RootLayout({
<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">
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center justify-between 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>
<nav className="flex items-center gap-1">
<NavLink href="/manage">Manage Libraries</NavLink>
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">

328
src/app/manage/page.tsx Normal file
View File

@@ -0,0 +1,328 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import type { Library, LibraryType } from '@/types'
const TYPE_LABELS: Record<LibraryType, string> = {
games: 'Games',
mixed: 'Mixed Media',
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ManagePage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
const refresh = () => {
fetch('/api/libraries')
.then((r) => r.json())
.then((data: Library[]) => {
setLibraries(data)
setLoading(false)
})
.catch(() => setLoading(false))
}
useEffect(() => {
refresh()
}, [])
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Manage Libraries
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Add or remove media library folders.
</p>
<Section title="Configured Libraries">
{loading ? (
<LoadingRows />
) : libraries.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No libraries configured yet.
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => (
<LibraryRow key={lib.id} library={lib} onRemoved={refresh} />
))}
</div>
)}
</Section>
<Section title="Add a Library">
<AddLibraryForm onAdded={refresh} />
</Section>
</div>
)
}
// ─── Section wrapper ──────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
// ─── Library Row ──────────────────────────────────────────────────────────────
function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => void }) {
const [confirming, setConfirming] = useState(false)
const [removing, setRemoving] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleRemoveClick = () => {
if (!confirming) {
setConfirming(true)
// Auto-cancel confirmation after 4s if user does nothing
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
// Second click — confirmed
if (cancelRef.current) clearTimeout(cancelRef.current)
setRemoving(true)
fetch(`/api/libraries/${encodeURIComponent(library.id)}`, { method: 'DELETE' })
.then(() => onRemoved())
.catch(() => setRemoving(false))
}
const handleCancel = () => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}
return (
<div className="flex items-center gap-4 py-3 first:pt-0 last:pb-0">
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
{library.name}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
{TYPE_LABELS[library.type] ?? library.type}
</span>
</div>
<p
className="text-xs font-mono truncate"
style={{ color: 'var(--text-secondary)' }}
title={library.path}
>
{library.path}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{confirming && (
<button
onClick={handleCancel}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
)}
<button
onClick={handleRemoveClick}
disabled={removing}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
</button>
</div>
</div>
)
}
// ─── Add Library Form ─────────────────────────────────────────────────────────
function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
const [name, setName] = useState('')
const [libPath, setLibPath] = useState('')
const [type, setType] = useState<LibraryType>('games')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const res = await fetch('/api/libraries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, path: libPath, type }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Something went wrong.')
setSubmitting(false)
return
}
// Success — reset form
setName('')
setLibPath('')
setType('games')
setSubmitting(false)
onAdded()
} catch {
setError('Network error. Please try again.')
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Games"
required
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Type">
<select
value={type}
onChange={(e) => setType(e.target.value as LibraryType)}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 cursor-pointer"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
>
<option value="games">Games</option>
<option value="mixed">Mixed Media</option>
</select>
</Field>
</div>
<Field label="Path">
<input
type="text"
value={libPath}
onChange={(e) => setLibPath(e.target.value)}
placeholder="e.g. /mnt/nas/Games or ./data/Games"
required
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
{error && (
<p className="text-sm rounded-lg px-3 py-2" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
{error}
</p>
)}
<div>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!submitting) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{submitting ? 'Adding…' : 'Add Library'}
</button>
</div>
</form>
)
}
// ─── Field wrapper ────────────────────────────────────────────────────────────
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{children}
</div>
)
}
// ─── Loading skeleton ─────────────────────────────────────────────────────────
function LoadingRows() {
return (
<div className="flex flex-col gap-3">
{[70, 50, 85].map((w) => (
<div key={w} className="flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -1,29 +1,39 @@
import { redirect } from 'next/navigation'
import { getLibraries } from '@/lib/libraries'
import LibraryCard from '@/components/LibraryCard'
import Link from 'next/link'
export default function HomePage() {
const libraries = getLibraries()
if (libraries.length === 0) {
redirect('/manage')
}
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 className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Libraries
</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
</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>
)}
<Link
href="/manage"
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}
>
Manage
</Link>
</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>
)
}

View File

@@ -0,0 +1,29 @@
'use client'
import Link from 'next/link'
export default function NavLink({
href,
children,
}: {
href: string
children: React.ReactNode
}) {
return (
<Link
href={href}
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{children}
</Link>
)
}

View File

@@ -1,12 +1,17 @@
import path from 'path'
import fs from 'fs'
import type { Library } from '@/types'
import type { Library, LibraryType } from '@/types'
const CONFIG_PATH = path.resolve(process.cwd(), 'libraries.json')
const CONFIG_TMP = CONFIG_PATH + '.tmp'
export function getLibraries(): Library[] {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
return JSON.parse(raw) as Library[]
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
return JSON.parse(raw) as Library[]
} catch {
return []
}
}
export function getLibrary(id: string): Library | undefined {
@@ -36,3 +41,87 @@ export function resolveAndJail(libraryRoot: string, subpath: string): string {
}
return resolved
}
/**
* Slugifies a name into a URL-safe id.
* e.g. "My Games Library" → "my-games-library"
*/
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/**
* Atomically writes the library list to disk.
*/
function writeLibraries(libraries: Library[]): void {
const json = JSON.stringify(libraries, null, 2) + '\n'
fs.writeFileSync(CONFIG_TMP, json, 'utf-8')
fs.renameSync(CONFIG_TMP, CONFIG_PATH)
}
/**
* Adds a new library. Validates the path exists as a directory.
* Throws a descriptive Error on failure.
*/
export function addLibrary(name: string, libraryPath: string, type: LibraryType): Library {
const trimmedName = name.trim()
const trimmedPath = libraryPath.trim()
if (!trimmedName) throw new Error('Name is required.')
if (!trimmedPath) throw new Error('Path is required.')
// Validate the path exists and is a directory
const resolved = path.isAbsolute(trimmedPath)
? trimmedPath
: path.resolve(process.cwd(), trimmedPath)
let stat: fs.Stats
try {
stat = fs.statSync(resolved)
} catch {
throw new Error(`Path does not exist: ${trimmedPath}`)
}
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${trimmedPath}`)
}
const libraries = getLibraries()
// Generate a unique id
const baseId = slugify(trimmedName) || 'library'
let id = baseId
let suffix = 2
while (libraries.some((lib) => lib.id === id)) {
id = `${baseId}-${suffix++}`
}
// Reject exact duplicate names (same name, case-insensitive)
const duplicate = libraries.find(
(lib) => lib.name.toLowerCase() === trimmedName.toLowerCase()
)
if (duplicate) {
throw new Error(`A library named "${trimmedName}" already exists.`)
}
const newLibrary: Library = { id, name: trimmedName, path: trimmedPath, type }
writeLibraries([...libraries, newLibrary])
return newLibrary
}
/**
* Removes a library by id. Returns true if removed, false if not found.
*/
export function removeLibrary(id: string): boolean {
const libraries = getLibraries()
const index = libraries.findIndex((lib) => lib.id === id)
if (index === -1) return false
const updated = libraries.filter((lib) => lib.id !== id)
writeLibraries(updated)
return true
}