feat(cover): fix EPUB cover extraction for namespace-prefixed OPF

This commit is contained in:
neon
2026-01-25 04:45:28 -06:00
parent 0cbcfe7932
commit ee05c50b72
14 changed files with 259 additions and 51 deletions
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BookCard, BookFileRef } from '@projectx/types' import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover' import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next' import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
const router = useRouter() const router = useRouter()
@@ -24,9 +25,21 @@ const seriesLine = computed(() => {
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null) const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const extraFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value)) const extraFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value))
const { getVersion } = useCoverVersions()
const coverSrc = computed(() => {
const base = `/api/books/${props.book.id}/thumbnail`
const v = getVersion(props.book.id)
return v ? `${base}?t=${v}` : base
})
const coverLoaded = ref(false) const coverLoaded = ref(false)
const coverFailed = ref(false) const coverFailed = ref(false)
watch(coverSrc, () => {
coverLoaded.value = false
coverFailed.value = false
})
function openFile(file: BookFileRef) { function openFile(file: BookFileRef) {
router.push({ router.push({
name: 'reader', name: 'reader',
@@ -50,7 +63,7 @@ function openFile(file: BookFileRef) {
> >
<img <img
v-if="!coverFailed" v-if="!coverFailed"
:src="`/api/books/${book.id}/thumbnail`" :src="coverSrc"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200" class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'" :class="coverLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy" loading="lazy"
@@ -1,12 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useCoverVersions } from '../composables/useCoverVersions'
const props = defineProps<{ const props = defineProps<{
bookId: number bookId: number
type?: 'thumbnail' | 'cover' type?: 'thumbnail' | 'cover'
class?: string class?: string
alt?: string alt?: string
}>() }>()
const { getVersion } = useCoverVersions()
const src = computed(() => {
const base = `/api/books/${props.bookId}/${props.type ?? 'thumbnail'}`
const v = getVersion(props.bookId)
return v ? `${base}?t=${v}` : base
})
</script> </script>
<template> <template>
<img :src="`/api/books/${props.bookId}/${props.type ?? 'thumbnail'}`" :class="props.class" :alt="props.alt ?? ''" loading="lazy" /> <img :src="src" :class="props.class" :alt="props.alt ?? ''" loading="lazy" />
</template> </template>
@@ -0,0 +1,15 @@
import { ref } from 'vue'
const versions = ref<Map<number, number>>(new Map())
export function useCoverVersions() {
function bumpVersion(bookId: number) {
versions.value = new Map(versions.value).set(bookId, Date.now())
}
function getVersion(bookId: number): number | undefined {
return versions.value.get(bookId)
}
return { getVersion, bumpVersion }
}
@@ -1,10 +1,12 @@
import { ref } from 'vue' import { ref } from 'vue'
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import type { ScanProgressEvent } from '@projectx/types' import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types'
import { getAccessToken } from '@/lib/api' import { getAccessToken } from '@/lib/api'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
let socket: Socket | null = null let socket: Socket | null = null
const progressMap = ref<Map<number, ScanProgressEvent>>(new Map()) const progressMap = ref<Map<number, ScanProgressEvent>>(new Map())
const coverRefreshMap = ref<Map<number, CoverRefreshProgressEvent>>(new Map())
const subscribedLibraries = new Set<number>() const subscribedLibraries = new Set<number>()
function getSocket(): Socket { function getSocket(): Socket {
@@ -28,6 +30,20 @@ function getSocket(): Socket {
} }
}) })
socket.on('cover:refresh:progress', (event: CoverRefreshProgressEvent) => {
coverRefreshMap.value = new Map(coverRefreshMap.value).set(event.libraryId, event)
if (event.status === 'completed') {
setTimeout(() => {
coverRefreshMap.value.delete(event.libraryId)
coverRefreshMap.value = new Map(coverRefreshMap.value)
}, 3000)
}
})
socket.on('cover:refreshed', (event: CoverRefreshedEvent) => {
useCoverVersions().bumpVersion(event.bookId)
})
socket.on('disconnect', () => { socket.on('disconnect', () => {
// Clear stale progress bars but keep subscribedLibraries so we can re-subscribe on reconnect. // Clear stale progress bars but keep subscribedLibraries so we can re-subscribe on reconnect.
progressMap.value = new Map() progressMap.value = new Map()
@@ -60,5 +76,13 @@ export function useScanProgress() {
return progressMap.value.get(libraryId)?.status === 'running' return progressMap.value.get(libraryId)?.status === 'running'
} }
return { subscribeLibrary, getProgress, isScanning, progressMap } function getCoverRefreshProgress(libraryId: number): CoverRefreshProgressEvent | undefined {
return coverRefreshMap.value.get(libraryId)
}
function isRefreshingCovers(libraryId: number): boolean {
return coverRefreshMap.value.get(libraryId)?.status === 'running'
}
return { subscribeLibrary, getProgress, isScanning, progressMap, getCoverRefreshProgress, isRefreshingCovers, coverRefreshMap }
} }
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { FolderOpen, Plus, RefreshCw, Pencil, Trash2, Library } from 'lucide-vue-next' import { FolderOpen, Plus, RefreshCw, Pencil, Trash2, Library, Images } from 'lucide-vue-next'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { Library as LibraryType, LibraryStats } from '@projectx/types' import type { Library as LibraryType, LibraryStats } from '@projectx/types'
@@ -12,7 +12,7 @@ import { useScanProgress, getSocket } from '@/features/scanner/composables/useSc
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { libraries, fetchLibraries } = useLibraries() const { libraries, fetchLibraries } = useLibraries()
const { subscribeLibrary, getProgress, isScanning, progressMap } = useScanProgress() const { subscribeLibrary, getProgress, isScanning, progressMap, getCoverRefreshProgress, isRefreshingCovers } = useScanProgress()
const stats = ref<Record<number, LibraryStats>>({}) const stats = ref<Record<number, LibraryStats>>({})
const scanningAll = ref(false) const scanningAll = ref(false)
@@ -77,6 +77,15 @@ async function scan(lib: LibraryType) {
} }
} }
async function refreshCovers(lib: LibraryType) {
try {
const res = await api(`/api/scanner/libraries/${lib.id}/refresh-covers`, { method: 'POST' })
if (!res.ok) toast.error(`Failed to refresh covers for "${lib.name}"`)
} catch {
toast.error(`Failed to refresh covers for "${lib.name}"`)
}
}
async function scanAll() { async function scanAll() {
scanningAll.value = true scanningAll.value = true
try { try {
@@ -172,6 +181,17 @@ function scanProgressLabel(libraryId: number): string {
return '' return ''
} }
function coverRefreshLabel(libraryId: number): string {
const p = getCoverRefreshProgress(libraryId)
if (!p) return ''
if (p.status === 'running') {
const pct = p.total > 0 ? Math.floor((p.processed / p.total) * 100) : 0
return `Refreshing covers ${pct}% (${p.processed}/${p.total})`
}
if (p.status === 'completed') return `Covers refreshed (${p.total} processed)`
return ''
}
function iconComponent(iconName: string | null | undefined) { function iconComponent(iconName: string | null | undefined) {
return iconName ? null : Library return iconName ? null : Library
} }
@@ -248,6 +268,23 @@ function iconComponent(iconName: string | null | undefined) {
/> />
</div> </div>
</div> </div>
<!-- Cover refresh progress bar -->
<div v-if="getCoverRefreshProgress(lib.id)" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-muted-foreground">{{ coverRefreshLabel(lib.id) }}</span>
</div>
<div v-if="getCoverRefreshProgress(lib.id)?.status === 'running'" class="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-accent transition-all duration-300"
:style="{
width:
getCoverRefreshProgress(lib.id)!.total > 0
? `${Math.floor((getCoverRefreshProgress(lib.id)!.processed / getCoverRefreshProgress(lib.id)!.total) * 100)}%`
: '0%',
}"
/>
</div>
</div>
</div> </div>
<div class="flex items-center gap-1.5 shrink-0"> <div class="flex items-center gap-1.5 shrink-0">
<button <button
@@ -264,6 +301,14 @@ function iconComponent(iconName: string | null | undefined) {
> >
<Trash2 :size="13" /> <Trash2 :size="13" />
</button> </button>
<button
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
title="Refresh covers"
:disabled="isRefreshingCovers(lib.id)"
@click="refreshCovers(lib)"
>
<Images :size="13" :class="isRefreshingCovers(lib.id) ? 'animate-pulse' : ''" />
</button>
<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" 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="isScanning(lib.id)" :disabled="isScanning(lib.id)"
+12
View File
@@ -9,3 +9,15 @@ export interface ScanProgressEvent {
missing: number; missing: number;
errorMessage?: string; errorMessage?: string;
} }
export interface CoverRefreshProgressEvent {
libraryId: number;
processed: number;
total: number;
status: 'running' | 'completed';
}
export interface CoverRefreshedEvent {
bookId: number;
libraryId: number;
}
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1769371048439, "when": 1769384427779,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
+56 -4
View File
@@ -1,11 +1,31 @@
import * as unzipper from 'unzipper'; import * as unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }); const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true });
function resolvePath(base: string, rel: string): string {
const parts = (base + rel).split('/');
const resolved: string[] = [];
for (const part of parts) {
if (part === '..') resolved.pop();
else if (part !== '.') resolved.push(part);
}
return resolved.join('/');
}
function findInZip(zip: unzipper.CentralDirectory, rawHref: string, opfDir: string) {
const decoded = decodeURIComponent(rawHref);
const candidates = [resolvePath(opfDir, decoded), resolvePath(opfDir, rawHref), decoded, rawHref];
const lower = candidates.map((c) => c.toLowerCase());
return zip.files.find((f) => {
const p = f.path.startsWith('/') ? f.path.slice(1) : f.path;
return candidates.includes(p) || lower.includes(p.toLowerCase());
});
}
/** /**
* Extract the cover image bytes from an EPUB file. * Extract the cover image bytes from an EPUB file.
* Looks for cover-image in manifest (EPUB3 properties or EPUB2 meta cover). * Tries EPUB3 cover-image property, EPUB2 meta cover, guide reference, and id-based fallbacks.
* Returns null if no cover is found or the file is not a valid EPUB. * Returns null if no cover is found or the file is not a valid EPUB.
*/ */
export async function extractEpubCover(absolutePath: string): Promise<Buffer | null> { export async function extractEpubCover(absolutePath: string): Promise<Buffer | null> {
@@ -54,6 +74,30 @@ export async function extractEpubCover(absolutePath: string): Promise<Buffer | n
} }
} }
// Try EPUB2 guide: <guide><reference type="cover" href="..."/></guide>
if (!coverItem) {
const guide = pkg?.['guide'] as Record<string, unknown> | undefined;
const refs = guide?.['reference'];
const refList: Record<string, unknown>[] = Array.isArray(refs) ? refs : refs ? [refs] : [];
const coverRef = refList.find((r) =>
String(r['@_type'] ?? '')
.toLowerCase()
.includes('cover'),
);
if (coverRef) {
const guideHref = decodeURIComponent(String(coverRef['@_href'] ?? '').split('#')[0]);
coverItem = itemList.find((i) => {
const itemHref = decodeURIComponent(String(i['@_href'] ?? '').split('#')[0]);
return itemHref === guideHref || resolvePath(opfDir, itemHref) === resolvePath(opfDir, guideHref);
});
// If guide points to an HTML page, skip - we only want image items
if (coverItem) {
const mt = String(coverItem['@_media-type'] ?? '').toLowerCase();
if (!mt.startsWith('image/')) coverItem = undefined;
}
}
}
// Fallback: item whose id is "cover" or "cover-image" // Fallback: item whose id is "cover" or "cover-image"
if (!coverItem) { if (!coverItem) {
coverItem = itemList.find((i) => { coverItem = itemList.find((i) => {
@@ -62,13 +106,21 @@ export async function extractEpubCover(absolutePath: string): Promise<Buffer | n
}); });
} }
// Fallback: item whose href contains "cover" and is an image
if (!coverItem) {
coverItem = itemList.find((i) => {
const mt = String(i['@_media-type'] ?? '').toLowerCase();
const href = String(i['@_href'] ?? '').toLowerCase();
return mt.startsWith('image/') && href.includes('cover');
});
}
if (!coverItem) return null; if (!coverItem) return null;
const href = String(coverItem['@_href'] ?? ''); const href = String(coverItem['@_href'] ?? '');
if (!href) return null; if (!href) return null;
const imagePath = opfDir + href; const imageFile = findInZip(zip, href, opfDir);
const imageFile = zip.files.find((f) => f.path === imagePath || f.path === '/' + imagePath);
if (!imageFile) return null; if (!imageFile) return null;
return await imageFile.buffer(); return await imageFile.buffer();
+10 -9
View File
@@ -18,7 +18,8 @@ export interface ParsedOpf {
const parser = new XMLParser({ const parser = new XMLParser({
ignoreAttributes: false, ignoreAttributes: false,
attributeNamePrefix: '@_', attributeNamePrefix: '@_',
isArray: (name) => ['dc:creator', 'dc:identifier', 'dc:subject', 'dc:title', 'meta', 'item'].includes(name), removeNSPrefix: true,
isArray: (name) => ['creator', 'identifier', 'subject', 'title', 'meta', 'item'].includes(name),
textNodeName: '#text', textNodeName: '#text',
allowBooleanAttributes: true, allowBooleanAttributes: true,
}); });
@@ -98,7 +99,7 @@ export function parseOpf(xml: string): ParsedOpf {
let title: string | null = null; let title: string | null = null;
let subtitle: string | null = null; let subtitle: string | null = null;
const rawTitles = toArray(metadata['dc:title']); const rawTitles = toArray(metadata['title']);
if (rawTitles.length === 1) { if (rawTitles.length === 1) {
title = getText(rawTitles[0]); title = getText(rawTitles[0]);
} else if (rawTitles.length > 1) { } else if (rawTitles.length > 1) {
@@ -115,7 +116,7 @@ export function parseOpf(xml: string): ParsedOpf {
// ── Authors ──────────────────────────────────────────────────────────────── // ── Authors ────────────────────────────────────────────────────────────────
const authors: { name: string; sortName: string | null }[] = []; const authors: { name: string; sortName: string | null }[] = [];
const rawCreators = toArray(metadata['dc:creator']); const rawCreators = toArray(metadata['creator']);
for (const c of rawCreators) { for (const c of rawCreators) {
const mo = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>; const mo = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>;
@@ -140,7 +141,7 @@ export function parseOpf(xml: string): ParsedOpf {
let isbn10: string | null = null; let isbn10: string | null = null;
let isbn13: string | null = null; let isbn13: string | null = null;
for (const ident of toArray(metadata['dc:identifier'])) { for (const ident of toArray(metadata['identifier'])) {
const mo = (typeof ident === 'object' && ident !== null ? ident : {}) as Record<string, unknown>; const mo = (typeof ident === 'object' && ident !== null ? ident : {}) as Record<string, unknown>;
const scheme = ((mo['@_opf:scheme'] ?? mo['@_scheme'] ?? '') as string).toLowerCase(); const scheme = ((mo['@_opf:scheme'] ?? mo['@_scheme'] ?? '') as string).toLowerCase();
const value = getText(ident); const value = getText(ident);
@@ -154,7 +155,7 @@ export function parseOpf(xml: string): ParsedOpf {
} }
// ── Tags ─────────────────────────────────────────────────────────────────── // ── Tags ───────────────────────────────────────────────────────────────────
const tags = toArray(metadata['dc:subject']).map(getText).filter(Boolean); const tags = toArray(metadata['subject']).map(getText).filter(Boolean);
// ── Series (Calibre EPUB2, then EPUB3) ──────────────────────────────────── // ── Series (Calibre EPUB2, then EPUB3) ────────────────────────────────────
let seriesName: string | null = namedMeta('calibre:series'); let seriesName: string | null = namedMeta('calibre:series');
@@ -180,11 +181,11 @@ export function parseOpf(xml: string): ParsedOpf {
} }
// ── Scalar fields ────────────────────────────────────────────────────────── // ── Scalar fields ──────────────────────────────────────────────────────────
const description = getText(metadata['dc:description']) || null; const description = getText(metadata['description']) || null;
const publisher = getText(metadata['dc:publisher']) || null; const publisher = getText(metadata['publisher']) || null;
const language = getText(metadata['dc:language']) || null; const language = getText(metadata['language']) || null;
const rawDate = toArray(metadata['dc:date'])[0]; const rawDate = toArray(metadata['date'])[0];
const publishedYear = rawDate ? parseYear(getText(rawDate)) : null; const publishedYear = rawDate ? parseYear(getText(rawDate)) : null;
return { return {
@@ -35,6 +35,15 @@ export class MetadataService {
await Promise.all([this.extractMetadata(bookId, absolutePath, format), this.extractCover(bookId, absolutePath, format)]); await Promise.all([this.extractMetadata(bookId, absolutePath, format), this.extractCover(bookId, absolutePath, format)]);
} }
async refreshCoverForBook(bookId: number, absolutePath: string, format: string): Promise<boolean> {
try {
const saved = await extractAndSaveCover(absolutePath, format, bookId, this.booksPath);
return !!saved;
} catch {
return false;
}
}
// ── Metadata ───────────────────────────────────────────────────────────────── // ── Metadata ─────────────────────────────────────────────────────────────────
private async extractMetadata(bookId: number, absolutePath: string, format: string): Promise<void> { private async extractMetadata(bookId: number, absolutePath: string, format: string): Promise<void> {
+10 -8
View File
@@ -1,15 +1,9 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import type { ScanProgressEvent } from '@projectx/types'; import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { ScanJobStore } from './scan-job-store.service'; import { ScanJobStore } from './scan-job-store.service';
@@ -67,4 +61,12 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
emitProgress(event: ScanProgressEvent): void { emitProgress(event: ScanProgressEvent): void {
this.server.to(`library:${event.libraryId}`).emit('scan:progress', event); this.server.to(`library:${event.libraryId}`).emit('scan:progress', event);
} }
emitCoverRefreshProgress(event: CoverRefreshProgressEvent): void {
this.server.to(`library:${event.libraryId}`).emit('cover:refresh:progress', event);
}
emitCoverRefreshed(event: CoverRefreshedEvent): void {
this.server.to(`library:${event.libraryId}`).emit('cover:refreshed', event);
}
} }
@@ -13,4 +13,11 @@ export class ScannerController {
scan(@Param('id', ParseIntPipe) libraryId: number) { scan(@Param('id', ParseIntPipe) libraryId: number) {
return this.scannerService.startScan(libraryId, 'manual'); return this.scannerService.startScan(libraryId, 'manual');
} }
@Post('libraries/:id/refresh-covers')
@RequirePermission('manage_libraries')
@HttpCode(HttpStatus.ACCEPTED)
refreshCovers(@Param('id', ParseIntPipe) libraryId: number) {
return this.scannerService.refreshCovers(libraryId);
}
} }
@@ -31,10 +31,7 @@ export class ScannerRepository {
} }
async failAllRunningJobs(errorMessage: string): Promise<void> { async failAllRunningJobs(errorMessage: string): Promise<void> {
await this.db await this.db.update(scanJobs).set({ status: 'failed', errorMessage, completedAt: new Date() }).where(eq(scanJobs.status, 'running'));
.update(scanJobs)
.set({ status: 'failed', errorMessage, completedAt: new Date() })
.where(eq(scanJobs.status, 'running'));
} }
// ── Library Folders ──────────────────────────────────────────────────────── // ── Library Folders ────────────────────────────────────────────────────────
@@ -49,6 +46,14 @@ export class ScannerRepository {
return this.db.select().from(books).where(eq(books.libraryFolderId, libraryFolderId)); return this.db.select().from(books).where(eq(books.libraryFolderId, libraryFolderId));
} }
async findPrimaryBookFilesByLibrary(libraryId: number) {
return this.db
.select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format })
.from(books)
.innerJoin(bookFiles, and(eq(bookFiles.bookId, books.id), eq(bookFiles.role, 'primary')))
.where(and(eq(books.libraryId, libraryId), ne(books.status, 'missing')));
}
async findBookByFolderPath(folderPath: string) { async findBookByFolderPath(folderPath: string) {
const [book] = await this.db.select().from(books).where(eq(books.folderPath, folderPath)).limit(1); const [book] = await this.db.select().from(books).where(eq(books.folderPath, folderPath)).limit(1);
return book ?? null; return book ?? null;
+31 -18
View File
@@ -1,6 +1,6 @@
import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap } from '@nestjs/common'; import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap } from '@nestjs/common';
import type { ScanProgressEvent } from '@projectx/types'; import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import { MetadataService } from '../metadata/metadata.service'; import { MetadataService } from '../metadata/metadata.service';
import { ScanGateway } from './scan.gateway'; import { ScanGateway } from './scan.gateway';
import { ScanJobStore } from './scan-job-store.service'; import { ScanJobStore } from './scan-job-store.service';
@@ -47,13 +47,38 @@ export class ScannerService implements OnApplicationBootstrap {
this.scanJobStore.create(job.id, libraryId, 0); this.scanJobStore.create(job.id, libraryId, 0);
this.emitFromStore(libraryId, job.id, 'running'); this.emitFromStore(libraryId, job.id, 'running');
this.runScan(libraryId, job.id, folders).catch((err) => this.runScan(libraryId, job.id, folders).catch((err) => this.logger.error(`Scan job ${job.id} crashed unexpectedly: ${(err as Error).message}`));
this.logger.error(`Scan job ${job.id} crashed unexpectedly: ${(err as Error).message}`),
);
return { jobId: job.id }; return { jobId: job.id };
} }
async refreshCovers(libraryId: number): Promise<{ queued: number }> {
const rows = await this.scannerRepo.findPrimaryBookFilesByLibrary(libraryId);
const candidates = rows.filter((r) => r.format && METADATA_FORMATS.has(r.format));
const total = candidates.length;
this.scanGateway.emitCoverRefreshProgress({ libraryId, processed: 0, total, status: 'running' });
(async () => {
let processed = 0;
for (const row of candidates) {
const refreshed = await this.metadataService.refreshCoverForBook(row.bookId, row.absolutePath, row.format!);
processed++;
if (refreshed) {
this.scanGateway.emitCoverRefreshed({ bookId: row.bookId, libraryId } satisfies CoverRefreshedEvent);
}
this.scanGateway.emitCoverRefreshProgress({
libraryId,
processed,
total,
status: processed < total ? 'running' : 'completed',
} satisfies CoverRefreshProgressEvent);
}
})().catch((err) => this.logger.warn(`Cover refresh crashed for library ${libraryId}: ${(err as Error).message}`));
return { queued: total };
}
startScanAsync(libraryId: number): void { startScanAsync(libraryId: number): void {
if (this.scanJobStore.isRunning(libraryId)) return; if (this.scanJobStore.isRunning(libraryId)) return;
this.startScan(libraryId, 'manual').catch((err) => this.startScan(libraryId, 'manual').catch((err) =>
@@ -61,11 +86,7 @@ export class ScannerService implements OnApplicationBootstrap {
); );
} }
private async runScan( private async runScan(libraryId: number, jobId: number, folders: Awaited<ReturnType<ScannerRepository['findLibraryFolders']>>): Promise<void> {
libraryId: number,
jobId: number,
folders: Awaited<ReturnType<ScannerRepository['findLibraryFolders']>>,
): Promise<void> {
this.logger.log(`Scan job ${jobId} started for library ${libraryId}`); this.logger.log(`Scan job ${jobId} started for library ${libraryId}`);
type FolderWork = { type FolderWork = {
@@ -104,15 +125,7 @@ export class ScannerService implements OnApplicationBootstrap {
try { try {
for (const { id: folderId, path: folderPath, candidates, knownBooks, knownFiles } of folderWork) { for (const { id: folderId, path: folderPath, candidates, knownBooks, knownFiles } of folderWork) {
const counts = await this.scanFolderCandidates( const counts = await this.scanFolderCandidates(folderId, libraryId, folderPath, candidates, knownBooks, knownFiles, jobId);
folderId,
libraryId,
folderPath,
candidates,
knownBooks,
knownFiles,
jobId,
);
totals.addedCount += counts.addedCount; totals.addedCount += counts.addedCount;
totals.updatedCount += counts.updatedCount; totals.updatedCount += counts.updatedCount;
totals.missingCount += counts.missingCount; totals.missingCount += counts.missingCount;