support webp, some fixes.

This commit is contained in:
Stefan Hardegger
2026-06-08 09:19:42 +02:00
parent ca20e54115
commit f4908637b3
7 changed files with 760 additions and 22 deletions

View File

@@ -159,6 +159,68 @@ public class ConfigController {
}
}
/**
* Preview migration of flat EPUB content images into story subfolders (dry run)
*/
@PostMapping("/migrate/epub-images/preview")
public ResponseEntity<Map<String, Object>> previewEpubImageMigration() {
try {
logger.info("Starting EPUB image migration preview");
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(true);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("movedCount", result.getMovedCount());
response.put("unmatchedCount", result.getUnmatchedCount());
response.put("errors", result.getErrors());
response.put("hasErrors", result.hasErrors());
response.put("dryRun", true);
response.put("movedFiles", result.getMovedFiles());
logger.info("EPUB image migration preview completed: {} to move, {} unmatched",
result.getMovedCount(), result.getUnmatchedCount());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Failed to preview EPUB image migration", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("error", "Failed to preview migration: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
return ResponseEntity.status(500).body(errorResponse);
}
}
/**
* Execute migration of flat EPUB content images into story subfolders
*/
@PostMapping("/migrate/epub-images/execute")
public ResponseEntity<Map<String, Object>> executeEpubImageMigration() {
try {
logger.info("Starting EPUB image migration execution");
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("movedCount", result.getMovedCount());
response.put("unmatchedCount", result.getUnmatchedCount());
response.put("errors", result.getErrors());
response.put("hasErrors", result.hasErrors());
response.put("dryRun", false);
response.put("movedFiles", result.getMovedFiles());
logger.info("EPUB image migration completed: {} moved, {} unmatched, {} errors",
result.getMovedCount(), result.getUnmatchedCount(), result.getErrors().size());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Failed to execute EPUB image migration", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("error", "Failed to execute migration: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
return ResponseEntity.status(500).body(errorResponse);
}
}
/**
* Create detailed file information for orphaned image including story relationship
*/

View File

@@ -536,7 +536,7 @@ public class EPUBImportService {
(int) (Math.random() * 100000) + "." + extension;
MultipartFile imageFile = new EPUBCoverMultipartFile(imageData, filename, mediaType);
String imagePath = imageService.uploadImage(imageFile, ImageService.ImageType.CONTENT);
String imagePath = imageService.uploadContentImage(imageFile, storyId);
String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
img.attr("src", imageUrl);

View File

@@ -18,6 +18,7 @@ import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
@@ -31,11 +32,11 @@ public class ImageService {
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/jpg", "image/png"
"image/jpeg", "image/jpg", "image/png", "image/webp"
);
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"jpg", "jpeg", "png"
"jpg", "jpeg", "png", "webp"
);
@Value("${storycove.images.upload-dir:/app/images}")
@@ -102,13 +103,16 @@ public class ImageService {
String filename = UUID.randomUUID().toString() + "." + extension;
Path filePath = typeDir.resolve(filename);
// Process and resize image
BufferedImage processedImage = processImage(file, imageType);
// Save processed image
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos);
Files.write(filePath, baos.toByteArray());
// Process and save image
if (extension.equals("webp")) {
// ImageIO does not support WebP natively; save verbatim without resize
Files.write(filePath, file.getBytes());
} else {
BufferedImage processedImage = processImage(file, imageType);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos);
Files.write(filePath, baos.toByteArray());
}
// Return relative path for database storage
return imageType.getDirectory() + "/" + filename;
@@ -255,6 +259,27 @@ public class ImageService {
return ALLOWED_EXTENSIONS.contains(extension);
}
/**
* Upload a content image for a specific story, storing it in the story's subfolder.
* Use this instead of uploadImage(file, ImageType.CONTENT) for story content images.
*/
public String uploadContentImage(MultipartFile file, UUID storyId) throws IOException {
validateFile(file);
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
Files.createDirectories(contentDir);
String originalFilename = file.getOriginalFilename();
String extension = getFileExtension(originalFilename);
String filename = UUID.randomUUID().toString() + "." + extension;
Path filePath = contentDir.resolve(filename);
// Content images are stored verbatim — no resizing needed, preserves all formats including WebP
Files.write(filePath, file.getBytes());
return ImageType.CONTENT.getDirectory() + "/" + storyId.toString() + "/" + filename;
}
// Content image processing methods
/**
@@ -562,6 +587,8 @@ public class ImageService {
return "jpg";
case "image/png":
return "png";
case "image/webp":
return "webp";
default:
return null;
}
@@ -753,7 +780,7 @@ public class ImageService {
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
// Pattern to match local image URLs in content
Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png)))[\"']", Pattern.CASE_INSENSITIVE);
Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png|webp)))[\"']", Pattern.CASE_INSENSITIVE);
for (com.storycove.entity.Story story : allStories) {
// Add story cover image filename if present
@@ -878,7 +905,7 @@ public class ImageService {
// Fallback: just use the filename portion if it's in the right structure
String fileName = absPath.getFileName().toString();
if (fileName.matches(".*\\.(jpg|jpeg|png)$")) {
if (fileName.matches(".*\\.(jpg|jpeg|png|webp)$")) {
// Try to preserve directory structure if it looks like covers/ or avatars/
Path parent = absPath.getParent();
if (parent != null) {
@@ -952,6 +979,145 @@ public class ImageService {
}
}
/**
* Migrate EPUB content images that were incorrectly stored flat in content/
* instead of content/{storyId}/.
*/
public EpubImageMigrationResult migrateEpubContentImages(boolean dryRun) {
logger.info("Starting EPUB content image migration (dryRun: {})", dryRun);
List<Map<String, Object>> movedFiles = new ArrayList<>();
List<String> errors = new ArrayList<>();
int movedCount = 0;
int unmatchedCount = 0;
try {
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
if (!Files.exists(contentDir)) {
logger.debug("Content images directory does not exist: {}", contentDir);
return new EpubImageMigrationResult(movedFiles, movedCount, unmatchedCount, errors, dryRun);
}
// Collect flat image files (depth 1 only — files directly in content/, not in subdirs)
List<Path> flatFiles;
try (var stream = Files.list(contentDir)) {
flatFiles = stream
.filter(Files::isRegularFile)
.filter(p -> !isSynologySystemPath(p))
.filter(this::isValidImageFile)
.toList();
}
if (flatFiles.isEmpty()) {
logger.info("No flat EPUB images found to migrate");
return new EpubImageMigrationResult(movedFiles, movedCount, unmatchedCount, errors, dryRun);
}
logger.info("Found {} flat content images to process", flatFiles.size());
// Load all stories once
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
for (Path flatFile : flatFiles) {
try {
String filename = flatFile.getFileName().toString();
logger.debug("Processing flat image: {}", filename);
// Find stories that reference this file by filename in their content HTML
List<com.storycove.entity.Story> referencingStories = allStories.stream()
.filter(s -> s.getContentHtml() != null && s.getContentHtml().contains("content/" + filename))
.toList();
long fileSize = Files.exists(flatFile) ? Files.size(flatFile) : 0;
if (referencingStories.isEmpty()) {
logger.debug("No story references flat image: {}", filename);
unmatchedCount++;
Map<String, Object> entry = new HashMap<>();
entry.put("fileName", filename);
entry.put("filePath", flatFile.toString());
entry.put("fileSize", fileSize);
entry.put("formattedSize", formatFileSize(fileSize));
entry.put("storyId", null);
entry.put("storyTitle", null);
entry.put("action", "unmatched");
movedFiles.add(entry);
continue;
}
// Move/copy to each referencing story's subdirectory
boolean firstStory = true;
for (com.storycove.entity.Story story : referencingStories) {
String storyId = story.getId().toString();
Path targetDir = contentDir.resolve(storyId);
Path targetFile = targetDir.resolve(filename);
// Build the new URL for this story's library
String currentLibraryId = libraryService.getCurrentLibraryId();
String oldUrl = "/api/files/images/" + currentLibraryId + "/content/" + filename;
String newUrl = "/api/files/images/" + currentLibraryId + "/content/" + storyId + "/" + filename;
Map<String, Object> entry = new HashMap<>();
entry.put("fileName", filename);
entry.put("filePath", flatFile.toString());
entry.put("targetPath", targetFile.toString());
entry.put("fileSize", fileSize);
entry.put("formattedSize", formatFileSize(fileSize));
entry.put("storyId", storyId);
entry.put("storyTitle", story.getTitle());
entry.put("action", referencingStories.size() > 1 ? "copy" : "move");
movedFiles.add(entry);
if (!dryRun) {
Files.createDirectories(targetDir);
if (firstStory && referencingStories.size() == 1) {
Files.move(flatFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.copy(flatFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
// Update story content to point to the new path
String updatedContent = story.getContentHtml().replace(oldUrl, newUrl);
if (!updatedContent.equals(story.getContentHtml())) {
story.setContentHtml(updatedContent);
storyService.update(story.getId(), story);
logger.debug("Updated story {} content with new image path", storyId);
}
}
firstStory = false;
movedCount++;
}
// If multiple stories referenced the same file, delete the original after copying
if (!dryRun && referencingStories.size() > 1 && Files.exists(flatFile)) {
Files.deleteIfExists(flatFile);
}
} catch (Exception e) {
logger.error("Error migrating flat image {}: {}", flatFile.getFileName(), e.getMessage(), e);
errors.add("Failed to migrate " + flatFile.getFileName() + ": " + e.getMessage());
}
}
logger.info("EPUB image migration completed. Moved: {}, Unmatched: {}, Errors: {}",
movedCount, unmatchedCount, errors.size());
} catch (Exception e) {
logger.error("Error during EPUB image migration", e);
errors.add("General migration error: " + e.getMessage());
}
return new EpubImageMigrationResult(movedFiles, movedCount, unmatchedCount, errors, dryRun);
}
private String formatFileSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
return String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0));
}
/**
* Result class for content image processing
*/
@@ -1009,6 +1175,33 @@ public class ImageService {
}
}
/**
* Result class for EPUB content image migration
*/
public static class EpubImageMigrationResult {
private final List<Map<String, Object>> movedFiles;
private final int movedCount;
private final int unmatchedCount;
private final List<String> errors;
private final boolean dryRun;
public EpubImageMigrationResult(List<Map<String, Object>> movedFiles, int movedCount,
int unmatchedCount, List<String> errors, boolean dryRun) {
this.movedFiles = movedFiles;
this.movedCount = movedCount;
this.unmatchedCount = unmatchedCount;
this.errors = errors;
this.dryRun = dryRun;
}
public List<Map<String, Object>> getMovedFiles() { return movedFiles; }
public int getMovedCount() { return movedCount; }
public int getUnmatchedCount() { return unmatchedCount; }
public List<String> getErrors() { return errors; }
public boolean isDryRun() { return dryRun; }
public boolean hasErrors() { return !errors.isEmpty(); }
}
/**
* Check if a path is a Synology system path that should be ignored
*/

View File

@@ -67,9 +67,10 @@ class ImageServiceTest {
"image", "test.png", "image/png", pngData
);
// Configure ImageService with test values
when(libraryService.getCurrentImagePath()).thenReturn("/default");
when(libraryService.getCurrentLibraryId()).thenReturn("default");
// Configure ImageService with test values — lenient to avoid UnnecessaryStubbingException
// in tests that only use one of the two stubs
lenient().when(libraryService.getCurrentImagePath()).thenReturn("/default");
lenient().when(libraryService.getCurrentLibraryId()).thenReturn("default");
// Set image service properties using reflection
ReflectionTestUtils.setField(imageService, "baseUploadDir", tempDir.toString());
@@ -171,6 +172,42 @@ class ImageServiceTest {
assertEquals("image/png", validImageFile.getContentType());
}
@Test
@DisplayName("Should accept WebP files")
void testAcceptWebpFile() throws IOException {
// Arrange
MockMultipartFile webpFile = new MockMultipartFile(
"image", "test.webp", "image/webp", createMinimalWebpData()
);
// Act & Assert — uploadContentImage saves raw bytes, so a valid WebP should be accepted and saved
String resultPath = imageService.uploadContentImage(webpFile, testStoryId);
assertTrue(resultPath.endsWith(".webp"), "Result path should end with .webp but was: " + resultPath);
Path savedFile = tempDir.resolve("default").resolve(resultPath);
assertTrue(Files.exists(savedFile), "WebP file should be saved on disk");
}
@Test
@DisplayName("uploadContentImage should save WebP file without ImageIO processing")
void uploadContentImage_acceptsWebpFile() throws IOException {
// Arrange
byte[] webpData = createMinimalWebpData();
MockMultipartFile webpFile = new MockMultipartFile(
"image", "content.webp", "image/webp", webpData
);
// Act
String resultPath = imageService.uploadContentImage(webpFile, testStoryId);
// Assert
assertTrue(resultPath.startsWith("content/" + testStoryId + "/"),
"Path should be content/{storyId}/filename but was: " + resultPath);
assertTrue(resultPath.endsWith(".webp"), "Filename should retain .webp extension");
Path savedFile = tempDir.resolve("default").resolve(resultPath);
assertTrue(Files.exists(savedFile), "Saved file should exist on disk");
assertArrayEquals(webpData, Files.readAllBytes(savedFile), "Saved bytes should match original WebP data");
}
// ========================================
// Image Type Tests
// ========================================
@@ -400,8 +437,10 @@ class ImageServiceTest {
void testCollectImageReferences() {
// Arrange
Story story = new Story();
// Use a UUID-based filename — collectAllImageReferences() filters by isUuidBasedFilename()
String imageFilename = UUID.randomUUID() + ".jpg";
story.setId(testStoryId);
story.setContentHtml("<p><img src=\"/api/files/images/default/content/" + testStoryId + "/test-image.jpg\"></p>");
story.setContentHtml("<p><img src=\"/api/files/images/default/content/" + testStoryId + "/" + imageFilename + "\"></p>");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(authorService.findAll()).thenReturn(new ArrayList<>());
@@ -590,10 +629,155 @@ class ImageServiceTest {
assertNotNull(result);
}
// ========================================
// uploadContentImage Tests
// ========================================
@Test
@DisplayName("uploadContentImage should save file into story UUID subfolder")
void uploadContentImage_savesToStorySubfolder() throws IOException {
// Arrange
Path contentDir = tempDir.resolve("default").resolve("content");
// Act
String resultPath = imageService.uploadContentImage(validImageFile, testStoryId);
// Assert
assertTrue(resultPath.startsWith("content/" + testStoryId + "/"),
"Path should be content/{storyId}/filename but was: " + resultPath);
Path savedFile = tempDir.resolve("default").resolve(resultPath);
assertTrue(Files.exists(savedFile), "Saved file should exist on disk");
assertTrue(Files.exists(contentDir.resolve(testStoryId.toString())),
"Story UUID subdirectory should have been created");
}
@Test
@DisplayName("uploadContentImage should reject invalid file")
void uploadContentImage_rejectsInvalidFile() {
MockMultipartFile emptyFile = new MockMultipartFile("image", "test.png", "image/png", new byte[0]);
assertThrows(IllegalArgumentException.class,
() -> imageService.uploadContentImage(emptyFile, testStoryId));
}
// ========================================
// migrateEpubContentImages Tests
// ========================================
@Test
@DisplayName("migrateEpubContentImages dry run should identify flat files with story matches")
void migrateEpubContentImages_dryRun_identifiesCorrectFiles() throws IOException {
// Arrange: place a flat image in content/
Path contentDir = tempDir.resolve("default").resolve("content");
Files.createDirectories(contentDir);
String filename = "epub-img-12345.png";
Files.write(contentDir.resolve(filename), createMinimalPngData());
String libraryId = "default";
Story story = new Story();
story.setId(testStoryId);
story.setTitle("Test Story");
story.setContentHtml("<img src=\"/api/files/images/" + libraryId + "/content/" + filename + "\">");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(libraryService.getCurrentLibraryId()).thenReturn(libraryId);
// Act
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(true);
// Assert
assertEquals(1, result.getMovedCount());
assertEquals(0, result.getUnmatchedCount());
assertTrue(result.isDryRun());
assertFalse(result.hasErrors());
// Dry run: file should still be at original location
assertTrue(Files.exists(contentDir.resolve(filename)), "Dry run must not move file");
}
@Test
@DisplayName("migrateEpubContentImages execute should move file and story content is updated")
void migrateEpubContentImages_execute_movesFilesAndUpdatesContent() throws IOException {
// Arrange
Path contentDir = tempDir.resolve("default").resolve("content");
Files.createDirectories(contentDir);
String filename = "epub-img-99999.png";
Files.write(contentDir.resolve(filename), createMinimalPngData());
String libraryId = "default";
String oldUrl = "/api/files/images/" + libraryId + "/content/" + filename;
Story story = new Story();
story.setId(testStoryId);
story.setTitle("Test Story");
story.setContentHtml("<img src=\"" + oldUrl + "\">");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(libraryService.getCurrentLibraryId()).thenReturn(libraryId);
when(storyService.update(eq(testStoryId), any())).thenReturn(story);
// Act
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false);
// Assert
assertEquals(1, result.getMovedCount());
assertFalse(result.isDryRun());
assertFalse(result.hasErrors());
// Original flat file should be gone
assertFalse(Files.exists(contentDir.resolve(filename)), "Flat file should have been moved");
// File should now exist in story subfolder
assertTrue(Files.exists(contentDir.resolve(testStoryId.toString()).resolve(filename)),
"File should now be in story subfolder");
// storyService.update should have been called to update content URLs
verify(storyService).update(eq(testStoryId), any());
}
@Test
@DisplayName("migrateEpubContentImages should leave unmatched files in place")
void migrateEpubContentImages_unmatchedFile_notMoved() throws IOException {
// Arrange: flat image with no story referencing it
Path contentDir = tempDir.resolve("default").resolve("content");
Files.createDirectories(contentDir);
String filename = "orphaned-img.png";
Files.write(contentDir.resolve(filename), createMinimalPngData());
Story story = new Story();
story.setId(testStoryId);
story.setTitle("Unrelated Story");
story.setContentHtml("<p>No images here</p>");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
// Act
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false);
// Assert
assertEquals(0, result.getMovedCount());
assertEquals(1, result.getUnmatchedCount());
assertTrue(Files.exists(contentDir.resolve(filename)), "Unmatched file must remain");
verify(storyService, never()).update(any(), any());
}
// ========================================
// Helper Methods
// ========================================
/**
* Create minimal valid WebP data for testing.
* This is the smallest possible WebP file (a 1x1 pixel lossless image).
*/
private byte[] createMinimalWebpData() {
// Minimal WebP file: RIFF header + WEBP marker + VP8L chunk for a 1x1 white pixel
return new byte[]{
'R', 'I', 'F', 'F', // RIFF marker
0x24, 0x00, 0x00, 0x00, // File size minus 8 (36 bytes)
'W', 'E', 'B', 'P', // WEBP marker
'V', 'P', '8', 'L', // VP8L chunk marker (lossless)
0x14, 0x00, 0x00, 0x00, // Chunk size (20 bytes)
0x2F, // Signature byte
0x00, 0x00, 0x00, 0x00, // Width-1=0, Height-1=0, alpha, version
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Image data (white pixel)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
}
/**
* Create minimal valid PNG data for testing.
* This is a 1x1 pixel transparent PNG image.