Add Movie and TV Library types. #5
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