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 <noreply@anthropic.com>
This commit is contained in:
68
package-lock.json
generated
68
package-lock.json
generated
@@ -9,8 +9,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"fast-xml-parser": "^5.5.10",
|
||||||
"next": "^15.5.14",
|
"next": "^15.5.14",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -1637,6 +1638,7 @@
|
|||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -1667,6 +1669,7 @@
|
|||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -3792,6 +3795,41 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-builder": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz",
|
||||||
|
"integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-builder": "^1.1.4",
|
||||||
|
"path-expression-matcher": "^1.2.1",
|
||||||
|
"strnum": "^2.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -5668,6 +5706,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -6524,6 +6577,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@@ -6892,6 +6957,7 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"fast-xml-parser": "^5.5.10",
|
||||||
"next": "^15.5.14",
|
"next": "^15.5.14",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes: LibraryType[] = ['games', 'mixed']
|
const validTypes: LibraryType[] = ['games', 'mixed', 'movies', 'tv']
|
||||||
if (!validTypes.includes(type as LibraryType)) {
|
if (!validTypes.includes(type as LibraryType)) {
|
||||||
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/app/api/movies/route.ts
Normal file
65
src/app/api/movies/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { scanMoviesLibrary } from '@/lib/movies'
|
||||||
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
|
|
||||||
|
export function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'movies') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
const movies = scanMoviesLibrary(root, libraryId)
|
||||||
|
return NextResponse.json(movies)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DELETE(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const movieId = searchParams.get('movieId')
|
||||||
|
|
||||||
|
if (!libraryId || !movieId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId or movieId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'movies') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
const dirName = decodeURIComponent(movieId)
|
||||||
|
|
||||||
|
let movieDir: string
|
||||||
|
try {
|
||||||
|
movieDir = resolveAndJail(root, dirName)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid movie path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(movieDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllAssignmentsForItem(`${libraryId}:${movieId}`)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
78
src/app/api/tv/route.ts
Normal file
78
src/app/api/tv/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv'
|
||||||
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
|
|
||||||
|
export function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const seriesId = searchParams.get('seriesId')
|
||||||
|
const seasonId = searchParams.get('seasonId')
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'tv') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
if (seriesId && seasonId) {
|
||||||
|
const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId)
|
||||||
|
return NextResponse.json(episodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesId) {
|
||||||
|
const seasons = scanTvSeasons(root, libraryId, seriesId)
|
||||||
|
return NextResponse.json(seasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = scanTvLibrary(root, libraryId)
|
||||||
|
return NextResponse.json(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DELETE(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const seriesId = searchParams.get('seriesId')
|
||||||
|
|
||||||
|
if (!libraryId || !seriesId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'tv') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
const dirName = decodeURIComponent(seriesId)
|
||||||
|
|
||||||
|
let seriesDir: string
|
||||||
|
try {
|
||||||
|
seriesDir = resolveAndJail(root, dirName)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(seriesDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllAssignmentsForItem(`${libraryId}:${seriesId}`)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { getLibrary } from '@/lib/libraries'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import GamesView from '@/components/games/GamesView'
|
import GamesView from '@/components/games/GamesView'
|
||||||
import MixedView from '@/components/mixed/MixedView'
|
import MixedView from '@/components/mixed/MixedView'
|
||||||
|
import MoviesView from '@/components/movies/MoviesView'
|
||||||
|
import TvView from '@/components/tv/TvView'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -29,6 +31,8 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
{library.type === 'games' && <GamesView libraryId={id} />}
|
{library.type === 'games' && <GamesView libraryId={id} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
||||||
|
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
||||||
|
{library.type === 'tv' && <TvView libraryId={id} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import type { Library, LibraryType } from '@/types'
|
|||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
games: '🎮',
|
games: '🎮',
|
||||||
mixed: '🗂️',
|
mixed: '🗂️',
|
||||||
|
movies: '🎬',
|
||||||
|
tv: '📺',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<LibraryType, string> = {
|
const TYPE_LABELS: Record<LibraryType, string> = {
|
||||||
games: 'Games',
|
games: 'Games',
|
||||||
mixed: 'Mixed Media',
|
mixed: 'Mixed Media',
|
||||||
|
movies: 'Movies',
|
||||||
|
tv: 'TV Shows',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
@@ -329,6 +333,8 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
|
|||||||
>
|
>
|
||||||
<option value="games">Games</option>
|
<option value="games">Games</option>
|
||||||
<option value="mixed">Mixed Media</option>
|
<option value="mixed">Mixed Media</option>
|
||||||
|
<option value="movies">Movies</option>
|
||||||
|
<option value="tv">TV Shows</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
208
src/components/movies/MovieDetailModal.tsx
Normal file
208
src/components/movies/MovieDetailModal.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | 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 <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const heroUrl = movie.backdropUrl ?? movie.posterUrl
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hero image */}
|
||||||
|
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
|
{heroUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={heroUrl}
|
||||||
|
alt={movie.title}
|
||||||
|
className="w-full object-cover max-h-64"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-1">
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{movie.title}
|
||||||
|
</h2>
|
||||||
|
{movie.year && (
|
||||||
|
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{movie.year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
{movie.rating !== null && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
★ {movie.rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{movie.runtime !== null && (
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{movie.runtime} min
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{movie.genres.map((g) => (
|
||||||
|
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{movie.plot && (
|
||||||
|
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{movie.plot}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPlaying(true)}
|
||||||
|
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
<span>▶</span>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<div className="mt-4 pt-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
{confirming && (
|
||||||
|
<p className="text-xs flex-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This will permanently delete the folder and all its contents.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{confirming && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancelDelete}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0 transition-colors disabled:opacity-50 ml-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
||||||
|
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!confirming) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!confirming) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete movie'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
src/components/movies/MoviesView.tsx
Normal file
169
src/components/movies/MoviesView.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import type { Movie } from '@/types'
|
||||||
|
import MovieDetailModal from './MovieDetailModal'
|
||||||
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
libraryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MoviesView({ libraryId }: Props) {
|
||||||
|
const [movies, setMovies] = useState<Movie[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Movie | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
|
||||||
|
const toggleTag = (tagId: string) =>
|
||||||
|
setSelectedTagIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchMovies = useCallback(() => {
|
||||||
|
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setMovies(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load movies')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchMovies() }, [fetchMovies])
|
||||||
|
|
||||||
|
const fetchAssignments = useCallback(() => {
|
||||||
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setAssignments)
|
||||||
|
.catch(() => {})
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
|
const filtered = movies.filter((movie) => {
|
||||||
|
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
|
if (selectedTagIds.size > 0) {
|
||||||
|
const movieTags = assignments[`${libraryId}:${movie.id}`] ?? []
|
||||||
|
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeleted = (movieId: string) => {
|
||||||
|
setSelected(null)
|
||||||
|
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
<div className="w-52 flex-shrink-0">
|
||||||
|
<FilterPanel
|
||||||
|
libraryId={libraryId}
|
||||||
|
assignments={assignments}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
selectedTagIds={selectedTagIds}
|
||||||
|
onTagToggle={toggleTag}
|
||||||
|
refreshKey={filterRefreshKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingGrid />
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : movies.length === 0 ? (
|
||||||
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
<p className="text-lg mb-1">No movies found</p>
|
||||||
|
<p className="text-sm">Each movie should be a folder containing a video file.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{filtered.map((movie) => (
|
||||||
|
<button
|
||||||
|
key={movie.id}
|
||||||
|
onClick={() => setSelected(movie)}
|
||||||
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
|
{movie.posterUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={movie.posterUrl}
|
||||||
|
alt={movie.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
||||||
|
🎬
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium truncate leading-tight"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
title={movie.title}
|
||||||
|
>
|
||||||
|
{movie.title}
|
||||||
|
</p>
|
||||||
|
{movie.year && (
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{movie.year}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<MovieDetailModal
|
||||||
|
movie={selected}
|
||||||
|
libraryId={libraryId}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||||
|
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/components/tv/EpisodeCard.tsx
Normal file
67
src/components/tv/EpisodeCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TvEpisode } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
episode: TvEpisode
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpisodeCard({ episode, onClick }: Props) {
|
||||||
|
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="aspect-video w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
|
{episode.thumbnailUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={episode.thumbnailUrl}
|
||||||
|
alt={episode.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-3xl">▶</div>
|
||||||
|
)}
|
||||||
|
{/* Play overlay on hover */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
||||||
|
>
|
||||||
|
<span className="text-3xl text-white">▶</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
{epLabel && (
|
||||||
|
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>
|
||||||
|
{epLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium truncate leading-tight"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
title={episode.title}
|
||||||
|
>
|
||||||
|
{episode.title}
|
||||||
|
</p>
|
||||||
|
{episode.aired && (
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{episode.aired}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
421
src/components/tv/TvView.tsx
Normal file
421
src/components/tv/TvView.tsx
Normal file
@@ -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<ViewLevel>('series')
|
||||||
|
const [series, setSeries] = useState<TvSeries[]>([])
|
||||||
|
const [seasons, setSeasons] = useState<TvSeason[]>([])
|
||||||
|
const [episodes, setEpisodes] = useState<TvEpisode[]>([])
|
||||||
|
const [selectedSeries, setSelectedSeries] = useState<TvSeries | null>(null)
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<TvSeason | null>(null)
|
||||||
|
const [playingEpisode, setPlayingEpisode] = useState<TvEpisode | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
const [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 (
|
||||||
|
<VideoPlayerModal
|
||||||
|
url={videoUrl}
|
||||||
|
name={playingEpisode.title}
|
||||||
|
onClose={() => setPlayingEpisode(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 mb-6 text-sm flex-wrap">
|
||||||
|
{view !== 'series' ? (
|
||||||
|
<button onClick={goToSeries} className="transition-colors" style={{ color: 'var(--accent)' }}>
|
||||||
|
All Series
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>All Series</span>
|
||||||
|
)}
|
||||||
|
{selectedSeries && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
|
{view === 'episodes' ? (
|
||||||
|
<button onClick={goToSeasons} className="transition-colors" style={{ color: 'var(--accent)' }}>
|
||||||
|
{selectedSeries.title}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{selectedSeries.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedSeason && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{selectedSeason.title}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'series' && (
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
<div className="w-52 flex-shrink-0">
|
||||||
|
<FilterPanel
|
||||||
|
libraryId={libraryId}
|
||||||
|
assignments={assignments}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
selectedTagIds={selectedTagIds}
|
||||||
|
onTagToggle={toggleTag}
|
||||||
|
refreshKey={filterRefreshKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{loading ? (
|
||||||
|
<SeriesLoadingGrid />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorMsg message={error} />
|
||||||
|
) : series.length === 0 ? (
|
||||||
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
<p className="text-lg mb-1">No TV shows found</p>
|
||||||
|
<p className="text-sm">Each series should be a folder containing season subdirectories with video files.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{filteredSeries.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => openSeries(s)}
|
||||||
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
|
{s.posterUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={s.posterUrl} alt={s.title} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}>
|
||||||
|
{s.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'seasons' && selectedSeries && (
|
||||||
|
<div>
|
||||||
|
{/* Series info header */}
|
||||||
|
<div className="flex items-start gap-4 mb-6 p-4 rounded-xl" style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
|
||||||
|
{selectedSeries.posterUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
|
||||||
|
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
|
||||||
|
{selectedSeries.genres.map((g) => (
|
||||||
|
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedSeries.plot && (
|
||||||
|
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Delete series button */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{confirming && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteSeries}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
||||||
|
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!confirming) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!confirming) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={confirming ? 'Click again to permanently delete this series and all its files' : 'Delete this series'}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete series'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<SeasonLoadingGrid />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorMsg message={error} />
|
||||||
|
) : seasons.length === 0 ? (
|
||||||
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
No seasons found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<button
|
||||||
|
key={season.id}
|
||||||
|
onClick={() => openSeason(season)}
|
||||||
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="aspect-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
|
{season.posterUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={season.posterUrl} alt={season.title} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-3xl">📺</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-xs font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{season.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{season.episodeCount} episode{season.episodeCount !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'episodes' && selectedSeason && (
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<EpisodeLoadingGrid />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorMsg message={error} />
|
||||||
|
) : episodes.length === 0 ? (
|
||||||
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
No episodes found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{episodes.map((ep) => (
|
||||||
|
<EpisodeCard
|
||||||
|
key={ep.id}
|
||||||
|
episode={ep}
|
||||||
|
onClick={() => setPlayingEpisode(ep)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorMsg({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeriesLoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||||
|
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeasonLoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||||
|
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '60%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeLoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||||
|
<div className="aspect-video w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '80%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -41,8 +41,33 @@ function initDb(db: Database.Database): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
type TEXT NOT NULL CHECK(type IN ('games', 'mixed')),
|
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
|
||||||
cover_ext TEXT NULL
|
cover_ext TEXT NULL
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
migrateLibrariesType(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLibrariesType(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
|
||||||
|
if (row && !row.sql.includes("'movies'")) {
|
||||||
|
db.exec(`
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
CREATE TABLE libraries_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
|
||||||
|
cover_ext TEXT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO libraries_new SELECT * FROM libraries;
|
||||||
|
DROP TABLE libraries;
|
||||||
|
ALTER TABLE libraries_new RENAME TO libraries;
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/lib/movies.ts
Normal file
110
src/lib/movies.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Movie } from '@/types'
|
||||||
|
import { parseMovieNfo } from './nfo'
|
||||||
|
|
||||||
|
const HIDDEN_FILES = /^\./
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
|
||||||
|
|
||||||
|
function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||||
|
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
|
||||||
|
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the first file in a directory whose basename (without extension)
|
||||||
|
* matches the given pattern (case-insensitive).
|
||||||
|
*/
|
||||||
|
function findFile(dir: string, pattern: RegExp): string | null {
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entries.find(
|
||||||
|
(e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e)))
|
||||||
|
) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVideoFile(dir: string): string | null {
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entries.find(
|
||||||
|
(e) => !HIDDEN_FILES.test(e) && VIDEO_EXTENSIONS.has(path.extname(e).toLowerCase())
|
||||||
|
) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNfoFile(dir: string, dirName: string): string | null {
|
||||||
|
// Try {dirName}.nfo first, then movie.nfo, then any .nfo
|
||||||
|
const candidates = [`${dirName}.nfo`, 'movie.nfo']
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) {
|
||||||
|
return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie[] {
|
||||||
|
let dirs: string[]
|
||||||
|
try {
|
||||||
|
dirs = fs
|
||||||
|
.readdirSync(libraryRoot, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
||||||
|
.map((d) => d.name)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const movies: Movie[] = []
|
||||||
|
|
||||||
|
for (const dirName of dirs) {
|
||||||
|
const moviePath = path.join(libraryRoot, dirName)
|
||||||
|
|
||||||
|
const videoFile = findVideoFile(moviePath)
|
||||||
|
if (!videoFile) continue
|
||||||
|
|
||||||
|
const nfoFile = findNfoFile(moviePath, dirName)
|
||||||
|
const nfo = nfoFile ? parseMovieNfo(path.join(moviePath, nfoFile)) : null
|
||||||
|
|
||||||
|
const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i)
|
||||||
|
const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i)
|
||||||
|
|
||||||
|
const id = encodeURIComponent(dirName)
|
||||||
|
const videoRelPath = path.join(dirName, videoFile)
|
||||||
|
|
||||||
|
movies.push({
|
||||||
|
id,
|
||||||
|
title: nfo?.title ?? dirName,
|
||||||
|
year: nfo?.year ?? null,
|
||||||
|
plot: nfo?.plot ?? null,
|
||||||
|
rating: nfo?.rating ?? null,
|
||||||
|
genres: nfo?.genres ?? [],
|
||||||
|
runtime: nfo?.runtime ?? null,
|
||||||
|
posterUrl: posterFile
|
||||||
|
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||||
|
: null,
|
||||||
|
backdropUrl: backdropFile
|
||||||
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
|
: null,
|
||||||
|
videoPath: videoRelPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return movies.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
}
|
||||||
108
src/lib/nfo.ts
Normal file
108
src/lib/nfo.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
|
|
||||||
|
const parser = new XMLParser({ isArray: (name) => name === 'genre' })
|
||||||
|
|
||||||
|
function parseFile(filePath: string): Record<string, unknown> | null {
|
||||||
|
let xml: string
|
||||||
|
try {
|
||||||
|
xml = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parser.parse(xml) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(val: unknown): number | null {
|
||||||
|
if (val === undefined || val === null || val === '') return null
|
||||||
|
const n = Number(val)
|
||||||
|
return isNaN(n) ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
function toString(val: unknown): string | null {
|
||||||
|
if (val === undefined || val === null || val === '') return null
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(val: unknown): string[] {
|
||||||
|
if (!val) return []
|
||||||
|
if (Array.isArray(val)) return val.map(String).filter(Boolean)
|
||||||
|
return [String(val)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieNfoData {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
rating: number | null
|
||||||
|
runtime: number | null
|
||||||
|
genres: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvShowNfoData {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string[]
|
||||||
|
status: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeNfoData {
|
||||||
|
title: string | null
|
||||||
|
season: number | null
|
||||||
|
episode: number | null
|
||||||
|
plot: string | null
|
||||||
|
aired: string | null
|
||||||
|
rating: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMovieNfo(filePath: string): MovieNfoData | null {
|
||||||
|
const doc = parseFile(filePath)
|
||||||
|
if (!doc) return null
|
||||||
|
const m = doc.movie as Record<string, unknown> | undefined
|
||||||
|
if (!m) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toString(m.title),
|
||||||
|
year: toNumber(m.year),
|
||||||
|
plot: toString(m.plot),
|
||||||
|
rating: toNumber(m.rating),
|
||||||
|
runtime: toNumber(m.runtime),
|
||||||
|
genres: toStringArray(m.genre),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTvShowNfo(filePath: string): TvShowNfoData | null {
|
||||||
|
const doc = parseFile(filePath)
|
||||||
|
if (!doc) return null
|
||||||
|
const s = doc.tvshow as Record<string, unknown> | undefined
|
||||||
|
if (!s) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toString(s.title),
|
||||||
|
year: toNumber(s.year),
|
||||||
|
plot: toString(s.plot),
|
||||||
|
genres: toStringArray(s.genre),
|
||||||
|
status: toString(s.status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEpisodeNfo(filePath: string): EpisodeNfoData | null {
|
||||||
|
const doc = parseFile(filePath)
|
||||||
|
if (!doc) return null
|
||||||
|
const e = doc.episodedetails as Record<string, unknown> | undefined
|
||||||
|
if (!e) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toString(e.title),
|
||||||
|
season: toNumber(e.season),
|
||||||
|
episode: toNumber(e.episode),
|
||||||
|
plot: toString(e.plot),
|
||||||
|
aired: toString(e.aired),
|
||||||
|
rating: toNumber(e.rating),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,3 +219,8 @@ export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
|||||||
const db = getDb()
|
const db = getDb()
|
||||||
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)
|
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeAllAssignmentsForItem(mediaKey: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey)
|
||||||
|
}
|
||||||
|
|||||||
206
src/lib/tv.ts
Normal file
206
src/lib/tv.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||||
|
import { parseTvShowNfo, parseEpisodeNfo } from './nfo'
|
||||||
|
|
||||||
|
const HIDDEN_FILES = /^\./
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
|
||||||
|
|
||||||
|
function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||||
|
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
|
||||||
|
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoFile(name: string): boolean {
|
||||||
|
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the first file in a directory whose basename (without extension)
|
||||||
|
* matches the given pattern (case-insensitive).
|
||||||
|
*/
|
||||||
|
function findFile(dir: string, pattern: RegExp): string | null {
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entries.find(
|
||||||
|
(e) => !HIDDEN_FILES.test(e) && pattern.test(path.basename(e, path.extname(e)))
|
||||||
|
) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDirs(dir: string): string[] {
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
||||||
|
.map((d) => d.name)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFiles(dir: string): string[] {
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isFile() && !HIDDEN_FILES.test(d.name))
|
||||||
|
.map((d) => d.name)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeasonNumber(dirName: string): number | null {
|
||||||
|
// Matches: "Season 01", "Season 1", "S01", "S1", bare "1", "01"
|
||||||
|
const m =
|
||||||
|
dirName.match(/^season\s*(\d+)$/i) ??
|
||||||
|
dirName.match(/^s(\d+)$/i) ??
|
||||||
|
dirName.match(/^(\d+)$/)
|
||||||
|
return m ? parseInt(m[1], 10) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVideosInDir(dir: string): number {
|
||||||
|
return readFiles(dir).filter(isVideoFile).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[] {
|
||||||
|
const seriesDirs = readDirs(libraryRoot)
|
||||||
|
const series: TvSeries[] = []
|
||||||
|
|
||||||
|
for (const dirName of seriesDirs) {
|
||||||
|
const seriesPath = path.join(libraryRoot, dirName)
|
||||||
|
const nfoFile = path.join(seriesPath, 'tvshow.nfo')
|
||||||
|
const nfo = parseTvShowNfo(nfoFile)
|
||||||
|
|
||||||
|
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
|
||||||
|
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
|
||||||
|
|
||||||
|
const seasonDirs = readDirs(seriesPath)
|
||||||
|
const seasonCount = seasonDirs.filter((sd) => {
|
||||||
|
const sdPath = path.join(seriesPath, sd)
|
||||||
|
return countVideosInDir(sdPath) > 0
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const id = encodeURIComponent(dirName)
|
||||||
|
|
||||||
|
series.push({
|
||||||
|
id,
|
||||||
|
title: nfo?.title ?? dirName,
|
||||||
|
year: nfo?.year ?? null,
|
||||||
|
plot: nfo?.plot ?? null,
|
||||||
|
genres: nfo?.genres ?? [],
|
||||||
|
status: nfo?.status ?? null,
|
||||||
|
posterUrl: posterFile
|
||||||
|
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||||
|
: null,
|
||||||
|
backdropUrl: backdropFile
|
||||||
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
|
: null,
|
||||||
|
seasonCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return series.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanTvSeasons(
|
||||||
|
libraryRoot: string,
|
||||||
|
libraryId: string,
|
||||||
|
seriesId: string
|
||||||
|
): TvSeason[] {
|
||||||
|
const seriesDirName = decodeURIComponent(seriesId)
|
||||||
|
const seriesPath = path.join(libraryRoot, seriesDirName)
|
||||||
|
|
||||||
|
const seasonDirs = readDirs(seriesPath)
|
||||||
|
const seasons: TvSeason[] = []
|
||||||
|
|
||||||
|
for (const dirName of seasonDirs) {
|
||||||
|
const seasonPath = path.join(seriesPath, dirName)
|
||||||
|
const episodeCount = countVideosInDir(seasonPath)
|
||||||
|
if (episodeCount === 0) continue
|
||||||
|
|
||||||
|
const seasonNumber = parseSeasonNumber(dirName)
|
||||||
|
|
||||||
|
// Look for season poster: "season01-poster.*" or "poster.*" in season dir
|
||||||
|
const seasonPosterPattern = seasonNumber !== null
|
||||||
|
? new RegExp(`^season${String(seasonNumber).padStart(2, '0')}-poster$`, 'i')
|
||||||
|
: /^poster$/i
|
||||||
|
const posterFile =
|
||||||
|
findFile(seasonPath, seasonPosterPattern) ?? findFile(seasonPath, /^poster$/i)
|
||||||
|
|
||||||
|
const id = encodeURIComponent(dirName)
|
||||||
|
const title = seasonNumber !== null ? `Season ${seasonNumber}` : dirName
|
||||||
|
|
||||||
|
seasons.push({
|
||||||
|
id,
|
||||||
|
seriesId,
|
||||||
|
title,
|
||||||
|
seasonNumber,
|
||||||
|
posterUrl: posterFile
|
||||||
|
? thumbnailApiUrl(libraryId, path.join(seriesDirName, dirName, posterFile))
|
||||||
|
: null,
|
||||||
|
episodeCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasons.sort((a, b) => {
|
||||||
|
if (a.seasonNumber !== null && b.seasonNumber !== null) {
|
||||||
|
return a.seasonNumber - b.seasonNumber
|
||||||
|
}
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanTvEpisodes(
|
||||||
|
libraryRoot: string,
|
||||||
|
libraryId: string,
|
||||||
|
seriesId: string,
|
||||||
|
seasonId: string
|
||||||
|
): TvEpisode[] {
|
||||||
|
const seriesDirName = decodeURIComponent(seriesId)
|
||||||
|
const seasonDirName = decodeURIComponent(seasonId)
|
||||||
|
const seasonPath = path.join(libraryRoot, seriesDirName, seasonDirName)
|
||||||
|
|
||||||
|
const files = readFiles(seasonPath)
|
||||||
|
const videoFiles = files.filter(isVideoFile)
|
||||||
|
const episodes: TvEpisode[] = []
|
||||||
|
|
||||||
|
for (const videoFile of videoFiles) {
|
||||||
|
const baseName = path.basename(videoFile, path.extname(videoFile))
|
||||||
|
const nfoFileName = files.find(
|
||||||
|
(f) => path.basename(f, path.extname(f)) === baseName && path.extname(f).toLowerCase() === '.nfo'
|
||||||
|
)
|
||||||
|
const nfo = nfoFileName
|
||||||
|
? parseEpisodeNfo(path.join(seasonPath, nfoFileName))
|
||||||
|
: null
|
||||||
|
|
||||||
|
const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile)
|
||||||
|
const id = encodeURIComponent(videoFile)
|
||||||
|
|
||||||
|
episodes.push({
|
||||||
|
id,
|
||||||
|
title: nfo?.title ?? baseName,
|
||||||
|
episodeNumber: nfo?.episode ?? null,
|
||||||
|
seasonNumber: nfo?.season ?? null,
|
||||||
|
plot: nfo?.plot ?? null,
|
||||||
|
aired: nfo?.aired ?? null,
|
||||||
|
rating: nfo?.rating ?? null,
|
||||||
|
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
||||||
|
videoPath: videoRelPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return episodes.sort((a, b) => {
|
||||||
|
if (a.episodeNumber !== null && b.episodeNumber !== null) {
|
||||||
|
return a.episodeNumber - b.episodeNumber
|
||||||
|
}
|
||||||
|
return (a.title ?? '').localeCompare(b.title ?? '')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type LibraryType = 'games' | 'mixed'
|
export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv'
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,6 +26,52 @@ export interface FileEntry {
|
|||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Movie {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
rating: number | null
|
||||||
|
genres: string[]
|
||||||
|
runtime: number | null
|
||||||
|
posterUrl: string | null
|
||||||
|
backdropUrl: string | null
|
||||||
|
videoPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvSeries {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string[]
|
||||||
|
status: string | null
|
||||||
|
posterUrl: string | null
|
||||||
|
backdropUrl: string | null
|
||||||
|
seasonCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvSeason {
|
||||||
|
id: string
|
||||||
|
seriesId: string
|
||||||
|
title: string
|
||||||
|
seasonNumber: number | null
|
||||||
|
posterUrl: string | null
|
||||||
|
episodeCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvEpisode {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
episodeNumber: number | null
|
||||||
|
seasonNumber: number | null
|
||||||
|
plot: string | null
|
||||||
|
aired: string | null
|
||||||
|
rating: number | null
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
videoPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectoryListing {
|
export interface DirectoryListing {
|
||||||
path: string
|
path: string
|
||||||
entries: FileEntry[]
|
entries: FileEntry[]
|
||||||
|
|||||||
Reference in New Issue
Block a user