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)"
|
||||
|
||||
Reference in New Issue
Block a user