From 819748d1ffb16bcc5849e40139227cf2ec9e8633 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:20:21 -0400 Subject: [PATCH] DB-first library reads, mixed library indexing, and manual NFO refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/api/games/route.ts | 8 +- src/app/api/movies/route.ts | 6 +- src/app/api/nfo-refresh/route.ts | 159 +++++++++++++++++++++ src/app/api/tv/route.ts | 13 +- src/components/movies/MovieDetailModal.tsx | 26 +++- src/components/movies/MoviesView.tsx | 1 + src/components/tv/TvView.tsx | 24 ++++ src/lib/db.ts | 51 ++++++- src/lib/games.ts | 62 ++++++++ src/lib/movies.ts | 56 ++++++-- src/lib/scanner.ts | 148 ++++++++++++++----- src/lib/tv.ts | 137 +++++++++++++++--- 12 files changed, 597 insertions(+), 94 deletions(-) create mode 100644 src/app/api/nfo-refresh/route.ts diff --git a/src/app/api/games/route.ts b/src/app/api/games/route.ts index 54ae4ad..9098f78 100644 --- a/src/app/api/games/route.ts +++ b/src/app/api/games/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' -import { scanGamesLibrary } from '@/lib/games' +import { getLibrary } from '@/lib/libraries' +import { gamesFromDb } from '@/lib/games' import { requireLibraryAccess } from '@/lib/auth' export async function GET(request: NextRequest) { @@ -22,7 +22,5 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 }) } - const root = resolveLibraryRoot(library) - const games = scanGamesLibrary(root, libraryId) - return NextResponse.json(games) + return NextResponse.json(gamesFromDb(libraryId)) } diff --git a/src/app/api/movies/route.ts b/src/app/api/movies/route.ts index 74caf9f..12ba6e9 100644 --- a/src/app/api/movies/route.ts +++ b/src/app/api/movies/route.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' -import { scanMoviesLibrary } from '@/lib/movies' +import { moviesFromDb } from '@/lib/movies' import { removeAllAssignmentsForItem } from '@/lib/tags' import { requireLibraryAccess, requireAdmin } from '@/lib/auth' @@ -25,9 +25,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 }) } - const root = resolveLibraryRoot(library) - const movies = scanMoviesLibrary(root, libraryId) - return NextResponse.json(movies) + return NextResponse.json(moviesFromDb(libraryId)) } export async function DELETE(request: NextRequest) { diff --git a/src/app/api/nfo-refresh/route.ts b/src/app/api/nfo-refresh/route.ts new file mode 100644 index 0000000..576f4a1 --- /dev/null +++ b/src/app/api/nfo-refresh/route.ts @@ -0,0 +1,159 @@ +import path from 'path' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' +import { requireAdmin } from '@/lib/auth' +import { getDb } from '@/lib/db' +import { findNfoFile } from '@/lib/movies' +import { parseMovieNfo, parseTvShowNfo, parseEpisodeNfo } from '@/lib/nfo' + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const itemType = searchParams.get('itemType') as 'movie' | 'tv_series' | 'tv_episode' | null + const itemKey = searchParams.get('itemKey') + + if (!libraryId || !itemType || !itemKey) { + return NextResponse.json({ error: 'Missing libraryId, itemType, or itemKey' }, { status: 400 }) + } + + if (!['movie', 'tv_series', 'tv_episode'].includes(itemType)) { + return NextResponse.json({ error: 'itemType must be movie, tv_series, or tv_episode' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + + const db = getDb() + const row = db + .prepare('SELECT * FROM media_items WHERE item_key = ?') + .get(itemKey) as { + item_key: string + item_type: string + title: string | null + year: number | null + plot: string | null + genres: string | null + metadata: string | null + file_path: string | null + } | undefined + + if (!row) { + return NextResponse.json({ error: 'Item not found in database' }, { status: 404 }) + } + + const libraryRoot = resolveLibraryRoot(library) + const existingMeta = row.metadata ? JSON.parse(row.metadata) : {} + + if (itemType === 'movie') { + // item_key: {libraryId}:movie:{encodedDirName} + const encodedDirName = itemKey.split(':movie:')[1] + if (!encodedDirName) { + return NextResponse.json({ error: 'Invalid item key' }, { status: 400 }) + } + const dirName = decodeURIComponent(encodedDirName) + const movieDir = path.join(libraryRoot, dirName) + const nfoFileName = findNfoFile(movieDir, dirName) + if (!nfoFileName) { + return NextResponse.json({ updated: false, reason: 'no nfo found' }) + } + const nfo = parseMovieNfo(path.join(movieDir, nfoFileName)) + if (!nfo) { + return NextResponse.json({ updated: false, reason: 'nfo parse failed' }) + } + db.prepare(` + UPDATE media_items SET + title = @title, + year = @year, + plot = @plot, + genres = @genres, + metadata = @metadata + WHERE item_key = @item_key + `).run({ + item_key: itemKey, + title: nfo.title ?? row.title, + year: nfo.year ?? null, + plot: nfo.plot ?? null, + genres: JSON.stringify(nfo.genres ?? []), + metadata: JSON.stringify({ + ...existingMeta, + rating: nfo.rating ?? null, + runtime: nfo.runtime ?? null, + }), + }) + return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year }) + } + + if (itemType === 'tv_series') { + // item_key: {libraryId}:tv_series:{encodedDirName} + const encodedDirName = itemKey.split(':tv_series:')[1] + if (!encodedDirName) { + return NextResponse.json({ error: 'Invalid item key' }, { status: 400 }) + } + const dirName = decodeURIComponent(encodedDirName) + const seriesDir = path.join(libraryRoot, dirName) + const nfoPath = path.join(seriesDir, 'tvshow.nfo') + const nfo = parseTvShowNfo(nfoPath) + if (!nfo) { + return NextResponse.json({ updated: false, reason: 'no nfo found' }) + } + db.prepare(` + UPDATE media_items SET + title = @title, + year = @year, + plot = @plot, + genres = @genres, + metadata = @metadata + WHERE item_key = @item_key + `).run({ + item_key: itemKey, + title: nfo.title ?? row.title, + year: nfo.year ?? null, + plot: nfo.plot ?? null, + genres: JSON.stringify(nfo.genres ?? []), + metadata: JSON.stringify({ + ...existingMeta, + status: nfo.status ?? null, + }), + }) + return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year }) + } + + if (itemType === 'tv_episode') { + if (!row.file_path) { + return NextResponse.json({ updated: false, reason: 'no file_path in database' }) + } + const episodeDir = path.join(libraryRoot, path.dirname(row.file_path)) + const baseName = path.basename(row.file_path, path.extname(row.file_path)) + const nfoPath = path.join(episodeDir, `${baseName}.nfo`) + const nfo = parseEpisodeNfo(nfoPath) + if (!nfo) { + return NextResponse.json({ updated: false, reason: 'no nfo found' }) + } + db.prepare(` + UPDATE media_items SET + title = @title, + plot = @plot, + metadata = @metadata + WHERE item_key = @item_key + `).run({ + item_key: itemKey, + title: nfo.title ?? row.title, + plot: nfo.plot ?? null, + metadata: JSON.stringify({ + ...existingMeta, + episodeNumber: nfo.episode ?? existingMeta.episodeNumber ?? null, + seasonNumber: nfo.season ?? existingMeta.seasonNumber ?? null, + aired: nfo.aired ?? null, + rating: nfo.rating ?? null, + }), + }) + return NextResponse.json({ updated: true, title: nfo.title }) + } + + return NextResponse.json({ error: 'Unhandled itemType' }, { status: 400 }) +} diff --git a/src/app/api/tv/route.ts b/src/app/api/tv/route.ts index 4e14839..a297ce9 100644 --- a/src/app/api/tv/route.ts +++ b/src/app/api/tv/route.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' -import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv' +import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv' import { removeAllAssignmentsForItem } from '@/lib/tags' import { requireLibraryAccess, requireAdmin } from '@/lib/auth' @@ -27,20 +27,15 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 }) } - const root = resolveLibraryRoot(library) - if (seriesId && seasonId) { - const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId) - return NextResponse.json(episodes) + return NextResponse.json(tvEpisodesFromDb(libraryId, seriesId, seasonId)) } if (seriesId) { - const seasons = scanTvSeasons(root, libraryId, seriesId) - return NextResponse.json(seasons) + return NextResponse.json(tvSeasonsFromDb(libraryId, seriesId)) } - const series = scanTvLibrary(root, libraryId) - return NextResponse.json(series) + return NextResponse.json(tvSeriesFromDb(libraryId)) } export async function DELETE(request: NextRequest) { diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index a45dc2d..2abe176 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -13,15 +13,17 @@ interface Props { onNext?: () => void onTagsChanged?: () => void onDeleted: (movieId: string) => void + onMetadataRefreshed?: () => void } -export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted }: Props) { +export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) { const overlayRef = useRef(null) const menuRef = useRef(null) const [playing, setPlaying] = useState(false) const [menuOpen, setMenuOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) + const [refreshing, setRefreshing] = useState(false) useEffect(() => { const handleKey = (e: KeyboardEvent) => { @@ -66,6 +68,18 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on .catch(() => setDeleting(false)) } + const handleRefreshMetadata = () => { + setRefreshing(true) + setMenuOpen(false) + const itemKey = `${libraryId}:movie:${movie.id}` + fetch( + `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`, + { method: 'POST' } + ) + .then(() => onMetadataRefreshed?.()) + .finally(() => setRefreshing(false)) + } + if (playing) { return ( +