From e8b317f99da1b31c133ef89be88cb52bdc9942f9 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 11:36:05 -0400 Subject: [PATCH 1/3] add movies and tv show library types with Jellyfin NFO support - Add `movies` type: per-movie folders with video files, poster/backdrop images, and optional Jellyfin NFO metadata (title, year, plot, rating, genres, runtime). Grid view with 2:3 poster art, detail modal with play and two-click delete of the movie folder. - Add `tv` type: Series -> Season -> Episode hierarchy with lazy loading at each level. Reads tvshow.nfo and episodedetails NFO files for metadata. Episode grid with video thumbnails, streams via existing video player. Delete is limited to the entire series folder to avoid breaking Jellyfin. - Add fast-xml-parser dependency for Kodi/Jellyfin NFO parsing (lib/nfo.ts) - Migrate existing DB to expand the libraries CHECK constraint to include the two new types; migration is idempotent and preserves existing data. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 68 +++- package.json | 1 + src/app/api/libraries/route.ts | 2 +- src/app/api/movies/route.ts | 65 ++++ src/app/api/tv/route.ts | 78 ++++ src/app/library/[id]/page.tsx | 4 + src/app/manage/page.tsx | 6 + src/components/movies/MovieDetailModal.tsx | 208 ++++++++++ src/components/movies/MoviesView.tsx | 169 +++++++++ src/components/tv/EpisodeCard.tsx | 67 ++++ src/components/tv/TvView.tsx | 421 +++++++++++++++++++++ src/lib/db.ts | 27 +- src/lib/movies.ts | 110 ++++++ src/lib/nfo.ts | 108 ++++++ src/lib/tags.ts | 5 + src/lib/tv.ts | 206 ++++++++++ src/types/index.ts | 48 ++- 17 files changed, 1589 insertions(+), 4 deletions(-) create mode 100644 src/app/api/movies/route.ts create mode 100644 src/app/api/tv/route.ts create mode 100644 src/components/movies/MovieDetailModal.tsx create mode 100644 src/components/movies/MoviesView.tsx create mode 100644 src/components/tv/EpisodeCard.tsx create mode 100644 src/components/tv/TvView.tsx create mode 100644 src/lib/movies.ts create mode 100644 src/lib/nfo.ts create mode 100644 src/lib/tv.ts 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/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/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx new file mode 100644 index 0000000..3c4b780 --- /dev/null +++ b/src/components/movies/MovieDetailModal.tsx @@ -0,0 +1,208 @@ +'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 [playing, setPlaying] = useState(false) + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const cancelRef = useRef | null>(null) + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKey) + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', handleKey) + document.body.style.overflow = '' + } + }, [onClose]) + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose() + } + + const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}` + + const handleDeleteClick = () => { + if (!confirming) { + setConfirming(true) + cancelRef.current = setTimeout(() => setConfirming(false), 4000) + return + } + if (cancelRef.current) clearTimeout(cancelRef.current) + setDeleting(true) + fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, { + method: 'DELETE', + }) + .then(() => onDeleted(movie.id)) + .catch(() => setDeleting(false)) + } + + const handleCancelDelete = () => { + if (cancelRef.current) clearTimeout(cancelRef.current) + setConfirming(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 */} +
+
+

+ {movie.title} +

+ {movie.year && ( + + {movie.year} + + )} +
+ + {/* 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} +

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

+ Tags +

+ +
+ + {/* Delete */} +
+ {confirming && ( +

+ This will permanently delete the folder and all its contents. +

+ )} + {confirming && ( + + )} + +
+
+
+
+ ) +} 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..6e45a45 --- /dev/null +++ b/src/components/tv/TvView.tsx @@ -0,0 +1,421 @@ +'use client' + +import { useEffect, 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 [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + + 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) }) + } + + const goToSeries = () => { + setView('series') + setSelectedSeries(null) + setSelectedSeason(null) + setConfirming(false) + } + + const goToSeasons = () => { + setView('seasons') + setSelectedSeason(null) + setConfirming(false) + } + + const handleDeleteSeries = () => { + if (!selectedSeries) return + if (!confirming) { + setConfirming(true) + setTimeout(() => setConfirming(false), 4000) + 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}

+ {(selectedSeries.year || selectedSeries.genres.length > 0) && ( +
+ {selectedSeries.year && {selectedSeries.year}} + {selectedSeries.genres.map((g) => ( + {g} + ))} +
+ )} + {selectedSeries.plot && ( +

{selectedSeries.plot}

+ )} +
+ {/* Delete series button */} +
+ {confirming && ( + + )} + +
+
+ + {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/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..9148e06 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 @@ -26,6 +26,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[] -- 2.49.1 From b254907ccad82ee710fbeacbd6c1501718934fa3 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:09:05 -0400 Subject: [PATCH 2/3] put delete actions behind a kebab menu to prevent accidental deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the always-visible delete buttons on the movie detail modal and TV series header with a โ‹ฎ kebab menu. Selecting "Delete" from the menu shows an inline confirmation banner before any action is taken. Co-Authored-By: Claude Sonnet 4.6 --- src/components/movies/MovieDetailModal.tsx | 142 +++++++++++--------- src/components/tv/TvView.tsx | 145 +++++++++++++-------- 2 files changed, 175 insertions(+), 112 deletions(-) diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index 3c4b780..e111ff9 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -15,14 +15,19 @@ interface Props { 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) - const cancelRef = useRef | null>(null) useEffect(() => { const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + if (menuOpen) { setMenuOpen(false); return } + if (confirming) { setConfirming(false); return } + onClose() + } } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' @@ -30,7 +35,19 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose]) + }, [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() @@ -38,13 +55,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}` - const handleDeleteClick = () => { - if (!confirming) { - setConfirming(true) - cancelRef.current = setTimeout(() => setConfirming(false), 4000) - return - } - if (cancelRef.current) clearTimeout(cancelRef.current) + const handleConfirmDelete = () => { setDeleting(true) fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, { method: 'DELETE', @@ -53,11 +64,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan .catch(() => setDeleting(false)) } - const handleCancelDelete = () => { - if (cancelRef.current) clearTimeout(cancelRef.current) - setConfirming(false) - } - if (playing) { return setPlaying(false)} /> } @@ -103,8 +109,9 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan {/* Info */}
-
-

+ {/* Title row with kebab menu */} +
+

{movie.title}

{movie.year && ( @@ -112,6 +119,35 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan {movie.year} )} + {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
{/* Meta row */} @@ -141,6 +177,37 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan

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

+ Permanently delete this movie and all its files? +

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

- - {/* Delete */} -
- {confirming && ( -

- This will permanently delete the folder and all its contents. -

- )} - {confirming && ( - - )} - -
diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index 6e45a45..58b5c04 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' +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' @@ -26,8 +26,10 @@ export default function TvView({ libraryId }: Props) { 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) => { @@ -80,10 +82,23 @@ export default function TvView({ libraryId }: Props) { .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) } @@ -95,11 +110,6 @@ export default function TvView({ libraryId }: Props) { const handleDeleteSeries = () => { if (!selectedSeries) return - if (!confirming) { - setConfirming(true) - setTimeout(() => setConfirming(false), 4000) - return - } setDeleting(true) fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`, @@ -235,61 +245,88 @@ export default function TvView({ libraryId }: Props) { {view === 'seasons' && selectedSeries && (
{/* Series info header */} -
- {selectedSeries.posterUrl && ( - // eslint-disable-next-line @next/next/no-img-element - {selectedSeries.title} - )} -
-

{selectedSeries.title}

- {(selectedSeries.year || selectedSeries.genres.length > 0) && ( -
- {selectedSeries.year && {selectedSeries.year}} - {selectedSeries.genres.map((g) => ( - {g} - ))} +
+
+ {selectedSeries.posterUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {selectedSeries.title} + )} +
+
+

{selectedSeries.title}

+ {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
- )} - {selectedSeries.plot && ( -

{selectedSeries.plot}

- )} + {(selectedSeries.year || selectedSeries.genres.length > 0) && ( +
+ {selectedSeries.year && {selectedSeries.year}} + {selectedSeries.genres.map((g) => ( + {g} + ))} +
+ )} + {selectedSeries.plot && ( +

{selectedSeries.plot}

+ )} +
- {/* Delete series button */} -
- {confirming && ( + {/* Confirmation banner */} + {confirming && ( +
+

+ Permanently delete this series and all its files? +

- )} - -
+ +
+ )}
{loading ? ( -- 2.49.1 From 122d7aa3323d010c2d77c48d3cbd553b6557e5d9 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:49:42 -0400 Subject: [PATCH 3/3] add series grouping, cover upload, and multi-zip download to games library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Series grouping: a top-level folder with no .zip but game subfolders is now treated as a GameSeries. Clicking a series drills into it with a breadcrumb; a game-count badge distinguishes series cards from game cards. Series fall back to the first game's cover when no series-level cover exists. - Cover upload: new POST /api/game-cover endpoint writes cover.jpg or widecover.jpg directly into the game/series folder (re-encoded via sharp). A kebab menu on GameDetailModal opens an Edit Images panel showing previews and upload/replace buttons for both cover and wide cover. - Multi-zip download: Game.zipFiles replaces zipPath and includes all .zip files in the folder. A single zip shows the existing download button; multiple zips render a split button โ€” primary action downloads the first file, a dropdown arrow lists all files by name. Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/game-cover/route.ts | 99 +++++ src/components/games/GameDetailModal.tsx | 444 ++++++++++++++++++++--- src/components/games/GamesView.tsx | 222 ++++++++---- src/lib/games.ts | 124 +++++-- src/types/index.ts | 10 +- 5 files changed, 739 insertions(+), 160 deletions(-) create mode 100644 src/app/api/game-cover/route.ts 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/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/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/types/index.ts b/src/types/index.ts index 9148e06..4f2d51f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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' -- 2.49.1