scan fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s

This commit is contained in:
Garret Patti
2026-04-20 20:31:18 -04:00
parent 8f8f8c3001
commit bd028a7a5d
7 changed files with 449 additions and 24 deletions

View File

@@ -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<NextResponse> {
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 }
)
}
}

View File

@@ -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<NextResponse> {
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 }
)
}
}

View File

@@ -22,7 +22,7 @@ const TYPE_LABELS: Record<LibraryType, string> = {
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ManagePage() {
function ManagePage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
@@ -111,6 +111,8 @@ function LibraryRow({
const [showBulkRename, setShowBulkRename] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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)
@@ -246,6 +268,22 @@ function LibraryRow({
</button>
</>
)}
{(library.type === 'tv' || library.type === 'movies') && (
<button
onClick={() => setShowImportWarning(true)}
disabled={importingMetadata}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => {
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{importingMetadata ? 'Importing…' : 'Import Metadata'}
</button>
)}
{library.coverExt && (
<button
onClick={handleRemoveCover}
@@ -296,6 +334,13 @@ function LibraryRow({
{showBulkRename && (
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
)}
{showImportWarning && (library.type === 'tv' || library.type === 'movies') && (
<ImportWarningModal
libraryType={library.type}
onConfirm={handleImportMetadata}
onCancel={() => setShowImportWarning(false)}
/>
)}
</div>
)
}
@@ -658,3 +703,57 @@ function LoadingRows() {
</div>
)
}
function ImportWarningModal({
libraryType,
onConfirm,
onCancel,
}: {
libraryType: 'tv' | 'movies'
onConfirm: () => void
onCancel: () => void
}) {
const label = libraryType === 'tv' ? 'TV' : 'Movie'
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onCancel}
>
<div
className="w-full max-w-md rounded-2xl border p-5"
style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Import {label} Metadata
</h3>
<p className="text-sm mb-5" style={{ color: 'var(--text-secondary)' }}>
Full metadata import will refresh metadata for ALL items in this library, overwriting any
existing data. Continue?
</p>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Import
</button>
</div>
</div>
</div>
)
}
export default ManagePage

View File

@@ -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
}

103
src/lib/movie-metadata.ts Normal file
View File

@@ -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<void>((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
}

View File

@@ -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

142
src/lib/tv-metadata.ts Normal file
View File

@@ -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<void>((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
}