mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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">
|
<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)"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user