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[]