Fix Image Processing

This commit is contained in:
Stefan Hardegger
2025-09-28 20:06:52 +02:00
parent 622cf9ac76
commit c291559366
8 changed files with 1089 additions and 19 deletions

View File

@@ -2,10 +2,12 @@ package com.storycove;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class StoryCoveApplication {
public static void main(String[] args) {

View File

@@ -44,6 +44,8 @@ public class StoryController {
private final ReadingTimeService readingTimeService;
private final EPUBImportService epubImportService;
private final EPUBExportService epubExportService;
private final AsyncImageProcessingService asyncImageProcessingService;
private final ImageProcessingProgressService progressService;
public StoryController(StoryService storyService,
AuthorService authorService,
@@ -54,7 +56,9 @@ public class StoryController {
SearchServiceAdapter searchServiceAdapter,
ReadingTimeService readingTimeService,
EPUBImportService epubImportService,
EPUBExportService epubExportService) {
EPUBExportService epubExportService,
AsyncImageProcessingService asyncImageProcessingService,
ImageProcessingProgressService progressService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
@@ -65,6 +69,8 @@ public class StoryController {
this.readingTimeService = readingTimeService;
this.epubImportService = epubImportService;
this.epubExportService = epubExportService;
this.asyncImageProcessingService = asyncImageProcessingService;
this.progressService = progressService;
}
@GetMapping
@@ -718,28 +724,15 @@ public class StoryController {
private Story processExternalImagesIfNeeded(Story story) {
try {
if (story.getContentHtml() != null && !story.getContentHtml().trim().isEmpty()) {
logger.debug("Processing external images for story: {}", story.getId());
logger.debug("Starting async image processing for story: {}", story.getId());
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(story.getContentHtml(), story.getId());
// Start async processing - this returns immediately
asyncImageProcessingService.processStoryImagesAsync(story.getId(), story.getContentHtml());
// If content was changed (external images were processed), update the story
if (!result.getProcessedContent().equals(story.getContentHtml())) {
logger.info("External images processed for story {}: {} images downloaded",
story.getId(), result.getDownloadedImages().size());
// Update the story with the processed content
story.setContentHtml(result.getProcessedContent());
story = storyService.updateContentOnly(story.getId(), result.getProcessedContent());
}
if (result.hasWarnings()) {
logger.warn("Image processing warnings for story {}: {}",
story.getId(), result.getWarnings());
}
logger.info("Async image processing started for story: {}", story.getId());
}
} catch (Exception e) {
logger.error("Failed to process external images for story {}: {}",
logger.error("Failed to start async image processing for story {}: {}",
story.getId(), e.getMessage(), e);
// Don't fail the entire operation if image processing fails
}
@@ -747,6 +740,31 @@ public class StoryController {
return story;
}
@GetMapping("/{id}/image-processing-progress")
public ResponseEntity<Map<String, Object>> getImageProcessingProgress(@PathVariable UUID id) {
ImageProcessingProgressService.ImageProcessingProgress progress = progressService.getProgress(id);
if (progress == null) {
return ResponseEntity.ok(Map.of(
"isProcessing", false,
"message", "No active image processing"
));
}
Map<String, Object> response = Map.of(
"isProcessing", !progress.isCompleted(),
"totalImages", progress.getTotalImages(),
"processedImages", progress.getProcessedImages(),
"currentImageUrl", progress.getCurrentImageUrl() != null ? progress.getCurrentImageUrl() : "",
"status", progress.getStatus(),
"progressPercentage", progress.getProgressPercentage(),
"completed", progress.isCompleted(),
"error", progress.getErrorMessage() != null ? progress.getErrorMessage() : ""
);
return ResponseEntity.ok(response);
}
@GetMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(
@RequestParam String title,

View File

@@ -0,0 +1,122 @@
package com.storycove.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class AsyncImageProcessingService {
private static final Logger logger = LoggerFactory.getLogger(AsyncImageProcessingService.class);
private final ImageService imageService;
private final StoryService storyService;
private final ImageProcessingProgressService progressService;
@Autowired
public AsyncImageProcessingService(ImageService imageService,
StoryService storyService,
ImageProcessingProgressService progressService) {
this.imageService = imageService;
this.storyService = storyService;
this.progressService = progressService;
}
@Async
public CompletableFuture<Void> processStoryImagesAsync(UUID storyId, String contentHtml) {
logger.info("Starting async image processing for story: {}", storyId);
try {
// Count external images first
int externalImageCount = countExternalImages(contentHtml);
if (externalImageCount == 0) {
logger.debug("No external images found for story {}", storyId);
return CompletableFuture.completedFuture(null);
}
// Start progress tracking
ImageProcessingProgressService.ImageProcessingProgress progress =
progressService.startProgress(storyId, externalImageCount);
// Process images with progress updates
ImageService.ContentImageProcessingResult result =
processImagesWithProgress(contentHtml, storyId, progress);
// Update story with processed content if changed
if (!result.getProcessedContent().equals(contentHtml)) {
progressService.updateProgress(storyId, progress.getTotalImages(),
"Saving processed content", "Updating story content");
storyService.updateContentOnly(storyId, result.getProcessedContent());
progressService.completeProgress(storyId,
String.format("Completed: %d images processed", result.getDownloadedImages().size()));
logger.info("Async image processing completed for story {}: {} images processed",
storyId, result.getDownloadedImages().size());
} else {
progressService.completeProgress(storyId, "Completed: No images needed processing");
}
// Clean up progress after a delay to allow frontend to see completion
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(5000); // 5 seconds delay
progressService.removeProgress(storyId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (Exception e) {
logger.error("Async image processing failed for story {}: {}", storyId, e.getMessage(), e);
progressService.setError(storyId, e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
private int countExternalImages(String contentHtml) {
if (contentHtml == null || contentHtml.trim().isEmpty()) {
return 0;
}
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
Matcher matcher = imgPattern.matcher(contentHtml);
int count = 0;
while (matcher.find()) {
String src = matcher.group(1);
if (isExternalUrl(src)) {
count++;
}
}
return count;
}
private boolean isExternalUrl(String url) {
return url != null &&
(url.startsWith("http://") || url.startsWith("https://")) &&
!url.contains("/api/files/images/");
}
private ImageService.ContentImageProcessingResult processImagesWithProgress(
String contentHtml, UUID storyId, ImageProcessingProgressService.ImageProcessingProgress progress) {
// Use a custom version of processContentImages that provides progress callbacks
return imageService.processContentImagesWithProgress(contentHtml, storyId,
(currentUrl, processedCount, totalCount) -> {
progressService.updateProgress(storyId, processedCount, currentUrl,
String.format("Processing image %d of %d", processedCount + 1, totalCount));
});
}
}

View File

@@ -0,0 +1,108 @@
package com.storycove.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ImageProcessingProgressService {
private static final Logger logger = LoggerFactory.getLogger(ImageProcessingProgressService.class);
private final Map<UUID, ImageProcessingProgress> progressMap = new ConcurrentHashMap<>();
public static class ImageProcessingProgress {
private final UUID storyId;
private final int totalImages;
private volatile int processedImages;
private volatile String currentImageUrl;
private volatile String status;
private volatile boolean completed;
private volatile String errorMessage;
public ImageProcessingProgress(UUID storyId, int totalImages) {
this.storyId = storyId;
this.totalImages = totalImages;
this.processedImages = 0;
this.status = "Starting";
this.completed = false;
}
// Getters
public UUID getStoryId() { return storyId; }
public int getTotalImages() { return totalImages; }
public int getProcessedImages() { return processedImages; }
public String getCurrentImageUrl() { return currentImageUrl; }
public String getStatus() { return status; }
public boolean isCompleted() { return completed; }
public String getErrorMessage() { return errorMessage; }
public double getProgressPercentage() {
return totalImages > 0 ? (double) processedImages / totalImages * 100 : 100;
}
// Setters
public void setProcessedImages(int processedImages) { this.processedImages = processedImages; }
public void setCurrentImageUrl(String currentImageUrl) { this.currentImageUrl = currentImageUrl; }
public void setStatus(String status) { this.status = status; }
public void setCompleted(boolean completed) { this.completed = completed; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public void incrementProcessed() {
this.processedImages++;
}
}
public ImageProcessingProgress startProgress(UUID storyId, int totalImages) {
ImageProcessingProgress progress = new ImageProcessingProgress(storyId, totalImages);
progressMap.put(storyId, progress);
logger.info("Started image processing progress tracking for story {} with {} images", storyId, totalImages);
return progress;
}
public ImageProcessingProgress getProgress(UUID storyId) {
return progressMap.get(storyId);
}
public void updateProgress(UUID storyId, int processedImages, String currentImageUrl, String status) {
ImageProcessingProgress progress = progressMap.get(storyId);
if (progress != null) {
progress.setProcessedImages(processedImages);
progress.setCurrentImageUrl(currentImageUrl);
progress.setStatus(status);
logger.debug("Updated progress for story {}: {}/{} - {}", storyId, processedImages, progress.getTotalImages(), status);
}
}
public void completeProgress(UUID storyId, String finalStatus) {
ImageProcessingProgress progress = progressMap.get(storyId);
if (progress != null) {
progress.setCompleted(true);
progress.setStatus(finalStatus);
logger.info("Completed image processing for story {}: {}", storyId, finalStatus);
}
}
public void setError(UUID storyId, String errorMessage) {
ImageProcessingProgress progress = progressMap.get(storyId);
if (progress != null) {
progress.setErrorMessage(errorMessage);
progress.setStatus("Error: " + errorMessage);
progress.setCompleted(true);
logger.error("Image processing error for story {}: {}", storyId, errorMessage);
}
}
public void removeProgress(UUID storyId) {
progressMap.remove(storyId);
logger.debug("Removed progress tracking for story {}", storyId);
}
public boolean isProcessing(UUID storyId) {
ImageProcessingProgress progress = progressMap.get(storyId);
return progress != null && !progress.isCompleted();
}
}

View File

@@ -334,6 +334,101 @@ public class ImageService {
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
}
/**
* Functional interface for progress callbacks during image processing
*/
@FunctionalInterface
public interface ImageProcessingProgressCallback {
void onProgress(String currentImageUrl, int processedCount, int totalCount);
}
/**
* Process content images with progress callbacks for async processing
*/
public ContentImageProcessingResult processContentImagesWithProgress(String htmlContent, UUID storyId, ImageProcessingProgressCallback progressCallback) {
logger.debug("Processing content images with progress for story: {}, content length: {}", storyId,
htmlContent != null ? htmlContent.length() : 0);
List<String> warnings = new ArrayList<>();
List<String> downloadedImages = new ArrayList<>();
if (htmlContent == null || htmlContent.trim().isEmpty()) {
logger.debug("No content to process for story: {}", storyId);
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
}
// Find all img tags with src attributes
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
Matcher matcher = imgPattern.matcher(htmlContent);
// First pass: count external images
List<String> externalImages = new ArrayList<>();
Matcher countMatcher = imgPattern.matcher(htmlContent);
while (countMatcher.find()) {
String imageUrl = countMatcher.group(1);
if (!imageUrl.startsWith("/") && !imageUrl.startsWith("data:")) {
externalImages.add(imageUrl);
}
}
int totalExternalImages = externalImages.size();
int processedCount = 0;
StringBuffer processedContent = new StringBuffer();
matcher.reset(); // Reset the matcher for processing
while (matcher.find()) {
String fullImgTag = matcher.group(0);
String imageUrl = matcher.group(1);
logger.debug("Found image: {} in tag: {}", imageUrl, fullImgTag);
try {
// Skip if it's already a local path or data URL
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
logger.debug("Skipping local/data URL: {}", imageUrl);
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
continue;
}
// Call progress callback
if (progressCallback != null) {
progressCallback.onProgress(imageUrl, processedCount, totalExternalImages);
}
logger.debug("Processing external image #{}: {}", processedCount + 1, imageUrl);
// Download and store the image
String localPath = downloadImageFromUrl(imageUrl, storyId);
downloadedImages.add(localPath);
// Generate local URL
String localUrl = getLocalImageUrl(storyId, localPath);
logger.debug("Downloaded image: {} -> {}", imageUrl, localUrl);
// Replace the src attribute with the local path
String newImgTag = fullImgTag
.replaceFirst("src=\"" + Pattern.quote(imageUrl) + "\"", "src=\"" + localUrl + "\"")
.replaceFirst("src='" + Pattern.quote(imageUrl) + "'", "src='" + localUrl + "'");
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
processedCount++;
} catch (Exception e) {
logger.warn("Failed to download image: {} - Error: {}", imageUrl, e.getMessage());
warnings.add("Failed to download image: " + imageUrl + " - " + e.getMessage());
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
}
}
matcher.appendTail(processedContent);
logger.info("Processed {} external images for story: {} (Total: {}, Downloaded: {}, Warnings: {})",
processedCount, storyId, processedCount, downloadedImages.size(), warnings.size());
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
}
/**
* Download an image from a URL and store it locally
*/