DB-first library reads, mixed library indexing, and manual NFO refresh

- API reads now serve from media_items cache instead of scanning the filesystem
  on every request; scans (manual or scheduled) remain the write path
- NFO metadata is no longer parsed automatically during scans; title falls back
  to folder/filename — metadata can be refreshed per-item via the kabob menu
- Mixed libraries are now indexed in media_items (new mixed_file item type)
  with file_path stored; scanMixed walks recursively and upserts all files
- Added file_path column to media_items and migrated item_type CHECK constraint
  to include mixed_file via safe table-recreation migration
- New POST /api/nfo-refresh endpoint reads the .nfo for a single item and
  patches its DB row (supports movie, tv_series, tv_episode)
- Added "Refresh metadata" button to movie and TV series kabob menus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-06 18:20:21 -04:00
parent 01a4a1c0b7
commit 819748d1ff
12 changed files with 597 additions and 94 deletions

View File

@@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import type { Movie } from '@/types'
import { parseMovieNfo } from './nfo'
import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
function findVideoFile(dir: string): string | null {
@@ -16,7 +16,7 @@ function findVideoFile(dir: string): string | null {
) ?? null
}
function findNfoFile(dir: string, dirName: string): string | null {
export 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[]
@@ -26,9 +26,8 @@ function findNfoFile(dir: string, dirName: string): string | null {
return null
}
for (const candidate of candidates) {
if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) {
return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())!
}
const match = entries.find((e) => e.toLowerCase() === candidate.toLowerCase())
if (match) return match
}
return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null
}
@@ -52,9 +51,6 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
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)
@@ -63,12 +59,12 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
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,
title: dirName,
year: null,
plot: null,
rating: null,
genres: [],
runtime: null,
posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null,
@@ -81,3 +77,35 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
return movies.sort((a, b) => a.title.localeCompare(b.title))
}
export function moviesFromDb(libraryId: string): Movie[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'movie' ORDER BY title`)
.all(libraryId) as Array<{
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}>
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':movie:')[1] ?? row.item_key
return {
id: idPart,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
rating: meta.rating ?? null,
genres: row.genres ? JSON.parse(row.genres) : [],
runtime: meta.runtime ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
videoPath: row.file_path ?? '',
}
})
}