Compare commits
No commits in common. "72642351d0c42c2025214780ebc6025637652e6c" and "d9a78ea83760e6547c6d8ec1f3f972c325162f2a" have entirely different histories.
72642351d0
...
d9a78ea837
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.6.55",
|
||||
"version": "1.6.54",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -3,9 +3,7 @@ package com.sucukdeluxe.extractor;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.exception.ZipException;
|
||||
import net.lingala.zip4j.model.FileHeader;
|
||||
import net.sf.sevenzipjbinding.ExtractAskMode;
|
||||
import net.sf.sevenzipjbinding.ExtractOperationResult;
|
||||
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
|
||||
import net.sf.sevenzipjbinding.IArchiveOpenCallback;
|
||||
import net.sf.sevenzipjbinding.IArchiveOpenVolumeCallback;
|
||||
import net.sf.sevenzipjbinding.IInArchive;
|
||||
@ -362,99 +360,110 @@ public final class JBindExtractorMain {
|
||||
try {
|
||||
context = openSevenZipArchive(request.archiveFile, password);
|
||||
IInArchive archive = context.archive;
|
||||
int itemCount = archive.getNumberOfItems();
|
||||
if (itemCount <= 0) {
|
||||
ISimpleInArchive simple = archive.getSimpleInterface();
|
||||
ISimpleInArchiveItem[] items = simple.getArchiveItems();
|
||||
if (items == null) {
|
||||
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
// Pre-scan: collect file indices, sizes, output paths, and detect encryption
|
||||
long totalUnits = 0;
|
||||
boolean encrypted = false;
|
||||
List<Integer> fileIndices = new ArrayList<Integer>();
|
||||
List<File> outputFiles = new ArrayList<File>();
|
||||
List<Long> fileSizes = new ArrayList<Long>();
|
||||
for (ISimpleInArchiveItem item : items) {
|
||||
if (item == null || item.isFolder()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
encrypted = encrypted || item.isEncrypted();
|
||||
} catch (Throwable ignored) {
|
||||
// ignore encrypted flag read issues
|
||||
}
|
||||
totalUnits += safeSize(item.getSize());
|
||||
}
|
||||
ProgressTracker progress = new ProgressTracker(totalUnits);
|
||||
progress.emitStart();
|
||||
|
||||
Set<String> reserved = new HashSet<String>();
|
||||
for (ISimpleInArchiveItem item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < itemCount; i++) {
|
||||
Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER);
|
||||
String entryPath = (String) archive.getProperty(i, PropID.PATH);
|
||||
String entryName = normalizeEntryName(entryPath, "item-" + i);
|
||||
|
||||
if (Boolean.TRUE.equals(isFolder)) {
|
||||
String entryName = normalizeEntryName(item.getPath(), "item-" + item.getItemIndex());
|
||||
if (item.isFolder()) {
|
||||
File dir = resolveDirectory(request.targetDir, entryName);
|
||||
ensureDirectory(dir);
|
||||
reserved.add(pathKey(dir));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
|
||||
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
|
||||
} catch (Throwable ignored) {
|
||||
// ignore encrypted flag read issues
|
||||
}
|
||||
|
||||
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
|
||||
long itemSize = safeSize(rawSize);
|
||||
totalUnits += itemSize;
|
||||
|
||||
long itemUnits = safeSize(item.getSize());
|
||||
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
|
||||
fileIndices.add(i);
|
||||
outputFiles.add(output); // null if skipped
|
||||
fileSizes.add(itemSize);
|
||||
}
|
||||
|
||||
if (fileIndices.isEmpty()) {
|
||||
// All items are folders or skipped
|
||||
ProgressTracker progress = new ProgressTracker(1);
|
||||
progress.emitStart();
|
||||
progress.emitDone();
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressTracker progress = new ProgressTracker(totalUnits);
|
||||
progress.emitStart();
|
||||
|
||||
// Build index array for bulk extract
|
||||
int[] indices = new int[fileIndices.size()];
|
||||
for (int i = 0; i < fileIndices.size(); i++) {
|
||||
indices[i] = fileIndices.get(i);
|
||||
}
|
||||
|
||||
// Map from archive index to our position in fileIndices/outputFiles
|
||||
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
|
||||
for (int i = 0; i < fileIndices.size(); i++) {
|
||||
indexToPos.put(fileIndices.get(i), i);
|
||||
}
|
||||
|
||||
// Bulk extraction state
|
||||
final boolean encryptedFinal = encrypted;
|
||||
final String effectivePassword = password == null ? "" : password;
|
||||
final File[] currentOutput = new File[1];
|
||||
final FileOutputStream[] currentStream = new FileOutputStream[1];
|
||||
final boolean[] currentSuccess = new boolean[1];
|
||||
final long[] currentRemaining = new long[1];
|
||||
final Throwable[] firstError = new Throwable[1];
|
||||
final int[] currentPos = new int[] { -1 };
|
||||
|
||||
try {
|
||||
archive.extract(indices, false, new BulkExtractCallback(
|
||||
archive, indexToPos, fileIndices, outputFiles, fileSizes,
|
||||
progress, encryptedFinal, effectivePassword, currentOutput,
|
||||
currentStream, currentSuccess, currentRemaining, currentPos, firstError
|
||||
));
|
||||
} catch (SevenZipException error) {
|
||||
if (looksLikeWrongPassword(error, encryptedFinal)) {
|
||||
throw new WrongPasswordException(error);
|
||||
if (output == null) {
|
||||
progress.advance(itemUnits);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (firstError[0] != null) {
|
||||
if (firstError[0] instanceof WrongPasswordException) {
|
||||
throw (WrongPasswordException) firstError[0];
|
||||
ensureDirectory(output.getParentFile());
|
||||
rejectSymlink(output);
|
||||
final FileOutputStream out = new FileOutputStream(output);
|
||||
final long[] remaining = new long[] { itemUnits };
|
||||
boolean extractionSuccess = false;
|
||||
try {
|
||||
ExtractOperationResult result = item.extractSlow(new ISequentialOutStream() {
|
||||
@Override
|
||||
public int write(byte[] data) throws SevenZipException {
|
||||
if (data == null || data.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
out.write(data);
|
||||
} catch (IOException error) {
|
||||
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
|
||||
}
|
||||
long accounted = Math.min(remaining[0], (long) data.length);
|
||||
remaining[0] -= accounted;
|
||||
progress.advance(accounted);
|
||||
return data.length;
|
||||
}
|
||||
}, password == null ? "" : password);
|
||||
|
||||
if (remaining[0] > 0) {
|
||||
progress.advance(remaining[0]);
|
||||
}
|
||||
|
||||
if (result != ExtractOperationResult.OK) {
|
||||
if (isPasswordFailure(result, encrypted)) {
|
||||
throw new WrongPasswordException(new IOException("Falsches Passwort"));
|
||||
}
|
||||
throw new IOException("7z-Fehler: " + result.name());
|
||||
}
|
||||
extractionSuccess = true;
|
||||
} catch (SevenZipException error) {
|
||||
if (looksLikeWrongPassword(error, encrypted)) {
|
||||
throw new WrongPasswordException(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
out.close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (!extractionSuccess && output.exists()) {
|
||||
try {
|
||||
output.delete();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
java.util.Date modified = item.getLastWriteTime();
|
||||
if (modified != null) {
|
||||
output.setLastModified(modified.getTime());
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// best effort
|
||||
}
|
||||
throw (Exception) firstError[0];
|
||||
}
|
||||
|
||||
progress.emitDone();
|
||||
@ -879,176 +888,6 @@ public final class JBindExtractorMain {
|
||||
private final List<String> passwords = new ArrayList<String>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk extraction callback that implements both IArchiveExtractCallback and
|
||||
* ICryptoGetTextPassword. Using the bulk IInArchive.extract() API instead of
|
||||
* per-item extractSlow() is critical for performance — solid RAR archives
|
||||
* otherwise re-decode from the beginning for every single item.
|
||||
*/
|
||||
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
|
||||
private final IInArchive archive;
|
||||
private final Map<Integer, Integer> indexToPos;
|
||||
private final List<Integer> fileIndices;
|
||||
private final List<File> outputFiles;
|
||||
private final List<Long> fileSizes;
|
||||
private final ProgressTracker progress;
|
||||
private final boolean encrypted;
|
||||
private final String password;
|
||||
private final File[] currentOutput;
|
||||
private final FileOutputStream[] currentStream;
|
||||
private final boolean[] currentSuccess;
|
||||
private final long[] currentRemaining;
|
||||
private final int[] currentPos;
|
||||
private final Throwable[] firstError;
|
||||
|
||||
BulkExtractCallback(IInArchive archive, Map<Integer, Integer> indexToPos,
|
||||
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
|
||||
ProgressTracker progress, boolean encrypted, String password,
|
||||
File[] currentOutput, FileOutputStream[] currentStream,
|
||||
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
|
||||
Throwable[] firstError) {
|
||||
this.archive = archive;
|
||||
this.indexToPos = indexToPos;
|
||||
this.fileIndices = fileIndices;
|
||||
this.outputFiles = outputFiles;
|
||||
this.fileSizes = fileSizes;
|
||||
this.progress = progress;
|
||||
this.encrypted = encrypted;
|
||||
this.password = password;
|
||||
this.currentOutput = currentOutput;
|
||||
this.currentStream = currentStream;
|
||||
this.currentSuccess = currentSuccess;
|
||||
this.currentRemaining = currentRemaining;
|
||||
this.currentPos = currentPos;
|
||||
this.firstError = firstError;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cryptoGetTextPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTotal(long total) {
|
||||
// 7z reports total compressed bytes; we track uncompressed via ProgressTracker
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCompleted(long complete) {
|
||||
// Not used — we track per-write progress
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
|
||||
closeCurrentStream();
|
||||
|
||||
Integer pos = indexToPos.get(index);
|
||||
if (pos == null) {
|
||||
return null;
|
||||
}
|
||||
currentPos[0] = pos;
|
||||
currentOutput[0] = outputFiles.get(pos);
|
||||
currentSuccess[0] = false;
|
||||
currentRemaining[0] = fileSizes.get(pos);
|
||||
|
||||
if (extractAskMode != ExtractAskMode.EXTRACT) {
|
||||
currentOutput[0] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentOutput[0] == null) {
|
||||
progress.advance(currentRemaining[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDirectory(currentOutput[0].getParentFile());
|
||||
rejectSymlink(currentOutput[0]);
|
||||
currentStream[0] = new FileOutputStream(currentOutput[0]);
|
||||
} catch (IOException error) {
|
||||
throw new SevenZipException("Fehler beim Erstellen: " + error.getMessage(), error);
|
||||
}
|
||||
|
||||
return new ISequentialOutStream() {
|
||||
@Override
|
||||
public int write(byte[] data) throws SevenZipException {
|
||||
if (data == null || data.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
currentStream[0].write(data);
|
||||
} catch (IOException error) {
|
||||
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
|
||||
}
|
||||
long accounted = Math.min(currentRemaining[0], (long) data.length);
|
||||
currentRemaining[0] -= accounted;
|
||||
progress.advance(accounted);
|
||||
return data.length;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareOperation(ExtractAskMode extractAskMode) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOperationResult(ExtractOperationResult result) throws SevenZipException {
|
||||
if (currentRemaining[0] > 0) {
|
||||
progress.advance(currentRemaining[0]);
|
||||
currentRemaining[0] = 0;
|
||||
}
|
||||
|
||||
if (result == ExtractOperationResult.OK) {
|
||||
currentSuccess[0] = true;
|
||||
closeCurrentStream();
|
||||
if (currentPos[0] >= 0 && currentOutput[0] != null) {
|
||||
try {
|
||||
int archiveIndex = fileIndices.get(currentPos[0]);
|
||||
java.util.Date modified = (java.util.Date) archive.getProperty(archiveIndex, PropID.LAST_MODIFICATION_TIME);
|
||||
if (modified != null) {
|
||||
currentOutput[0].setLastModified(modified.getTime());
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
} else {
|
||||
closeCurrentStream();
|
||||
if (currentOutput[0] != null && currentOutput[0].exists()) {
|
||||
try {
|
||||
currentOutput[0].delete();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (firstError[0] == null) {
|
||||
if (isPasswordFailure(result, encrypted)) {
|
||||
firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort"));
|
||||
} else {
|
||||
firstError[0] = new IOException("7z-Fehler: " + result.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeCurrentStream() {
|
||||
if (currentStream[0] != null) {
|
||||
try {
|
||||
currentStream[0].close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
currentStream[0] = null;
|
||||
}
|
||||
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) {
|
||||
try {
|
||||
currentOutput[0].delete();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class WrongPasswordException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
|
||||
@ -751,86 +751,60 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] {
|
||||
function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] {
|
||||
const entryLower = archiveName.toLowerCase();
|
||||
|
||||
// Helper: get item basename (try targetPath first, then fileName)
|
||||
const itemBaseName = (item: DownloadItem): string =>
|
||||
path.basename(item.targetPath || item.fileName || "");
|
||||
|
||||
// Try pattern-based matching first (for multipart archives)
|
||||
let pattern: RegExp | null = null;
|
||||
const multipartMatch = entryLower.match(/^(.*)\.part0*1\.rar$/);
|
||||
if (multipartMatch) {
|
||||
const prefix = multipartMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i");
|
||||
}
|
||||
if (!pattern) {
|
||||
const rarMatch = entryLower.match(/^(.*)\.rar$/);
|
||||
if (rarMatch) {
|
||||
const stem = rarMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i");
|
||||
}
|
||||
}
|
||||
if (!pattern) {
|
||||
const zipSplitMatch = entryLower.match(/^(.*)\.zip\.001$/);
|
||||
if (zipSplitMatch) {
|
||||
const stem = zipSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i");
|
||||
}
|
||||
}
|
||||
if (!pattern) {
|
||||
const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.001$/);
|
||||
if (sevenSplitMatch) {
|
||||
const stem = sevenSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i");
|
||||
}
|
||||
}
|
||||
if (!pattern && /^(.*)\.001$/.test(entryLower) && !/\.(zip|7z)\.001$/.test(entryLower)) {
|
||||
const genericSplitMatch = entryLower.match(/^(.*)\.001$/);
|
||||
if (genericSplitMatch) {
|
||||
const stem = genericSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i");
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 1: Pattern match (handles multipart archives)
|
||||
if (pattern) {
|
||||
const matched = items.filter((item) => pattern!.test(itemBaseName(item)));
|
||||
if (matched.length > 0) return matched;
|
||||
}
|
||||
|
||||
// Attempt 2: Exact filename match (case-insensitive)
|
||||
const exactMatch = items.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
|
||||
if (exactMatch.length > 0) return exactMatch;
|
||||
|
||||
// Attempt 3: Stem-based fuzzy match — strip archive extensions and compare stems.
|
||||
// Handles cases where debrid services modify filenames slightly.
|
||||
const archiveStem = entryLower
|
||||
.replace(/\.part\d+\.rar$/i, "")
|
||||
.replace(/\.r\d{2,3}$/i, "")
|
||||
.replace(/\.rar$/i, "")
|
||||
.replace(/\.(zip|7z)\.\d{3}$/i, "")
|
||||
.replace(/\.\d{3}$/i, "")
|
||||
.replace(/\.(zip|7z)$/i, "");
|
||||
if (archiveStem.length > 3) {
|
||||
const stemMatch = items.filter((item) => {
|
||||
const name = itemBaseName(item).toLowerCase();
|
||||
return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name);
|
||||
const pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i");
|
||||
return items.filter((item) => {
|
||||
const name = path.basename(item.targetPath || item.fileName || "");
|
||||
return pattern.test(name);
|
||||
});
|
||||
if (stemMatch.length > 0) return stemMatch;
|
||||
}
|
||||
|
||||
// Attempt 4: If only one item in the list and one archive — return it as a best-effort match.
|
||||
// This handles single-file packages where the filename may have been modified.
|
||||
if (items.length === 1) {
|
||||
const singleName = itemBaseName(items[0]).toLowerCase();
|
||||
if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) {
|
||||
return items;
|
||||
}
|
||||
const rarMatch = entryLower.match(/^(.*)\.rar$/);
|
||||
if (rarMatch) {
|
||||
const stem = rarMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i");
|
||||
return items.filter((item) => {
|
||||
const name = path.basename(item.targetPath || item.fileName || "");
|
||||
return pattern.test(name);
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
// Split ZIP (e.g., movie.zip.001, movie.zip.002)
|
||||
const zipSplitMatch = entryLower.match(/^(.*)\.zip\.001$/);
|
||||
if (zipSplitMatch) {
|
||||
const stem = zipSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i");
|
||||
return items.filter((item) => {
|
||||
const name = path.basename(item.targetPath || item.fileName || "");
|
||||
return pattern.test(name);
|
||||
});
|
||||
}
|
||||
// Split 7z (e.g., movie.7z.001, movie.7z.002)
|
||||
const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.001$/);
|
||||
if (sevenSplitMatch) {
|
||||
const stem = sevenSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i");
|
||||
return items.filter((item) => {
|
||||
const name = path.basename(item.targetPath || item.fileName || "");
|
||||
return pattern.test(name);
|
||||
});
|
||||
}
|
||||
// Generic .NNN splits (e.g., movie.001, movie.002)
|
||||
const genericSplitMatch = entryLower.match(/^(.*)\.001$/);
|
||||
if (genericSplitMatch && !/\.(zip|7z)\.001$/.test(entryLower)) {
|
||||
const stem = genericSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i");
|
||||
return items.filter((item) => {
|
||||
const name = path.basename(item.targetPath || item.fileName || "");
|
||||
return pattern.test(name);
|
||||
});
|
||||
}
|
||||
return items.filter((item) => {
|
||||
const name = path.basename(item.targetPath || item.fileName || "").toLowerCase();
|
||||
return name === entryLower;
|
||||
});
|
||||
}
|
||||
|
||||
function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
@ -6392,11 +6366,49 @@ export class DownloadManager extends EventEmitter {
|
||||
const resolveArchiveItems = (archiveName: string): DownloadItem[] =>
|
||||
resolveArchiveItemsFromList(archiveName, items);
|
||||
|
||||
// Track archives for parallel hybrid extraction progress
|
||||
const hybridResolvedItems = new Map<string, DownloadItem[]>();
|
||||
const hybridStartTimes = new Map<string, number>();
|
||||
// Track multiple active archives for parallel hybrid extraction.
|
||||
// Using plain object instead of Map — Map.has() was mysteriously
|
||||
// returning false despite Map.set() being called with the same key.
|
||||
const hybridInitializedArchives = new Set<string>();
|
||||
const hybridResolvedItems: Array<{ key: string; items: DownloadItem[] }> = [];
|
||||
const hybridStartTimes: Array<{ key: string; time: number }> = [];
|
||||
let hybridLastEmitAt = 0;
|
||||
|
||||
const findHybridResolved = (key: string): DownloadItem[] | undefined => {
|
||||
for (let i = 0; i < hybridResolvedItems.length; i++) {
|
||||
if (hybridResolvedItems[i].key === key) return hybridResolvedItems[i].items;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const setHybridResolved = (key: string, items: DownloadItem[]): void => {
|
||||
for (let i = 0; i < hybridResolvedItems.length; i++) {
|
||||
if (hybridResolvedItems[i].key === key) { hybridResolvedItems[i].items = items; return; }
|
||||
}
|
||||
hybridResolvedItems.push({ key, items });
|
||||
};
|
||||
const removeHybridResolved = (key: string): void => {
|
||||
for (let i = hybridResolvedItems.length - 1; i >= 0; i--) {
|
||||
if (hybridResolvedItems[i].key === key) { hybridResolvedItems.splice(i, 1); return; }
|
||||
}
|
||||
};
|
||||
const findHybridStartTime = (key: string): number | undefined => {
|
||||
for (let i = 0; i < hybridStartTimes.length; i++) {
|
||||
if (hybridStartTimes[i].key === key) return hybridStartTimes[i].time;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const setHybridStartTime = (key: string, time: number): void => {
|
||||
for (let i = 0; i < hybridStartTimes.length; i++) {
|
||||
if (hybridStartTimes[i].key === key) { hybridStartTimes[i].time = time; return; }
|
||||
}
|
||||
hybridStartTimes.push({ key, time });
|
||||
};
|
||||
const removeHybridStartTime = (key: string): void => {
|
||||
for (let i = hybridStartTimes.length - 1; i >= 0; i--) {
|
||||
if (hybridStartTimes[i].key === key) { hybridStartTimes.splice(i, 1); return; }
|
||||
}
|
||||
};
|
||||
|
||||
// Mark items based on whether their archive is actually ready for extraction.
|
||||
// Only items whose archive is in readyArchives get "Ausstehend"; others keep
|
||||
// "Warten auf Parts" to avoid flicker between hybrid runs.
|
||||
@ -6441,21 +6453,28 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
if (progress.phase === "done") {
|
||||
hybridResolvedItems.clear();
|
||||
hybridStartTimes.clear();
|
||||
// Do NOT mark remaining archives as "Done" here — some may have
|
||||
// failed. The post-extraction code (result.failed check) will
|
||||
// assign the correct label. Only clear the tracking caches.
|
||||
hybridInitializedArchives.clear();
|
||||
hybridResolvedItems.length = 0;
|
||||
hybridStartTimes.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress.archiveName) {
|
||||
// Resolve items for this archive if not yet tracked
|
||||
if (!hybridResolvedItems.has(progress.archiveName)) {
|
||||
if (!hybridInitializedArchives.has(progress.archiveName)) {
|
||||
hybridInitializedArchives.add(progress.archiveName);
|
||||
const resolved = resolveArchiveItems(progress.archiveName);
|
||||
hybridResolvedItems.set(progress.archiveName, resolved);
|
||||
hybridStartTimes.set(progress.archiveName, nowMs());
|
||||
setHybridResolved(progress.archiveName, resolved);
|
||||
setHybridStartTime(progress.archiveName, nowMs());
|
||||
if (resolved.length === 0) {
|
||||
logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
|
||||
logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.slice(0, 5).map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
|
||||
} else {
|
||||
logger.info(`resolveArchiveItems (hybrid): ${resolved.length} Items für archiveName="${progress.archiveName}"`);
|
||||
// Immediately label the matched items and force emit so the UI
|
||||
// transitions from "Ausstehend" to the extraction label right away.
|
||||
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
|
||||
const initAt = nowMs();
|
||||
for (const entry of resolved) {
|
||||
@ -6468,12 +6487,12 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState(true);
|
||||
}
|
||||
}
|
||||
const archItems = hybridResolvedItems.get(progress.archiveName) || [];
|
||||
const archItems = findHybridResolved(progress.archiveName) || [];
|
||||
|
||||
// If archive is at 100%, mark its items as done and remove from active
|
||||
if (Number(progress.archivePercent ?? 0) >= 100) {
|
||||
const doneAt = nowMs();
|
||||
const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt;
|
||||
const startedAt = findHybridStartTime(progress.archiveName) || doneAt;
|
||||
const doneLabel = formatExtractDone(doneAt - startedAt);
|
||||
for (const entry of archItems) {
|
||||
if (!isExtractedLabel(entry.fullStatus)) {
|
||||
@ -6481,8 +6500,9 @@ export class DownloadManager extends EventEmitter {
|
||||
entry.updatedAt = doneAt;
|
||||
}
|
||||
}
|
||||
hybridResolvedItems.delete(progress.archiveName);
|
||||
hybridStartTimes.delete(progress.archiveName);
|
||||
hybridInitializedArchives.delete(progress.archiveName);
|
||||
removeHybridResolved(progress.archiveName);
|
||||
removeHybridStartTime(progress.archiveName);
|
||||
// Show transitional label while next archive initializes
|
||||
const done = progress.current + 1;
|
||||
if (done < progress.total) {
|
||||
@ -6774,9 +6794,46 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}, extractTimeoutMs);
|
||||
try {
|
||||
// Track archives for parallel extraction progress
|
||||
const fullResolvedItems = new Map<string, DownloadItem[]>();
|
||||
const fullStartTimes = new Map<string, number>();
|
||||
// Track multiple active archives for parallel extraction.
|
||||
// Using plain object — Map.has() had a mysterious caching failure.
|
||||
const fullInitializedArchives = new Set<string>();
|
||||
const fullResolvedItems: Array<{ key: string; items: DownloadItem[] }> = [];
|
||||
const fullStartTimes: Array<{ key: string; time: number }> = [];
|
||||
|
||||
const findFullResolved = (key: string): DownloadItem[] | undefined => {
|
||||
for (let i = 0; i < fullResolvedItems.length; i++) {
|
||||
if (fullResolvedItems[i].key === key) return fullResolvedItems[i].items;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const setFullResolved = (key: string, items: DownloadItem[]): void => {
|
||||
for (let i = 0; i < fullResolvedItems.length; i++) {
|
||||
if (fullResolvedItems[i].key === key) { fullResolvedItems[i].items = items; return; }
|
||||
}
|
||||
fullResolvedItems.push({ key, items });
|
||||
};
|
||||
const removeFullResolved = (key: string): void => {
|
||||
for (let i = fullResolvedItems.length - 1; i >= 0; i--) {
|
||||
if (fullResolvedItems[i].key === key) { fullResolvedItems.splice(i, 1); return; }
|
||||
}
|
||||
};
|
||||
const findFullStartTime = (key: string): number | undefined => {
|
||||
for (let i = 0; i < fullStartTimes.length; i++) {
|
||||
if (fullStartTimes[i].key === key) return fullStartTimes[i].time;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const setFullStartTime = (key: string, time: number): void => {
|
||||
for (let i = 0; i < fullStartTimes.length; i++) {
|
||||
if (fullStartTimes[i].key === key) { fullStartTimes[i].time = time; return; }
|
||||
}
|
||||
fullStartTimes.push({ key, time });
|
||||
};
|
||||
const removeFullStartTime = (key: string): void => {
|
||||
for (let i = fullStartTimes.length - 1; i >= 0; i--) {
|
||||
if (fullStartTimes[i].key === key) { fullStartTimes.splice(i, 1); return; }
|
||||
}
|
||||
};
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir: pkg.outputDir,
|
||||
@ -6800,22 +6857,28 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
if (progress.phase === "done") {
|
||||
fullResolvedItems.clear();
|
||||
fullStartTimes.clear();
|
||||
// Do NOT mark remaining archives as "Done" here — some may have
|
||||
// failed. The post-extraction code (result.failed check) will
|
||||
// assign the correct label. Only clear the tracking caches.
|
||||
fullInitializedArchives.clear();
|
||||
fullResolvedItems.length = 0;
|
||||
fullStartTimes.length = 0;
|
||||
emitExtractStatus("Entpacken 100%", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress.archiveName) {
|
||||
// Resolve items for this archive if not yet tracked
|
||||
if (!fullResolvedItems.has(progress.archiveName)) {
|
||||
if (!fullInitializedArchives.has(progress.archiveName)) {
|
||||
fullInitializedArchives.add(progress.archiveName);
|
||||
const resolved = resolveArchiveItems(progress.archiveName);
|
||||
fullResolvedItems.set(progress.archiveName, resolved);
|
||||
fullStartTimes.set(progress.archiveName, nowMs());
|
||||
setFullResolved(progress.archiveName, resolved);
|
||||
setFullStartTime(progress.archiveName, nowMs());
|
||||
if (resolved.length === 0) {
|
||||
logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
|
||||
logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.slice(0, 5).map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
|
||||
} else {
|
||||
logger.info(`resolveArchiveItems (full): ${resolved.length} Items für archiveName="${progress.archiveName}"`);
|
||||
// Immediately label items and force emit for instant UI feedback
|
||||
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
|
||||
const initAt = nowMs();
|
||||
for (const entry of resolved) {
|
||||
@ -6827,12 +6890,12 @@ export class DownloadManager extends EventEmitter {
|
||||
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true);
|
||||
}
|
||||
}
|
||||
const archiveItems = fullResolvedItems.get(progress.archiveName) || [];
|
||||
const archiveItems = findFullResolved(progress.archiveName) || [];
|
||||
|
||||
// If archive is at 100%, mark its items as done and remove from active
|
||||
if (Number(progress.archivePercent ?? 0) >= 100) {
|
||||
const doneAt = nowMs();
|
||||
const startedAt = fullStartTimes.get(progress.archiveName) || doneAt;
|
||||
const startedAt = findFullStartTime(progress.archiveName) || doneAt;
|
||||
const doneLabel = formatExtractDone(doneAt - startedAt);
|
||||
for (const entry of archiveItems) {
|
||||
if (!isExtractedLabel(entry.fullStatus)) {
|
||||
@ -6840,8 +6903,9 @@ export class DownloadManager extends EventEmitter {
|
||||
entry.updatedAt = doneAt;
|
||||
}
|
||||
}
|
||||
fullResolvedItems.delete(progress.archiveName);
|
||||
fullStartTimes.delete(progress.archiveName);
|
||||
fullInitializedArchives.delete(progress.archiveName);
|
||||
removeFullResolved(progress.archiveName);
|
||||
removeFullStartTime(progress.archiveName);
|
||||
// Show transitional label while next archive initializes
|
||||
const done = progress.current + 1;
|
||||
if (done < progress.total) {
|
||||
|
||||
@ -65,111 +65,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
||||
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("emits progress callbacks with archiveName and percent", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
// Create a ZIP with some content to trigger progress
|
||||
const zipPath = path.join(packageDir, "progress-test.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
||||
zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100)));
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const progressUpdates: Array<{
|
||||
archiveName: string;
|
||||
percent: number;
|
||||
phase: string;
|
||||
archivePercent?: number;
|
||||
}> = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onProgress: (update) => {
|
||||
progressUpdates.push({
|
||||
archiveName: update.archiveName,
|
||||
percent: update.percent,
|
||||
phase: update.phase,
|
||||
archivePercent: update.archivePercent,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
|
||||
// Should have at least preparing, extracting, and done phases
|
||||
const phases = new Set(progressUpdates.map((u) => u.phase));
|
||||
expect(phases.has("preparing")).toBe(true);
|
||||
expect(phases.has("extracting")).toBe(true);
|
||||
|
||||
// Extracting phase should include the archive name
|
||||
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
||||
expect(extracting.length).toBeGreaterThan(0);
|
||||
|
||||
// Should end at 100%
|
||||
const lastExtracting = extracting[extracting.length - 1];
|
||||
expect(lastExtracting.archivePercent).toBe(100);
|
||||
|
||||
// Files should exist
|
||||
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts multiple archives sequentially with progress for each", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
// Create two separate ZIP archives
|
||||
const zip1 = new AdmZip();
|
||||
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
||||
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
||||
|
||||
const zip2 = new AdmZip();
|
||||
zip2.addFile("episode02.txt", Buffer.from("ep2 content"));
|
||||
zip2.writeZip(path.join(packageDir, "archive2.zip"));
|
||||
|
||||
const archiveNames = new Set<string>();
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onProgress: (update) => {
|
||||
if (update.phase === "extracting" && update.archiveName) {
|
||||
archiveNames.add(update.archiveName);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
// Both archive names should have appeared in progress
|
||||
expect(archiveNames.has("archive1.zip")).toBe(true);
|
||||
expect(archiveNames.has("archive2.zip")).toBe(true);
|
||||
// Both files extracted
|
||||
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("respects ask/skip conflict mode in jvm backend", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveArchiveItemsFromList } from "../src/main/download-manager";
|
||||
|
||||
type MinimalItem = {
|
||||
targetPath?: string;
|
||||
fileName?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function makeItems(names: string[]): MinimalItem[] {
|
||||
return names.map((name) => ({
|
||||
targetPath: `C:\\Downloads\\Package\\${name}`,
|
||||
fileName: name,
|
||||
id: name,
|
||||
status: "completed",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("resolveArchiveItemsFromList", () => {
|
||||
// ── Multipart RAR (.partN.rar) ──
|
||||
|
||||
it("matches multipart .part1.rar archives", () => {
|
||||
const items = makeItems([
|
||||
"Movie.part1.rar",
|
||||
"Movie.part2.rar",
|
||||
"Movie.part3.rar",
|
||||
"Other.rar",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((i: any) => i.fileName)).toEqual([
|
||||
"Movie.part1.rar",
|
||||
"Movie.part2.rar",
|
||||
"Movie.part3.rar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches multipart .part01.rar archives (zero-padded)", () => {
|
||||
const items = makeItems([
|
||||
"Film.part01.rar",
|
||||
"Film.part02.rar",
|
||||
"Film.part10.rar",
|
||||
"Unrelated.zip",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Film.part01.rar", items as any);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
|
||||
|
||||
it("matches old-style .rar + .rNN volumes", () => {
|
||||
const items = makeItems([
|
||||
"Archive.rar",
|
||||
"Archive.r00",
|
||||
"Archive.r01",
|
||||
"Archive.r02",
|
||||
"Other.zip",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ── Single RAR ──
|
||||
|
||||
it("matches a single .rar file", () => {
|
||||
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
||||
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
||||
});
|
||||
|
||||
// ── Split ZIP ──
|
||||
|
||||
it("matches split .zip.NNN files", () => {
|
||||
const items = makeItems([
|
||||
"Data.zip",
|
||||
"Data.zip.001",
|
||||
"Data.zip.002",
|
||||
"Data.zip.003",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Data.zip.001", items as any);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ── Split 7z ──
|
||||
|
||||
it("matches split .7z.NNN files", () => {
|
||||
const items = makeItems([
|
||||
"Backup.7z.001",
|
||||
"Backup.7z.002",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Backup.7z.001", items as any);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Generic .NNN splits ──
|
||||
|
||||
it("matches generic .NNN split files", () => {
|
||||
const items = makeItems([
|
||||
"video.001",
|
||||
"video.002",
|
||||
"video.003",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("video.001", items as any);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── Exact filename match ──
|
||||
|
||||
it("matches a single .zip by exact name", () => {
|
||||
const items = makeItems(["myarchive.zip", "other.rar"]);
|
||||
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
||||
});
|
||||
|
||||
// ── Case insensitivity ──
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
const items = makeItems([
|
||||
"MOVIE.PART1.RAR",
|
||||
"MOVIE.PART2.RAR",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Stem-based fallback ──
|
||||
|
||||
it("uses stem-based fallback when exact patterns fail", () => {
|
||||
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
|
||||
// but the disk file is "Movie.part1.rar"
|
||||
const items = makeItems([
|
||||
"Movie.rar",
|
||||
]);
|
||||
// The archive on disk is "Movie.part1.rar" but there's no item matching the
|
||||
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
// stem fallback: "movie" starts with "movie" and ends with .rar
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Single item fallback ──
|
||||
|
||||
it("returns single archive item when no pattern matches", () => {
|
||||
const items = makeItems(["totally-different-name.rar"]);
|
||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||
// Single item in list with archive extension → return it
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Empty when no match ──
|
||||
|
||||
it("returns empty when items have no archive extensions", () => {
|
||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Items without targetPath ──
|
||||
|
||||
it("falls back to fileName when targetPath is missing", () => {
|
||||
const items = [
|
||||
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
||||
{ fileName: "Movie.part2.rar", id: "2", status: "completed" },
|
||||
];
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Multiple archives, should not cross-match ──
|
||||
|
||||
it("does not cross-match different archive groups", () => {
|
||||
const items = makeItems([
|
||||
"Episode.S01E01.part1.rar",
|
||||
"Episode.S01E01.part2.rar",
|
||||
"Episode.S01E02.part1.rar",
|
||||
"Episode.S01E02.part2.rar",
|
||||
]);
|
||||
const result1 = resolveArchiveItemsFromList("Episode.S01E01.part1.rar", items as any);
|
||||
expect(result1).toHaveLength(2);
|
||||
expect(result1.every((i: any) => i.fileName.includes("S01E01"))).toBe(true);
|
||||
|
||||
const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any);
|
||||
expect(result2).toHaveLength(2);
|
||||
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user