mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(cover): fix EPUB cover extraction for namespace-prefixed OPF
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { BookCard, BookFileRef } from '@projectx/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
|
||||
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 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 coverFailed = ref(false)
|
||||
|
||||
watch(coverSrc, () => {
|
||||
coverLoaded.value = false
|
||||
coverFailed.value = false
|
||||
})
|
||||
|
||||
function openFile(file: BookFileRef) {
|
||||
router.push({
|
||||
name: 'reader',
|
||||
@@ -50,7 +63,7 @@ function openFile(file: BookFileRef) {
|
||||
>
|
||||
<img
|
||||
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="coverLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
|
||||
const props = defineProps<{
|
||||
bookId: number
|
||||
type?: 'thumbnail' | 'cover'
|
||||
class?: 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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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 { 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 { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
|
||||
let socket: Socket | null = null
|
||||
const progressMap = ref<Map<number, ScanProgressEvent>>(new Map())
|
||||
const coverRefreshMap = ref<Map<number, CoverRefreshProgressEvent>>(new Map())
|
||||
const subscribedLibraries = new Set<number>()
|
||||
|
||||
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', () => {
|
||||
// Clear stale progress bars but keep subscribedLibraries so we can re-subscribe on reconnect.
|
||||
progressMap.value = new Map()
|
||||
@@ -60,5 +76,13 @@ export function useScanProgress() {
|
||||
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">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
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 { api } from '@/lib/api'
|
||||
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 router = useRouter()
|
||||
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 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() {
|
||||
scanningAll.value = true
|
||||
try {
|
||||
@@ -172,6 +181,17 @@ function scanProgressLabel(libraryId: number): string {
|
||||
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) {
|
||||
return iconName ? null : Library
|
||||
}
|
||||
@@ -248,6 +268,23 @@ function iconComponent(iconName: string | null | undefined) {
|
||||
/>
|
||||
</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 class="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
@@ -264,6 +301,14 @@ function iconComponent(iconName: string | null | undefined) {
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</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
|
||||
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)"
|
||||
|
||||
@@ -9,3 +9,15 @@ export interface ScanProgressEvent {
|
||||
missing: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface CoverRefreshProgressEvent {
|
||||
libraryId: number;
|
||||
processed: number;
|
||||
total: number;
|
||||
status: 'running' | 'completed';
|
||||
}
|
||||
|
||||
export interface CoverRefreshedEvent {
|
||||
bookId: number;
|
||||
libraryId: number;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1769371048439,
|
||||
"when": 1769384427779,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import * as unzipper from 'unzipper';
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
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"
|
||||
if (!coverItem) {
|
||||
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;
|
||||
|
||||
const href = String(coverItem['@_href'] ?? '');
|
||||
if (!href) return null;
|
||||
|
||||
const imagePath = opfDir + href;
|
||||
const imageFile = zip.files.find((f) => f.path === imagePath || f.path === '/' + imagePath);
|
||||
const imageFile = findInZip(zip, href, opfDir);
|
||||
if (!imageFile) return null;
|
||||
|
||||
return await imageFile.buffer();
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface ParsedOpf {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
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',
|
||||
allowBooleanAttributes: true,
|
||||
});
|
||||
@@ -98,7 +99,7 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
let title: string | null = null;
|
||||
let subtitle: string | null = null;
|
||||
|
||||
const rawTitles = toArray(metadata['dc:title']);
|
||||
const rawTitles = toArray(metadata['title']);
|
||||
if (rawTitles.length === 1) {
|
||||
title = getText(rawTitles[0]);
|
||||
} else if (rawTitles.length > 1) {
|
||||
@@ -115,7 +116,7 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
|
||||
// ── Authors ────────────────────────────────────────────────────────────────
|
||||
const authors: { name: string; sortName: string | null }[] = [];
|
||||
const rawCreators = toArray(metadata['dc:creator']);
|
||||
const rawCreators = toArray(metadata['creator']);
|
||||
|
||||
for (const c of rawCreators) {
|
||||
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 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 scheme = ((mo['@_opf:scheme'] ?? mo['@_scheme'] ?? '') as string).toLowerCase();
|
||||
const value = getText(ident);
|
||||
@@ -154,7 +155,7 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
}
|
||||
|
||||
// ── 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) ────────────────────────────────────
|
||||
let seriesName: string | null = namedMeta('calibre:series');
|
||||
@@ -180,11 +181,11 @@ export function parseOpf(xml: string): ParsedOpf {
|
||||
}
|
||||
|
||||
// ── Scalar fields ──────────────────────────────────────────────────────────
|
||||
const description = getText(metadata['dc:description']) || null;
|
||||
const publisher = getText(metadata['dc:publisher']) || null;
|
||||
const language = getText(metadata['dc:language']) || null;
|
||||
const description = getText(metadata['description']) || null;
|
||||
const publisher = getText(metadata['publisher']) || 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;
|
||||
|
||||
return {
|
||||
|
||||
@@ -35,6 +35,15 @@ export class MetadataService {
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async extractMetadata(bookId: number, absolutePath: string, format: string): Promise<void> {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
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 { ScanJobStore } from './scan-job-store.service';
|
||||
|
||||
@@ -67,4 +61,12 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
emitProgress(event: ScanProgressEvent): void {
|
||||
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) {
|
||||
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> {
|
||||
await this.db
|
||||
.update(scanJobs)
|
||||
.set({ status: 'failed', errorMessage, completedAt: new Date() })
|
||||
.where(eq(scanJobs.status, 'running'));
|
||||
await this.db.update(scanJobs).set({ status: 'failed', errorMessage, completedAt: new Date() }).where(eq(scanJobs.status, 'running'));
|
||||
}
|
||||
|
||||
// ── Library Folders ────────────────────────────────────────────────────────
|
||||
@@ -49,6 +46,14 @@ export class ScannerRepository {
|
||||
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) {
|
||||
const [book] = await this.db.select().from(books).where(eq(books.folderPath, folderPath)).limit(1);
|
||||
return book ?? null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { ScanGateway } from './scan.gateway';
|
||||
import { ScanJobStore } from './scan-job-store.service';
|
||||
@@ -47,13 +47,38 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
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).catch((err) => this.logger.error(`Scan job ${job.id} crashed unexpectedly: ${(err as Error).message}`));
|
||||
|
||||
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 {
|
||||
if (this.scanJobStore.isRunning(libraryId)) return;
|
||||
this.startScan(libraryId, 'manual').catch((err) =>
|
||||
@@ -61,11 +86,7 @@ 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']>>): Promise<void> {
|
||||
this.logger.log(`Scan job ${jobId} started for library ${libraryId}`);
|
||||
|
||||
type FolderWork = {
|
||||
@@ -104,15 +125,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);
|
||||
totals.addedCount += counts.addedCount;
|
||||
totals.updatedCount += counts.updatedCount;
|
||||
totals.missingCount += counts.missingCount;
|
||||
|
||||
Reference in New Issue
Block a user