Release v1.4.22 with incremental hybrid extraction (JDownloader-style)

Implements hybrid extraction: when a package has multiple episodes with
multi-part archives, completed archive sets are extracted immediately
while the rest of the package continues downloading. Uses the existing
hybridExtract setting (already in UI/types/storage).

Key changes:
- Export findArchiveCandidates/pathSetKey from extractor.ts
- Add onlyArchives/skipPostCleanup options to ExtractOptions
- Add findReadyArchiveSets to identify complete archive sets
- Add runHybridExtraction for incremental extraction passes
- Requeue logic in runPackagePostProcessing for new completions
- Resume state preserved across hybrid passes (no premature clear)
- Guard against extracting incomplete multi-part archives
- Correct abort/toggle handling during hybrid extraction
- Package toggle now also aborts active post-processing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe
2026-02-28 07:25:18 +01:00
co-authored by Claude Opus 4.6
parent d7162592e0
commit f70237f13d
3 changed files with 243 additions and 39 deletions
+46 -34
View File
@@ -24,6 +24,8 @@ export interface ExtractOptions {
passwordList?: string;
signal?: AbortSignal;
onProgress?: (update: ExtractProgressUpdate) => void;
onlyArchives?: Set<string>;
skipPostCleanup?: boolean;
}
export interface ExtractProgressUpdate {
@@ -43,7 +45,7 @@ const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000;
const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000;
const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function pathSetKey(filePath: string): string {
export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
}
@@ -80,7 +82,7 @@ type ExtractResumeState = {
completedArchives: string[];
};
function findArchiveCandidates(packageDir: string): string[] {
export function findArchiveCandidates(packageDir: string): string[] {
if (!packageDir || !fs.existsSync(packageDir)) {
return [];
}
@@ -831,23 +833,31 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
throw new Error("aborted:extract");
}
const candidates = findArchiveCandidates(options.packageDir);
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
const allCandidates = findArchiveCandidates(options.packageDir);
const candidates = options.onlyArchives
? allCandidates.filter((archivePath) => {
const key = process.platform === "win32" ? path.resolve(archivePath).toLowerCase() : path.resolve(archivePath);
return options.onlyArchives!.has(key);
})
: allCandidates;
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
if (candidates.length === 0) {
const existingResume = readExtractResumeState(options.packageDir);
if (existingResume.size > 0 && hasAnyEntries(options.targetDir)) {
if (!options.onlyArchives) {
const existingResume = readExtractResumeState(options.packageDir);
if (existingResume.size > 0 && hasAnyEntries(options.targetDir)) {
clearExtractResumeState(options.packageDir);
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
options.onProgress?.({
current: existingResume.size,
total: existingResume.size,
percent: 100,
archiveName: "",
phase: "done"
});
return { extracted: existingResume.size, failed: 0, lastError: "" };
}
clearExtractResumeState(options.packageDir);
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
options.onProgress?.({
current: existingResume.size,
total: existingResume.size,
percent: 100,
archiveName: "",
phase: "done"
});
return { extracted: existingResume.size, failed: 0, lastError: "" };
}
clearExtractResumeState(options.packageDir);
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
return { extracted: 0, failed: 0, lastError: "" };
}
@@ -856,9 +866,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
let passwordCandidates = archivePasswords(options.passwordList || "");
const resumeCompleted = readExtractResumeState(options.packageDir);
const resumeCompletedAtStart = resumeCompleted.size;
const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath)));
const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath)));
for (const archiveName of Array.from(resumeCompleted.values())) {
if (!candidateNames.has(archiveName)) {
if (!allCandidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName);
}
}
@@ -869,7 +879,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
let extracted = resumeCompleted.size;
let extracted = candidates.length - pendingCandidates.length;
let failed = 0;
let lastError = "";
const extractedArchives = new Set<string>();
@@ -996,32 +1006,34 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
extracted = 0;
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
} else {
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode);
if (options.cleanupMode !== "none") {
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
}
if (options.removeLinks) {
const removedLinks = removeDownloadLinkArtifacts(options.targetDir);
logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`);
}
if (options.removeSamples) {
const removedSamples = removeSampleArtifacts(options.targetDir);
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
if (!options.skipPostCleanup) {
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode);
if (options.cleanupMode !== "none") {
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
}
if (options.removeLinks) {
const removedLinks = removeDownloadLinkArtifacts(options.targetDir);
logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`);
}
if (options.removeSamples) {
const removedSamples = removeSampleArtifacts(options.targetDir);
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
}
}
if (failed === 0 && resumeCompleted.size >= candidates.length) {
if (failed === 0 && resumeCompleted.size >= allCandidates.length) {
clearExtractResumeState(options.packageDir);
}
if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
if (!options.skipPostCleanup && options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
const removedDirs = removeEmptyDirectoryTree(options.packageDir);
if (removedDirs > 0) {
logger.info(`Leere Download-Ordner entfernt: ${removedDirs} (root=${options.packageDir})`);
}
}
}
} else {
} else if (!options.skipPostCleanup) {
try {
if (fs.existsSync(options.targetDir) && fs.readdirSync(options.targetDir).length === 0) {
fs.rmSync(options.targetDir, { recursive: true, force: true });