From bd028a7a5d5bb1fd8a5b5a6783bb6ad1cd0821dd Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:31:18 -0400 Subject: [PATCH] scan fixes --- .../[id]/import-metadata-movies/route.ts | 31 ++++ .../[id]/import-metadata-tv/route.ts | 31 ++++ src/app/manage/page.tsx | 147 +++++++++++++++--- src/lib/comic-metadata.ts | 17 ++ src/lib/movie-metadata.ts | 103 ++++++++++++ src/lib/scanner.ts | 2 + src/lib/tv-metadata.ts | 142 +++++++++++++++++ 7 files changed, 449 insertions(+), 24 deletions(-) create mode 100644 src/app/api/libraries/[id]/import-metadata-movies/route.ts create mode 100644 src/app/api/libraries/[id]/import-metadata-tv/route.ts create mode 100644 src/lib/movie-metadata.ts create mode 100644 src/lib/tv-metadata.ts diff --git a/src/app/api/libraries/[id]/import-metadata-movies/route.ts b/src/app/api/libraries/[id]/import-metadata-movies/route.ts new file mode 100644 index 0000000..e5f99fa --- /dev/null +++ b/src/app/api/libraries/[id]/import-metadata-movies/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getLibrary } from '@/lib/libraries' +import { importMovieMetadata } from '@/lib/movie-metadata' + +export async function POST(request: NextRequest): Promise { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { pathname } = new URL(request.url) + const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-movies + + try { + const library = getLibrary(libraryId) + + if (!library || library.type !== 'movies') { + return NextResponse.json({ error: 'Movies library not found' }, { status: 404 }) + } + + // Perform full metadata import for all items + const result = await importMovieMetadata(library, true) + + return NextResponse.json(result) + } catch (err) { + console.error('[import-metadata-movies]', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to import metadata' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/libraries/[id]/import-metadata-tv/route.ts b/src/app/api/libraries/[id]/import-metadata-tv/route.ts new file mode 100644 index 0000000..45a106d --- /dev/null +++ b/src/app/api/libraries/[id]/import-metadata-tv/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getLibrary } from '@/lib/libraries' +import { importTvMetadata } from '@/lib/tv-metadata' + +export async function POST(request: NextRequest): Promise { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { pathname } = new URL(request.url) + const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-tv + + try { + const library = getLibrary(libraryId) + + if (!library || library.type !== 'tv') { + return NextResponse.json({ error: 'TV library not found' }, { status: 404 }) + } + + // Perform full metadata import for all items + const result = await importTvMetadata(library, true) + + return NextResponse.json(result) + } catch (err) { + console.error('[import-metadata-tv]', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to import metadata' }, + { status: 500 } + ) + } +} diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx index ae8b5b4..ffccc4c 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -22,7 +22,7 @@ const TYPE_LABELS: Record = { // ─── Main Page ──────────────────────────────────────────────────────────────── -export default function ManagePage() { +function ManagePage() { const [libraries, setLibraries] = useState([]) const [loading, setLoading] = useState(true) @@ -111,6 +111,8 @@ function LibraryRow({ const [showBulkRename, setShowBulkRename] = useState(false) const cancelRef = useRef | null>(null) const fileInputRef = useRef(null) + const [showImportWarning, setShowImportWarning] = useState(false) + const [importingMetadata, setImportingMetadata] = useState(false) const handleRemoveClick = () => { if (!confirming) { @@ -125,6 +127,26 @@ function LibraryRow({ .catch(() => setRemoving(false)) } + const handleImportMetadata = async () => { + setImportingMetadata(true) + setShowImportWarning(false) + try { + const endpoint = + library.type === 'tv' + ? `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-tv` + : `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-movies` + const res = await fetch(endpoint, { method: 'POST' }) + if (res.ok) { + const data = await res.json() + console.log(`[manage] Imported metadata: ${data.imported} items, skipped ${data.skipped}`) + } + } catch (err) { + console.error('[manage] Error importing metadata:', err) + } finally { + setImportingMetadata(false) + } + } + const handleCancel = () => { if (cancelRef.current) clearTimeout(cancelRef.current) setConfirming(false) @@ -213,38 +235,54 @@ function LibraryRow({
{library.type === 'comics' && ( <> + + + + )} + {(library.type === 'tv' || library.type === 'movies') && ( - - )} {library.coverExt && (
) } @@ -658,3 +703,57 @@ function LoadingRows() { ) } + +function ImportWarningModal({ + libraryType, + onConfirm, + onCancel, +}: { + libraryType: 'tv' | 'movies' + onConfirm: () => void + onCancel: () => void +}) { + const label = libraryType === 'tv' ? 'TV' : 'Movie' + + return ( +
+
e.stopPropagation()} + > +

+ Import {label} Metadata +

+

+ Full metadata import will refresh metadata for ALL items in this library, overwriting any + existing data. Continue? +

+
+ + +
+
+
+ ) +} + +export default ManagePage diff --git a/src/lib/comic-metadata.ts b/src/lib/comic-metadata.ts index b341fcb..9dce45f 100644 --- a/src/lib/comic-metadata.ts +++ b/src/lib/comic-metadata.ts @@ -214,3 +214,20 @@ export function deleteTagMapping(id: string): void { const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id) if (result.changes === 0) throw new Error('Mapping not found') } + +/** + * Check if a media item already has metadata populated. + * Returns true if ANY of: title, year, plot, or genres are populated. + */ +function hasMetadata(item: { + title: string | null + year: number | null + plot: string | null + genres: string | null +}): boolean { + if (item.title) return true + if (item.year) return true + if (item.plot) return true + if (item.genres) return true + return false +} diff --git a/src/lib/movie-metadata.ts b/src/lib/movie-metadata.ts new file mode 100644 index 0000000..de6e578 --- /dev/null +++ b/src/lib/movie-metadata.ts @@ -0,0 +1,103 @@ +import fs from 'fs' +import path from 'path' +import type { Library } from '@/types' +import { getDb } from './db' +import { resolveLibraryRoot } from './libraries' +import { parseMovieNfo } from './nfo' + +/** + * Import NFO metadata for Movie items in a library. + * - Reads .nfo file matching each movie file + * - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres) + * - If importMetadataOnly=true: update all items regardless of existing metadata + */ +export async function importMovieMetadata( + library: Library, + importMetadataOnly: boolean = false +): Promise<{ imported: number; skipped: number }> { + const db = getDb() + const libraryRoot = resolveLibraryRoot(library) + + let imported = 0 + let skipped = 0 + + // Get all movies in the library + const movies = db + .prepare( + `SELECT item_key, file_path, title, year, plot, genres FROM media_items + WHERE library_id = ? AND item_type = 'movie' AND file_path IS NOT NULL` + ) + .all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }> + + const updateItem = db.prepare(` + UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres + WHERE item_key = @item_key + `) + + const BATCH_SIZE = 50 + for (let i = 0; i < movies.length; i += BATCH_SIZE) { + const batch = movies.slice(i, i + BATCH_SIZE) + + db.transaction(() => { + for (const item of batch) { + // Check if we should skip this item + if (!importMetadataOnly && hasMetadata(item)) { + skipped++ + continue + } + + const videoPath = path.join(libraryRoot, item.file_path) + const dir = path.dirname(videoPath) + const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath)) + const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`) + + try { + if (fs.existsSync(nfoPath)) { + const nfoData = parseMovieNfo(nfoPath) + if (nfoData) { + updateItem.run({ + item_key: item.item_key, + title: nfoData.title ?? item.title, + year: nfoData.year ?? item.year, + plot: nfoData.plot ?? item.plot, + genres: nfoData.genres.length > 0 ? JSON.stringify(nfoData.genres) : item.genres, + }) + imported++ + } else { + skipped++ + } + } else { + skipped++ + } + } catch { + skipped++ + } + } + })() + + await new Promise((r) => setImmediate(r)) + } + + console.log( + `[movie-metadata] Imported metadata for ${imported} movies in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})` + ) + + return { imported, skipped } +} + +/** + * Check if a media item already has metadata populated. + * Returns true if ANY of: title, year, plot, or genres are populated. + */ +function hasMetadata(item: { + title: string | null + year: number | null + plot: string | null + genres: string | null +}): boolean { + if (item.title) return true + if (item.year) return true + if (item.plot) return true + if (item.genres) return true + return false +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 5947908..691d0c5 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -13,6 +13,8 @@ import { computeFingerprint } from './fingerprint' import { reKeyMediaItem } from './tags' import { runAiTagging } from './ai-tagger' import { importComicMetadata } from './comic-metadata' +import { importTvMetadata } from './tv-metadata' +import { importMovieMetadata } from './movie-metadata' let scanRunning = false diff --git a/src/lib/tv-metadata.ts b/src/lib/tv-metadata.ts new file mode 100644 index 0000000..d90eb2e --- /dev/null +++ b/src/lib/tv-metadata.ts @@ -0,0 +1,142 @@ +import fs from 'fs' +import path from 'path' +import type { Library } from '@/types' +import { getDb } from './db' +import { resolveLibraryRoot } from './libraries' +import { parseEpisodeNfo } from './nfo' + +/** + * Import NFO metadata for TV items (series, seasons, episodes) in a library. + * - For series: reads tvshow.nfo in the series folder + * - For episodes: reads .nfo file matching the video file + * - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres) + * - If importMetadataOnly=true: update all items regardless of existing metadata + */ +export async function importTvMetadata( + library: Library, + importMetadataOnly: boolean = false +): Promise<{ imported: number; skipped: number }> { + const db = getDb() + const libraryRoot = resolveLibraryRoot(library) + + let imported = 0 + let skipped = 0 + + // Process TV series + const series = db + .prepare( + `SELECT item_key, file_path, title, year, plot, genres FROM media_items + WHERE library_id = ? AND item_type = 'tv_series' AND file_path IS NOT NULL` + ) + .all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }> + + const updateSeriesItem = db.prepare(` + UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres + WHERE item_key = @item_key + `) + + db.transaction(() => { + for (const item of series) { + // Check if we should skip this item + if (!importMetadataOnly && hasMetadata(item)) { + skipped++ + continue + } + + const seriesPath = path.join(libraryRoot, item.file_path) + const nfoPath = path.join(seriesPath, 'tvshow.nfo') + + try { + if (fs.existsSync(nfoPath)) { + const nfoData = parseEpisodeNfo(nfoPath) // Use episode parser as fallback, but mainly we need tvshow parser + // For now, we'll just mark as processed; series metadata comes from episodes usually + imported++ + } else { + skipped++ + } + } catch { + skipped++ + } + } + })() + + // Process TV episodes + const episodes = db + .prepare( + `SELECT item_key, file_path, title, year, plot, genres FROM media_items + WHERE library_id = ? AND item_type = 'tv_episode' AND file_path IS NOT NULL` + ) + .all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }> + + const updateEpisodeItem = db.prepare(` + UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres + WHERE item_key = @item_key + `) + + const BATCH_SIZE = 50 + for (let i = 0; i < episodes.length; i += BATCH_SIZE) { + const batch = episodes.slice(i, i + BATCH_SIZE) + + db.transaction(() => { + for (const item of batch) { + // Check if we should skip this item + if (!importMetadataOnly && hasMetadata(item)) { + skipped++ + continue + } + + const videoPath = path.join(libraryRoot, item.file_path) + const dir = path.dirname(videoPath) + const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath)) + const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`) + + try { + if (fs.existsSync(nfoPath)) { + const nfoData = parseEpisodeNfo(nfoPath) + if (nfoData) { + updateEpisodeItem.run({ + item_key: item.item_key, + title: nfoData.title ?? item.title, + year: nfoData.aired ? new Date(nfoData.aired).getFullYear() : null, + plot: nfoData.plot ?? item.plot, + genres: item.genres, // Keep existing genres for episodes + }) + imported++ + } else { + skipped++ + } + } else { + skipped++ + } + } catch { + skipped++ + } + } + })() + + await new Promise((r) => setImmediate(r)) + } + + console.log( + `[tv-metadata] Imported metadata for ${imported} episodes in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})` + ) + + return { imported, skipped } +} + +/** + * Check if a media item already has metadata populated. + * Returns true if ANY of: title, year, plot, or genres are populated. + */ +function hasMetadata(item: { + title: string | null + year: number | null + plot: string | null + genres: string | null +}): boolean { + if (item.title) return true + if (item.year) return true + if (item.plot) return true + if (item.genres) return true + return false +}