diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class index aa67331..e3c3b5e 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index 54d4c8f..fe9414c 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class new file mode 100644 index 0000000..f4414fb Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class new file mode 100644 index 0000000..9b29d83 Binary files /dev/null and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class index 5cf9f85..80669bf 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class index f570152..d06ab5e 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index 1c0c20a..471ebab 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class index 050bbd2..507bea2 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class index 9013963..b282d77 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index e4c49b0..12a9fe1 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index d4c29ce..63dbc49 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index b60b275..f3c4090 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -3,7 +3,9 @@ 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; @@ -360,110 +362,99 @@ public final class JBindExtractorMain { try { context = openSevenZipArchive(request.archiveFile, password); IInArchive archive = context.archive; - ISimpleInArchive simple = archive.getSimpleInterface(); - ISimpleInArchiveItem[] items = simple.getArchiveItems(); - if (items == null) { + int itemCount = archive.getNumberOfItems(); + if (itemCount <= 0) { 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; - 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(); - + List fileIndices = new ArrayList(); + List outputFiles = new ArrayList(); + List fileSizes = new ArrayList(); Set reserved = new HashSet(); - for (ISimpleInArchiveItem item : items) { - if (item == null) { - continue; - } - String entryName = normalizeEntryName(item.getPath(), "item-" + item.getItemIndex()); - if (item.isFolder()) { + 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)) { File dir = resolveDirectory(request.targetDir, entryName); ensureDirectory(dir); reserved.add(pathKey(dir)); continue; } - long itemUnits = safeSize(item.getSize()); - File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); - if (output == null) { - progress.advance(itemUnits); - continue; - } - - 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()); - } + Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED); + encrypted = encrypted || Boolean.TRUE.equals(isEncrypted); } catch (Throwable ignored) { - // best effort + // ignore encrypted flag read issues } + + Long rawSize = (Long) archive.getProperty(i, PropID.SIZE); + long itemSize = safeSize(rawSize); + totalUnits += itemSize; + + 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 indexToPos = new HashMap(); + 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); + } + throw error; + } + + if (firstError[0] != null) { + if (firstError[0] instanceof WrongPasswordException) { + throw (WrongPasswordException) firstError[0]; + } + throw (Exception) firstError[0]; } progress.emitDone(); @@ -888,6 +879,176 @@ public final class JBindExtractorMain { private final List passwords = new ArrayList(); } + /** + * 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 indexToPos; + private final List fileIndices; + private final List outputFiles; + private final List 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 indexToPos, + List fileIndices, List outputFiles, List 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; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 29158dd..3a27909 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -751,60 +751,86 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( return null; } -function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { +export 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, "\\$&"); - 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); - }); + pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"); } - 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); - }); + 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"); + } } - // 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); - }); + if (!pattern) { + const zipSplitMatch = entryLower.match(/^(.*)\.zip\.001$/); + if (zipSplitMatch) { + const stem = zipSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i"); + } } - // 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); - }); + if (!pattern) { + const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.001$/); + if (sevenSplitMatch) { + const stem = sevenSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i"); + } } - // 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); - }); + 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"); + } } - return items.filter((item) => { - const name = path.basename(item.targetPath || item.fileName || "").toLowerCase(); - return name === entryLower; - }); + + // 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); + }); + 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; + } + } + + return []; } function retryDelayWithJitter(attempt: number, baseMs: number): number { @@ -6366,49 +6392,11 @@ export class DownloadManager extends EventEmitter { const resolveArchiveItems = (archiveName: string): DownloadItem[] => resolveArchiveItemsFromList(archiveName, items); - // 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(); - const hybridResolvedItems: Array<{ key: string; items: DownloadItem[] }> = []; - const hybridStartTimes: Array<{ key: string; time: number }> = []; + // Track archives for parallel hybrid extraction progress + const hybridResolvedItems = new Map(); + const hybridStartTimes = new Map(); 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. @@ -6453,28 +6441,21 @@ export class DownloadManager extends EventEmitter { return; } if (progress.phase === "done") { - // 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; + hybridResolvedItems.clear(); + hybridStartTimes.clear(); return; } if (progress.archiveName) { // Resolve items for this archive if not yet tracked - if (!hybridInitializedArchives.has(progress.archiveName)) { - hybridInitializedArchives.add(progress.archiveName); + if (!hybridResolvedItems.has(progress.archiveName)) { const resolved = resolveArchiveItems(progress.archiveName); - setHybridResolved(progress.archiveName, resolved); - setHybridStartTime(progress.archiveName, nowMs()); + hybridResolvedItems.set(progress.archiveName, resolved); + hybridStartTimes.set(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.slice(0, 5).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.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) { @@ -6487,12 +6468,12 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } } - const archItems = findHybridResolved(progress.archiveName) || []; + const archItems = hybridResolvedItems.get(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 = findHybridStartTime(progress.archiveName) || doneAt; + const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archItems) { if (!isExtractedLabel(entry.fullStatus)) { @@ -6500,9 +6481,8 @@ export class DownloadManager extends EventEmitter { entry.updatedAt = doneAt; } } - hybridInitializedArchives.delete(progress.archiveName); - removeHybridResolved(progress.archiveName); - removeHybridStartTime(progress.archiveName); + hybridResolvedItems.delete(progress.archiveName); + hybridStartTimes.delete(progress.archiveName); // Show transitional label while next archive initializes const done = progress.current + 1; if (done < progress.total) { @@ -6794,46 +6774,9 @@ export class DownloadManager extends EventEmitter { } }, extractTimeoutMs); try { - // Track multiple active archives for parallel extraction. - // Using plain object — Map.has() had a mysterious caching failure. - const fullInitializedArchives = new Set(); - 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; } - } - }; + // Track archives for parallel extraction progress + const fullResolvedItems = new Map(); + const fullStartTimes = new Map(); const result = await extractPackageArchives({ packageDir: pkg.outputDir, @@ -6857,28 +6800,22 @@ export class DownloadManager extends EventEmitter { return; } if (progress.phase === "done") { - // 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; + fullResolvedItems.clear(); + fullStartTimes.clear(); emitExtractStatus("Entpacken 100%", true); return; } if (progress.archiveName) { // Resolve items for this archive if not yet tracked - if (!fullInitializedArchives.has(progress.archiveName)) { - fullInitializedArchives.add(progress.archiveName); + if (!fullResolvedItems.has(progress.archiveName)) { const resolved = resolveArchiveItems(progress.archiveName); - setFullResolved(progress.archiveName, resolved); - setFullStartTime(progress.archiveName, nowMs()); + fullResolvedItems.set(progress.archiveName, resolved); + fullStartTimes.set(progress.archiveName, nowMs()); if (resolved.length === 0) { - 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(", ")}]`); + 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(", ")}]`); } 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) { @@ -6890,12 +6827,12 @@ export class DownloadManager extends EventEmitter { emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); } } - const archiveItems = findFullResolved(progress.archiveName) || []; + const archiveItems = fullResolvedItems.get(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 = findFullStartTime(progress.archiveName) || doneAt; + const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; const doneLabel = formatExtractDone(doneAt - startedAt); for (const entry of archiveItems) { if (!isExtractedLabel(entry.fullStatus)) { @@ -6903,9 +6840,8 @@ export class DownloadManager extends EventEmitter { entry.updatedAt = doneAt; } } - fullInitializedArchives.delete(progress.archiveName); - removeFullResolved(progress.archiveName); - removeFullStartTime(progress.archiveName); + fullResolvedItems.delete(progress.archiveName); + fullStartTimes.delete(progress.archiveName); // Show transitional label while next archive initializes const done = progress.current + 1; if (done < progress.total) { diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index b62ef79..46014e6 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -65,6 +65,111 @@ 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(); + + 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"; diff --git a/tests/resolve-archive-items.test.ts b/tests/resolve-archive-items.test.ts new file mode 100644 index 0000000..adf7f14 --- /dev/null +++ b/tests/resolve-archive-items.test.ts @@ -0,0 +1,188 @@ +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); + }); +});