mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(library): apply format settings during scan and add creator
This commit is contained in:
@@ -9,7 +9,7 @@ function getCollectionIcon(iconName: string | null) {
|
||||
}
|
||||
return FolderOpen
|
||||
}
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -28,17 +28,25 @@ import {
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import { useCollections } from '@/features/collection/composables/useCollections'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||
import type { Library } from '@projectx/types'
|
||||
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
|
||||
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
||||
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
const { lenses, fetchLenses } = useLenses()
|
||||
const { collections, fetchCollections } = useCollections()
|
||||
const { hasPermission } = usePermissions()
|
||||
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
||||
|
||||
const createLensOpen = ref(false)
|
||||
const createCollectionOpen = ref(false)
|
||||
const createLibraryOpen = ref(false)
|
||||
const scanningLibraryId = ref<number | null>(null)
|
||||
|
||||
const activeLibraryId = computed(() => {
|
||||
const id = route.params.id
|
||||
@@ -65,8 +73,35 @@ const activeCollectionId = computed(() => {
|
||||
return route.name === 'collection' && id ? Number(id) : null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
function scanPct(libraryId: number): number {
|
||||
const p = getProgress(libraryId)
|
||||
if (!p || p.total === 0) return 0
|
||||
return Math.floor((p.processed / p.total) * 100)
|
||||
}
|
||||
|
||||
function onLibrarySaved(library: Library) {
|
||||
createLibraryOpen.value = false
|
||||
subscribeLibrary(library.id)
|
||||
scanningLibraryId.value = library.id
|
||||
fetchLibraries()
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getSocket()
|
||||
await fetchLibraries()
|
||||
for (const lib of libraries.value) {
|
||||
subscribeLibrary(lib.id)
|
||||
}
|
||||
fetchLenses()
|
||||
fetchCollections()
|
||||
})
|
||||
@@ -75,6 +110,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<CreateLensDialog :open="createLensOpen" @close="createLensOpen = false" />
|
||||
<CreateCollectionDialog :open="createCollectionOpen" @close="createCollectionOpen = false" />
|
||||
<LibraryCreatorModal v-if="createLibraryOpen" @close="createLibraryOpen = false" @saved="onLibrarySaved" />
|
||||
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
@@ -115,30 +151,58 @@ onMounted(() => {
|
||||
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
|
||||
Libraries
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupAction v-if="hasPermission('manage_libraries')" tooltip="New Library" @click="createLibraryOpen = true">
|
||||
<Plus />
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="lib in libraries" :key="lib.id">
|
||||
<SidebarMenuButton
|
||||
:is-active="activeLibraryId === lib.id"
|
||||
:tooltip="lib.name"
|
||||
:tooltip="getProgress(lib.id)?.status === 'running' ? `${lib.name} - Scanning ${scanPct(lib.id)}%` : lib.name"
|
||||
class="gap-2.5"
|
||||
@click="router.push({ name: 'library', params: { id: lib.id } })"
|
||||
>
|
||||
<component
|
||||
:is="getLibraryIcon(lib.icon)"
|
||||
:size="15"
|
||||
:class="activeLibraryId === lib.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'"
|
||||
:class="[
|
||||
activeLibraryId === lib.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50',
|
||||
getProgress(lib.id)?.status === 'running' ? 'animate-pulse' : '',
|
||||
]"
|
||||
/>
|
||||
<span :class="activeLibraryId === lib.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
|
||||
{{ lib.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="lib.bookCount !== undefined"
|
||||
v-if="getProgress(lib.id)?.status === 'running'"
|
||||
class="ml-auto shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-xs tabular-nums text-primary group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ scanPct(lib.id) }}%
|
||||
</span>
|
||||
<span
|
||||
v-else-if="lib.bookCount !== undefined"
|
||||
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ lib.bookCount.toLocaleString() }}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<Transition name="scan-progress">
|
||||
<div v-if="getProgress(lib.id)?.status === 'running'" class="px-2 pb-1.5 group-data-[collapsible=icon]:hidden">
|
||||
<div class="h-0.5 w-full rounded-full bg-sidebar-foreground/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
:style="{
|
||||
width: getProgress(lib.id)!.total > 0 ? `${scanPct(lib.id)}%` : '30%',
|
||||
animation: getProgress(lib.id)!.total === 0 ? 'pulse 1.5s ease-in-out infinite' : 'none',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[10px] text-sidebar-foreground/45">
|
||||
{{ getProgress(lib.id)!.processed.toLocaleString() }} / {{ getProgress(lib.id)!.total.toLocaleString() }} books
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -231,3 +295,26 @@ onMounted(() => {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scan-progress-enter-active {
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
max-height 0.25s ease;
|
||||
}
|
||||
.scan-progress-leave-active {
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
max-height 0.35s ease;
|
||||
}
|
||||
.scan-progress-enter-from,
|
||||
.scan-progress-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
.scan-progress-enter-to,
|
||||
.scan-progress-leave-from {
|
||||
opacity: 1;
|
||||
max-height: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -180,7 +180,8 @@ function onManualKeydown(e: KeyboardEvent) {
|
||||
<!-- Prescan button -->
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-border">
|
||||
<div v-if="prescanResult" class="text-xs text-muted-foreground">
|
||||
{{ prescanResult.totalFiles }} total book file{{ prescanResult.totalFiles === 1 ? '' : 's' }} found across all folders
|
||||
{{ prescanResult.totalFiles }} book file{{ prescanResult.totalFiles === 1 ? '' : 's' }} found - actual book count may be lower if multiple
|
||||
formats of the same book exist
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted-foreground">Run a prescan to validate paths before saving.</div>
|
||||
<button
|
||||
|
||||
@@ -141,6 +141,9 @@ function onPatternKeydown(e: KeyboardEvent) {
|
||||
@dragend="fmtDrag.onDragEnd()"
|
||||
>
|
||||
<GripVertical :size="14" class="text-muted-foreground/40 shrink-0" />
|
||||
<span class="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-[10px] font-bold shrink-0">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-mono text-foreground uppercase">{{ fmt }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ FORMAT_LABELS[fmt] ?? fmt }}</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api'
|
||||
import type { Library, OrganizationMode, PrescanResult } from '@projectx/types'
|
||||
|
||||
export const DEFAULT_METADATA_PRECEDENCE = ['embedded', 'opfFile']
|
||||
export const DEFAULT_FORMAT_PRIORITY = ['epub', 'pdf', 'cbz', 'cbr', 'mobi', 'azw3', 'fb2']
|
||||
export const DEFAULT_FORMAT_PRIORITY = ['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2']
|
||||
|
||||
export const METADATA_LABELS: Record<string, string> = {
|
||||
embedded: 'Embedded metadata',
|
||||
@@ -15,8 +15,10 @@ export const FORMAT_LABELS: Record<string, string> = {
|
||||
pdf: 'PDF',
|
||||
cbz: 'CBZ',
|
||||
cbr: 'CBR',
|
||||
cb7: 'CB7',
|
||||
mobi: 'MOBI',
|
||||
azw3: 'AZW3',
|
||||
azw: 'AZW',
|
||||
fb2: 'FB2',
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1770835586812,
|
||||
"when": 1770967055120,
|
||||
"tag": "0000_add_kobo_sync",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { extname, basename } from 'path';
|
||||
|
||||
const PRIMARY_FORMATS = new Set(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'djvu', 'djv', 'txt', 'docx', 'rtf', 'lit']);
|
||||
const PRIMARY_FORMATS = new Set(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2']);
|
||||
|
||||
const COVER_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp']);
|
||||
const COVER_BASENAMES = new Set(['cover', 'folder', 'thumbnail', 'artwork', 'front']);
|
||||
|
||||
@@ -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, scanJobs } from '../../db/schema';
|
||||
import { bookFiles, bookMetadata, books, libraryFolders, libraries, scanJobs } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -40,6 +40,14 @@ export class ScannerRepository {
|
||||
return this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, libraryId));
|
||||
}
|
||||
|
||||
async findLibrarySettings(libraryId: number) {
|
||||
const [row] = await this.db
|
||||
.select({ allowedFormats: libraries.allowedFormats, formatPriority: libraries.formatPriority })
|
||||
.from(libraries)
|
||||
.where(eq(libraries.id, libraryId));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ── Books ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async findBooksByLibraryFolder(libraryFolderId: number) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent,
|
||||
import { MetadataService } from '../metadata/metadata.service';
|
||||
import { ScanGateway } from './scan.gateway';
|
||||
import { ScanJobStore } from './scan-job-store.service';
|
||||
import { classifyFile } from './lib/classify';
|
||||
import { classifyFile, FileRole } from './lib/classify';
|
||||
import { sha256File } from './lib/hash';
|
||||
import { waitForStability } from './lib/stability';
|
||||
import { BookCandidate, FileStat, findBookCandidates } from './lib/walk';
|
||||
@@ -39,15 +39,20 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
throw new ConflictException(`A scan is already running for library ${libraryId}`);
|
||||
}
|
||||
|
||||
const folders = await this.scannerRepo.findLibraryFolders(libraryId);
|
||||
const [folders, settings] = await Promise.all([this.scannerRepo.findLibraryFolders(libraryId), this.scannerRepo.findLibrarySettings(libraryId)]);
|
||||
if (folders.length === 0) throw new NotFoundException(`Library ${libraryId} has no folders`);
|
||||
|
||||
const allowedFormats = settings?.allowedFormats ?? [];
|
||||
const formatPriority = settings?.formatPriority ?? ['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2'];
|
||||
|
||||
const job = await this.scannerRepo.createScanJob(libraryId, triggeredBy);
|
||||
|
||||
this.scanJobStore.create(job.id, libraryId, 0);
|
||||
this.emitFromStore(libraryId, job.id, 'running');
|
||||
|
||||
this.runScan(libraryId, job.id, folders).catch((err) => this.logger.error(`Scan job ${job.id} crashed unexpectedly: ${(err as Error).message}`));
|
||||
this.runScan(libraryId, job.id, folders, allowedFormats, formatPriority).catch((err) =>
|
||||
this.logger.error(`Scan job ${job.id} crashed unexpectedly: ${(err as Error).message}`),
|
||||
);
|
||||
|
||||
return { jobId: job.id };
|
||||
}
|
||||
@@ -86,7 +91,13 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
private async runScan(libraryId: number, jobId: number, folders: Awaited<ReturnType<ScannerRepository['findLibraryFolders']>>): Promise<void> {
|
||||
private async runScan(
|
||||
libraryId: number,
|
||||
jobId: number,
|
||||
folders: Awaited<ReturnType<ScannerRepository['findLibraryFolders']>>,
|
||||
allowedFormats: string[],
|
||||
formatPriority: string[],
|
||||
): Promise<void> {
|
||||
this.logger.log(`Scan job ${jobId} started for library ${libraryId}`);
|
||||
|
||||
type FolderWork = {
|
||||
@@ -101,6 +112,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
const folderWork: FolderWork[] = [];
|
||||
let totalCandidates = 0;
|
||||
|
||||
const allowed = allowedFormats.length > 0 ? new Set(allowedFormats) : null;
|
||||
|
||||
for (const folder of folders) {
|
||||
let candidates: BookCandidate[] = [];
|
||||
try {
|
||||
@@ -109,6 +122,18 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
this.logger.warn(`Cannot walk ${folder.path}: ${err}`);
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
candidates = candidates
|
||||
.map((c) => ({
|
||||
...c,
|
||||
files: c.files.filter((f) => {
|
||||
const { role, format } = classifyFile(f.absolutePath);
|
||||
return role !== 'primary' || (format !== null && allowed.has(format));
|
||||
}),
|
||||
}))
|
||||
.filter((c) => c.files.some((f) => classifyFile(f.absolutePath).role === 'primary'));
|
||||
}
|
||||
|
||||
const [knownBooks, knownFiles] = await Promise.all([
|
||||
this.scannerRepo.findBooksByLibraryFolder(folder.id),
|
||||
this.scannerRepo.findBookFilesByLibraryFolder(folder.id),
|
||||
@@ -125,7 +150,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
try {
|
||||
for (const { id: folderId, path: folderPath, candidates, knownBooks, knownFiles } of folderWork) {
|
||||
const counts = await this.scanFolderCandidates(folderId, libraryId, folderPath, candidates, knownBooks, knownFiles, jobId);
|
||||
const counts = await this.scanFolderCandidates(folderId, libraryId, folderPath, candidates, knownBooks, knownFiles, jobId, formatPriority);
|
||||
totals.addedCount += counts.addedCount;
|
||||
totals.updatedCount += counts.updatedCount;
|
||||
totals.missingCount += counts.missingCount;
|
||||
@@ -154,6 +179,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
knownBooks: Awaited<ReturnType<ScannerRepository['findBooksByLibraryFolder']>>,
|
||||
knownFiles: Awaited<ReturnType<ScannerRepository['findBookFilesByLibraryFolder']>>,
|
||||
jobId: number,
|
||||
formatPriority: string[],
|
||||
): Promise<ScanCounts> {
|
||||
const counts: ScanCounts = { addedCount: 0, updatedCount: 0, missingCount: 0 };
|
||||
|
||||
@@ -166,7 +192,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
const batch = candidates.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const results = await Promise.all(
|
||||
batch.map((c) => this.processCandidate(c, libraryId, libraryFolderId, bookByFolderPath, fileByPath, fileByIno)),
|
||||
batch.map((c) => this.processCandidate(c, libraryId, libraryFolderId, bookByFolderPath, fileByPath, fileByIno, formatPriority)),
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
@@ -200,6 +226,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookByFolderPath: Map<string, { id: number; status: string; folderPath: string }>,
|
||||
fileByPath: Map<string, { id: number; ino: number; sizeBytes: number | null; mtime: Date | null; hash: string | null }>,
|
||||
fileByIno: Map<number, { id: number; absolutePath: string }>,
|
||||
formatPriority: string[],
|
||||
): Promise<{ bookId: number; added: number; updated: number }> {
|
||||
const counts = { added: 0, updated: 0 };
|
||||
const fileCounts: ScanCounts = { addedCount: 0, updatedCount: 0, missingCount: 0 };
|
||||
@@ -208,13 +235,21 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
counts.added += fileCounts.addedCount;
|
||||
counts.updated += fileCounts.updatedCount;
|
||||
|
||||
// Determine which format gets the 'primary' role when multiple primary-format files exist.
|
||||
const primaryFiles = candidate.files.filter((f) => classifyFile(f.absolutePath).role === 'primary');
|
||||
const chosenFormat =
|
||||
primaryFiles.length > 1 ? (formatPriority.find((fmt) => primaryFiles.some((f) => classifyFile(f.absolutePath).format === fmt)) ?? null) : null;
|
||||
|
||||
for (const fileStat of candidate.files) {
|
||||
const { format } = classifyFile(fileStat.absolutePath);
|
||||
const { format, role: classifiedRole } = classifyFile(fileStat.absolutePath);
|
||||
const role: FileRole =
|
||||
chosenFormat !== null && classifiedRole === 'primary' ? (format === chosenFormat ? 'primary' : 'supplementary') : classifiedRole;
|
||||
|
||||
const fileCount: ScanCounts = { addedCount: 0, updatedCount: 0, missingCount: 0 };
|
||||
let isNew: boolean;
|
||||
|
||||
try {
|
||||
isNew = await this.processFile(fileStat, book.id, libraryFolderId, fileByPath, fileByIno, fileCount);
|
||||
isNew = await this.processFile(fileStat, format, role, book.id, libraryFolderId, fileByPath, fileByIno, fileCount);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to process file ${fileStat.absolutePath}: ${err}`);
|
||||
continue;
|
||||
@@ -265,6 +300,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
private async processFile(
|
||||
fileStat: FileStat,
|
||||
format: string | null,
|
||||
role: FileRole,
|
||||
bookId: number,
|
||||
libraryFolderId: number,
|
||||
fileByPath: Map<string, { id: number; ino: number; sizeBytes: number | null; mtime: Date | null; hash: string | null }>,
|
||||
@@ -273,8 +310,6 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
): Promise<boolean> {
|
||||
await waitForStability(fileStat.absolutePath);
|
||||
|
||||
const { format, role } = classifyFile(fileStat.absolutePath);
|
||||
|
||||
// 1. Path match — file didn't move.
|
||||
const byPath = fileByPath.get(fileStat.absolutePath);
|
||||
if (byPath) {
|
||||
|
||||
Reference in New Issue
Block a user