Merge pull request 'feat/manual-import' (#52) from feat/manual-import into main

Reviewed-on: #52
This commit is contained in:
rov 2026-04-23 09:38:22 -03:00
commit 9640902c4e
6 changed files with 173 additions and 109 deletions

12
pom.xml
View File

@ -131,10 +131,14 @@
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.junrar</groupId>
<artifactId>junrar</artifactId>
<version>7.5.8</version>
<scope>compile</scope>
<groupId>net.sf.sevenzipjbinding</groupId>
<artifactId>sevenzipjbinding</artifactId>
<version>16.02-2.01</version>
</dependency>
<dependency>
<groupId>net.sf.sevenzipjbinding</groupId>
<artifactId>sevenzipjbinding-all-platforms</artifactId>
<version>16.02-2.01</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@ -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<Void> retryImportJob(@PathVariable Long jobId) {
contentImportService.retryImportJob(jobId);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -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<MangaImportJob> findImportJob(Long id) {
return mangaImportJobRepository.findById(id);
}

View File

@ -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<String, byte[]> extract(InputStream stream) throws Exception {
Map<String, byte[]> 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;
}
}

View File

@ -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<String, byte[]> extract(InputStream stream) throws Exception {
Map<String, byte[]> 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<String, byte[]> entryMap;
private int currentIndex;
private ByteArrayOutputStream currentOs;
public ExtractCallback(IInArchive inArchive, Map<String, byte[]> 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) {}
}
}

View File

@ -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<String, byte[]> extract(InputStream stream) throws Exception {
Map<String, byte[]> 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;
}
}