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:
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validTypes: LibraryType[] = ['games', 'mixed']
|
||||
const validTypes: LibraryType[] = ['games', 'mixed', 'movies', 'tv']
|
||||
if (!validTypes.includes(type as LibraryType)) {
|
||||
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
|
||||
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 GamesView from '@/components/games/GamesView'
|
||||
import MixedView from '@/components/mixed/MixedView'
|
||||
import MoviesView from '@/components/movies/MoviesView'
|
||||
import TvView from '@/components/tv/TvView'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -29,6 +31,8 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
||||
|
||||
{library.type === 'games' && <GamesView libraryId={id} />}
|
||||
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
||||
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
||||
{library.type === 'tv' && <TvView libraryId={id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,15 @@ import type { Library, LibraryType } from '@/types'
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
games: '🎮',
|
||||
mixed: '🗂️',
|
||||
movies: '🎬',
|
||||
tv: '📺',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<LibraryType, string> = {
|
||||
games: 'Games',
|
||||
mixed: 'Mixed Media',
|
||||
movies: 'Movies',
|
||||
tv: 'TV Shows',
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
@@ -329,6 +333,8 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
|
||||
>
|
||||
<option value="games">Games</option>
|
||||
<option value="mixed">Mixed Media</option>
|
||||
<option value="movies">Movies</option>
|
||||
<option value="tv">TV Shows</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user