This commit is contained in:
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
103
src/lib/movie-metadata.ts
Normal 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
|
||||
}
|
||||
@@ -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
142
src/lib/tv-metadata.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user