diff --git a/package-lock.json b/package-lock.json index 1a77f10..40a69d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@types/better-sqlite3": "^7.6.13", "better-sqlite3": "^12.8.0", + "fast-xml-parser": "^5.5.10", "next": "^15.5.14", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -18,6 +18,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", @@ -1637,6 +1638,7 @@ "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1667,6 +1669,7 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -3792,6 +3795,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.10", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz", + "integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.1", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5668,6 +5706,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", + "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6524,6 +6577,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6892,6 +6957,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 4d9133b..a4b911c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "license": "ISC", "dependencies": { "better-sqlite3": "^12.8.0", + "fast-xml-parser": "^5.5.10", "next": "^15.5.14", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/src/app/api/game-cover/route.ts b/src/app/api/game-cover/route.ts new file mode 100644 index 0000000..b2fcaf3 --- /dev/null +++ b/src/app/api/game-cover/route.ts @@ -0,0 +1,99 @@ +import path from 'path' +import fs from 'fs' +import sharp from 'sharp' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' + +const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB + +type CoverType = 'cover' | 'widecover' + +function isCoverType(s: string | null): s is CoverType { + return s === 'cover' || s === 'widecover' +} + +export async function POST(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const itemId = searchParams.get('itemId') + const coverType = searchParams.get('coverType') + + if (!libraryId || !itemId) { + return NextResponse.json({ error: 'Missing libraryId or itemId' }, { status: 400 }) + } + if (!isCoverType(coverType)) { + return NextResponse.json({ error: 'coverType must be "cover" or "widecover"' }, { 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 libraryRoot = resolveLibraryRoot(library) + const folderPath = decodeURIComponent(itemId) + + let resolvedDir: string + try { + resolvedDir = resolveAndJail(libraryRoot, folderPath) + } catch { + return NextResponse.json({ error: 'Invalid item path' }, { status: 400 }) + } + + if (!fs.existsSync(resolvedDir)) { + return NextResponse.json({ error: 'Game folder 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()) + + let processedBuffer: Buffer + try { + processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer() + } catch { + return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 }) + } + + const destFilename = `${coverType}.jpg` + const destPath = path.join(resolvedDir, destFilename) + + // Remove any existing file with the same base name but a different extension + const basePattern = new RegExp(`^${coverType}\\.`, 'i') + try { + for (const f of fs.readdirSync(resolvedDir)) { + if (basePattern.test(f) && f.toLowerCase() !== destFilename) { + fs.unlinkSync(path.join(resolvedDir, f)) + } + } + } catch { /* ignore */ } + + fs.writeFileSync(destPath, processedBuffer) + + const relPath = path.join(folderPath, destFilename) + + // cover uses the thumbnail endpoint; widecover is served directly + const url = + coverType === 'cover' + ? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}` + : `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}` + + return NextResponse.json({ url }, { status: 200 }) +} diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index 8fd1c61..52fcde3 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -26,7 +26,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 }) } - const validTypes: LibraryType[] = ['games', 'mixed'] + const validTypes: LibraryType[] = ['games', 'mixed', 'movies', 'tv'] if (!validTypes.includes(type as LibraryType)) { return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 }) } diff --git a/src/app/api/movies/route.ts b/src/app/api/movies/route.ts new file mode 100644 index 0000000..b4bdf68 --- /dev/null +++ b/src/app/api/movies/route.ts @@ -0,0 +1,65 @@ +import fs from 'fs' +import path from 'path' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { scanMoviesLibrary } from '@/lib/movies' +import { removeAllAssignmentsForItem } from '@/lib/tags' + +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 !== 'movies') { + return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 }) + } + + const root = resolveLibraryRoot(library) + const movies = scanMoviesLibrary(root, libraryId) + return NextResponse.json(movies) +} + +export function DELETE(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const movieId = searchParams.get('movieId') + + if (!libraryId || !movieId) { + return NextResponse.json({ error: 'Missing libraryId or movieId' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + if (library.type !== 'movies') { + return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 }) + } + + const root = resolveLibraryRoot(library) + const dirName = decodeURIComponent(movieId) + + let movieDir: string + try { + movieDir = resolveAndJail(root, dirName) + } catch { + return NextResponse.json({ error: 'Invalid movie path' }, { status: 400 }) + } + + try { + fs.rmSync(movieDir, { recursive: true, force: true }) + } catch { + return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 }) + } + + removeAllAssignmentsForItem(`${libraryId}:${movieId}`) + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/tv/route.ts b/src/app/api/tv/route.ts new file mode 100644 index 0000000..6d29ed5 --- /dev/null +++ b/src/app/api/tv/route.ts @@ -0,0 +1,78 @@ +import fs from 'fs' +import path from 'path' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv' +import { removeAllAssignmentsForItem } from '@/lib/tags' + +export function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const seriesId = searchParams.get('seriesId') + const seasonId = searchParams.get('seasonId') + + 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 !== 'tv') { + return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 }) + } + + const root = resolveLibraryRoot(library) + + if (seriesId && seasonId) { + const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId) + return NextResponse.json(episodes) + } + + if (seriesId) { + const seasons = scanTvSeasons(root, libraryId, seriesId) + return NextResponse.json(seasons) + } + + const series = scanTvLibrary(root, libraryId) + return NextResponse.json(series) +} + +export function DELETE(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const seriesId = searchParams.get('seriesId') + + if (!libraryId || !seriesId) { + return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + if (library.type !== 'tv') { + return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 }) + } + + const root = resolveLibraryRoot(library) + const dirName = decodeURIComponent(seriesId) + + let seriesDir: string + try { + seriesDir = resolveAndJail(root, dirName) + } catch { + return NextResponse.json({ error: 'Invalid series path' }, { status: 400 }) + } + + try { + fs.rmSync(seriesDir, { recursive: true, force: true }) + } catch { + return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 }) + } + + removeAllAssignmentsForItem(`${libraryId}:${seriesId}`) + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/library/[id]/page.tsx b/src/app/library/[id]/page.tsx index 8b67fce..15d0e9b 100644 --- a/src/app/library/[id]/page.tsx +++ b/src/app/library/[id]/page.tsx @@ -2,6 +2,8 @@ import { getLibrary } from '@/lib/libraries' import { notFound } from 'next/navigation' import GamesView from '@/components/games/GamesView' import MixedView from '@/components/mixed/MixedView' +import MoviesView from '@/components/movies/MoviesView' +import TvView from '@/components/tv/TvView' interface Props { params: Promise<{ id: string }> @@ -29,6 +31,8 @@ export default async function LibraryPage({ params, searchParams }: Props) { {library.type === 'games' && } {library.type === 'mixed' && } + {library.type === 'movies' && } + {library.type === 'tv' && } ) } diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx index afd7595..70bf022 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -7,11 +7,15 @@ import type { Library, LibraryType } from '@/types' const TYPE_ICONS: Record = { games: '๐ŸŽฎ', mixed: '๐Ÿ—‚๏ธ', + movies: '๐ŸŽฌ', + tv: '๐Ÿ“บ', } const TYPE_LABELS: Record = { games: 'Games', mixed: 'Mixed Media', + movies: 'Movies', + tv: 'TV Shows', } // โ”€โ”€โ”€ Main Page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -329,6 +333,8 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) { > + + diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx index 4777af5..30709ef 100644 --- a/src/components/games/GameDetailModal.tsx +++ b/src/components/games/GameDetailModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import type { Game } from '@/types' import TagSelector from '@/components/tags/TagSelector' @@ -9,14 +9,22 @@ interface Props { libraryId: string onClose: () => void onTagsChanged?: () => void + onCoverUploaded?: () => void } -export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged }: Props) { +export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: Props) { const overlayRef = useRef(null) + const menuRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + const [editingImages, setEditingImages] = useState(false) useEffect(() => { const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + if (menuOpen) { setMenuOpen(false); return } + if (editingImages) { setEditingImages(false); return } + onClose() + } } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' @@ -24,13 +32,27 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose]) + }, [onClose, menuOpen, editingImages]) + + // Close menu on outside click + useEffect(() => { + if (!menuOpen) return + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [menuOpen]) const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === overlayRef.current) onClose() } - const downloadHref = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(game.zipPath)}` + const zipDownloadUrl = (zipPath: string) => + `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}` + const heroImage = game.wideCoverUrl ?? game.coverUrl return (
- {/* Close button */} - + {editingImages ? ( + setEditingImages(false)} + onUploaded={onCoverUploaded} + /> + ) : ( + <> + {/* Close button */} + - {/* Wide cover / cover hero */} -
- {game.wideCoverUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${game.title} - ) : game.coverUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${game.title} - ) : ( -
๐ŸŽฎ
- )} -
+ {/* Hero image */} +
+ {heroImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${game.title} + ) : ( +
๐ŸŽฎ
+ )} +
- {/* Info */} -
-

- {game.title} -

- ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')} - onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} - > - โ†“ - Download .zip - + {/* Info */} +
+ {/* Title row with kebab menu */} +
+

+ {game.title} +

- {/* Tags */} -
-

- Tags -

- -
-
+ {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
+
+ + + + {/* Tags */} +
+

+ Tags +

+ +
+
+ + )}
) } + +// โ”€โ”€โ”€ Download Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function DownloadButton({ + zipFiles, + downloadUrl, +}: { + zipFiles: string[] + downloadUrl: (zipPath: string) => string +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + const close = useCallback(() => setOpen(false), []) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) close() + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open, close]) + + const primary = zipFiles[0] + const primaryName = primary.split('/').pop() ?? primary + + if (zipFiles.length === 1) { + return ( + ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')} + onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} + > + โ†“ + Download .zip + + ) + } + + return ( +
+
+ {/* Primary download */} + ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')} + onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')} + > + โ†“ + {primaryName} + + + {/* Divider */} +
+ + {/* Dropdown toggle */} + +
+ + {open && ( + + )} +
+ ) +} + +// โ”€โ”€โ”€ Image Editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ImageEditorProps { + game: Game + libraryId: string + onBack: () => void + onUploaded?: () => void +} + +function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) { + return ( +
+ {/* Header */} +
+ +

+ Edit Images +

+
+ +
+ + +
+
+ ) +} + +// โ”€โ”€โ”€ Image Slot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ImageSlotProps { + label: string + description: string + currentUrl: string | null + fallback: string + aspectClass: string + libraryId: string + itemId: string + coverType: 'cover' | 'widecover' + onUploaded?: () => void +} + +function ImageSlot({ + label, description, currentUrl, fallback, aspectClass, + libraryId, itemId, coverType, onUploaded, +}: ImageSlotProps) { + const inputRef = useRef(null) + const [preview, setPreview] = useState(null) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + + const displayUrl = preview ?? currentUrl + + const handleChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + // Show local preview immediately + const objectUrl = URL.createObjectURL(file) + setPreview(objectUrl) + setError(null) + setUploading(true) + + const form = new FormData() + form.append('cover', file) + + try { + const res = await fetch( + `/api/game-cover?libraryId=${encodeURIComponent(libraryId)}&itemId=${encodeURIComponent(itemId)}&coverType=${coverType}`, + { method: 'POST', body: form } + ) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError(data.error ?? 'Upload failed.') + setPreview(null) + } else { + onUploaded?.() + } + } catch { + setError('Network error.') + setPreview(null) + } finally { + setUploading(false) + e.target.value = '' + } + } + + return ( +
+
+

{label}

+

{description}

+
+ +
+ {/* Preview */} +
+ {displayUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {label} + ) : ( +
{fallback}
+ )} + {uploading && ( +
+ Savingโ€ฆ +
+ )} +
+ + {/* Controls */} +
+ + {error && ( +

{error}

+ )} +
+
+ + +
+ ) +} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index 9a27e8e..0b2a266 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' -import type { Game } from '@/types' +import { useEffect, useState, useCallback, useRef } from 'react' +import type { Game, GameSeries } from '@/types' import GameDetailModal from './GameDetailModal' import FilterPanel from '@/components/FilterPanel' @@ -10,10 +10,13 @@ interface Props { } export default function GamesView({ libraryId }: Props) { - const [games, setGames] = useState([]) + const [items, setItems] = useState<(Game | GameSeries)[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [selectedSeries, setSelectedSeries] = useState(null) const [selected, setSelected] = useState(null) + const selectedRef = useRef(selected) + selectedRef.current = selected const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) @@ -26,12 +29,26 @@ export default function GamesView({ libraryId }: Props) { return next }) - useEffect(() => { + const fetchGames = useCallback((syncSelected = false) => { fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) - .then((data) => { - setGames(data) + .then((data: (Game | GameSeries)[]) => { + setItems(data) setLoading(false) + if (syncSelected && selectedRef.current) { + const id = selectedRef.current.id + // Search top-level games and inside series + let updated: Game | undefined + for (const item of data) { + if ('games' in item) { + updated = item.games.find((g) => g.id === id) + } else if (item.id === id) { + updated = item + } + if (updated) break + } + if (updated) setSelected(updated) + } }) .catch(() => { setError('Failed to load games') @@ -39,6 +56,8 @@ export default function GamesView({ libraryId }: Props) { }) }, [libraryId]) + useEffect(() => { fetchGames() }, [fetchGames]) + const fetchAssignments = useCallback(() => { fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) @@ -48,10 +67,17 @@ export default function GamesView({ libraryId }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) - const filtered = games.filter((game) => { - if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false + // Items shown in the current view level + const visibleItems: (Game | GameSeries)[] = selectedSeries + ? selectedSeries.games + : items + + const filtered = visibleItems.filter((item) => { + if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0) { - const gameTags = assignments[`${libraryId}:${game.id}`] ?? [] + // Tag filtering only applies to games (series don't have tags directly) + if ('games' in item) return true + const gameTags = assignments[`${libraryId}:${item.id}`] ?? [] if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false } return true @@ -71,57 +97,51 @@ export default function GamesView({ libraryId }: Props) { />
+ {/* Breadcrumb when inside a series */} + {selectedSeries && ( +
+ + / + + {selectedSeries.title} + +
+ )} + {loading ? ( ) : error ? ( - - ) : games.length === 0 ? ( - +
+ {error} +
+ ) : items.length === 0 ? ( +
+

No games found

+

Each game should be a folder containing a .zip file.

+
) : (
- {filtered.map((game) => ( - - ))} + {filtered.map((item) => + 'games' in item ? ( + { setSelectedSeries(item); setSearch('') }} + /> + ) : ( + setSelected(item)} + /> + ) + )}
)} @@ -131,6 +151,7 @@ export default function GamesView({ libraryId }: Props) { libraryId={libraryId} onClose={() => setSelected(null)} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} + onCoverUploaded={() => fetchGames(true)} /> )}
@@ -138,6 +159,80 @@ export default function GamesView({ libraryId }: Props) { ) } +function GameCard({ game, onClick }: { game: Game; onClick: () => void }) { + return ( + + ) +} + +function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) { + return ( + + ) +} + function LoadingGrid() { return (
@@ -152,20 +247,3 @@ function LoadingGrid() {
) } - -function ErrorMessage({ message }: { message: string }) { - return ( -
- {message} -
- ) -} - -function EmptyState() { - return ( -
-

No games found

-

Each game should be a folder containing a .zip file.

-
- ) -} diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx new file mode 100644 index 0000000..e111ff9 --- /dev/null +++ b/src/components/movies/MovieDetailModal.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { Movie } from '@/types' +import TagSelector from '@/components/tags/TagSelector' +import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' + +interface Props { + movie: Movie + libraryId: string + onClose: () => void + onTagsChanged?: () => void + onDeleted: (movieId: string) => void +} + +export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) { + const overlayRef = useRef(null) + const menuRef = useRef(null) + const [playing, setPlaying] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (menuOpen) { setMenuOpen(false); return } + if (confirming) { setConfirming(false); return } + onClose() + } + } + document.addEventListener('keydown', handleKey) + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', handleKey) + document.body.style.overflow = '' + } + }, [onClose, menuOpen, confirming]) + + // Close menu on outside click + useEffect(() => { + if (!menuOpen) return + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [menuOpen]) + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose() + } + + const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}` + + const handleConfirmDelete = () => { + setDeleting(true) + fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, { + method: 'DELETE', + }) + .then(() => onDeleted(movie.id)) + .catch(() => setDeleting(false)) + } + + if (playing) { + return setPlaying(false)} /> + } + + const heroUrl = movie.backdropUrl ?? movie.posterUrl + + return ( +
+
+ {/* Close button */} + + + {/* Hero image */} +
+ {heroUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {movie.title} + ) : ( +
๐ŸŽฌ
+ )} +
+ + {/* Info */} +
+ {/* Title row with kebab menu */} +
+

+ {movie.title} +

+ {movie.year && ( + + {movie.year} + + )} + {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
+
+ + {/* Meta row */} + {(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && ( +
+ {movie.rating !== null && ( + + โ˜… {movie.rating.toFixed(1)} + + )} + {movie.runtime !== null && ( + + {movie.runtime} min + + )} + {movie.genres.map((g) => ( + + {g} + + ))} +
+ )} + + {movie.plot && ( +

+ {movie.plot} +

+ )} + + {/* Confirmation banner */} + {confirming && ( +
+

+ Permanently delete this movie and all its files? +

+ + +
+ )} + + {/* Play button */} + + + {/* Tags */} +
+

+ Tags +

+ +
+
+
+
+ ) +} diff --git a/src/components/movies/MoviesView.tsx b/src/components/movies/MoviesView.tsx new file mode 100644 index 0000000..a19778b --- /dev/null +++ b/src/components/movies/MoviesView.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import type { Movie } from '@/types' +import MovieDetailModal from './MovieDetailModal' +import FilterPanel from '@/components/FilterPanel' + +interface Props { + libraryId: string +} + +export default function MoviesView({ libraryId }: Props) { + const [movies, setMovies] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selected, setSelected] = useState(null) + const [search, setSearch] = useState('') + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [assignments, setAssignments] = useState>({}) + const [filterRefreshKey, setFilterRefreshKey] = useState(0) + + const toggleTag = (tagId: string) => + setSelectedTagIds((prev) => { + const next = new Set(prev) + next.has(tagId) ? next.delete(tagId) : next.add(tagId) + return next + }) + + const fetchMovies = useCallback(() => { + fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}`) + .then((r) => r.json()) + .then((data) => { + setMovies(data) + setLoading(false) + }) + .catch(() => { + setError('Failed to load movies') + setLoading(false) + }) + }, [libraryId]) + + useEffect(() => { fetchMovies() }, [fetchMovies]) + + const fetchAssignments = useCallback(() => { + fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) + .then((r) => r.json()) + .then(setAssignments) + .catch(() => {}) + }, [libraryId]) + + useEffect(() => { fetchAssignments() }, [fetchAssignments]) + + const filtered = movies.filter((movie) => { + if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false + if (selectedTagIds.size > 0) { + const movieTags = assignments[`${libraryId}:${movie.id}`] ?? [] + if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false + } + return true + }) + + const handleDeleted = (movieId: string) => { + setSelected(null) + setMovies((prev) => prev.filter((m) => m.id !== movieId)) + } + + return ( +
+
+ +
+
+ {loading ? ( + + ) : error ? ( +
+ {error} +
+ ) : movies.length === 0 ? ( +
+

No movies found

+

Each movie should be a folder containing a video file.

+
+ ) : ( +
+ {filtered.map((movie) => ( + + ))} +
+ )} + + {selected && ( + setSelected(null)} + onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} + onDeleted={handleDeleted} + /> + )} +
+
+ ) +} + +function LoadingGrid() { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/src/components/tv/EpisodeCard.tsx b/src/components/tv/EpisodeCard.tsx new file mode 100644 index 0000000..c59e371 --- /dev/null +++ b/src/components/tv/EpisodeCard.tsx @@ -0,0 +1,67 @@ +'use client' + +import type { TvEpisode } from '@/types' + +interface Props { + episode: TvEpisode + onClick: () => void +} + +export default function EpisodeCard({ episode, onClick }: Props) { + const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null + + return ( + + ) +} diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx new file mode 100644 index 0000000..58b5c04 --- /dev/null +++ b/src/components/tv/TvView.tsx @@ -0,0 +1,458 @@ +'use client' + +import { useEffect, useRef, useState, useCallback } from 'react' +import type { TvSeries, TvSeason, TvEpisode } from '@/types' +import FilterPanel from '@/components/FilterPanel' +import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' +import EpisodeCard from './EpisodeCard' + +interface Props { + libraryId: string +} + +type ViewLevel = 'series' | 'seasons' | 'episodes' + +export default function TvView({ libraryId }: Props) { + const [view, setView] = useState('series') + const [series, setSeries] = useState([]) + const [seasons, setSeasons] = useState([]) + const [episodes, setEpisodes] = useState([]) + const [selectedSeries, setSelectedSeries] = useState(null) + const [selectedSeason, setSelectedSeason] = useState(null) + const [playingEpisode, setPlayingEpisode] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [search, setSearch] = useState('') + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [assignments, setAssignments] = useState>({}) + const [filterRefreshKey, setFilterRefreshKey] = useState(0) + const [menuOpen, setMenuOpen] = useState(false) + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const menuRef = useRef(null) + + const toggleTag = (tagId: string) => + setSelectedTagIds((prev) => { + const next = new Set(prev) + next.has(tagId) ? next.delete(tagId) : next.add(tagId) + return next + }) + + const fetchSeries = useCallback(() => { + setLoading(true) + setError(null) + fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`) + .then((r) => r.json()) + .then((data) => { setSeries(data); setLoading(false) }) + .catch(() => { setError('Failed to load TV library'); setLoading(false) }) + }, [libraryId]) + + useEffect(() => { fetchSeries() }, [fetchSeries]) + + const fetchAssignments = useCallback(() => { + fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) + .then((r) => r.json()) + .then(setAssignments) + .catch(() => {}) + }, [libraryId]) + + useEffect(() => { fetchAssignments() }, [fetchAssignments]) + + const openSeries = (s: TvSeries) => { + setSelectedSeries(s) + setView('seasons') + setLoading(true) + setError(null) + fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`) + .then((r) => r.json()) + .then((data) => { setSeasons(data); setLoading(false) }) + .catch(() => { setError('Failed to load seasons'); setLoading(false) }) + } + + const openSeason = (season: TvSeason) => { + setSelectedSeason(season) + setView('episodes') + setLoading(true) + setError(null) + fetch( + `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(season.seriesId)}&seasonId=${encodeURIComponent(season.id)}` + ) + .then((r) => r.json()) + .then((data) => { setEpisodes(data); setLoading(false) }) + .catch(() => { setError('Failed to load episodes'); setLoading(false) }) + } + + // Close menu on outside click + useEffect(() => { + if (!menuOpen) return + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [menuOpen]) + + const goToSeries = () => { + setView('series') + setSelectedSeries(null) + setSelectedSeason(null) + setMenuOpen(false) + setConfirming(false) + } + + const goToSeasons = () => { + setView('seasons') + setSelectedSeason(null) + setConfirming(false) + } + + const handleDeleteSeries = () => { + if (!selectedSeries) return + setDeleting(true) + fetch( + `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`, + { method: 'DELETE' } + ) + .then(() => { + setSeries((prev) => prev.filter((s) => s.id !== selectedSeries.id)) + goToSeries() + setDeleting(false) + }) + .catch(() => setDeleting(false)) + } + + const filteredSeries = series.filter((s) => { + if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false + if (selectedTagIds.size > 0) { + const tags = assignments[`${libraryId}:${s.id}`] ?? [] + if (![...selectedTagIds].every((id) => tags.includes(id))) return false + } + return true + }) + + if (playingEpisode) { + const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(playingEpisode.videoPath)}` + return ( + setPlayingEpisode(null)} + /> + ) + } + + return ( +
+ {/* Breadcrumb */} +
+ {view !== 'series' ? ( + + ) : ( + All Series + )} + {selectedSeries && ( + <> + / + {view === 'episodes' ? ( + + ) : ( + + {selectedSeries.title} + + )} + + )} + {selectedSeason && ( + <> + / + + {selectedSeason.title} + + + )} +
+ + {view === 'series' && ( +
+
+ +
+
+ {loading ? ( + + ) : error ? ( + + ) : series.length === 0 ? ( +
+

No TV shows found

+

Each series should be a folder containing season subdirectories with video files.

+
+ ) : ( +
+ {filteredSeries.map((s) => ( + + ))} +
+ )} +
+
+ )} + + {view === 'seasons' && selectedSeries && ( +
+ {/* Series info header */} +
+
+ {selectedSeries.posterUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {selectedSeries.title} + )} +
+
+

{selectedSeries.title}

+ {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
+
+ {(selectedSeries.year || selectedSeries.genres.length > 0) && ( +
+ {selectedSeries.year && {selectedSeries.year}} + {selectedSeries.genres.map((g) => ( + {g} + ))} +
+ )} + {selectedSeries.plot && ( +

{selectedSeries.plot}

+ )} +
+
+ {/* Confirmation banner */} + {confirming && ( +
+

+ Permanently delete this series and all its files? +

+ + +
+ )} +
+ + {loading ? ( + + ) : error ? ( + + ) : seasons.length === 0 ? ( +
+ No seasons found. +
+ ) : ( +
+ {seasons.map((season) => ( + + ))} +
+ )} +
+ )} + + {view === 'episodes' && selectedSeason && ( +
+ {loading ? ( + + ) : error ? ( + + ) : episodes.length === 0 ? ( +
+ No episodes found. +
+ ) : ( +
+ {episodes.map((ep) => ( + setPlayingEpisode(ep)} + /> + ))} +
+ )} +
+ )} +
+ ) +} + +function ErrorMsg({ message }: { message: string }) { + return ( +
+ {message} +
+ ) +} + +function SeriesLoadingGrid() { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) +} + +function SeasonLoadingGrid() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) +} + +function EpisodeLoadingGrid() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 4f188e9..1ae1da4 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -41,8 +41,33 @@ function initDb(db: Database.Database): void { id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, - type TEXT NOT NULL CHECK(type IN ('games', 'mixed')), + type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')), cover_ext TEXT NULL ); `) + + migrateLibrariesType(db) +} + +function migrateLibrariesType(db: Database.Database): void { + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'") + .get() as { sql: string } | undefined + + if (row && !row.sql.includes("'movies'")) { + db.exec(` + BEGIN TRANSACTION; + CREATE TABLE libraries_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')), + cover_ext TEXT NULL + ); + INSERT INTO libraries_new SELECT * FROM libraries; + DROP TABLE libraries; + ALTER TABLE libraries_new RENAME TO libraries; + COMMIT; + `) + } } diff --git a/src/lib/games.ts b/src/lib/games.ts index 3a80b08..accd450 100644 --- a/src/lib/games.ts +++ b/src/lib/games.ts @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import type { Game } from '@/types' +import type { Game, GameSeries } from '@/types' const HIDDEN_FILES = /^\./ @@ -25,10 +25,56 @@ function fileApiUrl(libraryId: string, relativePath: string): string { return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` } -export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] { - let gameDirs: string[] +function thumbnailApiUrl(libraryId: string, relativePath: string): string { + return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +/** + * Attempts to build a Game from a directory. + * @param absPath Absolute path to the game directory. + * @param dirName The directory's own name (used as title). + * @param relPath Path relative to the library root (used for IDs and file URLs). + * @param libraryId Library identifier. + * @returns Game, or null if the directory contains no .zip file. + */ +function buildGame( + absPath: string, + dirName: string, + relPath: string, + libraryId: string +): Game | null { + let allFiles: string[] try { - gameDirs = fs + allFiles = fs.readdirSync(absPath) + } catch { + return null + } + + const zipFiles = allFiles + .filter((f) => f.toLowerCase().endsWith('.zip')) + .sort((a, b) => a.localeCompare(b)) + if (zipFiles.length === 0) return null + + const coverFile = findFile(absPath, /^cover$/i) + const wideCoverFile = findFile(absPath, /^widecover$/i) + + return { + id: encodeURIComponent(relPath), + title: dirName, + coverUrl: coverFile + ? thumbnailApiUrl(libraryId, path.join(relPath, coverFile)) + : null, + wideCoverUrl: wideCoverFile + ? fileApiUrl(libraryId, path.join(relPath, wideCoverFile)) + : null, + zipFiles: zipFiles.map((f) => path.join(relPath, f)), + } +} + +export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] { + let topDirs: string[] + try { + topDirs = fs .readdirSync(libraryRoot, { withFileTypes: true }) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) .map((d) => d.name) @@ -36,42 +82,66 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] return [] } - const games: Game[] = [] + const results: (Game | GameSeries)[] = [] - for (const dirName of gameDirs) { - const gamePath = path.join(libraryRoot, dirName) + for (const dirName of topDirs) { + const absPath = path.join(libraryRoot, dirName) - // Find the .zip file (first match) - let zipFile: string | null = null + let allFiles: string[] try { - const allFiles = fs.readdirSync(gamePath) - zipFile = allFiles.find((f) => f.toLowerCase().endsWith('.zip')) ?? null + allFiles = fs.readdirSync(absPath) } catch { - // skip unreadable dirs continue } - if (!zipFile) continue + // Standalone game: directory directly contains a .zip + const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip')) + if (hasZip) { + const game = buildGame(absPath, dirName, dirName, libraryId) + if (game) results.push(game) + continue + } - // Case-insensitive cover matching - const coverFile = findFile(gamePath, /^cover$/i) - const wideCoverFile = findFile(gamePath, /^widecover$/i) + // No .zip here โ€” check subdirectories (series detection) + let subDirs: string[] + try { + subDirs = fs + .readdirSync(absPath, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) + .map((d) => d.name) + } catch { + continue + } - const id = encodeURIComponent(dirName) - const zipRelPath = path.join(dirName, zipFile) + const seriesGames: Game[] = [] + for (const subDir of subDirs) { + const game = buildGame( + path.join(absPath, subDir), + subDir, + path.join(dirName, subDir), + libraryId + ) + if (game) seriesGames.push(game) + } - games.push({ - id, + if (seriesGames.length === 0) continue + + // It's a series โ€” check for an optional series-level cover + const seriesCoverFile = findFile(absPath, /^cover$/i) + const seriesWideCoverFile = findFile(absPath, /^widecover$/i) + + results.push({ + id: encodeURIComponent(dirName), title: dirName, - coverUrl: coverFile - ? fileApiUrl(libraryId, path.join(dirName, coverFile)) + coverUrl: seriesCoverFile + ? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile)) + : seriesGames[0].coverUrl, + wideCoverUrl: seriesWideCoverFile + ? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile)) : null, - wideCoverUrl: wideCoverFile - ? fileApiUrl(libraryId, path.join(dirName, wideCoverFile)) - : null, - zipPath: zipRelPath, + games: seriesGames.sort((a, b) => a.title.localeCompare(b.title)), }) } - return games.sort((a, b) => a.title.localeCompare(b.title)) + return results.sort((a, b) => a.title.localeCompare(b.title)) } diff --git a/src/lib/movies.ts b/src/lib/movies.ts new file mode 100644 index 0000000..72dfd8d --- /dev/null +++ b/src/lib/movies.ts @@ -0,0 +1,110 @@ +import fs from 'fs' +import path from 'path' +import type { Movie } from '@/types' +import { parseMovieNfo } from './nfo' + +const HIDDEN_FILES = /^\./ + +const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) + +function fileApiUrl(libraryId: string, relativePath: string): string { + return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +function thumbnailApiUrl(libraryId: string, relativePath: string): string { + return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +/** + * Finds the first file in a directory whose basename (without extension) + * matches the given pattern (case-insensitive). + */ +function findFile(dir: string, pattern: RegExp): string | null { + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + return null + } + return entries.find( + (e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e))) + ) ?? null +} + +function findVideoFile(dir: string): string | null { + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + return null + } + return entries.find( + (e) => !HIDDEN_FILES.test(e) && VIDEO_EXTENSIONS.has(path.extname(e).toLowerCase()) + ) ?? null +} + +function findNfoFile(dir: string, dirName: string): string | null { + // Try {dirName}.nfo first, then movie.nfo, then any .nfo + const candidates = [`${dirName}.nfo`, 'movie.nfo'] + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + return null + } + for (const candidate of candidates) { + if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) { + return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())! + } + } + return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null +} + +export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie[] { + let dirs: string[] + try { + dirs = fs + .readdirSync(libraryRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) + .map((d) => d.name) + } catch { + return [] + } + + const movies: Movie[] = [] + + for (const dirName of dirs) { + const moviePath = path.join(libraryRoot, dirName) + + const videoFile = findVideoFile(moviePath) + if (!videoFile) continue + + const nfoFile = findNfoFile(moviePath, dirName) + const nfo = nfoFile ? parseMovieNfo(path.join(moviePath, nfoFile)) : null + + const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i) + const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i) + + const id = encodeURIComponent(dirName) + const videoRelPath = path.join(dirName, videoFile) + + movies.push({ + id, + title: nfo?.title ?? dirName, + year: nfo?.year ?? null, + plot: nfo?.plot ?? null, + rating: nfo?.rating ?? null, + genres: nfo?.genres ?? [], + runtime: nfo?.runtime ?? null, + posterUrl: posterFile + ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) + : null, + backdropUrl: backdropFile + ? fileApiUrl(libraryId, path.join(dirName, backdropFile)) + : null, + videoPath: videoRelPath, + }) + } + + return movies.sort((a, b) => a.title.localeCompare(b.title)) +} diff --git a/src/lib/nfo.ts b/src/lib/nfo.ts new file mode 100644 index 0000000..82b8cd7 --- /dev/null +++ b/src/lib/nfo.ts @@ -0,0 +1,108 @@ +import fs from 'fs' +import { XMLParser } from 'fast-xml-parser' + +const parser = new XMLParser({ isArray: (name) => name === 'genre' }) + +function parseFile(filePath: string): Record | null { + let xml: string + try { + xml = fs.readFileSync(filePath, 'utf-8') + } catch { + return null + } + try { + return parser.parse(xml) as Record + } catch { + return null + } +} + +function toNumber(val: unknown): number | null { + if (val === undefined || val === null || val === '') return null + const n = Number(val) + return isNaN(n) ? null : n +} + +function toString(val: unknown): string | null { + if (val === undefined || val === null || val === '') return null + return String(val) +} + +function toStringArray(val: unknown): string[] { + if (!val) return [] + if (Array.isArray(val)) return val.map(String).filter(Boolean) + return [String(val)] +} + +export interface MovieNfoData { + title: string | null + year: number | null + plot: string | null + rating: number | null + runtime: number | null + genres: string[] +} + +export interface TvShowNfoData { + title: string | null + year: number | null + plot: string | null + genres: string[] + status: string | null +} + +export interface EpisodeNfoData { + title: string | null + season: number | null + episode: number | null + plot: string | null + aired: string | null + rating: number | null +} + +export function parseMovieNfo(filePath: string): MovieNfoData | null { + const doc = parseFile(filePath) + if (!doc) return null + const m = doc.movie as Record | undefined + if (!m) return null + + return { + title: toString(m.title), + year: toNumber(m.year), + plot: toString(m.plot), + rating: toNumber(m.rating), + runtime: toNumber(m.runtime), + genres: toStringArray(m.genre), + } +} + +export function parseTvShowNfo(filePath: string): TvShowNfoData | null { + const doc = parseFile(filePath) + if (!doc) return null + const s = doc.tvshow as Record | undefined + if (!s) return null + + return { + title: toString(s.title), + year: toNumber(s.year), + plot: toString(s.plot), + genres: toStringArray(s.genre), + status: toString(s.status), + } +} + +export function parseEpisodeNfo(filePath: string): EpisodeNfoData | null { + const doc = parseFile(filePath) + if (!doc) return null + const e = doc.episodedetails as Record | undefined + if (!e) return null + + return { + title: toString(e.title), + season: toNumber(e.season), + episode: toNumber(e.episode), + plot: toString(e.plot), + aired: toString(e.aired), + rating: toNumber(e.rating), + } +} diff --git a/src/lib/tags.ts b/src/lib/tags.ts index 0d66d45..8b4247a 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -219,3 +219,8 @@ export function removeAllAssignmentsForLibrary(libraryId: string): void { const db = getDb() db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`) } + +export function removeAllAssignmentsForItem(mediaKey: string): void { + const db = getDb() + db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey) +} diff --git a/src/lib/tv.ts b/src/lib/tv.ts new file mode 100644 index 0000000..a4ef949 --- /dev/null +++ b/src/lib/tv.ts @@ -0,0 +1,206 @@ +import fs from 'fs' +import path from 'path' +import type { TvSeries, TvSeason, TvEpisode } from '@/types' +import { parseTvShowNfo, parseEpisodeNfo } from './nfo' + +const HIDDEN_FILES = /^\./ + +const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts']) + +function fileApiUrl(libraryId: string, relativePath: string): string { + return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +function thumbnailApiUrl(libraryId: string, relativePath: string): string { + return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +function isVideoFile(name: string): boolean { + return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase()) +} + +/** + * Finds the first file in a directory whose basename (without extension) + * matches the given pattern (case-insensitive). + */ +function findFile(dir: string, pattern: RegExp): string | null { + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + return null + } + return entries.find( + (e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e))) + ) ?? null +} + +function readDirs(dir: string): string[] { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) + .map((d) => d.name) + } catch { + return [] + } +} + +function readFiles(dir: string): string[] { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isFile() && !HIDDEN_FILES.test(d.name)) + .map((d) => d.name) + } catch { + return [] + } +} + +function parseSeasonNumber(dirName: string): number | null { + // Matches: "Season 01", "Season 1", "S01", "S1", bare "1", "01" + const m = + dirName.match(/^season\s*(\d+)$/i) ?? + dirName.match(/^s(\d+)$/i) ?? + dirName.match(/^(\d+)$/) + return m ? parseInt(m[1], 10) : null +} + +function countVideosInDir(dir: string): number { + return readFiles(dir).filter(isVideoFile).length +} + +export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[] { + const seriesDirs = readDirs(libraryRoot) + const series: TvSeries[] = [] + + for (const dirName of seriesDirs) { + const seriesPath = path.join(libraryRoot, dirName) + const nfoFile = path.join(seriesPath, 'tvshow.nfo') + const nfo = parseTvShowNfo(nfoFile) + + const posterFile = findFile(seriesPath, /^(poster|folder)$/i) + const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i) + + const seasonDirs = readDirs(seriesPath) + const seasonCount = seasonDirs.filter((sd) => { + const sdPath = path.join(seriesPath, sd) + return countVideosInDir(sdPath) > 0 + }).length + + const id = encodeURIComponent(dirName) + + series.push({ + id, + title: nfo?.title ?? dirName, + year: nfo?.year ?? null, + plot: nfo?.plot ?? null, + genres: nfo?.genres ?? [], + status: nfo?.status ?? null, + posterUrl: posterFile + ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) + : null, + backdropUrl: backdropFile + ? fileApiUrl(libraryId, path.join(dirName, backdropFile)) + : null, + seasonCount, + }) + } + + return series.sort((a, b) => a.title.localeCompare(b.title)) +} + +export function scanTvSeasons( + libraryRoot: string, + libraryId: string, + seriesId: string +): TvSeason[] { + const seriesDirName = decodeURIComponent(seriesId) + const seriesPath = path.join(libraryRoot, seriesDirName) + + const seasonDirs = readDirs(seriesPath) + const seasons: TvSeason[] = [] + + for (const dirName of seasonDirs) { + const seasonPath = path.join(seriesPath, dirName) + const episodeCount = countVideosInDir(seasonPath) + if (episodeCount === 0) continue + + const seasonNumber = parseSeasonNumber(dirName) + + // Look for season poster: "season01-poster.*" or "poster.*" in season dir + const seasonPosterPattern = seasonNumber !== null + ? new RegExp(`^season${String(seasonNumber).padStart(2, '0')}-poster$`, 'i') + : /^poster$/i + const posterFile = + findFile(seasonPath, seasonPosterPattern) ?? findFile(seasonPath, /^poster$/i) + + const id = encodeURIComponent(dirName) + const title = seasonNumber !== null ? `Season ${seasonNumber}` : dirName + + seasons.push({ + id, + seriesId, + title, + seasonNumber, + posterUrl: posterFile + ? thumbnailApiUrl(libraryId, path.join(seriesDirName, dirName, posterFile)) + : null, + episodeCount, + }) + } + + return seasons.sort((a, b) => { + if (a.seasonNumber !== null && b.seasonNumber !== null) { + return a.seasonNumber - b.seasonNumber + } + return a.title.localeCompare(b.title) + }) +} + +export function scanTvEpisodes( + libraryRoot: string, + libraryId: string, + seriesId: string, + seasonId: string +): TvEpisode[] { + const seriesDirName = decodeURIComponent(seriesId) + const seasonDirName = decodeURIComponent(seasonId) + const seasonPath = path.join(libraryRoot, seriesDirName, seasonDirName) + + const files = readFiles(seasonPath) + const videoFiles = files.filter(isVideoFile) + const episodes: TvEpisode[] = [] + + for (const videoFile of videoFiles) { + const baseName = path.basename(videoFile, path.extname(videoFile)) + const nfoFileName = files.find( + (f) => path.basename(f, path.extname(f)) === baseName && path.extname(f).toLowerCase() === '.nfo' + ) + const nfo = nfoFileName + ? parseEpisodeNfo(path.join(seasonPath, nfoFileName)) + : null + + const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile) + const id = encodeURIComponent(videoFile) + + episodes.push({ + id, + title: nfo?.title ?? baseName, + episodeNumber: nfo?.episode ?? null, + seasonNumber: nfo?.season ?? null, + plot: nfo?.plot ?? null, + aired: nfo?.aired ?? null, + rating: nfo?.rating ?? null, + thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath), + videoPath: videoRelPath, + }) + } + + return episodes.sort((a, b) => { + if (a.episodeNumber !== null && b.episodeNumber !== null) { + return a.episodeNumber - b.episodeNumber + } + return (a.title ?? '').localeCompare(b.title ?? '') + }) +} diff --git a/src/types/index.ts b/src/types/index.ts index f04a7cd..4f2d51f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -export type LibraryType = 'games' | 'mixed' +export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv' export interface Library { id: string @@ -13,7 +13,15 @@ export interface Game { title: string coverUrl: string | null wideCoverUrl: string | null - zipPath: string + zipFiles: string[] +} + +export interface GameSeries { + id: string + title: string + coverUrl: string | null + wideCoverUrl: string | null + games: Game[] } export type MediaType = 'video' | 'image' | 'other' @@ -26,6 +34,52 @@ export interface FileEntry { thumbnailUrl: string | null } +export interface Movie { + id: string + title: string + year: number | null + plot: string | null + rating: number | null + genres: string[] + runtime: number | null + posterUrl: string | null + backdropUrl: string | null + videoPath: string +} + +export interface TvSeries { + id: string + title: string + year: number | null + plot: string | null + genres: string[] + status: string | null + posterUrl: string | null + backdropUrl: string | null + seasonCount: number +} + +export interface TvSeason { + id: string + seriesId: string + title: string + seasonNumber: number | null + posterUrl: string | null + episodeCount: number +} + +export interface TvEpisode { + id: string + title: string + episodeNumber: number | null + seasonNumber: number | null + plot: string | null + aired: string | null + rating: number | null + thumbnailUrl: string | null + videoPath: string +} + export interface DirectoryListing { path: string entries: FileEntry[]