support webp, some fixes.
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user