diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index f03a84477..b4fde70ff 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -56,6 +56,15 @@ jobs: restore-keys: | gradle-${{ runner.os }}- + # Needed for NightCompress + - name: Install libarchive + run: | + sudo apt-get install libarchive-dev --yes + + # Place libarchive somewhere that java looks for it. + sudo mkdir -p /usr/java/packages/lib/ + sudo cp /usr/lib/x86_64-linux-gnu/libarchive.* /usr/java/packages/lib/ + - name: Execute Backend Tests id: backend_tests run: | diff --git a/Dockerfile b/Dockerfile index 54faf1e0b..fabc93134 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,8 +35,6 @@ RUN set -eux; \ jar_path="$(find build/libs -maxdepth 1 -name '*.jar' ! -name '*plain.jar' | head -n 1)"; \ cp "$jar_path" /workspace/booklore-api/app.jar -FROM linuxserver/unrar:7.1.10 AS unrar-layer - FROM mwader/static-ffmpeg:8.1 AS ffprobe-layer FROM scratch AS kepubify-layer-amd64 @@ -70,13 +68,15 @@ ENV JAVA_TOOL_OPTIONS="-XX:+UseShenandoahGC \ -XX:InitialRAMPercentage=8.0 \ -XX:+ExitOnOutOfMemoryError" -RUN apk add --no-cache su-exec libstdc++ libgcc && \ +RUN apk add --no-cache su-exec libstdc++ libgcc libarchive && \ mkdir -p /bookdrop +# Manually link `libarchive.so.13` so java and other libraries can see it +RUN ln -s /usr/lib/libarchive.so.13 /usr/lib/libarchive.so + COPY packaging/docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh -COPY --from=unrar-layer /usr/bin/unrar-alpine /usr/local/bin/unrar COPY --from=ffprobe-layer /ffprobe /usr/local/bin/ffprobe COPY --from=kepubify-layer /kepubify /usr/local/bin/kepubify @@ -102,4 +102,4 @@ ARG BOOKLORE_PORT=6060 EXPOSE ${BOOKLORE_PORT} ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["java", "-jar", "/app/app.jar"] +CMD ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "/app/app.jar"] diff --git a/booklore-api/build.gradle.kts b/booklore-api/build.gradle.kts index 050932239..1db942cd7 100644 --- a/booklore-api/build.gradle.kts +++ b/booklore-api/build.gradle.kts @@ -86,8 +86,8 @@ dependencies { // --- Audio Metadata (Audiobook Support) --- implementation("com.github.RouHim:jaudiotagger:2.0.19") - // --- UNRAR Support --- - implementation("com.github.junrar:junrar:7.5.8") + // --- Archive Support --- + implementation("com.github.gotson.nightcompress:nightcompress:1.1.1") // --- JSON & Web Scraping --- implementation("org.jsoup:jsoup:1.22.1") @@ -139,7 +139,7 @@ hibernate { tasks.named("test") { useJUnitPlatform() - jvmArgs("-XX:+EnableDynamicAgentLoading") + jvmArgs("-XX:+EnableDynamicAgentLoading", "--enable-native-access=ALL-UNNAMED") finalizedBy(tasks.named("jacocoTestReport")) } diff --git a/booklore-api/src/main/java/org/booklore/service/ArchiveService.java b/booklore-api/src/main/java/org/booklore/service/ArchiveService.java new file mode 100644 index 000000000..8cf17992f --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/service/ArchiveService.java @@ -0,0 +1,124 @@ +package org.booklore.service; + +import com.github.gotson.nightcompress.Archive; +import com.github.gotson.nightcompress.ArchiveEntry; +import com.github.gotson.nightcompress.LibArchiveException; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +@Slf4j +@Service +public class ArchiveService { + @PostConstruct + public void checkArchiveAvailable() { + try { + // We want to check the version early because it allows for + // NightCompress to preload the libarchive library in a safe + // thread. Loading native libraries is not a thread safe operation. + if (!Archive.isAvailable()) { + log.warn("LibArchive is not available"); + } + } catch (Throwable e) { + log.error("LibArchive could not be loaded", e); + } + } + + public static boolean isAvailable() { + return Archive.isAvailable(); + } + + public static class Entry { + private Entry() {} + + @Getter + private String name; + + @Getter + private long size; + } + + private Entry getEntryFromArchiveEntry(ArchiveEntry archiveEntry) { + Entry entry = new Entry(); + + entry.name = archiveEntry.getName(); + entry.size = archiveEntry.getSize(); + + return entry; + } + + public List getEntries(Path path) throws IOException { + return streamEntries(path).toList(); + } + + public Stream streamEntries(Path path) throws IOException { + try { + return Archive.getEntries(path) + .stream() + .map(this::getEntryFromArchiveEntry); + } catch (LibArchiveException e) { + throw new IOException("Failed to read archive", e); + } + } + + public List getEntryNames(Path path) throws IOException { + return streamEntryNames(path).toList(); + } + + public Stream streamEntryNames(Path path) throws IOException { + try { + return Archive.getEntries(path) + .stream() + .map(ArchiveEntry::getName); + } catch (LibArchiveException e) { + throw new IOException("Failed to read archive", e); + } + } + + public long transferEntryTo(Path path, String entryName, OutputStream outputStream) throws IOException { + // We cannot directly use the NightCompress `InputStream` as it is limited + // in its implementation and will cause fatal errors. Instead, we can use + // the `transferTo` on an output stream to copy data around. + try (InputStream inputStream = Archive.getInputStream(path, entryName)) { + if (inputStream != null) { + return inputStream.transferTo(outputStream); + } + } catch (Exception e) { + throw new IOException("Failed to extract from archive: " + e.getMessage(), e); + } + + throw new IOException("Entry not found in archive"); + } + + public byte[] getEntryBytes(Path path, String entryName) throws IOException { + try ( + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ) { + transferEntryTo(path, entryName, outputStream); + + return outputStream.toByteArray(); + } + } + + public long extractEntryToPath(Path path, String entryName, Path outputPath) throws IOException { + try (InputStream inputStream = Archive.getInputStream(path, entryName)) { + if (inputStream != null) { + return Files.copy(inputStream, outputPath); + } + } catch (Exception e) { + throw new IOException("Failed to extract from archive: " + e.getMessage(), e); + } + + throw new IOException("Entry not found in archive"); + } +} diff --git a/booklore-api/src/main/java/org/booklore/service/fileprocessor/CbxProcessor.java b/booklore-api/src/main/java/org/booklore/service/fileprocessor/CbxProcessor.java index c3fad87f6..1a611cae2 100644 --- a/booklore-api/src/main/java/org/booklore/service/fileprocessor/CbxProcessor.java +++ b/booklore-api/src/main/java/org/booklore/service/fileprocessor/CbxProcessor.java @@ -11,6 +11,7 @@ import org.booklore.model.enums.BookFileType; import org.booklore.repository.BookAdditionalFileRepository; import org.booklore.repository.BookRepository; +import org.booklore.service.ArchiveService; import org.booklore.service.book.BookCreatorService; import org.booklore.service.metadata.MetadataMatchService; import org.booklore.service.metadata.extractor.CbxMetadataExtractor; @@ -19,14 +20,7 @@ import org.booklore.util.BookCoverUtils; import org.booklore.util.FileService; import org.booklore.util.FileUtils; -import org.booklore.util.UnrarHelper; -import com.github.junrar.Archive; -import com.github.junrar.rarfile.FileHeader; import lombok.extern.slf4j.Slf4j; -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.springframework.stereotype.Service; import java.awt.image.BufferedImage; @@ -34,7 +28,6 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.nio.file.Path; -import java.io.InputStream; import java.util.*; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -48,8 +41,8 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce private static final Pattern UNDERSCORE_HYPHEN_PATTERN = Pattern.compile("[_\\-]"); private static final Pattern IMAGE_EXTENSION_PATTERN = Pattern.compile(".*\\.(jpg|jpeg|png|webp)"); - private static final Pattern IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN = Pattern.compile("(?i).*\\.(jpg|jpeg|png|webp)"); private final CbxMetadataExtractor cbxMetadataExtractor; + private final ArchiveService archiveService; public CbxProcessor(BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, @@ -58,9 +51,11 @@ public CbxProcessor(BookRepository bookRepository, FileService fileService, MetadataMatchService metadataMatchService, SidecarMetadataWriter sidecarMetadataWriter, - CbxMetadataExtractor cbxMetadataExtractor) { + CbxMetadataExtractor cbxMetadataExtractor, + ArchiveService archiveService) { super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter); this.cbxMetadataExtractor = cbxMetadataExtractor; + this.archiveService = archiveService; } @Override @@ -89,9 +84,10 @@ public boolean generateCover(BookEntity bookEntity) { @Override public boolean generateCover(BookEntity bookEntity, BookFileEntity bookFile) { - File file = FileUtils.getBookFullPath(bookEntity, bookFile).toFile(); + Path bookPath = FileUtils.getBookFullPath(bookEntity, bookFile); + try { - Optional imageOptional = extractImagesFromArchive(file); + Optional imageOptional = extractImagesFromArchive(bookPath); if (imageOptional.isPresent()) { BufferedImage image = imageOptional.get(); try { @@ -118,135 +114,30 @@ public List getSupportedTypes() { return List.of(BookFileType.CBX); } - private Optional extractImagesFromArchive(File file) { - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(file); - - return switch (type) { - case ZIP -> extractFirstImageFromZip(file); - case SEVEN_ZIP -> extractFirstImageFrom7z(file); - case RAR -> extractFirstImageFromRar(file); - default -> Optional.empty(); - }; - } - - private Optional extractFirstImageFromZip(File file) { - // Fast path: Try reading from Central Directory - try (ZipFile zipFile = ZipFile.builder() - .setFile(file) - .setUseUnicodeExtraFields(true) - .setIgnoreLocalFileHeader(true) - .get()) { - Optional image = findAndReadFirstImage(zipFile); - if (image.isPresent()) return image; - } catch (Exception e) { - log.debug("Fast path failed for ZIP extraction: {}", e.getMessage()); - } + private Optional extractImagesFromArchive(Path path) { + List archiveEntries; - // Slow path: Fallback to scanning local file headers - try (ZipFile zipFile = ZipFile.builder() - .setFile(file) - .setUseUnicodeExtraFields(true) - .setIgnoreLocalFileHeader(false) - .get()) { - return findAndReadFirstImage(zipFile); + try { + archiveEntries = archiveService.streamEntryNames(path) + .filter(name -> IMAGE_EXTENSION_PATTERN.matcher(name.toLowerCase()).matches()) + .sorted() + .toList(); } catch (Exception e) { - log.error("Error extracting ZIP: {}", e.getMessage()); + log.warn("Error reading archive {}: {}", path.getFileName(), e.getMessage()); return Optional.empty(); } - } - private Optional findAndReadFirstImage(ZipFile zipFile) { - return Collections.list(zipFile.getEntries()).stream() - .filter(e -> !e.isDirectory() && IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN.matcher(e.getName()).matches()) - .min(Comparator.comparing(ZipArchiveEntry::getName)) - .map(entry -> { - try (InputStream is = zipFile.getInputStream(entry)) { - return FileService.readImage(is); - } catch (Exception e) { - log.warn("Failed to read image from ZIP entry {}: {}", entry.getName(), e.getMessage()); - return null; - } - }); - } - - private Optional extractFirstImageFrom7z(File file) { - try (SevenZFile sevenZFile = SevenZFile.builder().setFile(file).get()) { - List imageEntries = new ArrayList<>(); - SevenZArchiveEntry entry; - while ((entry = sevenZFile.getNextEntry()) != null) { - if (!entry.isDirectory() && IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN.matcher(entry.getName()).matches()) { - imageEntries.add(entry); - } - } - imageEntries.sort(Comparator.comparing(SevenZArchiveEntry::getName)); - - try (SevenZFile sevenZFileReset = SevenZFile.builder().setFile(file).get()) { - for (SevenZArchiveEntry imgEntry : imageEntries) { - SevenZArchiveEntry current; - while ((current = sevenZFileReset.getNextEntry()) != null) { - if (current.equals(imgEntry)) { - byte[] content = new byte[(int) current.getSize()]; - int offset = 0; - while (offset < content.length) { - int bytesRead = sevenZFileReset.read(content, offset, content.length - offset); - if (bytesRead < 0) break; - offset += bytesRead; - } - return Optional.ofNullable(FileService.readImage(new ByteArrayInputStream(content))); - } - } - } + for (String entryName : archiveEntries) { + try ( + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ) { + archiveService.transferEntryTo(path, entryName, outputStream); + return Optional.ofNullable(FileService.readImage(new ByteArrayInputStream(outputStream.toByteArray()))); + } catch (Exception e) { + log.warn("Error reading archive {} entry {}: {}", path.getFileName(), entryName, e.getMessage()); } - } catch (Exception e) { - log.error("Error extracting 7z: {}", e.getMessage()); } - return Optional.empty(); - } - private Optional extractFirstImageFromRar(File file) { - try (Archive archive = new Archive(file)) { - List imageHeaders = archive.getFileHeaders().stream() - .filter(h -> !h.isDirectory() && IMAGE_EXTENSION_PATTERN.matcher(h.getFileName().toLowerCase()).matches()) - .sorted(Comparator.comparing(FileHeader::getFileName)) - .toList(); - - for (FileHeader header : imageHeaders) { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - archive.extractFile(header, baos); - return Optional.ofNullable(FileService.readImage(new ByteArrayInputStream(baos.toByteArray()))); - } catch (Exception e) { - log.warn("Error reading RAR entry {}: {}", header.getFileName(), e.getMessage()); - } - } - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI: {}", file.getName(), e.getMessage()); - return extractFirstImageFromRarViaCli(file); - } - log.error("Error extracting RAR: {}", e.getMessage()); - } - return Optional.empty(); - } - - private Optional extractFirstImageFromRarViaCli(File file) { - try { - List entries = UnrarHelper.listEntries(file.toPath()); - List imageEntries = entries.stream() - .filter(name -> IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN.matcher(name).matches()) - .sorted() - .toList(); - for (String entry : imageEntries) { - try { - byte[] bytes = UnrarHelper.extractEntryBytes(file.toPath(), entry); - BufferedImage image = FileService.readImage(new ByteArrayInputStream(bytes)); - if (image != null) return Optional.of(image); - } catch (Exception ex) { - log.warn("Error reading RAR entry via CLI {}: {}", entry, ex.getMessage()); - } - } - } catch (Exception e) { - log.error("unrar CLI fallback also failed for {}: {}", file.getName(), e.getMessage()); - } return Optional.empty(); } @@ -260,7 +151,7 @@ private void extractAndSetMetadata(BookEntity bookEntity) { } BookMetadataEntity metadata = bookEntity.getMetadata(); - + // Basic fields metadata.setTitle(truncate(extracted.getTitle(), 1000)); metadata.setSubtitle(truncate(extracted.getSubtitle(), 1000)); @@ -272,11 +163,11 @@ private void extractAndSetMetadata(BookEntity bookEntity) { metadata.setSeriesTotal(extracted.getSeriesTotal()); metadata.setPageCount(extracted.getPageCount()); metadata.setLanguage(truncate(extracted.getLanguage(), 1000)); - + // ISBN fields metadata.setIsbn13(truncate(extracted.getIsbn13(), 64)); metadata.setIsbn10(truncate(extracted.getIsbn10(), 64)); - + // External IDs metadata.setAsin(truncate(extracted.getAsin(), 20)); metadata.setGoodreadsId(truncate(extracted.getGoodreadsId(), 100)); @@ -286,7 +177,7 @@ private void extractAndSetMetadata(BookEntity bookEntity) { metadata.setComicvineId(truncate(extracted.getComicvineId(), 100)); metadata.setLubimyczytacId(truncate(extracted.getLubimyczytacId(), 100)); metadata.setRanobedbId(truncate(extracted.getRanobedbId(), 100)); - + // Ratings metadata.setAmazonRating(extracted.getAmazonRating()); metadata.setAmazonReviewCount(extracted.getAmazonReviewCount()); @@ -301,7 +192,7 @@ private void extractAndSetMetadata(BookEntity bookEntity) { if (extracted.getAuthors() != null) { bookCreatorService.addAuthorsToBook(extracted.getAuthors(), bookEntity); } - + // Categories if (extracted.getCategories() != null) { Set validCategories = extracted.getCategories().stream() @@ -309,7 +200,7 @@ private void extractAndSetMetadata(BookEntity bookEntity) { .collect(Collectors.toSet()); bookCreatorService.addCategoriesToBook(validCategories, bookEntity); } - + // Moods if (extracted.getMoods() != null && !extracted.getMoods().isEmpty()) { Set validMoods = extracted.getMoods().stream() @@ -317,7 +208,7 @@ private void extractAndSetMetadata(BookEntity bookEntity) { .collect(Collectors.toSet()); bookCreatorService.addMoodsToBook(validMoods, bookEntity); } - + // Tags if (extracted.getTags() != null && !extracted.getTags().isEmpty()) { Set validTags = extracted.getTags().stream() diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/CbxConversionService.java b/booklore-api/src/main/java/org/booklore/service/kobo/CbxConversionService.java index c7a27cba4..d547063ce 100644 --- a/booklore-api/src/main/java/org/booklore/service/kobo/CbxConversionService.java +++ b/booklore-api/src/main/java/org/booklore/service/kobo/CbxConversionService.java @@ -4,23 +4,17 @@ import org.booklore.model.entity.BookEntity; import org.booklore.model.entity.CategoryEntity; import org.booklore.model.entity.TagEntity; +import org.booklore.service.ArchiveService; import org.booklore.util.ArchiveUtils; import org.booklore.util.FileService; -import org.booklore.util.UnrarHelper; -import com.github.junrar.Archive; -import com.github.junrar.exception.RarException; -import com.github.junrar.rarfile.FileHeader; import freemarker.cache.ClassTemplateLoader; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; import lombok.extern.slf4j.Slf4j; -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.ZipArchiveOutputStream; -import org.apache.commons.compress.archivers.zip.ZipFile; import org.springframework.stereotype.Service; import org.springframework.util.FileSystemUtils; @@ -75,9 +69,11 @@ public class CbxConversionService { private static final String EXTRACTED_IMAGES_SUBDIR = "cbx_extracted_images"; private final Configuration freemarkerConfig; + private final ArchiveService archiveService; - public CbxConversionService() { + public CbxConversionService(ArchiveService archiveService) { this.freemarkerConfig = initializeFreemarkerConfiguration(); + this.archiveService = archiveService; } public record EpubContentFileGroup(String contentKey, String imagePath, String htmlPath) { @@ -103,12 +99,11 @@ public record EpubContentFileGroup(String contentKey, String imagePath, String h * @return the converted EPUB file * @throws IOException if file I/O operations fail * @throws TemplateException if EPUB template processing fails - * @throws RarException if RAR extraction fails (for CBR files) * @throws IllegalArgumentException if the file format is not supported * @throws IllegalStateException if no valid images are found in the archive */ public File convertCbxToEpub(File cbxFile, File tempDir, BookEntity bookEntity, int compressionPercentage) - throws IOException, TemplateException, RarException { + throws IOException, TemplateException { validateInputs(cbxFile, tempDir); log.info("Starting CBX to EPUB conversion for: {}", cbxFile.getName()); @@ -121,7 +116,7 @@ public File convertCbxToEpub(File cbxFile, File tempDir, BookEntity bookEntity, } private File executeCbxConversion(File cbxFile, File tempDir, BookEntity bookEntity, int compressionPercentage) - throws IOException, TemplateException, RarException { + throws IOException, TemplateException { Path epubFilePath = Paths.get(tempDir.getAbsolutePath(), cbxFile.getName() + ".epub"); File epubFile = epubFilePath.toFile(); @@ -187,145 +182,28 @@ private Configuration initializeFreemarkerConfiguration() { return config; } - private List extractImagesFromCbx(File cbxFile, Path extractedImagesDir) throws IOException, RarException { - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(cbxFile); - - return switch (type) { - case ZIP -> extractImagesFromZip(cbxFile, extractedImagesDir); - case RAR -> extractImagesFromRar(cbxFile, extractedImagesDir); - case SEVEN_ZIP -> extractImagesFrom7z(cbxFile, extractedImagesDir); - default -> throw new IllegalArgumentException("Unsupported archive format: " + cbxFile.getName()); - }; - } - - private List extractImagesFromZip(File cbzFile, Path extractedImagesDir) throws IOException { - // Fast path: Try reading from Central Directory - try (ZipFile zipFile = ZipFile.builder() - .setFile(cbzFile) - .setUseUnicodeExtraFields(true) - .setIgnoreLocalFileHeader(true) - .get()) { - List paths = extractImagesFromZipFile(zipFile, extractedImagesDir); - if (!paths.isEmpty()) - return paths; - } catch (Exception e) { - log.debug("Fast path extraction failed for {}: {}", cbzFile.getName(), e.getMessage()); - } - - // Slow path: Fallback to scanning local file headers - try (ZipFile zipFile = ZipFile.builder() - .setFile(cbzFile) - .setUseUnicodeExtraFields(true) - .setIgnoreLocalFileHeader(false) - .get()) { - return extractImagesFromZipFile(zipFile, extractedImagesDir); - } - } - - private List extractImagesFromZipFile(ZipFile zipFile, Path extractedImagesDir) { + private List extractImagesFromCbx(File cbxFile, Path extractedImagesDir) throws IOException { List imagePaths = new ArrayList<>(); - for (ZipArchiveEntry entry : Collections.list(zipFile.getEntries())) { - if (entry.isDirectory() || !isImageFile(entry.getName())) { + + for (ArchiveService.Entry entry : archiveService.getEntries(cbxFile.toPath())) { + if (!isImageFile(entry.getName())) { continue; } + validateImageSize(entry.getName(), entry.getSize()); + try { - validateImageSize(entry.getName(), entry.getSize()); Path outputPath = extractedImagesDir.resolve(extractFileName(entry.getName())); - try (InputStream inputStream = zipFile.getInputStream(entry)) { - Files.copy(inputStream, outputPath); - imagePaths.add(outputPath); - } - } catch (Exception e) { - log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage()); - } - } - - log.debug("Found {} image entries in CBZ file", imagePaths.size()); - imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase())); - return imagePaths; - } - - private List extractImagesFromRar(File cbrFile, Path extractedImagesDir) throws IOException, RarException { - List imagePaths = new ArrayList<>(); - - try (Archive rarFile = new Archive(cbrFile)) { - for (FileHeader fileHeader : rarFile) { - if (fileHeader.isDirectory() || !isImageFile(fileHeader.getFileName())) { - continue; - } - - validateImageSize(fileHeader.getFileName(), fileHeader.getFullUnpackSize()); - - try (InputStream inputStream = rarFile.getInputStream(fileHeader)) { - Path outputPath = extractedImagesDir.resolve(extractFileName(fileHeader.getFileName())); - Files.copy(inputStream, outputPath); - imagePaths.add(outputPath); - } catch (Exception e) { - log.warn("Error extracting image {}: {}", fileHeader.getFileName(), e.getMessage()); - } - } - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI: {}", cbrFile.getName(), e.getMessage()); - return extractImagesFromRarViaCli(cbrFile, extractedImagesDir); - } - if (e instanceof IOException ioe) throw ioe; - if (e instanceof RarException re) throw re; - throw new IOException("Failed to read RAR archive: " + e.getMessage(), e); - } - log.debug("Found {} image entries in CBR file", imagePaths.size()); - imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase())); - return imagePaths; - } + archiveService.extractEntryToPath(cbxFile.toPath(), entry.getName(), outputPath); - private List extractImagesFromRarViaCli(File cbrFile, Path extractedImagesDir) throws IOException { - List imagePaths = new ArrayList<>(); - List entries = UnrarHelper.listEntries(cbrFile.toPath()); - for (String entryName : entries) { - if (!isImageFile(entryName)) continue; - try { - byte[] bytes = UnrarHelper.extractEntryBytes(cbrFile.toPath(), entryName); - validateImageSize(entryName, bytes.length); - Path outputPath = extractedImagesDir.resolve(extractFileName(entryName)); - Files.write(outputPath, bytes); imagePaths.add(outputPath); } catch (Exception e) { - log.warn("Error extracting image via CLI {}: {}", entryName, e.getMessage()); - } - } - log.debug("Found {} image entries in CBR file (via unrar CLI)", imagePaths.size()); - imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase())); - return imagePaths; - } - - private List extractImagesFrom7z(File cb7File, Path extractedImagesDir) throws IOException { - List imagePaths = new ArrayList<>(); - - try (SevenZFile sevenZFile = SevenZFile.builder().setFile(cb7File).get()) { - SevenZArchiveEntry entry; - while ((entry = sevenZFile.getNextEntry()) != null) { - if (entry.isDirectory() || !isImageFile(entry.getName())) { - continue; - } - - validateImageSize(entry.getName(), entry.getSize()); - - try { - Path outputPath = extractedImagesDir.resolve(extractFileName(entry.getName())); - try (InputStream entryInputStream = sevenZFile.getInputStream(entry); - OutputStream fileOutputStream = Files.newOutputStream(outputPath)) { - entryInputStream.transferTo(fileOutputStream); - } - imagePaths.add(outputPath); - } catch (Exception e) { - log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage()); - } + log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage()); } } - log.debug("Found {} image entries in CB7 file", imagePaths.size()); + log.debug("Found {} image entries in CBR file", imagePaths.size()); imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase())); return imagePaths; } diff --git a/booklore-api/src/main/java/org/booklore/service/metadata/extractor/CbxMetadataExtractor.java b/booklore-api/src/main/java/org/booklore/service/metadata/extractor/CbxMetadataExtractor.java index 595ea56f3..39a57d84c 100644 --- a/booklore-api/src/main/java/org/booklore/service/metadata/extractor/CbxMetadataExtractor.java +++ b/booklore-api/src/main/java/org/booklore/service/metadata/extractor/CbxMetadataExtractor.java @@ -1,25 +1,18 @@ package org.booklore.service.metadata.extractor; -import com.github.junrar.Archive; -import com.github.junrar.rarfile.FileHeader; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; -import org.apache.commons.compress.archivers.sevenz.SevenZFile; import org.apache.commons.io.FilenameUtils; import org.booklore.model.dto.BookMetadata; import org.booklore.model.dto.ComicMetadata; -import org.booklore.util.ArchiveUtils; -import org.booklore.util.UnrarHelper; +import org.booklore.service.ArchiveService; import org.springframework.stereotype.Component; import org.w3c.dom.Document; import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; import javax.imageio.ImageIO; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; import java.awt.*; import java.awt.image.BufferedImage; import java.io.*; @@ -27,10 +20,10 @@ import java.time.LocalDate; import java.util.*; import java.util.List; +import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; +import java.util.stream.Stream; @Slf4j @Component @@ -47,93 +40,32 @@ public class CbxMetadataExtractor implements FileMetadataExtractor { private static final Pattern COMICVINE_URL_PATTERN = Pattern.compile("comicvine\\.gamespot\\.com/issue/(?:[^/]+/)?([\\w-]+)"); private static final Pattern HARDCOVER_URL_PATTERN = Pattern.compile("hardcover\\.app/books/([\\w-]+)"); + private final ArchiveService archiveService; + + public CbxMetadataExtractor(ArchiveService archiveService) { + this.archiveService = archiveService; + } + @Override public BookMetadata extractMetadata(File file) { - String baseName = FilenameUtils.getBaseName(file.getName()); - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(file); - - // CBZ path (ZIP) - if (type == ArchiveUtils.ArchiveType.ZIP) { - try (ZipFile zipFile = new ZipFile(file)) { - ZipEntry entry = findComicInfoEntry(zipFile); - if (entry == null) { - return BookMetadata.builder().title(baseName).build(); - } - try (InputStream is = zipFile.getInputStream(entry)) { - Document document = buildSecureDocument(is); - return mapDocumentToMetadata(document, baseName); - } - } catch (Exception e) { - log.warn("Failed to extract metadata from CBZ", e); - return BookMetadata.builder().title(baseName).build(); - } - } + return extractMetadata(file.toPath()); + } - // CB7 path (7z) - if (type == ArchiveUtils.ArchiveType.SEVEN_ZIP) { - try (SevenZFile sevenZ = SevenZFile.builder().setFile(file).get()) { - SevenZArchiveEntry entry = findSevenZComicInfoEntry(sevenZ); - if (entry == null) { - return BookMetadata.builder().title(baseName).build(); - } - byte[] xmlBytes = readSevenZEntryBytes(sevenZ, entry); - if (xmlBytes == null) { - return BookMetadata.builder().title(baseName).build(); - } - try (InputStream is = new ByteArrayInputStream(xmlBytes)) { - Document document = buildSecureDocument(is); - return mapDocumentToMetadata(document, baseName); - } - } catch (Exception e) { - log.warn("Failed to extract metadata from CB7", e); - return BookMetadata.builder().title(baseName).build(); - } - } + public BookMetadata extractMetadata(Path path) { + String baseName = FilenameUtils.getBaseName(path.toString()); - // CBR path (RAR) - if (type == ArchiveUtils.ArchiveType.RAR) { - try (Archive archive = new Archive(file)) { - try { - FileHeader header = findComicInfoHeader(archive); - if (header == null) { - return BookMetadata.builder().title(baseName).build(); - } - byte[] xmlBytes = readRarEntryBytes(archive, header); - if (xmlBytes == null) { - return BookMetadata.builder().title(baseName).build(); - } - try (InputStream is = new ByteArrayInputStream(xmlBytes)) { - Document document = buildSecureDocument(is); - return mapDocumentToMetadata(document, baseName); - } - } catch (Exception e) { - log.warn("Failed to extract metadata from CBR", e); - return BookMetadata.builder().title(baseName).build(); - } - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI: {}", file.getName(), e.getMessage()); - try { - return extractMetadataFromRarViaCli(file.toPath(), baseName); - } catch (Exception ex) { - log.warn("unrar CLI fallback also failed for {}", file.getName(), ex); - } - } + try (InputStream is = findComicInfoEntryInputStream(path)) { + if (is != null) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } else { + log.warn("No metadata existed in CBR"); } + } catch (Exception e) { + log.warn("Failed to extract metadata from CBR", e); } - return BookMetadata.builder().title(baseName).build(); - } - private ZipEntry findComicInfoEntry(ZipFile zipFile) { - Enumeration entries = zipFile.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - String name = entry.getName(); - if (isComicInfoName(name)) { - return entry; - } - } - return null; + return BookMetadata.builder().title(baseName).build(); } /** @@ -566,159 +498,20 @@ private LocalDate parseDate(String year, String month, String day) { @Override public byte[] extractCover(File file) { - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(file); - - // CBZ path - if (type == ArchiveUtils.ArchiveType.ZIP) { - try (ZipFile zipFile = new ZipFile(file)) { - // Try front cover via ComicInfo - ZipEntry coverEntry = findFrontCoverEntry(zipFile); - if (coverEntry != null) { - try (InputStream is = zipFile.getInputStream(coverEntry)) { - byte[] bytes = is.readAllBytes(); - if (canDecode(bytes)) return bytes; - } - } - // Fallback: iterate images alphabetically until a decodable one is found - ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile); - if (firstImage != null) { - // Build a sorted list and iterate for decodable formats - java.util.List images = listZipImageEntries(zipFile); - for (ZipEntry e : images) { - try (InputStream is = zipFile.getInputStream(e)) { - byte[] bytes = is.readAllBytes(); - if (canDecode(bytes)) return bytes; - } - } - } - } catch (Exception e) { - log.warn("Failed to extract cover image from CBZ", e); - return generatePlaceholderCover(250, 350); - } - } - - // CB7 path - if (type == ArchiveUtils.ArchiveType.SEVEN_ZIP) { - try (SevenZFile sevenZ = SevenZFile.builder().setFile(file).get()) { - // Try via ComicInfo.xml first - SevenZArchiveEntry ci = findSevenZComicInfoEntry(sevenZ); - if (ci != null) { - byte[] xmlBytes = readSevenZEntryBytes(sevenZ, ci); - if (xmlBytes != null) { - try (InputStream is = new ByteArrayInputStream(xmlBytes)) { - Document document = buildSecureDocument(is); - String imageName = findFrontCoverImageName(document); - if (imageName != null) { - SevenZArchiveEntry byName = findSevenZEntryByName(sevenZ, imageName); - if (byName != null) { - byte[] bytes = readSevenZEntryBytes(sevenZ, byName); - if (canDecode(bytes)) return bytes; - } - try { - int index = Integer.parseInt(imageName); - SevenZArchiveEntry byIndex = findSevenZImageEntryByIndex(sevenZ, index); - if (byIndex != null) { - byte[] bytes = readSevenZEntryBytes(sevenZ, byIndex); - if (canDecode(bytes)) return bytes; - } - if (index > 0) { - SevenZArchiveEntry offByOne = findSevenZImageEntryByIndex(sevenZ, index - 1); - if (offByOne != null) { - byte[] bytes = readSevenZEntryBytes(sevenZ, offByOne); - if (canDecode(bytes)) return bytes; - } - } - } catch (NumberFormatException ignore) { - // continue to fallback - } - } - } - } - } - - // Fallback: iterate images alphabetically until a decodable one is found - SevenZArchiveEntry first = findFirstAlphabeticalSevenZImageEntry(sevenZ); - if (first != null) { - java.util.List images = listSevenZImageEntries(sevenZ); - for (SevenZArchiveEntry e : images) { - byte[] bytes = readSevenZEntryBytes(sevenZ, e); - if (canDecode(bytes)) return bytes; - } - } - } catch (Exception e) { - log.warn("Failed to extract cover image from CB7", e); - return generatePlaceholderCover(250, 350); - } - } - - // CBR path - if (type == ArchiveUtils.ArchiveType.RAR) { - try (Archive archive = new Archive(file)) { - try { - - // Try via ComicInfo.xml first - FileHeader comicInfo = findComicInfoHeader(archive); - if (comicInfo != null) { - byte[] xmlBytes = readRarEntryBytes(archive, comicInfo); - if (xmlBytes != null) { - try (InputStream is = new ByteArrayInputStream(xmlBytes)) { - Document document = buildSecureDocument(is); - String imageName = findFrontCoverImageName(document); - if (imageName != null) { - FileHeader byName = findRarHeaderByName(archive, imageName); - if (byName != null) { - byte[] bytes = readRarEntryBytes(archive, byName); - if (canDecode(bytes)) return bytes; - } - try { - int index = Integer.parseInt(imageName); - FileHeader byIndex = findRarImageHeaderByIndex(archive, index); - if (byIndex != null) { - byte[] bytes = readRarEntryBytes(archive, byIndex); - if (canDecode(bytes)) return bytes; - } - if (index > 0) { - FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1); - if (offByOne != null) { - byte[] bytes = readRarEntryBytes(archive, offByOne); - if (canDecode(bytes)) return bytes; - } - } - } catch (NumberFormatException ignore) { - // ignore and continue fallback - } - } - } - } - } - - // Fallback: iterate images alphabetically until a decodable one is found - FileHeader firstImage = findFirstAlphabeticalImageHeader(archive); - if (firstImage != null) { - List images = listRarImageHeaders(archive); - for (FileHeader fh : images) { - byte[] bytes = readRarEntryBytes(archive, fh); - if (canDecode(bytes)) return bytes; - } - } - } catch (Exception e) { - log.warn("Failed to extract cover image from CBR", e); - return generatePlaceholderCover(250, 350); - } - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI for cover: {}", file.getName(), e.getMessage()); - try { - byte[] coverBytes = extractCoverFromRarViaCli(file.toPath()); - if (coverBytes != null) return coverBytes; - } catch (Exception ex) { - log.warn("unrar CLI cover fallback also failed for {}", file.getName(), ex); - } - } - } - } + return extractCover(file.toPath()); + } - return generatePlaceholderCover(250, 350); + public byte[] extractCover(Path path) { + return Stream.>>of( + () -> extractCoverEntryNameFromComicInfo(path), + () -> extractCoverEntryNameFallback(path) + ) + .flatMap(Supplier::get) + .map(coverEntry -> readArchiveEntryBytes(path, coverEntry)) + .filter(Objects::nonNull) + .filter(this::canDecode) + .findFirst() + .orElseGet(() -> generatePlaceholderCover(250, 350)); } private boolean canDecode(byte[] bytes) { @@ -731,86 +524,73 @@ private boolean canDecode(byte[] bytes) { } } - private ZipEntry findFrontCoverEntry(ZipFile zipFile) { - ZipEntry comicInfoEntry = findComicInfoEntry(zipFile); - if (comicInfoEntry != null) { - try (InputStream is = zipFile.getInputStream(comicInfoEntry)) { - Document document = buildSecureDocument(is); - String imageName = findFrontCoverImageName(document); - if (imageName != null) { - ZipEntry byName = zipFile.getEntry(imageName); - if (byName != null) { - return byName; - } - // also try base-name match for archives with directories or odd encodings - String imageBase = baseName(imageName); - java.util.Enumeration it = zipFile.entries(); - while (it.hasMoreElements()) { - ZipEntry e = it.nextElement(); - if (!e.isDirectory() && isImageEntry(e.getName())) { - if (baseName(e.getName()).equalsIgnoreCase(imageBase)) { - return e; - } - } - } - try { - int index = Integer.parseInt(imageName); - ZipEntry byIndex = findImageEntryByIndex(zipFile, index); - if (byIndex != null) { - return byIndex; - } - if (index > 0) { - ZipEntry offByOne = findImageEntryByIndex(zipFile, index - 1); - if (offByOne != null) return offByOne; - } - } catch (NumberFormatException ignore) { - // ignore - } - } - } catch (Exception e) { - log.warn("Failed to parse ComicInfo.xml for cover", e); - } - } - // Heuristic filenames before generic fallback - ZipEntry heuristic = findHeuristicCover(zipFile); - if (heuristic != null) return heuristic; - return findFirstAlphabeticalImageEntry(zipFile); - } + private Stream extractCoverEntryNameFromComicInfo(Path cbxPath) { + Set possibleCoverImages = new LinkedHashSet<>(); - private ZipEntry findImageEntryByIndex(ZipFile zipFile, int index) { - Enumeration entries = zipFile.entries(); - int count = 0; - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - if (!entry.isDirectory() && isImageEntry(entry.getName())) { - if (count == index) { - return entry; - } - count++; + List entryNames = getComicImageEntryNames(cbxPath).toList(); + + try (InputStream is = findComicInfoEntryInputStream(cbxPath)) { + if (is == null) { + return Stream.empty(); } - } - return null; - } - private String findFrontCoverImageName(Document document) { - NodeList pages = document.getElementsByTagName("Page"); - for (int i = 0; i < pages.getLength(); i++) { - org.w3c.dom.Node node = pages.item(i); - if (node instanceof org.w3c.dom.Element page) { - String type = page.getAttribute("Type"); - if (type != null && "FrontCover".equalsIgnoreCase(type)) { + Document document = buildSecureDocument(is); + + NodeList pages = document.getElementsByTagName("Page"); + for (int i = 0; i < pages.getLength(); i++) { + try { + org.w3c.dom.Node node = pages.item(i); + if (!(node instanceof org.w3c.dom.Element page)) { + continue; + } + + if (!"FrontCover".equalsIgnoreCase(page.getAttribute("Type"))) { + continue; + } + + // The `ImageFile` is an entry name to read. String imageFile = page.getAttribute("ImageFile"); if (imageFile != null && !imageFile.isBlank()) { - return imageFile.trim(); + possibleCoverImages.add(imageFile.trim()); } + + // The `Image` attribute is an index of the pages in the CBZ to read. String image = page.getAttribute("Image"); if (image != null && !image.isBlank()) { - return image.trim(); + int index = Integer.parseInt(image.trim()); + if (entryNames.size() > index) { + possibleCoverImages.add(entryNames.get(index)); + } else if (index > 0 && entryNames.size() > index - 1) { + // It's possible there's an off-by-one error in some cases. + possibleCoverImages.add(entryNames.get(index - 1)); + } } + } catch (Exception e) { + // Do nothing } } + + } catch (Exception e) { + log.warn("Failed to read ComicInfo.xml from archive {}: {}", cbxPath, e.getMessage()); + } + + return possibleCoverImages.stream(); + } + + private Stream extractCoverEntryNameFallback(Path cbxPath) { + return getComicImageEntryNames(cbxPath) + .sorted((a, b) -> Boolean.compare(likelyCoverName(baseName(b)), likelyCoverName(baseName(a)))); + } + + private Stream getComicImageEntryNames(Path cbxPath) { + try { + return archiveService.streamEntryNames(cbxPath) + .filter(this::isImageEntry) + .sorted(this::naturalCompare); + } catch (Exception e) { + log.warn("Failed to extract cover image from archive {}", cbxPath.getFileName(), e); + return Stream.empty(); } - return null; } private boolean isImageEntry(String name) { @@ -867,179 +647,49 @@ private byte[] generatePlaceholderCover(int width, int height) { } } - - // ==== RAR (.cbr) helpers ==== - private FileHeader findComicInfoHeader(Archive archive) { - if (archive == null) return null; - for (FileHeader fh : archive.getFileHeaders()) { - String name = fh.getFileName(); - if (name == null) continue; - if (isComicInfoName(name)) { - return fh; - } - } - return null; - } - - private FileHeader findRarHeaderByName(Archive archive, String imageName) { - if (archive == null || imageName == null) return null; - for (FileHeader fh : archive.getFileHeaders()) { - String name = fh.getFileName(); - if (name == null) continue; - if (name.equalsIgnoreCase(imageName)) return fh; - // also try base-name match to be lenient - if (baseName(name).equalsIgnoreCase(baseName(imageName))) return fh; - } - return null; - } - - private FileHeader findRarImageHeaderByIndex(Archive archive, int index) { - int count = 0; - for (FileHeader fh : archive.getFileHeaders()) { - if (!fh.isDirectory() && isImageEntry(fh.getFileName())) { - if (count == index) return fh; - count++; - } - } - return null; - } - - - - private byte[] readRarEntryBytes(Archive archive, FileHeader header) { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - archive.extractFile(header, baos); - return baos.toByteArray(); + private String findComicInfoEntry(Path cbxPath) { + try { + return archiveService.streamEntryNames(cbxPath) + .filter(CbxMetadataExtractor::isComicInfoName) + .findFirst() + .orElse(null); } catch (Exception e) { - log.warn("Failed to read RAR entry bytes for {}", header != null ? header.getFileName() : "", e); + log.warn("Could not find comic info entry for archive {}", cbxPath.getFileName()); return null; } } - private String baseName(String path) { - if (path == null) return null; - int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); - return slash >= 0 ? path.substring(slash + 1) : path; - } + private InputStream findComicInfoEntryInputStream(Path cbxPath) { + String comicInfoEntry = findComicInfoEntry(cbxPath); - private FileHeader findFirstAlphabeticalImageHeader(Archive archive) { - if (archive == null) return null; - List images = new ArrayList<>(); - for (FileHeader fh : archive.getFileHeaders()) { - if (fh == null || fh.isDirectory()) continue; - String name = fh.getFileName(); - if (name == null) continue; - if (isImageEntry(name)) { - images.add(fh); - } - } - if (images.isEmpty()) return null; - images.sort((a, b) -> naturalCompare(a.getFileName(), b.getFileName())); - return images.getFirst(); - } - - private ZipEntry findFirstAlphabeticalImageEntry(ZipFile zipFile) { - List images = new ArrayList<>(); - Enumeration entries = zipFile.entries(); - while (entries.hasMoreElements()) { - ZipEntry e = entries.nextElement(); - if (!e.isDirectory() && isImageEntry(e.getName())) { - images.add(e); - } - } - if (images.isEmpty()) return null; - images.sort((a, b) -> naturalCompare(a.getName(), b.getName())); - return images.getFirst(); - } - - // ==== 7z (.cb7) helpers ==== - private SevenZArchiveEntry findSevenZComicInfoEntry(SevenZFile sevenZ) { - for (SevenZArchiveEntry e : sevenZ.getEntries()) { - if (e == null || e.isDirectory()) continue; - String name = e.getName(); - if (name != null && isComicInfoName(name)) { - return e; - } - } - return null; - } - - private SevenZArchiveEntry findSevenZEntryByName(SevenZFile sevenZ, String imageName) { - if (imageName == null) return null; - for (SevenZArchiveEntry e : sevenZ.getEntries()) { - if (e == null || e.isDirectory()) continue; - String name = e.getName(); - if (name == null) continue; - if (name.equalsIgnoreCase(imageName)) return e; - // also allow base-name match - if (baseName(name).equalsIgnoreCase(baseName(imageName))) return e; + if (comicInfoEntry == null) { + // If we can't find a comic info entry, give up. + return null; } - return null; - } - private SevenZArchiveEntry findSevenZImageEntryByIndex(SevenZFile sevenZ, int index) { - int count = 0; - for (SevenZArchiveEntry e : sevenZ.getEntries()) { - if (!e.isDirectory() && isImageEntry(e.getName())) { - if (count == index) return e; - count++; - } - } - return null; - } + byte[] xmlBytes = readArchiveEntryBytes(cbxPath, comicInfoEntry); - private SevenZArchiveEntry findFirstAlphabeticalSevenZImageEntry(SevenZFile sevenZ) { - List images = new ArrayList<>(); - for (SevenZArchiveEntry e : sevenZ.getEntries()) { - if (!e.isDirectory() && isImageEntry(e.getName())) { - images.add(e); - } + if (xmlBytes == null) { + return null; } - if (images.isEmpty()) return null; - images.sort((a, b) -> naturalCompare(a.getName(), b.getName())); - return images.getFirst(); - } - private byte[] readSevenZEntryBytes(SevenZFile sevenZ, SevenZArchiveEntry entry) throws IOException { - try (InputStream is = sevenZ.getInputStream(entry); - ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - if (is == null) return null; - is.transferTo(baos); - return baos.toByteArray(); - } + return new ByteArrayInputStream(xmlBytes); } - private java.util.List listZipImageEntries(ZipFile zipFile) { - java.util.List images = new java.util.ArrayList<>(); - java.util.Enumeration en = zipFile.entries(); - while (en.hasMoreElements()) { - ZipEntry e = en.nextElement(); - if (!e.isDirectory() && isImageEntry(e.getName())) images.add(e); + private byte[] readArchiveEntryBytes(Path cbxPath, String entryName) { + try { + return archiveService.getEntryBytes(cbxPath, entryName); + } catch (Exception e) { + log.warn("Failed to read archive {} entry bytes for {}", cbxPath.getFileName(), entryName, e); } - images.sort((a, b) -> naturalCompare(a.getName(), b.getName())); - // Heuristic preferred names first - images.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a.getName())), !likelyCoverName(baseName(b.getName())))); - return images; - } - private java.util.List listSevenZImageEntries(SevenZFile sevenZ) { - java.util.List images = new java.util.ArrayList<>(); - for (SevenZArchiveEntry e : sevenZ.getEntries()) { - if (!e.isDirectory() && isImageEntry(e.getName())) images.add(e); - } - images.sort((a, b) -> naturalCompare(a.getName(), b.getName())); - images.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a.getName())), !likelyCoverName(baseName(b.getName())))); - return images; + return null; } - private java.util.List listRarImageHeaders(Archive archive) { - java.util.List images = new java.util.ArrayList<>(); - for (FileHeader fh : archive.getFileHeaders()) { - if (fh != null && !fh.isDirectory() && isImageEntry(fh.getFileName())) images.add(fh); - } - images.sort((a, b) -> naturalCompare(a.getFileName(), b.getFileName())); - images.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a.getFileName())), !likelyCoverName(baseName(b.getFileName())))); - return images; + private String baseName(String path) { + if (path == null) return null; + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash >= 0 ? path.substring(slash + 1) : path; } private boolean likelyCoverName(String base) { @@ -1077,14 +727,6 @@ private int naturalCompare(String a, String b) { return Integer.compare(n1 - i, n2 - j); } - private ZipEntry findHeuristicCover(ZipFile zipFile) { - java.util.List images = listZipImageEntries(zipFile); - for (ZipEntry e : images) { - if (likelyCoverName(baseName(e.getName()))) return e; - } - return null; - } - private static boolean isComicInfoName(String name) { if (name == null) return false; String n = name.replace('\\', '/'); @@ -1092,63 +734,4 @@ private static boolean isComicInfoName(String name) { String lower = n.toLowerCase(); return "comicinfo.xml".equals(lower) || lower.endsWith("/comicinfo.xml"); } - - private BookMetadata extractMetadataFromRarViaCli(Path rarPath, String baseName) throws Exception { - List entries = UnrarHelper.listEntries(rarPath); - String comicInfoEntry = entries.stream() - .filter(CbxMetadataExtractor::isComicInfoName) - .findFirst() - .orElse(null); - if (comicInfoEntry == null) { - return BookMetadata.builder().title(baseName).build(); - } - byte[] xmlBytes = UnrarHelper.extractEntryBytes(rarPath, comicInfoEntry); - if (xmlBytes == null || xmlBytes.length == 0) { - return BookMetadata.builder().title(baseName).build(); - } - try (InputStream is = new ByteArrayInputStream(xmlBytes)) { - Document document = buildSecureDocument(is); - return mapDocumentToMetadata(document, baseName); - } - } - - private byte[] extractCoverFromRarViaCli(Path rarPath) throws Exception { - List entries = UnrarHelper.listEntries(rarPath); - - String comicInfoEntry = entries.stream() - .filter(CbxMetadataExtractor::isComicInfoName) - .findFirst() - .orElse(null); - if (comicInfoEntry != null) { - byte[] xmlBytes = UnrarHelper.extractEntryBytes(rarPath, comicInfoEntry); - if (xmlBytes != null && xmlBytes.length > 0) { - try (InputStream is = new ByteArrayInputStream(xmlBytes)) { - Document document = buildSecureDocument(is); - String imageName = findFrontCoverImageName(document); - if (imageName != null) { - String match = entries.stream() - .filter(e -> e.equalsIgnoreCase(imageName) || baseName(e).equalsIgnoreCase(baseName(imageName))) - .findFirst() - .orElse(null); - if (match != null) { - byte[] bytes = UnrarHelper.extractEntryBytes(rarPath, match); - if (canDecode(bytes)) return bytes; - } - } - } - } - } - - List imageEntries = entries.stream() - .filter(this::isImageEntry) - .sorted(this::naturalCompare) - .toList(); - List sorted = new ArrayList<>(imageEntries); - sorted.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a)), !likelyCoverName(baseName(b)))); - for (String entry : sorted) { - byte[] bytes = UnrarHelper.extractEntryBytes(rarPath, entry); - if (canDecode(bytes)) return bytes; - } - return null; - } } diff --git a/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java b/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java index 15db26dce..a437147f0 100644 --- a/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java +++ b/booklore-api/src/main/java/org/booklore/service/metadata/writer/CbxMetadataWriter.java @@ -1,28 +1,22 @@ package org.booklore.service.metadata.writer; -import com.github.junrar.Archive; -import com.github.junrar.rarfile.FileHeader; -import jakarta.xml.bind.JAXBContext; -import jakarta.xml.bind.Marshaller; -import jakarta.xml.bind.Unmarshaller; -import jakarta.xml.bind.ValidationEvent; +import jakarta.xml.bind.*; + import javax.xml.XMLConstants; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParserFactory; import javax.xml.transform.sax.SAXSource; -import org.xml.sax.InputSource; -import org.xml.sax.XMLReader; + +import org.booklore.service.ArchiveService; +import org.xml.sax.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; -import org.apache.commons.compress.archivers.sevenz.SevenZFile; import org.booklore.model.MetadataClearFlags; import org.booklore.model.dto.settings.MetadataPersistenceSettings; import org.booklore.model.entity.*; import org.booklore.model.enums.BookFileType; import org.booklore.model.enums.ComicCreatorRole; import org.booklore.service.appsettings.AppSettingService; -import org.booklore.util.ArchiveUtils; -import org.booklore.util.UnrarHelper; import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; import org.springframework.stereotype.Component; @@ -31,24 +25,19 @@ 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.Enumeration; import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; @Slf4j @Component @RequiredArgsConstructor public class CbxMetadataWriter implements MetadataWriter { - - private static final Pattern VALID_FILENAME_PATTERN = Pattern.compile("^[\\w./\\\\-]+$"); - private static final int BUFFER_SIZE = 8192; + private static final String DEFAULT_COMICINFO_XML = "ComicInfo.xml"; // Cache JAXBContext for performance private static final JAXBContext JAXB_CONTEXT; @@ -62,6 +51,7 @@ public class CbxMetadataWriter implements MetadataWriter { } private final AppSettingService appSettingService; + private final ArchiveService archiveService; @Override public void saveMetadataToFile(File file, BookMetadataEntity metadata, String thumbnailUrl, MetadataClearFlags clearFlags) { @@ -69,42 +59,20 @@ public void saveMetadataToFile(File file, BookMetadataEntity metadata, String th return; } - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(file); - boolean isCbz = type == ArchiveUtils.ArchiveType.ZIP; - boolean isCbr = type == ArchiveUtils.ArchiveType.RAR; - boolean isCb7 = type == ArchiveUtils.ArchiveType.SEVEN_ZIP; - - if (type == ArchiveUtils.ArchiveType.UNKNOWN) { - log.warn("Unknown archive type for file: {}", file.getName()); - return; - } - Path backupPath = createBackupFile(file); Path extractDir = null; Path tempArchive = null; boolean writeSucceeded = false; try { - ComicInfo comicInfo = loadOrCreateComicInfo(file, isCbz, isCb7, isCbr); + ComicInfo comicInfo = loadOrCreateComicInfo(file.toPath()); applyMetadataChanges(comicInfo, metadata, clearFlags); byte[] xmlContent = convertToBytes(comicInfo); - if (isCbz) { - log.debug("CbxMetadataWriter: Writing ComicInfo.xml to CBZ file: {}, XML size: {} bytes", file.getName(), xmlContent.length); - tempArchive = updateZipArchive(file, xmlContent); - writeSucceeded = true; - log.info("CbxMetadataWriter: Successfully wrote metadata to CBZ file: {}", file.getName()); - } else if (isCb7) { - log.debug("CbxMetadataWriter: Converting CB7 to CBZ and writing ComicInfo.xml: {}", file.getName()); - tempArchive = convert7zToZip(file, xmlContent); - writeSucceeded = true; - log.info("CbxMetadataWriter: Successfully converted CB7 to CBZ and wrote metadata: {}", file.getName()); - } else { - log.debug("CbxMetadataWriter: Writing ComicInfo.xml to RAR file: {}", file.getName()); - tempArchive = updateRarArchive(file, xmlContent, extractDir); - writeSucceeded = true; - log.info("CbxMetadataWriter: Successfully wrote metadata to RAR/CBZ file: {}", file.getName()); - } + log.debug("CbxMetadataWriter: Writing ComicInfo.xml to CBZ file: {}, XML size: {} bytes", file.getName(), xmlContent.length); + tempArchive = updateArchive(file, xmlContent); + writeSucceeded = true; + log.info("CbxMetadataWriter: Successfully wrote metadata to CBZ file: {}", file.getName()); } catch (Exception e) { restoreOriginalFile(backupPath, file); log.warn("Failed to write metadata for {}: {}", file.getName(), e.getMessage(), e); @@ -142,85 +110,43 @@ private Path createBackupFile(File file) { } } - private ComicInfo loadOrCreateComicInfo(File file, boolean isCbz, boolean isCb7, boolean isCbr) throws Exception { - if (isCbz) { - return loadFromZip(file); - } else if (isCb7) { - return loadFrom7z(file); - } else { - return loadFromRar(file); - } - } + private ComicInfo loadOrCreateComicInfo(Path path) { + String comicInfoEntry = findComicInfoEntryName(path); - private ComicInfo loadFromZip(File file) throws Exception { - try (ZipFile zipFile = new ZipFile(file)) { - ZipEntry xmlEntry = findComicInfoEntry(zipFile); - if (xmlEntry != null) { - try (InputStream stream = zipFile.getInputStream(xmlEntry)) { - return parseComicInfo(stream); - } - } + if (comicInfoEntry == null) { + // If we can't find a comicInfo entry, bail out. return new ComicInfo(); } - } - private ComicInfo loadFrom7z(File file) throws Exception { - try (SevenZFile archive = SevenZFile.builder().setFile(file).get()) { - SevenZArchiveEntry xmlEntry = findComicInfoIn7z(archive); - if (xmlEntry != null) { - try (InputStream stream = archive.getInputStream(xmlEntry)) { - return parseComicInfo(stream); - } - } + byte[] comicInfoXML; + try { + comicInfoXML = archiveService.getEntryBytes(path, comicInfoEntry); + } catch (Exception e) { + log.warn("Could not read archive {}: {}", path, e.getMessage()); return new ComicInfo(); } - } - - private SevenZArchiveEntry findComicInfoIn7z(SevenZFile archive) { - for (SevenZArchiveEntry entry : archive.getEntries()) { - if (entry != null && !entry.isDirectory() && isComicInfoXml(entry.getName())) { - return entry; - } - } - return null; - } - private ComicInfo loadFromRar(File file) throws Exception { - try (Archive archive = new Archive(file)) { - FileHeader xmlHeader = findComicInfoInRar(archive); - if (xmlHeader != null) { - try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - extractRarEntry(archive, xmlHeader, buffer); - try (InputStream stream = new ByteArrayInputStream(buffer.toByteArray())) { - return parseComicInfo(stream); - } - } - } - return new ComicInfo(); + try ( + ByteArrayInputStream bais = new ByteArrayInputStream(comicInfoXML) + ) { + return parseComicInfo(bais); } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI: {}", file.getName(), e.getMessage()); - return loadFromRarViaCli(file.toPath()); - } - throw e; + log.warn("Could not parse archive ComicInfo {}: {}", path, e.getMessage()); + return new ComicInfo(); } } - private ComicInfo loadFromRarViaCli(Path rarPath) throws Exception { - java.util.List entries = UnrarHelper.listEntries(rarPath); - String comicInfoEntry = entries.stream() - .filter(CbxMetadataWriter::isComicInfoXml) - .findFirst() - .orElse(null); - if (comicInfoEntry != null) { - byte[] xmlBytes = UnrarHelper.extractEntryBytes(rarPath, comicInfoEntry); - if (xmlBytes != null && xmlBytes.length > 0) { - try (InputStream stream = new ByteArrayInputStream(xmlBytes)) { - return parseComicInfo(stream); - } - } + private String findComicInfoEntryName(Path path) { + try { + return archiveService.streamEntryNames(path) + .filter(CbxMetadataWriter::isComicInfoXml) + .findFirst() + .orElse(null); + } catch (IOException e) { + log.warn("Failed to read archive {}: {}", path.getFileName(), e.getMessage()); } - return new ComicInfo(); + + return null; } private void applyMetadataChanges(ComicInfo info, BookMetadataEntity metadata, MetadataClearFlags clearFlags) { @@ -472,7 +398,7 @@ private String mapAgeRatingToComicInfo(Integer ageRating) { return "Early Childhood"; } - private ComicInfo parseComicInfo(InputStream xmlStream) throws Exception { + private ComicInfo parseComicInfo(InputStream xmlStream) throws SAXException, ParserConfigurationException, JAXBException { // Use a SAXSource with an explicitly secured XMLReader to prevent XXE injection. // This is more robust than System.setProperty() because it is per-instance and // cannot be inadvertently disabled by other code running in the same JVM. @@ -520,188 +446,29 @@ private byte[] convertToBytes(ComicInfo comicInfo) throws Exception { return outputStream.toByteArray(); } - private Path updateZipArchive(File originalFile, byte[] xmlContent) throws Exception { + private Path updateArchive(File originalFile, byte[] xmlContent) throws Exception { // Create temp file in same directory as original for true atomic move on same filesystem Path tempArchive = Files.createTempFile(originalFile.toPath().getParent(), ".cbx_edit_", ".cbz"); - rebuildZipWithNewXml(originalFile.toPath(), tempArchive, xmlContent); - replaceFileAtomic(tempArchive, originalFile.toPath()); - return null; - } - - private Path convert7zToZip(File original7z, byte[] xmlContent) throws Exception { - // Create temp file in same directory as original for true atomic move on same filesystem - Path tempZip = Files.createTempFile(original7z.toPath().getParent(), ".cbx_edit_", ".cbz"); - repack7zToZipWithXml(original7z, tempZip, xmlContent); - - Path targetPath = original7z.toPath().resolveSibling(removeFileExtension(original7z.getName()) + ".cbz"); - replaceFileAtomic(tempZip, targetPath); - - try { - Files.deleteIfExists(original7z.toPath()); - } catch (Exception ignored) { - } - return null; - } - - private void repack7zToZipWithXml(File source7z, Path targetZip, byte[] xmlContent) throws Exception { - try (SevenZFile archive = SevenZFile.builder().setFile(source7z).get(); - ZipOutputStream zipOutput = new ZipOutputStream(Files.newOutputStream(targetZip))) { - - for (SevenZArchiveEntry entry : archive.getEntries()) { - if (entry.isDirectory()) continue; - String entryName = entry.getName(); - if (isComicInfoXml(entryName)) continue; - if (!isPathSafe(entryName)) { - log.warn("Skipping unsafe 7z entry name: {}", entryName); - continue; - } - - zipOutput.putNextEntry(new ZipEntry(entryName)); - try (InputStream entryStream = archive.getInputStream(entry)) { - if (entryStream != null) copyStream(entryStream, zipOutput); - } - zipOutput.closeEntry(); - } - - zipOutput.putNextEntry(new ZipEntry("ComicInfo.xml")); - zipOutput.write(xmlContent); - zipOutput.closeEntry(); - } - } - - private Path updateRarArchive(File originalRar, byte[] xmlContent, Path extractDir) throws Exception { - String rarCommand = System.getenv().getOrDefault("BOOKLORE_RAR_BIN", "rar"); - boolean rarAvailable = checkRarAvailability(rarCommand); - - if (rarAvailable) { - return updateRarWithCommand(originalRar, xmlContent, rarCommand, extractDir); - } else { - log.warn("`rar` binary not found. Falling back to CBZ conversion for {}", originalRar.getName()); - return convertRarToZipArchive(originalRar, xmlContent); - } - } - - private Path updateRarWithCommand(File originalRar, byte[] xmlContent, String rarCommand, Path extractDir) throws Exception { - extractDir = Files.createTempDirectory("cbx_rar_"); - extractRarContents(originalRar, extractDir); - - Path xmlPath = extractDir.resolve("ComicInfo.xml"); - Files.write(xmlPath, xmlContent); - - Path targetRar = originalRar.toPath().toAbsolutePath().normalize(); - String safeCommand = isExecutableSafe(rarCommand) ? rarCommand : "rar"; - ProcessBuilder processBuilder = new ProcessBuilder(safeCommand, "a", "-idq", "-ep1", "-ma5", targetRar.toString(), "."); - processBuilder.directory(extractDir.toFile()); - Process process = processBuilder.start(); - int exitCode = process.waitFor(); - - if (exitCode == 0) { - return null; - } else { - log.warn("RAR creation failed with exit code {}. Falling back to CBZ conversion for {}", exitCode, originalRar.getName()); - return convertRarToZipArchive(originalRar, xmlContent); - } - } - - private void extractRarContents(File rarFile, Path targetDir) throws Exception { - try (Archive archive = new Archive(rarFile)) { - for (FileHeader header : archive.getFileHeaders()) { - String entryName = header.getFileName(); - if (entryName == null || entryName.isBlank()) continue; - if (!isPathSafe(entryName)) { - log.warn("Skipping unsafe RAR entry name: {}", entryName); - continue; - } + rebuildArchiveWithNewXml(originalFile.toPath(), tempArchive, xmlContent); - Path outputPath = targetDir.resolve(entryName).normalize(); - if (!outputPath.startsWith(targetDir)) { - log.warn("Skipping traversal entry outside tempDir: {}", entryName); - continue; - } + Path originalPath = originalFile.toPath().toAbsolutePath(); + Path targetPath = replaceFileExtension(originalPath, "cbz"); - if (header.isDirectory()) { - Files.createDirectories(outputPath); - } else { - Files.createDirectories(outputPath.getParent()); - try (OutputStream fileOutput = Files.newOutputStream(outputPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { - extractRarEntry(archive, header, fileOutput); - } - } - } - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI extractAll: {}", rarFile.getName(), e.getMessage()); - UnrarHelper.extractAll(rarFile.toPath(), targetDir); - return; - } - throw e; - } - } + replaceFileAtomic(tempArchive, targetPath); - private Path convertRarToZipArchive(File rarFile, byte[] xmlContent) throws Exception { - // Create temp file in same directory as original for true atomic move on same filesystem - Path tempZip = Files.createTempFile(rarFile.toPath().getParent(), ".cbx_edit_", ".cbz"); - - try (Archive rarArchive = new Archive(rarFile); - ZipOutputStream zipOutput = new ZipOutputStream(Files.newOutputStream(tempZip))) { - - for (FileHeader header : rarArchive.getFileHeaders()) { - if (header.isDirectory()) continue; - String entryName = header.getFileName(); - if (isComicInfoXml(entryName)) continue; - if (!isPathSafe(entryName)) { - log.warn("Skipping unsafe RAR entry name: {}", entryName); - continue; - } - - zipOutput.putNextEntry(new ZipEntry(entryName)); - extractRarEntry(rarArchive, header, zipOutput); - zipOutput.closeEntry(); - } + if (!originalPath.equals(targetPath)) { + log.debug("Deleting CBX archive after conversion to CBZ: {}", originalPath); - zipOutput.putNextEntry(new ZipEntry("ComicInfo.xml")); - zipOutput.write(xmlContent); - zipOutput.closeEntry(); - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI for RAR-to-ZIP: {}", rarFile.getName(), e.getMessage()); - convertRarToZipViaCli(rarFile.toPath(), tempZip, xmlContent); - } else { - throw e; + try { + Files.deleteIfExists(originalPath); + } catch (Exception e) { + log.warn("Unable to delete original CBX archive {}: {}", originalPath, e.getMessage()); } } - Path targetPath = rarFile.toPath().resolveSibling(removeFileExtension(rarFile.getName()) + ".cbz"); - replaceFileAtomic(tempZip, targetPath); - - try { - Files.deleteIfExists(rarFile.toPath()); - } catch (Exception ignored) { - } - return null; } - private void convertRarToZipViaCli(Path rarPath, Path tempZip, byte[] xmlContent) throws Exception { - java.util.List entries = UnrarHelper.listEntries(rarPath); - try (ZipOutputStream zipOutput = new ZipOutputStream(Files.newOutputStream(tempZip))) { - for (String entryName : entries) { - if (isComicInfoXml(entryName)) continue; - if (!isPathSafe(entryName)) { - log.warn("Skipping unsafe RAR entry name: {}", entryName); - continue; - } - byte[] bytes = UnrarHelper.extractEntryBytes(rarPath, entryName); - zipOutput.putNextEntry(new ZipEntry(entryName)); - zipOutput.write(bytes); - zipOutput.closeEntry(); - } - zipOutput.putNextEntry(new ZipEntry("ComicInfo.xml")); - zipOutput.write(xmlContent); - zipOutput.closeEntry(); - } - } - private void restoreOriginalFile(Path backupPath, File targetFile) { try { if (backupPath != null) { @@ -735,24 +502,6 @@ private void cleanupTempFiles(Path tempArchive, Path extractDir, Path backupPath } } - private ZipEntry findComicInfoEntry(ZipFile zipFile) { - Enumeration entries = zipFile.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - String entryName = entry.getName(); - if (isComicInfoXml(entryName)) return entry; - } - return null; - } - - private FileHeader findComicInfoInRar(Archive archive) { - for (FileHeader header : archive.getFileHeaders()) { - String entryName = header.getFileName(); - if (entryName != null && isComicInfoXml(entryName)) return header; - } - return null; - } - private String joinStrings(Set values) { return (values == null || values.isEmpty()) ? null : String.join(", ", values); } @@ -765,10 +514,18 @@ private String formatFloatValue(Float value) { private static boolean isComicInfoXml(String entryName) { if (entryName == null) return false; - String normalized = entryName.replace('\\', '/'); - if (normalized.endsWith("/")) return false; - String lowerCase = normalized.toLowerCase(Locale.ROOT); - return "comicinfo.xml".equals(lowerCase) || lowerCase.endsWith("/comicinfo.xml"); + String normalized = entryName + .replace('\\', '/') + .toLowerCase(Locale.ROOT); + + if (normalized.endsWith("/")) { + // Directories cannot be a comic info XML + return false; + } + + String comicInfoFilename = CbxMetadataWriter.DEFAULT_COMICINFO_XML.toLowerCase(Locale.ROOT); + + return comicInfoFilename.equals(normalized) || normalized.endsWith("/" + comicInfoFilename); } private static boolean isPathSafe(String entryName) { @@ -784,48 +541,37 @@ private static boolean isPathSafe(String entryName) { return true; } - private void rebuildZipWithNewXml(Path sourceZip, Path targetZip, byte[] xmlContent) throws Exception { - try (ZipFile zipFile = new ZipFile(sourceZip.toFile()); - ZipOutputStream zipOutput = new ZipOutputStream(Files.newOutputStream(targetZip))) { - ZipEntry existingXml = findComicInfoEntry(zipFile); - Enumeration entries = zipFile.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - String entryName = entry.getName(); - if (existingXml != null && entryName.equals(existingXml.getName())) { + private void rebuildArchiveWithNewXml(Path sourceArchive, Path targetZip, byte[] xmlContent) throws Exception { + String comicInfoEntryName = findComicInfoEntryName(sourceArchive); + + try ( + ZipOutputStream zipOutput = new ZipOutputStream(Files.newOutputStream(targetZip)) + ) { + for (String entryName : archiveService.getEntryNames(sourceArchive)) { + if (isComicInfoXml(entryName)) { + // Skip copying over any existing comic info entry continue; } + if (!isPathSafe(entryName)) { - log.warn("Skipping unsafe ZIP entry name: {}", entryName); + log.warn("Skipping unsafe CBZ entry name: {}", entryName); continue; } + zipOutput.putNextEntry(new ZipEntry(entryName)); - try (InputStream entryStream = zipFile.getInputStream(entry)) { - copyStream(entryStream, zipOutput); - } + + archiveService.transferEntryTo(sourceArchive, entryName, zipOutput); + zipOutput.closeEntry(); } - String xmlEntryName = (existingXml != null ? existingXml.getName() : "ComicInfo.xml"); + + String xmlEntryName = (comicInfoEntryName != null ? comicInfoEntryName : CbxMetadataWriter.DEFAULT_COMICINFO_XML); zipOutput.putNextEntry(new ZipEntry(xmlEntryName)); zipOutput.write(xmlContent); zipOutput.closeEntry(); } } - private void copyStream(InputStream input, OutputStream output) throws IOException { - byte[] buffer = new byte[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = input.read(buffer)) != -1) { - output.write(buffer, 0, bytesRead); - } - } - - private void extractRarEntry(Archive archive, FileHeader fileHeader, OutputStream output) throws Exception { - try (InputStream entryStream = archive.getInputStream(fileHeader)) { - copyStream(entryStream, output); - } - } - private static void replaceFileAtomic(Path source, Path target) throws Exception { try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); @@ -834,27 +580,19 @@ private static void replaceFileAtomic(Path source, Path target) throws Exception } } - private boolean checkRarAvailability(String rarCommand) { - try { - String safeCommand = isExecutableSafe(rarCommand) ? rarCommand : "rar"; - Process check = new ProcessBuilder(safeCommand, "--help").redirectErrorStream(true).start(); - int exitCode = check.waitFor(); - return (exitCode == 0); - } catch (Exception ex) { - log.warn("RAR binary check failed: {}", ex.getMessage()); - return false; - } - } + private static Path replaceFileExtension(Path path, String extension) { + String filename = path.getFileName().toString(); - private boolean isExecutableSafe(String command) { - if (command == null || command.isBlank()) return false; - return VALID_FILENAME_PATTERN.matcher(command).matches(); - } + if (filename.toLowerCase(Locale.ROOT).endsWith("." + extension.toLowerCase())) { + // If the file extension is already there, do nothing. + return path; + } - private static String removeFileExtension(String filename) { int lastDot = filename.lastIndexOf('.'); - if (lastDot > 0) return filename.substring(0, lastDot); - return filename; + if (lastDot > 0) { + filename = filename.substring(0, lastDot); + } + return path.resolveSibling(filename + "." + extension); } @Override diff --git a/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java b/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java index f808851ba..5862afaf5 100644 --- a/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java +++ b/booklore-api/src/main/java/org/booklore/service/reader/CbxReaderService.java @@ -1,30 +1,21 @@ package org.booklore.service.reader; -import com.github.junrar.Archive; -import com.github.junrar.rarfile.FileHeader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -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.pdfbox.io.IOUtils; import org.booklore.exception.ApiError; import org.booklore.model.dto.response.CbxPageInfo; import org.booklore.model.entity.BookEntity; import org.booklore.model.entity.BookFileEntity; import org.booklore.model.enums.BookFileType; import org.booklore.repository.BookRepository; -import org.booklore.util.ArchiveUtils; +import org.booklore.service.ArchiveService; import org.booklore.util.FileUtils; -import org.booklore.util.UnrarHelper; import org.springframework.stereotype.Service; import java.io.FileNotFoundException; 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.util.*; @@ -40,15 +31,7 @@ public class CbxReaderService { private static final String[] SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".heic", ".gif", ".bmp"}; - 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 SYSTEM_FILES = Set.of(".ds_store", "thumbs.db", "desktop.ini"); private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d+"); @@ -56,18 +39,16 @@ public class CbxReaderService { private final BookRepository bookRepository; private final Map archiveCache = new ConcurrentHashMap<>(); + private final ArchiveService archiveService; + private static class CachedArchiveMetadata { final List imageEntries; final long lastModified; - final Charset successfulEncoding; - final boolean useUnicodeExtraFields; volatile long lastAccessed; - CachedArchiveMetadata(List imageEntries, long lastModified, Charset successfulEncoding, boolean useUnicodeExtraFields) { + CachedArchiveMetadata(List imageEntries, long lastModified) { this.imageEntries = List.copyOf(imageEntries); this.lastModified = lastModified; - this.successfulEncoding = successfulEncoding; - this.useUnicodeExtraFields = useUnicodeExtraFields; this.lastAccessed = System.currentTimeMillis(); } } @@ -131,7 +112,7 @@ public void streamPageImage(Long bookId, String bookType, int page, OutputStream CachedArchiveMetadata metadata = getCachedMetadata(cbxPath); validatePageRequest(bookId, page, metadata.imageEntries); String entryName = metadata.imageEntries.get(page - 1); - streamEntryFromArchive(cbxPath, entryName, outputStream, metadata); + archiveService.transferEntryTo(cbxPath, entryName, outputStream); } private Path getBookPath(Long bookId, String bookType) { @@ -193,235 +174,21 @@ private void evictOldestCacheEntries() { private CachedArchiveMetadata scanArchiveMetadata(Path cbxPath) throws IOException { long lastModified = Files.getLastModifiedTime(cbxPath).toMillis(); - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(cbxPath.toFile()); - return switch (type) { - case ZIP -> scanZipMetadata(cbxPath, lastModified); - case SEVEN_ZIP -> { - List entries = getImageEntriesFrom7z(cbxPath); - yield new CachedArchiveMetadata(entries, lastModified, null, false); - } - case RAR -> { - List entries = getImageEntriesFromRar(cbxPath); - yield new CachedArchiveMetadata(entries, lastModified, null, false); - } - default -> throw new IOException("Unsupported archive format: " + cbxPath.getFileName()); - }; - } - - 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 { - List entries = getImageEntriesFromZipWithEncoding(cbxPath, oldCache.successfulEncoding, true, oldCache.useUnicodeExtraFields); - return new CachedArchiveMetadata(entries, lastModified, oldCache.successfulEncoding, oldCache.useUnicodeExtraFields); - } catch (Exception e) { - log.debug("Cached encoding {} with useUnicode={} failed, trying others", oldCache.successfulEncoding, oldCache.useUnicodeExtraFields); - } - } - - // Try combinations per encoding - for (Charset encoding : ENCODINGS_TO_TRY) { - // Priority 1: Fast path, Unicode Enabled - try { - List entries = getImageEntriesFromZipWithEncoding(cbxPath, encoding, true, true); - return new CachedArchiveMetadata(entries, lastModified, encoding, true); - } catch (Exception e) { - log.trace("ZIP strategy failed (Fast, Unicode, {}): {}", encoding, e.getMessage()); - } - - // Priority 2: Slow path, Unicode Enabled - try { - List entries = getImageEntriesFromZipWithEncoding(cbxPath, encoding, false, true); - return new CachedArchiveMetadata(entries, lastModified, encoding, true); - } catch (Exception e) { - log.trace("ZIP strategy failed (Slow, Unicode, {}): {}", encoding, e.getMessage()); - } - - // Priority 3: Fast path, Unicode Disabled (Fallback) - try { - List entries = getImageEntriesFromZipWithEncoding(cbxPath, encoding, true, false); - return new CachedArchiveMetadata(entries, lastModified, encoding, false); - } catch (Exception e) { - log.trace("ZIP strategy failed (Fast, No-Unicode, {}): {}", encoding, e.getMessage()); - } - - // Priority 4: Slow path, Unicode Disabled (Fallback) - try { - List entries = getImageEntriesFromZipWithEncoding(cbxPath, encoding, false, false); - return new CachedArchiveMetadata(entries, lastModified, encoding, false); - } catch (Exception e) { - log.trace("ZIP strategy failed (Slow, No-Unicode, {}): {}", encoding, e.getMessage()); - } - } - throw new IOException("Unable to read ZIP archive with any supported encoding"); + List entries = getImageEntries(cbxPath); + return new CachedArchiveMetadata(entries, lastModified); } - private List getImageEntriesFromZipWithEncoding(Path cbxPath, Charset charset, boolean useFastPath, boolean useUnicodeExtraFields) throws IOException { - try (org.apache.commons.compress.archivers.zip.ZipFile zipFile = - org.apache.commons.compress.archivers.zip.ZipFile.builder() - .setPath(cbxPath) - .setCharset(charset) - .setUseUnicodeExtraFields(useUnicodeExtraFields) - .setIgnoreLocalFileHeader(useFastPath) - .get()) { - List entries = new ArrayList<>(); - Enumeration enumeration = zipFile.getEntries(); - while (enumeration.hasMoreElements()) { - ZipArchiveEntry entry = enumeration.nextElement(); - if (!entry.isDirectory() && isImageFile(entry.getName())) { - entries.add(entry.getName()); - } - } - sortNaturally(entries); - return entries; - } - } - - private void streamEntryFromZip(Path cbxPath, String entryName, OutputStream outputStream, CachedArchiveMetadata metadata) throws IOException { - Charset encoding = metadata != null ? metadata.successfulEncoding : null; - boolean useUnicode = metadata != null && metadata.useUnicodeExtraFields; - - if (encoding != null) { - if (tryStreamEntry(cbxPath, entryName, outputStream, encoding, true, useUnicode)) return; - if (tryStreamEntry(cbxPath, entryName, outputStream, encoding, false, useUnicode)) return; - } - - for (Charset charset : ENCODINGS_TO_TRY) { - if (charset.equals(encoding)) continue; // Skip failed cached encoding if we want, or retry it with different flags? - - if (tryStreamEntry(cbxPath, entryName, outputStream, charset, true, true)) return; - if (tryStreamEntry(cbxPath, entryName, outputStream, charset, false, true)) return; - if (tryStreamEntry(cbxPath, entryName, outputStream, charset, true, false)) return; - if (tryStreamEntry(cbxPath, entryName, outputStream, charset, false, false)) return; - } - - throw new IOException("Unable to find entry in ZIP archive: " + entryName); - } - - private boolean tryStreamEntry(Path cbxPath, String entryName, OutputStream outputStream, Charset charset, boolean useFastPath, boolean useUnicode) { + private List getImageEntries(Path cbxPath) throws IOException { try { - if (streamEntryFromZipWithEncoding(cbxPath, entryName, outputStream, charset, useFastPath, useUnicode)) { - return true; - } - } catch (Exception e) { - log.trace("Stream strategy failed ({}, Fast={}, Unicode={}): {}", charset, useFastPath, useUnicode, e.getMessage()); - } - return false; - } - - - - private void streamEntryFromArchive(Path cbxPath, String entryName, OutputStream outputStream, CachedArchiveMetadata metadata) throws IOException { - ArchiveUtils.ArchiveType type = ArchiveUtils.detectArchiveType(cbxPath.toFile()); - switch (type) { - case ZIP -> streamEntryFromZip(cbxPath, entryName, outputStream, metadata); - case SEVEN_ZIP -> streamEntryFrom7z(cbxPath, entryName, outputStream); - case RAR -> streamEntryFromRar(cbxPath, entryName, outputStream); - default -> throw new IOException("Unsupported archive format: " + cbxPath.getFileName()); - } - } - - private boolean streamEntryFromZipWithEncoding(Path cbxPath, String entryName, OutputStream outputStream, Charset charset, boolean useFastPath, boolean useUnicodeExtraFields) throws IOException { - try (org.apache.commons.compress.archivers.zip.ZipFile zipFile = - org.apache.commons.compress.archivers.zip.ZipFile.builder() - .setPath(cbxPath) - .setCharset(charset) - .setUseUnicodeExtraFields(useUnicodeExtraFields) - .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 getImageEntriesFrom7z(Path cbxPath) throws IOException { - List entries = new ArrayList<>(); - try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cbxPath).get()) { - SevenZArchiveEntry entry; - while ((entry = sevenZFile.getNextEntry()) != null) { - if (!entry.isDirectory() && isImageFile(entry.getName())) { - 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); - } + return archiveService.streamEntryNames(cbxPath) + .filter(this::isImageFile) + .sorted(CbxReaderService::sortNaturally) + .toList(); - private void copySevenZEntry(SevenZFile sevenZFile, OutputStream out, long size) throws IOException { - 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; - } - out.write(buffer, 0, read); - remaining -= read; - } - } - - private List getImageEntriesFromRar(Path cbxPath) throws IOException { - List entries = new ArrayList<>(); - try (Archive archive = new Archive(cbxPath.toFile())) { - for (FileHeader header : archive.getFileHeaders()) { - if (!header.isDirectory() && isImageFile(header.getFileName())) { - entries.add(header.getFileName()); - } - } - } catch (Exception e) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI: {}", cbxPath.getFileName(), e.getMessage()); - entries = UnrarHelper.listEntries(cbxPath).stream() - .filter(this::isImageFile) - .collect(Collectors.toCollection(ArrayList::new)); - } else { - throw new IOException("Failed to read RAR archive: " + e.getMessage(), e); - } - } - sortNaturally(entries); - return entries; - } - - 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) { - if (UnrarHelper.isAvailable()) { - log.info("junrar failed for {}, falling back to unrar CLI: {}", cbxPath.getFileName(), e.getMessage()); - UnrarHelper.extractEntry(cbxPath, entryName, outputStream); - return; - } - throw new IOException("Failed to extract from RAR archive: " + e.getMessage(), e); + throw new IOException("Failed to read archive: " + e.getMessage(), e); } - throw new FileNotFoundException("Entry not found in RAR archive: " + entryName); } private boolean isImageFile(String name) { @@ -470,25 +237,23 @@ private String baseName(String path) { return slash >= 0 ? path.substring(slash + 1) : path; } - private void sortNaturally(List 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 (DIGIT_PATTERN.matcher(part1).matches() && DIGIT_PATTERN.matcher(part2).matches()) { - 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; - } + private static int sortNaturally(String s1, String 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 (DIGIT_PATTERN.matcher(part1).matches() && DIGIT_PATTERN.matcher(part2).matches()) { + 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 s1.compareToIgnoreCase(s2); - }); + } + return s1.compareToIgnoreCase(s2); } } diff --git a/booklore-api/src/main/java/org/booklore/util/UnrarHelper.java b/booklore-api/src/main/java/org/booklore/util/UnrarHelper.java deleted file mode 100644 index 6d656d937..000000000 --- a/booklore-api/src/main/java/org/booklore/util/UnrarHelper.java +++ /dev/null @@ -1,134 +0,0 @@ -package org.booklore.util; - -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.TimeUnit; - -@Slf4j -@UtilityClass -public class UnrarHelper { - - private static final String UNRAR_BIN_ENV = "BOOKLORE_UNRAR_BIN"; - private static final String DEFAULT_UNRAR_BIN = "unrar"; - private static final int PROCESS_TIMEOUT_SECONDS = 120; - private static volatile Boolean cachedAvailability; - - public static boolean isAvailable() { - if (cachedAvailability != null) { - return cachedAvailability; - } - synchronized (UnrarHelper.class) { - if (cachedAvailability != null) { - return cachedAvailability; - } - try { - Process process = new ProcessBuilder(getUnrarBin()) - .redirectErrorStream(true) - .start(); - try (InputStream is = process.getInputStream()) { - is.readAllBytes(); - } - boolean finished = process.waitFor(5, TimeUnit.SECONDS); - cachedAvailability = finished; - } catch (Exception e) { - log.debug("unrar binary not available: {}", e.getMessage()); - cachedAvailability = false; - } - if (cachedAvailability) { - log.info("unrar CLI detected, RAR5 fallback enabled"); - } - return cachedAvailability; - } - } - - public static List listEntries(Path rarPath) throws IOException { - ProcessBuilder pb = new ProcessBuilder(getUnrarBin(), "lb", rarPath.toAbsolutePath().toString()); - pb.redirectErrorStream(true); - Process process = pb.start(); - String output; - try (InputStream is = process.getInputStream()) { - output = new String(is.readAllBytes()); - } - try { - if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new IOException("unrar list timed out for: " + rarPath); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("unrar list interrupted", e); - } - if (process.exitValue() != 0) { - throw new IOException("unrar list failed (exit " + process.exitValue() + ") for: " + rarPath); - } - return Arrays.stream(output.split("\n")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - } - - public static void extractEntry(Path rarPath, String entryName, OutputStream out) throws IOException { - ProcessBuilder pb = new ProcessBuilder( - getUnrarBin(), "p", "-inul", rarPath.toAbsolutePath().toString(), entryName - ); - pb.redirectErrorStream(false); - Process process = pb.start(); - try (InputStream is = process.getInputStream()) { - is.transferTo(out); - } - try { - if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new IOException("unrar extract timed out for entry: " + entryName); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("unrar extract interrupted", e); - } - if (process.exitValue() != 0) { - throw new IOException("unrar extract failed (exit " + process.exitValue() + ") for entry: " + entryName); - } - } - - public static byte[] extractEntryBytes(Path rarPath, String entryName) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - extractEntry(rarPath, entryName, baos); - return baos.toByteArray(); - } - - public static void extractAll(Path rarPath, Path targetDir) throws IOException { - ProcessBuilder pb = new ProcessBuilder( - getUnrarBin(), "x", "-o+", rarPath.toAbsolutePath().toString(), targetDir.toAbsolutePath() + "/" - ); - pb.redirectErrorStream(true); - Process process = pb.start(); - try (InputStream is = process.getInputStream()) { - is.readAllBytes(); - } - try { - if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new IOException("unrar extractAll timed out for: " + rarPath); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("unrar extractAll interrupted", e); - } - if (process.exitValue() != 0) { - throw new IOException("unrar extractAll failed (exit " + process.exitValue() + ") for: " + rarPath); - } - } - - private static String getUnrarBin() { - String env = System.getenv(UNRAR_BIN_ENV); - return (env != null && !env.isBlank()) ? env : DEFAULT_UNRAR_BIN; - } -} diff --git a/booklore-api/src/test/java/org/booklore/service/Rar5FallbackIntegrationTest.java b/booklore-api/src/test/java/org/booklore/service/Rar5IntegrationTest.java similarity index 87% rename from booklore-api/src/test/java/org/booklore/service/Rar5FallbackIntegrationTest.java rename to booklore-api/src/test/java/org/booklore/service/Rar5IntegrationTest.java index a6f5d243d..fa4daebdb 100644 --- a/booklore-api/src/test/java/org/booklore/service/Rar5FallbackIntegrationTest.java +++ b/booklore-api/src/test/java/org/booklore/service/Rar5IntegrationTest.java @@ -9,9 +9,8 @@ import org.booklore.service.reader.CbxReaderService; import org.booklore.repository.BookRepository; import org.booklore.service.appsettings.AppSettingService; -import org.booklore.util.UnrarHelper; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -20,30 +19,21 @@ import java.io.File; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assumptions.assumeThat; /** * Integration tests that feed a real RAR5 archive into the service layer - * and verify junrar fails then the unrar CLI fallback kicks in. */ +@EnabledIf("org.booklore.service.ArchiveService#isAvailable") @ExtendWith(MockitoExtension.class) -class Rar5FallbackIntegrationTest { +class Rar5IntegrationTest { private static final Path RAR5_CBR = Path.of("src/test/resources/cbx/test-rar5.cbr"); - @BeforeAll - static void checkUnrarAvailable() { - assumeThat(UnrarHelper.isAvailable()) - .as("unrar binary must be on PATH to run these tests") - .isTrue(); - } - // -- CbxMetadataExtractor: extractMetadata fallback -- @Test @@ -51,7 +41,7 @@ void metadataExtractor_extractsComicInfoFromRar5(@TempDir Path tempDir) throws E Path cbrCopy = tempDir.resolve("test.cbr"); Files.copy(RAR5_CBR, cbrCopy); - CbxMetadataExtractor extractor = new CbxMetadataExtractor(); + CbxMetadataExtractor extractor = new CbxMetadataExtractor(new ArchiveService()); BookMetadata metadata = extractor.extractMetadata(cbrCopy.toFile()); assertThat(metadata.getTitle()).isEqualTo("Test RAR5 Comic"); @@ -70,13 +60,13 @@ void readerService_listsImagePagesFromRar5(@TempDir Path tempDir) throws Excepti BookEntity book = new BookEntity(); book.setId(99L); BookRepository mockRepo = org.mockito.Mockito.mock(BookRepository.class); - org.mockito.Mockito.when(mockRepo.findById(99L)).thenReturn(java.util.Optional.of(book)); + org.mockito.Mockito.when(mockRepo.findByIdWithBookFiles(99L)).thenReturn(java.util.Optional.of(book)); try (var fileUtilsStatic = org.mockito.Mockito.mockStatic(org.booklore.util.FileUtils.class)) { fileUtilsStatic.when(() -> org.booklore.util.FileUtils.getBookFullPath(book)) .thenReturn(cbrCopy); - CbxReaderService readerService = new CbxReaderService(mockRepo); + CbxReaderService readerService = new CbxReaderService(mockRepo, new ArchiveService()); List pages = readerService.getAvailablePages(99L); assertThat(pages).hasSize(3); @@ -92,13 +82,15 @@ void readerService_streamsImageFromRar5(@TempDir Path tempDir) throws Exception BookEntity book = new BookEntity(); book.setId(99L); BookRepository mockRepo = org.mockito.Mockito.mock(BookRepository.class); - org.mockito.Mockito.when(mockRepo.findById(99L)).thenReturn(java.util.Optional.of(book)); + org.mockito.Mockito.when(mockRepo.findByIdWithBookFiles(99L)).thenReturn(java.util.Optional.of(book)); - try (var fileUtilsStatic = org.mockito.Mockito.mockStatic(org.booklore.util.FileUtils.class)) { + try ( + var fileUtilsStatic = org.mockito.Mockito.mockStatic(org.booklore.util.FileUtils.class) + ) { fileUtilsStatic.when(() -> org.booklore.util.FileUtils.getBookFullPath(book)) .thenReturn(cbrCopy); - CbxReaderService readerService = new CbxReaderService(mockRepo); + CbxReaderService readerService = new CbxReaderService(mockRepo, new ArchiveService()); ByteArrayOutputStream out = new ByteArrayOutputStream(); readerService.streamPageImage(99L, 1, out); @@ -122,7 +114,7 @@ void conversionService_extractsImagesFromRar5(@TempDir Path tempDir) throws Exce meta.setTitle("Test RAR5 Comic"); book.setMetadata(meta); - CbxConversionService conversionService = new CbxConversionService(); + CbxConversionService conversionService = new CbxConversionService(new ArchiveService()); File epub = conversionService.convertCbxToEpub(cbrCopy.toFile(), tempDir.toFile(), book, 85); assertThat(epub).exists(); @@ -143,7 +135,7 @@ void conversionService_extractsImagesFromRar5(@TempDir Path tempDir) throws Exce // -- CbxMetadataWriter: loadFromRar + convertRarToZipArchive fallback -- @Test - void metadataWriter_convertsRar5ToCbzViaFallback(@TempDir Path tempDir) throws Exception { + void metadataWriter_convertsRar5ToCbz(@TempDir Path tempDir) throws Exception { Path cbrCopy = tempDir.resolve("test.cbr"); Files.copy(RAR5_CBR, cbrCopy); @@ -159,7 +151,7 @@ void metadataWriter_convertsRar5ToCbzViaFallback(@TempDir Path tempDir) throws E appSettings.setMetadataPersistenceSettings(persistenceSettings); org.mockito.Mockito.when(mockSettings.getAppSettings()).thenReturn(appSettings); - CbxMetadataWriter writer = new CbxMetadataWriter(mockSettings); + CbxMetadataWriter writer = new CbxMetadataWriter(mockSettings, new ArchiveService()); BookMetadataEntity metadata = new BookMetadataEntity(); metadata.setTitle("Updated RAR5 Title"); diff --git a/booklore-api/src/test/java/org/booklore/service/fileprocessor/CbxProcessorTest.java b/booklore-api/src/test/java/org/booklore/service/fileprocessor/CbxProcessorTest.java deleted file mode 100644 index fc4269f20..000000000 --- a/booklore-api/src/test/java/org/booklore/service/fileprocessor/CbxProcessorTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.booklore.service.fileprocessor; - -import org.booklore.mapper.BookMapper; -import org.booklore.model.entity.BookEntity; -import org.booklore.model.entity.BookFileEntity; -import org.booklore.model.entity.BookMetadataEntity; -import org.booklore.model.entity.LibraryPathEntity; -import org.booklore.repository.BookAdditionalFileRepository; -import org.booklore.repository.BookRepository; -import org.booklore.service.book.BookCreatorService; -import org.booklore.service.metadata.MetadataMatchService; -import org.booklore.service.metadata.extractor.CbxMetadataExtractor; -import org.booklore.service.metadata.sidecar.SidecarMetadataWriter; -import org.booklore.util.FileService; -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.junit.jupiter.MockitoExtension; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Path; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class CbxProcessorTest { - - @Mock private BookRepository bookRepository; - @Mock private BookAdditionalFileRepository bookAdditionalFileRepository; - @Mock private BookCreatorService bookCreatorService; - @Mock private BookMapper bookMapper; - @Mock private FileService fileService; - @Mock private MetadataMatchService metadataMatchService; - @Mock private SidecarMetadataWriter sidecarMetadataWriter; - @Mock private CbxMetadataExtractor cbxMetadataExtractor; - - private CbxProcessor cbxProcessor; - - @TempDir - Path tempDir; - - @BeforeEach - void setUp() { - cbxProcessor = new CbxProcessor( - bookRepository, - bookAdditionalFileRepository, - bookCreatorService, - bookMapper, - fileService, - metadataMatchService, - sidecarMetadataWriter, - cbxMetadataExtractor - ); - } - - @Test - void generateCover_ZipNamedAsCbr_ShouldExtractImage() throws IOException { - // Create a ZIP file named .cbr - File zipAsCbr = tempDir.resolve("mismatched.cbr").toFile(); - try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipAsCbr))) { - ZipEntry entry = new ZipEntry("cover.jpg"); - zos.putNextEntry(entry); - BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); - ImageIO.write(image, "jpg", zos); - zos.closeEntry(); - } - - BookEntity bookEntity = new BookEntity(); - bookEntity.setId(1L); - - // Create and configure the primary book file - BookFileEntity bookFile = new BookFileEntity(); - bookFile.setFileName(zipAsCbr.getName()); - bookFile.setFileSubPath(""); - bookFile.setBook(bookEntity); - bookEntity.getBookFiles().add(bookFile); - LibraryPathEntity libPath = new LibraryPathEntity(); - libPath.setPath(tempDir.toString()); - bookEntity.setLibraryPath(libPath); - bookEntity.setMetadata(new BookMetadataEntity()); - - when(fileService.saveCoverImages(any(BufferedImage.class), eq(1L))).thenReturn(true); - - cbxProcessor.generateCover(bookEntity); - - verify(fileService).saveCoverImages(any(BufferedImage.class), eq(1L)); - } -} diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionIntegrationTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionIntegrationTest.java index ebaa8d2f4..5f67ee4a0 100644 --- a/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionIntegrationTest.java +++ b/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionIntegrationTest.java @@ -3,13 +3,14 @@ import org.booklore.model.entity.BookEntity; import org.booklore.model.entity.BookMetadataEntity; import freemarker.template.TemplateException; -import com.github.junrar.exception.RarException; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.apache.commons.compress.archivers.zip.ZipFile; +import org.booklore.service.ArchiveService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; import org.junit.jupiter.api.io.TempDir; import javax.imageio.ImageIO; @@ -24,6 +25,7 @@ import static org.assertj.core.api.Assertions.*; @DisplayName("CBX Conversion Integration Test") +@EnabledIf("org.booklore.service.ArchiveService#isAvailable") class CbxConversionIntegrationTest { @TempDir @@ -33,12 +35,12 @@ class CbxConversionIntegrationTest { @BeforeEach void setUp() { - conversionService = new CbxConversionService(); + conversionService = new CbxConversionService(new ArchiveService()); } @Test @DisplayName("Should successfully convert CBZ to EPUB with valid structure") - void convertCbzToEpub_MainConversionTest() throws IOException, TemplateException, RarException { + void convertCbzToEpub_MainConversionTest() throws IOException, TemplateException { File testCbzFile = createTestComicCbzFile(); BookEntity bookMetadata = createTestBookMetadata(); diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionServiceTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionServiceTest.java index e9a85b3c9..1f7c7ef4e 100644 --- a/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/kobo/CbxConversionServiceTest.java @@ -3,13 +3,14 @@ import org.booklore.model.entity.BookEntity; import org.booklore.model.entity.BookMetadataEntity; import freemarker.template.TemplateException; -import com.github.junrar.exception.RarException; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.apache.commons.compress.archivers.zip.ZipFile; +import org.booklore.service.ArchiveService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; import org.junit.jupiter.api.io.TempDir; import javax.imageio.ImageIO; @@ -25,6 +26,7 @@ import static org.assertj.core.api.Assertions.*; @DisplayName("CBX Conversion Service Tests") +@EnabledIf("org.booklore.service.ArchiveService#isAvailable") class CbxConversionServiceTest { @TempDir @@ -36,13 +38,13 @@ class CbxConversionServiceTest { @BeforeEach void setUp() throws IOException { - cbxConversionService = new CbxConversionService(); + cbxConversionService = new CbxConversionService(new ArchiveService()); testCbzFile = createTestCbzFile(); testBookEntity = createTestBookEntity(); } @Test - void convertCbxToEpub_WithValidCbzFile_ShouldGenerateValidEpub() throws IOException, TemplateException, RarException { + void convertCbxToEpub_WithValidCbzFile_ShouldGenerateValidEpub() throws IOException, TemplateException { File epubFile = cbxConversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), testBookEntity,85); assertThat(epubFile).exists(); @@ -94,7 +96,7 @@ void convertCbxToEpub_WithEmptyCbzFile_ShouldThrowException() throws IOException } @Test - void convertCbxToEpub_WithNullBookEntity_ShouldUseDefaultMetadata() throws IOException, TemplateException, RarException { + void convertCbxToEpub_WithNullBookEntity_ShouldUseDefaultMetadata() throws IOException, TemplateException { File epubFile = cbxConversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), null,85); assertThat(epubFile).exists(); @@ -102,7 +104,7 @@ void convertCbxToEpub_WithNullBookEntity_ShouldUseDefaultMetadata() throws IOExc } @Test - void convertCbxToEpub_WithMultipleImages_ShouldPreservePageOrder() throws IOException, TemplateException, RarException { + void convertCbxToEpub_WithMultipleImages_ShouldPreservePageOrder() throws IOException, TemplateException { File multiPageCbzFile = createMultiPageCbzFile(); File epubFile = cbxConversionService.convertCbxToEpub(multiPageCbzFile, tempDir.toFile(), testBookEntity,85); @@ -112,7 +114,7 @@ void convertCbxToEpub_WithMultipleImages_ShouldPreservePageOrder() throws IOExce } @Test - void convertCbxToEpub_WithZipNamedAsCbr_ShouldGenerateValidEpub() throws IOException, TemplateException, RarException { + void convertCbxToEpub_WithZipNamedAsCbr_ShouldGenerateValidEpub() throws IOException, TemplateException { File zipAsCbr = new File(tempDir.toFile(), "fake.cbr"); try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(zipAsCbr))) { BufferedImage testImage = createTestImage("Page 1", Color.RED); diff --git a/booklore-api/src/test/java/org/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java b/booklore-api/src/test/java/org/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java index 0fbdb41f6..a9ac00c43 100644 --- a/booklore-api/src/test/java/org/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java +++ b/booklore-api/src/test/java/org/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java @@ -2,62 +2,53 @@ import org.booklore.model.dto.BookMetadata; import org.booklore.model.dto.ComicMetadata; +import org.booklore.service.ArchiveService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; +import java.util.Map; import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class CbxMetadataExtractorTest { - + @Mock private ArchiveService archiveService; private CbxMetadataExtractor extractor; - @TempDir - Path tempDir; - @BeforeEach void setUp() { - extractor = new CbxMetadataExtractor(); + extractor = new CbxMetadataExtractor(archiveService); } - private File createCbz(String comicInfoXml) throws IOException { - return createCbz(comicInfoXml, true); - } - - private File createCbz(String comicInfoXml, boolean includeImage) throws IOException { - Path cbzPath = tempDir.resolve("test.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - if (comicInfoXml != null) { - zos.putNextEntry(new ZipEntry("ComicInfo.xml")); - zos.write(comicInfoXml.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - } - if (includeImage) { - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } - } - return cbzPath.toFile(); + private byte[] createMinimalJpeg(int rgb) throws IOException { + BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + img.setRGB(0, 0, rgb); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpg", baos); + return baos.toByteArray(); } - private byte[] createMinimalJpeg() throws IOException { + private byte[] createMinimalPng() throws IOException { BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(img, "jpg", baos); + ImageIO.write(img, "png", baos); return baos.toByteArray(); } @@ -70,13 +61,47 @@ private String wrapInComicInfo(String innerXml) { """.formatted(innerXml); } + private Path mockArchiveContents(Map contents) throws IOException { + Path path = Path.of("test.cbz"); + Set keys = contents.keySet(); + when(archiveService.streamEntryNames(path)).then((i) -> keys.stream()); + + for (String key : keys) { + when(archiveService.getEntryBytes(path, key)).thenReturn(contents.get(key)); + } + + return path; + } + + private Path mockRaisesException() throws IOException { + Path path = Path.of("test.cbz"); + when(archiveService.streamEntryNames(path)).thenThrow(IOException.class); + when(archiveService.getEntryBytes(path, "ComicInfo.xml")).thenThrow(IOException.class); + return path; + } + + private Path mockEmptyArchive() throws IOException { + Path path = Path.of("test.cbz"); + when(archiveService.streamEntryNames(path)).then((i) -> Stream.empty()); + when(archiveService.getEntryBytes(eq(path), any())).thenThrow(IOException.class); + return path; + } + + private Path mockComicInfo(String innerXml) throws IOException { + Path path = Path.of("test.cbz"); + String xml = wrapInComicInfo(innerXml); + when(archiveService.getEntryBytes(path, "ComicInfo.xml")).thenReturn(xml.getBytes()); + when(archiveService.streamEntryNames(path)).then((i) -> Stream.of("ComicInfo.xml")); + + return path; + } + @Nested class ExtractMetadataFromZip { @Test void extractsTitleFromComicInfo() throws IOException { - String xml = wrapInComicInfo("Batman: Year One"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Batman: Year One"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -85,8 +110,7 @@ void extractsTitleFromComicInfo() throws IOException { @Test void fallsBackToFilenameWhenTitleMissing() throws IOException { - String xml = wrapInComicInfo("DC Comics"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("DC Comics"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -95,8 +119,7 @@ void fallsBackToFilenameWhenTitleMissing() throws IOException { @Test void fallsBackToFilenameWhenTitleBlank() throws IOException { - String xml = wrapInComicInfo(" "); - File cbz = createCbz(xml); + Path cbz = mockComicInfo(" "); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -105,7 +128,7 @@ void fallsBackToFilenameWhenTitleBlank() throws IOException { @Test void fallsBackToFilenameWhenNoComicInfo() throws IOException { - File cbz = createCbz(null); + Path cbz = mockEmptyArchive(); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -114,8 +137,7 @@ void fallsBackToFilenameWhenNoComicInfo() throws IOException { @Test void extractsPublisher() throws IOException { - String xml = wrapInComicInfo("Marvel Comics"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Marvel Comics"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -124,8 +146,7 @@ void extractsPublisher() throws IOException { @Test void extractsDescriptionFromSummary() throws IOException { - String xml = wrapInComicInfo("A dark tale of vengeance."); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("A dark tale of vengeance."); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -134,11 +155,10 @@ void extractsDescriptionFromSummary() throws IOException { @Test void prefersDescriptionOverSummaryWhenBothPresent() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Summary text Description text """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -148,11 +168,10 @@ void prefersDescriptionOverSummaryWhenBothPresent() throws IOException { @Test void fallsToDescriptionWhenSummaryBlank() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Fallback description """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -161,8 +180,7 @@ void fallsToDescriptionWhenSummaryBlank() throws IOException { @Test void extractsLanguageISO() throws IOException { - String xml = wrapInComicInfo("en"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("en"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -171,12 +189,11 @@ void extractsLanguageISO() throws IOException { @Test void returnsMetadataForCorruptFile() throws IOException { - Path corruptPath = tempDir.resolve("corrupt.cbz"); - Files.write(corruptPath, new byte[]{0x50, 0x4B, 0x03, 0x04, 0x00}); + Path path = mockRaisesException(); - BookMetadata metadata = extractor.extractMetadata(corruptPath.toFile()); + BookMetadata metadata = extractor.extractMetadata(path); - assertThat(metadata.getTitle()).isEqualTo("corrupt"); + assertThat(metadata.getTitle()).isEqualTo("test"); } } @@ -185,8 +202,7 @@ class SeriesAndNumberParsing { @Test void extractsSeriesName() throws IOException { - String xml = wrapInComicInfo("The Sandman"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("The Sandman"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -195,8 +211,7 @@ void extractsSeriesName() throws IOException { @Test void extractsSeriesNumberAsFloat() throws IOException { - String xml = wrapInComicInfo("3.5"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("3.5"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -205,8 +220,7 @@ void extractsSeriesNumberAsFloat() throws IOException { @Test void extractsWholeSeriesNumber() throws IOException { - String xml = wrapInComicInfo("12"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("12"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -215,8 +229,7 @@ void extractsWholeSeriesNumber() throws IOException { @Test void handlesInvalidSeriesNumber() throws IOException { - String xml = wrapInComicInfo("abc"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("abc"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -225,8 +238,7 @@ void handlesInvalidSeriesNumber() throws IOException { @Test void extractsSeriesTotal() throws IOException { - String xml = wrapInComicInfo("75"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("75"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -235,8 +247,7 @@ void extractsSeriesTotal() throws IOException { @Test void extractsPageCount() throws IOException { - String xml = wrapInComicInfo("32"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("32"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -245,11 +256,10 @@ void extractsPageCount() throws IOException { @Test void prefersPageCountOverPages() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" 32 48 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -258,8 +268,7 @@ void prefersPageCountOverPages() throws IOException { @Test void fallsToPagesWhenPageCountMissing() throws IOException { - String xml = wrapInComicInfo("48"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("48"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -272,12 +281,11 @@ class DateParsing { @Test void parsesFullDate() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" 2023 6 15 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -286,8 +294,7 @@ void parsesFullDate() throws IOException { @Test void parsesYearOnly() throws IOException { - String xml = wrapInComicInfo("1986"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("1986"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -296,11 +303,10 @@ void parsesYearOnly() throws IOException { @Test void parsesYearAndMonth() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" 2020 11 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -309,11 +315,10 @@ void parsesYearAndMonth() throws IOException { @Test void returnsNullForMissingYear() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" 6 15 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -322,12 +327,11 @@ void returnsNullForMissingYear() throws IOException { @Test void returnsNullForInvalidDate() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" 2023 13 32 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -336,8 +340,7 @@ void returnsNullForInvalidDate() throws IOException { @Test void handlesNonNumericYear() throws IOException { - String xml = wrapInComicInfo("unknown"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("unknown"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -350,8 +353,7 @@ class IsbnParsing { @Test void extractsValid13DigitGtin() throws IOException { - String xml = wrapInComicInfo("9781234567890"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("9781234567890"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -360,8 +362,7 @@ void extractsValid13DigitGtin() throws IOException { @Test void normalizesGtinWithDashes() throws IOException { - String xml = wrapInComicInfo("978-1-234-56789-0"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("978-1-234-56789-0"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -370,8 +371,7 @@ void normalizesGtinWithDashes() throws IOException { @Test void normalizesGtinWithSpaces() throws IOException { - String xml = wrapInComicInfo("978 1 234 56789 0"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("978 1 234 56789 0"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -380,8 +380,7 @@ void normalizesGtinWithSpaces() throws IOException { @Test void rejectsInvalidGtin() throws IOException { - String xml = wrapInComicInfo("12345"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("12345"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -390,8 +389,7 @@ void rejectsInvalidGtin() throws IOException { @Test void rejectsNonNumericGtin() throws IOException { - String xml = wrapInComicInfo("978ABC1234567"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("978ABC1234567"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -400,8 +398,7 @@ void rejectsNonNumericGtin() throws IOException { @Test void ignoresBlankGtin() throws IOException { - String xml = wrapInComicInfo(" "); - File cbz = createCbz(xml); + Path cbz = mockComicInfo(" "); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -414,8 +411,7 @@ class AuthorsAndCategories { @Test void extractsSingleWriter() throws IOException { - String xml = wrapInComicInfo("Alan Moore"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Alan Moore"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -424,8 +420,7 @@ void extractsSingleWriter() throws IOException { @Test void splitsMultipleWritersByComma() throws IOException { - String xml = wrapInComicInfo("Alan Moore, Dave Gibbons"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Alan Moore, Dave Gibbons"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -434,8 +429,7 @@ void splitsMultipleWritersByComma() throws IOException { @Test void splitsWritersBySemicolon() throws IOException { - String xml = wrapInComicInfo("Neil Gaiman; Mike Carey"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Neil Gaiman; Mike Carey"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -444,8 +438,7 @@ void splitsWritersBySemicolon() throws IOException { @Test void extractsGenreAsCategories() throws IOException { - String xml = wrapInComicInfo("Superhero, Action"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Superhero, Action"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -454,8 +447,7 @@ void extractsGenreAsCategories() throws IOException { @Test void extractsTagsFromXml() throws IOException { - String xml = wrapInComicInfo("dark, gritty; mature"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("dark, gritty; mature"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -464,8 +456,7 @@ void extractsTagsFromXml() throws IOException { @Test void returnsNullAuthorsWhenWriterMissing() throws IOException { - String xml = wrapInComicInfo("No Writer"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("No Writer"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -474,8 +465,7 @@ void returnsNullAuthorsWhenWriterMissing() throws IOException { @Test void ignoresEmptyValuesInSplit() throws IOException { - String xml = wrapInComicInfo("Alan Moore,,, Dave Gibbons"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Alan Moore,,, Dave Gibbons"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -488,8 +478,7 @@ class ComicMetadataExtraction { @Test void extractsIssueNumber() throws IOException { - String xml = wrapInComicInfo("42"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("42"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -499,11 +488,10 @@ void extractsIssueNumber() throws IOException { @Test void extractsVolume() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Batman 2016 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -514,11 +502,10 @@ void extractsVolume() throws IOException { @Test void extractsStoryArc() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Court of Owls 3 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -529,11 +516,10 @@ void extractsStoryArc() throws IOException { @Test void extractsAlternateSeries() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Detective Comics 500 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -544,7 +530,7 @@ void extractsAlternateSeries() throws IOException { @Test void extractsCreatorRoles() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Jim Lee, Greg Capullo Scott Williams Alex Sinclair @@ -552,7 +538,6 @@ void extractsCreatorRoles() throws IOException { Jim Lee Bob Harras """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -567,8 +552,7 @@ void extractsCreatorRoles() throws IOException { @Test void extractsImprint() throws IOException { - String xml = wrapInComicInfo("Vertigo"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Vertigo"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -577,8 +561,7 @@ void extractsImprint() throws IOException { @Test void extractsFormat() throws IOException { - String xml = wrapInComicInfo("Trade Paperback"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Trade Paperback"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -587,8 +570,7 @@ void extractsFormat() throws IOException { @Test void extractsBlackAndWhiteYes() throws IOException { - String xml = wrapInComicInfo("Yes"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Yes"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -597,8 +579,7 @@ void extractsBlackAndWhiteYes() throws IOException { @Test void extractsBlackAndWhiteTrue() throws IOException { - String xml = wrapInComicInfo("true"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("true"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -607,8 +588,7 @@ void extractsBlackAndWhiteTrue() throws IOException { @Test void blackAndWhiteNotSetForNo() throws IOException { - String xml = wrapInComicInfo("No"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("No"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -619,8 +599,7 @@ void blackAndWhiteNotSetForNo() throws IOException { @Test void extractsMangaYes() throws IOException { - String xml = wrapInComicInfo("Yes"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Yes"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -631,8 +610,7 @@ void extractsMangaYes() throws IOException { @Test void extractsMangaRightToLeft() throws IOException { - String xml = wrapInComicInfo("YesAndRightToLeft"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("YesAndRightToLeft"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -643,8 +621,7 @@ void extractsMangaRightToLeft() throws IOException { @Test void extractsMangaNo() throws IOException { - String xml = wrapInComicInfo("No"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("No"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -655,12 +632,11 @@ void extractsMangaNo() throws IOException { @Test void extractsCharactersTeamsLocations() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Batman, Robin Justice League; Teen Titans Gotham City, Metropolis """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -672,8 +648,7 @@ void extractsCharactersTeamsLocations() throws IOException { @Test void noComicMetadataWhenNoComicFieldsPresent() throws IOException { - String xml = wrapInComicInfo("Just a title"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Just a title"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -682,8 +657,7 @@ void noComicMetadataWhenNoComicFieldsPresent() throws IOException { @Test void extractsWebLink() throws IOException { - String xml = wrapInComicInfo("https://example.com/comic"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("https://example.com/comic"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -692,8 +666,7 @@ void extractsWebLink() throws IOException { @Test void extractsNotes() throws IOException { - String xml = wrapInComicInfo("Some notes here"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("Some notes here"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -706,8 +679,7 @@ class WebFieldParsing { @Test void extractsGoodreadsIdFromUrl() throws IOException { - String xml = wrapInComicInfo("https://www.goodreads.com/book/show/12345-some-book"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("https://www.goodreads.com/book/show/12345-some-book"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -716,8 +688,7 @@ void extractsGoodreadsIdFromUrl() throws IOException { @Test void extractsAsinFromAmazonUrl() throws IOException { - String xml = wrapInComicInfo("https://www.amazon.com/dp/B08N5WRWNW"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("https://www.amazon.com/dp/B08N5WRWNW"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -726,8 +697,7 @@ void extractsAsinFromAmazonUrl() throws IOException { @Test void extractsComicvineIdFromUrl() throws IOException { - String xml = wrapInComicInfo("https://comicvine.gamespot.com/issue/batman-1/4000-12345"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("https://comicvine.gamespot.com/issue/batman-1/4000-12345"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -736,8 +706,7 @@ void extractsComicvineIdFromUrl() throws IOException { @Test void extractsHardcoverIdFromUrl() throws IOException { - String xml = wrapInComicInfo("https://hardcover.app/books/batman-year-one"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("https://hardcover.app/books/batman-year-one"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -746,9 +715,8 @@ void extractsHardcoverIdFromUrl() throws IOException { @Test void extractsMultipleIdsFromSpaceSeparatedUrls() throws IOException { - String xml = wrapInComicInfo( + Path cbz = mockComicInfo( "https://www.goodreads.com/book/show/99999 https://www.amazon.com/dp/B012345678"); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -762,8 +730,7 @@ class BookLoreNoteParsing { @Test void extractsMoodsFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:Moods] dark, brooding"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:Moods] dark, brooding"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -772,8 +739,7 @@ void extractsMoodsFromNotes() throws IOException { @Test void extractsSubtitleFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:Subtitle] The Dark Knight Returns"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:Subtitle] The Dark Knight Returns"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -782,8 +748,7 @@ void extractsSubtitleFromNotes() throws IOException { @Test void extractsIsbn13FromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:ISBN13] 9781234567890"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:ISBN13] 9781234567890"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -792,8 +757,7 @@ void extractsIsbn13FromNotes() throws IOException { @Test void extractsIsbn10FromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:ISBN10] 0123456789"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:ISBN10] 0123456789"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -802,8 +766,7 @@ void extractsIsbn10FromNotes() throws IOException { @Test void extractsAsinFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:ASIN] B08N5WRWNW"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:ASIN] B08N5WRWNW"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -812,8 +775,7 @@ void extractsAsinFromNotes() throws IOException { @Test void extractsGoodreadsIdFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:GoodreadsId] 12345"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:GoodreadsId] 12345"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -822,8 +784,7 @@ void extractsGoodreadsIdFromNotes() throws IOException { @Test void extractsComicvineIdFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:ComicvineId] 4000-12345"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:ComicvineId] 4000-12345"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -832,12 +793,11 @@ void extractsComicvineIdFromNotes() throws IOException { @Test void extractsRatingsFromNotes() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" [BookLore:AmazonRating] 4.5 [BookLore:GoodreadsRating] 4.2 [BookLore:HardcoverRating] 3.8 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -848,8 +808,7 @@ void extractsRatingsFromNotes() throws IOException { @Test void extractsHardcoverBookIdFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:HardcoverBookId] abc-123"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:HardcoverBookId] abc-123"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -858,8 +817,7 @@ void extractsHardcoverBookIdFromNotes() throws IOException { @Test void extractsHardcoverIdFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:HardcoverId] hc-456"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:HardcoverId] hc-456"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -868,8 +826,7 @@ void extractsHardcoverIdFromNotes() throws IOException { @Test void extractsGoogleIdFromNotes() throws IOException { - String xml = wrapInComicInfo("[BookLore:GoogleId] google-789"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:GoogleId] google-789"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -878,11 +835,10 @@ void extractsGoogleIdFromNotes() throws IOException { @Test void extractsLubimyczytacFromNotes() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" [BookLore:LubimyczytacId] lub-123 [BookLore:LubimyczytacRating] 4.1 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -892,11 +848,10 @@ void extractsLubimyczytacFromNotes() throws IOException { @Test void extractsRanobedbFromNotes() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" [BookLore:RanobedbId] rdb-456 [BookLore:RanobedbRating] 3.9 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -906,11 +861,10 @@ void extractsRanobedbFromNotes() throws IOException { @Test void mergesTagsFromXmlAndNotes() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" existing-tag [BookLore:Tags] new-tag, another-tag """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -919,8 +873,7 @@ void mergesTagsFromXmlAndNotes() throws IOException { @Test void notesUsedAsDescriptionWhenSummaryMissing() throws IOException { - String xml = wrapInComicInfo("This is a great comic.\n[BookLore:ISBN13] 9780000000000"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("This is a great comic.\n[BookLore:ISBN13] 9780000000000"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -930,11 +883,10 @@ void notesUsedAsDescriptionWhenSummaryMissing() throws IOException { @Test void notesNotUsedAsDescriptionWhenSummaryPresent() throws IOException { - String xml = wrapInComicInfo(""" + Path cbz = mockComicInfo(""" Official summary This is a great comic.\n[BookLore:ISBN13] 9780000000000 """); - File cbz = createCbz(xml); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -943,8 +895,7 @@ void notesNotUsedAsDescriptionWhenSummaryPresent() throws IOException { @Test void handlesInvalidRatingGracefully() throws IOException { - String xml = wrapInComicInfo("[BookLore:AmazonRating] not-a-number"); - File cbz = createCbz(xml); + Path cbz = mockComicInfo("[BookLore:AmazonRating] not-a-number"); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -957,24 +908,24 @@ class CoverExtraction { @Test void extractsCoverFromCbzWithImage() throws IOException { - File cbz = createCbz(wrapInComicInfo("Test"), true); + byte[] expected = createMinimalJpeg(1); + Path cbz = mockArchiveContents(Map.of( + "ComicInfo.xml", wrapInComicInfo("Test").getBytes(), + "path_001.jpg", expected + )); - byte[] cover = extractor.extractCover(cbz); + byte[] actual = extractor.extractCover(cbz); - assertThat(cover).isNotNull(); - assertThat(cover.length).isGreaterThan(0); + assertThat(actual).isEqualTo(expected); } @Test void returnPlaceholderForEmptyCbz() throws IOException { - Path cbzPath = tempDir.resolve("empty.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("readme.txt")); - zos.write("no images here".getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - } + Path cbzPath = mockArchiveContents(Map.of( + "readme.txt", "no images here".getBytes() + )); - byte[] cover = extractor.extractCover(cbzPath.toFile()); + byte[] cover = extractor.extractCover(cbzPath); assertThat(cover).isNotNull(); // Placeholder is a generated image @@ -986,161 +937,111 @@ void returnPlaceholderForEmptyCbz() throws IOException { @Test void extractsCoverFromFirstAlphabeticalImage() throws IOException { - Path cbzPath = tempDir.resolve("multipage.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("page003.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - - zos.putNextEntry(new ZipEntry("page002.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } + byte[] expected = createMinimalJpeg(2); + Path cbzPath = mockArchiveContents(Map.of( + "page003.jpg", createMinimalJpeg(1), + "page001.jpg", expected, + "page002.jpg", createMinimalJpeg(3) + )); - byte[] cover = extractor.extractCover(cbzPath.toFile()); + byte[] actual = extractor.extractCover(cbzPath); - assertThat(cover).isNotNull(); - assertThat(cover.length).isGreaterThan(0); + assertThat(actual).isEqualTo(expected); } @Test void prefersCoverNamedFileOverAlphabetical() throws IOException { - Path cbzPath = tempDir.resolve("withcover.cbz"); - byte[] coverJpeg = createMinimalJpeg(); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - - zos.putNextEntry(new ZipEntry("cover.jpg")); - zos.write(coverJpeg); - zos.closeEntry(); - } + byte[] expected = createMinimalJpeg(1); + Path cbzPath = mockArchiveContents(Map.of( + "page001.jpg", createMinimalJpeg(2), + "cover.jpg", expected + )); - byte[] cover = extractor.extractCover(cbzPath.toFile()); + byte[] actual = extractor.extractCover(cbzPath); - assertThat(cover).isNotNull(); + assertThat(actual).isEqualTo(expected); } @Test void extractsCoverViaFrontCoverPageElement() throws IOException { - String xml = """ - - + String xml = wrapInComicInfo(""" Test - + + + - - """; - Path cbzPath = tempDir.resolve("frontcover.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("ComicInfo.xml")); - zos.write(xml.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); + """); - zos.putNextEntry(new ZipEntry("cover_image.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); + byte[] expected = createMinimalJpeg(1); - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } + Path cbzPath = mockArchiveContents(Map.of( + "ComicInfo.xml", xml.getBytes(), + "page001.jpg", createMinimalJpeg(2), + "cover_image.jpg", expected, + "page003.jpg", createMinimalJpeg(3) + )); - byte[] cover = extractor.extractCover(cbzPath.toFile()); + byte[] actual = extractor.extractCover(cbzPath); - assertThat(cover).isNotNull(); - assertThat(cover.length).isGreaterThan(0); + assertThat(actual).isEqualTo(expected); } @Test void frontCoverPageByImageIndex() throws IOException { - String xml = """ - - + String xml = wrapInComicInfo(""" Test - - """; - Path cbzPath = tempDir.resolve("indexcover.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("ComicInfo.xml")); - zos.write(xml.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); + """); - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } + byte[] expected = createMinimalJpeg(1); + Path cbzPath = mockArchiveContents(Map.of( + "ComicInfo.xml", xml.getBytes(), + "page001.jpg", expected + )); - byte[] cover = extractor.extractCover(cbzPath.toFile()); + byte[] actual = extractor.extractCover(cbzPath); - assertThat(cover).isNotNull(); + assertThat(actual).isEqualTo(expected); } @Test void skipsMacOsxEntries() throws IOException { - Path cbzPath = tempDir.resolve("macosx.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("__MACOSX/._cover.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } + byte[] expected = createMinimalJpeg(1); + Path cbzPath = mockArchiveContents(Map.of( + "__MACOSX/._cover.jpg", createMinimalJpeg(2), + "page001.jpg", expected + )); - byte[] cover = extractor.extractCover(cbzPath.toFile()); + byte[] actual = extractor.extractCover(cbzPath); - assertThat(cover).isNotNull(); + assertThat(actual).isEqualTo(expected); } @Test void skipsDotFiles() throws IOException { - Path cbzPath = tempDir.resolve("dotfiles.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry(".hidden.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - - zos.putNextEntry(new ZipEntry(".DS_Store")); - zos.write("data".getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); + byte[] expected = createMinimalJpeg(1); + Path cbzPath = mockArchiveContents(Map.of( + ".hidden.jpg", createMinimalJpeg(2), + ".DS_Store", "data".getBytes(), + "actual_page.jpg", expected + )); - zos.putNextEntry(new ZipEntry("actual_page.png")); - zos.write(createMinimalPng()); - zos.closeEntry(); - } + byte[] cover = extractor.extractCover(cbzPath); - byte[] cover = extractor.extractCover(cbzPath.toFile()); - - assertThat(cover).isNotNull(); + assertThat(cover).isEqualTo(expected); } @Test void returnsPlaceholderForCorruptFile() throws IOException { - Path corruptPath = tempDir.resolve("corrupt.cbz"); - Files.write(corruptPath, new byte[]{0x50, 0x4B, 0x03, 0x04, 0x00}); + Path path = mockRaisesException(); - byte[] cover = extractor.extractCover(corruptPath.toFile()); + byte[] cover = extractor.extractCover(path); assertThat(cover).isNotNull(); } - - private byte[] createMinimalPng() throws IOException { - BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(img, "png", baos); - return baos.toByteArray(); - } } @Nested @@ -1148,38 +1049,26 @@ class ComicInfoCaseInsensitive { @Test void findsComicInfoRegardlessOfCase() throws IOException { - Path cbzPath = tempDir.resolve("casetest.cbz"); String xml = wrapInComicInfo("Case Test"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("COMICINFO.XML")); - zos.write(xml.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } + Path cbzPath = mockArchiveContents(Map.of( + "COMICINFO.XML", xml.getBytes() + )); - BookMetadata metadata = extractor.extractMetadata(cbzPath.toFile()); + BookMetadata metadata = extractor.extractMetadata(cbzPath); assertThat(metadata.getTitle()).isEqualTo("Case Test"); } @Test void findsComicInfoInSubdirectory() throws IOException { - Path cbzPath = tempDir.resolve("subdir.cbz"); String xml = wrapInComicInfo("Subdir Test"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("metadata/ComicInfo.xml")); - zos.write(xml.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - zos.putNextEntry(new ZipEntry("page001.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } + Path cbzPath = mockArchiveContents(Map.of( + "metadata/ComicInfo.xml", xml.getBytes() + )); - BookMetadata metadata = extractor.extractMetadata(cbzPath.toFile()); + BookMetadata metadata = extractor.extractMetadata(cbzPath); assertThat(metadata.getTitle()).isEqualTo("Subdir Test"); } @@ -1190,9 +1079,7 @@ class FullComicInfoIntegration { @Test void extractsAllFieldsFromRichComicInfo() throws IOException { - String xml = """ - - + Path cbz = mockComicInfo(""" Batman: The Dark Knight Returns Batman 1 @@ -1223,9 +1110,7 @@ void extractsAllFieldsFromRichComicInfo() throws IOException { [BookLore:Subtitle] Part One [BookLore:Moods] dark, intense, brooding [BookLore:ISBN10] 1563893428 - - """; - File cbz = createCbz(xml); + """); BookMetadata metadata = extractor.extractMetadata(cbz); @@ -1271,34 +1156,26 @@ class ImageFormatSupport { @Test void recognizesJpgExtension() throws IOException { - Path cbzPath = tempDir.resolve("formats.cbz"); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("image.jpg")); - zos.write(createMinimalJpeg()); - zos.closeEntry(); - } - - byte[] cover = extractor.extractCover(cbzPath.toFile()); - assertThat(cover).isNotNull(); - assertThat(cover.length).isGreaterThan(0); + byte[] expected = createMinimalJpeg(1); + Path cbzPath = mockArchiveContents(Map.of( + "image.jpg", expected + )); + + byte[] actual = extractor.extractCover(cbzPath); + + assertThat(actual).isEqualTo(expected); } @Test void recognizesPngExtension() throws IOException { - Path cbzPath = tempDir.resolve("png.cbz"); - BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(img, "png", baos); - - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(cbzPath))) { - zos.putNextEntry(new ZipEntry("image.png")); - zos.write(baos.toByteArray()); - zos.closeEntry(); - } - - byte[] cover = extractor.extractCover(cbzPath.toFile()); - assertThat(cover).isNotNull(); - assertThat(cover.length).isGreaterThan(0); + byte[] expected = createMinimalPng(); + Path cbzPath = mockArchiveContents(Map.of( + "image.png", expected + )); + + byte[] actual = extractor.extractCover(cbzPath); + + assertThat(actual).isEqualTo(expected); } } @@ -1307,20 +1184,18 @@ class UnknownArchiveType { @Test void returnsFallbackForNonArchiveFile() throws IOException { - Path txtPath = tempDir.resolve("notanarchive.txt"); - Files.writeString(txtPath, "This is not an archive."); + Path path = mockRaisesException(); - BookMetadata metadata = extractor.extractMetadata(txtPath.toFile()); + BookMetadata metadata = extractor.extractMetadata(path); - assertThat(metadata.getTitle()).isEqualTo("notanarchive"); + assertThat(metadata.getTitle()).isEqualTo("test"); } @Test void returnsPlaceholderCoverForNonArchiveFile() throws IOException { - Path txtPath = tempDir.resolve("notanarchive.txt"); - Files.writeString(txtPath, "This is not an archive."); + Path path = mockRaisesException(); - byte[] cover = extractor.extractCover(txtPath.toFile()); + byte[] cover = extractor.extractCover(path); assertThat(cover).isNotNull(); } diff --git a/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxComicInfoComplianceTest.java b/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxComicInfoComplianceTest.java index 462c81289..7965f3a70 100644 --- a/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxComicInfoComplianceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxComicInfoComplianceTest.java @@ -6,10 +6,12 @@ import org.booklore.model.entity.BookMetadataEntity; import org.booklore.model.entity.MoodEntity; import org.booklore.model.entity.TagEntity; +import org.booklore.service.ArchiveService; import org.booklore.service.appsettings.AppSettingService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; import org.mockito.Mockito; import java.io.File; @@ -28,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +@EnabledIf("org.booklore.service.ArchiveService#isAvailable") class CbxComicInfoComplianceTest { private CbxMetadataWriter writer; @@ -47,7 +50,7 @@ void setup() throws Exception { settings.setMetadataPersistenceSettings(persistence); Mockito.when(appSettingService.getAppSettings()).thenReturn(settings); - writer = new CbxMetadataWriter(appSettingService); + writer = new CbxMetadataWriter(appSettingService, new ArchiveService()); tempDir = Files.createTempDirectory("compliance_test_"); } diff --git a/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxMetadataWriterTest.java b/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxMetadataWriterTest.java index 0f61b083a..7faf911a0 100644 --- a/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxMetadataWriterTest.java +++ b/booklore-api/src/test/java/org/booklore/service/metadata/writer/CbxMetadataWriterTest.java @@ -6,10 +6,12 @@ import org.booklore.model.entity.*; import org.booklore.model.enums.BookFileType; import org.booklore.model.enums.ComicCreatorRole; +import org.booklore.service.ArchiveService; import org.booklore.service.appsettings.AppSettingService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; import org.mockito.Mockito; import org.w3c.dom.Document; @@ -34,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.*; +@EnabledIf("org.booklore.service.ArchiveService#isAvailable") class CbxMetadataWriterTest { private CbxMetadataWriter writer; @@ -53,7 +56,7 @@ void setup() throws Exception { settings.setMetadataPersistenceSettings(persistence); Mockito.when(appSettingService.getAppSettings()).thenReturn(settings); - writer = new CbxMetadataWriter(appSettingService); + writer = new CbxMetadataWriter(appSettingService, new ArchiveService()); tempDir = Files.createTempDirectory("cbx_writer_test_"); } @@ -260,13 +263,14 @@ void saveMetadataToFile_cbz_updatesExistingComicInfo() throws Exception { @Test void saveMetadataToFile_ZipNamedAsCbr_ShouldUpdateMetadata() throws Exception { File zipAsCbr = createCbz(tempDir.resolve("mismatched.cbr"), new String[]{"page1.jpg"}); + Path zipAsCbz = tempDir.resolve("mismatched.cbz"); BookMetadataEntity meta = new BookMetadataEntity(); meta.setTitle("Mismatched Title"); writer.saveMetadataToFile(zipAsCbr, meta, null, new MetadataClearFlags()); - try (ZipFile zip = new ZipFile(zipAsCbr)) { + try (ZipFile zip = new ZipFile(zipAsCbz.toFile())) { ZipEntry ci = zip.getEntry("ComicInfo.xml"); assertNotNull(ci, "ComicInfo.xml should be present"); Document doc = parseXml(zip.getInputStream(ci)); diff --git a/booklore-api/src/test/java/org/booklore/service/reader/CbxReaderServiceTest.java b/booklore-api/src/test/java/org/booklore/service/reader/CbxReaderServiceTest.java index 97603a67a..dea14d330 100644 --- a/booklore-api/src/test/java/org/booklore/service/reader/CbxReaderServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/reader/CbxReaderServiceTest.java @@ -1,15 +1,9 @@ package org.booklore.service.reader; -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.booklore.exception.APIException; import org.booklore.exception.ApiError; import org.booklore.model.entity.BookEntity; import org.booklore.repository.BookRepository; +import org.booklore.service.ArchiveService; import org.booklore.util.FileUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,14 +12,12 @@ import org.mockito.junit.jupiter.MockitoExtension; 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.time.Instant; +import java.util.*; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -36,6 +28,9 @@ class CbxReaderServiceTest { @Mock BookRepository bookRepository; + @Mock + ArchiveService archiveService; + @InjectMocks CbxReaderService cbxReaderService; @@ -44,289 +39,90 @@ class CbxReaderServiceTest { BookEntity bookEntity; Path cbzPath; - Path cb7Path; - Path cbrPath; @BeforeEach 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 testGetAvailablePages_CBZ_Success() throws Exception { - when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); - - ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg"); - ZipArchiveEntry entry2 = new ZipArchiveEntry("2.png"); - Enumeration entries = Collections.enumeration(List.of(entry1, entry2)); - 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 zipFileStatic = mockStatic(ZipFile.class)) { - zipFileStatic.when(ZipFile::builder).thenReturn(builder); - - Files.createFile(cbzPath); - Files.setLastModifiedTime(cbzPath, FileTime.fromMillis(System.currentTimeMillis())); - - List pages = cbxReaderService.getAvailablePages(1L); - assertEquals(List.of(1, 2), pages); - } - } - } - - @Test - void testGetAvailablePages_CBZ_Fallback_Success() throws Exception { - when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); - - // Unicode enabled -> throws Exception - ZipFile zipFileFail = mock(ZipFile.class); - when(zipFileFail.getEntries()).thenThrow(new IllegalArgumentException("Corrupt extra fields")); - - // Unicode disabled -> returns valid entries - ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg"); - Enumeration entries = Collections.enumeration(List.of(entry1)); - ZipFile zipFileSuccess = mock(ZipFile.class); - when(zipFileSuccess.getEntries()).thenReturn(entries); - - ZipFile.Builder builder = mock(ZipFile.Builder.class, RETURNS_DEEP_STUBS); - when(builder.setPath(any(Path.class))).thenReturn(builder); - when(builder.setCharset(any(Charset.class))).thenReturn(builder); - when(builder.setIgnoreLocalFileHeader(anyBoolean())).thenReturn(builder); - - // Mock builder behavior based on UseUnicodeExtraFields call - when(builder.setUseUnicodeExtraFields(anyBoolean())).thenAnswer(invocation -> { - return builder; - }); - - - when(builder.get()) - .thenReturn(zipFileFail) // 1. Fast, Unicode=True - .thenReturn(zipFileFail) // 2. Slow, Unicode=True - .thenReturn(zipFileSuccess); // 3. Fast, Unicode=False - - try (MockedStatic zipFileStatic = mockStatic(ZipFile.class)) { - zipFileStatic.when(ZipFile::builder).thenReturn(builder); - - Files.createFile(cbzPath); - Files.setLastModifiedTime(cbzPath, FileTime.fromMillis(System.currentTimeMillis())); - - List pages = cbxReaderService.getAvailablePages(1L); - assertEquals(List.of(1), pages); - - // Verify that we eventually called with useUnicode=false - verify(builder).setUseUnicodeExtraFields(eq(false)); - } - } - } - - @Test - void testStreamPageImage_CBZ_Success() throws Exception { - when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); - - ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg"); - Enumeration 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 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() { + void testGetAvailablePages_ThrowsOnMissingBook() { when(bookRepository.findByIdWithBookFiles(2L)).thenReturn(Optional.empty()); assertThrows(ApiError.BOOK_NOT_FOUND.createException().getClass(), () -> cbxReaderService.getAvailablePages(2L)); } @Test - void testGetAvailablePages_CB7_Success() throws Exception { + void testGetAvailablePages_Success() throws Exception { when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cb7Path); - - 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); + when(archiveService.streamEntryNames(cbzPath)).then((i) -> Stream.of("1.jpg")); - SevenZFile.Builder builder = mock(SevenZFile.Builder.class, RETURNS_DEEP_STUBS); - when(builder.setPath(cb7Path)).thenReturn(builder); - when(builder.get()).thenReturn(sevenZFile); - - try (MockedStatic sevenZFileStatic = mockStatic(SevenZFile.class)) { - sevenZFileStatic.when(SevenZFile::builder).thenReturn(builder); - - Files.createFile(cb7Path); - Files.setLastModifiedTime(cb7Path, FileTime.fromMillis(System.currentTimeMillis())); - - List pages = cbxReaderService.getAvailablePages(1L); - assertEquals(List.of(1), pages); - } - } - } - - @Test - void testGetAvailablePages_CBR_Success() throws Exception { - when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbrPath); - - FileHeader header = mock(FileHeader.class); - when(header.isDirectory()).thenReturn(false); - when(header.getFileName()).thenReturn("1.jpg"); - - try (MockedConstruction 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())); + try ( + MockedStatic fileUtilsStatic = mockStatic(FileUtils.class); + MockedStatic filesStatic = mockStatic(Files.class); + ) { + fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); + filesStatic.when(() -> Files.getLastModifiedTime(cbzPath)).thenReturn(FileTime.from(Instant.now())); - List pages = cbxReaderService.getAvailablePages(1L); - assertEquals(List.of(1), pages); - } + List pages = cbxReaderService.getAvailablePages(1L); + assertEquals(List.of(1), pages); } } @Test - void testStreamPageImage_CBR_Success() throws Exception { + void testStreamPageImage_Success() throws Exception { when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbrPath); - - FileHeader header = mock(FileHeader.class); - when(header.isDirectory()).thenReturn(false); - when(header.getFileName()).thenReturn("1.jpg"); - - try (MockedConstruction 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())); + when(archiveService.streamEntryNames(cbzPath)).then((i) -> Stream.of("1.jpg")); + when( + archiveService.transferEntryTo(eq(cbzPath), eq("1.jpg"), any()) + ).then((i) -> {i.getArgument(2, OutputStream.class).write(new byte[]{1, 2, 3}); return null; }); + try ( + MockedStatic fileUtilsStatic = mockStatic(FileUtils.class); + MockedStatic filesStatic = mockStatic(Files.class); + ) { + fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); + filesStatic.when(() -> Files.getLastModifiedTime(cbzPath)).thenReturn(FileTime.from(Instant.now())); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - cbxReaderService.streamPageImage(1L, 1, out); - assertArrayEquals(new byte[]{1, 2, 3}, out.toByteArray()); - } + 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.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { + when(archiveService.streamEntryNames(cbzPath)).then((i) -> Stream.of("1.jpg")); + try ( + MockedStatic fileUtilsStatic = mockStatic(FileUtils.class); + MockedStatic filesStatic = mockStatic(Files.class); + ) { fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); + filesStatic.when(() -> Files.getLastModifiedTime(cbzPath)).thenReturn(FileTime.from(Instant.now())); - ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg"); - Enumeration 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 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.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { - fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(unknownPath); - - Files.createFile(unknownPath); - Files.setLastModifiedTime(unknownPath, FileTime.fromMillis(System.currentTimeMillis())); - - assertThrows(APIException.class, () -> cbxReaderService.getAvailablePages(1L)); + assertThrows( + FileNotFoundException.class, + () -> cbxReaderService.streamPageImage(1L, 2, new ByteArrayOutputStream()) + ); } } @Test void testStreamPageImage_EntryNotFound_Throws() throws Exception { when(bookRepository.findByIdWithBookFiles(1L)).thenReturn(Optional.of(bookEntity)); - try (MockedStatic fileUtilsStatic = mockStatic(FileUtils.class)) { + when(archiveService.streamEntryNames(cbzPath)).then((i) -> Stream.of("1.jpg")); + try ( + MockedStatic fileUtilsStatic = mockStatic(FileUtils.class); + MockedStatic filesStatic = mockStatic(Files.class); + ) { fileUtilsStatic.when(() -> FileUtils.getBookFullPath(bookEntity)).thenReturn(cbzPath); + filesStatic.when(() -> Files.getLastModifiedTime(cbzPath)).thenReturn(FileTime.from(Instant.now())); - ZipArchiveEntry entry1 = new ZipArchiveEntry("1.jpg"); - Enumeration 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 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())); - } + assertThrows( + FileNotFoundException.class, + () -> cbxReaderService.streamPageImage(1L, 2, new ByteArrayOutputStream()) + ); } } } diff --git a/booklore-api/src/test/java/org/booklore/util/UnrarHelperIntegrationTest.java b/booklore-api/src/test/java/org/booklore/util/UnrarHelperIntegrationTest.java deleted file mode 100644 index 7bf209323..000000000 --- a/booklore-api/src/test/java/org/booklore/util/UnrarHelperIntegrationTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.booklore.util; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assumptions.assumeThat; - -class UnrarHelperIntegrationTest { - - private static final Path RAR5_CBR = Path.of("src/test/resources/cbx/test-rar5.cbr"); - - @BeforeAll - static void checkUnrarAvailable() { - assumeThat(UnrarHelper.isAvailable()) - .as("unrar binary must be on PATH to run these tests") - .isTrue(); - } - - @Test - void listEntries_returnsAllEntriesFromRar5Archive() throws IOException { - List entries = UnrarHelper.listEntries(RAR5_CBR); - - assertThat(entries).containsExactly( - "ComicInfo.xml", - "page_001.jpg", - "page_002.jpg", - "page_003.jpg" - ); - } - - @Test - void extractEntryBytes_extractsComicInfoXml() throws IOException { - byte[] bytes = UnrarHelper.extractEntryBytes(RAR5_CBR, "ComicInfo.xml"); - String xml = new String(bytes); - - assertThat(xml).contains("Test RAR5 Comic"); - assertThat(xml).contains("RAR5 Test Series"); - assertThat(xml).contains("Test Author"); - } - - @Test - void extractEntry_streamsImageToOutputStream() throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - UnrarHelper.extractEntry(RAR5_CBR, "page_001.jpg", out); - - byte[] imageBytes = out.toByteArray(); - assertThat(imageBytes).hasSizeGreaterThan(0); - assertThat(imageBytes[0]).isEqualTo((byte) 0xFF); - assertThat(imageBytes[1]).isEqualTo((byte) 0xD8); - } - - @Test - void extractAll_extractsAllFilesToDirectory(@TempDir Path tempDir) throws IOException { - UnrarHelper.extractAll(RAR5_CBR, tempDir); - - assertThat(tempDir.resolve("ComicInfo.xml")).exists(); - assertThat(tempDir.resolve("page_001.jpg")).exists(); - assertThat(tempDir.resolve("page_002.jpg")).exists(); - assertThat(tempDir.resolve("page_003.jpg")).exists(); - - String xml = Files.readString(tempDir.resolve("ComicInfo.xml")); - assertThat(xml).contains("Test RAR5 Comic"); - } - - @Test - void listEntries_throwsForNonExistentFile() { - Path bogus = Path.of("/tmp/does-not-exist.cbr"); - - assertThatThrownBy(() -> UnrarHelper.listEntries(bogus)) - .isInstanceOf(IOException.class) - .hasMessageContaining("unrar list failed"); - } - - @Test - void extractEntryBytes_throwsForNonExistentEntry() { - assertThatThrownBy(() -> UnrarHelper.extractEntryBytes(RAR5_CBR, "no-such-file.jpg")) - .isInstanceOf(IOException.class) - .hasMessageContaining("unrar extract failed"); - } -} diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index 114fe53eb..fa6e76105 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -3,10 +3,9 @@ services: build: context: . dockerfile_inline: | - FROM linuxserver/unrar:7.1.10 AS unrar-layer FROM gradle:9.3.1-jdk25-alpine - RUN apk add --no-cache libstdc++ libgcc - COPY --from=unrar-layer /usr/bin/unrar-alpine /usr/local/bin/unrar + RUN apk add --no-cache libstdc++ libgcc libarchive + RUN ln -s /usr/lib/libarchive.so.13 /usr/lib/libarchive.so command: sh -c "cd /booklore-api && ./gradlew bootRun" ports: - "${BACKEND_PORT:-6060}:6060"