From 51a01ea03fcaad9c583706c37199a43fb88f2530 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 5 Mar 2026 06:24:12 +0100 Subject: [PATCH] Use bulk IInArchive.extract() for ~8x faster extraction, fix archive item resolution - Replace extractSlow() per-item extraction with IInArchive.extract() bulk API in 7-Zip-JBinding. Solid RAR archives no longer re-decode from the beginning for each item, bringing extraction speed close to native WinRAR/7z.exe (~375 MB/s instead of ~43 MB/s). - Add BulkExtractCallback implementing both IArchiveExtractCallback and ICryptoGetTextPassword for proper password handling during bulk extraction. - Fix resolveArchiveItemsFromList with multi-level fallback matching: 1. Pattern match (multipart RAR, split ZIP/7z, generic splits) 2. Exact filename match (case-insensitive) 3. Stem-based fuzzy match (handles debrid service filename modifications) 4. Single-item archive fallback - Simplify caching from Set+Array workaround back to simple Map (the original "caching failure" was caused by resolveArchiveItemsFromList returning empty arrays, not by Map/Set/Object data structure bugs). - Add comprehensive tests for archive item resolution (14 test cases) and JVM extraction progress callbacks (2 test cases). Co-Authored-By: Claude Opus 4.6 --- .../extractor/JBindExtractorMain$1.class | Bin 1710 -> 254 bytes .../JBindExtractorMain$Backend.class | Bin 2260 -> 2260 bytes ...dExtractorMain$BulkExtractCallback$1.class | Bin 0 -> 1966 bytes ...indExtractorMain$BulkExtractCallback.class | Bin 0 -> 6088 bytes .../JBindExtractorMain$ConflictMode.class | Bin 2014 -> 2014 bytes ...JBindExtractorMain$ExtractionRequest.class | Bin 2539 -> 2539 bytes .../JBindExtractorMain$ProgressTracker.class | Bin 1387 -> 1387 bytes ...ExtractorMain$SevenZipArchiveContext.class | Bin 1663 -> 1663 bytes ...ExtractorMain$SevenZipVolumeCallback.class | Bin 3666 -> 3667 bytes ...ExtractorMain$WrongPasswordException.class | Bin 458 -> 458 bytes .../extractor/JBindExtractorMain.class | Bin 23733 -> 23830 bytes .../extractor/JBindExtractorMain.java | 337 +++++++++++++----- src/main/download-manager.ts | 258 +++++--------- tests/extractor-jvm.test.ts | 105 ++++++ tests/resolve-archive-items.test.ts | 188 ++++++++++ 15 files changed, 639 insertions(+), 249 deletions(-) create mode 100644 resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback$1.class create mode 100644 resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$BulkExtractCallback.class create mode 100644 tests/resolve-archive-items.test.ts 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 aa67331473b01ad9d45a22e0f926de02464f6309..e3c3b5e77c4124c4d2a231e300c1e89a158d4025 100644 GIT binary patch delta 126 zcmZ3-`;SrJ)W2Q(7#J8#7`WLPm>3!KCZA{06*pvOU}0ns$Vx0r)Xz!GOV{^L%1TWx znf!ybmP!L)w5-n=JVEw4oHdrkPnw1o6C|c2n&E}dc-R#EAUCQ|G zKjAo|GivKcU!0NP*cp8D!9U9J+z>2d+6ShY*_?aNJ@^c`Zp6S2D z)-#whFt4nKWN_$R$24A2fHOOCj|hG&_C_NN?$}jN6czTb;-ZGv3|zwNgwqdEDBH5Z z@6&XBg|}s8EghF}MMKfR0^TGnJPAjb!gg)P_8l%Vk+IZczC|cJ?#)US@p&YC>DnGs z{E**-PkVJfiu_yFlW{n^$%!omHO_Bj)%C^os96=^hJuZdthjaGmQl#-E*DGOVHL^j zFWar2js73MxS{k)5QUCVEy_gd@9BAkJ4agf9WQ7z`^rK#f|`c+8I+UYY@m$m+!eh6 z@}VmmM|>yT%$y99-q_)ex}o52%i=K>9g&)GGD^NL!X?jcw*?nF^1peY;RcVClTEmI;>Qx%?g|SJ;D=o7=;1 zZXYARvYxQW|AQ<+Ut@{aF^u!>9Ny`9_=QiEhmZ1qVJLs_6$aw>bH9P|9Xxdp7s?MX zed_^cZtdgQGtYG}Tj(IeqJu(VALgHIs>RLx!I!_|m22J2CuUl#^J+NV!5jI5`y6eU zGoIj)5bI`>Q}xg{NYb|$rtdIDcQHZVW0HQrCAx<-(^@>XUzvJE13EhHwe_h 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 54d4c8fc551815e9499e5e59e247bb352d09d971..fe9414c16c2d42e23e76145188d97b38e1689fcf 100644 GIT binary patch delta 97 zcmca2ctvo-Mm9#9$(z{pf#f4LLr!M~Hf9$FZf4iXGVD`jJs3ooJsD(~y%-dkeHd(* weHk2>{TV!&0~mangC?J2cL3_*=db~iJ{*y}wha8tb_{aN_6)krKr=a%06`KIApigX delta 97 zcmca2ctvo-Mm9#*$(z{pf#f4LLrxzCHfCQ2Zf3v9GVD`j0~th^gBWC)gBcW=Lm6zC w!x$WxBN#lHBN=>|qbHwZcL3_*=db~iJ{*y}ZVdd)?hJCw9t^t7Kr=a%0AKkO%m4rY 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 0000000000000000000000000000000000000000..f4414fb9e1bf22d63748290b540e4896318e3068 GIT binary patch literal 1966 zcmb_dTW=dh6#gdbWbO4f4Q(N}hMLxZFKIUk=l7}d<O=VmK8 z%`o6MrI(x{Yvtpb%V~%rrb5#Zht4px#@D%FaeLLM*4KpTlebxEOaD59l1eXY7{qB6 zLpq+p8HSt3_0JIJrYSrxIXyK+8GV{MeoE;(<(({~V=PEZR0F>g_%+`ZyiMtz!?P-$ z(=m(@hLOX74E^_9=?hv@ck=0F6=Mu%chSXaVZ#(1Uplsm^9*TQ_=b1a@Wi^XKbM`g zI#DU9hP6=fvFz-DKhH4yq{tK&;qrmB8p72ufmjqT5Mn_zE#W5WLbek%v*`+17xuM8 zh`6L9i5H0%-suRt!Eia%&3A{juEAV-pQ`7BDlm$db)=AH&{lrSB*SdyrTnQV-V8|Tce2?#?rt93}bxY*By)&(22CwX4ooHuRI1$!M8eYX~ zDqh#|2Hs>Cd{X8L_nXwfZE1&umP%>D4kD*x7H{ndW+n{gi<1cEXkEPMI;$@6utY~e zOSn|^8@Q=rPDdW|46{d?vZstOsJyYxZIdXDq)K5c7Z@fF>)2vVd=?1Xm)xSk=}rwp z;UsFZOaaV04T|G*S=!=u(5?%2DQu43loc6H)qE;Jg?D;-?f=Q~s9kGL;F=;FY8sLK z=jD}Y)b8yX647rJoz=pV-dY2TsUMUq2} zmdSnxmf3>(4M_~|(sO`TD39qmj3S{z{P(1d(0Vuf6QbGeuh6gD&wLB^0H>6h>?Y2B zjZ2k>7+-maiIpu}IFs1K#mP;)M9U^JlUum_V;4WA7}@PFAK>cTuKbCS5Q|;m8hv&> zyM3R0$1y@@$td{`W6D6In0`h~`2}(1SBxlsU|jhP=at`)Q~ty)iqQ+WMPh^ujzdA2_I;?}_Kt24RlHBTF?utI8a|+1Sd{R0gXCq5{Rg}kIfwuN literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9b29d83e3cf6e7632752abf98926ac20b12735bf GIT binary patch literal 6088 zcmcgw33OD|8U9}GgqKVnTQJ~=XrzeAgfIcLsDo581f7_Wl#Ib7w)iqU!X%TKaTbh} z0!mxExZ{ReYZswFt1Qlh#VXbcYVBe-Tf5sRD-oQ+&`~cbG!0`hPQh4ha*}&>BO*jE0Dj^dwe!62=-MvNja!?xcpeHILsPS(K$R&v254sW@4Ilha0DAle+I zZpLSKmr91ho<==p5}u;rRFo?yS;Y9s4pJK zlvtx-4(1N(Ijc|}N4<)RG&G=*r#E@zP@{Qt z8MKy}6}FXG6Y|=bSwnI^O&XfoQtMx1a-6SXfv(;!a|?Tt!?%0@UrS(d>kVshh?;Y`*c%gTxMxJjzzW(Dp%!fjqR7!)d7RotSWb~LEwOJy_WII$VGN%6NUsH+%V zGRu=2y)u(*!JR7Z(r`DvtYG$Va9Xa@6f@$w1b>;4NQG%o1x*(zs2R!QhAd-9{tE6@ z@l_39!`Bs@GMrQ<;Fy0gLt(?BmcWX}rp5jxe&3>iziH`;fWNU$LHV$fvr8E*@55FV z+cbO=+Z9Y7PSRk8WVVy;O36k%Nf$Y*!atG_yU7g%!kxLWXU68mdeU%W2fnT0ei`;u z$_RvdIkp_TH0;J6Ca#2$Y|s-)2DH$skW7g(lC-OMFz0~BW*fOSqoE)B=n_UB!!i>Z z%Y$|~aX`aCbAok~A!Wz#9SslT5uVSA7jx#xfN|!C@p347?Q&0#D)5cUpqIxP=$oyi zJ`fd(@EBv(5f_ST56etr^QxSaGpZfJ6O!UdMlm~u-S3L$^4d7bYSPvv0U2v>TsUaExyVMU17X-cBPPkhPzpT>3vKCLdU~&F7$BI8=f8)(uBAi0}}t z+nLC^jPQ;wr~F^_US{4Dj`yM>hRr}UsfSs4^3@mZjme0|@L9|t%Yb3tzS`a=j1Q>` zD~FBu{ubl%6wA1f9;V4;S<@rNwj_B$q?u<80nUN&CT~lzxDjLVHxNY?R1I$^L$^YE zE9IdE7U%Nryb)OpSz?;*K;*BHk$=U9Q9-&cdADjqbl_5GCNKTXV*g)a@%ABh-oUzjOC-lQkxDau#}3~?*gKlzgX`JD96{S(-b`_`Lal4#pl(=0bY0PuGv@}$=Yit_x z-L7$Icz2Vtg5UC4ON~HW2Zy*G#o`8x6E|X#xCt(CGfowoak{t#RpM665w~H!xE<$- zJKz&r;1_qIN!*2t#odO59aW9{LKsTR~)WyG$ z6ob3Ul5HiyA4ewJNwVkhr>x=>onRD_+jCUh$0*!2bsuW?v(1fVk^q1!UqWAZj^~V1aB{C-IDU8hp% zd>vGqIbPgcwFh@>#bxZx6OMod=6o^xErQ%eP^D<2J0dxSGr)Bfeyi-jbuvU_U7xjhyjHcSCP&O%ATmfci^kKk>I@-h==oW7xBHqF}@h@x;Z(}3dP2wF~ zBaY&3@h-NC|6sd#4?7$R_BsmiutVTkMKcZ?H%#M>qQ+d95}sO#zlV0 zE_N_FJ0_saF&>j06EV#(30_AT>Ks#Wp<^=q4wrd~u_(M)Zhj2SaxW$n;cs}^+O|FX zlnf0&eI$JaujV0D^O3%Wzvm%Ujw9u{UZ$AX00I#v delta 83 zcmcb|e~*6y6B}dvWM(#fAZg5|10)mJdSp`>M3~bUWSP?$6qz#_jG40-%$ai-9GG(% jT$uAGbFv2k)y1#}^CmFxGAA-fF()yoGsjQf!>$AX6}%C) 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 f5701524ff17c2999d5a18261349de4c3b561cf9..d06ab5efc6b675fd1a73dd311fd4d282e8239071 100644 GIT binary patch delta 129 zcmaDY{91U!H+J4E1{UUA25#m&1|jDB$;=#Dj9HWQICOwyG=~m*mH-EX5rgsML=Mr( zi#Y_rd=my!F#iIFAW+pKunt~MEmn|PtI2|#qC)%|7#J8B8FYap8<1vT&YGOe2{O13 MOzz|~U=rW}0DA-*PXGV_ delta 129 zcmaDY{91U!H+J4)1{UTr25#nZ1|jB($;=#DjK!1nICOwyG=~m*u>c2y5rgsML=Mr( zi#Y_rd=my!F#iIFAW+pKunt~MEmn|PtI2|#qC)%|7#J8B8FYap8<1vTE}ops2{O13 MOzz|~U}9hZ0G{_7wEzGB 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 1c0c20a49c0063d2cf122f3791f6ff11065b3781..471ebab45d5ad83fc710cfab44c2499eb667ab12 100644 GIT binary patch delta 118 zcmV~$T?zqV7>41e=AI9qQie41m-g6I*QZBg}Fk@f?MoeTE7GNd973B}1PzW=NumfcicN3l`3t4!7 zh?&YUQGuCa=1NHIN5`{N#LB!IH(ASL%CS+Its3mqVy`|24LI7u^#Uj5Ijh9QzHU_G UsvceC<7l$7=tiNID;fh#N=q!Rx6em26mP>1_71?pm02c z2ul)!G)oGD3QHP;I!h{p21^EmK1&vZ8A}d>B}+Dg6-yq2BTGJm2TS2(DK8ZHaHrO?XCA|i(+E-qXg#bLcE%0;^@L!n(z#y3B1E)FX{ z!Uz)wE;2ugyT8SIeR}G7e*9nm^2Kpu*&vwBbJSO|T@$tAT=ASynqM>S5Iu z-N0pCQ8wgJv_JPE&8F(uQZw73xuZ5pO0%mz_B4!XoPAAlplJ>@!;w{EQx38B_{sDjNJ8A#` delta 230 zcmWN{xk>|300q#Sxfcf~&lr=CjS*p#NJcXR1eZcZ8ym5(6fMGF5wJ3iVC5eO?gr-fXo-rcc_=zMJPI#18s-g)*-Z@CJ3> z+HEiPcn_BkFnqLk-ciIS^za!)zI5j+hWLhv@0j2R=J|RI1qN0Y#mO5Or2sT`1gHQ2 delta 18 ZcmX@be2RI)Dn>TudInbJhRGWkr2sjY1!MpK diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index d4c29cef8a5228dc7477023297da0f6b0d3c805d..63dbc49286d6e5699c9b13287f43a34270284fbf 100644 GIT binary patch literal 23830 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 literal 23733 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 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); + }); +});