feat(scanner): add live book grid during active scan

This commit is contained in:
neon
2026-04-08 05:02:57 -06:00
parent 0e9ae2ae10
commit 6de8e8bc60
13 changed files with 272 additions and 29 deletions
+16 -12
View File
@@ -48,12 +48,22 @@ const { lenses, fetchLenses, reorderLenses } = useLenses()
const { collections, fetchCollections, reorderCollections } = useCollections()
const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const refreshedFor = new Set<number>()
watch(progressMap, (map) => {
for (const [libraryId, event] of map.entries()) {
if (event.status === 'completed' && !refreshedFor.has(libraryId)) {
refreshedFor.add(libraryId)
refreshLibraries()
setTimeout(() => refreshedFor.delete(libraryId), 5000)
}
}
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const createLensOpen = ref(false)
const createCollectionOpen = ref(false)
const createLibraryOpen = ref(false)
const scanningLibraryId = ref<number | null>(null)
const { isOpen: librariesOpen, toggle: toggleLibraries } = useSidebarSection('libraries')
const { isOpen: lensesOpen, toggle: toggleLenses } = useSidebarSection('lenses')
@@ -108,19 +118,13 @@ function scanPct(libraryId: number): number {
function onLibrarySaved(library: Library) {
createLibraryOpen.value = false
subscribeLibrary(library.id)
scanningLibraryId.value = library.id
refreshLibraries()
}
watch(progressMap, (map) => {
if (scanningLibraryId.value === null) return
const event = map.get(scanningLibraryId.value)
if (event?.status === 'completed') {
const id = scanningLibraryId.value
scanningLibraryId.value = null
router.push({ name: 'library', params: { id } })
}
})
const sortKey = `projectx:sort:library:${library.id}`
localStorage.setItem(sortKey, JSON.stringify([{ field: 'addedAt', dir: 'desc' }]))
router.push({ name: 'library', params: { id: library.id } })
}
onMounted(async () => {
getSocket()
+1 -1
View File
@@ -4,7 +4,7 @@ import { storage } from '@/services/storage'
export type CardOverlayKey = 'series' | 'progress-bar' | 'progress-pill' | 'format' | 'rating' | 'new' | 'read-status'
export type AuthorCoverShape = 'square' | 'circle'
const coverSize = ref(storage.get('coverSize', 140))
const coverSize = ref(storage.get('coverSize', 130))
const gridGap = ref(storage.get('gridGap', 20))
const viewMode = ref<'grid' | 'list'>(storage.get('viewMode', 'grid'))
const cardOverlays = ref<CardOverlayKey[]>(storage.get('cardOverlays', ['progress-bar', 'format', 'rating', 'read-status']))
@@ -12,7 +12,7 @@ function gridGapKey(type: ViewType, id: number) {
}
export function useViewDisplaySettings(viewType: ViewType, viewId: Readonly<Ref<number | null>>) {
const globalCoverSize = storage.get('coverSize', 140)
const globalCoverSize = storage.get('coverSize', 130)
const globalGridGap = storage.get('gridGap', 20)
const coverSize = ref(globalCoverSize)
@@ -15,10 +15,12 @@ const props = withDefaults(
gridGap: number
selectionMode?: boolean
isSelected?: (bookId: number) => boolean
newBookIds?: Set<number>
}>(),
{
selectionMode: false,
isSelected: undefined,
newBookIds: () => new Set<number>(),
},
)
@@ -92,7 +94,7 @@ const scrollerStyle = computed(() => ({
class="book-grid-scroller"
>
<template #default="{ item: book }">
<div class="book-grid-cell">
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
<BookCoverCard
:book="book"
:selection-mode="selectionMode"
@@ -122,4 +124,19 @@ const scrollerStyle = computed(() => ({
padding-right: var(--book-grid-gap);
padding-bottom: var(--book-grid-gap);
}
.book-grid-cell--new {
animation: book-enter 0.35s ease-out both;
}
@keyframes book-enter {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
@@ -39,7 +39,7 @@ const ALL_SECTIONS: { id: SectionId; label: string; icon: unknown; component: un
]
// Access is only meaningful after a library exists
const sections = computed(() => (mode.value === 'create' ? ALL_SECTIONS.slice(0, 6) : ALL_SECTIONS))
const sections = computed(() => (mode.value === 'create' ? ALL_SECTIONS.slice(0, 7) : ALL_SECTIONS))
// ── Stepper state ──────────────────────────────────────────────────────────
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ScanProgressEvent } from '@projectx/types'
const props = defineProps<{
progress: ScanProgressEvent | undefined
}>()
const isActive = computed(() => props.progress?.status === 'running')
const pct = computed(() => {
if (!props.progress || props.progress.total === 0) return 0
return Math.min(100, Math.floor((props.progress.processed / props.progress.total) * 100))
})
const label = computed(() => {
if (!props.progress) return ''
const p = props.progress
const parts = [`${p.processed.toLocaleString()}/${p.total.toLocaleString()} files`]
if (p.added > 0) parts.push(`${p.added} ${p.added === 1 ? 'book' : 'books'} found`)
return parts.join(' · ')
})
</script>
<template>
<div v-if="isActive" class="flex items-center gap-3 px-4 py-2 rounded-lg bg-primary/5 border border-primary/15">
<div class="relative h-1.5 flex-1 rounded-full bg-primary/10 overflow-hidden">
<div class="absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-300 ease-out" :style="{ width: `${pct}%` }" />
</div>
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ label }}</span>
</div>
</template>
@@ -0,0 +1,71 @@
import { onUnmounted, ref, type Ref } from 'vue'
import type { BookCard, ScanBooksAddedEvent } from '@projectx/types'
import { getSocket } from './useScanProgress'
const THROTTLE_MS = 500
const ANIMATION_DURATION_MS = 400
const ANIMATE_BATCH_LIMIT = 6
export function useLiveScanBooks(libraryId: Ref<number | null>, existingBooks: Ref<BookCard[]>, total?: Ref<number>) {
const newBookIds = ref(new Set<number>())
let buffer: BookCard[] = []
let flushTimer: ReturnType<typeof setTimeout> | null = null
let handler: ((event: ScanBooksAddedEvent) => void) | null = null
function flush() {
flushTimer = null
if (buffer.length === 0) return
const batch = buffer
buffer = []
const existingIdSet = new Set(existingBooks.value.map((b) => b.id))
const deduped = batch.filter((b) => !existingIdSet.has(b.id))
if (deduped.length === 0) return
existingBooks.value = [...deduped, ...existingBooks.value]
if (total) total.value += deduped.length
if (deduped.length <= ANIMATE_BATCH_LIMIT) {
const ids = new Set(deduped.map((b) => b.id))
newBookIds.value = new Set([...newBookIds.value, ...ids])
setTimeout(() => {
const current = new Set(newBookIds.value)
for (const id of ids) current.delete(id)
newBookIds.value = current
}, ANIMATION_DURATION_MS)
}
}
function onBooksAdded(event: ScanBooksAddedEvent) {
if (event.libraryId !== libraryId.value) return
buffer.push(...event.books)
if (!flushTimer) {
flushTimer = setTimeout(flush, THROTTLE_MS)
}
}
function start() {
if (handler) return
handler = onBooksAdded
getSocket().on('scan:books:added', handler)
}
function stop() {
if (handler) {
getSocket().off('scan:books:added', handler)
handler = null
}
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
buffer = []
newBookIds.value = new Set()
}
onUnmounted(stop)
return { newBookIds, start, stop }
}
@@ -29,7 +29,6 @@ const stats = ref<Record<number, LibraryStats>>({})
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)
@@ -99,10 +98,6 @@ watch(progressMap, (map) => {
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 } })
}
}
}
})
@@ -169,8 +164,10 @@ async function onSaved(library: LibraryType) {
editingLibrary.value = null
subscribeLibrary(library.id)
if (isNew) {
pendingNavigateLibraryId.value = library.id
toast.success(`Library "${library.name}" created`)
const sortKey = `projectx:sort:library:${library.id}`
localStorage.setItem(sortKey, JSON.stringify([{ field: 'addedAt', dir: 'desc' }]))
router.push({ name: 'library', params: { id: library.id } })
} else {
toast.success(`Library "${library.name}" updated`)
}
+20 -3
View File
@@ -21,6 +21,8 @@ import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { useLiveScanBooks } from '@/features/scanner/composables/useLiveScanBooks'
import ScanProgressBar from '@/features/scanner/components/ScanProgressBar.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
@@ -131,11 +133,19 @@ function forgetSavedFilter() {
localStorage.removeItem(getFilterKey(libraryId.value))
}
const { subscribeLibrary } = useScanProgress()
const { subscribeLibrary, getProgress, isScanning } = useScanProgress()
const { newBookIds, start: startLiveScan, stop: stopLiveScan } = useLiveScanBooks(libraryId, books, total)
const scanProgress = computed(() => (libraryId.value !== null ? getProgress(libraryId.value) : undefined))
watch(
libraryId,
(id) => {
if (id !== null) subscribeLibrary(id)
if (id !== null) {
subscribeLibrary(id)
startLiveScan()
} else {
stopLiveScan()
}
},
{ immediate: true },
)
@@ -376,6 +386,8 @@ function handleBookAction(book: BookCard, action: BookActionType) {
<template v-else>
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<ScanProgressBar :progress="scanProgress" class="mb-3" />
<!-- Filter builder panel -->
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
<div class="flex items-center justify-between mb-3">
@@ -445,7 +457,11 @@ function handleBookAction(book: BookCard, action: BookActionType) {
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<BookOpen :size="28" class="text-muted-foreground/70" />
</div>
<div class="flex flex-col gap-1">
<div v-if="libraryId !== null && isScanning(libraryId)" class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">Scanning your library...</p>
<p class="text-xs text-muted-foreground max-w-xs">Books will appear here as they are discovered.</p>
</div>
<div v-else class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">Your library is empty</p>
<p class="text-xs text-muted-foreground max-w-xs">Once you add books to this library, they will appear here.</p>
</div>
@@ -459,6 +475,7 @@ function handleBookAction(book: BookCard, action: BookActionType) {
:grid-gap="gridGap"
:selection-mode="selectionMode"
:is-selected="isSelected"
:new-book-ids="newBookIds"
@action="handleBookAction"
@select="handleSelect"
/>
+5
View File
@@ -36,3 +36,8 @@ export interface BookMovedEvent {
libraryId: number;
bookIds: number[];
}
export interface ScanBooksAddedEvent {
libraryId: number;
books: import("./book").BookCard[];
}
@@ -9,6 +9,7 @@ import type {
BookRestoredEvent,
CoverRefreshedEvent,
CoverRefreshProgressEvent,
ScanBooksAddedEvent,
ScanProgressEvent,
} from '@projectx/types';
import { AuthService } from '../auth/auth.service';
@@ -94,4 +95,8 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
this.logger.debug(`[scanner.ws_emit] [book:moved] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
this.server?.to(`library:${event.libraryId}`).emit('book:moved', event);
}
emitBooksAdded(event: ScanBooksAddedEvent): void {
this.server?.to(`library:${event.libraryId}`).emit('scan:books:added', event);
}
}
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookFiles, bookMetadata, books, libraryFolders, libraries, scanJobs } from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, genres, libraryFolders, libraries, scanJobs } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -311,4 +311,45 @@ export class ScannerRepository {
async updateBookFolderPath(bookId: number, folderPath: string) {
await this.db.update(books).set({ folderPath, updatedAt: new Date() }).where(eq(books.id, bookId));
}
async findBookCardData(bookIds: number[]) {
if (bookIds.length === 0) return { rows: [], authorRows: [], fileRows: [], genreRows: [] };
const [rows, authorRows, fileRows, genreRows] = await Promise.all([
this.db
.select({
id: books.id,
status: books.status,
primaryFileId: books.primaryFileId,
folderPath: books.folderPath,
addedAt: books.addedAt,
title: bookMetadata.title,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
rating: bookMetadata.rating,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds)),
this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(bookAuthors.displayOrder),
this.db
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds)),
this.db
.select({ bookId: bookGenres.bookId, name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(inArray(bookGenres.bookId, bookIds)),
]);
return { rows, authorRows, fileRows, genreRows };
}
}
+58 -3
View File
@@ -1,6 +1,6 @@
import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common';
import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanBooksAddedEvent, ScanProgressEvent } from '@projectx/types';
import { BookMetadataFetchOrchestratorService } from '../book-metadata-fetch/book-metadata-fetch-orchestrator.service';
import { MetadataService } from '../metadata/metadata.service';
import { ScanGateway } from './scan.gateway';
@@ -13,9 +13,12 @@ import { fingerprintFile } from './lib/hash';
import { waitForStability } from './lib/stability';
import { BookCandidate, FileStat, findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk';
import { ScannerRepository } from './scanner.repository';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'cb7', 'fb2', 'pdf', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']);
const BATCH_SIZE = 5;
const BOOK_EMIT_BUFFER_SIZE = 30;
const BOOK_EMIT_FLUSH_INTERVAL_MS = 1500;
type OrganizationMode = 'book_per_file' | 'book_per_folder';
interface ScanCounts {
@@ -40,6 +43,55 @@ export class ScannerService implements OnApplicationBootstrap {
@Optional() private readonly autoFetchOrchestrator?: BookMetadataFetchOrchestratorService,
) {}
// ── Live book emission buffer ──────────────────────────────────────────────
private readonly bookEmitBuffer = new Map<number, number[]>();
private readonly bookEmitTimers = new Map<number, ReturnType<typeof setTimeout>>();
private bufferBookForEmit(libraryId: number, bookId: number): void {
let ids = this.bookEmitBuffer.get(libraryId);
if (!ids) {
ids = [];
this.bookEmitBuffer.set(libraryId, ids);
}
ids.push(bookId);
if (ids.length >= BOOK_EMIT_BUFFER_SIZE) {
this.flushBookEmitBuffer(libraryId);
return;
}
if (!this.bookEmitTimers.has(libraryId)) {
this.bookEmitTimers.set(
libraryId,
setTimeout(() => this.flushBookEmitBuffer(libraryId), BOOK_EMIT_FLUSH_INTERVAL_MS),
);
}
}
private flushBookEmitBuffer(libraryId: number): void {
const timer = this.bookEmitTimers.get(libraryId);
if (timer) clearTimeout(timer);
this.bookEmitTimers.delete(libraryId);
const ids = this.bookEmitBuffer.get(libraryId);
this.bookEmitBuffer.delete(libraryId);
if (!ids || ids.length === 0) return;
this.buildAndEmitBookCards(libraryId, ids).catch((err) => {
this.logger.warn(
`[scanner.emit_books_added] [fail] libraryId=${libraryId} bookCount=${ids.length} errorClass=${err instanceof Error ? err.name : 'Error'} error="${(err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"')}" - failed to emit added books`,
);
});
}
private async buildAndEmitBookCards(libraryId: number, bookIds: number[]): Promise<void> {
const { rows, authorRows, fileRows, genreRows } = await this.scannerRepo.findBookCardData(bookIds);
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, []);
if (cards.length > 0) {
this.scanGateway.emitBooksAdded({ libraryId, books: cards } satisfies ScanBooksAddedEvent);
}
}
async onApplicationBootstrap(): Promise<void> {
await this.scannerRepo.failAllRunningJobs('Server restarted during scan');
}
@@ -439,6 +491,7 @@ export class ScannerService implements OnApplicationBootstrap {
);
this.emitFromStore(libraryId, jobId, 'failed', message);
} finally {
this.flushBookEmitBuffer(libraryId);
this.scanJobStore.delete(libraryId);
}
}
@@ -483,6 +536,9 @@ export class ScannerService implements OnApplicationBootstrap {
seenBookIds.add(r.bookId);
counts.addedCount += r.added;
counts.updatedCount += r.updated;
if (r.added > 0) {
this.bufferBookForEmit(libraryId, r.bookId);
}
}
const entry = this.scanJobStore.increment(libraryId, { processed: batch.length });
@@ -566,8 +622,7 @@ export class ScannerService implements OnApplicationBootstrap {
continue;
}
counts.added += fileCount.addedCount;
counts.updated += fileCount.updatedCount;
counts.updated += fileCount.addedCount + fileCount.updatedCount;
if (processResult.fileId !== null) {
registeredFiles.push({