diff --git a/pom.xml b/pom.xml
index bea9041..8807d1f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -131,10 +131,14 @@
compile
- com.github.junrar
- junrar
- 7.5.8
- compile
+ net.sf.sevenzipjbinding
+ sevenzipjbinding
+ 16.02-2.01
+
+
+ net.sf.sevenzipjbinding
+ sevenzipjbinding-all-platforms
+ 16.02-2.01
org.springframework.boot
diff --git a/src/main/java/com/magamochi/content/controller/ContentController.java b/src/main/java/com/magamochi/content/controller/ContentController.java
index 850cc7d..fb9a6b4 100644
--- a/src/main/java/com/magamochi/content/controller/ContentController.java
+++ b/src/main/java/com/magamochi/content/controller/ContentController.java
@@ -120,4 +120,16 @@ public class ContentController {
@Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) {
return DefaultResponseDTO.ok(contentImportService.getImportJobs(filter, pageable));
}
+
+ @Operation(
+ summary = "Retry a failed import job",
+ description =
+ "Resets a failed import job to PENDING so it will be re-processed by the scanner.",
+ tags = {"Content"},
+ operationId = "retryImportJob")
+ @PostMapping(value = "/import/jobs/{jobId}/retry")
+ public DefaultResponseDTO retryImportJob(@PathVariable Long jobId) {
+ contentImportService.retryImportJob(jobId);
+ return DefaultResponseDTO.ok().build();
+ }
}
diff --git a/src/main/java/com/magamochi/content/service/ContentImportService.java b/src/main/java/com/magamochi/content/service/ContentImportService.java
index 733534a..29f1ae8 100644
--- a/src/main/java/com/magamochi/content/service/ContentImportService.java
+++ b/src/main/java/com/magamochi/content/service/ContentImportService.java
@@ -7,6 +7,7 @@ import static org.apache.commons.lang3.StringUtils.isBlank;
import com.magamochi.catalog.model.specification.MangaImportJobSpecification;
import com.magamochi.catalog.service.MangaContentProviderService;
import com.magamochi.catalog.service.MangaResolutionService;
+import com.magamochi.common.exception.NotFoundException;
import com.magamochi.common.exception.UnprocessableException;
import com.magamochi.common.model.enumeration.ContentType;
import com.magamochi.content.model.dto.MangaImportJobDTO;
@@ -213,6 +214,28 @@ public class ContentImportService {
}
}
+ @Transactional
+ public void retryImportJob(Long jobId) {
+ var job =
+ mangaImportJobRepository
+ .findById(jobId)
+ .orElseThrow(() -> new NotFoundException("Import job not found: " + jobId));
+
+ if (job.getStatus() != ImportJobStatus.FAILED) {
+ throw new UnprocessableException(
+ "Only FAILED jobs can be retried. Current status: " + job.getStatus());
+ }
+
+ if (!s3Service.objectExists(job.getS3FileKey())) {
+ throw new UnprocessableException("The source file no longer exists in S3. Cannot retry.");
+ }
+
+ job.setStatus(ImportJobStatus.PENDING);
+ job.setErrorMessage(null);
+ job.setErrorStacktrace(null);
+ mangaImportJobRepository.save(job);
+ }
+
public Optional findImportJob(Long id) {
return mangaImportJobRepository.findById(id);
}
diff --git a/src/main/java/com/magamochi/content/service/archive/RarExtractor.java b/src/main/java/com/magamochi/content/service/archive/RarExtractor.java
deleted file mode 100644
index 72f1da7..0000000
--- a/src/main/java/com/magamochi/content/service/archive/RarExtractor.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.magamochi.content.service.archive;
-
-import static java.util.Objects.isNull;
-import static java.util.Objects.nonNull;
-
-import com.github.junrar.Archive;
-import com.github.junrar.rarfile.FileHeader;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.StandardCopyOption;
-import java.util.Map;
-import java.util.TreeMap;
-import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator;
-import org.springframework.stereotype.Component;
-
-@Component
-public class RarExtractor implements ArchiveExtractor {
-
- @Override
- public boolean supports(String mimeType) {
- if (isNull(mimeType)) {
- return false;
- }
-
- return mimeType.startsWith("application/x-rar-compressed")
- || mimeType.startsWith("application/rar");
- }
-
- @Override
- public Map extract(InputStream stream) throws Exception {
- Map entryMap =
- new TreeMap<>(CaseInsensitiveSimpleNaturalComparator.getInstance());
-
- var tempFile = Files.createTempFile("manga_import_rar_", ".rar");
-
- try {
- Files.copy(stream, tempFile, StandardCopyOption.REPLACE_EXISTING);
-
- try (var archive = new Archive(tempFile.toFile())) {
- FileHeader fileHeader;
- while (nonNull(fileHeader = archive.nextFileHeader())) {
- if (fileHeader.isDirectory()) {
- continue;
- }
-
- var os = new ByteArrayOutputStream();
- archive.extractFile(fileHeader, os);
- entryMap.put(fileHeader.getFileName(), os.toByteArray());
- }
- }
- } finally {
- Files.deleteIfExists(tempFile);
- }
-
- return entryMap;
- }
-}
diff --git a/src/main/java/com/magamochi/content/service/archive/SevenZipExtractor.java b/src/main/java/com/magamochi/content/service/archive/SevenZipExtractor.java
new file mode 100644
index 0000000..f92c6c4
--- /dev/null
+++ b/src/main/java/com/magamochi/content/service/archive/SevenZipExtractor.java
@@ -0,0 +1,130 @@
+package com.magamochi.content.service.archive;
+
+import static java.util.Objects.isNull;
+import static java.util.Objects.nonNull;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.Map;
+import java.util.TreeMap;
+import lombok.extern.slf4j.Slf4j;
+import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator;
+import net.sf.sevenzipjbinding.ExtractAskMode;
+import net.sf.sevenzipjbinding.ExtractOperationResult;
+import net.sf.sevenzipjbinding.IArchiveExtractCallback;
+import net.sf.sevenzipjbinding.IInArchive;
+import net.sf.sevenzipjbinding.ISequentialOutStream;
+import net.sf.sevenzipjbinding.PropID;
+import net.sf.sevenzipjbinding.SevenZip;
+import net.sf.sevenzipjbinding.SevenZipException;
+import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class SevenZipExtractor implements ArchiveExtractor {
+ public SevenZipExtractor() {
+ try {
+ SevenZip.initSevenZipFromPlatformJAR();
+ log.info("Successfully initialized SevenZipJBinding for multi-platform archive extraction.");
+ } catch (net.sf.sevenzipjbinding.SevenZipNativeInitializationException e) {
+ log.error(
+ "Failed to initialize SevenZipJBinding. Native bindings may be missing or incompatible.",
+ e);
+ }
+ }
+
+ @Override
+ public boolean supports(String mimeType) {
+ if (isNull(mimeType)) {
+ return false;
+ }
+
+ return mimeType.startsWith("application/zip")
+ || mimeType.startsWith("application/x-rar-compressed")
+ || mimeType.startsWith("application/rar")
+ || mimeType.startsWith("application/x-7z-compressed");
+ }
+
+ @Override
+ public Map extract(InputStream stream) throws Exception {
+ Map entryMap =
+ new TreeMap<>(CaseInsensitiveSimpleNaturalComparator.getInstance());
+ var tempFile = Files.createTempFile("manga_import_archive_", ".tmp");
+
+ try {
+ Files.copy(stream, tempFile, StandardCopyOption.REPLACE_EXISTING);
+
+ try (var randomAccessFile = new RandomAccessFile(tempFile.toFile(), "r");
+ var inStream = new RandomAccessFileInStream(randomAccessFile);
+ var inArchive = SevenZip.openInArchive(null, inStream)) {
+
+ inArchive.extract(null, false, new ExtractCallback(inArchive, entryMap));
+ }
+ } finally {
+ Files.deleteIfExists(tempFile);
+ }
+
+ return entryMap;
+ }
+
+ private static class ExtractCallback implements IArchiveExtractCallback {
+ private final IInArchive inArchive;
+ private final Map entryMap;
+ private int currentIndex;
+ private ByteArrayOutputStream currentOs;
+
+ public ExtractCallback(IInArchive inArchive, Map entryMap) {
+ this.inArchive = inArchive;
+ this.entryMap = entryMap;
+ }
+
+ @Override
+ public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode)
+ throws SevenZipException {
+ this.currentIndex = index;
+
+ if (!extractAskMode.equals(ExtractAskMode.EXTRACT)) {
+ return null;
+ }
+
+ var isFolder = (Boolean) inArchive.getProperty(index, PropID.IS_FOLDER);
+ if (nonNull(isFolder) && isFolder) {
+ return null;
+ }
+
+ currentOs = new ByteArrayOutputStream();
+ return data -> {
+ try {
+ currentOs.write(data);
+ return data.length;
+ } catch (Exception e) {
+ throw new SevenZipException("Error writing to stream", e);
+ }
+ };
+ }
+
+ @Override
+ public void prepareOperation(ExtractAskMode extractAskMode) {}
+
+ @Override
+ public void setOperationResult(ExtractOperationResult extractOperationResult)
+ throws SevenZipException {
+ if (extractOperationResult.equals(ExtractOperationResult.OK) && nonNull(currentOs)) {
+ var path = (String) inArchive.getProperty(currentIndex, PropID.PATH);
+ entryMap.put(path, currentOs.toByteArray());
+ }
+
+ currentOs = null;
+ }
+
+ @Override
+ public void setTotal(long total) {}
+
+ @Override
+ public void setCompleted(long complete) {}
+ }
+}
diff --git a/src/main/java/com/magamochi/content/service/archive/ZipExtractor.java b/src/main/java/com/magamochi/content/service/archive/ZipExtractor.java
deleted file mode 100644
index 1725c6f..0000000
--- a/src/main/java/com/magamochi/content/service/archive/ZipExtractor.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.magamochi.content.service.archive;
-
-import static java.util.Objects.isNull;
-import static java.util.Objects.nonNull;
-
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.util.Map;
-import java.util.TreeMap;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator;
-import org.springframework.stereotype.Component;
-
-@Component
-public class ZipExtractor implements ArchiveExtractor {
- @Override
- public boolean supports(String mimeType) {
- if (isNull(mimeType)) {
- return false;
- }
-
- return mimeType.startsWith("application/zip");
- }
-
- @Override
- public Map extract(InputStream stream) throws Exception {
- Map entryMap =
- new TreeMap<>(CaseInsensitiveSimpleNaturalComparator.getInstance());
-
- try (var zis = new ZipInputStream(stream)) {
- ZipEntry entry;
- while (nonNull(entry = zis.getNextEntry())) {
- if (entry.isDirectory()) {
- continue;
- }
-
- var os = new ByteArrayOutputStream();
- zis.transferTo(os);
- entryMap.put(entry.getName(), os.toByteArray());
- zis.closeEntry();
- }
- }
-
- return entryMap;
- }
-}