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>
26 KiB
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
// 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
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
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
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:
- 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
- HTTP request: send Range header if resuming, detect 206/200/416
- 416 handling: check Content-Range for total, if file complete → accept, else throw RangeNotSatisfied
- 200 with Range sent: throw RangeIgnored
- Streaming loop: buffered read with stall timeout, write with NTFS alignment, backpressure handling
- Heartbeat: emit heartbeat every 1s regardless of transfer state
- Speed limiting: token bucket or simple delay between chunks
- Content-Disposition: parse filename, notify via callback
- 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
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
export async function runPipeline(ctx: PipelineContext): Promise<PipelineResult>
Steps within the pipeline:
- 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"
- Stream: Call streamToFile() with resolved direct URL
- On error → classifyFetchError() or classifyHttpStatus() → throw DownloadError
- On progress → forward to ctx.onProgress
- Integrity check (if enabled): Call validateFileAgainstManifest()
- On mismatch → throw DownloadError(FileCorrupt)
- 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
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
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
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
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 processingstop(): stop Scheduler, abort all active pipelines, persist retry statetogglePause(): delegate to Scheduler.setPaused()
Step 4: Wire up Pipeline execution
When Scheduler requests a new download:
- Create AbortController for the item
- Call
runPipeline()with item context - On success → mark completed, release slot, trigger post-processing if package done
- 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
// 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:
import { DownloadManager } from "./download-manager";
To:
import { DownloadManager } from "./download/download-manager";
Step 2: Build and verify
npm run build
Fix any TypeScript compilation errors.
Step 3: Run existing tests
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
npm run build
Step 2: Run all fast tests
npx vitest run --reporter=verbose
Step 3: Remove old download-manager.ts (only after confirming stability)
Step 4: Commit
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"