- 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>
111 lines
3.2 KiB
TypeScript
111 lines
3.2 KiB
TypeScript
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))
|
|
}
|