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

206
src/lib/tv.ts Normal file
View 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 ?? '')
})
}