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 { TvSeries, TvSeason, TvEpisode } from '@/types'
import { parseTvShowNfo, parseEpisodeNfo } from './nfo'
import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
function isVideoFile(name: string): boolean {
@@ -49,8 +49,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): 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)
@@ -69,11 +67,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
series.push({
id,
title: nfo?.title ?? dirName,
year: nfo?.year ?? null,
plot: nfo?.plot ?? null,
genres: nfo?.genres ?? [],
status: nfo?.status ?? null,
title: dirName,
year: null,
plot: null,
genres: [],
status: null,
posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null,
@@ -168,24 +166,17 @@ export function scanTvEpisodes(
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,
title: baseName,
episodeNumber: null,
seasonNumber: null,
plot: null,
aired: null,
rating: null,
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
videoPath: videoRelPath,
})
@@ -198,3 +189,107 @@ export function scanTvEpisodes(
return (a.title ?? '').localeCompare(b.title ?? '')
})
}
// ---------------------------------------------------------------------------
// DB readers
// ---------------------------------------------------------------------------
type DbRow = {
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'tv_series' ORDER BY title`)
.all(libraryId) as DbRow[]
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key
return {
id: idPart,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
genres: row.genres ? JSON.parse(row.genres) : [],
status: meta.status ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
seasonCount: meta.seasonCount ?? 0,
}
})
}
export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[] {
const db = getDb()
const parentKey = `${libraryId}:tv_series:${seriesId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_season'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_season:{seriesId}:{seasonId}
const parts = row.item_key.split(':tv_season:')
const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key
return {
id: seasonId,
seriesId,
title: row.title ?? seasonId,
seasonNumber: meta.seasonNumber ?? null,
posterUrl: meta.posterUrl ?? null,
episodeCount: meta.episodeCount ?? 0,
}
})
.sort((a, b) => {
if (a.seasonNumber !== null && b.seasonNumber !== null) {
return a.seasonNumber - b.seasonNumber
}
return a.title.localeCompare(b.title)
})
}
export function tvEpisodesFromDb(
libraryId: string,
seriesId: string,
seasonId: string
): TvEpisode[] {
const db = getDb()
const parentKey = `${libraryId}:tv_season:${seriesId}:${seasonId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_episode'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_episode:{seriesId}:{seasonId}:{episodeId}
const suffix = row.item_key.split(':tv_episode:')[1] ?? ''
const episodeId = suffix.split(':').slice(2).join(':')
return {
id: episodeId,
title: row.title ?? decodeURIComponent(episodeId),
episodeNumber: meta.episodeNumber ?? null,
seasonNumber: meta.seasonNumber ?? null,
plot: row.plot ?? null,
aired: meta.aired ?? null,
rating: meta.rating ?? null,
thumbnailUrl: meta.thumbnailUrl ?? null,
videoPath: row.file_path ?? '',
}
})
.sort((a, b) => {
if (a.episodeNumber !== null && b.episodeNumber !== null) {
return a.episodeNumber - b.episodeNumber
}
return (a.title ?? '').localeCompare(b.title ?? '')
})
}