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:
@@ -81,13 +81,14 @@ function initDb(db: Database.Database): void {
|
||||
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')),
|
||||
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
|
||||
);
|
||||
|
||||
@@ -96,6 +97,7 @@ function initDb(db: Database.Database): void {
|
||||
`)
|
||||
|
||||
migrateLibrariesType(db)
|
||||
migrateMediaItemsSchema(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 {
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { Game, GameSeries } from '@/types'
|
||||
import { getDb } from './db'
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { Movie } from '@/types'
|
||||
import { parseMovieNfo } from './nfo'
|
||||
import { getDb } from './db'
|
||||
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||
|
||||
function findVideoFile(dir: string): string | null {
|
||||
@@ -16,7 +16,7 @@ function findVideoFile(dir: string): string | 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
|
||||
const candidates = [`${dirName}.nfo`, 'movie.nfo']
|
||||
let entries: string[]
|
||||
@@ -26,9 +26,8 @@ function findNfoFile(dir: string, dirName: string): string | null {
|
||||
return null
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) {
|
||||
return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())!
|
||||
}
|
||||
const match = entries.find((e) => e.toLowerCase() === candidate.toLowerCase())
|
||||
if (match) return match
|
||||
}
|
||||
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)
|
||||
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 backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i)
|
||||
|
||||
@@ -63,12 +59,12 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
||||
|
||||
movies.push({
|
||||
id,
|
||||
title: nfo?.title ?? dirName,
|
||||
year: nfo?.year ?? null,
|
||||
plot: nfo?.plot ?? null,
|
||||
rating: nfo?.rating ?? null,
|
||||
genres: nfo?.genres ?? [],
|
||||
runtime: nfo?.runtime ?? null,
|
||||
title: dirName,
|
||||
year: null,
|
||||
plot: null,
|
||||
rating: null,
|
||||
genres: [],
|
||||
runtime: null,
|
||||
posterUrl: posterFile
|
||||
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||
: null,
|
||||
@@ -81,3 +77,35 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
||||
|
||||
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 ?? '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,14 +71,15 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO media_items (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, @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, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
year = excluded.year,
|
||||
plot = excluded.plot,
|
||||
genres = excluded.genres,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
@@ -92,7 +93,13 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
||||
year: movie.year ?? null,
|
||||
plot: movie.plot ?? null,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -117,20 +124,21 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsertSeries = db.prepare(`
|
||||
INSERT INTO media_items (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, @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, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
year = excluded.year,
|
||||
plot = excluded.plot,
|
||||
genres = excluded.genres,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
const upsertChild = db.prepare(`
|
||||
INSERT INTO media_items (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, @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, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
parent_key = excluded.parent_key,
|
||||
title = excluded.title,
|
||||
@@ -138,6 +146,7 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
plot = excluded.plot,
|
||||
genres = excluded.genres,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
@@ -153,7 +162,13 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
year: show.year ?? null,
|
||||
plot: show.plot ?? null,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -173,7 +188,12 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
year: null,
|
||||
plot: null,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -198,7 +218,9 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
seasonNumber: episode.seasonNumber,
|
||||
aired: episode.aired,
|
||||
rating: episode.rating,
|
||||
thumbnailUrl: episode.thumbnailUrl,
|
||||
}),
|
||||
file_path: episode.videoPath,
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
@@ -229,21 +251,23 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsertGame = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, scanned_at)
|
||||
VALUES (@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, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
const upsertChildGame = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, scanned_at)
|
||||
VALUES (@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, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
parent_key = excluded.parent_key,
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
@@ -259,7 +283,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
item_key: seriesKey,
|
||||
item_type: 'game_series',
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -275,7 +304,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
item_type: 'game',
|
||||
parent_key: seriesKey,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -293,7 +327,12 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
item_key: gameKey,
|
||||
item_type: 'game',
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
const fs = await import('fs')
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isFile() && !d.name.startsWith('.'))
|
||||
.map((d) => d.name) as unknown as string[]
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const fsSync = await import('fs') as typeof import('fs')
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
let count = 0
|
||||
for (const filename of entries) {
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
let mediaType: 'image' | 'video' | null = null
|
||||
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
|
||||
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
|
||||
if (!mediaType) continue
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const absPath = path.join(libraryRoot, filename)
|
||||
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 {
|
||||
await getThumbnailPath(absPath, library.id, mediaType)
|
||||
count++
|
||||
} catch (err) {
|
||||
console.warn(`[scanner] Could not generate thumbnail for ${filename}:`, err instanceof Error ? err.message : err)
|
||||
dirents = fsSync.readdirSync(absDir, { withFileTypes: true, encoding: 'utf-8' }) as import('fs').Dirent[]
|
||||
} catch {
|
||||
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++
|
||||
|
||||
const ext = path.extname(name).toLowerCase()
|
||||
let mediaType: 'image' | 'video' | null = null
|
||||
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
|
||||
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
|
||||
if (mediaType) {
|
||||
const absPath = path.join(absDir, name)
|
||||
getThumbnailPath(absPath, library.id, mediaType).catch((err) => {
|
||||
console.warn(`[scanner] Could not generate thumbnail for ${relPath}:`, 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`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
137
src/lib/tv.ts
137
src/lib/tv.ts
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
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'
|
||||
|
||||
function isVideoFile(name: string): boolean {
|
||||
@@ -49,8 +49,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
||||
|
||||
for (const dirName of seriesDirs) {
|
||||
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 backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
|
||||
@@ -69,11 +67,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
||||
|
||||
series.push({
|
||||
id,
|
||||
title: nfo?.title ?? dirName,
|
||||
year: nfo?.year ?? null,
|
||||
plot: nfo?.plot ?? null,
|
||||
genres: nfo?.genres ?? [],
|
||||
status: nfo?.status ?? null,
|
||||
title: dirName,
|
||||
year: null,
|
||||
plot: null,
|
||||
genres: [],
|
||||
status: null,
|
||||
posterUrl: posterFile
|
||||
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||
: null,
|
||||
@@ -168,24 +166,17 @@ export function scanTvEpisodes(
|
||||
|
||||
for (const videoFile of videoFiles) {
|
||||
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 id = encodeURIComponent(videoFile)
|
||||
|
||||
episodes.push({
|
||||
id,
|
||||
title: nfo?.title ?? baseName,
|
||||
episodeNumber: nfo?.episode ?? null,
|
||||
seasonNumber: nfo?.season ?? null,
|
||||
plot: nfo?.plot ?? null,
|
||||
aired: nfo?.aired ?? null,
|
||||
rating: nfo?.rating ?? null,
|
||||
title: baseName,
|
||||
episodeNumber: null,
|
||||
seasonNumber: null,
|
||||
plot: null,
|
||||
aired: null,
|
||||
rating: null,
|
||||
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
||||
videoPath: videoRelPath,
|
||||
})
|
||||
@@ -198,3 +189,107 @@ export function scanTvEpisodes(
|
||||
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 ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user