feature: PDF-Thumbnails für Dokumente (Upload + Admin-Backfill) #308
@@ -164,12 +164,19 @@
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF rendering for training data export -->
|
||||
<!-- PDF rendering for training data export and thumbnail generation -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
<version>3.0.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- TIFF decoding plugin for ImageIO (thumbnail generation from scanned TIFFs) -->
|
||||
<dependency>
|
||||
<groupId>com.twelvemonkeys.imageio</groupId>
|
||||
<artifactId>imageio-tiff</artifactId>
|
||||
<version>3.12.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -37,4 +37,19 @@ public class AsyncConfig {
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean("thumbnailExecutor")
|
||||
public Executor thumbnailExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(1);
|
||||
executor.setMaxPoolSize(2);
|
||||
executor.setQueueCapacity(200);
|
||||
executor.setThreadNamePrefix("Thumbnail-");
|
||||
// CallerRunsPolicy applies back-pressure to quick-upload batches and admin backfill
|
||||
// instead of dropping work (shared taskExecutor uses AbortPolicy). Safe because the
|
||||
// task is dispatched via TransactionSynchronization.afterCommit, which runs on a
|
||||
// post-commit callback thread without active transaction synchronization.
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -23,6 +24,7 @@ public class AdminController {
|
||||
private final MassImportService massImportService;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final ThumbnailBackfillService thumbnailBackfillService;
|
||||
|
||||
@PostMapping("/trigger-import")
|
||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||
@@ -47,4 +49,15 @@ public class AdminController {
|
||||
int count = documentService.backfillFileHashes();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/generate-thumbnails")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
||||
thumbnailBackfillService.runBackfillAsync();
|
||||
return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus());
|
||||
}
|
||||
|
||||
@GetMapping("/thumbnail-status")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> thumbnailStatus() {
|
||||
return ResponseEntity.ok(thumbnailBackfillService.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,31 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
// --- THUMBNAIL ---
|
||||
@GetMapping("/{id}/thumbnail")
|
||||
public ResponseEntity<InputStreamResource> getDocumentThumbnail(@PathVariable UUID id) {
|
||||
Document doc = documentService.getDocumentById(id);
|
||||
|
||||
if (doc.getThumbnailKey() == null) {
|
||||
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id);
|
||||
}
|
||||
|
||||
try {
|
||||
FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey());
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.IMAGE_JPEG)
|
||||
// `private` (not `public`) prevents shared caches from serving one user's
|
||||
// thumbnail to another (CWE-525). `immutable` is safe because the URL
|
||||
// carries a ?v=<thumbnailGeneratedAt> cache-buster that changes whenever
|
||||
// the underlying file is replaced.
|
||||
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
|
||||
.body(download.resource());
|
||||
} catch (FileService.StorageFileNotFoundException e) {
|
||||
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND,
|
||||
"Thumbnail missing in storage: " + doc.getThumbnailKey());
|
||||
}
|
||||
}
|
||||
|
||||
// --- METADATA ---
|
||||
@GetMapping("/{id}")
|
||||
public Document getDocument(@PathVariable UUID id) {
|
||||
|
||||
@@ -38,6 +38,10 @@ public enum ErrorCode {
|
||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||
IMPORT_ALREADY_RUNNING,
|
||||
|
||||
// --- Thumbnails ---
|
||||
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
|
||||
THUMBNAIL_BACKFILL_ALREADY_RUNNING,
|
||||
|
||||
// --- Invites ---
|
||||
/** The invite code does not exist. 404 */
|
||||
INVITE_NOT_FOUND,
|
||||
|
||||
@@ -43,6 +43,13 @@ public class Document {
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
// S3 key of the generated thumbnail (e.g. "thumbnails/{docId}.jpg"); null until generated
|
||||
@Column(name = "thumbnail_key")
|
||||
private String thumbnailKey;
|
||||
|
||||
@Column(name = "thumbnail_generated_at")
|
||||
private LocalDateTime thumbnailGeneratedAt;
|
||||
|
||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||
@Column(name = "original_filename", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -46,6 +46,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
List<Document> findByFilePathIsNotNullAndThumbnailKeyIsNull();
|
||||
|
||||
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
|
||||
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ public class DocumentService {
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -125,6 +126,7 @@ public class DocumentService {
|
||||
if (wasPlaceholder) {
|
||||
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||
}
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
return new StoreResult(saved, isNew);
|
||||
}
|
||||
|
||||
@@ -187,7 +189,8 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// Datei
|
||||
if (file != null && !file.isEmpty()) {
|
||||
boolean fileUploaded = file != null && !file.isEmpty();
|
||||
if (fileUploaded) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
@@ -197,6 +200,9 @@ public class DocumentService {
|
||||
|
||||
Document finalDoc = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(finalDoc);
|
||||
if (fileUploaded) {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
|
||||
}
|
||||
return finalDoc;
|
||||
}
|
||||
|
||||
@@ -249,7 +255,8 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
if (newFile != null && !newFile.isEmpty()) {
|
||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||
if (fileReplaced) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
@@ -268,6 +275,10 @@ public class DocumentService {
|
||||
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
|
||||
}
|
||||
|
||||
if (fileReplaced) {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -329,6 +340,7 @@ public class DocumentService {
|
||||
}
|
||||
Document saved = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(saved);
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
if (wasPlaceholder) {
|
||||
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,27 @@ public class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a streaming download from S3/MinIO. The caller is responsible for
|
||||
* closing the returned stream — typically via try-with-resources. Preferred
|
||||
* over {@link #downloadFileBytes(String)} for large files (multi-MB PDFs
|
||||
* during thumbnail generation) because it avoids loading the entire file
|
||||
* into heap memory.
|
||||
*/
|
||||
public InputStream downloadFileStream(String s3Key) throws IOException {
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
return s3Client.getObject(getObjectRequest);
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new IOException("Failed to open stream from storage: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a presigned URL for downloading an object from S3/MinIO.
|
||||
* Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware
|
||||
|
||||
@@ -59,6 +59,7 @@ public class MassImportService {
|
||||
private final PersonService personService;
|
||||
private final TagService tagService;
|
||||
private final S3Client s3Client;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
|
||||
@Value("${app.s3.bucket}")
|
||||
private String bucketName;
|
||||
@@ -332,7 +333,10 @@ public class MassImportService {
|
||||
if (tag != null) doc.getTags().add(tag);
|
||||
doc.setMetadataComplete(metadataComplete);
|
||||
|
||||
documentRepository.save(doc);
|
||||
Document saved = documentRepository.save(doc);
|
||||
if (file.isPresent()) {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* Bridges document upload paths to asynchronous thumbnail generation. Use
|
||||
* {@link #dispatchAfterCommit(UUID)} from inside {@code @Transactional} service methods —
|
||||
* it registers a post-commit hook so the async task only fires when the surrounding
|
||||
* transaction actually commits, and is silently skipped on rollback. Mirrors
|
||||
* {@link org.raddatz.familienarchiv.audit.AuditService#logAfterCommit}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ThumbnailAsyncRunner {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final ThumbnailService thumbnailService;
|
||||
|
||||
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
|
||||
private long generateTimeoutSeconds = 30L;
|
||||
|
||||
/**
|
||||
* Registers a post-commit hook that triggers asynchronous thumbnail generation for the
|
||||
* given document. When no transaction is active the task is dispatched immediately.
|
||||
* Safe to call from inside {@code @Transactional} service methods.
|
||||
*/
|
||||
public void dispatchAfterCommit(UUID documentId) {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
generateAsync(documentId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
generateAsync(documentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs thumbnail generation on the {@code thumbnailExecutor} pool, wrapped in a watchdog
|
||||
* timeout so a hung PDFBox render cannot occupy a pool thread indefinitely. Never throws:
|
||||
* all errors and timeouts are logged and swallowed so upload paths are not affected.
|
||||
*/
|
||||
@Async("thumbnailExecutor")
|
||||
public void generateAsync(UUID documentId) {
|
||||
Optional<Document> docOpt = documentRepository.findById(documentId);
|
||||
if (docOpt.isEmpty()) {
|
||||
log.warn("Thumbnail generation skipped: document not found id={}", documentId);
|
||||
return;
|
||||
}
|
||||
Document doc = docOpt.get();
|
||||
|
||||
ExecutorService timeoutWorker = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "Thumbnail-Render-" + documentId);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
try {
|
||||
Future<ThumbnailService.Outcome> future = timeoutWorker.submit(
|
||||
() -> thumbnailService.generate(doc));
|
||||
try {
|
||||
future.get(generateTimeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
log.warn("Thumbnail generation timed out after {}s for doc={}",
|
||||
generateTimeoutSeconds, documentId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail generation errored for doc={} reason={}",
|
||||
documentId, e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
timeoutWorker.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Sequentially regenerates thumbnails for documents that have a file attached but no
|
||||
* thumbnail yet. Runs on the {@code thumbnailExecutor} pool — single-threaded iteration
|
||||
* is intentional: PDFBox + ImageIO are memory-heavy and we cap peak usage by processing
|
||||
* documents one at a time. Only one backfill can run at a time; concurrent starts are
|
||||
* rejected with {@link ErrorCode#THUMBNAIL_BACKFILL_ALREADY_RUNNING}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ThumbnailBackfillService {
|
||||
|
||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||
|
||||
public record BackfillStatus(
|
||||
State state,
|
||||
String message,
|
||||
int total,
|
||||
int processed,
|
||||
int skipped,
|
||||
int failed,
|
||||
LocalDateTime startedAt
|
||||
) {}
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final ThumbnailService thumbnailService;
|
||||
|
||||
private volatile BackfillStatus currentStatus = new BackfillStatus(
|
||||
State.IDLE, "Kein Backfill gestartet.", 0, 0, 0, 0, null);
|
||||
|
||||
public BackfillStatus getStatus() {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
@Async("thumbnailExecutor")
|
||||
public void runBackfillAsync() {
|
||||
if (currentStatus.state() == State.RUNNING) {
|
||||
throw DomainException.conflict(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING,
|
||||
"Thumbnail-Backfill läuft bereits");
|
||||
}
|
||||
|
||||
LocalDateTime startedAt = LocalDateTime.now();
|
||||
List<Document> docs;
|
||||
try {
|
||||
docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull();
|
||||
} catch (Exception e) {
|
||||
currentStatus = new BackfillStatus(State.FAILED,
|
||||
"Backfill fehlgeschlagen: " + e.getMessage(),
|
||||
0, 0, 0, 0, startedAt);
|
||||
log.warn("Thumbnail backfill aborted before starting: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
int total = docs.size();
|
||||
currentStatus = new BackfillStatus(State.RUNNING,
|
||||
"Backfill läuft…", total, 0, 0, 0, startedAt);
|
||||
log.info("Thumbnail backfill started: total={}", total);
|
||||
|
||||
int processed = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
for (Document doc : docs) {
|
||||
ThumbnailService.Outcome outcome;
|
||||
try {
|
||||
outcome = thumbnailService.generate(doc);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail generation failed for doc={} reason={}",
|
||||
doc.getId(), e.getMessage());
|
||||
outcome = ThumbnailService.Outcome.FAILED;
|
||||
}
|
||||
switch (outcome) {
|
||||
case SUCCESS -> processed++;
|
||||
case SKIPPED -> skipped++;
|
||||
case FAILED -> failed++;
|
||||
}
|
||||
currentStatus = new BackfillStatus(State.RUNNING,
|
||||
"Backfill läuft…", total, processed, skipped, failed, startedAt);
|
||||
}
|
||||
|
||||
long durationMs = Duration.between(startedAt, LocalDateTime.now()).toMillis();
|
||||
log.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}",
|
||||
total, processed, skipped, failed, durationMs);
|
||||
|
||||
currentStatus = new BackfillStatus(State.DONE,
|
||||
String.format("Fertig: %d erzeugt, %d übersprungen, %d fehlgeschlagen.",
|
||||
processed, skipped, failed),
|
||||
total, processed, skipped, failed, startedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image)
|
||||
* and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via
|
||||
* {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for
|
||||
* historical documents. Explicitly does not throw — failures are returned as
|
||||
* {@link Outcome#FAILED} so the backfill can account for them without aborting the run.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ThumbnailService {
|
||||
|
||||
public enum Outcome { SUCCESS, SKIPPED, FAILED }
|
||||
|
||||
private static final int THUMBNAIL_WIDTH = 240;
|
||||
private static final float JPEG_QUALITY = 0.85f;
|
||||
private static final int PDF_RENDER_DPI = 100;
|
||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||
private static final Set<String> IMAGE_CONTENT_TYPES =
|
||||
Set.of("image/jpeg", "image/png", "image/tiff");
|
||||
|
||||
// Deterministic S3 key — `thumbnails/{docId}.jpg`. When a document's file is replaced
|
||||
// the regenerated thumbnail overwrites this same key via PutObject, so we never
|
||||
// orphan old thumbnails. The URL-level cache buster is the `thumbnail_generated_at`
|
||||
// timestamp (see /api/documents/{id}/thumbnail ?v= param).
|
||||
private static final String THUMBNAIL_KEY_PREFIX = "thumbnails/";
|
||||
private static final String THUMBNAIL_KEY_SUFFIX = ".jpg";
|
||||
|
||||
private final FileService fileService;
|
||||
private final S3Client s3Client;
|
||||
private final DocumentRepository documentRepository;
|
||||
|
||||
@Value("${app.s3.bucket}")
|
||||
private String bucketName;
|
||||
|
||||
public ThumbnailService(FileService fileService, S3Client s3Client,
|
||||
DocumentRepository documentRepository) {
|
||||
this.fileService = fileService;
|
||||
this.s3Client = s3Client;
|
||||
this.documentRepository = documentRepository;
|
||||
}
|
||||
|
||||
public Outcome generate(Document doc) {
|
||||
if (doc.getFilePath() == null) {
|
||||
log.debug("Document {} has no filePath, skipping thumbnail", doc.getId());
|
||||
return Outcome.SKIPPED;
|
||||
}
|
||||
String contentType = doc.getContentType();
|
||||
if (contentType == null || !isSupported(contentType)) {
|
||||
log.warn("Document {} has unsupported contentType {}, skipping thumbnail",
|
||||
doc.getId(), contentType);
|
||||
return Outcome.SKIPPED;
|
||||
}
|
||||
|
||||
BufferedImage source = readSourceImage(doc, contentType);
|
||||
if (source == null) return Outcome.FAILED;
|
||||
|
||||
byte[] jpeg = encodeThumbnail(source, doc.getId());
|
||||
if (jpeg == null) return Outcome.FAILED;
|
||||
|
||||
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
||||
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
||||
|
||||
return persistThumbnailMetadata(doc, thumbnailKey);
|
||||
}
|
||||
|
||||
private static String thumbnailKeyFor(UUID documentId) {
|
||||
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
||||
}
|
||||
|
||||
private BufferedImage readSourceImage(Document doc, String contentType) {
|
||||
try {
|
||||
return PDF_CONTENT_TYPE.equals(contentType)
|
||||
? renderPdfFirstPage(doc.getFilePath())
|
||||
: readImage(doc.getFilePath());
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail source read failed for doc={} reason={}",
|
||||
doc.getId(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] encodeThumbnail(BufferedImage source, UUID documentId) {
|
||||
try {
|
||||
BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH);
|
||||
return encodeJpeg(scaled, JPEG_QUALITY);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail JPEG encoding failed for doc={} reason={}",
|
||||
documentId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean uploadToStorage(String thumbnailKey, byte[] jpeg, UUID documentId) {
|
||||
try {
|
||||
s3Client.putObject(
|
||||
PutObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(thumbnailKey)
|
||||
.contentType("image/jpeg")
|
||||
.build(),
|
||||
RequestBody.fromBytes(jpeg));
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail upload failed for doc={} key={} reason={}",
|
||||
documentId, thumbnailKey, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) {
|
||||
try {
|
||||
doc.setThumbnailKey(thumbnailKey);
|
||||
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
||||
documentRepository.save(doc);
|
||||
return Outcome.SUCCESS;
|
||||
} catch (Exception e) {
|
||||
// Thumbnail is already in S3 but the entity update failed. Because the S3
|
||||
// key is deterministic (thumbnails/{docId}.jpg), the next successful run
|
||||
// — either a re-upload of this document or the admin backfill — will
|
||||
// overwrite it cleanly. Logging distinctly so an operator tracking
|
||||
// backfill totals can spot the database-side issue.
|
||||
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
|
||||
doc.getId(), thumbnailKey, e.getMessage());
|
||||
return Outcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSupported(String contentType) {
|
||||
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
|
||||
}
|
||||
|
||||
private BufferedImage renderPdfFirstPage(String s3Key) throws IOException {
|
||||
try (InputStream in = fileService.downloadFileStream(s3Key);
|
||||
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
|
||||
PDFRenderer renderer = new PDFRenderer(pdf);
|
||||
return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage readImage(String s3Key) throws IOException {
|
||||
try (InputStream in = fileService.downloadFileStream(s3Key)) {
|
||||
BufferedImage img = ImageIO.read(in);
|
||||
if (img == null) {
|
||||
throw new IOException("No ImageIO reader available for " + s3Key);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) {
|
||||
int sourceWidth = source.getWidth();
|
||||
int sourceHeight = source.getHeight();
|
||||
int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth));
|
||||
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = scaled.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
|
||||
g.dispose();
|
||||
return scaled;
|
||||
}
|
||||
|
||||
private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(quality);
|
||||
try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) {
|
||||
writer.setOutput(out);
|
||||
writer.write(null, new IIOImage(image, null, null), param);
|
||||
} finally {
|
||||
writer.dispose();
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN thumbnail_key VARCHAR(255),
|
||||
ADD COLUMN thumbnail_generated_at TIMESTAMP;
|
||||
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -16,10 +17,13 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -33,6 +37,7 @@ class AdminControllerTest {
|
||||
@MockitoBean MassImportService massImportService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean DocumentVersionService documentVersionService;
|
||||
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@Test
|
||||
@@ -83,4 +88,57 @@ class AdminControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/generate-thumbnails ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void generateThumbnails_returns202_withStatus_whenAdmin() throws Exception {
|
||||
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
|
||||
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.state").value("RUNNING"))
|
||||
.andExpect(jsonPath("$.total").value(10));
|
||||
|
||||
verify(thumbnailBackfillService).runBackfillAsync();
|
||||
}
|
||||
|
||||
// ─── GET /api/admin/thumbnail-status ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void thumbnailStatus_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/thumbnail-status"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void thumbnailStatus_returns200_withCurrentStatus_whenAdmin() throws Exception {
|
||||
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
|
||||
ThumbnailBackfillService.State.DONE, "Fertig: 5 erzeugt, 0 übersprungen, 0 fehlgeschlagen.",
|
||||
5, 5, 0, 0, LocalDateTime.now());
|
||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/thumbnail-status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.state").value("DONE"))
|
||||
.andExpect(jsonPath("$.processed").value(5))
|
||||
.andExpect(jsonPath("$.total").value(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -361,6 +362,62 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/thumbnail ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build();
|
||||
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg").build();
|
||||
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
|
||||
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
|
||||
.thenReturn(new FileService.S3FileDownload(
|
||||
new org.springframework.core.io.InputStreamResource(stream), "image/jpeg"));
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", "image/jpeg"))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("private")))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public"))))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("immutable")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg").build();
|
||||
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
|
||||
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -55,6 +55,7 @@ class DocumentServiceTest {
|
||||
@Mock AuditService auditService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -257,6 +258,107 @@ class DocumentServiceTest {
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void storeDocument_dispatchesThumbnailAfterSave() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
||||
UUID savedId = UUID.randomUUID();
|
||||
Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build();
|
||||
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
||||
|
||||
documentService.storeDocument(file, null);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("No file");
|
||||
UUID savedId = UUID.randomUUID();
|
||||
Document saved = Document.builder().id(savedId).title("No file")
|
||||
.originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verifyNoInteractions(thumbnailAsyncRunner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("With file");
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
UUID savedId = UUID.randomUUID();
|
||||
Document saved = Document.builder().id(savedId).title("With file")
|
||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
when(fileService.uploadFile(any(), any()))
|
||||
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
|
||||
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Doc").originalFilename("old.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);
|
||||
|
||||
verifyNoInteractions(thumbnailAsyncRunner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Doc").originalFilename("old.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
org.springframework.mock.web.MockMultipartFile newFile =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any()))
|
||||
.thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachFile_dispatchesThumbnailAfterSave() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Placeholder").originalFilename("placeholder")
|
||||
.status(DocumentStatus.PLACEHOLDER).build();
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any()))
|
||||
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
|
||||
|
||||
documentService.attachFile(id, file, null);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
|
||||
}
|
||||
|
||||
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -197,4 +197,39 @@ class FileServiceTest {
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Failed to download");
|
||||
}
|
||||
|
||||
// ─── downloadFileStream ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void downloadFileStream_returnsStreamableContent() throws IOException {
|
||||
byte[] content = "streamed bytes".getBytes();
|
||||
GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build();
|
||||
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
||||
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
||||
|
||||
try (java.io.InputStream result = fileService.downloadFileStream("documents/file.pdf")) {
|
||||
assertThat(result.readAllBytes()).isEqualTo(content);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadFileStream_throwsStorageFileNotFoundException_whenNoSuchKey() {
|
||||
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
|
||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||
|
||||
assertThatThrownBy(() -> fileService.downloadFileStream("missing/key.pdf"))
|
||||
.isInstanceOf(FileService.StorageFileNotFoundException.class)
|
||||
.hasMessageContaining("missing/key.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadFileStream_throwsIOException_whenS3Exception() {
|
||||
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
|
||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||
|
||||
assertThatThrownBy(() -> fileService.downloadFileStream("documents/file.pdf"))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Failed to open stream");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,13 @@ class MassImportServiceTest {
|
||||
@Mock PersonService personService;
|
||||
@Mock TagService tagService;
|
||||
@Mock S3Client s3Client;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
|
||||
MassImportService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new MassImportService(documentRepository, personService, tagService, s3Client);
|
||||
service = new MassImportService(documentRepository, personService, tagService, s3Client, thumbnailAsyncRunner);
|
||||
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
||||
ReflectionTestUtils.setField(service, "colIndex", 0);
|
||||
ReflectionTestUtils.setField(service, "colBox", 1);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ThumbnailAsyncRunnerTest {
|
||||
|
||||
private DocumentRepository documentRepository;
|
||||
private ThumbnailService thumbnailService;
|
||||
private ThumbnailAsyncRunner runner;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
thumbnailService = mock(ThumbnailService.class);
|
||||
runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
runner.dispatchAfterCommit(id);
|
||||
|
||||
verify(thumbnailService).generate(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
runner.dispatchAfterCommit(id);
|
||||
|
||||
// Nothing fired yet — registered, not executed
|
||||
verify(thumbnailService, never()).generate(any());
|
||||
|
||||
// Simulate commit
|
||||
ArgumentCaptor<TransactionSynchronization> captor =
|
||||
ArgumentCaptor.forClass(TransactionSynchronization.class);
|
||||
assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1);
|
||||
TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit();
|
||||
|
||||
verify(thumbnailService).generate(doc);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAfterCommit_whenRollback_doesNotDispatch() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
runner.dispatchAfterCommit(id);
|
||||
|
||||
// Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired
|
||||
TransactionSynchronizationManager.getSynchronizations().get(0)
|
||||
.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
|
||||
verify(thumbnailService, never()).generate(any());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateAsync_skipsWhenDocumentMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
runner.generateAsync(id);
|
||||
|
||||
verifyNoInteractions(thumbnailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
// generate sleeps longer than the timeout — simulates a hung PDFBox render
|
||||
when(thumbnailService.generate(doc)).thenAnswer(inv -> {
|
||||
Thread.sleep(5_000);
|
||||
return ThumbnailService.Outcome.SUCCESS;
|
||||
});
|
||||
// Shrink timeout for the test
|
||||
ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
runner.generateAsync(id);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
// Must return before the 5s sleep — within ~2s with timeout=1s plus overhead
|
||||
assertThat(elapsed).isLessThan(3_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ThumbnailBackfillServiceTest {
|
||||
|
||||
private DocumentRepository documentRepository;
|
||||
private ThumbnailService thumbnailService;
|
||||
private ThumbnailBackfillService backfillService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
thumbnailService = mock(ThumbnailService.class);
|
||||
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialStatus_isIdle() {
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE);
|
||||
assertThat(status.total()).isZero();
|
||||
assertThat(status.processed()).isZero();
|
||||
assertThat(status.startedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_processesAllDocumentsAndFinishesDone() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
Document c = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b, c));
|
||||
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.total()).isEqualTo(3);
|
||||
assertThat(status.processed()).isEqualTo(3);
|
||||
assertThat(status.skipped()).isZero();
|
||||
assertThat(status.failed()).isZero();
|
||||
verify(thumbnailService, times(3)).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_countsSkippedSeparately() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b));
|
||||
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.processed()).isEqualTo(1);
|
||||
assertThat(status.skipped()).isEqualTo(1);
|
||||
assertThat(status.failed()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_continuesAfterFailureAndCountsIt() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
Document c = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b, c));
|
||||
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
|
||||
when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.processed()).isEqualTo(2);
|
||||
assertThat(status.failed()).isEqualTo(1);
|
||||
verify(thumbnailService, times(3)).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b));
|
||||
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
|
||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.processed()).isEqualTo(1);
|
||||
assertThat(status.failed()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_rejectsConcurrentStart() {
|
||||
// Force state=RUNNING via reflection
|
||||
ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus(
|
||||
ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(backfillService, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> backfillService.runBackfillAsync())
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_setsStartedAtAndMessage() {
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(doc()));
|
||||
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.startedAt()).isAfter(before);
|
||||
assertThat(status.message()).isNotBlank();
|
||||
}
|
||||
|
||||
private Document doc() {
|
||||
return Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("t")
|
||||
.originalFilename("f.pdf")
|
||||
.filePath("documents/f.pdf")
|
||||
.contentType("application/pdf")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Full round-trip integration test against real MinIO and real Postgres. Catches S3
|
||||
* signing / presigning issues that a mocked S3Client would miss — the rest of the
|
||||
* test pyramid mocks at the FileService boundary.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class ThumbnailServiceIntegrationTest {
|
||||
|
||||
private static final String BUCKET = "archive-documents";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
static GenericContainer<?> minio = new GenericContainer<>("minio/minio:RELEASE.2024-06-13T22-53-53Z")
|
||||
.withEnv("MINIO_ROOT_USER", ACCESS_KEY)
|
||||
.withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
|
||||
.withCommand("server /data")
|
||||
.withExposedPorts(9000);
|
||||
|
||||
static {
|
||||
minio.start();
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void s3Properties(DynamicPropertyRegistry registry) {
|
||||
registry.add("app.s3.endpoint", () -> "http://" + minio.getHost() + ":" + minio.getMappedPort(9000));
|
||||
registry.add("app.s3.access-key", () -> ACCESS_KEY);
|
||||
registry.add("app.s3.secret-key", () -> SECRET_KEY);
|
||||
registry.add("app.s3.bucket", () -> BUCKET);
|
||||
registry.add("app.s3.region", () -> "eu-central-1");
|
||||
}
|
||||
|
||||
@Autowired S3Client s3Client;
|
||||
@Autowired ThumbnailService thumbnailService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@Test
|
||||
void generate_writesDecodableJpegToMinio_readbackMatches() throws IOException {
|
||||
// Ensure bucket exists (the real app has a bootstrap container for this; in tests we do it here).
|
||||
// Re-creating is a no-op; wrap in try/catch because the SDK throws on "already owned".
|
||||
try (S3Client bootstrap = buildClient()) {
|
||||
try {
|
||||
bootstrap.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
|
||||
} catch (Exception ignored) {
|
||||
// already exists
|
||||
}
|
||||
}
|
||||
|
||||
// Persist first so Hibernate assigns the UUID — avoids StaleObjectState on a pre-set id
|
||||
Document persisted = documentRepository.save(Document.builder()
|
||||
.title("IT Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.contentType("application/pdf")
|
||||
.build());
|
||||
UUID docId = persisted.getId();
|
||||
String pdfKey = "documents/" + docId + "_test.pdf";
|
||||
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(BUCKET)
|
||||
.key(pdfKey)
|
||||
.contentType("application/pdf")
|
||||
.build(),
|
||||
RequestBody.fromBytes(createSamplePdf()));
|
||||
|
||||
persisted.setFilePath(pdfKey);
|
||||
persisted = documentRepository.save(persisted);
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(persisted);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
Document reloaded = documentRepository.findById(docId).orElseThrow();
|
||||
assertThat(reloaded.getThumbnailKey()).isEqualTo("thumbnails/" + docId + ".jpg");
|
||||
assertThat(reloaded.getThumbnailGeneratedAt()).isNotNull();
|
||||
|
||||
// Read back from MinIO and verify it decodes as a JPEG of the expected width
|
||||
try (InputStream in = s3Client.getObject(GetObjectRequest.builder()
|
||||
.bucket(BUCKET).key(reloaded.getThumbnailKey()).build())) {
|
||||
byte[] jpegBytes = in.readAllBytes();
|
||||
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(jpegBytes));
|
||||
assertThat(decoded).isNotNull();
|
||||
assertThat(decoded.getWidth()).isEqualTo(240);
|
||||
}
|
||||
}
|
||||
|
||||
private static S3Client buildClient() {
|
||||
return S3Client.builder()
|
||||
.endpointOverride(URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000)))
|
||||
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.region(Region.of("eu-central-1"))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf() throws IOException {
|
||||
try (PDDocument pdf = new PDDocument()) {
|
||||
pdf.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
pdf.save(bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ThumbnailServiceTest {
|
||||
|
||||
private FileService fileService;
|
||||
private S3Client s3Client;
|
||||
private DocumentRepository documentRepository;
|
||||
private ThumbnailService thumbnailService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileService = mock(FileService.class);
|
||||
s3Client = mock(S3Client.class);
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
|
||||
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsSkipped_whenDocumentHasNoFilePath() {
|
||||
Document doc = makeDoc("application/pdf", null);
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
|
||||
verifyNoInteractions(s3Client);
|
||||
assertThat(doc.getThumbnailKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsSkipped_forUnsupportedContentType() throws IOException {
|
||||
Document doc = makeDoc("application/msword", "documents/letter.doc");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3}));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
|
||||
verifyNoInteractions(s3Client);
|
||||
assertThat(doc.getThumbnailKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
byte[] pdfBytes = createSamplePdf();
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(pdfBytes));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
ArgumentCaptor<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
|
||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
||||
verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture());
|
||||
|
||||
PutObjectRequest req = putCaptor.getValue();
|
||||
assertThat(req.bucket()).isEqualTo("test-bucket");
|
||||
assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
||||
assertThat(req.contentType()).isEqualTo("image/jpeg");
|
||||
|
||||
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
|
||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
|
||||
assertThat(jpg).isNotNull();
|
||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
||||
|
||||
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
||||
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_rendersPng_uploadsJpegAtWidth240() throws IOException {
|
||||
Document doc = makeDoc("image/png", "documents/scan.png");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
||||
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
|
||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
|
||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
||||
assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_rendersJpeg_uploadsScaledJpeg() throws IOException {
|
||||
Document doc = makeDoc("image/jpeg", "documents/photo.jpg");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(
|
||||
readAll(bodyCaptor.getValue().contentStreamProvider().newStream())));
|
||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
||||
assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenS3PutThrows() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build());
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
assertThat(doc.getThumbnailKey()).isNull();
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenSourceStreamThrows() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenThrow(new IOException("network blip"));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
verifyNoInteractions(s3Client);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
|
||||
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
|
||||
// entity update blew up. We must still return FAILED so the backfill
|
||||
// tally is honest, without losing the fact that we already put bytes in S3.
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
||||
when(documentRepository.save(any()))
|
||||
.thenThrow(new RuntimeException("constraint violation"));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
verify(documentRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private Document makeDoc(String contentType, String filePath) {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.contentType(contentType)
|
||||
.filePath(filePath)
|
||||
.build();
|
||||
doc.setCreatedAt(LocalDateTime.now());
|
||||
doc.setUpdatedAt(LocalDateTime.now());
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
||||
content.newLineAtOffset(100, 700);
|
||||
content.showText("Lieber Hans,");
|
||||
content.endText();
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createSamplePng(int width, int height) throws IOException {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(Color.LIGHT_GRAY);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.setColor(Color.DARK_GRAY);
|
||||
g.fillRect(0, 0, width, height / 4);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] createSampleJpeg(int width, int height) throws IOException {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream stream) throws IOException {
|
||||
try (stream) {
|
||||
return stream.readAllBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
49
docs/adr/004-pdfbox-thumbnails.md
Normal file
49
docs/adr/004-pdfbox-thumbnails.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# ADR-004: In-Process PDFBox Thumbnails (not ocr-service)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The archive lists documents as text-only rows everywhere (home search, person detail, conversation timeline, Chronik). For a fundamentally visual archive — letters, scans, handwritten pages — this is a real discoverability problem. Issue #307 introduces a small JPEG thumbnail for every document.
|
||||
|
||||
A viable alternative to rendering in Spring Boot is delegating to the existing `ocr-service` (Python), which already has PyMuPDF/PIL available and is the project's designated place for PDF pixel work. The comparison is not obvious: either place works.
|
||||
|
||||
## Decision
|
||||
|
||||
Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (already a dependency for training-data export). A dedicated `thumbnailExecutor` pool isolates the work from the shared task pool used by OCR.
|
||||
|
||||
- PDF first page rendered via `PDFRenderer.renderImageWithDPI(0, 100, ImageType.RGB)`, scaled to 240 px width (bilinear) and encoded as JPEG quality 85.
|
||||
- Non-PDF image types (JPEG, PNG, TIFF) decoded via `javax.imageio` — TIFF requires the `twelvemonkeys-imageio-tiff` plugin on the classpath.
|
||||
- Upload paths fire-and-forget via `ThumbnailAsyncRunner.dispatchAfterCommit(docId)`; a `ThumbnailBackfillService` covers anything the async task missed or that pre-dates this feature.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Delegate to `ocr-service` (PyMuPDF) | Adds a network hop and a failure mode to every document upload. `ocr-service` is not guaranteed healthy at upload time (model-loading start period is 60 s). PDFBox is already a backend dependency — delegating is a net complexity increase. |
|
||||
| Render on the frontend with `pdfjs-dist` at display time | Would work for PDFs but not for scans / images; list pages would need to render dozens of PDFs on first paint; no server-side caching. |
|
||||
| Thumbor / imaginary / a dedicated thumbnail service | Overkill for a single-operator household tool; new container to operate and secure. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- Zero new infrastructure. `thumbnails/` is a prefix in the existing MinIO bucket — production migration to Hetzner Object Storage works identically.
|
||||
- Backfill is a plain sequential loop; no inter-service retry semantics.
|
||||
- Integration test runs against real MinIO without needing `ocr-service` to be healthy.
|
||||
|
||||
**Harder:**
|
||||
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
|
||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
||||
|
||||
### Operational caveats (intentional)
|
||||
|
||||
**Backfill state is in-memory and single-node.** `ThumbnailBackfillService.currentStatus` is a volatile reference updated on the thumbnail executor thread. Restarting the backend mid-run loses progress and the next `runBackfillAsync()` starts over. This mirrors `MassImportService.ImportStatus` and is acceptable because the household archive runs as a single Spring Boot process, backfill is a rare one-shot admin action, and re-running the backfill is idempotent (`findByFilePathIsNotNullAndThumbnailKeyIsNull()` naturally skips completed documents).
|
||||
|
||||
**`ThumbnailService` and `ThumbnailBackfillService` inject `DocumentRepository` directly.** This is a deliberate exception to the project's "services never reach into another domain's repository" rule. Treating thumbnails as a cross-cutting aspect of `Document` rather than a sub-domain avoids a circular dependency (`DocumentService` → `ThumbnailAsyncRunner` → `DocumentService` would close the loop). If thumbnail state grows beyond two columns into its own domain model, extract a proper `ThumbnailRepository` at that point — not before.
|
||||
|
||||
## Future Direction
|
||||
|
||||
- If a second image-processing job (OCR region crops, sharing previews) arrives, revisit moving all image work to `ocr-service` so the two share a single PyMuPDF instance.
|
||||
- If thumbnails ever need to be generated at multiple sizes, switch the key pattern from `thumbnails/{docId}.jpg` to `thumbnails/{docId}/{width}.jpg` — the endpoint and cache-bust URL are already structured to accommodate that.
|
||||
@@ -11,7 +11,8 @@ const AUTHENTICATED_PAGES = [
|
||||
{ name: 'home', path: '/' },
|
||||
{ name: 'persons', path: '/persons' },
|
||||
{ name: 'aktivitaeten', path: '/aktivitaeten' },
|
||||
{ name: 'admin', path: '/admin' }
|
||||
{ name: 'admin', path: '/admin' },
|
||||
{ name: 'admin-system', path: '/admin/system' }
|
||||
];
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
|
||||
@@ -248,3 +248,28 @@ test.describe('Admin system tab — backfill file hashes', () => {
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── System tab — generate thumbnails ─────────────────────────────────────────
|
||||
|
||||
test.describe('Admin system tab — generate thumbnails', () => {
|
||||
test('admin triggers thumbnail generation and sees DONE within 30s', async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Navigate to System tab
|
||||
await page.getByRole('button', { name: /system/i }).click();
|
||||
|
||||
const btn = page
|
||||
.locator('[data-thumbnails-trigger]')
|
||||
.or(page.getByRole('button', { name: /thumbnails erzeugen/i }));
|
||||
await expect(btn.first()).toBeVisible();
|
||||
await btn.first().click();
|
||||
|
||||
// Status transitions RUNNING → DONE; poll message shows the final summary
|
||||
await expect(page.getByTestId('thumbnails-status-done')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-generate-thumbnails.png' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,6 +338,13 @@
|
||||
"admin_system_import_status_running": "Import läuft…",
|
||||
"admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.",
|
||||
"admin_system_import_status_failed": "Fehler: {message}",
|
||||
"admin_system_thumbnails_heading": "Thumbnails erzeugen",
|
||||
"admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).",
|
||||
"admin_system_thumbnails_btn_start": "Thumbnails erzeugen",
|
||||
"admin_system_thumbnails_btn_retry": "Erneut starten",
|
||||
"admin_system_thumbnails_status_running": "Thumbnail-Generierung läuft…",
|
||||
"admin_system_thumbnails_status_done": "Fertig – {processed} erzeugt, {skipped} übersprungen, {failed} fehlgeschlagen.",
|
||||
"admin_system_thumbnails_status_failed": "Fehler: {message}",
|
||||
"comp_expandable_show_more": "Mehr anzeigen",
|
||||
"comp_expandable_show_less": "Weniger anzeigen",
|
||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||
|
||||
@@ -338,6 +338,13 @@
|
||||
"admin_system_import_status_running": "Import running…",
|
||||
"admin_system_import_status_done": "Import complete – {count} documents processed.",
|
||||
"admin_system_import_status_failed": "Error: {message}",
|
||||
"admin_system_thumbnails_heading": "Generate thumbnails",
|
||||
"admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).",
|
||||
"admin_system_thumbnails_btn_start": "Generate thumbnails",
|
||||
"admin_system_thumbnails_btn_retry": "Run again",
|
||||
"admin_system_thumbnails_status_running": "Thumbnail generation running…",
|
||||
"admin_system_thumbnails_status_done": "Done — {processed} generated, {skipped} skipped, {failed} failed.",
|
||||
"admin_system_thumbnails_status_failed": "Error: {message}",
|
||||
"comp_expandable_show_more": "Show more",
|
||||
"comp_expandable_show_less": "Show less",
|
||||
"error_comment_not_found": "The comment could not be found.",
|
||||
|
||||
@@ -338,6 +338,13 @@
|
||||
"admin_system_import_status_running": "Importación en curso…",
|
||||
"admin_system_import_status_done": "Importación completada – {count} documentos procesados.",
|
||||
"admin_system_import_status_failed": "Error: {message}",
|
||||
"admin_system_thumbnails_heading": "Generar miniaturas",
|
||||
"admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).",
|
||||
"admin_system_thumbnails_btn_start": "Generar miniaturas",
|
||||
"admin_system_thumbnails_btn_retry": "Reiniciar",
|
||||
"admin_system_thumbnails_status_running": "Generación de miniaturas en curso…",
|
||||
"admin_system_thumbnails_status_done": "Listo — {processed} generadas, {skipped} omitidas, {failed} fallidas.",
|
||||
"admin_system_thumbnails_status_failed": "Error: {message}",
|
||||
"comp_expandable_show_more": "Mostrar más",
|
||||
"comp_expandable_show_less": "Mostrar menos",
|
||||
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatDate } from '$lib/utils/date';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
@@ -37,7 +38,10 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
|
||||
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||
<a href="/documents/{doc.id}" class="block px-4 py-4 sm:py-5">
|
||||
<div class="flex gap-0 sm:gap-5">
|
||||
<div class="flex gap-3 sm:gap-5">
|
||||
<!-- Thumbnail tile -->
|
||||
<DocumentThumbnail doc={doc} />
|
||||
|
||||
<!-- Left column -->
|
||||
<div class="flex-1 sm:border-r sm:border-line sm:pr-5">
|
||||
<!-- Title -->
|
||||
|
||||
54
frontend/src/lib/components/DocumentThumbnail.svelte
Normal file
54
frontend/src/lib/components/DocumentThumbnail.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { thumbnailUrl } from '$lib/thumbnails';
|
||||
|
||||
type Doc = Pick<
|
||||
components['schemas']['Document'],
|
||||
'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'
|
||||
>;
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Fixed 60x84 tile used wherever a document row appears. When the backend has
|
||||
generated a thumbnail we render it with `object-cover` + `object-top` so
|
||||
letter salutations stay visible; otherwise we fall back to the file-type
|
||||
icon so the row never shows an empty rectangle. Dark mode uses
|
||||
`mix-blend-multiply` to keep bright paper scans from glaring against the
|
||||
dark page background.
|
||||
-->
|
||||
<div
|
||||
class="relative h-[84px] w-[60px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||
>
|
||||
{#if url}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center text-ink-3" aria-hidden="true">
|
||||
<!-- Generic document icon (heroicons document-text outline). Shown when the
|
||||
thumbnail hasn't been generated yet — applies equally to PDFs and to
|
||||
image scans, so we deliberately avoid a PDF-specific glyph here. -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-8 w-8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1383,6 +1383,9 @@ export interface components {
|
||||
filePath?: string;
|
||||
contentType?: string;
|
||||
fileHash?: string;
|
||||
thumbnailKey?: string;
|
||||
/** Format: date-time */
|
||||
thumbnailGeneratedAt?: string;
|
||||
originalFilename: string;
|
||||
/** @enum {string} */
|
||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
|
||||
37
frontend/src/lib/thumbnails.test.ts
Normal file
37
frontend/src/lib/thumbnails.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { thumbnailUrl } from './thumbnails';
|
||||
|
||||
describe('thumbnailUrl', () => {
|
||||
it('returns null when thumbnailKey is undefined', () => {
|
||||
expect(thumbnailUrl({ id: 'abc' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns url without version param when thumbnailKey present but generatedAt missing', () => {
|
||||
expect(thumbnailUrl({ id: 'abc', thumbnailKey: 'thumbnails/abc.jpg' })).toBe(
|
||||
'/api/documents/abc/thumbnail'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends encoded cache-bust param when generatedAt present', () => {
|
||||
const url = thumbnailUrl({
|
||||
id: 'abc',
|
||||
thumbnailKey: 'thumbnails/abc.jpg',
|
||||
thumbnailGeneratedAt: '2026-04-22T20:41:15.123456'
|
||||
});
|
||||
expect(url).toBe('/api/documents/abc/thumbnail?v=2026-04-22T20%3A41%3A15.123456');
|
||||
});
|
||||
|
||||
it('different generatedAt produces different URL — enables cache-bust on file replace', () => {
|
||||
const a = thumbnailUrl({
|
||||
id: 'x',
|
||||
thumbnailKey: 'thumbnails/x.jpg',
|
||||
thumbnailGeneratedAt: '2026-01-01T10:00:00'
|
||||
});
|
||||
const b = thumbnailUrl({
|
||||
id: 'x',
|
||||
thumbnailKey: 'thumbnails/x.jpg',
|
||||
thumbnailGeneratedAt: '2026-01-01T11:00:00'
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
18
frontend/src/lib/thumbnails.ts
Normal file
18
frontend/src/lib/thumbnails.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
type ThumbnailDoc = {
|
||||
id: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the URL for a document thumbnail image, or returns null when the document
|
||||
* has no thumbnail yet. When `thumbnailGeneratedAt` is present it is appended as a
|
||||
* `?v=…` query param so the browser / proxy cache is invalidated whenever the file
|
||||
* is replaced (the backend regenerates thumbnails at the same S3 key on replace).
|
||||
*/
|
||||
export function thumbnailUrl(doc: ThumbnailDoc): string | null {
|
||||
if (!doc.thumbnailKey) return null;
|
||||
const base = `/api/documents/${doc.id}/thumbnail`;
|
||||
if (!doc.thumbnailGeneratedAt) return base;
|
||||
return `${base}?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`;
|
||||
}
|
||||
@@ -14,8 +14,20 @@ type ImportStatus = {
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
type ThumbnailStatus = {
|
||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||
message: string;
|
||||
total: number;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
let importStatus: ImportStatus | null = $state(null);
|
||||
let thumbnailStatus: ThumbnailStatus | null = $state(null);
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let thumbnailPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval) return;
|
||||
@@ -29,6 +41,18 @@ function stopPolling() {
|
||||
}
|
||||
}
|
||||
|
||||
function startThumbnailPolling() {
|
||||
if (thumbnailPollInterval) return;
|
||||
thumbnailPollInterval = setInterval(fetchThumbnailStatus, 2000);
|
||||
}
|
||||
|
||||
function stopThumbnailPolling() {
|
||||
if (thumbnailPollInterval) {
|
||||
clearInterval(thumbnailPollInterval);
|
||||
thumbnailPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchImportStatus() {
|
||||
const res = await fetch('/api/admin/import-status');
|
||||
if (res.ok) {
|
||||
@@ -51,11 +75,37 @@ async function triggerImport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchThumbnailStatus() {
|
||||
const res = await fetch('/api/admin/thumbnail-status');
|
||||
if (res.ok) {
|
||||
thumbnailStatus = await res.json();
|
||||
if (thumbnailStatus!.state === 'RUNNING') {
|
||||
startThumbnailPolling();
|
||||
} else {
|
||||
stopThumbnailPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerThumbnails() {
|
||||
const res = await fetch('/api/admin/generate-thumbnails', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
thumbnailStatus = await res.json();
|
||||
if (thumbnailStatus!.state === 'RUNNING') {
|
||||
startThumbnailPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchImportStatus();
|
||||
fetchThumbnailStatus();
|
||||
});
|
||||
|
||||
onDestroy(() => stopPolling());
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
stopThumbnailPolling();
|
||||
});
|
||||
|
||||
async function backfillVersions() {
|
||||
backfillLoading = true;
|
||||
@@ -168,5 +218,62 @@ async function backfillFileHashes() {
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail backfill -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-sm font-bold text-ink">
|
||||
{m.admin_system_thumbnails_heading()}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_thumbnails_description()}</p>
|
||||
|
||||
{#if thumbnailStatus?.state === 'RUNNING'}
|
||||
<p class="text-sm text-ink-2">
|
||||
{m.admin_system_thumbnails_status_running()}
|
||||
{#if thumbnailStatus.total > 0}
|
||||
<span class="text-ink-3">
|
||||
({thumbnailStatus.processed + thumbnailStatus.skipped + thumbnailStatus.failed} /
|
||||
{thumbnailStatus.total})
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{:else if thumbnailStatus?.state === 'DONE'}
|
||||
<p
|
||||
data-testid="thumbnails-status-done"
|
||||
class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700"
|
||||
>
|
||||
{m.admin_system_thumbnails_status_done({
|
||||
processed: thumbnailStatus.processed,
|
||||
skipped: thumbnailStatus.skipped,
|
||||
failed: thumbnailStatus.failed
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
data-thumbnails-trigger
|
||||
onclick={triggerThumbnails}
|
||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.admin_system_thumbnails_btn_retry()}
|
||||
</button>
|
||||
{:else if thumbnailStatus?.state === 'FAILED'}
|
||||
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{m.admin_system_thumbnails_status_failed({ message: thumbnailStatus.message })}
|
||||
</p>
|
||||
<button
|
||||
data-thumbnails-trigger
|
||||
onclick={triggerThumbnails}
|
||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.admin_system_thumbnails_btn_retry()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
data-thumbnails-trigger
|
||||
onclick={triggerThumbnails}
|
||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.admin_system_thumbnails_btn_start()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,14 +72,12 @@ const coCorrespondents = $derived.by(() => {
|
||||
documents={sentDocuments}
|
||||
heading={m.person_docs_heading()}
|
||||
emptyMessage={m.person_no_docs()}
|
||||
variant="sent"
|
||||
/>
|
||||
|
||||
<PersonDocumentList
|
||||
documents={receivedDocuments}
|
||||
heading={m.person_received_docs_heading()}
|
||||
emptyMessage={m.person_no_received_docs()}
|
||||
variant="received"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
|
||||
import DocumentThumbnail from '$lib/components/DocumentThumbnail.svelte';
|
||||
|
||||
const DOCS_PREVIEW_LIMIT = 5;
|
||||
|
||||
let {
|
||||
documents,
|
||||
heading,
|
||||
emptyMessage,
|
||||
variant = 'sent'
|
||||
emptyMessage
|
||||
}: {
|
||||
documents: {
|
||||
id: string;
|
||||
@@ -19,10 +19,12 @@ let {
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
status: string;
|
||||
contentType?: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
}[];
|
||||
heading: string;
|
||||
emptyMessage: string;
|
||||
variant?: 'sent' | 'received';
|
||||
} = $props();
|
||||
|
||||
const yearRange = $derived.by(() => {
|
||||
@@ -42,11 +44,6 @@ const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
|
||||
const visibleDocuments = $derived(
|
||||
showAll ? sortedDocuments : sortedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
|
||||
);
|
||||
|
||||
// Spec: sent = navy-tint icon bg, received = teal-tint icon bg
|
||||
const iconClasses = $derived(
|
||||
variant === 'sent' ? 'bg-primary/10 text-primary' : 'bg-accent/20 text-accent-fg'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mb-4 rounded-sm border border-line bg-surface">
|
||||
@@ -81,17 +78,8 @@ const iconClasses = $derived(
|
||||
href="/documents/{doc.id}"
|
||||
class="group flex items-center gap-3 border-b border-line px-4 py-3 transition-colors last:border-b-0 hover:bg-muted"
|
||||
>
|
||||
<!-- Tinted doc icon -->
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded {iconClasses}"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<!-- Thumbnail tile -->
|
||||
<DocumentThumbnail doc={doc} />
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
Reference in New Issue
Block a user