mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-13 20:19:03 +00:00
Stream CBZ contents directly from the archive instead of unzipping and caching files (#2229)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -40,6 +40,7 @@ out/
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
local/
|
||||
local-scripts/
|
||||
|
||||
### Dev config, books, and data ###
|
||||
booklore-ui/test-results/
|
||||
|
||||
@ -16,7 +16,9 @@ public class ImageCachingFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri.startsWith("/api/v1/media/book/") && (uri.contains("/cover") || uri.contains("/thumbnail") || uri.contains("/backup-cover"))) {
|
||||
if (uri.startsWith("/api/v1/media/book/") &&
|
||||
(uri.contains("/cover") || uri.contains("/thumbnail") || uri.contains("/backup-cover") ||
|
||||
uri.contains("/pdf/pages/") || uri.contains("/cbx/pages/"))) {
|
||||
response.setHeader(HttpHeaders.CACHE_CONTROL, "public, max-age=3600");
|
||||
response.setHeader(HttpHeaders.EXPIRES, String.valueOf(System.currentTimeMillis() + 3600_000));
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import com.adityachandel.booklore.service.book.BookService;
|
||||
import com.adityachandel.booklore.service.bookdrop.BookDropService;
|
||||
import com.adityachandel.booklore.service.reader.CbxReaderService;
|
||||
import com.adityachandel.booklore.service.reader.PdfReaderService;
|
||||
import com.adityachandel.booklore.service.IconService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
@ -21,9 +20,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Tag(name = "Book Media", description = "Endpoints for retrieving book media such as covers, thumbnails, and pages")
|
||||
@AllArgsConstructor
|
||||
@ -31,27 +27,22 @@ import java.util.regex.Pattern;
|
||||
@RequestMapping("/api/v1/media")
|
||||
public class BookMediaController {
|
||||
|
||||
private static final Pattern NON_ASCII_PATTERN = Pattern.compile("[^\\x00-\\x7F]");
|
||||
|
||||
private final BookService bookService;
|
||||
private final PdfReaderService pdfReaderService;
|
||||
private final CbxReaderService cbxReaderService;
|
||||
private final BookDropService bookDropService;
|
||||
private final IconService iconService;
|
||||
|
||||
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a specific book.")
|
||||
@ApiResponse(responseCode = "200", description = "Book thumbnail returned successfully")
|
||||
@GetMapping("/book/{bookId}/thumbnail")
|
||||
public ResponseEntity<Resource> getBookThumbnail(
|
||||
@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
public ResponseEntity<Resource> getBookThumbnail(@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
return ResponseEntity.ok(bookService.getBookThumbnail(bookId));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get book cover", description = "Retrieve the cover image for a specific book.")
|
||||
@ApiResponse(responseCode = "200", description = "Book cover returned successfully")
|
||||
@GetMapping("/book/{bookId}/cover")
|
||||
public ResponseEntity<Resource> getBookCover(
|
||||
@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
public ResponseEntity<Resource> getBookCover(@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
return ResponseEntity.ok(bookService.getBookCover(bookId));
|
||||
}
|
||||
|
||||
@ -80,8 +71,7 @@ public class BookMediaController {
|
||||
@Operation(summary = "Get bookdrop cover", description = "Retrieve the cover image for a specific bookdrop file.")
|
||||
@ApiResponse(responseCode = "200", description = "Bookdrop cover returned successfully")
|
||||
@GetMapping("/bookdrop/{bookdropId}/cover")
|
||||
public ResponseEntity<Resource> getBookdropCover(
|
||||
@Parameter(description = "ID of the bookdrop file") @PathVariable long bookdropId) {
|
||||
public ResponseEntity<Resource> getBookdropCover(@Parameter(description = "ID of the bookdrop file") @PathVariable long bookdropId) {
|
||||
Resource file = bookDropService.getBookdropCover(bookdropId);
|
||||
String contentDisposition = "inline; filename=\"cover.jpg\"; filename*=UTF-8''cover.jpg";
|
||||
return (file != null)
|
||||
|
||||
@ -24,8 +24,7 @@ public class CbxReaderController {
|
||||
@Operation(summary = "List pages in a CBX book", description = "Retrieve a list of available page numbers for a CBX book.")
|
||||
@ApiResponse(responseCode = "200", description = "Page numbers returned successfully")
|
||||
@GetMapping("/{bookId}/pages")
|
||||
public List<Integer> listPages(
|
||||
@Parameter(description = "ID of the book") @PathVariable Long bookId) {
|
||||
public List<Integer> listPages(@Parameter(description = "ID of the book") @PathVariable Long bookId) {
|
||||
return cbxReaderService.getAvailablePages(bookId);
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,6 @@ public enum AppSettingKey {
|
||||
COVER_CROPPING_SETTINGS ("cover_cropping_settings", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
AUTO_BOOK_SEARCH ("auto_book_search", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
SIMILAR_BOOK_RECOMMENDATION ("similar_book_recommendation", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
CBX_CACHE_SIZE_IN_MB ("cbx_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
PDF_CACHE_SIZE_IN_MB ("pdf_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
MAX_FILE_UPLOAD_SIZE_IN_MB ("max_file_upload_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
TELEMETRY_ENABLED ("telemetryEnabled", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
|
||||
@ -19,7 +19,6 @@ public class AppSettings {
|
||||
private boolean similarBookRecommendation;
|
||||
private boolean opdsServerEnabled;
|
||||
private String uploadPattern;
|
||||
private Integer cbxCacheSizeInMb;
|
||||
private Integer pdfCacheSizeInMb;
|
||||
private Integer maxFileUploadSizeInMb;
|
||||
private boolean remoteAuthEnabled;
|
||||
|
||||
@ -3,15 +3,6 @@ package com.adityachandel.booklore.model.enums;
|
||||
import lombok.Getter;
|
||||
|
||||
public enum TaskType {
|
||||
|
||||
CLEAR_CBX_CACHE(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"Clear CBX Cache",
|
||||
"Clears temporarily extracted comic book files used by the reader."
|
||||
),
|
||||
CLEAR_PDF_CACHE(
|
||||
false,
|
||||
false,
|
||||
|
||||
@ -134,7 +134,6 @@ public class AppSettingService {
|
||||
builder.similarBookRecommendation(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.SIMILAR_BOOK_RECOMMENDATION, "true")));
|
||||
builder.opdsServerEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OPDS_SERVER_ENABLED, "false")));
|
||||
builder.telemetryEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.TELEMETRY_ENABLED, "true")));
|
||||
builder.cbxCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.CBX_CACHE_SIZE_IN_MB, "5120")));
|
||||
builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120")));
|
||||
builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100")));
|
||||
builder.metadataDownloadOnBookdrop(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.METADATA_DOWNLOAD_ON_BOOKDROP, "true")));
|
||||
|
||||
@ -3,12 +3,8 @@ package com.adityachandel.booklore.service.reader;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.util.ArchiveUtils;
|
||||
import com.adityachandel.booklore.util.FileService;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.exception.RarException;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -23,222 +19,326 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CbxReaderService {
|
||||
|
||||
private static final String CACHE_INFO_FILENAME = ".cache-info";
|
||||
private static final String CBZ_EXTENSION = ".cbz";
|
||||
private static final String CBR_EXTENSION = ".cbr";
|
||||
private static final String CB7_EXTENSION = ".cb7";
|
||||
private static final String[] SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".heic"};
|
||||
private static final Charset[] ENCODINGS_TO_TRY = {
|
||||
StandardCharsets.UTF_8,
|
||||
Charset.forName("Shift_JIS"),
|
||||
StandardCharsets.ISO_8859_1,
|
||||
Charset.forName("CP437"),
|
||||
Charset.forName("MS932")
|
||||
};
|
||||
private static final int MAX_CACHE_ENTRIES = 50;
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
private static final Pattern NUMERIC_PATTERN = Pattern.compile("(\\d+)|(\\D+)");
|
||||
private static final Set<String> SYSTEM_FILES = Set.of(".ds_store", "thumbs.db", "desktop.ini");
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final AppSettingService appSettingService;
|
||||
private final FileService fileService;
|
||||
private final Map<String, CachedArchiveMetadata> archiveCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class CachedArchiveMetadata {
|
||||
final List<String> imageEntries;
|
||||
final long lastModified;
|
||||
final Charset successfulEncoding;
|
||||
volatile long lastAccessed;
|
||||
|
||||
CachedArchiveMetadata(List<String> imageEntries, long lastModified, Charset successfulEncoding) {
|
||||
this.imageEntries = List.copyOf(imageEntries);
|
||||
this.lastModified = lastModified;
|
||||
this.successfulEncoding = successfulEncoding;
|
||||
this.lastAccessed = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Integer> getAvailablePages(Long bookId) {
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
String bookFullPath = FileUtils.getBookFullPath(bookEntity);
|
||||
|
||||
Path cbzPath = Path.of(bookFullPath);
|
||||
Path cacheDir = Path.of(fileService.getCbxCachePath(), String.valueOf(bookId));
|
||||
Path cacheInfoPath = cacheDir.resolve(CACHE_INFO_FILENAME);
|
||||
|
||||
Path cbxPath = getBookPath(bookId);
|
||||
try {
|
||||
long maxCacheSizeBytes = mbToBytes(appSettingService.getAppSettings().getCbxCacheSizeInMb());
|
||||
long estimatedSize = estimateArchiveSize(cbzPath);
|
||||
|
||||
if (estimatedSize == -1) {
|
||||
long fileSize = Files.size(cbzPath);
|
||||
if (fileSize > maxCacheSizeBytes) {
|
||||
log.warn("Cache skipped: Physical file size {} exceeds max cache size {} (Estimation failed)", fileSize, maxCacheSizeBytes);
|
||||
throw ApiError.CACHE_TOO_LARGE.createException();
|
||||
}
|
||||
} else if (estimatedSize > maxCacheSizeBytes) {
|
||||
log.warn("Cache skipped: Estimated archive size {} exceeds max cache size {}", estimatedSize, maxCacheSizeBytes);
|
||||
throw ApiError.CACHE_TOO_LARGE.createException();
|
||||
}
|
||||
enforceCacheLimit();
|
||||
|
||||
if (needsCacheRefresh(cbzPath, cacheInfoPath)) {
|
||||
log.info("Invalidating cache for book {}", bookId);
|
||||
if (Files.exists(cacheDir)) FileUtils.deleteDirectoryRecursively(cacheDir);
|
||||
Files.createDirectories(cacheDir);
|
||||
extractCbxArchive(cbzPath, cacheDir);
|
||||
writeCacheInfo(cbzPath, cacheInfoPath);
|
||||
if (!Files.exists(cacheDir)) {
|
||||
log.warn("Cache for book {} was deleted during enforcement. Re-extracting.", bookId);
|
||||
Files.createDirectories(cacheDir);
|
||||
extractCbxArchive(cbzPath, cacheDir);
|
||||
writeCacheInfo(cbzPath, cacheInfoPath);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to cache CBZ for book {}", bookId, e);
|
||||
throw ApiError.FILE_READ_ERROR.createException("Failed to read archive: " + e.getMessage());
|
||||
}
|
||||
|
||||
try (var stream = Files.list(cacheDir)) {
|
||||
List<Path> imageFiles = stream
|
||||
.filter(p -> isImageFile(p.getFileName().toString()))
|
||||
.sorted(Comparator.comparing(Path::getFileName))
|
||||
.toList();
|
||||
|
||||
return java.util.stream.IntStream.rangeClosed(1, imageFiles.size())
|
||||
List<String> imageEntries = getImageEntriesFromArchiveCached(cbxPath);
|
||||
return IntStream.rangeClosed(1, imageEntries.size())
|
||||
.boxed()
|
||||
.collect(Collectors.toList());
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to list pages for book {}", bookId, e);
|
||||
return List.of();
|
||||
log.error("Failed to read archive for book {}", bookId, e);
|
||||
throw ApiError.FILE_READ_ERROR.createException("Failed to read archive: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void streamPageImage(Long bookId, int page, OutputStream outputStream) throws IOException {
|
||||
Path bookDir = Path.of(fileService.getCbxCachePath(), String.valueOf(bookId));
|
||||
List<Path> images;
|
||||
try (Stream<Path> files = Files.list(bookDir)) {
|
||||
images = files
|
||||
.filter(p -> isImageFile(p.getFileName().toString()))
|
||||
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
|
||||
.toList();
|
||||
}
|
||||
if (images.isEmpty()) {
|
||||
Path cbxPath = getBookPath(bookId);
|
||||
CachedArchiveMetadata metadata = getCachedMetadata(cbxPath);
|
||||
validatePageRequest(bookId, page, metadata.imageEntries);
|
||||
String entryName = metadata.imageEntries.get(page - 1);
|
||||
streamEntryFromArchive(cbxPath, entryName, outputStream, metadata.successfulEncoding);
|
||||
}
|
||||
|
||||
private Path getBookPath(Long bookId) {
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
String bookFullPath = FileUtils.getBookFullPath(bookEntity);
|
||||
return Path.of(bookFullPath);
|
||||
}
|
||||
|
||||
private void validatePageRequest(Long bookId, int page, List<String> imageEntries) throws FileNotFoundException {
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw new FileNotFoundException("No image files found for book: " + bookId);
|
||||
}
|
||||
if (page < 1 || page > images.size()) {
|
||||
throw new FileNotFoundException("Page out of range: " + page);
|
||||
}
|
||||
Path pagePath = images.get(page - 1);
|
||||
try (InputStream in = Files.newInputStream(pagePath)) {
|
||||
IOUtils.copy(in, outputStream);
|
||||
if (page < 1 || page > imageEntries.size()) {
|
||||
throw new FileNotFoundException("Page " + page + " out of range [1-" + imageEntries.size() + "]");
|
||||
}
|
||||
}
|
||||
|
||||
private void extractCbxArchive(Path cbxPath, Path targetDir) throws IOException {
|
||||
ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(cbxPath.toFile());
|
||||
switch (type) {
|
||||
case ZIP -> extractZipArchive(cbxPath, targetDir);
|
||||
case SEVEN_ZIP -> extract7zArchive(cbxPath, targetDir);
|
||||
case RAR -> extractRarArchive(cbxPath, targetDir);
|
||||
default -> throw new IOException("Unsupported archive format: " + cbxPath.getFileName());
|
||||
private CachedArchiveMetadata getCachedMetadata(Path cbxPath) throws IOException {
|
||||
String cacheKey = cbxPath.toString();
|
||||
long currentModified = Files.getLastModifiedTime(cbxPath).toMillis();
|
||||
CachedArchiveMetadata cached = archiveCache.get(cacheKey);
|
||||
if (cached != null && cached.lastModified == currentModified) {
|
||||
cached.lastAccessed = System.currentTimeMillis();
|
||||
log.debug("Cache hit for archive: {}", cbxPath.getFileName());
|
||||
return cached;
|
||||
}
|
||||
log.debug("Cache miss for archive: {}, scanning...", cbxPath.getFileName());
|
||||
CachedArchiveMetadata newMetadata = scanArchiveMetadata(cbxPath);
|
||||
archiveCache.put(cacheKey, newMetadata);
|
||||
evictOldestCacheEntries();
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
private List<String> getImageEntriesFromArchiveCached(Path cbxPath) throws IOException {
|
||||
return getCachedMetadata(cbxPath).imageEntries;
|
||||
}
|
||||
|
||||
private void evictOldestCacheEntries() {
|
||||
if (archiveCache.size() <= MAX_CACHE_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
List<String> keysToRemove = archiveCache.entrySet().stream()
|
||||
.sorted(Comparator.comparingLong(e -> e.getValue().lastAccessed))
|
||||
.limit(archiveCache.size() - MAX_CACHE_ENTRIES)
|
||||
.map(Map.Entry::getKey)
|
||||
.toList();
|
||||
keysToRemove.forEach(key -> {
|
||||
archiveCache.remove(key);
|
||||
log.debug("Evicted cache entry: {}", key);
|
||||
});
|
||||
}
|
||||
|
||||
private CachedArchiveMetadata scanArchiveMetadata(Path cbxPath) throws IOException {
|
||||
String filename = cbxPath.getFileName().toString().toLowerCase();
|
||||
long lastModified = Files.getLastModifiedTime(cbxPath).toMillis();
|
||||
if (filename.endsWith(CBZ_EXTENSION)) {
|
||||
return scanZipMetadata(cbxPath, lastModified);
|
||||
} else if (filename.endsWith(CB7_EXTENSION)) {
|
||||
List<String> entries = getImageEntriesFrom7z(cbxPath);
|
||||
return new CachedArchiveMetadata(entries, lastModified, null);
|
||||
} else if (filename.endsWith(CBR_EXTENSION)) {
|
||||
List<String> entries = getImageEntriesFromRar(cbxPath);
|
||||
return new CachedArchiveMetadata(entries, lastModified, null);
|
||||
} else {
|
||||
throw new IOException("Unsupported archive format: " + cbxPath.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
private void extractZipArchive(Path cbzPath, Path targetDir) throws IOException {
|
||||
String[] encodingsToTry = {"UTF-8", "Shift_JIS", "ISO-8859-1", "CP437", "MS932"};
|
||||
private void streamEntryFromArchive(Path cbxPath, String entryName, OutputStream outputStream, Charset cachedEncoding) throws IOException {
|
||||
String filename = cbxPath.getFileName().toString().toLowerCase();
|
||||
if (filename.endsWith(CBZ_EXTENSION)) {
|
||||
streamEntryFromZip(cbxPath, entryName, outputStream, cachedEncoding);
|
||||
} else if (filename.endsWith(CB7_EXTENSION)) {
|
||||
streamEntryFrom7z(cbxPath, entryName, outputStream);
|
||||
} else if (filename.endsWith(CBR_EXTENSION)) {
|
||||
streamEntryFromRar(cbxPath, entryName, outputStream);
|
||||
} else {
|
||||
throw new IOException("Unsupported archive format: " + cbxPath.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
for (String encoding : encodingsToTry) {
|
||||
Charset charset = Charset.forName(encoding);
|
||||
private CachedArchiveMetadata scanZipMetadata(Path cbxPath, long lastModified) throws IOException {
|
||||
String cacheKey = cbxPath.toString();
|
||||
CachedArchiveMetadata oldCache = archiveCache.get(cacheKey);
|
||||
if (oldCache != null && oldCache.successfulEncoding != null) {
|
||||
try {
|
||||
// Fast path: Try reading from Central Directory only
|
||||
if (extractZipWithEncoding(cbzPath, targetDir, charset, true)) return;
|
||||
List<String> entries = getImageEntriesFromZipWithEncoding(cbxPath, oldCache.successfulEncoding, true);
|
||||
return new CachedArchiveMetadata(entries, lastModified, oldCache.successfulEncoding);
|
||||
} catch (Exception e) {
|
||||
log.debug("Fast path failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
// Slow path: Fallback to scanning local file headers
|
||||
if (extractZipWithEncoding(cbzPath, targetDir, charset, false)) return;
|
||||
} catch (Exception e) {
|
||||
log.debug("Slow path failed for encoding {}: {}", encoding, e.getMessage());
|
||||
log.debug("Cached encoding {} failed, trying others", oldCache.successfulEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("Unable to extract ZIP archive with any supported encoding");
|
||||
for (Charset encoding : ENCODINGS_TO_TRY) {
|
||||
try {
|
||||
List<String> entries = getImageEntriesFromZipWithEncoding(cbxPath, encoding, true);
|
||||
return new CachedArchiveMetadata(entries, lastModified, encoding);
|
||||
} catch (Exception e) {
|
||||
log.debug("ZIP fast path failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
try {
|
||||
List<String> entries = getImageEntriesFromZipWithEncoding(cbxPath, encoding, false);
|
||||
return new CachedArchiveMetadata(entries, lastModified, encoding);
|
||||
} catch (Exception e) {
|
||||
log.debug("ZIP slow path failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
}
|
||||
throw new IOException("Unable to read ZIP archive with any supported encoding");
|
||||
}
|
||||
|
||||
private boolean extractZipWithEncoding(Path cbzPath, Path targetDir, Charset charset, boolean useFastPath) throws IOException {
|
||||
private List<String> getImageEntriesFromZipWithEncoding(Path cbxPath, Charset charset, boolean useFastPath) throws IOException {
|
||||
try (org.apache.commons.compress.archivers.zip.ZipFile zipFile =
|
||||
org.apache.commons.compress.archivers.zip.ZipFile.builder()
|
||||
.setPath(cbzPath)
|
||||
.setPath(cbxPath)
|
||||
.setCharset(charset)
|
||||
.setUseUnicodeExtraFields(true)
|
||||
.setIgnoreLocalFileHeader(useFastPath)
|
||||
.get()) {
|
||||
|
||||
var entries = zipFile.getEntries();
|
||||
boolean foundImages = false;
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipArchiveEntry entry = entries.nextElement();
|
||||
List<String> entries = new ArrayList<>();
|
||||
Enumeration<ZipArchiveEntry> enumeration = zipFile.getEntries();
|
||||
while (enumeration.hasMoreElements()) {
|
||||
ZipArchiveEntry entry = enumeration.nextElement();
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
String fileName = extractFileNameFromPath(entry.getName());
|
||||
Path target = targetDir.resolve(fileName);
|
||||
try (InputStream in = zipFile.getInputStream(entry)) {
|
||||
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
foundImages = true;
|
||||
}
|
||||
entries.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return foundImages;
|
||||
sortNaturally(entries);
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
private void extract7zArchive(Path cb7Path, Path targetDir) throws IOException {
|
||||
try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cb7Path).get()) {
|
||||
private void streamEntryFromZip(Path cbxPath, String entryName, OutputStream outputStream, Charset cachedEncoding) throws IOException {
|
||||
if (cachedEncoding != null) {
|
||||
if (streamEntryFromZipWithEncoding(cbxPath, entryName, outputStream, cachedEncoding, true)) {
|
||||
return;
|
||||
}
|
||||
if (streamEntryFromZipWithEncoding(cbxPath, entryName, outputStream, cachedEncoding, false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (Charset encoding : ENCODINGS_TO_TRY) {
|
||||
if (encoding.equals(cachedEncoding)) continue;
|
||||
try {
|
||||
if (streamEntryFromZipWithEncoding(cbxPath, entryName, outputStream, encoding, true)) {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("ZIP stream fast path failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
try {
|
||||
if (streamEntryFromZipWithEncoding(cbxPath, entryName, outputStream, encoding, false)) {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("ZIP stream slow path failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
}
|
||||
throw new IOException("Unable to find entry in ZIP archive: " + entryName);
|
||||
}
|
||||
|
||||
private boolean streamEntryFromZipWithEncoding(Path cbxPath, String entryName, OutputStream outputStream, Charset charset, boolean useFastPath) throws IOException {
|
||||
try (org.apache.commons.compress.archivers.zip.ZipFile zipFile =
|
||||
org.apache.commons.compress.archivers.zip.ZipFile.builder()
|
||||
.setPath(cbxPath)
|
||||
.setCharset(charset)
|
||||
.setUseUnicodeExtraFields(true)
|
||||
.setIgnoreLocalFileHeader(useFastPath)
|
||||
.get()) {
|
||||
ZipArchiveEntry entry = zipFile.getEntry(entryName);
|
||||
if (entry != null) {
|
||||
try (InputStream in = zipFile.getInputStream(entry)) {
|
||||
IOUtils.copy(in, outputStream);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<String> getImageEntriesFrom7z(Path cbxPath) throws IOException {
|
||||
List<String> entries = new ArrayList<>();
|
||||
try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cbxPath).get()) {
|
||||
SevenZArchiveEntry entry;
|
||||
while ((entry = sevenZFile.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
String fileName = extractFileNameFromPath(entry.getName());
|
||||
Path target = targetDir.resolve(fileName);
|
||||
try (OutputStream out = Files.newOutputStream(target)) {
|
||||
copySevenZEntry(sevenZFile, out, entry.getSize());
|
||||
}
|
||||
entries.add(entry.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
sortNaturally(entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
private void streamEntryFrom7z(Path cbxPath, String entryName, OutputStream outputStream) throws IOException {
|
||||
try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cbxPath).get()) {
|
||||
SevenZArchiveEntry entry;
|
||||
while ((entry = sevenZFile.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(entryName)) {
|
||||
copySevenZEntry(sevenZFile, outputStream, entry.getSize());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new FileNotFoundException("Entry not found in 7z archive: " + entryName);
|
||||
}
|
||||
|
||||
private void copySevenZEntry(SevenZFile sevenZFile, OutputStream out, long size) throws IOException {
|
||||
byte[] buffer = new byte[8192];
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
long remaining = size;
|
||||
while (remaining > 0) {
|
||||
int toRead = (int) Math.min(buffer.length, remaining);
|
||||
int read = sevenZFile.read(buffer, 0, toRead);
|
||||
if (read == -1) break;
|
||||
if (read == -1) {
|
||||
break;
|
||||
}
|
||||
out.write(buffer, 0, read);
|
||||
remaining -= read;
|
||||
}
|
||||
}
|
||||
|
||||
private void extractRarArchive(Path cbrPath, Path targetDir) throws IOException {
|
||||
try (Archive archive = new Archive(cbrPath.toFile())) {
|
||||
List<FileHeader> headers = archive.getFileHeaders();
|
||||
for (FileHeader header : headers) {
|
||||
private List<String> getImageEntriesFromRar(Path cbxPath) throws IOException {
|
||||
List<String> entries = new ArrayList<>();
|
||||
try (Archive archive = new Archive(cbxPath.toFile())) {
|
||||
for (FileHeader header : archive.getFileHeaders()) {
|
||||
if (!header.isDirectory() && isImageFile(header.getFileName())) {
|
||||
String fileName = extractFileNameFromPath(header.getFileName());
|
||||
Path target = targetDir.resolve(fileName);
|
||||
try (OutputStream out = Files.newOutputStream(target)) {
|
||||
archive.extractFile(header, out);
|
||||
}
|
||||
entries.add(header.getFileName());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Failed to extract CBR archive", e);
|
||||
throw new IOException("Failed to read RAR archive: " + e.getMessage(), e);
|
||||
}
|
||||
sortNaturally(entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
private String extractFileNameFromPath(String fullPath) {
|
||||
String normalizedPath = fullPath.replace("\\", "/");
|
||||
int lastSlash = normalizedPath.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? normalizedPath.substring(lastSlash + 1) : normalizedPath;
|
||||
private void streamEntryFromRar(Path cbxPath, String entryName, OutputStream outputStream) throws IOException {
|
||||
try (Archive archive = new Archive(cbxPath.toFile())) {
|
||||
for (FileHeader header : archive.getFileHeaders()) {
|
||||
if (header.getFileName().equals(entryName)) {
|
||||
archive.extractFile(header, outputStream);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Failed to extract from RAR archive: " + e.getMessage(), e);
|
||||
}
|
||||
throw new FileNotFoundException("Entry not found in RAR archive: " + entryName);
|
||||
}
|
||||
|
||||
private boolean isImageFile(String name) {
|
||||
if (!isContentEntry(name)) {
|
||||
return false;
|
||||
}
|
||||
String lower = name.toLowerCase().replace("\\", "/");
|
||||
String lower = name.toLowerCase().replace('\\', '/');
|
||||
for (String extension : SUPPORTED_IMAGE_EXTENSIONS) {
|
||||
if (lower.endsWith(extension)) {
|
||||
return true;
|
||||
@ -248,197 +348,50 @@ public class CbxReaderService {
|
||||
}
|
||||
|
||||
private boolean isContentEntry(String name) {
|
||||
if (name == null) return false;
|
||||
String norm = name.replace('\\', '/');
|
||||
if (norm.startsWith("__MACOSX/") || norm.contains("/__MACOSX/")) return false;
|
||||
String[] parts = norm.split("/");
|
||||
for (String part : parts) {
|
||||
if ("__MACOSX".equalsIgnoreCase(part)) return false;
|
||||
if (name == null || name.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String normalized = name.replace('\\', '/');
|
||||
if (normalized.startsWith("__MACOSX/") || normalized.contains("/__MACOSX/")) {
|
||||
return false;
|
||||
}
|
||||
String baseName = baseName(normalized).toLowerCase();
|
||||
if (baseName.startsWith("._") || baseName.startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
if (SYSTEM_FILES.contains(baseName)) {
|
||||
return false;
|
||||
}
|
||||
String base = baseName(norm);
|
||||
if (base.startsWith("._")) return false;
|
||||
if (base.startsWith(".")) return false;
|
||||
if (".ds_store".equalsIgnoreCase(base)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private String baseName(String path) {
|
||||
if (path == null) return null;
|
||||
if (path == null || path.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||||
return slash >= 0 ? path.substring(slash + 1) : path;
|
||||
}
|
||||
|
||||
|
||||
private boolean needsCacheRefresh(Path cbzPath, Path cacheInfoPath) throws IOException {
|
||||
if (!Files.exists(cacheInfoPath)) return true;
|
||||
|
||||
long currentLastModified = Files.getLastModifiedTime(cbzPath).toMillis();
|
||||
String recordedTimestamp = Files.readString(cacheInfoPath).trim();
|
||||
|
||||
try {
|
||||
long recordedLastModified = Long.parseLong(recordedTimestamp);
|
||||
return recordedLastModified != currentLastModified;
|
||||
} catch (NumberFormatException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCacheInfo(Path cbzPath, Path cacheInfoPath) throws IOException {
|
||||
long lastModified = Files.getLastModifiedTime(cbzPath).toMillis();
|
||||
Files.writeString(cacheInfoPath, String.valueOf(lastModified), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
}
|
||||
|
||||
private void enforceCacheLimit() {
|
||||
try {
|
||||
Path cacheRoot = Path.of(fileService.getCbxCachePath());
|
||||
if (!Files.exists(cacheRoot) || !Files.isDirectory(cacheRoot)) {
|
||||
return;
|
||||
}
|
||||
long totalSize = 0L;
|
||||
List<Path> cacheDirs;
|
||||
try (Stream<Path> stream = Files.list(cacheRoot)) {
|
||||
cacheDirs = stream.filter(Files::isDirectory).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
for (Path dir : cacheDirs) {
|
||||
totalSize += getDirectorySize(dir);
|
||||
}
|
||||
|
||||
long maxCacheSizeBytes = mbToBytes(appSettingService.getAppSettings().getCbxCacheSizeInMb());
|
||||
|
||||
while (totalSize > maxCacheSizeBytes && !cacheDirs.isEmpty()) {
|
||||
Path leastRecentlyReadDir = cacheDirs.stream()
|
||||
.min(Comparator.comparingLong(this::getLastReadTime))
|
||||
.orElse(null);
|
||||
|
||||
long sizeFreed = getDirectorySize(leastRecentlyReadDir);
|
||||
FileUtils.deleteDirectoryRecursively(leastRecentlyReadDir);
|
||||
cacheDirs.remove(leastRecentlyReadDir);
|
||||
totalSize -= sizeFreed;
|
||||
log.info("Deleted cache directory {} to enforce cache size limit", leastRecentlyReadDir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error enforcing cache size limit", e);
|
||||
}
|
||||
}
|
||||
|
||||
private long getLastReadTime(Path cacheDir) {
|
||||
Path cacheInfoPath = cacheDir.resolve(CACHE_INFO_FILENAME);
|
||||
if (!Files.exists(cacheInfoPath)) {
|
||||
return Long.MIN_VALUE;
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(cacheInfoPath).trim();
|
||||
return Long.parseLong(content);
|
||||
} catch (Exception e) {
|
||||
return Long.MIN_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private long getDirectorySize(Path dir) {
|
||||
try (Stream<Path> paths = Files.walk(dir)) {
|
||||
return paths
|
||||
.filter(Files::isRegularFile)
|
||||
.mapToLong(p -> {
|
||||
try {
|
||||
return Files.size(p);
|
||||
} catch (IOException e) {
|
||||
return 0L;
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
} catch (IOException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private long estimateArchiveSize(Path cbxPath) {
|
||||
try {
|
||||
ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(cbxPath.toFile());
|
||||
return switch (type) {
|
||||
case ZIP -> estimateCbzArchiveSize(cbxPath);
|
||||
case SEVEN_ZIP -> estimateCb7ArchiveSize(cbxPath);
|
||||
case RAR -> estimateCbrArchiveSize(cbxPath);
|
||||
default -> Long.MAX_VALUE;
|
||||
};
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to estimate archive size for {}", cbxPath, e);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private long estimateCbzArchiveSize(Path cbxPath) throws IOException {
|
||||
String[] encodingsToTry = {"UTF-8", "Shift_JIS", "ISO-8859-1", "CP437", "MS932"};
|
||||
|
||||
for (String encoding : encodingsToTry) {
|
||||
Charset charset = Charset.forName(encoding);
|
||||
try {
|
||||
long size = estimateCbzWithEncoding(cbxPath, charset, true);
|
||||
if (size > 0) return size;
|
||||
} catch (Exception e) {
|
||||
log.debug("Fast path estimation failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
long size = estimateCbzWithEncoding(cbxPath, charset, false);
|
||||
if (size > 0) return size;
|
||||
} catch (Exception e) {
|
||||
log.debug("Slow path estimation failed for encoding {}: {}", encoding, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("Unable to estimate archive size for {} with any supported encoding", cbxPath);
|
||||
return -1;
|
||||
}
|
||||
|
||||
private long estimateCbzWithEncoding(Path cbxPath, Charset charset, boolean useFastPath) throws IOException {
|
||||
try (org.apache.commons.compress.archivers.zip.ZipFile zipFile =
|
||||
org.apache.commons.compress.archivers.zip.ZipFile.builder()
|
||||
.setPath(cbxPath)
|
||||
.setCharset(charset)
|
||||
.setUseUnicodeExtraFields(true)
|
||||
.setIgnoreLocalFileHeader(useFastPath)
|
||||
.get()) {
|
||||
|
||||
long total = 0;
|
||||
var entries = zipFile.getEntries();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipArchiveEntry entry = entries.nextElement();
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
long size = entry.getSize();
|
||||
total += (size >= 0) ? size : entry.getCompressedSize();
|
||||
private void sortNaturally(List<String> entries) {
|
||||
entries.sort((s1, s2) -> {
|
||||
Matcher m1 = NUMERIC_PATTERN.matcher(s1);
|
||||
Matcher m2 = NUMERIC_PATTERN.matcher(s2);
|
||||
while (m1.find() && m2.find()) {
|
||||
String part1 = m1.group();
|
||||
String part2 = m2.group();
|
||||
if (part1.matches("\\d+") && part2.matches("\\d+")) {
|
||||
int cmp = Integer.compare(
|
||||
Integer.parseInt(part1),
|
||||
Integer.parseInt(part2)
|
||||
);
|
||||
if (cmp != 0) return cmp;
|
||||
} else {
|
||||
int cmp = part1.compareToIgnoreCase(part2);
|
||||
if (cmp != 0) return cmp;
|
||||
}
|
||||
}
|
||||
return total > 0 ? total : -1;
|
||||
}
|
||||
}
|
||||
|
||||
private long estimateCb7ArchiveSize(Path cbxPath) throws IOException {
|
||||
try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cbxPath).get()) {
|
||||
SevenZArchiveEntry entry;
|
||||
long total = 0;
|
||||
while ((entry = sevenZFile.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
total += entry.getSize();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
private long estimateCbrArchiveSize(Path cbxPath) throws IOException, RarException {
|
||||
try (Archive archive = new Archive(cbxPath.toFile())) {
|
||||
long total = 0;
|
||||
for (FileHeader header : archive.getFileHeaders()) {
|
||||
if (!header.isDirectory() && isImageFile(header.getFileName())) {
|
||||
total += header.getFullUnpackSize();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
private long mbToBytes(int mb) {
|
||||
return mb * 1024L * 1024L;
|
||||
return s1.compareToIgnoreCase(s2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,14 +4,6 @@ import lombok.Getter;
|
||||
|
||||
public enum TaskType {
|
||||
|
||||
CLEAR_CBX_CACHE(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"Clear CBX Cache",
|
||||
"Clears temporarily extracted comic book files used by the reader."
|
||||
),
|
||||
CLEAR_PDF_CACHE(
|
||||
false,
|
||||
false,
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
package com.adityachandel.booklore.task.tasks;
|
||||
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.request.TaskCreateRequest;
|
||||
import com.adityachandel.booklore.model.dto.response.TaskCreateResponse;
|
||||
import com.adityachandel.booklore.model.enums.TaskType;
|
||||
import com.adityachandel.booklore.model.enums.UserPermission;
|
||||
import com.adityachandel.booklore.task.TaskMetadataHelper;
|
||||
import com.adityachandel.booklore.task.TaskStatus;
|
||||
import com.adityachandel.booklore.util.FileService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Comparator;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ClearCbxCacheTask implements Task {
|
||||
|
||||
private FileService fileService;
|
||||
|
||||
@Override
|
||||
public void validatePermissions(BookLoreUser user, TaskCreateRequest request) {
|
||||
if (!UserPermission.CAN_ACCESS_TASK_MANAGER.isGranted(user.getPermissions())) {
|
||||
throw ApiError.PERMISSION_DENIED.createException(UserPermission.CAN_ACCESS_TASK_MANAGER);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TaskCreateResponse execute(TaskCreateRequest request) {
|
||||
TaskCreateResponse.TaskCreateResponseBuilder builder = TaskCreateResponse.builder()
|
||||
.taskId(UUID.randomUUID().toString())
|
||||
.taskType(TaskType.CLEAR_CBX_CACHE);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("{}: Task started", getTaskType());
|
||||
|
||||
try {
|
||||
fileService.clearCacheDirectory(fileService.getCbxCachePath());
|
||||
log.info("{}: Cache cleared", getTaskType());
|
||||
builder.status(TaskStatus.COMPLETED);
|
||||
} catch (Exception e) {
|
||||
log.error("{}: Error clearing cache", getTaskType(), e);
|
||||
builder.status(TaskStatus.FAILED);
|
||||
throw new RuntimeException("Failed to clear CBX cache", e);
|
||||
}
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
log.info("{}: Task completed. Duration: {} ms", getTaskType(), endTime - startTime);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TaskType getTaskType() {
|
||||
return TaskType.CLEAR_CBX_CACHE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMetadata() {
|
||||
return TaskMetadataHelper.getCacheSizeString(fileService.getCbxCachePath());
|
||||
}
|
||||
}
|
||||
@ -8,9 +8,6 @@ import com.adityachandel.booklore.repository.BookMetadataRepository;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@ -103,10 +100,6 @@ public class FileService {
|
||||
return Paths.get(appProperties.getPathConfig(), "metadata_backup", String.valueOf(bookId)).toString();
|
||||
}
|
||||
|
||||
public String getCbxCachePath() {
|
||||
return Paths.get(appProperties.getPathConfig(), "cbx_cache").toString();
|
||||
}
|
||||
|
||||
public String getPdfCachePath() {
|
||||
return Paths.get(appProperties.getPathConfig(), "pdf_cache").toString();
|
||||
}
|
||||
@ -161,7 +154,7 @@ public class FileService {
|
||||
log.warn("ImageIO/TwelveMonkeys decode failed (possibly unsupported format like AVIF/HEIC): {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
log.warn("Unable to decode image - likely unsupported format (AVIF, HEIC, or SVG)");
|
||||
return null;
|
||||
}
|
||||
@ -373,7 +366,7 @@ public class FileService {
|
||||
boolean isExtremelyTall = settings.isVerticalCroppingEnabled() && heightToWidthRatio > threshold;
|
||||
if (isExtremelyTall) {
|
||||
int croppedHeight = (int) (width * TARGET_COVER_ASPECT_RATIO);
|
||||
log.debug("Cropping tall image: {}x{} (ratio {}) -> {}x{}, smartCrop={}",
|
||||
log.debug("Cropping tall image: {}x{} (ratio {}) -> {}x{}, smartCrop={}",
|
||||
width, height, String.format("%.2f", heightToWidthRatio), width, croppedHeight, smartCrop);
|
||||
return cropFromTop(image, width, croppedHeight, smartCrop);
|
||||
}
|
||||
@ -381,7 +374,7 @@ public class FileService {
|
||||
boolean isExtremelyWide = settings.isHorizontalCroppingEnabled() && widthToHeightRatio > threshold;
|
||||
if (isExtremelyWide) {
|
||||
int croppedWidth = (int) (height / TARGET_COVER_ASPECT_RATIO);
|
||||
log.debug("Cropping wide image: {}x{} (ratio {}) -> {}x{}, smartCrop={}",
|
||||
log.debug("Cropping wide image: {}x{} (ratio {}) -> {}x{}, smartCrop={}",
|
||||
width, height, String.format("%.2f", widthToHeightRatio), croppedWidth, height, smartCrop);
|
||||
return cropFromLeft(image, croppedWidth, height, smartCrop);
|
||||
}
|
||||
@ -395,7 +388,7 @@ public class FileService {
|
||||
int contentStartY = findContentStartY(image);
|
||||
int margin = (int) (targetHeight * SMART_CROP_MARGIN_PERCENT);
|
||||
startY = Math.max(0, contentStartY - margin);
|
||||
|
||||
|
||||
int maxStartY = image.getHeight() - targetHeight;
|
||||
startY = Math.min(startY, maxStartY);
|
||||
}
|
||||
@ -408,7 +401,7 @@ public class FileService {
|
||||
int contentStartX = findContentStartX(image);
|
||||
int margin = (int) (targetWidth * SMART_CROP_MARGIN_PERCENT);
|
||||
startX = Math.max(0, contentStartX - margin);
|
||||
|
||||
|
||||
int maxStartX = image.getWidth() - targetWidth;
|
||||
startX = Math.min(startX, maxStartX);
|
||||
}
|
||||
@ -457,8 +450,8 @@ public class FileService {
|
||||
int r1 = (rgb1 >> 16) & 0xFF, g1 = (rgb1 >> 8) & 0xFF, b1 = rgb1 & 0xFF;
|
||||
int r2 = (rgb2 >> 16) & 0xFF, g2 = (rgb2 >> 8) & 0xFF, b2 = rgb2 & 0xFF;
|
||||
return Math.abs(r1 - r2) <= SMART_CROP_COLOR_TOLERANCE
|
||||
&& Math.abs(g1 - g2) <= SMART_CROP_COLOR_TOLERANCE
|
||||
&& Math.abs(b1 - b2) <= SMART_CROP_COLOR_TOLERANCE;
|
||||
&& Math.abs(g1 - g2) <= SMART_CROP_COLOR_TOLERANCE
|
||||
&& Math.abs(b1 - b2) <= SMART_CROP_COLOR_TOLERANCE;
|
||||
}
|
||||
|
||||
public static void setBookCoverPath(BookMetadataEntity bookMetadataEntity) {
|
||||
|
||||
@ -1,240 +1,284 @@
|
||||
package com.adityachandel.booklore.service.reader;
|
||||
|
||||
import com.adityachandel.booklore.exception.APIException;
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CbxReaderServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
BookRepository bookRepository;
|
||||
|
||||
@Mock
|
||||
private AppSettingService appSettingService;
|
||||
@InjectMocks
|
||||
CbxReaderService cbxReaderService;
|
||||
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Captor
|
||||
ArgumentCaptor<Long> longCaptor;
|
||||
|
||||
private CbxReaderService service;
|
||||
private Path cbzFile;
|
||||
private Path cacheDir;
|
||||
private BookEntity testBook;
|
||||
private final Long bookId = 113L;
|
||||
BookEntity bookEntity;
|
||||
Path cbzPath;
|
||||
Path cb7Path;
|
||||
Path cbrPath;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
service = new CbxReaderService(bookRepository, appSettingService, fileService);
|
||||
|
||||
cacheDir = tempDir.resolve("cbx_cache").resolve(String.valueOf(bookId));
|
||||
Files.createDirectories(cacheDir);
|
||||
|
||||
cbzFile = tempDir.resolve("doctorwho_fourdoctors.cbz");
|
||||
createTestCbzWithMacOsFiles(cbzFile.toFile());
|
||||
|
||||
LibraryPathEntity libraryPath = new LibraryPathEntity();
|
||||
libraryPath.setPath(cbzFile.getParent().toString());
|
||||
|
||||
testBook = new BookEntity();
|
||||
testBook.setId(bookId);
|
||||
testBook.setLibraryPath(libraryPath);
|
||||
testBook.setFileSubPath("");
|
||||
testBook.setFileName(cbzFile.getFileName().toString());
|
||||
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(testBook));
|
||||
when(fileService.getCbxCachePath()).thenReturn(cacheDir.getParent().toString());
|
||||
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setCbxCacheSizeInMb(1000);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
if (Files.exists(cacheDir)) {
|
||||
Files.walk(cacheDir)
|
||||
.sorted((a, b) -> -a.compareTo(b))
|
||||
.forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
void setup() throws Exception {
|
||||
bookEntity = new BookEntity();
|
||||
bookEntity.setId(1L);
|
||||
cbzPath = Path.of("/tmp/test.cbz");
|
||||
cb7Path = Path.of("/tmp/test.cb7");
|
||||
cbrPath = Path.of("/tmp/test.cbr");
|
||||
Files.deleteIfExists(cbzPath);
|
||||
Files.deleteIfExists(cb7Path);
|
||||
Files.deleteIfExists(cbrPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailablePages_filtersOutMacOsFiles_shouldReturnCorrectPageCount() throws IOException {
|
||||
List<Integer> pages = service.getAvailablePages(bookId);
|
||||
|
||||
assertEquals(130, pages.size(),
|
||||
"Page count should be 130 (actual comic pages), not 260 (including __MACOSX files)");
|
||||
assertEquals(1, pages.getFirst());
|
||||
assertEquals(130, pages.getLast());
|
||||
|
||||
List<Path> cachedFiles = Files.list(cacheDir)
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> !p.getFileName().toString().equals(".cache-info"))
|
||||
.toList();
|
||||
|
||||
assertEquals(130, cachedFiles.size(),
|
||||
"Cache should contain exactly 130 image files, not 260. Actual files: " +
|
||||
cachedFiles.stream().map(p -> p.getFileName().toString()).sorted().toList());
|
||||
|
||||
boolean hasMacOsFiles = cachedFiles.stream()
|
||||
.anyMatch(p -> p.getFileName().toString().startsWith("._") ||
|
||||
p.getFileName().toString().contains("__MACOSX"));
|
||||
assertFalse(hasMacOsFiles, "Cache should not contain any __MACOSX or ._ files. Found: " +
|
||||
cachedFiles.stream()
|
||||
.map(p -> p.getFileName().toString())
|
||||
.filter(name -> name.startsWith("._") || name.contains("__MACOSX"))
|
||||
.toList());
|
||||
|
||||
boolean allAreComicPages = cachedFiles.stream()
|
||||
.allMatch(p -> p.getFileName().toString().matches("DW_4D_\\d{3}\\.jpg"));
|
||||
assertTrue(allAreComicPages, "All cached files should be actual comic pages (DW_4D_*.jpg)");
|
||||
}
|
||||
void testGetAvailablePages_CBZ_Success() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath.toString());
|
||||
|
||||
@Test
|
||||
void streamPageImage_returnsActualComicPages_notMacOsFiles() throws IOException {
|
||||
service.getAvailablePages(bookId);
|
||||
|
||||
ByteArrayOutputStream page1Output = new ByteArrayOutputStream();
|
||||
service.streamPageImage(bookId, 1, page1Output);
|
||||
|
||||
byte[] page1Data = page1Output.toByteArray();
|
||||
assertTrue(page1Data.length > 0, "Page 1 should have content");
|
||||
assertEquals(0xFF, page1Data[0] & 0xFF);
|
||||
assertEquals(0xD8, page1Data[1] & 0xFF);
|
||||
|
||||
ByteArrayOutputStream page130Output = new ByteArrayOutputStream();
|
||||
service.streamPageImage(bookId, 130, page130Output);
|
||||
|
||||
byte[] page130Data = page130Output.toByteArray();
|
||||
assertTrue(page130Data.length > 0, "Page 130 should have content");
|
||||
assertEquals(0xFF, page130Data[0] & 0xFF);
|
||||
assertEquals(0xD8, page130Data[1] & 0xFF);
|
||||
|
||||
List<Path> cachedFiles = Files.list(cacheDir)
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> !p.getFileName().toString().equals(".cache-info"))
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
assertEquals("DW_4D_001.jpg", cachedFiles.getFirst().getFileName().toString());
|
||||
assertEquals("DW_4D_130.jpg", cachedFiles.get(129).getFileName().toString());
|
||||
}
|
||||
ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg");
|
||||
ZipArchiveEntry entry2 = new ZipArchiveEntry("2.png");
|
||||
Enumeration<ZipArchiveEntry> entries = Collections.enumeration(List.of(entry1, entry2));
|
||||
ZipFile zipFile = mock(ZipFile.class);
|
||||
when(zipFile.getEntries()).thenReturn(entries);
|
||||
|
||||
@Test
|
||||
void getAvailablePages_withMacOsFiles_shouldNotDoubleCountPages() throws IOException {
|
||||
List<Integer> pages = service.getAvailablePages(bookId);
|
||||
|
||||
assertNotEquals(260, pages.size(),
|
||||
"Page count should NOT be 260 (this was the bug - double counting __MACOSX files)");
|
||||
assertEquals(130, pages.size(),
|
||||
"Page count should be exactly 130 (actual comic pages only)");
|
||||
}
|
||||
ZipFile.Builder builder = mock(ZipFile.Builder.class, RETURNS_DEEP_STUBS);
|
||||
when(builder.setPath(cbzPath)).thenReturn(builder);
|
||||
when(builder.setCharset(any(Charset.class))).thenReturn(builder);
|
||||
when(builder.setUseUnicodeExtraFields(anyBoolean())).thenReturn(builder);
|
||||
when(builder.setIgnoreLocalFileHeader(anyBoolean())).thenReturn(builder);
|
||||
when(builder.get()).thenReturn(zipFile);
|
||||
|
||||
@Test
|
||||
void getAvailablePages_ZipWithCbrExtension_shouldWork() throws IOException {
|
||||
Path zipAsCbrFile = tempDir.resolve("misnamed.cbr");
|
||||
createTestCbzWithMacOsFiles(zipAsCbrFile.toFile());
|
||||
try (MockedStatic<ZipFile> zipFileStatic = mockStatic(ZipFile.class)) {
|
||||
zipFileStatic.when(ZipFile::builder).thenReturn(builder);
|
||||
|
||||
testBook.setFileName(zipAsCbrFile.getFileName().toString());
|
||||
Files.createFile(cbzPath);
|
||||
Files.setLastModifiedTime(cbzPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
List<Integer> pages = service.getAvailablePages(bookId);
|
||||
|
||||
assertEquals(130, pages.size(), "Should correctly detect and extract misnamed ZIP as CBR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailablePages_whenArchiveIsCorrupt_shouldThrowFileReadError() throws IOException {
|
||||
Path corruptCbz = tempDir.resolve("corrupt.cbz");
|
||||
Files.writeString(corruptCbz, "This is not a zip file");
|
||||
|
||||
testBook.setFileName(corruptCbz.getFileName().toString());
|
||||
testBook.getLibraryPath().setPath(tempDir.toString());
|
||||
|
||||
APIException exception = assertThrows(APIException.class, () -> service.getAvailablePages(bookId));
|
||||
|
||||
assertNotEquals(ApiError.CACHE_TOO_LARGE.getMessage(), exception.getMessage(),
|
||||
"Should not throw CACHE_TOO_LARGE for a corrupt file");
|
||||
assertTrue(exception.getMessage().startsWith("Error reading files from path"),
|
||||
"Should throw FILE_READ_ERROR (message starts with 'Error reading files from path'), actual: '" + exception.getMessage() + "'");
|
||||
}
|
||||
private void createTestCbzWithMacOsFiles(File cbzFile) throws IOException {
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(cbzFile))) {
|
||||
for (int i = 1; i <= 130; i++) {
|
||||
String pageNumber = String.format("%03d", i);
|
||||
|
||||
String comicPageName = "DW_4D_" + pageNumber + ".jpg";
|
||||
ZipEntry comicEntry = new ZipEntry(comicPageName);
|
||||
comicEntry.setTime(0L);
|
||||
zos.putNextEntry(comicEntry);
|
||||
byte[] comicImage = createTestImage(Color.RED, "Page " + i);
|
||||
zos.write(comicImage);
|
||||
zos.closeEntry();
|
||||
|
||||
String macOsFileName = "__MACOSX/._DW_4D_" + pageNumber + ".jpg";
|
||||
ZipEntry macOsEntry = new ZipEntry(macOsFileName);
|
||||
macOsEntry.setTime(0L);
|
||||
zos.putNextEntry(macOsEntry);
|
||||
byte[] macOsData = "MacOS metadata".getBytes();
|
||||
zos.write(macOsData);
|
||||
zos.closeEntry();
|
||||
List<Integer> pages = cbxReaderService.getAvailablePages(1L);
|
||||
assertEquals(List.of(1, 2), pages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] createTestImage(Color color, String label) throws IOException {
|
||||
BufferedImage image = new BufferedImage(400, 600, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
g2d.setColor(color);
|
||||
g2d.fillRect(0, 0, 400, 600);
|
||||
|
||||
g2d.setColor(Color.BLACK);
|
||||
g2d.setFont(g2d.getFont().deriveFont(24f));
|
||||
g2d.drawString(label, 50, 300);
|
||||
|
||||
g2d.dispose();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "jpg", baos);
|
||||
return baos.toByteArray();
|
||||
@Test
|
||||
void testStreamPageImage_CBZ_Success() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath.toString());
|
||||
|
||||
ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg");
|
||||
Enumeration<ZipArchiveEntry> entries = Collections.enumeration(List.of(entry1));
|
||||
ZipFile zipFile = mock(ZipFile.class);
|
||||
when(zipFile.getEntries()).thenReturn(entries);
|
||||
when(zipFile.getEntry("1.jpg")).thenReturn(entry1);
|
||||
when(zipFile.getInputStream(entry1)).thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3}));
|
||||
|
||||
ZipFile.Builder builder = mock(ZipFile.Builder.class, RETURNS_DEEP_STUBS);
|
||||
when(builder.setPath(cbzPath)).thenReturn(builder);
|
||||
when(builder.setCharset(any(Charset.class))).thenReturn(builder);
|
||||
when(builder.setUseUnicodeExtraFields(anyBoolean())).thenReturn(builder);
|
||||
when(builder.setIgnoreLocalFileHeader(anyBoolean())).thenReturn(builder);
|
||||
when(builder.get()).thenReturn(zipFile);
|
||||
|
||||
try (MockedStatic<ZipFile> zipFileStatic = mockStatic(ZipFile.class)) {
|
||||
zipFileStatic.when(ZipFile::builder).thenReturn(builder);
|
||||
|
||||
Files.createFile(cbzPath);
|
||||
Files.setLastModifiedTime(cbzPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
cbxReaderService.streamPageImage(1L, 1, out);
|
||||
assertArrayEquals(new byte[]{1, 2, 3}, out.toByteArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvailablePages_CBZ_ThrowsOnMissingBook() {
|
||||
when(bookRepository.findById(2L)).thenReturn(Optional.empty());
|
||||
assertThrows(ApiError.BOOK_NOT_FOUND.createException().getClass(), () -> cbxReaderService.getAvailablePages(2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvailablePages_CB7_Success() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cb7Path.toString());
|
||||
|
||||
SevenZArchiveEntry entry1 = mock(SevenZArchiveEntry.class);
|
||||
when(entry1.getName()).thenReturn("1.jpg");
|
||||
when(entry1.isDirectory()).thenReturn(false);
|
||||
|
||||
SevenZFile sevenZFile = mock(SevenZFile.class);
|
||||
when(sevenZFile.getNextEntry()).thenReturn(entry1, (SevenZArchiveEntry) null);
|
||||
|
||||
SevenZFile.Builder builder = mock(SevenZFile.Builder.class, RETURNS_DEEP_STUBS);
|
||||
when(builder.setPath(cb7Path)).thenReturn(builder);
|
||||
when(builder.get()).thenReturn(sevenZFile);
|
||||
|
||||
try (MockedStatic<SevenZFile> sevenZFileStatic = mockStatic(SevenZFile.class)) {
|
||||
sevenZFileStatic.when(SevenZFile::builder).thenReturn(builder);
|
||||
|
||||
Files.createFile(cb7Path);
|
||||
Files.setLastModifiedTime(cb7Path, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
List<Integer> pages = cbxReaderService.getAvailablePages(1L);
|
||||
assertEquals(List.of(1), pages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvailablePages_CBR_Success() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbrPath.toString());
|
||||
|
||||
FileHeader header = mock(FileHeader.class);
|
||||
when(header.isDirectory()).thenReturn(false);
|
||||
when(header.getFileName()).thenReturn("1.jpg");
|
||||
|
||||
try (MockedConstruction<Archive> ignored = mockConstruction(Archive.class, (mock, context) -> {
|
||||
when(mock.getFileHeaders()).thenReturn(List.of(header));
|
||||
})) {
|
||||
Files.deleteIfExists(cbrPath);
|
||||
Files.createFile(cbrPath);
|
||||
Files.setLastModifiedTime(cbrPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
List<Integer> pages = cbxReaderService.getAvailablePages(1L);
|
||||
assertEquals(List.of(1), pages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStreamPageImage_CBR_Success() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbrPath.toString());
|
||||
|
||||
FileHeader header = mock(FileHeader.class);
|
||||
when(header.isDirectory()).thenReturn(false);
|
||||
when(header.getFileName()).thenReturn("1.jpg");
|
||||
|
||||
try (MockedConstruction<Archive> ignored = mockConstruction(Archive.class, (mock, context) -> {
|
||||
when(mock.getFileHeaders()).thenReturn(List.of(header));
|
||||
doAnswer(invocation -> {
|
||||
OutputStream out = invocation.getArgument(1);
|
||||
out.write(new byte[]{1, 2, 3});
|
||||
return null;
|
||||
}).when(mock).extractFile(eq(header), any(OutputStream.class));
|
||||
})) {
|
||||
Files.deleteIfExists(cbrPath);
|
||||
Files.createFile(cbrPath);
|
||||
Files.setLastModifiedTime(cbrPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
cbxReaderService.streamPageImage(1L, 1, out);
|
||||
assertArrayEquals(new byte[]{1, 2, 3}, out.toByteArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStreamPageImage_PageOutOfRange_Throws() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath.toString());
|
||||
|
||||
ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg");
|
||||
Enumeration<ZipArchiveEntry> entries = Collections.enumeration(List.of(entry1));
|
||||
ZipFile zipFile = mock(ZipFile.class);
|
||||
when(zipFile.getEntries()).thenReturn(entries);
|
||||
|
||||
ZipFile.Builder builder = mock(ZipFile.Builder.class, RETURNS_DEEP_STUBS);
|
||||
when(builder.setPath(cbzPath)).thenReturn(builder);
|
||||
when(builder.setCharset(any(Charset.class))).thenReturn(builder);
|
||||
when(builder.setUseUnicodeExtraFields(anyBoolean())).thenReturn(builder);
|
||||
when(builder.setIgnoreLocalFileHeader(anyBoolean())).thenReturn(builder);
|
||||
when(builder.get()).thenReturn(zipFile);
|
||||
|
||||
try (MockedStatic<ZipFile> zipFileStatic = mockStatic(ZipFile.class)) {
|
||||
zipFileStatic.when(ZipFile::builder).thenReturn(builder);
|
||||
|
||||
Files.createFile(cbzPath);
|
||||
Files.setLastModifiedTime(cbzPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
assertThrows(FileNotFoundException.class, () -> cbxReaderService.streamPageImage(1L, 2, new ByteArrayOutputStream()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvailablePages_UnsupportedArchive_Throws() throws Exception {
|
||||
Path unknownPath = Path.of("/tmp/test.unknown");
|
||||
Files.deleteIfExists(unknownPath);
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(unknownPath.toString());
|
||||
|
||||
Files.createFile(unknownPath);
|
||||
Files.setLastModifiedTime(unknownPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
assertThrows(com.adityachandel.booklore.exception.APIException.class, () -> cbxReaderService.getAvailablePages(1L));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStreamPageImage_EntryNotFound_Throws() throws Exception {
|
||||
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
|
||||
try (MockedStatic<FileUtils> fileUtilsStatic = mockStatic(FileUtils.class)) {
|
||||
fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath.toString());
|
||||
|
||||
ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg");
|
||||
Enumeration<ZipArchiveEntry> entries = Collections.enumeration(List.of(entry1));
|
||||
ZipFile zipFile = mock(ZipFile.class);
|
||||
when(zipFile.getEntries()).thenReturn(entries);
|
||||
|
||||
ZipFile.Builder builder = mock(ZipFile.Builder.class, RETURNS_DEEP_STUBS);
|
||||
when(builder.setPath(cbzPath)).thenReturn(builder);
|
||||
when(builder.setCharset(any(Charset.class))).thenReturn(builder);
|
||||
when(builder.setUseUnicodeExtraFields(anyBoolean())).thenReturn(builder);
|
||||
when(builder.setIgnoreLocalFileHeader(anyBoolean())).thenReturn(builder);
|
||||
when(builder.get()).thenReturn(zipFile);
|
||||
|
||||
try (MockedStatic<ZipFile> zipFileStatic = mockStatic(ZipFile.class)) {
|
||||
zipFileStatic.when(ZipFile::builder).thenReturn(builder);
|
||||
|
||||
Files.createFile(cbzPath);
|
||||
Files.setLastModifiedTime(cbzPath, FileTime.fromMillis(System.currentTimeMillis()));
|
||||
|
||||
assertThrows(IOException.class, () -> cbxReaderService.streamPageImage(1L, 2, new ByteArrayOutputStream()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,18 +65,18 @@ class TaskCronServiceTest {
|
||||
@Test
|
||||
void testGetAllEnabledCronConfigs_returnsList() {
|
||||
List<TaskCronConfigurationEntity> configs = List.of(
|
||||
buildEntity(TaskType.CLEAR_CBX_CACHE, "0 0 1 * * *", true)
|
||||
buildEntity(TaskType.CLEAR_PDF_CACHE, "0 0 1 * * *", true)
|
||||
);
|
||||
when(repository.findByEnabledTrue()).thenReturn(configs);
|
||||
|
||||
List<TaskCronConfigurationEntity> result = service.getAllEnabledCronConfigs();
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(TaskType.CLEAR_CBX_CACHE, result.getFirst().getTaskType());
|
||||
assertEquals(TaskType.CLEAR_PDF_CACHE, result.getFirst().getTaskType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCronConfigOrDefault_existingConfig() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
TaskType type = TaskType.CLEAR_PDF_CACHE;
|
||||
TaskCronConfigurationEntity entity = buildEntity(type, "0 0 1 * * *", true);
|
||||
when(repository.findByTaskType(type)).thenReturn(Optional.of(entity));
|
||||
|
||||
@ -88,7 +88,7 @@ class TaskCronServiceTest {
|
||||
|
||||
@Test
|
||||
void testGetCronConfigOrDefault_noConfig_returnsDefault() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
TaskType type = TaskType.CLEAR_PDF_CACHE;
|
||||
when(repository.findByTaskType(type)).thenReturn(Optional.empty());
|
||||
|
||||
CronConfig config = service.getCronConfigOrDefault(type);
|
||||
@ -116,7 +116,7 @@ class TaskCronServiceTest {
|
||||
|
||||
@Test
|
||||
void testPatchCronConfig_updateExisting() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
TaskType type = TaskType.CLEAR_PDF_CACHE;
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).isDefaultPassword(false).build();
|
||||
TaskCronConfigurationEntity entity = buildEntity(type, "0 0 1 * * *", false);
|
||||
|
||||
@ -135,7 +135,7 @@ class TaskCronServiceTest {
|
||||
|
||||
@Test
|
||||
void testPatchCronConfig_createNew() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
TaskType type = TaskType.CLEAR_PDF_CACHE;
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).isDefaultPassword(false).build();
|
||||
|
||||
when(authService.getAuthenticatedUser()).thenReturn(user);
|
||||
@ -154,7 +154,7 @@ class TaskCronServiceTest {
|
||||
|
||||
@Test
|
||||
void testPatchCronConfig_invalidCronExpression_throws() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
TaskType type = TaskType.CLEAR_PDF_CACHE;
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).isDefaultPassword(false).build();
|
||||
|
||||
when(authService.getAuthenticatedUser()).thenReturn(user);
|
||||
@ -223,7 +223,7 @@ class TaskCronServiceTest {
|
||||
|
||||
@Test
|
||||
void testValidateTaskTypeForCron_supported() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
TaskType type = TaskType.CLEAR_PDF_CACHE;
|
||||
assertDoesNotThrow(() -> {
|
||||
var method = TaskCronService.class.getDeclaredMethod("validateTaskTypeForCron", TaskType.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
@ -264,7 +264,7 @@ class TaskHistoryServiceTest {
|
||||
List<TaskType> hiddenTypes = Arrays.asList(TaskType.values());
|
||||
TaskHistoryEntity dummyTask = TaskHistoryEntity.builder()
|
||||
.id("dummy")
|
||||
.type(TaskType.CLEAR_CBX_CACHE)
|
||||
.type(TaskType.CLEAR_PDF_CACHE)
|
||||
.status(TaskStatus.FAILED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(FIXED_TIME)
|
||||
|
||||
@ -101,7 +101,7 @@ class TaskServiceTest {
|
||||
|
||||
@Test
|
||||
void testExecuteTaskThrowsForUnknownTaskType() {
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEAR_CBX_CACHE).triggeredByCron(false).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEAR_PDF_CACHE).triggeredByCron(false).build();
|
||||
BookLoreUser user = new BookLoreUser();
|
||||
user.setId(1L);
|
||||
user.setUsername("user1");
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
package com.adityachandel.booklore.task.tasks;
|
||||
|
||||
import com.adityachandel.booklore.exception.APIException;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.request.TaskCreateRequest;
|
||||
import com.adityachandel.booklore.model.dto.response.TaskCreateResponse;
|
||||
import com.adityachandel.booklore.model.enums.TaskType;
|
||||
import com.adityachandel.booklore.task.TaskStatus;
|
||||
import com.adityachandel.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ClearCbxCacheTaskTest {
|
||||
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
|
||||
@InjectMocks
|
||||
private ClearCbxCacheTask clearCbxCacheTask;
|
||||
|
||||
private BookLoreUser user;
|
||||
private TaskCreateRequest request;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
user = BookLoreUser.builder()
|
||||
.permissions(new BookLoreUser.UserPermissions())
|
||||
.build();
|
||||
request = new TaskCreateRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePermissions_shouldThrowException_whenUserCannotAccessTaskManager() {
|
||||
user.getPermissions().setCanAccessTaskManager(false);
|
||||
assertThrows(APIException.class, () -> clearCbxCacheTask.validatePermissions(user, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_shouldClearCache() {
|
||||
String cachePathStr = "/tmp/cbx-cache";
|
||||
when(fileService.getCbxCachePath()).thenReturn(cachePathStr);
|
||||
doNothing().when(fileService).clearCacheDirectory(cachePathStr);
|
||||
|
||||
TaskCreateResponse response = clearCbxCacheTask.execute(request);
|
||||
|
||||
assertEquals(TaskType.CLEAR_CBX_CACHE, response.getTaskType());
|
||||
assertEquals(TaskStatus.COMPLETED, response.getStatus());
|
||||
|
||||
verify(fileService).getCbxCachePath();
|
||||
verify(fileService).clearCacheDirectory(cachePathStr);
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_shouldHandleException_whenClearCacheFails() {
|
||||
String cachePathStr = "/tmp/cbx-cache";
|
||||
when(fileService.getCbxCachePath()).thenReturn(cachePathStr);
|
||||
doThrow(new RuntimeException("Delete failed")).when(fileService).clearCacheDirectory(cachePathStr);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> clearCbxCacheTask.execute(request));
|
||||
}
|
||||
}
|
||||
@ -316,11 +316,6 @@ class FileServiceTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCbxCachePath_returnsCorrectPath() {
|
||||
assertTrue(fileService.getCbxCachePath().contains("cbx_cache"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPdfCachePath_returnsCorrectPath() {
|
||||
assertTrue(fileService.getPdfCachePath().contains("pdf_cache"));
|
||||
|
||||
@ -188,29 +188,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">CBX Cache Size</label>
|
||||
<div class="input-group">
|
||||
<div class="input-with-unit">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
[(ngModel)]="cbxCacheValue"
|
||||
placeholder="Cache size"/>
|
||||
<span class="unit">MB</span>
|
||||
</div>
|
||||
<p-button label="Save" severity="success" outlined (onClick)="saveCacheSize()"></p-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Limits the total size (in MB) of the CBX image cache. If exceeded, older cache entries are removed automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -47,7 +47,6 @@ export class GlobalPreferencesComponent implements OnInit {
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
appSettings$: Observable<AppSettings | null> = this.appSettingsService.appSettings$;
|
||||
cbxCacheValue?: number;
|
||||
maxFileUploadSizeInMb?: number;
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -55,9 +54,6 @@ export class GlobalPreferencesComponent implements OnInit {
|
||||
filter(settings => !!settings),
|
||||
take(1)
|
||||
).subscribe(settings => {
|
||||
if (settings?.cbxCacheSizeInMb) {
|
||||
this.cbxCacheValue = settings.cbxCacheSizeInMb;
|
||||
}
|
||||
if (settings?.maxFileUploadSizeInMb) {
|
||||
this.maxFileUploadSizeInMb = settings.maxFileUploadSizeInMb;
|
||||
}
|
||||
@ -89,15 +85,6 @@ export class GlobalPreferencesComponent implements OnInit {
|
||||
this.saveSetting(AppSettingKey.COVER_CROPPING_SETTINGS, this.coverCroppingSettings);
|
||||
}
|
||||
|
||||
saveCacheSize(): void {
|
||||
if (!this.cbxCacheValue || this.cbxCacheValue <= 0) {
|
||||
this.showMessage('error', 'Invalid Input', 'Please enter a valid cache size in MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveSetting(AppSettingKey.CBX_CACHE_SIZE_IN_MB, this.cbxCacheValue);
|
||||
}
|
||||
|
||||
saveFileSize() {
|
||||
if (!this.maxFileUploadSizeInMb || this.maxFileUploadSizeInMb <= 0) {
|
||||
this.showMessage('error', 'Invalid Input', 'Please enter a valid max file upload size in MB.');
|
||||
|
||||
@ -448,7 +448,6 @@ export class TaskManagementComponent implements OnInit, OnDestroy {
|
||||
|
||||
getTaskIcon(taskType: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
[TaskType.CLEAR_CBX_CACHE]: 'pi-database',
|
||||
[TaskType.CLEAR_PDF_CACHE]: 'pi-database',
|
||||
[TaskType.REFRESH_LIBRARY_METADATA]: 'pi-refresh',
|
||||
[TaskType.UPDATE_BOOK_RECOMMENDATIONS]: 'pi-sparkles',
|
||||
@ -471,7 +470,6 @@ export class TaskManagementComponent implements OnInit, OnDestroy {
|
||||
|
||||
getMetadataIcon(taskType: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
[TaskType.CLEAR_CBX_CACHE]: 'pi-database',
|
||||
[TaskType.CLEAR_PDF_CACHE]: 'pi-database',
|
||||
[TaskType.CLEANUP_DELETED_BOOKS]: 'pi-trash',
|
||||
[TaskType.CLEANUP_TEMP_METADATA]: 'pi-file'
|
||||
|
||||
@ -197,7 +197,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
describe('Enum value contract', () => {
|
||||
it('should validate TaskType enum values', () => {
|
||||
const values = Object.values(TaskType);
|
||||
expect(values).toContain('CLEAR_CBX_CACHE');
|
||||
expect(values).toContain('CLEAR_PDF_CACHE');
|
||||
expect(values).toContain('REFRESH_LIBRARY_METADATA');
|
||||
expect(values).toContain('REFRESH_METADATA_MANUAL');
|
||||
});
|
||||
@ -228,7 +228,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
|
||||
it('should call correct endpoint for startTask', () => {
|
||||
httpClientMock.post.mockReturnValue(of({}));
|
||||
const req: TaskCreateRequest = {taskType: TaskType.CLEAR_CBX_CACHE};
|
||||
const req: TaskCreateRequest = {taskType: TaskType.CLEAR_PDF_CACHE};
|
||||
service.startTask(req).subscribe();
|
||||
expect(httpClientMock.post).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/tasks\/start$/), req);
|
||||
});
|
||||
@ -256,7 +256,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
describe('Request payload contract', () => {
|
||||
it('should send TaskCreateRequest with correct structure', () => {
|
||||
httpClientMock.post.mockReturnValue(of({}));
|
||||
const req: TaskCreateRequest = {taskType: TaskType.CLEAR_CBX_CACHE, options: {metadataReplaceMode: MetadataReplaceMode.REPLACE_ALL}};
|
||||
const req: TaskCreateRequest = {taskType: TaskType.CLEAR_PDF_CACHE, options: {metadataReplaceMode: MetadataReplaceMode.REPLACE_ALL}};
|
||||
service.startTask(req).subscribe();
|
||||
expect(httpClientMock.post).toHaveBeenCalledWith(expect.any(String), req);
|
||||
});
|
||||
@ -264,7 +264,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
it('should send TaskCronConfigRequest with correct structure', () => {
|
||||
httpClientMock.patch.mockReturnValue(of({}));
|
||||
const req: TaskCronConfigRequest = {cronExpression: '0 0 * * *', enabled: false};
|
||||
service.updateCronConfig(TaskType.CLEAR_CBX_CACHE, req).subscribe();
|
||||
service.updateCronConfig(TaskType.CLEAR_PDF_CACHE, req).subscribe();
|
||||
expect(httpClientMock.patch).toHaveBeenCalledWith(expect.any(String), req);
|
||||
});
|
||||
});
|
||||
@ -272,7 +272,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
describe('Response type contract', () => {
|
||||
it('should expect TaskInfo[] from getAvailableTasks', () => {
|
||||
const mock: TaskInfo[] = [{
|
||||
taskType: TaskType.CLEAR_CBX_CACHE,
|
||||
taskType: TaskType.CLEAR_PDF_CACHE,
|
||||
name: 'Clear CBX',
|
||||
description: 'desc',
|
||||
parallel: false,
|
||||
@ -288,9 +288,9 @@ describe('TaskService - API Contract Tests', () => {
|
||||
});
|
||||
|
||||
it('should expect TaskCreateResponse from startTask', () => {
|
||||
const resp: TaskCreateResponse = {id: '1', type: TaskType.CLEAR_CBX_CACHE, status: TaskStatus.ACCEPTED};
|
||||
const resp: TaskCreateResponse = {id: '1', type: TaskType.CLEAR_PDF_CACHE, status: TaskStatus.ACCEPTED};
|
||||
httpClientMock.post.mockReturnValue(of(resp));
|
||||
service.startTask({taskType: TaskType.CLEAR_CBX_CACHE}).subscribe(result => {
|
||||
service.startTask({taskType: TaskType.CLEAR_PDF_CACHE}).subscribe(result => {
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result).toHaveProperty('type');
|
||||
expect(result).toHaveProperty('status');
|
||||
@ -319,7 +319,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
it('should expect CronConfig from updateCronConfig', () => {
|
||||
const resp: CronConfig = {
|
||||
id: 1,
|
||||
taskType: TaskType.CLEAR_CBX_CACHE,
|
||||
taskType: TaskType.CLEAR_PDF_CACHE,
|
||||
cronExpression: '* * * * *',
|
||||
enabled: true,
|
||||
options: null,
|
||||
@ -327,7 +327,7 @@ describe('TaskService - API Contract Tests', () => {
|
||||
updatedAt: null
|
||||
};
|
||||
httpClientMock.patch.mockReturnValue(of(resp));
|
||||
service.updateCronConfig(TaskType.CLEAR_CBX_CACHE, {enabled: true}).subscribe(result => {
|
||||
service.updateCronConfig(TaskType.CLEAR_PDF_CACHE, {enabled: true}).subscribe(result => {
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result).toHaveProperty('taskType');
|
||||
expect(result).toHaveProperty('enabled');
|
||||
|
||||
@ -5,7 +5,6 @@ import {API_CONFIG} from '../../../core/config/api-config';
|
||||
import {MetadataRefreshRequest} from '../../metadata/model/request/metadata-refresh-request.model';
|
||||
|
||||
export enum TaskType {
|
||||
CLEAR_CBX_CACHE = 'CLEAR_CBX_CACHE',
|
||||
CLEAR_PDF_CACHE = 'CLEAR_PDF_CACHE',
|
||||
REFRESH_LIBRARY_METADATA = 'REFRESH_LIBRARY_METADATA',
|
||||
UPDATE_BOOK_RECOMMENDATIONS = 'UPDATE_BOOK_RECOMMENDATIONS',
|
||||
@ -22,8 +21,7 @@ export const TASK_TYPE_CONFIG: Record<TaskType, { parallel: boolean; async: bool
|
||||
[TaskType.CLEANUP_DELETED_BOOKS]: {parallel: false, async: false, displayOrder: 4},
|
||||
[TaskType.CLEANUP_TEMP_METADATA]: {parallel: false, async: false, displayOrder: 5},
|
||||
[TaskType.REFRESH_METADATA_MANUAL]: {parallel: false, async: false, displayOrder: 6},
|
||||
[TaskType.CLEAR_CBX_CACHE]: {parallel: false, async: false, displayOrder: 7},
|
||||
[TaskType.CLEAR_PDF_CACHE]: {parallel: false, async: false, displayOrder: 8},
|
||||
[TaskType.CLEAR_PDF_CACHE]: {parallel: false, async: false, displayOrder: 7},
|
||||
};
|
||||
|
||||
export enum MetadataReplaceMode {
|
||||
|
||||
@ -149,7 +149,6 @@ export interface AppSettings {
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderDetails: OidcProviderDetails;
|
||||
oidcAutoProvisionDetails: OidcAutoProvisionDetails;
|
||||
cbxCacheSizeInMb: number;
|
||||
maxFileUploadSizeInMb: number;
|
||||
metadataProviderSettings: MetadataProviderSettings;
|
||||
metadataMatchWeights: MetadataMatchWeights;
|
||||
@ -171,7 +170,6 @@ export enum AppSettingKey {
|
||||
OIDC_ENABLED = 'OIDC_ENABLED',
|
||||
OIDC_PROVIDER_DETAILS = 'OIDC_PROVIDER_DETAILS',
|
||||
OIDC_AUTO_PROVISION_DETAILS = 'OIDC_AUTO_PROVISION_DETAILS',
|
||||
CBX_CACHE_SIZE_IN_MB = 'CBX_CACHE_SIZE_IN_MB',
|
||||
MAX_FILE_UPLOAD_SIZE_IN_MB = 'MAX_FILE_UPLOAD_SIZE_IN_MB',
|
||||
METADATA_PROVIDER_SETTINGS = 'METADATA_PROVIDER_SETTINGS',
|
||||
METADATA_MATCH_WEIGHTS = 'METADATA_MATCH_WEIGHTS',
|
||||
|
||||
@ -38,7 +38,6 @@ describe('AppSettingsService', () => {
|
||||
defaultPermissions: [],
|
||||
defaultLibraryIds: []
|
||||
},
|
||||
cbxCacheSizeInMb: 0,
|
||||
maxFileUploadSizeInMb: 0,
|
||||
metadataProviderSettings: {
|
||||
amazon: {enabled: false, cookie: '', domain: ''},
|
||||
@ -391,7 +390,6 @@ describe('AppSettingsService - API Contract Tests', () => {
|
||||
defaultPermissions: [],
|
||||
defaultLibraryIds: []
|
||||
},
|
||||
cbxCacheSizeInMb: 0,
|
||||
maxFileUploadSizeInMb: 0,
|
||||
metadataProviderSettings: {
|
||||
amazon: {enabled: false, cookie: '', domain: ''},
|
||||
|
||||
Reference in New Issue
Block a user