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

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:

  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

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:

  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

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 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

// 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"