From 77b4deb9426e98febbfbc613275c70d60dd38b75 Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:04:00 -0700 Subject: [PATCH] feat: add button to fetch metadata from book file in metadata editor (#2739) --- .../booklore/controller/BookController.java | 8 ++++++ .../service/metadata/BookMetadataService.java | 12 +++++++++ .../app/features/book/service/book.service.ts | 4 +++ .../metadata-editor.component.html | 24 ++++++++++++++++- .../metadata-editor.component.ts | 26 +++++++++++++++++++ booklore-ui/src/i18n/en/metadata.json | 6 ++++- booklore-ui/src/i18n/es/metadata.json | 6 ++++- 7 files changed, 83 insertions(+), 3 deletions(-) diff --git a/booklore-api/src/main/java/org/booklore/controller/BookController.java b/booklore-api/src/main/java/org/booklore/controller/BookController.java index 5af8724c7..d2f0f8c0f 100644 --- a/booklore-api/src/main/java/org/booklore/controller/BookController.java +++ b/booklore-api/src/main/java/org/booklore/controller/BookController.java @@ -120,6 +120,14 @@ public class BookController { return ResponseEntity.ok(bookMetadataService.getComicInfoMetadata(bookId)); } + @Operation(summary = "Get file metadata", description = "Extract embedded metadata from the book file.") + @ApiResponse(responseCode = "200", description = "File metadata returned successfully") + @GetMapping("/{bookId}/file-metadata") + public ResponseEntity getFileMetadata( + @Parameter(description = "ID of the book") @PathVariable long bookId) { + return ResponseEntity.ok(bookMetadataService.getFileMetadata(bookId)); + } + @Operation(summary = "Get book content", description = "Retrieve the binary content of a book for reading. Supports HTTP Range requests for partial content streaming.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "Full book content returned"), diff --git a/booklore-api/src/main/java/org/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/org/booklore/service/metadata/BookMetadataService.java index 94a399d21..8d4007896 100644 --- a/booklore-api/src/main/java/org/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/org/booklore/service/metadata/BookMetadataService.java @@ -23,6 +23,7 @@ import org.booklore.repository.BookRepository; import org.booklore.service.NotificationService; import org.booklore.service.book.BookQueryService; import org.booklore.service.metadata.extractor.CbxMetadataExtractor; +import org.booklore.service.metadata.extractor.MetadataExtractorFactory; import org.booklore.service.metadata.parser.BookParser; import org.booklore.service.metadata.parser.DetailedMetadataProvider; import org.booklore.util.FileUtils; @@ -58,6 +59,7 @@ public class BookMetadataService { private final BookQueryService bookQueryService; private final Map parserMap; private final CbxMetadataExtractor cbxMetadataExtractor; + private final MetadataExtractorFactory metadataExtractorFactory; private final MetadataClearFlagsMapper metadataClearFlagsMapper; private final PlatformTransactionManager transactionManager; @@ -147,6 +149,16 @@ public class BookMetadataService { return cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); } + public BookMetadata getFileMetadata(long bookId) { + log.info("Extracting file metadata for book ID: {}", bookId); + BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + var primaryFile = bookEntity.getPrimaryBookFile(); + if (primaryFile == null) { + throw ApiError.GENERIC_BAD_REQUEST.createException("Book has no file to extract metadata from"); + } + return metadataExtractorFactory.extractMetadata(primaryFile.getBookType(), new File(FileUtils.getBookFullPath(bookEntity))); + } + @Transactional public void bulkUpdateMetadata(BulkMetadataUpdateRequest request, boolean mergeCategories, boolean mergeMoods, boolean mergeTags) { MetadataClearFlags clearFlags = metadataClearFlagsMapper.toClearFlags(request); diff --git a/booklore-ui/src/app/features/book/service/book.service.ts b/booklore-ui/src/app/features/book/service/book.service.ts index 8b7ddd400..47fd00a71 100644 --- a/booklore-ui/src/app/features/book/service/book.service.ts +++ b/booklore-ui/src/app/features/book/service/book.service.ts @@ -690,6 +690,10 @@ export class BookService { return this.http.post(`${this.url}/${bookId}/regenerate-cover`, {}); } + getFileMetadata(bookId: number): Observable { + return this.http.get(`${this.url}/${bookId}/file-metadata`); + } + generateCustomCover(bookId: number): Observable { return this.http.post(`${this.url}/${bookId}/generate-custom-cover`, {}); } diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html index d42b7cb55..f2392b6c6 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html @@ -161,7 +161,6 @@ } - ` } @@ -967,6 +966,18 @@ }
+ @if (!book.isPhysical) { + + + } }
+ @if (!book.isPhysical) { + + + } (); isAutoFetching = false; + isFetchingFromFile = false; autoSaveEnabled = false; originalMetadata!: BookMetadata; @@ -1035,6 +1036,31 @@ export class MetadataEditorComponent implements OnInit { }, 15000); } + fetchFromFile(bookId: number) { + this.isFetchingFromFile = true; + this.bookService.getFileMetadata(bookId).pipe( + finalize(() => this.isFetchingFromFile = false), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: (metadata) => { + this.populateFormFromMetadata(metadata); + this.metadataForm.markAsDirty(); + this.messageService.add({ + severity: 'info', + summary: this.t.translate('metadata.editor.toast.successSummary'), + detail: this.t.translate('metadata.editor.toast.fileMetadataLoaded'), + }); + }, + error: (err) => { + this.messageService.add({ + severity: 'error', + summary: this.t.translate('metadata.editor.toast.errorSummary'), + detail: err?.error?.message || this.t.translate('metadata.editor.toast.fileMetadataFailed'), + }); + } + }); + } + onNext() { this.nextBookClicked.emit(); } diff --git a/booklore-ui/src/i18n/en/metadata.json b/booklore-ui/src/i18n/en/metadata.json index 3c879baff..8fad09fa6 100644 --- a/booklore-ui/src/i18n/en/metadata.json +++ b/booklore-ui/src/i18n/en/metadata.json @@ -83,6 +83,8 @@ "unlockAllBtn": "Unlock All", "lockAllBtn": "Lock All", "saveBtn": "Save", + "fetchFromFileBtn": "From File", + "fetchFromFileTooltip": "Load metadata embedded in the book file", "previousBookTooltip": "Go to previous book", "nextBookTooltip": "Go to next book", "autoFetchTooltip": "Automatically fetch metadata using default sources", @@ -125,7 +127,9 @@ "customAudiobookCoverGenerated": "Custom audiobook cover generated successfully.", "customAudiobookCoverPartialDetail": "Cover generated but failed to refresh display. Please refresh the page.", "customAudiobookCoverFailed": "Failed to generate custom audiobook cover", - "audiobookUploadFailed": "An error occurred while uploading the audiobook cover" + "audiobookUploadFailed": "An error occurred while uploading the audiobook cover", + "fileMetadataLoaded": "Metadata loaded from book file. Review and save.", + "fileMetadataFailed": "Failed to extract metadata from book file" } }, "picker": { diff --git a/booklore-ui/src/i18n/es/metadata.json b/booklore-ui/src/i18n/es/metadata.json index 500ec7b28..981433abb 100644 --- a/booklore-ui/src/i18n/es/metadata.json +++ b/booklore-ui/src/i18n/es/metadata.json @@ -83,6 +83,8 @@ "unlockAllBtn": "Desbloquear todo", "lockAllBtn": "Bloquear todo", "saveBtn": "Guardar", + "fetchFromFileBtn": "Del archivo", + "fetchFromFileTooltip": "Cargar metadatos incrustados en el archivo del libro", "previousBookTooltip": "Ir al libro anterior", "nextBookTooltip": "Ir al libro siguiente", "autoFetchTooltip": "Obtener metadatos automáticamente usando fuentes predeterminadas", @@ -125,7 +127,9 @@ "customAudiobookCoverGenerated": "Portada personalizada del audiolibro generada correctamente.", "customAudiobookCoverPartialDetail": "Portada generada pero no se pudo actualizar la visualización. Por favor recarga la página.", "customAudiobookCoverFailed": "Error al generar portada personalizada del audiolibro", - "audiobookUploadFailed": "Ocurrió un error al subir la portada del audiolibro" + "audiobookUploadFailed": "Ocurrió un error al subir la portada del audiolibro", + "fileMetadataLoaded": "Metadatos cargados del archivo del libro. Revisa y guarda.", + "fileMetadataFailed": "Error al extraer metadatos del archivo del libro" } }, "picker": {