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

@@ -71,14 +71,15 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
clearLibraryItems(db, library.id)
const upsert = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
year = excluded.year,
plot = excluded.plot,
genres = excluded.genres,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
@@ -92,7 +93,13 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
year: movie.year ?? null,
plot: movie.plot ?? null,
genres: JSON.stringify(movie.genres),
metadata: JSON.stringify({ rating: movie.rating, runtime: movie.runtime }),
metadata: JSON.stringify({
rating: movie.rating,
runtime: movie.runtime,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
}),
file_path: movie.videoPath,
scanned_at: now,
})
@@ -117,20 +124,21 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
clearLibraryItems(db, library.id)
const upsertSeries = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
year = excluded.year,
plot = excluded.plot,
genres = excluded.genres,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
const upsertChild = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @scanned_at)
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key,
title = excluded.title,
@@ -138,6 +146,7 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
plot = excluded.plot,
genres = excluded.genres,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
@@ -153,7 +162,13 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
year: show.year ?? null,
plot: show.plot ?? null,
genres: JSON.stringify(show.genres),
metadata: JSON.stringify({ status: show.status, seasonCount: show.seasonCount }),
metadata: JSON.stringify({
status: show.status,
seasonCount: show.seasonCount,
posterUrl: show.posterUrl,
backdropUrl: show.backdropUrl,
}),
file_path: null,
scanned_at: now,
})
@@ -173,7 +188,12 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
year: null,
plot: null,
genres: JSON.stringify([]),
metadata: JSON.stringify({ seasonNumber: season.seasonNumber, episodeCount: season.episodeCount }),
metadata: JSON.stringify({
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount,
posterUrl: season.posterUrl,
}),
file_path: null,
scanned_at: now,
})
@@ -198,7 +218,9 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
seasonNumber: episode.seasonNumber,
aired: episode.aired,
rating: episode.rating,
thumbnailUrl: episode.thumbnailUrl,
}),
file_path: episode.videoPath,
scanned_at: now,
})
@@ -229,21 +251,23 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
clearLibraryItems(db, library.id)
const upsertGame = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @scanned_at)
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
const upsertChildGame = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @scanned_at)
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key,
title = excluded.title,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
@@ -259,7 +283,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_key: seriesKey,
item_type: 'game_series',
title: series.title,
metadata: JSON.stringify({ gameCount: series.games.length }),
metadata: JSON.stringify({
gameCount: series.games.length,
coverUrl: series.coverUrl,
wideCoverUrl: series.wideCoverUrl,
}),
file_path: null,
scanned_at: now,
})
@@ -275,7 +304,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_type: 'game',
parent_key: seriesKey,
title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
metadata: JSON.stringify({
zipFiles: game.zipFiles,
coverUrl: game.coverUrl,
wideCoverUrl: game.wideCoverUrl,
}),
file_path: null,
scanned_at: now,
})
@@ -293,7 +327,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_key: gameKey,
item_type: 'game',
title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
metadata: JSON.stringify({
zipFiles: game.zipFiles,
coverUrl: game.coverUrl,
wideCoverUrl: game.wideCoverUrl,
}),
file_path: null,
scanned_at: now,
})
@@ -308,38 +347,69 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
}
// ---------------------------------------------------------------------------
// Mixed (thumbnail pre-generation only — no DB indexing)
// Mixed
// ---------------------------------------------------------------------------
async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
const fs = await import('fs')
let entries: string[]
try {
entries = fs.readdirSync(libraryRoot, { withFileTypes: true })
.filter((d) => d.isFile() && !d.name.startsWith('.'))
.map((d) => d.name) as unknown as string[]
} catch {
return
}
const fsSync = await import('fs') as typeof import('fs')
const db = getDb()
const now = Date.now()
let count = 0
for (const filename of entries) {
const ext = path.extname(filename).toLowerCase()
let mediaType: 'image' | 'video' | null = null
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
if (!mediaType) continue
clearLibraryItems(db, library.id)
const absPath = path.join(libraryRoot, filename)
const upsert = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
let fileCount = 0
function walk(absDir: string, relDir: string): void {
let dirents: import('fs').Dirent[]
try {
await getThumbnailPath(absPath, library.id, mediaType)
count++
} catch (err) {
console.warn(`[scanner] Could not generate thumbnail for ${filename}:`, err instanceof Error ? err.message : err)
dirents = fsSync.readdirSync(absDir, { withFileTypes: true, encoding: 'utf-8' }) as import('fs').Dirent[]
} catch {
return
}
for (const d of dirents) {
const name = d.name as string
if (name.startsWith('.')) continue
const relPath = relDir ? path.join(relDir, name) : name
if (d.isDirectory()) {
walk(path.join(absDir, name), relPath)
} else {
const title = path.basename(name, path.extname(name))
upsert.run({
library_id: library.id,
item_key: `${library.id}:mixed_file:${encodeURIComponent(relPath)}`,
item_type: 'mixed_file',
title,
file_path: relPath,
scanned_at: now,
})
fileCount++
const ext = path.extname(name).toLowerCase()
let mediaType: 'image' | 'video' | null = null
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
if (mediaType) {
const absPath = path.join(absDir, name)
getThumbnailPath(absPath, library.id, mediaType).catch((err) => {
console.warn(`[scanner] Could not generate thumbnail for ${relPath}:`, err instanceof Error ? err.message : err)
})
}
}
}
}
console.log(`[scanner] mixed: pre-generated thumbnails for ${count} files`)
walk(libraryRoot, '')
console.log(`[scanner] mixed: indexed ${fileCount} files, pre-generating thumbnails`)
}
// ---------------------------------------------------------------------------