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:
137
src/lib/tv.ts
137
src/lib/tv.ts
@@ -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 ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user