mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: add button to fetch metadata from book file in metadata editor (#2739)
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user