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:
Garret Patti
2026-04-05 11:36:05 -04:00
parent b3abc7ee4c
commit e8b317f99d
17 changed files with 1589 additions and 4 deletions

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

@@ -0,0 +1,78 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv'
import { removeAllAssignmentsForItem } from '@/lib/tags'
export function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId')
const seasonId = searchParams.get('seasonId')
if (!libraryId) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'tv') {
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
if (seriesId && seasonId) {
const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId)
return NextResponse.json(episodes)
}
if (seriesId) {
const seasons = scanTvSeasons(root, libraryId, seriesId)
return NextResponse.json(seasons)
}
const series = scanTvLibrary(root, libraryId)
return NextResponse.json(series)
}
export function DELETE(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId')
if (!libraryId || !seriesId) {
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'tv') {
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
const dirName = decodeURIComponent(seriesId)
let seriesDir: string
try {
seriesDir = resolveAndJail(root, dirName)
} catch {
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
}
try {
fs.rmSync(seriesDir, { recursive: true, force: true })
} catch {
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
}
removeAllAssignmentsForItem(`${libraryId}:${seriesId}`)
return new NextResponse(null, { status: 204 })
}