feat: stream bulk metadata refresh and cover re-extraction via SSE

- add useRefreshingBooks composable for shared per-book spinner state
- show refresh spinner on BookCoverCard during any bulk operation
- bulk-refresh-metadata and bulk-re-extract-cover endpoints now stream SSE
- client reads SSE stream and updates each card cover+spinner one-by-one
- wire all missing SelectionActionBar events in LensView and CollectionView
This commit is contained in:
neon
2026-03-02 23:33:37 -06:00
parent 3304554375
commit d63859cf30
7 changed files with 351 additions and 39 deletions
@@ -23,6 +23,7 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { useRefreshingBooks } from '../composables/useRefreshingBooks'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
@@ -58,6 +59,8 @@ const { coverUrl, bumpVersion } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id))
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
const { isRefreshing } = useRefreshingBooks()
const anyRefreshing = computed(() => refreshing.value || isRefreshing(props.book.id))
const reExtractingCover = ref(false)
async function reExtractCover() {
@@ -227,7 +230,7 @@ function handleCardClick(event: MouseEvent) {
<!-- Refresh spinner overlay -->
<Transition name="fade">
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
@@ -294,8 +297,8 @@ function handleCardClick(event: MouseEvent) {
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<Loader2 v-if="refreshing" class="size-4 mr-2 animate-spin" />
<DropdownMenuItem :disabled="anyRefreshing" @click="refreshWithFeedback(book.id)">
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
</DropdownMenuItem>
@@ -0,0 +1,23 @@
import { ref } from 'vue'
const refreshingIds = ref(new Set<number>())
export function useRefreshingBooks() {
function markRefreshing(ids: number[]) {
const next = new Set(refreshingIds.value)
ids.forEach((id) => next.add(id))
refreshingIds.value = next
}
function clearRefreshing(ids: number[]) {
const next = new Set(refreshingIds.value)
ids.forEach((id) => next.delete(id))
refreshingIds.value = next
}
function isRefreshing(id: number): boolean {
return refreshingIds.value.has(id)
}
return { markRefreshing, clearRefreshing, isRefreshing }
}
+112
View File
@@ -11,12 +11,15 @@ import ViewHeader from '@/components/ViewHeader.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import EditCollectionDialog from '@/features/collection/components/EditCollectionDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import { SidebarInset } from '@/components/ui/sidebar'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useCollections } from '@/features/collection/composables/useCollections'
import { useCollectionBooks } from '@/features/collection/composables/useCollectionBooks'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import type { BookCard } from '@projectx/types'
@@ -50,9 +53,113 @@ function toggleSelectionMode() {
}
const addToCollectionOpen = ref(false)
const sendBookOpen = ref(false)
const editCollectionOpen = ref(false)
let removingInProgress = false
let deletingInProgress = false
const { bumpVersion } = useCoverVersions()
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
async function handleBulkRefreshMetadata() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
markRefreshing(ids)
const res = await api('/api/v1/books/bulk-refresh-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids }),
})
if (!res.ok) {
clearRefreshing(ids)
toast.error('Failed to refresh metadata')
return
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let processed = 0
let failed = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
if (data.done) { processed = data.processed; failed = data.failed }
} catch { /* ignore malformed line */ }
}
}
} finally {
clearRefreshing(ids)
}
if (failed > 0) {
toast.warning(`Refreshed ${processed} book${processed === 1 ? '' : 's'}, ${failed} failed`)
} else {
toast.success(`Refreshed metadata for ${processed} book${processed === 1 ? '' : 's'}`)
}
}
async function handleBulkReExtractCover() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
markRefreshing(ids)
const res = await api('/api/v1/books/bulk-re-extract-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids }),
})
if (!res.ok) {
clearRefreshing(ids)
toast.error('Failed to re-extract covers')
return
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let processed = 0
let updated = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
if (data.done) { processed = data.processed; updated = data.updated }
} catch { /* ignore malformed line */ }
}
}
} finally {
clearRefreshing(ids)
}
toast.success(`Re-extracted ${updated} of ${processed} cover${processed === 1 ? '' : 's'}`)
}
async function handleExport(allFormats: boolean) {
const ids = [...selectedIds.value]
if (ids.length === 0) return
const toastId = toast.loading(`Preparing ${ids.length} book${ids.length === 1 ? '' : 's'} for download...`)
const res = await api('/api/v1/books/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids, allFormats }),
})
toast.dismiss(toastId)
if (!res.ok) {
toast.error('Export failed')
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'books.zip'
a.click()
URL.revokeObjectURL(url)
}
const quickViewBookId = ref<number | null>(null)
const quickViewOpen = ref(false)
@@ -146,8 +253,12 @@ watch(
:visible="selectionMode"
:count="selectedCount"
:in-collection="true"
@send="sendBookOpen = true"
@export="handleExport"
@add-to-collection="addToCollectionOpen = true"
@remove-from-collection="handleRemoveFromCollection"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@delete="handleDeleteSelected"
@exit="exitSelectionMode"
/>
@@ -160,6 +271,7 @@ watch(
/>
<EditCollectionDialog v-if="collection" :open="editCollectionOpen" :collection="collection" @close="editCollectionOpen = false" />
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
<SidebarInset class="flex flex-col h-screen glow-wrapper">
<AppHeader />
+46 -8
View File
@@ -21,6 +21,7 @@ import { useBookQuery, type BookCard } from '@/features/book/composables/useBook
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
@@ -195,6 +196,7 @@ watch(
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
const { bumpVersion } = useCoverVersions()
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
function handleSelect(id: number, event: MouseEvent) {
if (event.shiftKey)
@@ -235,19 +237,37 @@ async function handleDeleteSelected() {
async function handleBulkRefreshMetadata() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
const toastId = toast.loading(`Refreshing metadata for ${ids.length} book${ids.length === 1 ? '' : 's'}...`)
markRefreshing(ids)
const res = await api('/api/v1/books/bulk-refresh-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids }),
})
toast.dismiss(toastId)
if (!res.ok) {
clearRefreshing(ids)
toast.error('Failed to refresh metadata')
return
}
const { processed, failed } = await res.json()
ids.forEach((id) => bumpVersion(id))
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let processed = 0
let failed = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
if (data.done) { processed = data.processed; failed = data.failed }
} catch { /* ignore malformed line */ }
}
}
} finally {
clearRefreshing(ids)
}
if (failed > 0) {
toast.warning(`Refreshed ${processed} book${processed === 1 ? '' : 's'}, ${failed} failed`)
} else {
@@ -258,19 +278,37 @@ async function handleBulkRefreshMetadata() {
async function handleBulkReExtractCover() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
const toastId = toast.loading(`Re-extracting covers for ${ids.length} book${ids.length === 1 ? '' : 's'}...`)
markRefreshing(ids)
const res = await api('/api/v1/books/bulk-re-extract-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids }),
})
toast.dismiss(toastId)
if (!res.ok) {
clearRefreshing(ids)
toast.error('Failed to re-extract covers')
return
}
const { processed, updated } = await res.json()
ids.forEach((id) => bumpVersion(id))
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let processed = 0
let updated = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
if (data.done) { processed = data.processed; updated = data.updated }
} catch { /* ignore malformed line */ }
}
}
} finally {
clearRefreshing(ids)
}
toast.success(`Re-extracted ${updated} of ${processed} cover${processed === 1 ? '' : 's'}`)
}
+112
View File
@@ -10,6 +10,7 @@ import ViewHeader from '@/components/ViewHeader.vue'
import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import { SidebarInset } from '@/components/ui/sidebar'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
@@ -17,6 +18,8 @@ import { useLens } from '@/features/lens/composables/useLens'
import { useLenses } from '@/features/lens/composables/useLenses'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import FilterSummary from '@/features/book/components/FilterSummary.vue'
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
@@ -60,6 +63,110 @@ function toggleSelectionMode() {
}
const addToCollectionOpen = ref(false)
const sendBookOpen = ref(false)
const { bumpVersion } = useCoverVersions()
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
async function handleBulkRefreshMetadata() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
markRefreshing(ids)
const res = await api('/api/v1/books/bulk-refresh-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids }),
})
if (!res.ok) {
clearRefreshing(ids)
toast.error('Failed to refresh metadata')
return
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let processed = 0
let failed = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
if (data.done) { processed = data.processed; failed = data.failed }
} catch { /* ignore malformed line */ }
}
}
} finally {
clearRefreshing(ids)
}
if (failed > 0) {
toast.warning(`Refreshed ${processed} book${processed === 1 ? '' : 's'}, ${failed} failed`)
} else {
toast.success(`Refreshed metadata for ${processed} book${processed === 1 ? '' : 's'}`)
}
}
async function handleBulkReExtractCover() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
markRefreshing(ids)
const res = await api('/api/v1/books/bulk-re-extract-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids }),
})
if (!res.ok) {
clearRefreshing(ids)
toast.error('Failed to re-extract covers')
return
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let processed = 0
let updated = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
if (data.done) { processed = data.processed; updated = data.updated }
} catch { /* ignore malformed line */ }
}
}
} finally {
clearRefreshing(ids)
}
toast.success(`Re-extracted ${updated} of ${processed} cover${processed === 1 ? '' : 's'}`)
}
async function handleExport(allFormats: boolean) {
const ids = [...selectedIds.value]
if (ids.length === 0) return
const toastId = toast.loading(`Preparing ${ids.length} book${ids.length === 1 ? '' : 's'} for download...`)
const res = await api('/api/v1/books/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds: ids, allFormats }),
})
toast.dismiss(toastId)
if (!res.ok) {
toast.error('Export failed')
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'books.zip'
a.click()
URL.revokeObjectURL(url)
}
async function handleDeleteSelected() {
const ids = [...selectedIds.value]
@@ -178,7 +285,11 @@ watch(
<SelectionActionBar
:visible="selectionMode"
:count="selectedCount"
@send="sendBookOpen = true"
@export="handleExport"
@add-to-collection="addToCollectionOpen = true"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@delete="handleDeleteSelected"
@exit="exitSelectionMode"
/>
@@ -189,6 +300,7 @@ watch(
@update:open="addToCollectionOpen = $event"
@added="exitSelectionMode"
/>
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
<SidebarInset class="flex flex-col h-screen glow-wrapper">
<AppHeader />