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:
Garret Patti
2026-04-06 18:20:21 -04:00
parent 01a4a1c0b7
commit 819748d1ff
12 changed files with 597 additions and 94 deletions

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' import { getLibrary } from '@/lib/libraries'
import { scanGamesLibrary } from '@/lib/games' import { gamesFromDb } from '@/lib/games'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
export async function GET(request: NextRequest) { 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 }) return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
} }
const root = resolveLibraryRoot(library) return NextResponse.json(gamesFromDb(libraryId))
const games = scanGamesLibrary(root, libraryId)
return NextResponse.json(games)
} }

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanMoviesLibrary } from '@/lib/movies' import { moviesFromDb } from '@/lib/movies'
import { removeAllAssignmentsForItem } from '@/lib/tags' import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth' 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 }) return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
} }
const root = resolveLibraryRoot(library) return NextResponse.json(moviesFromDb(libraryId))
const movies = scanMoviesLibrary(root, libraryId)
return NextResponse.json(movies)
} }
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {

View 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 })
}

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' 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 { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth' 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 }) return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
} }
const root = resolveLibraryRoot(library)
if (seriesId && seasonId) { if (seriesId && seasonId) {
const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId) return NextResponse.json(tvEpisodesFromDb(libraryId, seriesId, seasonId))
return NextResponse.json(episodes)
} }
if (seriesId) { if (seriesId) {
const seasons = scanTvSeasons(root, libraryId, seriesId) return NextResponse.json(tvSeasonsFromDb(libraryId, seriesId))
return NextResponse.json(seasons)
} }
const series = scanTvLibrary(root, libraryId) return NextResponse.json(tvSeriesFromDb(libraryId))
return NextResponse.json(series)
} }
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {

View File

@@ -13,15 +13,17 @@ interface Props {
onNext?: () => void onNext?: () => void
onTagsChanged?: () => void onTagsChanged?: () => void
onDeleted: (movieId: string) => 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<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -66,6 +68,18 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
.catch(() => setDeleting(false)) .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) { if (playing) {
return ( return (
<VideoPlayerModal <VideoPlayerModal
@@ -175,6 +189,16 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max" className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> >
<button
onClick={handleRefreshMetadata}
disabled={refreshing}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button <button
onClick={() => { setMenuOpen(false); setConfirming(true) }} onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors" className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"

View File

@@ -205,6 +205,7 @@ export default function MoviesView({ libraryId }: Props) {
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined} onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onDeleted={handleDeleted} onDeleted={handleDeleted}
onMetadataRefreshed={fetchMovies}
/> />
)} )}
</div> </div>

View File

@@ -35,6 +35,7 @@ export default function TvView({ libraryId }: Props) {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [refreshingMeta, setRefreshingMeta] = useState(false)
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([]) const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false)
@@ -149,6 +150,19 @@ export default function TvView({ libraryId }: Props) {
.catch(() => setDeleting(false)) .catch(() => setDeleting(false))
} }
const handleRefreshSeriesMetadata = () => {
if (!selectedSeries) return
setRefreshingMeta(true)
setMenuOpen(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
{ method: 'POST' }
)
.then(() => fetchSeries())
.finally(() => setRefreshingMeta(false))
}
const handleDoomScroll = async () => { const handleDoomScroll = async () => {
setDoomScrollLoading(true) setDoomScrollLoading(true)
try { try {
@@ -415,6 +429,16 @@ export default function TvView({ libraryId }: Props) {
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max" className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
> >
<button
onClick={handleRefreshSeriesMetadata}
disabled={refreshingMeta}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button <button
onClick={() => { setMenuOpen(false); setConfirming(true) }} onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors" className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"

View File

@@ -81,13 +81,14 @@ function initDb(db: Database.Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
item_key TEXT NOT NULL UNIQUE, item_key TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series')), item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series','mixed_file')),
parent_key TEXT, parent_key TEXT,
title TEXT, title TEXT,
year INTEGER, year INTEGER,
plot TEXT, plot TEXT,
genres TEXT, genres TEXT,
metadata TEXT, metadata TEXT,
file_path TEXT,
scanned_at INTEGER NOT NULL scanned_at INTEGER NOT NULL
); );
@@ -96,6 +97,7 @@ function initDb(db: Database.Database): void {
`) `)
migrateLibrariesType(db) migrateLibrariesType(db)
migrateMediaItemsSchema(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -113,6 +115,53 @@ function seedAppSettings(db: Database.Database): void {
} }
} }
function migrateMediaItemsSchema(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
.get() as { sql: string } | undefined
if (!row) return
const needsFilePath = !row.sql.includes('file_path')
const needsMixedFile = !row.sql.includes("'mixed_file'")
if (!needsFilePath && !needsMixedFile) return
// Determine whether the current table already has file_path (partial migration)
const hasFilePath = !needsFilePath ? 'file_path,' : 'NULL as file_path,'
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE media_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
item_key TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL CHECK(item_type IN (
'movie','tv_series','tv_season','tv_episode',
'game','game_series','mixed_file')),
parent_key TEXT,
title TEXT,
year INTEGER,
plot TEXT,
genres TEXT,
metadata TEXT,
file_path TEXT,
scanned_at INTEGER NOT NULL
);
INSERT INTO media_items_new
SELECT id, library_id, item_key, item_type, parent_key,
title, year, plot, genres, metadata,
${hasFilePath}
scanned_at
FROM media_items;
DROP TABLE media_items;
ALTER TABLE media_items_new RENAME TO media_items;
CREATE INDEX media_items_library_id ON media_items(library_id);
CREATE INDEX media_items_parent_key ON media_items(parent_key);
COMMIT;
`)
}
function migrateLibrariesType(db: Database.Database): void { function migrateLibrariesType(db: Database.Database): void {
const row = db const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'") .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")

View File

@@ -1,6 +1,7 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Game, GameSeries } from '@/types' import type { Game, GameSeries } from '@/types'
import { getDb } from './db'
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
/** /**
@@ -119,3 +120,64 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game
return results.sort((a, b) => a.title.localeCompare(b.title)) return results.sort((a, b) => a.title.localeCompare(b.title))
} }
export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
const db = getDb()
type DbRow = {
item_key: string
item_type: string
parent_key: string | null
title: string | null
metadata: string | null
}
const allRows = db
.prepare(`SELECT item_key, item_type, parent_key, title, metadata
FROM media_items
WHERE library_id = ? AND item_type IN ('game', 'game_series')
ORDER BY title`)
.all(libraryId) as DbRow[]
const seriesMap = new Map<string, GameSeries>()
const standaloneGames: Game[] = []
// First pass: build series
for (const row of allRows) {
if (row.item_type !== 'game_series') continue
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key
seriesMap.set(row.item_key, {
id: idPart,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null,
games: [],
})
}
// Second pass: attach games
for (const row of allRows) {
if (row.item_type !== 'game') continue
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
const game: Game = {
id: idPart,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null,
zipFiles: meta.zipFiles ?? [],
}
if (row.parent_key && seriesMap.has(row.parent_key)) {
seriesMap.get(row.parent_key)!.games.push(game)
} else {
standaloneGames.push(game)
}
}
const results: (Game | GameSeries)[] = [
...Array.from(seriesMap.values()),
...standaloneGames,
]
return results.sort((a, b) => a.title.localeCompare(b.title))
}

View File

@@ -1,7 +1,7 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Movie } from '@/types' import type { Movie } from '@/types'
import { parseMovieNfo } from './nfo' import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
function findVideoFile(dir: string): string | null { function findVideoFile(dir: string): string | null {
@@ -16,7 +16,7 @@ function findVideoFile(dir: string): string | null {
) ?? null ) ?? null
} }
function findNfoFile(dir: string, dirName: string): string | null { export function findNfoFile(dir: string, dirName: string): string | null {
// Try {dirName}.nfo first, then movie.nfo, then any .nfo // Try {dirName}.nfo first, then movie.nfo, then any .nfo
const candidates = [`${dirName}.nfo`, 'movie.nfo'] const candidates = [`${dirName}.nfo`, 'movie.nfo']
let entries: string[] let entries: string[]
@@ -26,9 +26,8 @@ function findNfoFile(dir: string, dirName: string): string | null {
return null return null
} }
for (const candidate of candidates) { for (const candidate of candidates) {
if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) { const match = entries.find((e) => e.toLowerCase() === candidate.toLowerCase())
return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())! if (match) return match
}
} }
return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null
} }
@@ -52,9 +51,6 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
const videoFile = findVideoFile(moviePath) const videoFile = findVideoFile(moviePath)
if (!videoFile) continue if (!videoFile) continue
const nfoFile = findNfoFile(moviePath, dirName)
const nfo = nfoFile ? parseMovieNfo(path.join(moviePath, nfoFile)) : null
const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i) const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i)
const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i) const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i)
@@ -63,12 +59,12 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
movies.push({ movies.push({
id, id,
title: nfo?.title ?? dirName, title: dirName,
year: nfo?.year ?? null, year: null,
plot: nfo?.plot ?? null, plot: null,
rating: nfo?.rating ?? null, rating: null,
genres: nfo?.genres ?? [], genres: [],
runtime: nfo?.runtime ?? null, runtime: null,
posterUrl: posterFile posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null, : null,
@@ -81,3 +77,35 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
return movies.sort((a, b) => a.title.localeCompare(b.title)) return movies.sort((a, b) => a.title.localeCompare(b.title))
} }
export function moviesFromDb(libraryId: string): Movie[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'movie' ORDER BY title`)
.all(libraryId) as Array<{
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}>
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':movie:')[1] ?? row.item_key
return {
id: idPart,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
rating: meta.rating ?? null,
genres: row.genres ? JSON.parse(row.genres) : [],
runtime: meta.runtime ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
videoPath: row.file_path ?? '',
}
})
}

View File

@@ -71,14 +71,15 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
clearLibraryItems(db, library.id) clearLibraryItems(db, library.id)
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title, title = excluded.title,
year = excluded.year, year = excluded.year,
plot = excluded.plot, plot = excluded.plot,
genres = excluded.genres, genres = excluded.genres,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
@@ -92,7 +93,13 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
year: movie.year ?? null, year: movie.year ?? null,
plot: movie.plot ?? null, plot: movie.plot ?? null,
genres: JSON.stringify(movie.genres), genres: JSON.stringify(movie.genres),
metadata: JSON.stringify({ rating: movie.rating, runtime: movie.runtime }), metadata: JSON.stringify({
rating: movie.rating,
runtime: movie.runtime,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
}),
file_path: movie.videoPath,
scanned_at: now, scanned_at: now,
}) })
@@ -117,20 +124,21 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
clearLibraryItems(db, library.id) clearLibraryItems(db, library.id)
const upsertSeries = db.prepare(` const upsertSeries = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title, title = excluded.title,
year = excluded.year, year = excluded.year,
plot = excluded.plot, plot = excluded.plot,
genres = excluded.genres, genres = excluded.genres,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
const upsertChild = db.prepare(` const upsertChild = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key, parent_key = excluded.parent_key,
title = excluded.title, title = excluded.title,
@@ -138,6 +146,7 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
plot = excluded.plot, plot = excluded.plot,
genres = excluded.genres, genres = excluded.genres,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
@@ -153,7 +162,13 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
year: show.year ?? null, year: show.year ?? null,
plot: show.plot ?? null, plot: show.plot ?? null,
genres: JSON.stringify(show.genres), genres: JSON.stringify(show.genres),
metadata: JSON.stringify({ status: show.status, seasonCount: show.seasonCount }), metadata: JSON.stringify({
status: show.status,
seasonCount: show.seasonCount,
posterUrl: show.posterUrl,
backdropUrl: show.backdropUrl,
}),
file_path: null,
scanned_at: now, scanned_at: now,
}) })
@@ -173,7 +188,12 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
year: null, year: null,
plot: null, plot: null,
genres: JSON.stringify([]), genres: JSON.stringify([]),
metadata: JSON.stringify({ seasonNumber: season.seasonNumber, episodeCount: season.episodeCount }), metadata: JSON.stringify({
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount,
posterUrl: season.posterUrl,
}),
file_path: null,
scanned_at: now, scanned_at: now,
}) })
@@ -198,7 +218,9 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
seasonNumber: episode.seasonNumber, seasonNumber: episode.seasonNumber,
aired: episode.aired, aired: episode.aired,
rating: episode.rating, rating: episode.rating,
thumbnailUrl: episode.thumbnailUrl,
}), }),
file_path: episode.videoPath,
scanned_at: now, scanned_at: now,
}) })
@@ -229,21 +251,23 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
clearLibraryItems(db, library.id) clearLibraryItems(db, library.id)
const upsertGame = db.prepare(` const upsertGame = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title, title = excluded.title,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
const upsertChildGame = db.prepare(` const upsertChildGame = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, scanned_at) INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @scanned_at) VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key, parent_key = excluded.parent_key,
title = excluded.title, title = excluded.title,
metadata = excluded.metadata, metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
@@ -259,7 +283,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_key: seriesKey, item_key: seriesKey,
item_type: 'game_series', item_type: 'game_series',
title: series.title, title: series.title,
metadata: JSON.stringify({ gameCount: series.games.length }), metadata: JSON.stringify({
gameCount: series.games.length,
coverUrl: series.coverUrl,
wideCoverUrl: series.wideCoverUrl,
}),
file_path: null,
scanned_at: now, scanned_at: now,
}) })
@@ -275,7 +304,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_type: 'game', item_type: 'game',
parent_key: seriesKey, parent_key: seriesKey,
title: game.title, title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }), metadata: JSON.stringify({
zipFiles: game.zipFiles,
coverUrl: game.coverUrl,
wideCoverUrl: game.wideCoverUrl,
}),
file_path: null,
scanned_at: now, scanned_at: now,
}) })
@@ -293,7 +327,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
item_key: gameKey, item_key: gameKey,
item_type: 'game', item_type: 'game',
title: game.title, title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }), metadata: JSON.stringify({
zipFiles: game.zipFiles,
coverUrl: game.coverUrl,
wideCoverUrl: game.wideCoverUrl,
}),
file_path: null,
scanned_at: now, scanned_at: now,
}) })
@@ -308,38 +347,69 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mixed (thumbnail pre-generation only — no DB indexing) // Mixed
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function scanMixed(library: Library, libraryRoot: string): Promise<void> { async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
const fs = await import('fs') const fsSync = await import('fs') as typeof import('fs')
let entries: string[] const db = getDb()
const now = Date.now()
clearLibraryItems(db, library.id)
const upsert = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
let fileCount = 0
function walk(absDir: string, relDir: string): void {
let dirents: import('fs').Dirent[]
try { try {
entries = fs.readdirSync(libraryRoot, { withFileTypes: true }) dirents = fsSync.readdirSync(absDir, { withFileTypes: true, encoding: 'utf-8' }) as import('fs').Dirent[]
.filter((d) => d.isFile() && !d.name.startsWith('.'))
.map((d) => d.name) as unknown as string[]
} catch { } catch {
return return
} }
for (const d of dirents) {
const name = d.name as string
if (name.startsWith('.')) continue
const relPath = relDir ? path.join(relDir, name) : name
if (d.isDirectory()) {
walk(path.join(absDir, name), relPath)
} else {
const title = path.basename(name, path.extname(name))
upsert.run({
library_id: library.id,
item_key: `${library.id}:mixed_file:${encodeURIComponent(relPath)}`,
item_type: 'mixed_file',
title,
file_path: relPath,
scanned_at: now,
})
fileCount++
let count = 0 const ext = path.extname(name).toLowerCase()
for (const filename of entries) {
const ext = path.extname(filename).toLowerCase()
let mediaType: 'image' | 'video' | null = null let mediaType: 'image' | 'video' | null = null
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image' if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video' else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
if (!mediaType) continue if (mediaType) {
const absPath = path.join(absDir, name)
const absPath = path.join(libraryRoot, filename) getThumbnailPath(absPath, library.id, mediaType).catch((err) => {
try { console.warn(`[scanner] Could not generate thumbnail for ${relPath}:`, err instanceof Error ? err.message : err)
await getThumbnailPath(absPath, library.id, mediaType) })
count++ }
} catch (err) { }
console.warn(`[scanner] Could not generate thumbnail for ${filename}:`, err instanceof Error ? err.message : err)
} }
} }
console.log(`[scanner] mixed: pre-generated thumbnails for ${count} files`) walk(libraryRoot, '')
console.log(`[scanner] mixed: indexed ${fileCount} files, pre-generating thumbnails`)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,7 +1,7 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import { parseTvShowNfo, parseEpisodeNfo } from './nfo' import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils' import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
function isVideoFile(name: string): boolean { function isVideoFile(name: string): boolean {
@@ -49,8 +49,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
for (const dirName of seriesDirs) { for (const dirName of seriesDirs) {
const seriesPath = path.join(libraryRoot, dirName) const seriesPath = path.join(libraryRoot, dirName)
const nfoFile = path.join(seriesPath, 'tvshow.nfo')
const nfo = parseTvShowNfo(nfoFile)
const posterFile = findFile(seriesPath, /^(poster|folder)$/i) const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i) const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
@@ -69,11 +67,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
series.push({ series.push({
id, id,
title: nfo?.title ?? dirName, title: dirName,
year: nfo?.year ?? null, year: null,
plot: nfo?.plot ?? null, plot: null,
genres: nfo?.genres ?? [], genres: [],
status: nfo?.status ?? null, status: null,
posterUrl: posterFile posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile)) ? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null, : null,
@@ -168,24 +166,17 @@ export function scanTvEpisodes(
for (const videoFile of videoFiles) { for (const videoFile of videoFiles) {
const baseName = path.basename(videoFile, path.extname(videoFile)) const baseName = path.basename(videoFile, path.extname(videoFile))
const nfoFileName = files.find(
(f) => path.basename(f, path.extname(f)) === baseName && path.extname(f).toLowerCase() === '.nfo'
)
const nfo = nfoFileName
? parseEpisodeNfo(path.join(seasonPath, nfoFileName))
: null
const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile) const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile)
const id = encodeURIComponent(videoFile) const id = encodeURIComponent(videoFile)
episodes.push({ episodes.push({
id, id,
title: nfo?.title ?? baseName, title: baseName,
episodeNumber: nfo?.episode ?? null, episodeNumber: null,
seasonNumber: nfo?.season ?? null, seasonNumber: null,
plot: nfo?.plot ?? null, plot: null,
aired: nfo?.aired ?? null, aired: null,
rating: nfo?.rating ?? null, rating: null,
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath), thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
videoPath: videoRelPath, videoPath: videoRelPath,
}) })
@@ -198,3 +189,107 @@ export function scanTvEpisodes(
return (a.title ?? '').localeCompare(b.title ?? '') return (a.title ?? '').localeCompare(b.title ?? '')
}) })
} }
// ---------------------------------------------------------------------------
// DB readers
// ---------------------------------------------------------------------------
type DbRow = {
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'tv_series' ORDER BY title`)
.all(libraryId) as DbRow[]
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key
return {
id: idPart,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,
genres: row.genres ? JSON.parse(row.genres) : [],
status: meta.status ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
seasonCount: meta.seasonCount ?? 0,
}
})
}
export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[] {
const db = getDb()
const parentKey = `${libraryId}:tv_series:${seriesId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_season'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_season:{seriesId}:{seasonId}
const parts = row.item_key.split(':tv_season:')
const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key
return {
id: seasonId,
seriesId,
title: row.title ?? seasonId,
seasonNumber: meta.seasonNumber ?? null,
posterUrl: meta.posterUrl ?? null,
episodeCount: meta.episodeCount ?? 0,
}
})
.sort((a, b) => {
if (a.seasonNumber !== null && b.seasonNumber !== null) {
return a.seasonNumber - b.seasonNumber
}
return a.title.localeCompare(b.title)
})
}
export function tvEpisodesFromDb(
libraryId: string,
seriesId: string,
seasonId: string
): TvEpisode[] {
const db = getDb()
const parentKey = `${libraryId}:tv_season:${seriesId}:${seasonId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_episode'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_episode:{seriesId}:{seasonId}:{episodeId}
const suffix = row.item_key.split(':tv_episode:')[1] ?? ''
const episodeId = suffix.split(':').slice(2).join(':')
return {
id: episodeId,
title: row.title ?? decodeURIComponent(episodeId),
episodeNumber: meta.episodeNumber ?? null,
seasonNumber: meta.seasonNumber ?? null,
plot: row.plot ?? null,
aired: meta.aired ?? null,
rating: meta.rating ?? null,
thumbnailUrl: meta.thumbnailUrl ?? null,
videoPath: row.file_path ?? '',
}
})
.sort((a, b) => {
if (a.episodeNumber !== null && b.episodeNumber !== null) {
return a.episodeNumber - b.episodeNumber
}
return (a.title ?? '').localeCompare(b.title ?? '')
})
}