Aus der Bug-Analyse (3 Subagents): die Deferred-Post-Processing-Pipeline war
nur halb ins Abbruch-/Lifecycle-Management integriert — gleiche Ecke wie der
v1.7.156-Datenverlust.
H1: abortPostProcessing (globaler Stop/Shutdown/clearAll/external) bricht jetzt
auch packageDeferredPostProcessAbortControllers + die neue Hybrid-Map ab.
Vorher rasten MKV-Move/Cleanup/Rename gegen den synchronen Shutdown-Save.
H2: Hybrid-Post-Extract (Rename+MKV-Collect) lief als komplett ungetracktes
detached Promise. Jetzt in packageHybridPostProcessControllers (Set/Package)
registriert — SYNCHRON vor dem Promise, mit shouldAbort an beide Aufrufe.
Bewusst SEPARAT von der Deferred-Map, sonst würde runDeferredPostExtraction's
replace-Logik die laufende Hybrid-Arbeit selbst killen (Advisor-Fund).
Cancel/Reset/Stop stoppt jetzt laufende Hybrid-Verschiebungen.
M1: hasAnyDeferredPostProcessPending() — Scheduler-Abschluss + finishRun-Clear
gaten darauf. Run endet/Summary feuert nicht mehr während im Hintergrund
noch Dateien verschoben werden; Run-State wird nicht mehr mittendrin geleert.
H3: validateDownloadedFileCompletion akzeptierte 0-Byte bei source=stream-end
(kein Content-Length, keine Provider-Größe) als "fertig". Jetzt ok:false
-> bestehender download_underflow-Retry-Pfad. Verhindert leere Datei = komplett.
N1: toter (unerreichbarer) Disk-Fallback-Block in findReadyArchiveSets +
verwaiste pendingItemStatus-Map entfernt (verhaltensneutral).
Bewusst übersprungen: M2 (blockAllPersistence — vorgeschlagener Reset wäre
unsicher, In-Memory-Session ist nach Import stale) und M3 (cancelPendingAsyncSaves
— Generation-Guard schützt Korrektheit bereits). Siehe tasks/todo.md.
8 neue Tests (tests/download-completion.test.ts) inkl. H3-Regression. 621 Tests grün.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
156 lines
4.4 KiB
TypeScript
156 lines
4.4 KiB
TypeScript
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
|
|
|
export type DownloadCompletionSource =
|
|
| "content-range"
|
|
| "content-length"
|
|
| "provider-metadata"
|
|
| "stream-end";
|
|
|
|
export type DownloadCompletionPlan = {
|
|
expectedTotal: number | null;
|
|
source: DownloadCompletionSource;
|
|
canFinishEarly: boolean;
|
|
};
|
|
|
|
export function planDownloadCompletion(args: {
|
|
existingBytes: number;
|
|
responseStatus: number;
|
|
contentLength: number;
|
|
totalFromRange: number | null;
|
|
knownTotal: number | null;
|
|
correctedTotal: number | null;
|
|
}): DownloadCompletionPlan {
|
|
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
|
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
|
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
|
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
|
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
|
: 0;
|
|
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
|
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
|
: 0;
|
|
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
|
? Math.max(0, Math.floor(args.knownTotal || 0))
|
|
: 0;
|
|
|
|
if (correctedTotal > 0) {
|
|
return {
|
|
expectedTotal: correctedTotal,
|
|
source: totalFromRange > 0 ? "content-range" : "content-length",
|
|
canFinishEarly: true
|
|
};
|
|
}
|
|
|
|
if (totalFromRange > 0) {
|
|
return {
|
|
expectedTotal: totalFromRange,
|
|
source: "content-range",
|
|
canFinishEarly: true
|
|
};
|
|
}
|
|
|
|
if (contentLength > 0) {
|
|
return {
|
|
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
|
source: "content-length",
|
|
canFinishEarly: true
|
|
};
|
|
}
|
|
|
|
if (knownTotal > 0) {
|
|
return {
|
|
expectedTotal: knownTotal,
|
|
source: "provider-metadata",
|
|
canFinishEarly: false
|
|
};
|
|
}
|
|
|
|
return {
|
|
expectedTotal: null,
|
|
source: "stream-end",
|
|
canFinishEarly: false
|
|
};
|
|
}
|
|
|
|
export function validateDownloadedFileCompletion(args: {
|
|
actualBytes: number;
|
|
plan: DownloadCompletionPlan;
|
|
toleranceBytes?: number;
|
|
}): {
|
|
ok: boolean;
|
|
totalBytes: number;
|
|
acceptedMetadataMismatch: boolean;
|
|
error?: string;
|
|
} {
|
|
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
|
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
|
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
|
: 0;
|
|
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
|
|
|
if (
|
|
expectedTotal > 0 &&
|
|
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
|
actualBytes + toleranceBytes < expectedTotal
|
|
) {
|
|
return {
|
|
ok: false,
|
|
totalBytes: expectedTotal,
|
|
acceptedMetadataMismatch: false,
|
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
|
};
|
|
}
|
|
|
|
if (actualBytes <= 0 && expectedTotal > 0) {
|
|
return {
|
|
ok: false,
|
|
totalBytes: expectedTotal,
|
|
acceptedMetadataMismatch: false,
|
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
|
};
|
|
}
|
|
|
|
if (args.plan.source === "provider-metadata") {
|
|
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
|
return {
|
|
ok: false,
|
|
totalBytes: expectedTotal,
|
|
acceptedMetadataMismatch: false,
|
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
totalBytes: actualBytes,
|
|
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
|
};
|
|
}
|
|
|
|
if (args.plan.source === "stream-end") {
|
|
// H3: Kein Content-Length, keine Provider-Größe UND 0 Bytes empfangen → der
|
|
// Hoster hat die Verbindung sofort geschlossen. Das ist ein fehlgeschlagener
|
|
// Download, kein gültiges "fertig" — sonst gilt eine leere Datei als komplett
|
|
// und es gibt keinen Auto-Redownload. Verhält sich jetzt wie der bereits
|
|
// behandelte Fall actualBytes<=0 mit bekannter Größe (oben).
|
|
if (actualBytes <= 0) {
|
|
return {
|
|
ok: false,
|
|
totalBytes: 0,
|
|
acceptedMetadataMismatch: false,
|
|
error: "download_underflow:0/0"
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
totalBytes: actualBytes,
|
|
acceptedMetadataMismatch: false
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
totalBytes: Math.max(actualBytes, expectedTotal),
|
|
acceptedMetadataMismatch: false
|
|
};
|
|
}
|