diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/BackgroundUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/BackgroundUploadService.java index 3e45d4de3..8a0cb53e2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/BackgroundUploadService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/BackgroundUploadService.java @@ -44,6 +44,7 @@ public class BackgroundUploadService { deleteExistingBackgroundFiles(userId); fileService.saveBackgroundImage(originalImage, filename, userId); + originalImage.flush(); // Release resources after saving String fileUrl = FileService.getBackgroundUrl(filename, userId); return new UploadResponse(fileUrl); @@ -64,6 +65,7 @@ public class BackgroundUploadService { deleteExistingBackgroundFiles(userId); fileService.saveBackgroundImage(originalImage, filename, userId); + originalImage.flush(); // Release resources after saving String fileUrl = FileService.getBackgroundUrl(filename, userId); return new UploadResponse(fileUrl); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java index 357f25732..817eb4cee 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java @@ -74,11 +74,16 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce try { Optional imageOptional = extractImagesFromArchive(file); if (imageOptional.isPresent()) { - boolean saved = fileService.saveCoverImages(imageOptional.get(), bookEntity.getId()); - if (saved) { - bookEntity.getMetadata().setCoverUpdatedOn(Instant.now()); - bookMetadataRepository.save(bookEntity.getMetadata()); - return true; + BufferedImage image = imageOptional.get(); + try { + boolean saved = fileService.saveCoverImages(image, bookEntity.getId()); + if (saved) { + bookEntity.getMetadata().setCoverUpdatedOn(Instant.now()); + bookMetadataRepository.save(bookEntity.getMetadata()); + return true; + } + } finally { + image.flush(); // Release resources after processing } } } catch (Exception e) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java index 097cc9d33..0ed266801 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/EpubProcessor.java @@ -151,6 +151,12 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc private boolean saveCoverImage(Resource coverImage, long bookId) throws IOException { BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(coverImage.getData())); - return fileService.saveCoverImages(originalImage, bookId); + try { + return fileService.saveCoverImages(originalImage, bookId); + } finally { + if (originalImage != null) { + originalImage.flush(); // Release resources after processing + } + } } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java index 21cb0a7fa..82f3d5e56 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractor.java @@ -47,14 +47,20 @@ public class PdfMetadataExtractor implements FileMetadataExtractor { @Override public byte[] extractCover(File file) { + BufferedImage coverImage = null; try (PDDocument pdf = Loader.loadPDF(file)) { - BufferedImage coverImage = new PDFRenderer(pdf).renderImageWithDPI(0, 300, ImageType.RGB); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(coverImage, "jpg", baos); - return baos.toByteArray(); + coverImage = new PDFRenderer(pdf).renderImageWithDPI(0, 300, ImageType.RGB); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(coverImage, "jpg", baos); + return baos.toByteArray(); + } } catch (Exception e) { log.warn("Failed to extract cover from PDF: {}", file.getAbsolutePath(), e); return null; + } finally { + if (coverImage != null) { + coverImage.flush(); // Release native resources + } } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java index 3dcfb8fcd..a0631db9f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java @@ -130,9 +130,11 @@ public class AppMigrationService { try (var stream = Files.walk(thumbsDir)) { stream.filter(Files::isRegularFile) .forEach(path -> { + BufferedImage originalImage = null; + BufferedImage resized = null; try { // Load original image - BufferedImage originalImage = ImageIO.read(path.toFile()); + originalImage = ImageIO.read(path.toFile()); if (originalImage == null) { log.warn("Skipping non-image file: {}", path); return; @@ -150,7 +152,7 @@ public class AppMigrationService { ImageIO.write(originalImage, "jpg", coverFile.toFile()); // Resize and save thumbnail.jpg - BufferedImage resized = FileService.resizeImage(originalImage, 250, 350); + resized = FileService.resizeImage(originalImage, 250, 350); Path thumbnailFile = bookDir.resolve("thumbnail.jpg"); ImageIO.write(resized, "jpg", thumbnailFile.toFile()); @@ -158,6 +160,13 @@ public class AppMigrationService { } catch (IOException e) { log.error("Error processing file {}", path, e); throw new UncheckedIOException(e); + } finally { + if (originalImage != null) { + originalImage.flush(); + } + if (resized != null) { + resized.flush(); + } } }); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/PdfReaderService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/PdfReaderService.java index bbd866d6e..245cd2aea 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/PdfReaderService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/PdfReaderService.java @@ -94,9 +94,16 @@ public class PdfReaderService { try (PDDocument document = Loader.loadPDF(new File(pdfPath.toFile().toURI()))) { PDFRenderer renderer = new PDFRenderer(document); for (int i = 0; i < document.getNumberOfPages(); i++) { - BufferedImage image = renderer.renderImageWithDPI(i, 200, ImageType.RGB); - Path outputFile = targetDir.resolve(String.format("%04d.jpg", i + 1)); - ImageIO.write(image, "JPEG", outputFile.toFile()); + BufferedImage image = null; + try { + image = renderer.renderImageWithDPI(i, 200, ImageType.RGB); + Path outputFile = targetDir.resolve(String.format("%04d.jpg", i + 1)); + ImageIO.write(image, "JPEG", outputFile.toFile()); + } finally { + if (image != null) { + image.flush(); // Release native resources + } + } } } catch (IOException e) { log.error("Failed to render PDF pages from {}", pdfPath, e); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index 754466efb..bdf9ede08 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -143,15 +143,24 @@ public class FileService { } public static void saveImage(byte[] imageData, String filePath) throws IOException { - BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData)); - File outputFile = new File(filePath); - File parentDir = outputFile.getParentFile(); - if (!parentDir.exists() && !parentDir.mkdirs()) { - throw new IOException("Failed to create directory: " + parentDir); + BufferedImage originalImage = null; + try { + originalImage = ImageIO.read(new ByteArrayInputStream(imageData)); + if (originalImage == null) { + throw new IOException("Invalid image data: unable to decode image"); + } + File outputFile = new File(filePath); + File parentDir = outputFile.getParentFile(); + if (!parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Failed to create directory: " + parentDir); + } + ImageIO.write(originalImage, IMAGE_FORMAT, outputFile); + log.info("Image saved successfully to: {}", filePath); + } finally { + if (originalImage != null) { + originalImage.flush(); // Release native resources + } } - ImageIO.write(originalImage, IMAGE_FORMAT, outputFile); - originalImage.flush(); // Release native resources - log.info("Image saved successfully to: {}", filePath); } public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException { @@ -205,6 +214,7 @@ public class FileService { if (!success) { throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); } + originalImage.flush(); // Release resources after processing log.info("Cover images created and saved for book ID: {}", bookId); } catch (Exception e) { log.error("An error occurred while creating the thumbnail: {}", e.getMessage(), e); @@ -219,6 +229,7 @@ public class FileService { if (!success) { throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); } + originalImage.flush(); // Release resources after processing log.info("Cover images created and saved from URL for book ID: {}", bookId); } catch (Exception e) { log.error("An error occurred while creating thumbnail from URL: {}", e.getMessage(), e); @@ -227,44 +238,58 @@ public class FileService { } public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException { - String folderPath = getImagesFolder(bookId); - File folder = new File(folderPath); - if (!folder.exists() && !folder.mkdirs()) { - throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); + BufferedImage rgbImage = null; + BufferedImage resized = null; + BufferedImage thumb = null; + try { + String folderPath = getImagesFolder(bookId); + File folder = new File(folderPath); + if (!folder.exists() && !folder.mkdirs()) { + throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); + } + + rgbImage = new BufferedImage( + coverImage.getWidth(), + coverImage.getHeight(), + BufferedImage.TYPE_INT_RGB + ); + Graphics2D g = rgbImage.createGraphics(); + g.drawImage(coverImage, 0, 0, Color.WHITE, null); + g.dispose(); + // Note: coverImage is not flushed here - caller is responsible for its lifecycle + + // Resize original image if too large to prevent OOM + double scale = Math.min( + (double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(), + (double) MAX_ORIGINAL_HEIGHT / rgbImage.getHeight() + ); + if (scale < 1.0) { + resized = resizeImage(rgbImage, (int) (rgbImage.getWidth() * scale), (int) (rgbImage.getHeight() * scale)); + rgbImage.flush(); // Release resources of the original large image + rgbImage = resized; + } + + File originalFile = new File(folder, COVER_FILENAME); + boolean originalSaved = ImageIO.write(rgbImage, IMAGE_FORMAT, originalFile); + + thumb = resizeImage(rgbImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); + boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); + + return originalSaved && thumbnailSaved; + } finally { + // Cleanup resources created within this method + // Note: resized may equal rgbImage after reassignment, avoid double-flush + if (rgbImage != null) { + rgbImage.flush(); + } + if (resized != null && resized != rgbImage) { + resized.flush(); + } + if (thumb != null) { + thumb.flush(); + } } - BufferedImage rgbImage = new BufferedImage( - coverImage.getWidth(), - coverImage.getHeight(), - BufferedImage.TYPE_INT_RGB - ); - Graphics2D g = rgbImage.createGraphics(); - g.drawImage(coverImage, 0, 0, Color.WHITE, null); - g.dispose(); - coverImage.flush(); // Original input no longer needed after conversion - - // Resize original image if too large to prevent OOM - double scale = Math.min( - (double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(), - (double) MAX_ORIGINAL_HEIGHT / rgbImage.getHeight() - ); - if (scale < 1.0) { - BufferedImage resized = resizeImage(rgbImage, (int) (rgbImage.getWidth() * scale), (int) (rgbImage.getHeight() * scale)); - rgbImage.flush(); // Release resources of the original large image - rgbImage = resized; - } - - File originalFile = new File(folder, COVER_FILENAME); - boolean originalSaved = ImageIO.write(rgbImage, IMAGE_FORMAT, originalFile); - - BufferedImage thumb = resizeImage(rgbImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); - boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); - - // Cleanup resources - rgbImage.flush(); - thumb.flush(); - - return originalSaved && thumbnailSaved; } public static void setBookCoverPath(BookMetadataEntity bookMetadataEntity) { @@ -313,9 +338,8 @@ public class FileService { } log.info("Background image saved successfully for user {}: {}", userId, filename); - } - - public void deleteBackgroundFile(String filename, Long userId) { + // Note: input image is not flushed here - caller is responsible for its lifecycle + } public void deleteBackgroundFile(String filename, Long userId) { try { String backgroundsFolder = getBackgroundsFolder(userId); File file = new File(backgroundsFolder, filename); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractorTest.java index 5d3afc82b..ba9c81502 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractorTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/PdfMetadataExtractorTest.java @@ -12,9 +12,13 @@ import org.junit.jupiter.api.io.TempDir; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; class PdfMetadataExtractorTest { @@ -51,12 +55,10 @@ class PdfMetadataExtractorTest { @Test void extractMetadata_shouldUseFilenameWithoutExtension_whenMetadataMissing() throws IOException { // Arrange: Create a PDF with NO metadata title - // Name the file "Dune.pdf" File pdfFile = tempDir.resolve("Dune.pdf").toFile(); try (PDDocument doc = new PDDocument()) { doc.addPage(new PDPage()); - // explicitly leaving metadata empty doc.save(pdfFile); } @@ -83,4 +85,41 @@ class PdfMetadataExtractorTest { // Assert assertEquals("Harry Potter and the Sorcerer's Stone", result.getTitle()); } + + @Test + void extractCover_validPdf_returnsJpegBytes() throws IOException { + // Arrange: Create a simple one-page PDF + File pdfFile = tempDir.resolve("cover-test.pdf").toFile(); + + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(); + doc.addPage(page); + doc.save(pdfFile); + } + + // Act + byte[] coverBytes = extractor.extractCover(pdfFile); + + // Assert: Should return non-null, decodable JPEG bytes + assertNotNull(coverBytes); + assertTrue(coverBytes.length > 0); + + BufferedImage coverImage = ImageIO.read(new ByteArrayInputStream(coverBytes)); + assertNotNull(coverImage); + assertTrue(coverImage.getWidth() > 0); + assertTrue(coverImage.getHeight() > 0); + } + + @Test + void extractCover_invalidPdf_returnsNull() throws IOException { + // Arrange: Create a file that looks like a PDF but isn't + File invalidPdf = tempDir.resolve("invalid-pdf.pdf").toFile(); + java.nio.file.Files.writeString(invalidPdf.toPath(), "this is not a real pdf"); + + // Act + byte[] coverBytes = extractor.extractCover(invalidPdf); + + // Assert: Should return null for invalid PDF + assertNull(coverBytes); + } } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java index c47bf5f9c..ca6d598ce 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/FileServiceTest.java @@ -449,7 +449,7 @@ class FileServiceTest { byte[] invalidData = "not an image".getBytes(); Path outputPath = tempDir.resolve("invalid.jpg"); - assertThrows(IllegalArgumentException.class, () -> + assertThrows(IOException.class, () -> fileService.saveImage(invalidData, outputPath.toString())); } @@ -458,7 +458,7 @@ class FileServiceTest { byte[] emptyData = new byte[0]; Path outputPath = tempDir.resolve("empty.jpg"); - assertThrows(IllegalArgumentException.class, () -> + assertThrows(IOException.class, () -> fileService.saveImage(emptyData, outputPath.toString())); } @@ -534,7 +534,6 @@ class FileServiceTest { assertTrue(result); - // Verify the saved image has no transparency BufferedImage saved = ImageIO.read( new File(fileService.getCoverFile(3L))); assertFalse(saved.getColorModel().hasAlpha(), "Saved image should not have transparency"); @@ -565,6 +564,54 @@ class FileServiceTest { () -> assertEquals(1200, saved.getHeight()) ); } + + @Test + void largeImage_isScaledDownToMaxDimensions() throws IOException { + // Create a very large image that will trigger scaling + int largeWidth = 2000; // > MAX_ORIGINAL_WIDTH (1000) + int largeHeight = 3000; // > MAX_ORIGINAL_HEIGHT (1500) + + BufferedImage largeImage = createTestImage(largeWidth, largeHeight); + boolean result = fileService.saveCoverImages(largeImage, 5L); + + assertTrue(result); + + BufferedImage savedCover = ImageIO.read( + new File(fileService.getCoverFile(5L))); + + assertNotNull(savedCover); + + assertTrue(savedCover.getWidth() <= 1000, + "Cover width should be <= MAX_ORIGINAL_WIDTH (1000), was: " + savedCover.getWidth()); + assertTrue(savedCover.getHeight() <= 1500, + "Cover height should be <= MAX_ORIGINAL_HEIGHT (1500), was: " + savedCover.getHeight()); + + double originalRatio = (double) largeWidth / largeHeight; + double savedRatio = (double) savedCover.getWidth() / savedCover.getHeight(); + assertEquals(originalRatio, savedRatio, 0.01, "Aspect ratio should be preserved"); + } + + @Test + void smallImage_maintainsOriginalDimensions() throws IOException { + // Create a small image that should NOT be scaled down + int smallWidth = 400; // < MAX_ORIGINAL_WIDTH (1000) + int smallHeight = 600; // < MAX_ORIGINAL_HEIGHT (1500) + + BufferedImage smallImage = createTestImage(smallWidth, smallHeight); + boolean result = fileService.saveCoverImages(smallImage, 6L); + + assertTrue(result); + + BufferedImage savedCover = ImageIO.read( + new File(fileService.getCoverFile(6L))); + + assertNotNull(savedCover); + + assertEquals(smallWidth, savedCover.getWidth(), + "Small image width should be preserved"); + assertEquals(smallHeight, savedCover.getHeight(), + "Small image height should be preserved"); + } } @Nested