feat: add load-from-file metadata button on book edit tab

This commit is contained in:
neon
2026-04-06 15:13:57 -06:00
parent cfc424ea37
commit 79b4c45d88
5 changed files with 228 additions and 1 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ChevronDown, Loader2, RefreshCw, Sparkles, Star } from 'lucide-vue-next'
import { ChevronDown, HardDriveUpload, Loader2, RefreshCw, Sparkles, Star } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { BookDetail, BookMetadataLockField } from '@projectx/types'
import { FORMAT_TO_GROUP } from '@projectx/types'
@@ -12,6 +12,7 @@ import MetadataFieldLabel from './MetadataFieldLabel.vue'
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
import { type MetadataRefreshPreview, useRefreshMetadata } from '../../../composables/useRefreshMetadata'
import { type FileMetadata, useFileMetadata } from '../../../composables/useFileMetadata'
import { useMetadataLocks } from '../../../composables/useMetadataLocks'
import { useAuthorSearch } from '../../../composables/useAuthorSearch'
import { useNarratorSearch } from '../../../composables/useNarratorSearch'
@@ -254,6 +255,7 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
}
const { refreshing: autoFilling, previewRefresh } = useRefreshMetadata()
const { loading: loadingFromFile, loadFromFile } = useFileMetadata()
function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
return {
@@ -296,6 +298,54 @@ async function autoFill() {
showApplyResult(skippedFields, updatedCount)
}
function applyFileMetadataToForm(meta: FileMetadata): number {
let count = 0
const directFields = [
'title',
'subtitle',
'description',
'publisher',
'publishedYear',
'language',
'pageCount',
'seriesName',
'seriesIndex',
'isbn10',
'isbn13',
'authors',
'genres',
] as const
for (const field of directFields) {
if (meta[field] !== undefined) {
form[field] = meta[field] as never
count++
}
}
if (meta.comicMetadata) {
for (const [comicKey, formKey] of Object.entries(COMIC_FIELD_MAP) as [
keyof typeof COMIC_FIELD_MAP,
(typeof COMIC_FIELD_MAP)[keyof typeof COMIC_FIELD_MAP],
][]) {
const value = meta.comicMetadata[comicKey]
if (value !== undefined) {
form[formKey] = value as never
count++
}
}
}
return count
}
async function handleLoadFromFile() {
const meta = await loadFromFile(props.book.id)
if (!meta) {
toast.error('Failed to load metadata from file')
return
}
const count = applyFileMetadataToForm(meta)
toast.info(count > 0 ? `Loaded ${count} field${count === 1 ? '' : 's'} from file` : 'No metadata found in file')
}
async function handleLockToggle(field: BookMetadataLockField) {
const updated = await toggle(props.book.id, field)
if (updated) emit('locksChanged', updated.lockedFields)
@@ -340,6 +390,25 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
<p v-if="combinedError" class="text-sm text-destructive">{{ combinedError }}</p>
<span v-else />
<div class="flex items-center gap-2">
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
:disabled="loadingFromFile || !primaryFile"
@click="handleLoadFromFile"
>
<Loader2 v-if="loadingFromFile" class="size-3.5 animate-spin" />
<HardDriveUpload v-else class="size-3.5" />
Load from file
</button>
</TooltipTrigger>
<TooltipContent>{{
loadingFromFile ? 'Loading...' : !primaryFile ? 'No primary file available' : 'Load metadata from the primary book file'
}}</TooltipContent>
</Tooltip>
<div class="w-px h-4 bg-border mx-0.5" />
<button
class="search-online-btn flex items-center gap-1.5 h-8 px-3.5 rounded-lg text-primary-foreground text-sm font-medium transition-all"
@click="searchOpen = true"
@@ -0,0 +1,39 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { ComicMetadataFields } from '@projectx/types'
export interface FileMetadata {
title?: string | null
subtitle?: string | null
description?: string | null
publisher?: string | null
publishedYear?: number | null
language?: string | null
pageCount?: number | null
seriesName?: string | null
seriesIndex?: number | null
isbn10?: string | null
isbn13?: string | null
authors?: string[]
genres?: string[]
comicMetadata?: ComicMetadataFields
}
export function useFileMetadata() {
const loading = ref(false)
async function loadFromFile(bookId: number): Promise<FileMetadata | null> {
loading.value = true
try {
const res = await api(`/api/v1/books/${bookId}/metadata-from-file`)
if (!res.ok) return null
return (await res.json()) as FileMetadata
} catch {
return null
} finally {
loading.value = false
}
}
return { loading, loadFromFile }
}
@@ -369,6 +369,12 @@ export class BookController {
return this.bookService.refreshMetadata(id, preview === 'true', user);
}
@Get(':id/metadata-from-file')
@RequirePermission(Permission.LibraryEditMetadata)
getMetadataFromFile(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.bookService.getMetadataFromFile(id, user);
}
@Get(':id/write-log')
@RequirePermission(Permission.LibraryEditMetadata)
async getWriteLog(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
@@ -438,6 +438,16 @@ export class BookRepository {
return rows.map((r) => r.id);
}
async findPrimaryFile(bookId: number): Promise<{ absolutePath: string; format: string | null } | null> {
const [row] = await this.db
.select({ absolutePath: bookFiles.absolutePath, format: bookFiles.format })
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(eq(books.id, bookId))
.limit(1);
return row ?? null;
}
async findPrimaryFilesByBookIds(bookIds: number[]): Promise<{ bookId: number; absolutePath: string; format: string | null }[]> {
if (bookIds.length === 0) return [];
return this.db
+103
View File
@@ -1,6 +1,12 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger, NotFoundException, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { access, readdir, rm, stat } from 'fs/promises';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
import { basename, extname, join } from 'path';
import { DEFAULT_DOWNLOAD_PATTERN, MetadataProviderKey, Permission, isAudioFormat, resolveUploadPath } from '@projectx/types';
@@ -1083,6 +1089,103 @@ export class BookService {
};
}
async getMetadataFromFile(id: number, user: RequestUser): Promise<Record<string, unknown>> {
await this.verifyBookAccess(id, user);
const file = await this.bookRepo.findPrimaryFile(id);
if (!file) throw new NotFoundException(`Book ${id} has no primary file`);
const { absolutePath, format } = file;
if (!format) return {};
switch (format) {
case 'epub': {
const parsed = await extractEpubMetadata(absolutePath);
if (!parsed) return {};
return {
title: parsed.title,
subtitle: parsed.subtitle,
description: parsed.description,
publisher: parsed.publisher,
publishedYear: parsed.publishedYear,
language: parsed.language,
isbn10: parsed.isbn10,
isbn13: parsed.isbn13,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
};
}
case 'pdf': {
const parsed = await parsePdfFile(absolutePath);
if (!parsed) return {};
return {
title: parsed.title,
publisher: parsed.publisher,
pageCount: parsed.pageCount,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
}
case 'mobi':
case 'azw3':
case 'azw': {
const parsed = await parseMobiFile(absolutePath);
if (!parsed) return {};
const year = parsed.publishedDate ? parseInt(parsed.publishedDate.substring(0, 4), 10) || undefined : undefined;
return {
title: parsed.title,
description: parsed.description,
publisher: parsed.publisher,
publishedYear: year,
language: parsed.language,
isbn13: parsed.isbn,
authors: parsed.authors.length > 0 ? parsed.authors : undefined,
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
};
}
case 'cbz':
case 'cbr':
case 'cb7': {
const extractor = format === 'cbz' ? extractCbzMetadata : format === 'cbr' ? extractCbrMetadata : extractCb7Metadata;
const parsed = await extractor(absolutePath);
if (!parsed) return {};
return {
title: parsed.title,
subtitle: parsed.subtitle,
description: parsed.description,
publisher: parsed.publisher,
publishedYear: parsed.publishedYear,
language: parsed.language,
pageCount: parsed.pageCount,
isbn10: parsed.isbn10,
isbn13: parsed.isbn13,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
comicMetadata: parsed.comicMetadata ?? undefined,
};
}
case 'fb2': {
const parsed = await parseFb2File(absolutePath);
if (!parsed) return {};
return {
title: parsed.title,
description: parsed.description,
publishedYear: parsed.publishedYear,
language: parsed.language,
seriesName: parsed.seriesName,
seriesIndex: parsed.seriesIndex,
authors: parsed.authors.length > 0 ? parsed.authors : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
}
default:
return {};
}
}
private resolveChapters(
stored: AudiobookChapter[] | null | undefined,
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null }[],