add scanning

This commit is contained in:
Garret Patti
2026-04-05 18:55:53 -04:00
parent c87a9b33bb
commit 8829188c58
11 changed files with 872 additions and 0 deletions

373
src/lib/scanner.ts Normal file
View File

@@ -0,0 +1,373 @@
import path from 'path'
import type Database from 'better-sqlite3'
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries } from '@/types'
import { getDb } from './db'
import { getLibraries, resolveLibraryRoot } from './libraries'
import { setScanLastRan } from './app-settings'
import { scanMoviesLibrary } from './movies'
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
import { scanGamesLibrary } from './games'
import { getThumbnailPath } from './thumbnails'
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
let scanRunning = false
export function isScanRunning(): boolean {
return scanRunning
}
export async function runFullScan(): Promise<void> {
if (scanRunning) return
scanRunning = true
console.log('[scanner] Starting full library scan')
try {
const libraries = getLibraries()
for (const library of libraries) {
try {
await runLibraryScan(library)
} catch (err) {
console.error(`[scanner] Error scanning library "${library.name}":`, err)
}
}
const now = Date.now()
setScanLastRan(now)
console.log('[scanner] Full scan complete')
} finally {
scanRunning = false
}
}
export async function runLibraryScan(library: Library): Promise<void> {
const libraryRoot = resolveLibraryRoot(library)
console.log(`[scanner] Scanning library "${library.name}" (${library.type}) at ${libraryRoot}`)
switch (library.type) {
case 'movies':
await scanMovies(library, libraryRoot)
break
case 'tv':
await scanTv(library, libraryRoot)
break
case 'games':
await scanGames(library, libraryRoot)
break
case 'mixed':
await scanMixed(library, libraryRoot)
break
}
}
// ---------------------------------------------------------------------------
// Movies
// ---------------------------------------------------------------------------
async function scanMovies(library: Library, libraryRoot: string): Promise<void> {
const movies = scanMoviesLibrary(libraryRoot, library.id)
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, year, plot, genres, metadata, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
year = excluded.year,
plot = excluded.plot,
genres = excluded.genres,
metadata = excluded.metadata,
scanned_at = excluded.scanned_at
`)
for (const movie of movies) {
const itemKey = `${library.id}:movie:${movie.id}`
upsert.run({
library_id: library.id,
item_key: itemKey,
item_type: 'movie',
title: movie.title,
year: movie.year ?? null,
plot: movie.plot ?? null,
genres: JSON.stringify(movie.genres),
metadata: JSON.stringify({ rating: movie.rating, runtime: movie.runtime }),
scanned_at: now,
})
// Pre-generate poster thumbnail
if (movie.posterUrl) {
await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image')
}
}
console.log(`[scanner] movies: indexed ${movies.length} items`)
}
// ---------------------------------------------------------------------------
// TV
// ---------------------------------------------------------------------------
async function scanTv(library: Library, libraryRoot: string): Promise<void> {
const series = scanTvLibrary(libraryRoot, library.id)
const db = getDb()
const now = Date.now()
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)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
year = excluded.year,
plot = excluded.plot,
genres = excluded.genres,
metadata = excluded.metadata,
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)
ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key,
title = excluded.title,
year = excluded.year,
plot = excluded.plot,
genres = excluded.genres,
metadata = excluded.metadata,
scanned_at = excluded.scanned_at
`)
let episodeCount = 0
for (const show of series) {
const seriesKey = `${library.id}:tv_series:${show.id}`
upsertSeries.run({
library_id: library.id,
item_key: seriesKey,
item_type: 'tv_series',
title: show.title,
year: show.year ?? null,
plot: show.plot ?? null,
genres: JSON.stringify(show.genres),
metadata: JSON.stringify({ status: show.status, seasonCount: show.seasonCount }),
scanned_at: now,
})
if (show.posterUrl) {
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
}
const seasons = scanTvSeasons(libraryRoot, library.id, show.id)
for (const season of seasons) {
const seasonKey = `${library.id}:tv_season:${show.id}:${season.id}`
upsertChild.run({
library_id: library.id,
item_key: seasonKey,
item_type: 'tv_season',
parent_key: seriesKey,
title: season.title,
year: null,
plot: null,
genres: JSON.stringify([]),
metadata: JSON.stringify({ seasonNumber: season.seasonNumber, episodeCount: season.episodeCount }),
scanned_at: now,
})
if (season.posterUrl) {
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
}
const episodes = scanTvEpisodes(libraryRoot, library.id, show.id, season.id)
for (const episode of episodes) {
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
upsertChild.run({
library_id: library.id,
item_key: episodeKey,
item_type: 'tv_episode',
parent_key: seasonKey,
title: episode.title,
year: null,
plot: episode.plot ?? null,
genres: JSON.stringify([]),
metadata: JSON.stringify({
episodeNumber: episode.episodeNumber,
seasonNumber: episode.seasonNumber,
aired: episode.aired,
rating: episode.rating,
}),
scanned_at: now,
})
// Pre-generate video thumbnail (seek-based frame extraction)
const videoAbsPath = path.join(libraryRoot, episode.videoPath)
try {
await getThumbnailPath(videoAbsPath, library.id, 'video')
} catch (err) {
console.warn(`[scanner] Could not generate thumbnail for ${episode.videoPath}:`, err instanceof Error ? err.message : err)
}
episodeCount++
}
}
}
console.log(`[scanner] tv: indexed ${series.length} series, ${episodeCount} episodes`)
}
// ---------------------------------------------------------------------------
// Games
// ---------------------------------------------------------------------------
async function scanGames(library: Library, libraryRoot: string): Promise<void> {
const items = scanGamesLibrary(libraryRoot, library.id)
const db = getDb()
const now = Date.now()
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)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
metadata = excluded.metadata,
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)
ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key,
title = excluded.title,
metadata = excluded.metadata,
scanned_at = excluded.scanned_at
`)
let gameCount = 0
for (const item of items) {
if ('games' in item) {
// GameSeries
const series = item as GameSeries
const seriesKey = `${library.id}:game_series:${series.id}`
upsertGame.run({
library_id: library.id,
item_key: seriesKey,
item_type: 'game_series',
title: series.title,
metadata: JSON.stringify({ gameCount: series.games.length }),
scanned_at: now,
})
if (series.coverUrl) {
await prewarmThumbnailFromUrl(series.coverUrl, library.id, libraryRoot, 'image')
}
for (const game of series.games) {
const gameKey = `${library.id}:game:${game.id}`
upsertChildGame.run({
library_id: library.id,
item_key: gameKey,
item_type: 'game',
parent_key: seriesKey,
title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
scanned_at: now,
})
if (game.coverUrl) {
await prewarmThumbnailFromUrl(game.coverUrl, library.id, libraryRoot, 'image')
}
gameCount++
}
} else {
// Standalone Game
const game = item as Game
const gameKey = `${library.id}:game:${game.id}`
upsertGame.run({
library_id: library.id,
item_key: gameKey,
item_type: 'game',
title: game.title,
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
scanned_at: now,
})
if (game.coverUrl) {
await prewarmThumbnailFromUrl(game.coverUrl, library.id, libraryRoot, 'image')
}
gameCount++
}
}
console.log(`[scanner] games: indexed ${gameCount} games`)
}
// ---------------------------------------------------------------------------
// Mixed (thumbnail pre-generation only — no DB indexing)
// ---------------------------------------------------------------------------
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
}
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
const absPath = path.join(libraryRoot, filename)
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)
}
}
console.log(`[scanner] mixed: pre-generated thumbnails for ${count} files`)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function clearLibraryItems(db: Database.Database, libraryId: string): void {
db.prepare('DELETE FROM media_items WHERE library_id = ?').run(libraryId)
}
/**
* Extract the `path` query param from an /api/thumbnail URL and pre-warm
* the thumbnail cache for that file.
*/
async function prewarmThumbnailFromUrl(
apiUrl: string,
libraryId: string,
libraryRoot: string,
mediaType: 'image' | 'video'
): Promise<void> {
try {
const relPath = decodeURIComponent(
new URL(apiUrl, 'http://localhost').searchParams.get('path') ?? ''
)
if (!relPath) return
const absPath = path.join(libraryRoot, relPath)
await getThumbnailPath(absPath, libraryId, mediaType)
} catch (err) {
console.warn(`[scanner] Could not prewarm thumbnail for ${apiUrl}:`, err instanceof Error ? err.message : err)
}
}