feat(metadata): add FB2 cover extraction and fix stale staging cover

- add cover-fb2.ts: extract cover from <coverpage>/<binary> in FB2 XML
- add fb2-parser.ts: extract title, authors, series, language, year, genres from FB2 title-info
- wire FB2 into cover.ts extractor switch and metadata.service.ts extractMetadata
- wire FB2 into staging-metadata.service.ts for staging ingestion
- add fb2 to METADATA_FORMATS in scanner.service.ts and upload-processor.service.ts
- add MetadataService.saveExtractedCoverBytes() for saving cover bytes directly
- fix staging finalize: copy staging cover to book cover dir on finalize, overwriting stale covers
- add Regenerate Cover button to CoverEditorPanel with permission gate and cache busting
This commit is contained in:
neon
2026-03-01 21:44:57 -06:00
parent 9350fe12d2
commit 58ef757ccd
9 changed files with 265 additions and 5 deletions
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { ImagePlus, Link, RotateCcw, Upload, X } from 'lucide-vue-next'
import { Image, ImagePlus, Link, Loader2, RotateCcw, Upload, X } from 'lucide-vue-next'
import type { BookDetail } from '@projectx/types'
import { hideOnError } from '../../../lib/metadata-fetch'
import { useCoverEditor } from '../../../composables/useCoverEditor'
import { useCoverVersions } from '../../../composables/useCoverVersions'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null] }>()
@@ -12,7 +13,22 @@ const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null] }>()
const bookIdRef = computed(() => props.book.id)
const { uploading, error, previewSrc, pendingFile, pendingUrl, selectFile, setUrl, clearPending, confirm, revert } = useCoverEditor(bookIdRef)
const { coverUrl } = useCoverVersions()
const { coverUrl, bumpVersion } = useCoverVersions()
const { hasPermission } = usePermissions()
const reExtractingCover = ref(false)
async function reExtractCover() {
if (reExtractingCover.value) return
reExtractingCover.value = true
try {
await fetch(`/api/v1/books/${props.book.id}/re-extract-cover`, { method: 'POST' })
bumpVersion(props.book.id)
emit('coverChanged', 'extracted')
} finally {
reExtractingCover.value = false
}
}
const mode = ref<'file' | 'url'>('file')
const urlInput = ref('')
@@ -152,6 +168,16 @@ onUnmounted(() => clearTimeout(debounceTimer))
<RotateCcw class="size-3" />
Revert to original
</button>
<button
v-if="hasPermission('library_edit_metadata')"
class="flex items-center justify-center gap-1.5 w-full h-8 rounded-lg border border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="reExtractingCover"
@click="reExtractCover"
>
<Loader2 v-if="reExtractingCover" class="size-3 animate-spin" />
<Image v-else class="size-3" />
Regenerate Cover
</button>
</div>
</div>
</template>