From de8ba04bd3625c366acde9deb315c2d44cac4cab Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:01:34 -0400 Subject: [PATCH] bring up to date with github --- Dockerfile | 7 +- config/libraries.json | 14 ---- libraries.json | 8 -- package.json | 2 +- src/app/api/library-cover/[id]/route.ts | 103 ++++++++++++++++++++++++ src/app/layout.tsx | 2 +- src/app/manage/page.tsx | 88 +++++++++++++++++++- src/app/page.tsx | 7 -- src/components/LibraryCard.tsx | 40 ++++++--- src/lib/db.ts | 48 +++++++++++ src/lib/files.ts | 10 ++- src/lib/libraries.ts | 76 ++++++++--------- src/lib/tags.ts | 39 +-------- src/types/index.ts | 1 + 14 files changed, 316 insertions(+), 129 deletions(-) delete mode 100644 config/libraries.json delete mode 100644 libraries.json create mode 100644 src/app/api/library-cover/[id]/route.ts create mode 100644 src/lib/db.ts diff --git a/Dockerfile b/Dockerfile index 4db3c8c..16c4f79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,16 +31,13 @@ ENV NODE_ENV=production ENV PORT=3000 # Bind to all interfaces so the container port is reachable ENV HOSTNAME=0.0.0.0 -# Store libraries.json in /config so it can be bind-mounted as a directory. -# Mounting a directory (not a single file) ensures the atomic rename in -# writeLibraries() works — both .tmp and the target are on the same filesystem. -ENV LIBRARIES_CONFIG_PATH=/config/libraries.json + RUN mkdir -p /config # Copy standalone Next.js output COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/public ./public +# COPY --from=builder /app/public ./public # Copy native modules — Next.js standalone's file tracer does not follow # .node binary files, so we copy these manually from the deps stage. diff --git a/config/libraries.json b/config/libraries.json deleted file mode 100644 index 6570921..0000000 --- a/config/libraries.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "id": "games", - "name": "Games", - "path": "/data/Games", - "type": "games" - }, - { - "id": "various", - "name": "various", - "path": "/data/Various Media", - "type": "mixed" - } -] diff --git a/libraries.json b/libraries.json deleted file mode 100644 index 44edecf..0000000 --- a/libraries.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "id": "games", - "name": "Games", - "path": "./data/Games", - "type": "games" - } -] diff --git a/package.json b/package.json index b25657a..4d9133b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "author": "", "license": "ISC", "dependencies": { - "@types/better-sqlite3": "^7.6.13", "better-sqlite3": "^12.8.0", "next": "^15.5.14", "react": "^19.2.4", @@ -21,6 +20,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/app/api/library-cover/[id]/route.ts b/src/app/api/library-cover/[id]/route.ts new file mode 100644 index 0000000..d87553c --- /dev/null +++ b/src/app/api/library-cover/[id]/route.ts @@ -0,0 +1,103 @@ +import path from 'path' +import fs from 'fs' +import sharp from 'sharp' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, updateLibraryCover, clearLibraryCover } from '@/lib/libraries' + +const COVERS_DIR = path.resolve(process.cwd(), '.covers') +const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB + +function coverPath(id: string, ext: string) { + return path.join(COVERS_DIR, `${id}.${ext}`) +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const library = getLibrary(id) + if (!library?.coverExt) { + return new NextResponse(null, { status: 404 }) + } + + const filePath = coverPath(id, library.coverExt) + try { + const data = fs.readFileSync(filePath) + return new NextResponse(data, { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=86400', + }, + }) + } catch { + return new NextResponse(null, { status: 404 }) + } +} + +export async function POST( + 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 }) + } + + let formData: FormData + try { + formData = await request.formData() + } catch { + return NextResponse.json({ error: 'Invalid form data' }, { status: 400 }) + } + + const file = formData.get('cover') + if (!(file instanceof File)) { + return NextResponse.json({ error: 'cover file is required' }, { status: 400 }) + } + + if (file.size > MAX_COVER_BYTES) { + return NextResponse.json({ error: 'File too large. Maximum size is 10 MB.' }, { status: 400 }) + } + + const rawBuffer = Buffer.from(await file.arrayBuffer()) + + // Re-encode through sharp — validates it's a real image and strips metadata + let processedBuffer: Buffer + try { + processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer() + } catch { + return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 }) + } + + fs.mkdirSync(COVERS_DIR, { recursive: true }) + + // Remove any existing cover (may have a different extension from older uploads) + if (library.coverExt) { + try { fs.unlinkSync(coverPath(id, library.coverExt)) } catch { /* ignore */ } + } + + fs.writeFileSync(coverPath(id, 'jpg'), processedBuffer) + updateLibraryCover(id, 'jpg') + + return NextResponse.json({ coverExt: 'jpg' }, { status: 200 }) +} + +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 }) + } + + if (library.coverExt) { + try { fs.unlinkSync(coverPath(id, library.coverExt)) } catch { /* ignore */ } + clearLibraryCover(id) + } + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 04092fa..e8f207a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,7 +22,7 @@ export default function RootLayout({ MediaLore diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx index 7b922a0..afd7595 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -1,8 +1,14 @@ 'use client' import { useEffect, useState, useRef } from 'react' +import Image from 'next/image' import type { Library, LibraryType } from '@/types' +const TYPE_ICONS: Record = { + games: '🎮', + mixed: '🗂️', +} + const TYPE_LABELS: Record = { games: 'Games', mixed: 'Mixed Media', @@ -47,7 +53,7 @@ export default function ManagePage() { ) : (
{libraries.map((lib) => ( - + ))}
)} @@ -83,19 +89,27 @@ function Section({ title, children }: { title: string; children: React.ReactNode // ─── Library Row ────────────────────────────────────────────────────────────── -function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => void }) { +function LibraryRow({ + library, + onRemoved, + onUpdated, +}: { + library: Library + onRemoved: () => void + onUpdated: () => void +}) { const [confirming, setConfirming] = useState(false) const [removing, setRemoving] = useState(false) + const [uploadingCover, setUploadingCover] = useState(false) const cancelRef = useRef | null>(null) + const fileInputRef = useRef(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' }) @@ -108,8 +122,63 @@ function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => setConfirming(false) } + const handleCoverChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setUploadingCover(true) + const form = new FormData() + form.append('cover', file) + await fetch(`/api/library-cover/${encodeURIComponent(library.id)}`, { + method: 'POST', + body: form, + }) + setUploadingCover(false) + // Reset input so the same file can be re-selected + e.target.value = '' + onUpdated() + } + + const handleRemoveCover = async () => { + await fetch(`/api/library-cover/${encodeURIComponent(library.id)}`, { method: 'DELETE' }) + onUpdated() + } + return (
+ {/* Cover thumbnail */} + + {/* Info */}
@@ -134,6 +203,17 @@ function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => {/* Actions */}
+ {library.coverExt && ( + + )} {confirming && (
- - Manage -
{libraries.map((lib) => ( diff --git a/src/components/LibraryCard.tsx b/src/components/LibraryCard.tsx index 6cc4bc4..0ff4e4d 100644 --- a/src/components/LibraryCard.tsx +++ b/src/components/LibraryCard.tsx @@ -1,6 +1,7 @@ 'use client' import Link from 'next/link' +import Image from 'next/image' import type { Library } from '@/types' const TYPE_LABELS: Record = { @@ -17,7 +18,7 @@ export default function LibraryCard({ library }: { library: Library }) { return ( -
{TYPE_ICONS[library.type] ?? '📁'}
-
- {library.name} -
-
- {TYPE_LABELS[library.type] ?? library.type} + {library.coverExt ? ( +
+ {library.name} +
+ ) : ( +
+ {TYPE_ICONS[library.type] ?? '📁'} +
+ )} +
+
+ {library.name} +
+
+ {TYPE_LABELS[library.type] ?? library.type} +
) diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..4f188e9 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,48 @@ +import path from 'path' +import fs from 'fs' +import Database from 'better-sqlite3' + +const CONFIG_PATH = process.env.CONFIG_PATH ?? process.cwd() +const DB_PATH = path.resolve(CONFIG_PATH, 'medialore.db') + +let _db: Database.Database | null = null + +export function getDb(): Database.Database { + if (_db) return _db + _db = new Database(DB_PATH) + _db.pragma('journal_mode = WAL') + _db.pragma('foreign_keys = ON') + initDb(_db) + return _db +} + +function initDb(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS tag_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ); + + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category_id TEXT NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id); + + CREATE TABLE IF NOT EXISTS media_tags ( + media_key TEXT NOT NULL, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (media_key, tag_id) + ); + + CREATE TABLE IF NOT EXISTS libraries ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('games', 'mixed')), + cover_ext TEXT NULL + ); + `) +} diff --git a/src/lib/files.ts b/src/lib/files.ts index 86477c6..01c6059 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -1,6 +1,7 @@ import fs from 'fs' import path from 'path' import type { DirectoryListing, FileEntry, MediaType } from '@/types' +import { resolveAndJail } from '@/lib/libraries' const HIDDEN_FILES = /^\./ @@ -27,9 +28,12 @@ export function scanDirectory( libraryId: string, subpath: string ): DirectoryListing { - const absPath = subpath - ? path.resolve(libraryRoot, subpath) - : libraryRoot + let absPath: string + try { + absPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot + } catch { + return { path: subpath, entries: [] } + } let dirents: fs.Dirent[] try { diff --git a/src/lib/libraries.ts b/src/lib/libraries.ts index b3376f1..b6b5487 100644 --- a/src/lib/libraries.ts +++ b/src/lib/libraries.ts @@ -1,26 +1,25 @@ import path from 'path' import fs from 'fs' import type { Library, LibraryType } from '@/types' - -const CONFIG_PATH = process.env.LIBRARIES_CONFIG_PATH ?? path.resolve(process.cwd(), 'libraries.json') -const CONFIG_TMP = CONFIG_PATH + '.tmp' +import { getDb } from '@/lib/db' export function getLibraries(): Library[] { - try { - const raw = fs.readFileSync(CONFIG_PATH, 'utf-8') - return JSON.parse(raw) as Library[] - } catch { - return [] - } + const db = getDb() + return db + .prepare('SELECT id, name, path, type, cover_ext as coverExt FROM libraries ORDER BY name') + .all() as Library[] } export function getLibrary(id: string): Library | undefined { - return getLibraries().find((lib) => lib.id === id) + const db = getDb() + return db + .prepare('SELECT id, name, path, type, cover_ext as coverExt FROM libraries WHERE id = ?') + .get(id) as Library | undefined } /** * Resolves a library's configured path to an absolute filesystem path. - * Paths in libraries.json may be relative (to project root) or absolute. + * Paths may be relative (to project root) or absolute. */ export function resolveLibraryRoot(library: Library): string { if (path.isAbsolute(library.path)) { @@ -56,15 +55,6 @@ function slugify(name: string): string { .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. @@ -91,37 +81,47 @@ export function addLibrary(name: string, libraryPath: string, type: LibraryType) throw new Error(`Path is not a directory: ${trimmedPath}`) } - const libraries = getLibraries() + const db = getDb() + + // Reject exact duplicate names (case-insensitive) + const duplicate = db.prepare('SELECT 1 FROM libraries WHERE LOWER(name) = LOWER(?)').get(trimmedName) + if (duplicate) { + throw new Error(`A library named "${trimmedName}" already exists.`) + } // Generate a unique id const baseId = slugify(trimmedName) || 'library' let id = baseId let suffix = 2 - while (libraries.some((lib) => lib.id === id)) { + while (db.prepare('SELECT 1 FROM libraries WHERE id = ?').get(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.`) - } + db.prepare('INSERT INTO libraries (id, name, path, type) VALUES (?, ?, ?, ?)').run(id, trimmedName, trimmedPath, type) + return { id, name: trimmedName, path: trimmedPath, type, coverExt: null } +} - const newLibrary: Library = { id, name: trimmedName, path: trimmedPath, type } - writeLibraries([...libraries, newLibrary]) - return newLibrary +/** + * Sets the cover image extension for a library (e.g. "jpg", "png"). + */ +export function updateLibraryCover(id: string, ext: string): void { + const db = getDb() + db.prepare('UPDATE libraries SET cover_ext = ? WHERE id = ?').run(ext, id) +} + +/** + * Clears the custom cover image for a library. + */ +export function clearLibraryCover(id: string): void { + const db = getDb() + db.prepare('UPDATE libraries SET cover_ext = NULL WHERE id = ?').run(id) } /** * 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 + const db = getDb() + const result = db.prepare('DELETE FROM libraries WHERE id = ?').run(id) + return result.changes > 0 } diff --git a/src/lib/tags.ts b/src/lib/tags.ts index a794d03..0d66d45 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -1,42 +1,5 @@ -import path from 'path' -import Database from 'better-sqlite3' import type { Tag, TagCategory } from '@/types' - -const DB_PATH = path.resolve(process.cwd(), 'medialore.db') - -let _db: Database.Database | null = null - -function getDb(): Database.Database { - if (_db) return _db - _db = new Database(DB_PATH) - _db.pragma('journal_mode = WAL') - _db.pragma('foreign_keys = ON') - initDb(_db) - return _db -} - -function initDb(db: Database.Database): void { - db.exec(` - CREATE TABLE IF NOT EXISTS tag_categories ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE - ); - - CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - category_id TEXT NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id); - - CREATE TABLE IF NOT EXISTS media_tags ( - media_key TEXT NOT NULL, - tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (media_key, tag_id) - ); - `) -} +import { getDb } from '@/lib/db' function slugify(name: string): string { return name diff --git a/src/types/index.ts b/src/types/index.ts index 749df2c..f04a7cd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,7 @@ export interface Library { name: string path: string type: LibraryType + coverExt: string | null } export interface Game {