feat(scanner): add live scan progress via WebSocket and auto-scan

This commit is contained in:
neon
2026-01-19 16:48:11 -06:00
parent 2033a3d719
commit 73d66b75e3
17 changed files with 541 additions and 73 deletions
+7 -6
View File
@@ -27,17 +27,18 @@
"pdfjs-dist": "^5.4.530",
"pinia": "^3.0.4",
"reka-ui": "^2.7.0",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.26",
"vue": "^3.5.27",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9",
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.3",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^22.19.6",
"@types/node": "^22.19.7",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.6",
"@vue/eslint-config-typescript": "^14.6.0",
@@ -45,12 +46,12 @@
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.39.0",
"eslint-plugin-vue": "~10.6.2",
"eslint-plugin-oxlint": "~1.41.0",
"eslint-plugin-vue": "~10.7.0",
"jiti": "^2.6.1",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.39.0",
"oxlint": "~1.41.0",
"prettier": "3.8.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
@@ -0,0 +1,64 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import type { ScanProgressEvent } from '@projectx/types'
import { getAccessToken } from '@/lib/api'
let socket: Socket | null = null
const progressMap = ref<Map<number, ScanProgressEvent>>(new Map())
const subscribedLibraries = new Set<number>()
function getSocket(): Socket {
if (!socket) {
// Use a callback so getAccessToken() is called at (re)connect time, not just once at creation.
socket = io('/scan', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
autoConnect: true,
})
socket.on('scan:progress', (event: ScanProgressEvent) => {
progressMap.value.set(event.libraryId, event)
progressMap.value = new Map(progressMap.value)
if (event.status !== 'running') {
// Keep the final event for a short time so UI can show completion, then clear.
setTimeout(() => {
progressMap.value.delete(event.libraryId)
progressMap.value = new Map(progressMap.value)
}, 3000)
}
})
socket.on('disconnect', () => {
// Clear stale progress bars but keep subscribedLibraries so we can re-subscribe on reconnect.
progressMap.value = new Map()
})
socket.on('connect', () => {
// Re-subscribe to all libraries after reconnect (server-side rooms are per-socket).
for (const id of subscribedLibraries) {
socket!.emit('subscribe:library', id)
}
})
}
return socket
}
export { getSocket }
export function useScanProgress() {
function subscribeLibrary(libraryId: number): void {
if (subscribedLibraries.has(libraryId)) return
subscribedLibraries.add(libraryId)
getSocket().emit('subscribe:library', libraryId)
}
function getProgress(libraryId: number): ScanProgressEvent | undefined {
return progressMap.value.get(libraryId)
}
function isScanning(libraryId: number): boolean {
return progressMap.value.get(libraryId)?.status === 'running'
}
return { subscribeLibrary, getProgress, isScanning, progressMap }
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { FolderOpen, Plus, RefreshCw, Pencil, Trash2, Library } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
@@ -7,16 +7,18 @@ import { api } from '@/lib/api'
import type { Library as LibraryType, LibraryStats } from '@projectx/types'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
const route = useRoute()
const router = useRouter()
const { libraries, fetchLibraries } = useLibraries()
const { subscribeLibrary, getProgress, isScanning, progressMap } = useScanProgress()
const stats = ref<Record<number, LibraryStats>>({})
const scanning = ref<Record<number, boolean>>({})
const scanningAll = ref(false)
const creatorOpen = ref(false)
const editingLibrary = ref<LibraryType | null>(null)
const pendingNavigateLibraryId = ref<number | null>(null)
const deletingLibrary = ref<LibraryType | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
@@ -30,24 +32,48 @@ async function loadAllStats() {
)
}
function subscribeAll() {
for (const lib of libraries.value) {
subscribeLibrary(lib.id)
}
}
onMounted(async () => {
getSocket()
await fetchLibraries()
subscribeAll()
loadAllStats()
})
const statsReloadedFor = new Set<number>()
watch(progressMap, (map) => {
for (const [libraryId, event] of map) {
if (event.status === 'completed' && !statsReloadedFor.has(libraryId)) {
statsReloadedFor.add(libraryId)
api(`/api/libraries/${libraryId}/stats`).then(async (res) => {
if (res.ok) stats.value[libraryId] = await res.json()
setTimeout(() => statsReloadedFor.delete(libraryId), 5000)
})
if (pendingNavigateLibraryId.value === libraryId) {
pendingNavigateLibraryId.value = null
router.push({ name: 'library', params: { id: libraryId } })
}
}
}
})
async function scan(lib: LibraryType) {
scanning.value[lib.id] = true
try {
const res = await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
if (res.ok) {
toast.success(`Scan started for "${lib.name}"`)
subscribeLibrary(lib.id)
} else {
toast.error(`Failed to start scan for "${lib.name}"`)
}
} catch {
toast.error(`Failed to start scan for "${lib.name}"`)
} finally {
scanning.value[lib.id] = false
}
}
@@ -58,6 +84,7 @@ async function scanAll() {
const failed = results.filter((r) => !r.ok).length
if (failed === 0) {
toast.success('Scan started for all libraries')
subscribeAll()
} else {
toast.error(`${failed} librar${failed === 1 ? 'y' : 'ies'} failed to start`)
}
@@ -79,8 +106,11 @@ function openEdit(lib: LibraryType) {
}
async function onSaved(library: LibraryType) {
const isNew = !editingLibrary.value
creatorOpen.value = false
editingLibrary.value = null
subscribeLibrary(library.id)
if (isNew) pendingNavigateLibraryId.value = library.id
await fetchLibraries()
loadAllStats()
}
@@ -129,8 +159,20 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function scanProgressLabel(libraryId: number): string {
const p = getProgress(libraryId)
if (!p) return ''
if (p.status === 'running') {
if (p.total === 0) return 'Scanning...'
const pct = Math.floor((p.processed / p.total) * 100)
return `Scanning ${pct}% (${p.processed}/${p.total})`
}
if (p.status === 'completed') return `Done - ${p.added} added, ${p.updated} updated`
if (p.status === 'failed') return p.errorMessage ? `Failed: ${p.errorMessage}` : 'Scan failed'
return ''
}
function iconComponent(iconName: string | null | undefined) {
// Dynamic Lucide icon lookup via pre-imported default
return iconName ? null : Library
}
</script>
@@ -150,7 +192,7 @@ function iconComponent(iconName: string | null | undefined) {
@click="scanAll"
>
<RefreshCw :size="12" :class="scanningAll ? 'animate-spin' : ''" />
{{ scanningAll ? 'Scanning…' : 'Scan All' }}
{{ scanningAll ? 'Scanning...' : 'Scan All' }}
</button>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
@@ -166,11 +208,18 @@ function iconComponent(iconName: string | null | undefined) {
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="lib in libraries" :key="lib.id" class="bg-card px-5 py-4">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<RouterLink
:to="{ name: 'library', params: { id: lib.id } }"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
>
<FolderOpen :size="17" class="text-primary" />
</div>
</RouterLink>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">{{ lib.name }}</p>
<RouterLink
:to="{ name: 'library', params: { id: lib.id } }"
class="text-sm font-medium text-foreground hover:text-primary transition-colors truncate block"
>{{ lib.name }}</RouterLink
>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="stats[lib.id]" class="text-xs text-muted-foreground">
{{ stats[lib.id].totalBooks }} book{{ stats[lib.id].totalBooks === 1 ? '' : 's' }}
@@ -181,6 +230,24 @@ function iconComponent(iconName: string | null | undefined) {
</span>
<span v-if="lib.watch" class="text-xs text-primary/70">Watching</span>
</div>
<!-- Scan progress bar -->
<div v-if="getProgress(lib.id)" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs" :class="getProgress(lib.id)?.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ scanProgressLabel(lib.id) }}
</span>
</div>
<div v-if="getProgress(lib.id)?.status === 'running'" class="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{
width:
getProgress(lib.id)!.total > 0 ? `${Math.floor((getProgress(lib.id)!.processed / getProgress(lib.id)!.total) * 100)}%` : '100%',
animation: getProgress(lib.id)!.total === 0 ? 'pulse 1.5s ease-in-out infinite' : 'none',
}"
/>
</div>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<button
@@ -199,11 +266,11 @@ function iconComponent(iconName: string | null | undefined) {
</button>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="scanning[lib.id]"
:disabled="isScanning(lib.id)"
@click="scan(lib)"
>
<RefreshCw :size="12" :class="scanning[lib.id] ? 'animate-spin' : ''" />
{{ scanning[lib.id] ? 'Scanning…' : 'Scan' }}
<RefreshCw :size="12" :class="isScanning(lib.id) ? 'animate-spin' : ''" />
{{ isScanning(lib.id) ? 'Scanning...' : 'Scan' }}
</button>
</div>
</div>
+5
View File
@@ -16,6 +16,11 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3000',
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
changeOrigin: true,
},
},
},
})