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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
159
src/app/api/nfo-refresh/route.ts
Normal file
159
src/app/api/nfo-refresh/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user