beta-real-debrid-downloader/docs/plans/2026-03-08-download-system-v2-plan.md
Sucukdeluxe efa0909e11 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>
2026-03-08 18:14:17 +01:00

738 lines
26 KiB
Markdown

# 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"
```