feat: add button to fetch metadata from book file in metadata editor (#2739)

This commit is contained in:
ACX
2026-02-13 21:04:00 -07:00
committed by GitHub
parent 5076522f35
commit 77b4deb942
7 changed files with 83 additions and 3 deletions

View File

@@ -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"),

View File

@@ -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<MetadataProvider, BookParser> 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);

View File

@@ -690,6 +690,10 @@ export class BookService {
return this.http.post<void>(`${this.url}/${bookId}/regenerate-cover`, {});
}
getFileMetadata(bookId: number): Observable<BookMetadata> {
return this.http.get<BookMetadata>(`${this.url}/${bookId}/file-metadata`);
}
generateCustomCover(bookId: number): Observable<void> {
return this.http.post<void>(`${this.url}/${bookId}/generate-custom-cover`, {});
}

View File

@@ -161,7 +161,6 @@
<p-button [pTooltip]="t('unlockCoverTooltip')" tooltipPosition="top" size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('audiobookCover')" severity="warn"></p-button>
}
</div>
`
</div>
}
</div>
@@ -967,6 +966,18 @@
}
<div class="actions-right">
@if (!book.isPhysical) {
<p-button
[label]="isFetchingFromFile ? t('fetchingBtn') : t('fetchFromFileBtn')"
[icon]="isFetchingFromFile ? 'pi pi-spin pi-spinner' : 'pi pi-file'"
[outlined]="true"
severity="secondary"
(onClick)="fetchFromFile(book.id)"
[disabled]="isFetchingFromFile"
[pTooltip]="t('fetchFromFileTooltip')"
tooltipPosition="top">
</p-button>
}
<p-button
[label]="isAutoFetching ? t('fetchingBtn') : t('autoFetchBtn')"
[icon]="isAutoFetching ? 'pi pi-spin pi-spinner' : 'pi pi-bolt'"
@@ -1025,6 +1036,17 @@
</div>
}
<div class="mobile-lock-actions">
@if (!book.isPhysical) {
<p-button
icon="pi pi-file"
[outlined]="true"
severity="secondary"
(onClick)="fetchFromFile(book.id)"
[disabled]="isFetchingFromFile"
[pTooltip]="t('fetchFromFileBtn')"
tooltipPosition="top">
</p-button>
}
<p-button
icon="pi pi-bolt"
[outlined]="true"

View File

@@ -90,6 +90,7 @@ export class MetadataEditorComponent implements OnInit {
refreshingBookIds = new Set<number>();
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();
}

View File

@@ -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": {

View File

@@ -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": {