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:
ACX
2026-01-10 10:41:34 -07:00
committed by GitHub
parent af07110f4c
commit 4543381814
25 changed files with 550 additions and 779 deletions

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ out/
### VS Code ###
.vscode/
local/
local-scripts/
### Dev config, books, and data ###
booklore-ui/test-results/

View File

@ -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));
}

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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)),

View File

@ -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;

View File

@ -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,

View File

@ -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")));

View File

@ -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);
});
}
}

View File

@ -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,

View File

@ -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());
}
}

View File

@ -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) {

View File

@ -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()));
}
}
}
}

View File

@ -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);

View File

@ -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)

View File

@ -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");

View File

@ -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));
}
}

View File

@ -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"));

View File

@ -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>

View File

@ -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.');

View File

@ -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'

View 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');

View File

@ -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 {

View File

@ -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',

View File

@ -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: ''},