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)"