Add Movie and TV Library types. #5

Merged
gpatti merged 3 commits from library-types into main 2026-04-05 16:50:48 +00:00
21 changed files with 2391 additions and 164 deletions

68
package-lock.json generated
View File

@@ -9,8 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
"next": "^15.5.14", "next": "^15.5.14",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -1637,6 +1638,7 @@
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -1667,6 +1669,7 @@
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -3792,6 +3795,41 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -5668,6 +5706,21 @@
"node": ">=8" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6524,6 +6577,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6892,6 +6957,7 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {

View File

@@ -13,6 +13,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
"next": "^15.5.14", "next": "^15.5.14",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",

View File

@@ -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 })
}

View File

@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 }) 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)) { if (!validTypes.includes(type as LibraryType)) {
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 }) return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
} }

View File

@@ -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 })
}

78
src/app/api/tv/route.ts Normal file
View File

@@ -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 })
}

View File

@@ -2,6 +2,8 @@ import { getLibrary } from '@/lib/libraries'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import GamesView from '@/components/games/GamesView' import GamesView from '@/components/games/GamesView'
import MixedView from '@/components/mixed/MixedView' import MixedView from '@/components/mixed/MixedView'
import MoviesView from '@/components/movies/MoviesView'
import TvView from '@/components/tv/TvView'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -29,6 +31,8 @@ export default async function LibraryPage({ params, searchParams }: Props) {
{library.type === 'games' && <GamesView libraryId={id} />} {library.type === 'games' && <GamesView libraryId={id} />}
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />} {library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
{library.type === 'movies' && <MoviesView libraryId={id} />}
{library.type === 'tv' && <TvView libraryId={id} />}
</div> </div>
) )
} }

View File

@@ -7,11 +7,15 @@ import type { Library, LibraryType } from '@/types'
const TYPE_ICONS: Record<string, string> = { const TYPE_ICONS: Record<string, string> = {
games: '🎮', games: '🎮',
mixed: '🗂️', mixed: '🗂️',
movies: '🎬',
tv: '📺',
} }
const TYPE_LABELS: Record<LibraryType, string> = { const TYPE_LABELS: Record<LibraryType, string> = {
games: 'Games', games: 'Games',
mixed: 'Mixed Media', mixed: 'Mixed Media',
movies: 'Movies',
tv: 'TV Shows',
} }
// ─── Main Page ──────────────────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────────────────
@@ -329,6 +333,8 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
> >
<option value="games">Games</option> <option value="games">Games</option>
<option value="mixed">Mixed Media</option> <option value="mixed">Mixed Media</option>
<option value="movies">Movies</option>
<option value="tv">TV Shows</option>
</select> </select>
</Field> </Field>
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
@@ -9,14 +9,22 @@ interface Props {
libraryId: string libraryId: string
onClose: () => void onClose: () => void
onTagsChanged?: () => 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<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [editingImages, setEditingImages] = useState(false)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { 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.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -24,13 +32,27 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' 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) => { const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose() 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 ( return (
<div <div
@@ -43,65 +65,367 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl" className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> >
{/* Close button */} {editingImages ? (
<button <ImageEditor
onClick={onClose} game={game}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" libraryId={libraryId}
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }} onBack={() => setEditingImages(false)}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')} onUploaded={onCoverUploaded}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')} />
aria-label="Close" ) : (
> <>
{/* Close button */}
</button> <button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close"
>
</button>
{/* Wide cover / cover hero */} {/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}> <div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{game.wideCoverUrl ? ( {heroImage ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
src={game.wideCoverUrl} ) : (
alt={`${game.title} wide cover`} <div className="h-40 flex items-center justify-center text-5xl">🎮</div>
className="w-full object-cover max-h-64" )}
/> </div>
) : game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.coverUrl}
alt={`${game.title} cover`}
className="w-full object-contain max-h-64"
/>
) : (
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
)}
</div>
{/* Info */} {/* Info */}
<div className="p-5"> <div className="p-5">
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}> {/* Title row with kebab menu */}
{game.title} <div className="flex items-center gap-2 mb-4">
</h2> <h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
<a {game.title}
href={downloadHref} </h2>
download
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Download .zip
</a>
{/* Tags */} {/* Kebab menu */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <div className="relative flex-shrink-0" ref={menuRef}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}> <button
Tags onClick={() => setMenuOpen((o) => !o)}
</p> className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} /> style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
</div> onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
</div> onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit images
</button>
</div>
)}
</div>
</div>
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
</div>
</div>
</>
)}
</div> </div>
</div> </div>
) )
} }
// ─── Download Button ──────────────────────────────────────────────────────────
function DownloadButton({
zipFiles,
downloadUrl,
}: {
zipFiles: string[]
downloadUrl: (zipPath: string) => string
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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 (
<a
href={downloadUrl(primary)}
download
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Download .zip
</a>
)
}
return (
<div className="relative" ref={ref}>
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
{/* Primary download */}
<a
href={downloadUrl(primary)}
download
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors"
style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
<span></span>
{primaryName}
</a>
{/* Divider */}
<div style={{ width: '1px', backgroundColor: 'rgba(255,255,255,0.25)' }} />
{/* Dropdown toggle */}
<button
onClick={() => setOpen((o) => !o)}
className="px-3 flex items-center justify-center text-sm transition-colors"
style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="Show all downloads"
>
</button>
</div>
{open && (
<div
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{zipFiles.map((zipPath) => {
const name = zipPath.split('/').pop() ?? zipPath
return (
<a
key={zipPath}
href={downloadUrl(zipPath)}
download
onClick={close}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
<span style={{ color: 'var(--text-secondary)' }}></span>
{name}
</a>
)
})}
</div>
)}
</div>
)
}
// ─── Image Editor ─────────────────────────────────────────────────────────────
interface ImageEditorProps {
game: Game
libraryId: string
onBack: () => void
onUploaded?: () => void
}
function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) {
return (
<div className="p-5">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={onBack}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors flex-shrink-0"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="Back"
>
</button>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
Edit Images
</h2>
</div>
<div className="flex flex-col gap-6">
<ImageSlot
label="Cover"
description="Portrait artwork (3:4)"
currentUrl={game.coverUrl}
fallback="🎮"
aspectClass="aspect-[3/4]"
libraryId={libraryId}
itemId={game.id}
coverType="cover"
onUploaded={onUploaded}
/>
<ImageSlot
label="Wide Cover"
description="Landscape hero image"
currentUrl={game.wideCoverUrl}
fallback="🎮"
aspectClass="aspect-video"
libraryId={libraryId}
itemId={game.id}
coverType="widecover"
onUploaded={onUploaded}
/>
</div>
</div>
)
}
// ─── 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<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const displayUrl = preview ?? currentUrl
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div>
<div className="flex items-baseline justify-between mb-2">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{description}</p>
</div>
<div className="flex items-start gap-4">
{/* Preview */}
<div
className={`${aspectClass} rounded-lg overflow-hidden flex-shrink-0 relative`}
style={{
width: coverType === 'cover' ? '80px' : '160px',
backgroundColor: 'var(--border)',
border: '1px solid var(--border)',
}}
>
{displayUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={displayUrl} alt={label} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">{fallback}</div>
)}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Saving</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 text-left"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
>
{currentUrl || preview ? 'Replace image' : 'Upload image'}
</button>
{error && (
<p className="text-xs" style={{ color: '#fca5a5' }}>{error}</p>
)}
</div>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleChange}
/>
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import type { Game } from '@/types' import type { Game, GameSeries } from '@/types'
import GameDetailModal from './GameDetailModal' import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
@@ -10,10 +10,13 @@ interface Props {
} }
export default function GamesView({ libraryId }: Props) { export default function GamesView({ libraryId }: Props) {
const [games, setGames] = useState<Game[]>([]) const [items, setItems] = useState<(Game | GameSeries)[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedSeries, setSelectedSeries] = useState<GameSeries | null>(null)
const [selected, setSelected] = useState<Game | null>(null) const [selected, setSelected] = useState<Game | null>(null)
const selectedRef = useRef(selected)
selectedRef.current = selected
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
@@ -26,12 +29,26 @@ export default function GamesView({ libraryId }: Props) {
return next return next
}) })
useEffect(() => { const fetchGames = useCallback((syncSelected = false) => {
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data: (Game | GameSeries)[]) => {
setGames(data) setItems(data)
setLoading(false) 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(() => { .catch(() => {
setError('Failed to load games') setError('Failed to load games')
@@ -39,6 +56,8 @@ export default function GamesView({ libraryId }: Props) {
}) })
}, [libraryId]) }, [libraryId])
useEffect(() => { fetchGames() }, [fetchGames])
const fetchAssignments = useCallback(() => { const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -48,10 +67,17 @@ export default function GamesView({ libraryId }: Props) {
useEffect(() => { fetchAssignments() }, [fetchAssignments]) useEffect(() => { fetchAssignments() }, [fetchAssignments])
const filtered = games.filter((game) => { // Items shown in the current view level
if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false 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) { 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 if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
} }
return true return true
@@ -71,57 +97,51 @@ export default function GamesView({ libraryId }: Props) {
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Breadcrumb when inside a series */}
{selectedSeries && (
<div className="flex items-center gap-2 mb-4 text-sm">
<button
onClick={() => { setSelectedSeries(null); setSearch('') }}
className="transition-colors"
style={{ color: 'var(--accent)' }}
>
All Games
</button>
<span style={{ color: 'var(--text-secondary)' }}>/</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedSeries.title}
</span>
</div>
)}
{loading ? ( {loading ? (
<LoadingGrid /> <LoadingGrid />
) : error ? ( ) : error ? (
<ErrorMessage message={error} /> <div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
) : games.length === 0 ? ( {error}
<EmptyState /> </div>
) : items.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
<p className="text-lg mb-1">No games found</p>
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
</div>
) : ( ) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filtered.map((game) => ( {filtered.map((item) =>
<button 'games' in item ? (
key={game.id} <SeriesCard
onClick={() => setSelected(game)} key={item.id}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" series={item}
style={{ onClick={() => { setSelectedSeries(item); setSearch('') }}
borderColor: 'var(--border)', />
backgroundColor: 'var(--surface)', ) : (
}} <GameCard
onMouseEnter={(e) => { key={item.id}
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' game={item}
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)' onClick={() => setSelected(item)}
}} />
onMouseLeave={(e) => { )
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)' )}
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.coverUrl}
alt={game.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">
🎮
</div>
)}
</div>
<div className="p-2">
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={game.title}
>
{game.title}
</p>
</div>
</button>
))}
</div> </div>
)} )}
@@ -131,6 +151,7 @@ export default function GamesView({ libraryId }: Props) {
libraryId={libraryId} libraryId={libraryId}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onCoverUploaded={() => fetchGames(true)}
/> />
)} )}
</div> </div>
@@ -138,6 +159,80 @@ export default function GamesView({ libraryId }: Props) {
) )
} }
function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
return (
<button
onClick={onClick}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={game.coverUrl} alt={game.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
</div>
<div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
{game.title}
</p>
</div>
</button>
)
}
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
return (
<button
onClick={onClick}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{series.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
{/* Game count badge */}
<div
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
>
{series.games.length}
</div>
</div>
<div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={series.title}>
{series.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
Series
</p>
</div>
</button>
)
}
function LoadingGrid() { function LoadingGrid() {
return ( return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
@@ -152,20 +247,3 @@ function LoadingGrid() {
</div> </div>
) )
} }
function ErrorMessage({ message }: { message: string }) {
return (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{message}
</div>
)
}
function EmptyState() {
return (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
<p className="text-lg mb-1">No games found</p>
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
</div>
)
}

View File

@@ -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<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(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 <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} />
}
const heroUrl = movie.backdropUrl ?? movie.posterUrl
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={handleOverlayClick}
>
<div
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close"
>
</button>
{/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{heroUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={heroUrl}
alt={movie.title}
className="w-full object-cover max-h-64"
/>
) : (
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
)}
</div>
{/* Info */}
<div className="p-5">
{/* Title row with kebab menu */}
<div className="flex items-start gap-2 mb-1">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{movie.title}
</h2>
{movie.year && (
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{movie.year}
</span>
)}
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete movie
</button>
</div>
)}
</div>
</div>
{/* Meta row */}
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{movie.rating !== null && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{movie.rating.toFixed(1)}
</span>
)}
{movie.runtime !== null && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{movie.runtime} min
</span>
)}
{movie.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{g}
</span>
))}
</div>
)}
{movie.plot && (
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
{movie.plot}
</p>
)}
{/* Confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this movie and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 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={handleConfirmDelete}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Play button */}
<button
onClick={() => setPlaying(true)}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Play
</button>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<Movie[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Movie | null>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
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 (
<div className="flex gap-6 items-start">
<div className="w-52 flex-shrink-0">
<FilterPanel
libraryId={libraryId}
assignments={assignments}
search={search}
onSearchChange={setSearch}
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
/>
</div>
<div className="flex-1 min-w-0">
{loading ? (
<LoadingGrid />
) : error ? (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{error}
</div>
) : movies.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
<p className="text-lg mb-1">No movies found</p>
<p className="text-sm">Each movie should be a folder containing a video file.</p>
</div>
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filtered.map((movie) => (
<button
key={movie.id}
onClick={() => setSelected(movie)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{movie.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={movie.posterUrl}
alt={movie.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">
🎬
</div>
)}
</div>
<div className="p-2">
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={movie.title}
>
{movie.title}
</p>
{movie.year && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{movie.year}
</p>
)}
</div>
</button>
))}
</div>
)}
{selected && (
<MovieDetailModal
movie={selected}
libraryId={libraryId}
onClose={() => setSelected(null)}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onDeleted={handleDeleted}
/>
)}
</div>
</div>
)
}
function LoadingGrid() {
return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
<div className="p-2">
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
</div>
</div>
))}
</div>
)
}

View File

@@ -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 (
<button
onClick={onClick}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-video w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{episode.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={episode.thumbnailUrl}
alt={episode.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-3xl"></div>
)}
{/* Play overlay on hover */}
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
>
<span className="text-3xl text-white"></span>
</div>
</div>
<div className="p-2">
{epLabel && (
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>
{epLabel}
</p>
)}
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={episode.title}
>
{episode.title}
</p>
{episode.aired && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{episode.aired}
</p>
)}
</div>
</button>
)
}

View File

@@ -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<ViewLevel>('series')
const [series, setSeries] = useState<TvSeries[]>([])
const [seasons, setSeasons] = useState<TvSeason[]>([])
const [episodes, setEpisodes] = useState<TvEpisode[]>([])
const [selectedSeries, setSelectedSeries] = useState<TvSeries | null>(null)
const [selectedSeason, setSelectedSeason] = useState<TvSeason | null>(null)
const [playingEpisode, setPlayingEpisode] = useState<TvEpisode | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const menuRef = useRef<HTMLDivElement>(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 (
<VideoPlayerModal
url={videoUrl}
name={playingEpisode.title}
onClose={() => setPlayingEpisode(null)}
/>
)
}
return (
<div>
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-6 text-sm flex-wrap">
{view !== 'series' ? (
<button onClick={goToSeries} className="transition-colors" style={{ color: 'var(--accent)' }}>
All Series
</button>
) : (
<span style={{ color: 'var(--text-secondary)' }}>All Series</span>
)}
{selectedSeries && (
<>
<span style={{ color: 'var(--text-secondary)' }}>/</span>
{view === 'episodes' ? (
<button onClick={goToSeasons} className="transition-colors" style={{ color: 'var(--accent)' }}>
{selectedSeries.title}
</button>
) : (
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedSeries.title}
</span>
)}
</>
)}
{selectedSeason && (
<>
<span style={{ color: 'var(--text-secondary)' }}>/</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedSeason.title}
</span>
</>
)}
</div>
{view === 'series' && (
<div className="flex gap-6 items-start">
<div className="w-52 flex-shrink-0">
<FilterPanel
libraryId={libraryId}
assignments={assignments}
search={search}
onSearchChange={setSearch}
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
/>
</div>
<div className="flex-1 min-w-0">
{loading ? (
<SeriesLoadingGrid />
) : error ? (
<ErrorMsg message={error} />
) : series.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
<p className="text-lg mb-1">No TV shows found</p>
<p className="text-sm">Each series should be a folder containing season subdirectories with video files.</p>
</div>
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filteredSeries.map((s) => (
<button
key={s.id}
onClick={() => openSeries(s)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{s.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={s.posterUrl} alt={s.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
)}
</div>
<div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}>
{s.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}
</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
)}
{view === 'seasons' && selectedSeries && (
<div>
{/* Series info header */}
<div className="mb-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
<div className="flex items-start gap-4">
{selectedSeries.posterUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
{/* Kebab menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete series
</button>
</div>
)}
</div>
</div>
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
{selectedSeries.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
))}
</div>
)}
{selectedSeries.plot && (
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
)}
</div>
</div>
{/* Confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mt-3 px-3 py-2.5 rounded-lg"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this series and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 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={handleDeleteSeries}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
</div>
{loading ? (
<SeasonLoadingGrid />
) : error ? (
<ErrorMsg message={error} />
) : seasons.length === 0 ? (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
No seasons found.
</div>
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{seasons.map((season) => (
<button
key={season.id}
onClick={() => openSeason(season)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{season.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={season.posterUrl} alt={season.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-3xl">📺</div>
)}
</div>
<div className="p-2">
<p className="text-xs font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{season.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{season.episodeCount} episode{season.episodeCount !== 1 ? 's' : ''}
</p>
</div>
</button>
))}
</div>
)}
</div>
)}
{view === 'episodes' && selectedSeason && (
<div>
{loading ? (
<EpisodeLoadingGrid />
) : error ? (
<ErrorMsg message={error} />
) : episodes.length === 0 ? (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
No episodes found.
</div>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{episodes.map((ep) => (
<EpisodeCard
key={ep.id}
episode={ep}
onClick={() => setPlayingEpisode(ep)}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
function ErrorMsg({ message }: { message: string }) {
return (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{message}
</div>
)
}
function SeriesLoadingGrid() {
return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
<div className="p-2">
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
</div>
</div>
))}
</div>
)
}
function SeasonLoadingGrid() {
return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
<div className="p-2">
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '60%' }} />
</div>
</div>
))}
</div>
)
}
function EpisodeLoadingGrid() {
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="aspect-video w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
<div className="p-2">
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '80%' }} />
</div>
</div>
))}
</div>
)
}

View File

@@ -41,8 +41,33 @@ function initDb(db: Database.Database): void {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
path 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 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;
`)
}
} }

View File

@@ -1,6 +1,6 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Game } from '@/types' import type { Game, GameSeries } from '@/types'
const HIDDEN_FILES = /^\./ const HIDDEN_FILES = /^\./
@@ -25,10 +25,56 @@ function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
} }
export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] { function thumbnailApiUrl(libraryId: string, relativePath: string): string {
let gameDirs: 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 { 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 }) .readdirSync(libraryRoot, { withFileTypes: true })
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
.map((d) => d.name) .map((d) => d.name)
@@ -36,42 +82,66 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[]
return [] return []
} }
const games: Game[] = [] const results: (Game | GameSeries)[] = []
for (const dirName of gameDirs) { for (const dirName of topDirs) {
const gamePath = path.join(libraryRoot, dirName) const absPath = path.join(libraryRoot, dirName)
// Find the .zip file (first match) let allFiles: string[]
let zipFile: string | null = null
try { try {
const allFiles = fs.readdirSync(gamePath) allFiles = fs.readdirSync(absPath)
zipFile = allFiles.find((f) => f.toLowerCase().endsWith('.zip')) ?? null
} catch { } catch {
// skip unreadable dirs
continue 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 // No .zip here — check subdirectories (series detection)
const coverFile = findFile(gamePath, /^cover$/i) let subDirs: string[]
const wideCoverFile = findFile(gamePath, /^widecover$/i) 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 seriesGames: Game[] = []
const zipRelPath = path.join(dirName, zipFile) 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({ if (seriesGames.length === 0) continue
id,
// 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, title: dirName,
coverUrl: coverFile coverUrl: seriesCoverFile
? fileApiUrl(libraryId, path.join(dirName, coverFile)) ? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile))
: seriesGames[0].coverUrl,
wideCoverUrl: seriesWideCoverFile
? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile))
: null, : null,
wideCoverUrl: wideCoverFile games: seriesGames.sort((a, b) => a.title.localeCompare(b.title)),
? fileApiUrl(libraryId, path.join(dirName, wideCoverFile))
: null,
zipPath: zipRelPath,
}) })
} }
return games.sort((a, b) => a.title.localeCompare(b.title)) return results.sort((a, b) => a.title.localeCompare(b.title))
} }

110
src/lib/movies.ts Normal file
View File

@@ -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))
}

108
src/lib/nfo.ts Normal file
View File

@@ -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<string, unknown> | null {
let xml: string
try {
xml = fs.readFileSync(filePath, 'utf-8')
} catch {
return null
}
try {
return parser.parse(xml) as Record<string, unknown>
} 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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),
}
}

View File

@@ -219,3 +219,8 @@ export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb() const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`) 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)
}

206
src/lib/tv.ts Normal file
View File

@@ -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 ?? '')
})
}

View File

@@ -1,4 +1,4 @@
export type LibraryType = 'games' | 'mixed' export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv'
export interface Library { export interface Library {
id: string id: string
@@ -13,7 +13,15 @@ export interface Game {
title: string title: string
coverUrl: string | null coverUrl: string | null
wideCoverUrl: 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' export type MediaType = 'video' | 'image' | 'other'
@@ -26,6 +34,52 @@ export interface FileEntry {
thumbnailUrl: string | null 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 { export interface DirectoryListing {
path: string path: string
entries: FileEntry[] entries: FileEntry[]