diff --git a/backend/src/main/java/org/booklore/service/metadata/writer/AudiobookMetadataWriter.java b/backend/src/main/java/org/booklore/service/metadata/writer/AudiobookMetadataWriter.java index 79b40ea1af..848b121014 100644 --- a/backend/src/main/java/org/booklore/service/metadata/writer/AudiobookMetadataWriter.java +++ b/backend/src/main/java/org/booklore/service/metadata/writer/AudiobookMetadataWriter.java @@ -55,14 +55,6 @@ public void saveMetadataToFile(File audioFile, BookMetadataEntity metadata, Stri return; } - File backupFile = new File(audioFile.getParentFile(), audioFile.getName() + ".bak"); - try { - Files.copy(audioFile.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - } catch (IOException ex) { - log.warn("Failed to create backup of audiobook {}: {}", audioFile.getName(), ex.getMessage()); - return; - } - try { AudioFile f = AudioFileIO.read(audioFile); Tag tag = f.getTagOrCreateAndSetDefault(); @@ -140,7 +132,7 @@ public void saveMetadataToFile(File audioFile, BookMetadataEntity metadata, Stri } if (hasChanges[0]) { - f.commit(); + commitWithBackup(f, audioFile); log.info("Metadata updated in audiobook: {}", audioFile.getName()); } else { log.debug("No changes detected. Skipping audiobook write for: {}", audioFile.getName()); @@ -148,25 +140,47 @@ public void saveMetadataToFile(File audioFile, BookMetadataEntity metadata, Stri } catch (Exception e) { log.warn("Failed to write metadata to audiobook file {}: {}", audioFile.getName(), e.getMessage(), e); - if (backupFile.exists()) { - try { - Files.copy(backupFile.toPath(), audioFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - log.info("Restored audiobook from backup: {}", audioFile.getName()); - } catch (IOException io) { - log.error("Failed to restore audiobook from backup for {}: {}", audioFile.getName(), io.getMessage(), io); - } + } + } + + private void commitWithBackup(AudioFile audioFile, File originalFile) throws Exception { + File backupFile = new File(originalFile.getParentFile(), originalFile.getName() + ".bak"); + Files.copy(originalFile.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + boolean deleteBackup = false; + try { + audioFile.commit(); + validateWrittenAudiobook(originalFile); + deleteBackup = true; + } catch (Exception writeException) { + try { + Files.copy(backupFile.toPath(), originalFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + deleteBackup = true; + log.info("Restored audiobook from backup: {}", originalFile.getName()); + } catch (IOException restoreException) { + writeException.addSuppressed(restoreException); + log.error("Failed to restore audiobook from backup for {}: {}", + originalFile.getName(), restoreException.getMessage(), restoreException); } + throw writeException; } finally { - if (backupFile.exists()) { + if (deleteBackup) { try { - Files.delete(backupFile.toPath()); + Files.deleteIfExists(backupFile.toPath()); } catch (IOException ex) { - log.warn("Failed to delete backup for {}: {}", audioFile.getName(), ex.getMessage()); + log.warn("Failed to delete backup for {}: {}", originalFile.getName(), ex.getMessage()); } } } } + private void validateWrittenAudiobook(File audioFile) throws Exception { + AudioFile writtenFile = AudioFileIO.read(audioFile); + if (writtenFile.getAudioHeader() == null || writtenFile.getAudioHeader().getTrackLength() <= 0) { + throw new IOException("Written file contains no valid audio track"); + } + } + public void saveCoverToFolder(Path folderPath, byte[] coverData) { if (coverData == null || coverData.length == 0 || folderPath == null) { return; @@ -283,7 +297,7 @@ private void replaceCoverImageInternal(File audioFile, byte[] coverData, String artwork.setBinaryData(coverData); artwork.setMimeType(detectMimeType(coverData)); tag.setField(artwork); - f.commit(); + commitWithBackup(f, audioFile); log.info("Cover image updated in audiobook from {}: {}", source, audioFile.getName()); } catch (Exception e) { diff --git a/backend/src/test/java/org/booklore/service/metadata/writer/AudiobookMetadataWriterTest.java b/backend/src/test/java/org/booklore/service/metadata/writer/AudiobookMetadataWriterTest.java new file mode 100644 index 0000000000..c6a10efeb1 --- /dev/null +++ b/backend/src/test/java/org/booklore/service/metadata/writer/AudiobookMetadataWriterTest.java @@ -0,0 +1,122 @@ +package org.booklore.service.metadata.writer; + +import org.booklore.model.dto.settings.AppSettings; +import org.booklore.model.dto.settings.MetadataPersistenceSettings; +import org.booklore.model.entity.BookEntity; +import org.booklore.model.entity.BookFileEntity; +import org.booklore.model.entity.BookMetadataEntity; +import org.booklore.model.enums.BookFileType; +import org.booklore.service.appsettings.AppSettingService; +import org.jaudiotagger.audio.AudioFile; +import org.jaudiotagger.audio.AudioFileIO; +import org.jaudiotagger.audio.exceptions.CannotReadException; +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.Tag; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AudiobookMetadataWriterTest { + + @TempDir + Path tempDir; + + @Mock + AppSettingService appSettingService; + + private AudiobookMetadataWriter writer; + + @BeforeEach + void setUp() { + MetadataPersistenceSettings.FormatSettings audiobookSettings = + new MetadataPersistenceSettings.FormatSettings(true, 100); + MetadataPersistenceSettings.SaveToOriginalFile saveToOriginalFile = + new MetadataPersistenceSettings.SaveToOriginalFile(); + saveToOriginalFile.setAudiobook(audiobookSettings); + MetadataPersistenceSettings persistenceSettings = new MetadataPersistenceSettings(); + persistenceSettings.setSaveToOriginalFile(saveToOriginalFile); + AppSettings settings = new AppSettings(); + settings.setMetadataPersistenceSettings(persistenceSettings); + when(appSettingService.getAppSettings()).thenReturn(settings); + + writer = new AudiobookMetadataWriter(appSettingService); + } + + @Test + void restoresBackupWhenWrittenAudiobookFailsValidation() throws Exception { + Path path = tempDir.resolve("audiobook.m4b"); + Files.writeString(path, "original audio"); + File file = path.toFile(); + + AudioFile audioFile = mock(AudioFile.class); + Tag tag = mock(Tag.class); + when(audioFile.getTagOrCreateAndSetDefault()).thenReturn(tag); + when(tag.getFirst(any(FieldKey.class))).thenReturn(""); + doAnswer(_ -> { + Files.writeString(path, "corrupt output"); + return null; + }).when(audioFile).commit(); + + BookMetadataEntity metadata = new BookMetadataEntity(); + metadata.setTitle("Updated title"); + + try (MockedStatic audioFileIO = mockStatic(AudioFileIO.class)) { + audioFileIO.when(() -> AudioFileIO.read(file)) + .thenReturn(audioFile) + .thenThrow(new CannotReadException("missing audio track")); + + writer.saveMetadataToFile(file, metadata, null, null); + } + + assertThat(path).hasContent("original audio"); + assertThat(path.resolveSibling("audiobook.m4b.bak")).doesNotExist(); + verify(audioFile).commit(); + } + + @Test + void restoresBackupWhenCoverWriteFailsValidation() throws Exception { + Path path = tempDir.resolve("cover-update.m4b"); + Files.writeString(path, "original audio"); + File file = path.toFile(); + + AudioFile audioFile = mock(AudioFile.class); + Tag tag = mock(Tag.class); + when(audioFile.getTagOrCreateAndSetDefault()).thenReturn(tag); + doAnswer(_ -> { + Files.writeString(path, "corrupt output"); + return null; + }).when(audioFile).commit(); + + BookFileEntity bookFile = mock(BookFileEntity.class); + when(bookFile.getBookType()).thenReturn(BookFileType.AUDIOBOOK); + when(bookFile.getFullFilePath()).thenReturn(path); + BookEntity book = mock(BookEntity.class); + when(book.getBookFiles()).thenReturn(Set.of(bookFile)); + + try (MockedStatic audioFileIO = mockStatic(AudioFileIO.class)) { + audioFileIO.when(() -> AudioFileIO.read(file)) + .thenReturn(audioFile) + .thenThrow(new CannotReadException("missing audio track")); + + writer.replaceCoverImageFromBytes(book, new byte[]{1, 2, 3, 4}); + } + + assertThat(path).hasContent("original audio"); + assertThat(path.resolveSibling("cover-update.m4b.bak")).doesNotExist(); + verify(audioFile).commit(); + } +}