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:
co-authored by
Claude Opus 4.6
parent
63b412a43f
commit
efa0909e11
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user