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

View 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>
)
}