From 8829188c585c7264320833cf0c1a304e2aeeb9ee Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:55:53 -0400 Subject: [PATCH 1/2] add scanning --- package-lock.json | 18 ++ package.json | 2 + src/app/api/scan-settings/route.ts | 42 ++++ src/app/api/scan/route.ts | 32 +++ src/app/manage/scanning/page.tsx | 287 ++++++++++++++++++++++ src/components/ManageSubNav.tsx | 1 + src/instrumentation.ts | 3 + src/lib/app-settings.ts | 38 +++ src/lib/db.ts | 37 +++ src/lib/scanner.ts | 373 +++++++++++++++++++++++++++++ src/lib/scheduler.ts | 39 +++ 11 files changed, 872 insertions(+) create mode 100644 src/app/api/scan-settings/route.ts create mode 100644 src/app/api/scan/route.ts create mode 100644 src/app/manage/scanning/page.tsx create mode 100644 src/lib/app-settings.ts create mode 100644 src/lib/scanner.ts create mode 100644 src/lib/scheduler.ts diff --git a/package-lock.json b/package-lock.json index aa9f88f..536b827 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "fast-xml-parser": "^5.5.10", "iron-session": "^8.0.4", "next": "^15.5.14", + "node-cron": "^4.2.1", "react": "^19.2.4", "react-dom": "^19.2.4", "sharp": "^0.34.5" @@ -21,6 +22,7 @@ "@tailwindcss/postcss": "^4.2.2", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", @@ -1676,6 +1678,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5481,6 +5490,15 @@ "node": ">=10" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", diff --git a/package.json b/package.json index b21aee3..45db025 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "fast-xml-parser": "^5.5.10", "iron-session": "^8.0.4", "next": "^15.5.14", + "node-cron": "^4.2.1", "react": "^19.2.4", "react-dom": "^19.2.4", "sharp": "^0.34.5" @@ -24,6 +25,7 @@ "@tailwindcss/postcss": "^4.2.2", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", diff --git a/src/app/api/scan-settings/route.ts b/src/app/api/scan-settings/route.ts new file mode 100644 index 0000000..1108c77 --- /dev/null +++ b/src/app/api/scan-settings/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import cron from 'node-cron' +import { requireAdmin } from '@/lib/auth' +import { getScanConfig, updateScanConfig } from '@/lib/app-settings' +import { restartScheduler } from '@/lib/scheduler' + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { schedule, enabled } = getScanConfig() + return NextResponse.json({ schedule, enabled }) +} + +export async function PUT(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + let body: { schedule?: string; enabled?: boolean } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { schedule, enabled } = body + + if (typeof schedule !== 'string' || !schedule.trim()) { + return NextResponse.json({ error: 'schedule is required' }, { status: 400 }) + } + if (typeof enabled !== 'boolean') { + return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 }) + } + if (!cron.validate(schedule)) { + return NextResponse.json({ error: 'Invalid cron expression' }, { status: 400 }) + } + + updateScanConfig(schedule, enabled) + restartScheduler() + + return NextResponse.json({ schedule, enabled }) +} diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts new file mode 100644 index 0000000..b505c96 --- /dev/null +++ b/src/app/api/scan/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { isScanRunning, runFullScan } from '@/lib/scanner' +import { getScanConfig } from '@/lib/app-settings' + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const config = getScanConfig() + + return NextResponse.json({ + isRunning: isScanRunning(), + lastScanAt: config.lastScanAt, + schedule: config.schedule, + enabled: config.enabled, + }) +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + if (isScanRunning()) { + return NextResponse.json({ started: false, reason: 'already running' }, { status: 409 }) + } + + // Fire-and-forget — do not await + runFullScan().catch((err) => console.error('[api/scan] Scan error:', err)) + + return NextResponse.json({ started: true }, { status: 202 }) +} diff --git a/src/app/manage/scanning/page.tsx b/src/app/manage/scanning/page.tsx new file mode 100644 index 0000000..abed5a4 --- /dev/null +++ b/src/app/manage/scanning/page.tsx @@ -0,0 +1,287 @@ +'use client' + +import { useEffect, useState, useRef, useCallback } from 'react' + +interface ScanStatus { + isRunning: boolean + lastScanAt: number | null + schedule: string + enabled: boolean +} + +interface ScanSettings { + schedule: string + enabled: boolean +} + +function formatDate(ts: number | null): string { + if (!ts) return 'Never' + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(ts)) +} + +export default function ScanningPage() { + const [status, setStatus] = useState(null) + const [settings, setSettings] = useState({ schedule: '0 * * * *', enabled: true }) + const [loadingStatus, setLoadingStatus] = useState(true) + const [scanning, setScanning] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saveSuccess, setSaveSuccess] = useState(false) + const [savingSettings, setSavingSettings] = useState(false) + const pollRef = useRef | null>(null) + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch('/api/scan') + if (!res.ok) return + const data: ScanStatus = await res.json() + setStatus(data) + setScanning(data.isRunning) + setSettings({ schedule: data.schedule, enabled: data.enabled }) + } catch { + // ignore + } finally { + setLoadingStatus(false) + } + }, []) + + useEffect(() => { + fetchStatus() + }, [fetchStatus]) + + // Poll every 2s while a scan is in progress + useEffect(() => { + if (scanning) { + pollRef.current = setInterval(fetchStatus, 2000) + } else { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, [scanning, fetchStatus]) + + const handleScanNow = async () => { + if (scanning) return + try { + const res = await fetch('/api/scan', { method: 'POST' }) + if (res.status === 202) { + setScanning(true) + fetchStatus() + } else if (res.status === 409) { + setScanning(true) + } + } catch { + // ignore + } + } + + const handleSaveSettings = async (e: React.FormEvent) => { + e.preventDefault() + setSaveError(null) + setSaveSuccess(false) + setSavingSettings(true) + try { + const res = await fetch('/api/scan-settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }) + const data = await res.json() + if (!res.ok) { + setSaveError(data.error ?? 'Failed to save settings') + } else { + setSettings(data) + setSaveSuccess(true) + setTimeout(() => setSaveSuccess(false), 3000) + } + } catch { + setSaveError('Network error. Please try again.') + } finally { + setSavingSettings(false) + } + } + + return ( +
+

+ Library Scanning +

+

+ Scan libraries to index metadata and pre-generate thumbnails. +

+ +
+ {loadingStatus ? ( + + ) : ( +
+
+
+ + {scanning ? 'Scanning…' : 'Idle'} + + {scanning && ( + + Running + + )} +
+ + Last scan: {formatDate(status?.lastScanAt ?? null)} + +
+ +
+ )} +
+ +
+
+ + setSettings((s) => ({ ...s, schedule: e.target.value }))} + placeholder="0 * * * *" + required + className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')} + onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')} + /> +

+ Standard 5-field cron (minute hour day month weekday). Default: 0 * * * * (hourly). +

+
+ + + + + + {saveError && ( +

+ {saveError} +

+ )} + {saveSuccess && ( +

+ Settings saved. +

+ )} + +
+ +
+
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
+
{children}
+
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function LoadingRows() { + return ( +
+ {[70, 50].map((w) => ( +
+
+
+ ))} +
+ ) +} diff --git a/src/components/ManageSubNav.tsx b/src/components/ManageSubNav.tsx index 88f9048..f3df1f1 100644 --- a/src/components/ManageSubNav.tsx +++ b/src/components/ManageSubNav.tsx @@ -7,6 +7,7 @@ const TABS = [ { href: '/manage', label: 'Libraries' }, { href: '/manage/tags', label: 'Tags' }, { href: '/manage/users', label: 'Users' }, + { href: '/manage/scanning', label: 'Scanning' }, ] export default function ManageSubNav() { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 4c35da8..483d983 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -2,5 +2,8 @@ export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { const { initializeSecret } = await import('./lib/secret') initializeSecret() + + const { startScheduler } = await import('./lib/scheduler') + startScheduler() } } diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts new file mode 100644 index 0000000..e27f2c8 --- /dev/null +++ b/src/lib/app-settings.ts @@ -0,0 +1,38 @@ +import { getDb } from './db' + +interface ScanConfig { + schedule: string + enabled: boolean + lastScanAt: number | null +} + +function getSetting(key: string): string | null { + const db = getDb() + const row = db + .prepare('SELECT value FROM app_settings WHERE key = ?') + .get(key) as { value: string } | undefined + return row?.value ?? null +} + +function setSetting(key: string, value: string): void { + const db = getDb() + db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value) +} + +export function getScanConfig(): ScanConfig { + const schedule = getSetting('scan_schedule') ?? '0 * * * *' + const enabled = getSetting('scan_enabled') !== 'false' + const lastRanRaw = getSetting('scan_last_ran') + const lastScanAt = + lastRanRaw && lastRanRaw.length > 0 ? parseInt(lastRanRaw, 10) : null + return { schedule, enabled, lastScanAt } +} + +export function updateScanConfig(schedule: string, enabled: boolean): void { + setSetting('scan_schedule', schedule) + setSetting('scan_enabled', enabled ? 'true' : 'false') +} + +export function setScanLastRan(ts: number): void { + setSetting('scan_last_ran', String(ts)) +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 0936ec1..c499d96 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -71,9 +71,46 @@ function initDb(db: Database.Database): void { tv_loop INTEGER NOT NULL DEFAULT 0, tv_muted INTEGER NOT NULL DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS media_items ( + 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')), + parent_key TEXT, + title TEXT, + year INTEGER, + plot TEXT, + genres TEXT, + metadata TEXT, + scanned_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id); + CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key); `) migrateLibrariesType(db) + seedAppSettings(db) +} + +function seedAppSettings(db: Database.Database): void { + const defaults: Record = { + scan_schedule: '0 * * * *', + scan_enabled: 'true', + scan_last_ran: '', + } + const insert = db.prepare( + 'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)' + ) + for (const [key, value] of Object.entries(defaults)) { + insert.run(key, value) + } } function migrateLibrariesType(db: Database.Database): void { diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts new file mode 100644 index 0000000..7f3e8c5 --- /dev/null +++ b/src/lib/scanner.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts new file mode 100644 index 0000000..5771c04 --- /dev/null +++ b/src/lib/scheduler.ts @@ -0,0 +1,39 @@ +import cron, { type ScheduledTask } from 'node-cron' +import { getScanConfig } from './app-settings' +import { runFullScan } from './scanner' + +let scheduledTask: ScheduledTask | null = null + +export function startScheduler(): void { + const { schedule, enabled } = getScanConfig() + + if (!enabled) { + console.log('[scheduler] Scanning is disabled — scheduler not started') + return + } + + if (!cron.validate(schedule)) { + console.error(`[scheduler] Invalid cron expression "${schedule}" — scheduler not started`) + return + } + + scheduledTask = cron.schedule(schedule, () => { + console.log('[scheduler] Cron triggered — running full scan') + runFullScan().catch((err) => console.error('[scheduler] Scan error:', err)) + }) + + console.log(`[scheduler] Started with schedule: ${schedule}`) +} + +export function stopScheduler(): void { + if (scheduledTask) { + scheduledTask.stop() + scheduledTask = null + console.log('[scheduler] Stopped') + } +} + +export function restartScheduler(): void { + stopScheduler() + startScheduler() +} -- 2.49.1 From 6858c1e8cf440577dc4754d554c618ac2fcf6858 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:24:28 -0400 Subject: [PATCH 2/2] add tagging to tv --- src/app/api/browse/route.ts | 7 +++- src/components/mixed/MixedView.tsx | 50 ++++++++++++++++-------- src/components/tv/EpisodeCard.tsx | 24 ++++++++++-- src/components/tv/TvView.tsx | 62 ++++++++++++++++++++++++++++-- src/lib/files.ts | 52 +++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 25 deletions(-) diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 6c72934..0aaf717 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' -import { scanDirectory } from '@/lib/files' +import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' import { requireLibraryAccess } from '@/lib/auth' export async function GET(request: NextRequest) { @@ -24,6 +24,9 @@ export async function GET(request: NextRequest) { } const root = resolveLibraryRoot(library) - const listing = scanDirectory(root, libraryId, subpath) + const recursive = request.nextUrl.searchParams.get('recursive') === 'true' + const listing = recursive + ? scanDirectoryRecursive(root, libraryId, subpath) + : scanDirectory(root, libraryId, subpath) return NextResponse.json(listing) } diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 6ac4118..7b839c9 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -31,6 +31,9 @@ export default function MixedView({ libraryId, initialPath }: Props) { const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) + const [recursiveEntries, setRecursiveEntries] = useState([]) + const [recursiveLoading, setRecursiveLoading] = useState(false) + const [recursiveLoaded, setRecursiveLoaded] = useState(false) const toggleTag = (tagId: string) => setSelectedTagIds((prev) => { @@ -73,6 +76,29 @@ export default function MixedView({ libraryId, initialPath }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) + const filtersActive = search !== '' || selectedTagIds.size > 0 + + // Fetch the full recursive listing the first time any filter becomes active + useEffect(() => { + if (!filtersActive || recursiveLoaded || recursiveLoading) return + setRecursiveLoading(true) + fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`) + .then((r) => r.json()) + .then((data: DirectoryListing) => { + setRecursiveEntries(data.entries) + setRecursiveLoaded(true) + }) + .catch(() => {}) + .finally(() => setRecursiveLoading(false)) + }, [filtersActive, libraryId, recursiveLoaded, recursiveLoading]) + + const mediaKeyFor = (entry: FileEntry) => { + // In recursive mode entry.name is already the full relative path from the library root + if (filtersActive) return `${libraryId}:${encodeURIComponent(entry.name)}` + const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name + return `${libraryId}:${encodeURIComponent(rel)}` + } + const handleEntry = (entry: FileEntry) => { if (entry.type === 'directory') { const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name @@ -85,15 +111,12 @@ export default function MixedView({ libraryId, initialPath }: Props) { } else if (entry.mediaType === 'image') { setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) }) } else { - // Download other file types window.open(entry.url, '_blank') } } const handleTagEntry = (entry: FileEntry) => { - const relativePath = currentPath ? `${currentPath}/${entry.name}` : entry.name - const mediaKey = `${libraryId}:${encodeURIComponent(relativePath)}` - setTagPanel({ entry, mediaKey }) + setTagPanel({ entry, mediaKey: mediaKeyFor(entry) }) } const navigateUp = () => { @@ -107,12 +130,9 @@ export default function MixedView({ libraryId, initialPath }: Props) { ? currentPath.split('/').filter(Boolean) : [] - const mediaKeyFor = (entry: FileEntry) => { - const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name - return `${libraryId}:${encodeURIComponent(rel)}` - } + const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? []) - const filteredEntries = (listing?.entries ?? []).filter((entry) => { + const filteredEntries = sourceEntries.filter((entry) => { if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0 && entry.type !== 'directory') { const entryTags = assignments[mediaKeyFor(entry)] ?? [] @@ -121,8 +141,6 @@ export default function MixedView({ libraryId, initialPath }: Props) { return true }) - const filtersActive = search !== '' || selectedTagIds.size > 0 - return ( <>
@@ -184,23 +202,23 @@ export default function MixedView({ libraryId, initialPath }: Props) { })} - {loading && } + {(loading || recursiveLoading) && } {error && (
{error}
)} - {!loading && !error && listing && ( + {!loading && !recursiveLoading && !error && (filtersActive || listing) && ( <> {filteredEntries.length === 0 ? (
- This folder is empty. + {filtersActive ? 'No results found.' : 'This folder is empty.'}
) : (
- {/* Up button */} - {breadcrumbs.length > 0 && ( + {/* Up button — hidden during recursive search */} + {!filtersActive && breadcrumbs.length > 0 && (
+ {/* Tag button */} + {onTag && ( + + )}
{epLabel && ( @@ -62,6 +78,6 @@ export default function EpisodeCard({ episode, onClick }: Props) {

)}
- +
) } diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index a5bfce2..b213ba1 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import type { TvSeries, TvSeason, TvEpisode } from '@/types' import FilterPanel from '@/components/FilterPanel' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' +import TagSelector from '@/components/tags/TagSelector' import EpisodeCard from './EpisodeCard' interface Props { @@ -27,6 +28,7 @@ export default function TvView({ libraryId }: Props) { const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) + const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null) const [menuOpen, setMenuOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) @@ -141,6 +143,8 @@ export default function TvView({ libraryId }: Props) { { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onClose={() => setPlayingEpisode(null)} context="tv" /> @@ -225,10 +229,13 @@ export default function TvView({ libraryId }: Props) { ) : (
{filteredSeries.map((s) => ( -

@@ -255,7 +271,7 @@ export default function TvView({ libraryId }: Props) { {s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}

- + ))} )} @@ -416,12 +432,52 @@ export default function TvView({ libraryId }: Props) { key={ep.id} episode={ep} onClick={() => setPlayingEpisode(ep)} + onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} /> ))} )} )} + {tagPanel && ( +
{ if (e.target === e.currentTarget) setTagPanel(null) }} + > +
+
+
+

+ Tags +

+

+ {tagPanel.title} +

+
+ +
+
+ { setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }} + /> +
+
+
+ )} ) } diff --git a/src/lib/files.ts b/src/lib/files.ts index 01c6059..2c7c20b 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -76,3 +76,55 @@ export function scanDirectory( return { path: subpath, entries } } + +/** + * Recursively walks every subdirectory under `subpath` and returns a flat list + * of all files. Directory entries are omitted. Each FileEntry.name is the full + * relative path from the library root (e.g. FolderA/SubFolder/video.mp4). + */ +export function scanDirectoryRecursive( + libraryRoot: string, + libraryId: string, + subpath: string +): DirectoryListing { + let rootAbsPath: string + try { + rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot + } catch { + return { path: subpath, entries: [] } + } + + const entries: FileEntry[] = [] + + function walk(absDir: string, relDir: string): void { + let dirents: fs.Dirent[] + try { + dirents = fs.readdirSync(absDir, { withFileTypes: true }) + } catch { + return + } + for (const d of dirents) { + if (HIDDEN_FILES.test(d.name)) continue + const relPath = relDir ? path.join(relDir, d.name) : d.name + if (d.isDirectory()) { + walk(path.join(absDir, d.name), relPath) + } else { + const mediaType = getMediaType(d.name) + const hasThumbnail = mediaType === 'image' || mediaType === 'video' + // name = full relative path from library root so media keys match + const fullRelPath = subpath ? path.join(subpath, relPath) : relPath + entries.push({ + name: fullRelPath, + type: 'file', + mediaType, + url: fileApiUrl(libraryId, fullRelPath), + thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null, + }) + } + } + } + + walk(rootAbsPath, '') + entries.sort((a, b) => a.name.localeCompare(b.name)) + return { path: subpath, entries } +} -- 2.49.1