mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): add live book grid during active scan
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user