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:
@@ -41,8 +41,33 @@ function initDb(db: Database.Database): void {
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('games', 'mixed')),
|
||||
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
|
||||
cover_ext TEXT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
migrateLibrariesType(db)
|
||||
}
|
||||
|
||||
function migrateLibrariesType(db: Database.Database): void {
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||
.get() as { sql: string } | undefined
|
||||
|
||||
if (row && !row.sql.includes("'movies'")) {
|
||||
db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE libraries_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')),
|
||||
cover_ext TEXT NULL
|
||||
);
|
||||
INSERT INTO libraries_new SELECT * FROM libraries;
|
||||
DROP TABLE libraries;
|
||||
ALTER TABLE libraries_new RENAME TO libraries;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
110
src/lib/movies.ts
Normal file
110
src/lib/movies.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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))
|
||||
}
|
||||
108
src/lib/nfo.ts
Normal file
108
src/lib/nfo.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'fs'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
const parser = new XMLParser({ isArray: (name) => name === 'genre' })
|
||||
|
||||
function parseFile(filePath: string): Record<string, unknown> | null {
|
||||
let xml: string
|
||||
try {
|
||||
xml = fs.readFileSync(filePath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return parser.parse(xml) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toNumber(val: unknown): number | null {
|
||||
if (val === undefined || val === null || val === '') return null
|
||||
const n = Number(val)
|
||||
return isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
function toString(val: unknown): string | null {
|
||||
if (val === undefined || val === null || val === '') return null
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function toStringArray(val: unknown): string[] {
|
||||
if (!val) return []
|
||||
if (Array.isArray(val)) return val.map(String).filter(Boolean)
|
||||
return [String(val)]
|
||||
}
|
||||
|
||||
export interface MovieNfoData {
|
||||
title: string | null
|
||||
year: number | null
|
||||
plot: string | null
|
||||
rating: number | null
|
||||
runtime: number | null
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
export interface TvShowNfoData {
|
||||
title: string | null
|
||||
year: number | null
|
||||
plot: string | null
|
||||
genres: string[]
|
||||
status: string | null
|
||||
}
|
||||
|
||||
export interface EpisodeNfoData {
|
||||
title: string | null
|
||||
season: number | null
|
||||
episode: number | null
|
||||
plot: string | null
|
||||
aired: string | null
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
export function parseMovieNfo(filePath: string): MovieNfoData | null {
|
||||
const doc = parseFile(filePath)
|
||||
if (!doc) return null
|
||||
const m = doc.movie as Record<string, unknown> | undefined
|
||||
if (!m) return null
|
||||
|
||||
return {
|
||||
title: toString(m.title),
|
||||
year: toNumber(m.year),
|
||||
plot: toString(m.plot),
|
||||
rating: toNumber(m.rating),
|
||||
runtime: toNumber(m.runtime),
|
||||
genres: toStringArray(m.genre),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTvShowNfo(filePath: string): TvShowNfoData | null {
|
||||
const doc = parseFile(filePath)
|
||||
if (!doc) return null
|
||||
const s = doc.tvshow as Record<string, unknown> | undefined
|
||||
if (!s) return null
|
||||
|
||||
return {
|
||||
title: toString(s.title),
|
||||
year: toNumber(s.year),
|
||||
plot: toString(s.plot),
|
||||
genres: toStringArray(s.genre),
|
||||
status: toString(s.status),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEpisodeNfo(filePath: string): EpisodeNfoData | null {
|
||||
const doc = parseFile(filePath)
|
||||
if (!doc) return null
|
||||
const e = doc.episodedetails as Record<string, unknown> | undefined
|
||||
if (!e) return null
|
||||
|
||||
return {
|
||||
title: toString(e.title),
|
||||
season: toNumber(e.season),
|
||||
episode: toNumber(e.episode),
|
||||
plot: toString(e.plot),
|
||||
aired: toString(e.aired),
|
||||
rating: toNumber(e.rating),
|
||||
}
|
||||
}
|
||||
@@ -219,3 +219,8 @@ export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
||||
const db = getDb()
|
||||
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)
|
||||
}
|
||||
|
||||
export function removeAllAssignmentsForItem(mediaKey: string): void {
|
||||
const db = getDb()
|
||||
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey)
|
||||
}
|
||||
|
||||
206
src/lib/tv.ts
Normal file
206
src/lib/tv.ts
Normal 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 ?? '')
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user