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">
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)"
+12
View File
@@ -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;
}
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1769371048439,
"when": 1769384427779,
"tag": "0000_baseline",
"breakpoints": true
}
+56 -4
View File
@@ -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();
+10 -9
View File
@@ -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> {
+10 -8
View File
@@ -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;
+31 -18
View File
@@ -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;