feat(library): apply format settings during scan and add creator

This commit is contained in:
neon
2026-02-12 12:24:39 -06:00
parent f3a52302ca
commit 964115655b
8 changed files with 156 additions and 20 deletions
+92 -5
View File
@@ -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',
}
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1770835586812,
"when": 1770967055120,
"tag": "0000_add_kobo_sync",
"breakpoints": true
}
+1 -1
View File
@@ -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) {
+45 -10
View File
@@ -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) {