feat: Download System v2 — complete rewrite of download pipeline

Replace monolithic download-manager.ts (9500 lines) with 7 focused modules:

- error-classifier.ts: 25+ typed DownloadErrorKind enum, classifier functions
  for network/HTTP/debrid/extraction errors — no more string matching
- retry-manager.ts: Declarative per-error-kind retry policies, exponential
  backoff, shelving after 15 failures, state export/import
- stream-writer.ts: HTTP stream → file with pre-resume validation, stall
  detection, NTFS-aligned buffered writing, Range-ignored detection
- pipeline.ts: Single download lifecycle (unrestrict → stream → verify),
  throws typed errors, caller decides retry strategy
- post-processor.ts: Extraction state machine with hard caps (3 attempts
  per archive, 5 rounds per package), no infinite loops
- scheduler.ts: Queue management with priority-based slot allocation,
  heartbeat stall detection, global watchdog, provider cooldowns
- download-manager.ts: Drop-in orchestrator (~1500 lines), same public API

Fixes:
1. Hanging downloads: heartbeat-based stall detection + global watchdog
2. Wrong error classification: typed enum at point of origin
3. Unreliable resume: file size vs tracker validation, Range-ignored detection
4. Extraction loops: bounded retries with state machine

215 new unit tests for error-classifier and retry-manager (all passing).
Build compiles cleanly. Same IPC interface — UI unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe
2026-03-08 18:14:17 +01:00
co-authored by Claude Opus 4.6
parent 63b412a43f
commit efa0909e11
14 changed files with 6970 additions and 2 deletions
@@ -0,0 +1,259 @@
# Download System v2 — Complete Redesign
## Goal
Replace the 9500-line monolithic `download-manager.ts` with a clean, modular download system that fixes:
1. Downloads hanging without clean restart
2. Wrong error classification leading to wrong retry paths
3. Unreliable resume (corrupt files, unnecessary restarts)
4. Post-processing (extraction) breaking or looping
## Constraints
- Same IPC interface — drop-in replacement, no UI changes needed
- Same external dependencies (debrid.ts, storage.ts, integrity.ts)
- Same session/settings persistence format
## Architecture
### Module Structure
```
src/main/download/
├── download-manager.ts # Orchestrator (~500 lines) — coordination only
├── scheduler.ts # Queue management, slot allocation, priorities
├── pipeline.ts # Single download flow: unrestrict → stream → verify
├── stream-writer.ts # HTTP streaming, resume, buffered writing, NTFS
├── error-classifier.ts # Typed error system (enums, not string matching)
├── retry-manager.ts # Central retry logic, backoff, shelving, state
└── post-processor.ts # Extraction queue, hybrid retry, cleanup
```
### Module Responsibilities
#### 1. download-manager.ts (Orchestrator)
- Holds session state, packages, items
- Exposes same IPC methods as current (startRun, stopRun, pauseItem, etc.)
- Delegates to Scheduler for queue management
- Delegates to Pipeline for individual downloads
- Delegates to PostProcessor for extraction
- Emits same events as current (progress, status changes)
- Handles persistence (save/load session)
#### 2. scheduler.ts
- `findNextItem()`: priority-based queue with provider cooldown awareness
- `fillSlots()`: start downloads up to maxParallel
- Scheduler loop with generation guard (prevents stale schedulers)
- Global stall watchdog
- Provider cooldown tracking (circuit breaker)
- AllDebrid paced-start / hoster-limit logic
#### 3. pipeline.ts
- `runDownload(item, context)`: single download lifecycle
- Step 1: Unrestrict link via debrid service
- Step 2: Stream file via StreamWriter
- Step 3: Verify integrity (CRC if available)
- Step 4: Signal completion
- Each step returns typed result or throws typed DownloadError
- No retry logic here — just reports what happened
#### 4. stream-writer.ts
- `streamToFile(url, targetPath, options)`: HTTP streaming
- Resume support with pre-validation:
- Check existing file size against tracked downloadedBytes
- Truncate if sparse file detected (pre-allocated > actual)
- Send Range header only after validation
- HTTP 416 handling (complete vs incomplete)
- Server-ignored-range detection (200 instead of 206)
- Buffered writing with NTFS 4KB alignment
- Sparse file pre-allocation (Windows)
- Content-Disposition filename override
- Stall detection (configurable timeout, default 10s)
- Drain timeout for slow disks (default 5min)
- Progress reporting via callback
#### 5. error-classifier.ts
- `DownloadErrorKind` enum with all error categories
- `DownloadError` class extending Error with `.kind` property
- `classifyError(error, context)`: takes raw error + context, returns DownloadError
- Classifies at point of origin (HTTP layer, fetch layer, debrid layer)
- No post-hoc string matching needed
- `classifyHttpStatus(status, headers)`: HTTP-specific classification
- `classifyFetchError(error)`: network-level classification
- `classifyUnrestrictError(error)`: debrid-specific classification
```typescript
enum DownloadErrorKind {
// Network
NetworkReset, // ECONNRESET, socket hang up, EPIPE
Timeout, // No data received within stall timeout
DnsFailure, // ENOTFOUND
// HTTP
RangeNotSatisfied, // 416 — file may be complete or need restart
RangeIgnored, // Server sent 200 instead of 206
ServerError, // 500, 502, 503
RateLimited, // 429
Forbidden, // 403 — link expired
NotFound, // 404 — file removed from CDN
// Provider/Debrid
UnrestrictFailed, // Provider can't convert link
ProviderBusy, // Concurrent download limit
ProviderDown, // Provider service unavailable
HosterUnavailable, // Hoster down (not provider issue)
LinkDead, // Permanent: file deleted at source
QuotaExceeded, // Daily traffic limit
// Filesystem
DiskFull, // ENOSPC
PermissionDenied, // EACCES, EPERM
FileLocked, // EBUSY (Windows)
// Integrity
FileCorrupt, // CRC/size mismatch after download
FileTruncated, // Downloaded less than expected
// Extraction
WrongPassword, // Archive password incorrect
ArchiveCorrupt, // Archive header/data damaged
ExtractorCrash, // 7-Zip/WinRAR process crashed
ExtractionLoop, // Same archive failed extraction 3+ times
}
```
#### 6. retry-manager.ts
- `RetryManager` class holds all retry state per item
- Deklarative retry policies per DownloadErrorKind:
```typescript
interface RetryPolicy {
maxRetries: number; // 0 = no retry (permanent failure)
backoff: "fixed" | "exponential" | "linear";
baseDelayMs: number;
maxDelayMs: number;
resetFile: boolean; // Delete partial file before retry
switchProvider: boolean; // Try different provider
refreshLink: boolean; // Get new direct link from debrid
providerCooldownMs?: number; // Apply cooldown to current provider
}
```
- `shouldRetry(itemId, error)`: returns { retry: boolean, delayMs, actions[] }
- `recordFailure(itemId, error)`: tracks failure for shelving
- Shelving: after N total failures (configurable, default 15), pause 90s + reset provider
- State persists across stop/start (same format as current retryStateByItem)
- `resetItem(itemId)`: clear all retry state (manual reset)
#### 7. post-processor.ts
- `PostProcessor` class with extraction queue
- State machine per package:
```
pending → extracting → done
retry (max 2) → failed
```
- Tracks extraction attempts per archive (max 3 retries)
- No infinite loops: hard cap on retry count
- Hybrid extract retry: if archive corrupt + redownload suggested, queue redownload (max 1 time)
- Cleanup: remove partial extracts on failure
- Empty folder cleanup after successful extraction
### Data Flow
```
User clicks Start
DownloadManager.startRun()
Scheduler.start() — begins loop
Scheduler.findNextItem() — picks highest priority queued item
Pipeline.runDownload(item)
├── debridService.unrestrict(item.link)
│ └── error? → ErrorClassifier.classify() → DownloadError
├── StreamWriter.streamToFile(url, path, opts)
│ ├── Resume validation
│ ├── HTTP streaming with stall detection
│ └── error? → ErrorClassifier.classify() → DownloadError
└── integrityCheck(file)
└── error? → DownloadError(FileCorrupt)
Success → mark completed → Scheduler fills next slot
Error → RetryManager.shouldRetry(item, error)
├── retry: true → Scheduler.queueRetry(item, delay, actions)
└── retry: false → mark failed
All items done → PostProcessor.run(package)
├── Extract archives
├── Verify extracted files
└── Cleanup
```
### Resume Validation (Key Improvement)
Current problem: Resume trusts file size blindly, leading to corrupt files.
New approach:
1. Before sending Range header, validate existing file:
- `stat.size` must match `item.downloadedBytes` (±1KB tolerance for flush timing)
- If mismatch > 1MB: file is from sparse pre-allocation → truncate to downloadedBytes
- If mismatch < 1MB but > 1KB: suspicious → delete and restart fresh
2. After resume response, validate:
- 206 with correct Content-Range → continue
- 200 (range ignored) → classify as RangeIgnored, retry with fresh link
- 416 → check if file actually complete (existingBytes >= expectedTotal)
3. After download complete, validate:
- Final file size matches expected total
- CRC check if manifest available
### Stall Detection (Key Improvement)
Current problem: Downloads hang and stall detection sometimes doesn't trigger properly.
New approach:
- **Per-download heartbeat**: StreamWriter emits heartbeat every second with bytes received
- **Scheduler monitors heartbeats**: if no heartbeat for stallTimeoutMs → abort + retry
- **Disk-write awareness**: separate tracking for "blocked on disk write" vs "blocked on network"
- **Global watchdog**: if ALL active downloads show zero progress for 60s (excluding disk-blocked), abort all and re-queue
- **Validating timeout**: if unrestrict takes > 30s, abort and retry (prevents infinite hang in validation phase)
### Post-Processing State Machine (Key Improvement)
Current problem: Extraction can loop infinitely if archive keeps failing.
New approach:
```
ExtractionState per archive:
{
archivePath: string;
status: "pending" | "extracting" | "done" | "failed";
attempts: number; // max 3
lastError?: string;
redownloaded: boolean; // max 1 redownload
}
```
Rules:
- Max 3 extraction attempts per archive
- If `ArchiveCorrupt` + `redownloaded === false` → queue redownload, set redownloaded = true
- If `ArchiveCorrupt` + `redownloaded === true` → fail permanently
- If `WrongPassword` → try next password from list, fail after all exhausted
- If `ExtractorCrash` → retry once, fail on second crash
- Package marked as "completed with errors" if any archive fails permanently
## Migration Strategy
1. New code lives in `src/main/download/` directory
2. Old `src/main/download-manager.ts` stays untouched as reference
3. New `download-manager.ts` in `src/main/download/` implements same class interface
4. Switch import in `main.ts` from old to new
5. Test with real downloads
6. Delete old file when stable
## Testing Strategy
- Unit tests for ErrorClassifier (classify every known error string)
- Unit tests for RetryManager (policy application, shelving threshold)
- Unit tests for StreamWriter resume validation logic
- Unit tests for PostProcessor state machine
- Integration test: Scheduler + Pipeline with mocked debrid/HTTP
@@ -0,0 +1,737 @@
# Download System v2 — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the 9500-line monolithic download-manager.ts with 7 clean modules that fix hanging downloads, wrong error classification, unreliable resume, and extraction loops.
**Architecture:** Modular pipeline with typed errors, declarative retry policies, validated resume, and state-machine extraction. Same IPC interface — drop-in replacement.
**Tech Stack:** TypeScript, Node.js, Electron IPC, EventEmitter
---
### Task 1: Create error-classifier.ts — Typed Error System
**Files:**
- Create: `src/main/download/error-classifier.ts`
**Step 1: Create the DownloadErrorKind enum and DownloadError class**
```typescript
// src/main/download/error-classifier.ts
export enum DownloadErrorKind {
// Network
NetworkReset = "network_reset",
Timeout = "timeout",
DnsFailure = "dns_failure",
// HTTP
RangeNotSatisfied = "range_not_satisfied",
RangeIgnored = "range_ignored",
ServerError = "server_error",
RateLimited = "rate_limited",
Forbidden = "forbidden",
NotFound = "not_found",
// Provider/Debrid
UnrestrictFailed = "unrestrict_failed",
ProviderBusy = "provider_busy",
ProviderDown = "provider_down",
HosterUnavailable = "hoster_unavailable",
LinkDead = "link_dead",
QuotaExceeded = "quota_exceeded",
// Filesystem
DiskFull = "disk_full",
PermissionDenied = "permission_denied",
FileLocked = "file_locked",
// Integrity
FileCorrupt = "file_corrupt",
FileTruncated = "file_truncated",
ResumeUnderflow = "resume_underflow",
// Extraction
WrongPassword = "wrong_password",
ArchiveCorrupt = "archive_corrupt",
ExtractorCrash = "extractor_crash",
// Catchall
Unknown = "unknown",
}
export class DownloadError extends Error {
readonly kind: DownloadErrorKind;
readonly retryable: boolean;
readonly permanent: boolean;
readonly httpStatus?: number;
readonly originalError?: Error;
constructor(kind: DownloadErrorKind, message: string, opts?: {
httpStatus?: number;
originalError?: Error;
retryable?: boolean;
permanent?: boolean;
}) {
super(message);
this.name = "DownloadError";
this.kind = kind;
this.retryable = opts?.retryable ?? !isPermanentKind(kind);
this.permanent = opts?.permanent ?? isPermanentKind(kind);
this.httpStatus = opts?.httpStatus;
this.originalError = opts?.originalError;
}
}
```
**Step 2: Create classifyFetchError — network-level errors**
Classify raw fetch/network errors at the point they occur. Covers ECONNRESET, socket hang up, DNS, timeout, ENOSPC, EACCES, EPERM, EBUSY.
**Step 3: Create classifyHttpStatus — HTTP response errors**
Classify HTTP status codes: 416 → RangeNotSatisfied, 429 → RateLimited, 403 → Forbidden, 404 → NotFound, 5xx → ServerError. Also detect range-ignored (200 when Range was sent).
**Step 4: Create classifyUnrestrictError — debrid API errors**
Classify unrestrict response errors by checking for known patterns:
- "file not found", "file deleted", "link is dead" → LinkDead (permanent)
- "too many active", "concurrent limit" → ProviderBusy
- "hosternotavailable" → HosterUnavailable
- "server error", "maintenance", "cloudflare" → ProviderDown
- "quota", "traffic" → QuotaExceeded
- Everything else → UnrestrictFailed
**Step 5: Create classifyExtractionError**
Classify extraction failures: wrong_password → WrongPassword, corrupt header → ArchiveCorrupt, process crash → ExtractorCrash.
**Step 6: Helper function isPermanentKind**
Returns true for LinkDead, DiskFull, PermissionDenied — errors where retrying is pointless.
---
### Task 2: Create retry-manager.ts — Declarative Retry Logic
**Files:**
- Create: `src/main/download/retry-manager.ts`
**Step 1: Define RetryPolicy interface and RETRY_POLICIES map**
```typescript
export interface RetryPolicy {
maxRetries: number; // 0 = permanent failure, no retry
backoff: "fixed" | "exponential";
baseDelayMs: number;
maxDelayMs: number;
resetFile: boolean; // Delete partial file before retry
switchProvider: boolean; // Try different debrid provider
refreshLink: boolean; // Get new direct link
providerCooldownMs: number; // Apply cooldown to current provider (0 = no cooldown)
}
export const RETRY_POLICIES: Record<DownloadErrorKind, RetryPolicy> = {
[DownloadErrorKind.NetworkReset]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.Timeout]: { maxRetries: 10, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 30000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.DnsFailure]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 5000, maxDelayMs: 5000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.RangeNotSatisfied]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 200, maxDelayMs: 200, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.RangeIgnored]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.ServerError]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 60000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.RateLimited]: { maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.Forbidden]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.NotFound]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.UnrestrictFailed]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 20000 },
[DownloadErrorKind.ProviderBusy]: { maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 60000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 12000 },
[DownloadErrorKind.ProviderDown]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 10000, maxDelayMs: 180000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 30000 },
[DownloadErrorKind.HosterUnavailable]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 30000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 15000 },
[DownloadErrorKind.LinkDead]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.QuotaExceeded]: { maxRetries: 3, backoff: "exponential", baseDelayMs: 30000, maxDelayMs: 300000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 60000 },
[DownloadErrorKind.DiskFull]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.PermissionDenied]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.FileLocked]: { maxRetries: 3, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 10000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.FileCorrupt]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 500, maxDelayMs: 500, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.FileTruncated]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.ResumeUnderflow]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.WrongPassword]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.ArchiveCorrupt]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 },
[DownloadErrorKind.ExtractorCrash]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
[DownloadErrorKind.Unknown]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 60000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 },
};
```
**Step 2: Create RetryManager class**
```typescript
export interface RetryState {
failuresByKind: Partial<Record<DownloadErrorKind, number>>;
totalFailures: number;
shelveCount: number;
lastError?: DownloadError;
}
export interface RetryDecision {
shouldRetry: boolean;
delayMs: number;
actions: RetryAction[];
reason: string; // Human-readable status message
}
export type RetryAction = "reset_file" | "switch_provider" | "refresh_link" | "cooldown_provider" | "shelve";
export class RetryManager {
private states: Map<string, RetryState> = new Map();
private userRetryLimit: number = 0; // 0 = unlimited
constructor(retryLimit: number);
setRetryLimit(limit: number): void;
/**
* Record a failure and decide whether to retry.
*/
evaluate(itemId: string, error: DownloadError): RetryDecision;
/**
* Reset retry state for an item (manual reset).
*/
resetItem(itemId: string): void;
/**
* Get current retry state (for persistence).
*/
getState(itemId: string): RetryState | undefined;
/**
* Restore retry state (from persisted session).
*/
restoreState(itemId: string, state: RetryState): void;
/**
* Export all states for persistence.
*/
exportStates(): Record<string, RetryState>;
/**
* Import states from persistence.
*/
importStates(states: Record<string, RetryState>): void;
/**
* Remove state for deleted items.
*/
removeItem(itemId: string): void;
}
```
Key logic:
- `evaluate()` checks policy for error.kind, compares against current failure count
- If totalFailures >= 15 (SHELVE_THRESHOLD): shelve (90s pause + half-reset counters + switch provider)
- User retryLimit overrides policy maxRetries if set (retryLimit > 0)
- Backoff calculation: exponential = baseDelayMs * 1.5^(attempt-1) with jitter, capped at maxDelayMs
- Returns structured RetryDecision with all actions the caller needs to execute
---
### Task 3: Create stream-writer.ts — HTTP Streaming with Validated Resume
**Files:**
- Create: `src/main/download/stream-writer.ts`
**Step 1: Define StreamResult and StreamOptions interfaces**
```typescript
export interface StreamOptions {
url: string;
targetPath: string;
expectedBytes: number | null;
downloadedBytes: number; // Previously downloaded (for resume validation)
stallTimeoutMs: number;
connectTimeoutMs: number;
skipTlsVerify: boolean;
speedLimitBps: number; // 0 = no limit
signal: AbortSignal;
onProgress: (bytes: number, totalBytes: number | null, speedBps: number) => void;
onHeartbeat: () => void; // Called every ~1s even during slow transfer
onResumable: (resumable: boolean) => void;
onFileNameOverride: (newName: string) => void; // Content-Disposition
}
export interface StreamResult {
totalBytes: number;
downloadedBytes: number;
resumable: boolean;
fileName?: string; // If Content-Disposition provided new name
completed: boolean;
}
```
**Step 2: Implement streamToFile function**
Core logic:
1. **Pre-resume validation**: stat existing file, compare with downloadedBytes
- If file doesn't exist → fresh download (downloadedBytes = 0)
- If file.size matches downloadedBytes (±4KB) → resume from file.size
- If file.size > downloadedBytes + 1MB → truncate to downloadedBytes (sparse file fix)
- If file.size < downloadedBytes (but > 0) → file was corrupted, delete and restart
2. **HTTP request**: send Range header if resuming, detect 206/200/416
3. **416 handling**: check Content-Range for total, if file complete → accept, else throw RangeNotSatisfied
4. **200 with Range sent**: throw RangeIgnored
5. **Streaming loop**: buffered read with stall timeout, write with NTFS alignment, backpressure handling
6. **Heartbeat**: emit heartbeat every 1s regardless of transfer state
7. **Speed limiting**: token bucket or simple delay between chunks
8. **Content-Disposition**: parse filename, notify via callback
9. **Sparse pre-allocation**: on Windows, pre-allocate file with truncate for fresh downloads
**Step 3: Implement stall detection within the stream loop**
- Read with timeout (stallTimeoutMs)
- If timeout → throw DownloadError(Timeout)
- Track blockedOnDiskWrite state (write backpressure)
- Drain timeout for slow disks (5 min)
**Step 4: Implement the buffered writer with NTFS alignment**
- 512KB write buffer
- Flush aligned to 4KB boundaries
- Final flush writes remaining bytes
- Backpressure: await stream.drain() when write returns false
---
### Task 4: Create pipeline.ts — Single Download Lifecycle
**Files:**
- Create: `src/main/download/pipeline.ts`
**Step 1: Define PipelineContext and PipelineResult**
```typescript
export interface PipelineContext {
item: DownloadItem;
package: PackageEntry;
settings: AppSettings;
debridService: DebridService;
signal: AbortSignal;
cachedDirectUrl?: string; // Reuse from previous attempt
onStatus: (status: DownloadStatus, fullStatus: string) => void;
onProgress: (bytes: number, total: number | null, speed: number) => void;
onResumable: (resumable: boolean) => void;
onFileNameOverride: (newName: string) => void;
onProviderInfo: (provider: DebridProvider, label?: string, accountId?: string, accountLabel?: string) => void;
onHeartbeat: () => void;
}
export interface PipelineResult {
success: boolean;
downloadedBytes: number;
totalBytes: number | null;
directUrl?: string; // For caching across retries
resumable: boolean;
}
```
**Step 2: Implement runPipeline function**
```typescript
export async function runPipeline(ctx: PipelineContext): Promise<PipelineResult>
```
Steps within the pipeline:
1. **Unrestrict**: Call debridService.unrestrict() with abort signal, apply TLS skip if needed
- On error → classifyUnrestrictError() → throw DownloadError
- On success → emit provider info, update status to "downloading"
2. **Stream**: Call streamToFile() with resolved direct URL
- On error → classifyFetchError() or classifyHttpStatus() → throw DownloadError
- On progress → forward to ctx.onProgress
3. **Integrity check** (if enabled): Call validateFileAgainstManifest()
- On mismatch → throw DownloadError(FileCorrupt)
4. Return PipelineResult with final state
The pipeline does NOT handle retries — it runs once and either succeeds or throws a typed DownloadError. The caller (download-manager + retry-manager) decides what to do with errors.
---
### Task 5: Create post-processor.ts — Extraction State Machine
**Files:**
- Create: `src/main/download/post-processor.ts`
**Step 1: Define extraction state types**
```typescript
export interface ArchiveExtractionState {
archiveName: string;
status: "pending" | "extracting" | "done" | "failed";
attempts: number;
maxAttempts: number; // Default 3
redownloaded: boolean;
lastError?: string;
lastErrorKind?: DownloadErrorKind;
}
export interface PackagePostProcessState {
packageId: string;
status: "pending" | "extracting" | "done" | "failed" | "aborted";
archives: Map<string, ArchiveExtractionState>;
startedAt: number;
completedAt?: number;
}
```
**Step 2: Implement PostProcessor class**
```typescript
export class PostProcessor extends EventEmitter {
private states: Map<string, PackagePostProcessState> = new Map();
private abortControllers: Map<string, AbortController> = new Map();
private activeCount: number = 0;
private maxParallel: number;
constructor(maxParallel: number);
/**
* Queue a package for post-processing (extraction).
*/
queuePackage(packageId: string, options: PostProcessOptions): void;
/**
* Run the post-processing queue.
*/
async processQueue(): Promise<void>;
/**
* Abort processing for a specific package.
*/
abortPackage(packageId: string): void;
/**
* Abort all active post-processing.
*/
abortAll(): void;
/**
* Retry extraction for a specific package.
*/
retryPackage(packageId: string): void;
/**
* Get state for a package.
*/
getState(packageId: string): PackagePostProcessState | undefined;
/**
* Check if any processing is active.
*/
isActive(): boolean;
// Events:
// "progress" → { packageId, update: ExtractProgressUpdate }
// "package-done" → { packageId, success: boolean, errors: string[] }
// "archive-redownload" → { packageId, archiveName }
// "status" → { packageId, label: string }
}
```
Key rules:
- Max 3 extraction attempts per archive
- If ArchiveCorrupt + not yet redownloaded → emit "archive-redownload", set redownloaded=true
- If ArchiveCorrupt + already redownloaded → fail permanently
- If WrongPassword → try all passwords in list, then fail
- If ExtractorCrash → retry once, then fail
- Package "done" only when ALL archives are done or permanently failed
- Package "failed" if ANY archive failed permanently
- No infinite loops possible (hard cap on attempts)
---
### Task 6: Create scheduler.ts — Queue Management & Slot Allocation
**Files:**
- Create: `src/main/download/scheduler.ts`
**Step 1: Define scheduler types**
```typescript
export interface SchedulerConfig {
maxParallel: number;
stallTimeoutMs: number;
globalStallWatchdogMs: number;
allDebridStaggerMs: number;
}
export interface SlotRequest {
itemId: string;
packageId: string;
}
export interface ProviderCooldown {
provider: string;
cooldownUntil: number;
failureCount: number;
}
```
**Step 2: Implement Scheduler class**
```typescript
export class Scheduler extends EventEmitter {
private generation: number = 0;
private running: boolean = false;
private paused: boolean = false;
// Active download tracking
private activeSlots: Map<string, { packageId: string; heartbeatAt: number; bytesAtHeartbeat: number }> = new Map();
// Provider cooldowns (circuit breaker)
private providerCooldowns: Map<string, ProviderCooldown> = new Map();
// Retry delays per item
private retryDelays: Map<string, number> = new Map(); // itemId → retryAfterEpochMs
constructor(config: SchedulerConfig);
/**
* Start the scheduler loop.
*/
async start(findNextItem: () => SlotRequest | null, startItem: (slot: SlotRequest) => void): Promise<void>;
/**
* Stop the scheduler (bumps generation to kill old loop).
*/
stop(): void;
/**
* Pause/unpause slot allocation.
*/
setPaused(paused: boolean): void;
/**
* Register an item as actively downloading.
*/
claimSlot(itemId: string, packageId: string): void;
/**
* Release a slot (download finished/failed/cancelled).
*/
releaseSlot(itemId: string): void;
/**
* Record heartbeat from active download.
*/
heartbeat(itemId: string, downloadedBytes: number): void;
/**
* Schedule a retry delay for an item.
*/
scheduleRetry(itemId: string, delayMs: number): void;
/**
* Check if an item is delayed (retry pending).
*/
isDelayed(itemId: string): boolean;
/**
* Apply provider cooldown.
*/
applyProviderCooldown(provider: string, cooldownMs: number): void;
/**
* Check if provider is in cooldown.
*/
getProviderCooldownRemaining(provider: string): number;
/**
* Get number of active slots.
*/
get activeCount(): number;
/**
* Check if scheduler has capacity for more downloads.
*/
hasCapacity(): boolean;
// Events:
// "stall-detected" → { itemId } (per-item stall from heartbeat monitoring)
// "global-stall" → { itemIds: string[] } (all downloads stalled)
// "run-complete" → {} (no more items to process)
}
```
Key logic:
- Scheduler loop runs at 120ms intervals, checking for available slots
- Global stall watchdog: if zero bytes across ALL downloads for globalStallWatchdogMs → emit "global-stall"
- Per-item heartbeat monitoring: if no heartbeat for stallTimeoutMs → emit "stall-detected"
- Provider cooldowns: checked in findNextItem filter
- Retry delays: checked in findNextItem filter
- Generation guard: stop() bumps generation, old loop exits
---
### Task 7: Create download-manager.ts — Orchestrator (Drop-in Replacement)
**Files:**
- Create: `src/main/download/download-manager.ts`
- Create: `src/main/download/index.ts` (re-export)
**Step 1: Create the DownloadManager class with same constructor signature**
Same constructor as current: `(settings, session, storagePaths, options?)`. Must extend EventEmitter. Must emit "state" events with UiSnapshot.
**Step 2: Implement queue management methods**
Port directly from old code (these are mostly unchanged):
- `addPackages()`, `clearAll()`, `exportQueue()`, `importQueue()`
- `renamePackage()`, `reorderPackages()`, `togglePackage()`, `cancelPackage()`, `resetPackage()`
- `setPackagePriority()`, `removeItem()`, `skipItems()`, `resetItems()`
- `getSnapshot()`, `getStats()`, `getSessionStats()`
**Step 3: Implement start/stop/pause using new Scheduler**
- `start()`: create Scheduler, RetryManager, and begin processing
- `stop()`: stop Scheduler, abort all active pipelines, persist retry state
- `togglePause()`: delegate to Scheduler.setPaused()
**Step 4: Wire up Pipeline execution**
When Scheduler requests a new download:
1. Create AbortController for the item
2. Call `runPipeline()` with item context
3. On success → mark completed, release slot, trigger post-processing if package done
4. On DownloadError → call `RetryManager.evaluate()`
- If shouldRetry: execute actions (reset file, switch provider, etc.), schedule retry delay
- If !shouldRetry: mark failed
**Step 5: Wire up PostProcessor**
- Listen for "package-done" → update package status, trigger cleanup, add history entry
- Listen for "archive-redownload" → re-queue download item
- Listen for "progress" → forward extraction progress to UI
**Step 6: Wire up Scheduler events**
- "stall-detected" → abort the stalled download, retry via RetryManager
- "global-stall" → abort all, re-queue all active items
- "run-complete" → finalize session, create summary
**Step 7: Implement persistence**
- Same debounced persistSoon() / persistNow() pattern
- RetryManager states persisted alongside session
- PostProcessor states persisted alongside session
**Step 8: Implement speed/ETA calculation**
Port from old code: moving window speed, per-package speed, ETA calculation.
**Step 9: Implement reconnect handling**
Port 429/503 reconnect logic using new error types (RateLimited → reconnect wait).
**Step 10: Create index.ts re-export**
```typescript
// src/main/download/index.ts
export { DownloadManager } from "./download-manager";
```
---
### Task 8: Integration — Switch Import & Test
**Files:**
- Modify: `src/main/app-controller.ts` — change import path
- Keep: `src/main/download-manager.ts` — old file stays as reference
**Step 1: Update import in app-controller.ts**
Change from:
```typescript
import { DownloadManager } from "./download-manager";
```
To:
```typescript
import { DownloadManager } from "./download/download-manager";
```
**Step 2: Build and verify**
```bash
npm run build
```
Fix any TypeScript compilation errors.
**Step 3: Run existing tests**
```bash
npx vitest run --reporter=verbose tests/utils.test.ts tests/storage.test.ts tests/integrity.test.ts tests/cleanup.test.ts tests/extractor.test.ts tests/debrid.test.ts tests/update.test.ts tests/auto-rename.test.ts
```
All should still pass since we didn't change the external modules.
---
### Task 9: Write Unit Tests for New Modules
**Files:**
- Create: `tests/error-classifier.test.ts`
- Create: `tests/retry-manager.test.ts`
**Step 1: Test error classification**
Test that every known error string maps to the correct DownloadErrorKind:
- "socket hang up" → NetworkReset
- "ECONNRESET" → NetworkReset
- "file not found" → LinkDead
- "too many active" → ProviderBusy
- HTTP 416 → RangeNotSatisfied
- HTTP 429 → RateLimited
- etc.
**Step 2: Test retry decisions**
- NetworkReset: retries 3 times with 300ms delay, then fails
- LinkDead: fails immediately (maxRetries = 0)
- ProviderBusy: retries with exponential backoff, switches provider
- After 15 total failures: shelve (90s delay, halved counters)
- User retryLimit override works
**Step 3: Test shelving logic**
- Accumulate 15 failures across different kinds
- Verify counters are halved
- Verify 90s delay applied
- Verify provider switch requested
---
### Task 10: Cleanup & Finalize
**Step 1: Verify full build**
```bash
npm run build
```
**Step 2: Run all fast tests**
```bash
npx vitest run --reporter=verbose
```
**Step 3: Remove old download-manager.ts** (only after confirming stability)
**Step 4: Commit**
```bash
git add src/main/download/ tests/error-classifier.test.ts tests/retry-manager.test.ts
git commit -m "feat: replace monolithic download-manager with modular download system v2
- Split 9500-line download-manager.ts into 7 focused modules
- Add typed error classification (DownloadErrorKind enum)
- Add declarative retry policies per error type
- Add validated resume (pre-check file integrity before Range header)
- Add extraction state machine (max 3 retries, no infinite loops)
- Same IPC interface — drop-in replacement"
```