library-scanning #8

Merged
gpatti merged 2 commits from library-scanning into main 2026-04-05 23:24:49 +00:00
16 changed files with 1042 additions and 25 deletions

18
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"fast-xml-parser": "^5.5.10", "fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
"next": "^15.5.14", "next": "^15.5.14",
"node-cron": "^4.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sharp": "^0.34.5" "sharp": "^0.34.5"
@@ -21,6 +22,7 @@
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.4", "eslint": "^9.39.4",
@@ -1676,6 +1678,13 @@
"undici-types": "~7.18.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -5481,6 +5490,15 @@
"node": ">=10" "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": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",

View File

@@ -16,6 +16,7 @@
"fast-xml-parser": "^5.5.10", "fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
"next": "^15.5.14", "next": "^15.5.14",
"node-cron": "^4.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"sharp": "^0.34.5" "sharp": "^0.34.5"
@@ -24,6 +25,7 @@
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.4", "eslint": "^9.39.4",

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, resolveLibraryRoot } from '@/lib/libraries'
import { scanDirectory } from '@/lib/files' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -24,6 +24,9 @@ export async function GET(request: NextRequest) {
} }
const root = resolveLibraryRoot(library) 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) return NextResponse.json(listing)
} }

View File

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

32
src/app/api/scan/route.ts Normal file
View File

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

View File

@@ -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<ScanStatus | null>(null)
const [settings, setSettings] = useState<ScanSettings>({ schedule: '0 * * * *', enabled: true })
const [loadingStatus, setLoadingStatus] = useState(true)
const [scanning, setScanning] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveSuccess, setSaveSuccess] = useState(false)
const [savingSettings, setSavingSettings] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Library Scanning
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Scan libraries to index metadata and pre-generate thumbnails.
</p>
<Section title="Status">
{loadingStatus ? (
<LoadingRows />
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{scanning ? 'Scanning…' : 'Idle'}
</span>
{scanning && (
<span
className="text-xs px-2 py-0.5 rounded-full animate-pulse"
style={{ backgroundColor: '#16a34a33', color: '#4ade80' }}
>
Running
</span>
)}
</div>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Last scan: {formatDate(status?.lastScanAt ?? null)}
</span>
</div>
<button
onClick={handleScanNow}
disabled={scanning}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!scanning) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{scanning ? 'Scanning…' : 'Scan Now'}
</button>
</div>
)}
</Section>
<Section title="Schedule">
<form onSubmit={handleSaveSettings} className="flex flex-col gap-5">
<Field label="Cron Expression">
<input
type="text"
value={settings.schedule}
onChange={(e) => 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)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
Standard 5-field cron (minute hour day month weekday). Default: <code className="font-mono">0 * * * *</code> (hourly).
</p>
</Field>
<Field label="Automatic Scanning">
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
role="switch"
aria-checked={settings.enabled}
onClick={() => setSettings((s) => ({ ...s, enabled: !s.enabled }))}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors cursor-pointer"
style={{
backgroundColor: settings.enabled ? 'var(--accent)' : 'var(--border)',
}}
>
<span
className="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform"
style={{ transform: settings.enabled ? 'translateX(18px)' : 'translateX(3px)' }}
/>
</div>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{settings.enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</Field>
{saveError && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{saveError}
</p>
)}
{saveSuccess && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
>
Settings saved.
</p>
)}
<div>
<button
type="submit"
disabled={savingSettings}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!savingSettings) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{savingSettings ? 'Saving…' : 'Save Settings'}
</button>
</div>
</form>
</Section>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{children}
</div>
)
}
function LoadingRows() {
return (
<div className="flex flex-col gap-3">
{[70, 50].map((w) => (
<div key={w} className="flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -7,6 +7,7 @@ const TABS = [
{ href: '/manage', label: 'Libraries' }, { href: '/manage', label: 'Libraries' },
{ href: '/manage/tags', label: 'Tags' }, { href: '/manage/tags', label: 'Tags' },
{ href: '/manage/users', label: 'Users' }, { href: '/manage/users', label: 'Users' },
{ href: '/manage/scanning', label: 'Scanning' },
] ]
export default function ManageSubNav() { export default function ManageSubNav() {

View File

@@ -31,6 +31,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(true)
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
const [recursiveLoading, setRecursiveLoading] = useState(false)
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -73,6 +76,29 @@ export default function MixedView({ libraryId, initialPath }: Props) {
useEffect(() => { fetchAssignments() }, [fetchAssignments]) 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) => { const handleEntry = (entry: FileEntry) => {
if (entry.type === 'directory') { if (entry.type === 'directory') {
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
@@ -85,15 +111,12 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} else if (entry.mediaType === 'image') { } else if (entry.mediaType === 'image') {
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) }) setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
} else { } else {
// Download other file types
window.open(entry.url, '_blank') window.open(entry.url, '_blank')
} }
} }
const handleTagEntry = (entry: FileEntry) => { const handleTagEntry = (entry: FileEntry) => {
const relativePath = currentPath ? `${currentPath}/${entry.name}` : entry.name setTagPanel({ entry, mediaKey: mediaKeyFor(entry) })
const mediaKey = `${libraryId}:${encodeURIComponent(relativePath)}`
setTagPanel({ entry, mediaKey })
} }
const navigateUp = () => { const navigateUp = () => {
@@ -107,12 +130,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
? currentPath.split('/').filter(Boolean) ? currentPath.split('/').filter(Boolean)
: [] : []
const mediaKeyFor = (entry: FileEntry) => { const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
return `${libraryId}:${encodeURIComponent(rel)}`
}
const filteredEntries = (listing?.entries ?? []).filter((entry) => { const filteredEntries = sourceEntries.filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0 && entry.type !== 'directory') { if (selectedTagIds.size > 0 && entry.type !== 'directory') {
const entryTags = assignments[mediaKeyFor(entry)] ?? [] const entryTags = assignments[mediaKeyFor(entry)] ?? []
@@ -121,8 +141,6 @@ export default function MixedView({ libraryId, initialPath }: Props) {
return true return true
}) })
const filtersActive = search !== '' || selectedTagIds.size > 0
return ( return (
<> <>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
@@ -184,23 +202,23 @@ export default function MixedView({ libraryId, initialPath }: Props) {
})} })}
</nav> </nav>
{loading && <LoadingSkeleton />} {(loading || recursiveLoading) && <LoadingSkeleton />}
{error && ( {error && (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}> <div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{error} {error}
</div> </div>
)} )}
{!loading && !error && listing && ( {!loading && !recursiveLoading && !error && (filtersActive || listing) && (
<> <>
{filteredEntries.length === 0 ? ( {filteredEntries.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}> <div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
This folder is empty. {filtersActive ? 'No results found.' : 'This folder is empty.'}
</div> </div>
) : ( ) : (
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{/* Up button */} {/* Up button — hidden during recursive search */}
{breadcrumbs.length > 0 && ( {!filtersActive && breadcrumbs.length > 0 && (
<button <button
onClick={navigateUp} onClick={navigateUp}
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors" className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors"

View File

@@ -5,15 +5,19 @@ import type { TvEpisode } from '@/types'
interface Props { interface Props {
episode: TvEpisode episode: TvEpisode
onClick: () => void onClick: () => void
onTag?: () => void
} }
export default function EpisodeCard({ episode, onClick }: Props) { export default function EpisodeCard({ episode, onClick, onTag }: Props) {
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
return ( return (
<button <div
role="button"
tabIndex={0}
onClick={onClick} onClick={onClick}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } }}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2 cursor-pointer"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
@@ -42,6 +46,18 @@ export default function EpisodeCard({ episode, onClick }: Props) {
> >
<span className="text-3xl text-white"></span> <span className="text-3xl text-white"></span>
</div> </div>
{/* Tag button */}
{onTag && (
<button
onClick={(e) => { e.stopPropagation(); onTag() }}
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label={`Tag ${episode.title}`}
title="Tags"
>
🏷
</button>
)}
</div> </div>
<div className="p-2"> <div className="p-2">
{epLabel && ( {epLabel && (
@@ -62,6 +78,6 @@ export default function EpisodeCard({ episode, onClick }: Props) {
</p> </p>
)} )}
</div> </div>
</button> </div>
) )
} }

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import TagSelector from '@/components/tags/TagSelector'
import EpisodeCard from './EpisodeCard' import EpisodeCard from './EpisodeCard'
interface Props { interface Props {
@@ -27,6 +28,7 @@ export default function TvView({ libraryId }: Props) {
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(true)
const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null)
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)
@@ -141,6 +143,8 @@ export default function TvView({ libraryId }: Props) {
<VideoPlayerModal <VideoPlayerModal
url={videoUrl} url={videoUrl}
name={playingEpisode.title} name={playingEpisode.title}
mediaKey={`${libraryId}:${playingEpisode.id}`}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setPlayingEpisode(null)} onClose={() => setPlayingEpisode(null)}
context="tv" context="tv"
/> />
@@ -225,10 +229,13 @@ export default function TvView({ libraryId }: Props) {
) : ( ) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filteredSeries.map((s) => ( {filteredSeries.map((s) => (
<button <div
key={s.id} key={s.id}
role="button"
tabIndex={0}
onClick={() => openSeries(s)} onClick={() => openSeries(s)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSeries(s) } }}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2 cursor-pointer"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
@@ -246,6 +253,15 @@ export default function TvView({ libraryId }: Props) {
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div> <div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
)} )}
<button
onClick={(e) => { e.stopPropagation(); setTagPanel({ mediaKey: `${libraryId}:${s.id}`, title: s.title }) }}
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label={`Tag ${s.title}`}
title="Tags"
>
🏷
</button>
</div> </div>
<div className="p-2"> <div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}> <p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={s.title}>
@@ -255,7 +271,7 @@ export default function TvView({ libraryId }: Props) {
{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''} {s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}
</p> </p>
</div> </div>
</button> </div>
))} ))}
</div> </div>
)} )}
@@ -416,12 +432,52 @@ export default function TvView({ libraryId }: Props) {
key={ep.id} key={ep.id}
episode={ep} episode={ep}
onClick={() => setPlayingEpisode(ep)} onClick={() => setPlayingEpisode(ep)}
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
)} )}
{tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div>
<button
onClick={() => setTagPanel(null)}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label="Close"
>
</button>
</div>
<div className="px-5 py-4">
<TagSelector
mediaKey={tagPanel.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }}
/>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -2,5 +2,8 @@ export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') { if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeSecret } = await import('./lib/secret') const { initializeSecret } = await import('./lib/secret')
initializeSecret() initializeSecret()
const { startScheduler } = await import('./lib/scheduler')
startScheduler()
} }
} }

38
src/lib/app-settings.ts Normal file
View File

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

View File

@@ -71,9 +71,46 @@ function initDb(db: Database.Database): void {
tv_loop INTEGER NOT NULL DEFAULT 0, tv_loop INTEGER NOT NULL DEFAULT 0,
tv_muted 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) migrateLibrariesType(db)
seedAppSettings(db)
}
function seedAppSettings(db: Database.Database): void {
const defaults: Record<string, string> = {
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 { function migrateLibrariesType(db: Database.Database): void {

View File

@@ -76,3 +76,55 @@ export function scanDirectory(
return { path: subpath, entries } 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 }
}

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

39
src/lib/scheduler.ts Normal file
View File

@@ -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()
}