Compare commits
10 Commits
935f05e214
...
efa0909e11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa0909e11 | ||
|
|
63b412a43f | ||
|
|
2123a48bea | ||
|
|
4a27fd72c7 | ||
|
|
2bd7a187f8 | ||
|
|
7c2c8def51 | ||
|
|
38c9058beb | ||
|
|
842933e748 | ||
|
|
ef7905eeb4 | ||
|
|
6c1db14e24 |
259
docs/plans/2026-03-08-download-system-v2-design.md
Normal file
259
docs/plans/2026-03-08-download-system-v2-design.md
Normal file
@ -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
|
||||||
737
docs/plans/2026-03-08-download-system-v2-plan.md
Normal file
737
docs/plans/2026-03-08-download-system-v2-plan.md
Normal file
@ -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"
|
||||||
|
```
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.45",
|
"version": "1.7.50",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import {
|
|||||||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
||||||
import { importDlcContainers } from "./container";
|
import { importDlcContainers } from "./container";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
import { DownloadManager } from "./download-manager";
|
import { DownloadManager } from "./download/download-manager";
|
||||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||||
import { parseCollectorInput } from "./link-parser";
|
import { parseCollectorInput } from "./link-parser";
|
||||||
import { configureLogger, getLogFilePath, logger } from "./logger";
|
import { configureLogger, getLogFilePath, logger } from "./logger";
|
||||||
@ -179,14 +179,19 @@ export class AppController {
|
|||||||
return previousSettings;
|
return previousSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the live totalDownloadedAllTime from the download manager
|
// Preserve the live all-time counters from the download manager
|
||||||
const liveSettings = this.manager.getSettings();
|
const liveSettings = this.manager.getSettings();
|
||||||
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||||
|
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||||
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
||||||
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
||||||
|
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
||||||
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
||||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
||||||
);
|
);
|
||||||
|
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||||
|
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
||||||
|
);
|
||||||
this.settings = nextSettings;
|
this.settings = nextSettings;
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings);
|
this.manager.setSettings(this.settings);
|
||||||
@ -379,6 +384,15 @@ export class AppController {
|
|||||||
return this.manager.getSessionStats();
|
return this.manager.getSessionStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resetSessionStats(): void {
|
||||||
|
this.manager.resetSessionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetDownloadStats(): void {
|
||||||
|
this.manager.resetDownloadStats();
|
||||||
|
this.settings = this.manager.getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
const settings = { ...this.settings };
|
const settings = { ...this.settings };
|
||||||
const session = this.manager.getSession();
|
const session = this.manager.getSession();
|
||||||
|
|||||||
@ -102,6 +102,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
|
totalCompletedFilesAllTime: 0,
|
||||||
bandwidthSchedules: [],
|
bandwidthSchedules: [],
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
extractCpuPriority: "high",
|
extractCpuPriority: "high",
|
||||||
@ -110,8 +111,10 @@ export function defaultSettings(): AppSettings {
|
|||||||
hosterRouting: {},
|
hosterRouting: {},
|
||||||
providerDailyLimitBytes: {},
|
providerDailyLimitBytes: {},
|
||||||
providerDailyUsageBytes: {},
|
providerDailyUsageBytes: {},
|
||||||
|
providerTotalUsageBytes: {},
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
debridLinkApiKeyDailyLimitBytes: {},
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
debridLinkApiKeyDailyUsageBytes: {},
|
||||||
|
debridLinkApiKeyTotalUsageBytes: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
|
|||||||
@ -76,8 +76,10 @@ function cloneSettings(settings: AppSettings): AppSettings {
|
|||||||
debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])],
|
debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])],
|
||||||
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
|
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
|
||||||
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
|
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
|
||||||
|
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
|
||||||
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
|
||||||
|
debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import http from "node:http";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { logger, getLogFilePath } from "./logger";
|
import { logger, getLogFilePath } from "./logger";
|
||||||
import type { DownloadManager } from "./download-manager";
|
import type { DownloadManager } from "./download/download-manager";
|
||||||
|
|
||||||
const DEFAULT_PORT = 9868;
|
const DEFAULT_PORT = 9868;
|
||||||
const MAX_LOG_LINES = 10000;
|
const MAX_LOG_LINES = 10000;
|
||||||
|
|||||||
@ -21,7 +21,14 @@ import {
|
|||||||
UiSnapshot
|
UiSnapshot
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { addDebridLinkApiKeyDailyUsageBytes, addProviderDailyUsageBytes, getProviderUsageDayKey, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
|
import {
|
||||||
|
addDebridLinkApiKeyDailyUsageBytes,
|
||||||
|
addDebridLinkApiKeyTotalUsageBytes,
|
||||||
|
addProviderDailyUsageBytes,
|
||||||
|
addProviderTotalUsageBytes,
|
||||||
|
getProviderUsageDayKey,
|
||||||
|
isProviderDailyLimitReached
|
||||||
|
} from "../shared/provider-daily-limits";
|
||||||
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS } from "./constants";
|
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS } from "./constants";
|
||||||
|
|
||||||
// Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions
|
// Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions
|
||||||
@ -57,6 +64,7 @@ type ActiveTask = {
|
|||||||
resumable: boolean;
|
resumable: boolean;
|
||||||
nonResumableCounted: boolean;
|
nonResumableCounted: boolean;
|
||||||
freshRetryUsed?: boolean;
|
freshRetryUsed?: boolean;
|
||||||
|
resumeHardResetUsed?: boolean;
|
||||||
stallRetries?: number;
|
stallRetries?: number;
|
||||||
genericErrorRetries?: number;
|
genericErrorRetries?: number;
|
||||||
unrestrictRetries?: number;
|
unrestrictRetries?: number;
|
||||||
@ -289,8 +297,10 @@ function cloneSettings(settings: AppSettings): AppSettings {
|
|||||||
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
|
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
|
||||||
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
|
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
|
||||||
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
|
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
|
||||||
|
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
|
||||||
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
|
||||||
|
debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,6 +395,11 @@ function isFetchFailure(errorText: string): boolean {
|
|||||||
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isResumeHardResetReason(errorText: string): boolean {
|
||||||
|
const text = String(errorText || "");
|
||||||
|
return text.startsWith("resume_download_underflow:");
|
||||||
|
}
|
||||||
|
|
||||||
function isPermanentLinkError(errorText: string): boolean {
|
function isPermanentLinkError(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("permanent ungültig")
|
return text.includes("permanent ungültig")
|
||||||
@ -573,7 +588,7 @@ const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i;
|
|||||||
const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i;
|
const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i;
|
||||||
const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i;
|
const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i;
|
||||||
const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i;
|
const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i;
|
||||||
const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})(?=$|[._\-\s])/;
|
const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})([a-z])?(?=$|[._\-\s])/i;
|
||||||
const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i;
|
const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i;
|
||||||
const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i;
|
const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i;
|
||||||
const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i;
|
const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i;
|
||||||
@ -705,6 +720,7 @@ function extractCompactEpisodeToken(fileName: string, seasonHint: number | null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const code = match[1];
|
const code = match[1];
|
||||||
|
const episodeSuffix = String(match[2] || "").toLowerCase();
|
||||||
if (code === "4320" || code === "2160" || code === "1440" || code === "1080"
|
if (code === "4320" || code === "2160" || code === "1440" || code === "1080"
|
||||||
|| code === "0720" || code === "720" || code === "0576" || code === "576"
|
|| code === "0720" || code === "720" || code === "0576" || code === "576"
|
||||||
|| code === "0540" || code === "540" || code === "0480" || code === "480"
|
|| code === "0540" || code === "540" || code === "0480" || code === "480"
|
||||||
@ -712,11 +728,18 @@ function extractCompactEpisodeToken(fileName: string, seasonHint: number | null)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const letterOffset = episodeSuffix
|
||||||
|
? episodeSuffix.charCodeAt(0) - "a".charCodeAt(0)
|
||||||
|
: 0;
|
||||||
const toToken = (season: number, episode: number): string | null => {
|
const toToken = (season: number, episode: number): string | null => {
|
||||||
if (!Number.isFinite(season) || !Number.isFinite(episode) || season < 0 || season > 99 || episode <= 0 || episode > 999) {
|
const effectiveEpisode = episode + Math.max(0, letterOffset);
|
||||||
|
if (episodeSuffix && (letterOffset < 0 || letterOffset > 25)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`;
|
if (!Number.isFinite(season) || !Number.isFinite(effectiveEpisode) || season < 0 || season > 99 || effectiveEpisode <= 0 || effectiveEpisode > 999) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `S${String(season).padStart(2, "0")}E${String(effectiveEpisode).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (seasonHint !== null && Number.isFinite(seasonHint) && seasonHint >= 0 && seasonHint <= 99) {
|
if (seasonHint !== null && Number.isFinite(seasonHint) && seasonHint >= 0 && seasonHint <= 99) {
|
||||||
@ -1087,6 +1110,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private speedBytesLastWindow = 0;
|
private speedBytesLastWindow = 0;
|
||||||
|
|
||||||
private sessionDownloadedBytes = 0;
|
private sessionDownloadedBytes = 0;
|
||||||
|
private sessionCompletedFiles = 0;
|
||||||
|
|
||||||
private statsCache: DownloadStats | null = null;
|
private statsCache: DownloadStats | null = null;
|
||||||
|
|
||||||
@ -1152,6 +1176,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private retryStateByItem = new Map<string, {
|
private retryStateByItem = new Map<string, {
|
||||||
freshRetryUsed: boolean;
|
freshRetryUsed: boolean;
|
||||||
|
resumeHardResetUsed: boolean;
|
||||||
stallRetries: number;
|
stallRetries: number;
|
||||||
genericErrorRetries: number;
|
genericErrorRetries: number;
|
||||||
unrestrictRetries: number;
|
unrestrictRetries: number;
|
||||||
@ -1254,6 +1279,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
public setSettings(next: AppSettings): void {
|
public setSettings(next: AppSettings): void {
|
||||||
const previous = this.settings;
|
const previous = this.settings;
|
||||||
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||||
|
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||||
this.settings = next;
|
this.settings = next;
|
||||||
this.ensureProviderDailyUsageFresh(nowMs());
|
this.ensureProviderDailyUsageFresh(nowMs());
|
||||||
this.debridService.setSettings(next);
|
this.debridService.setSettings(next);
|
||||||
@ -1397,17 +1423,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
this.resetSessionTotalsIfQueueEmpty();
|
this.resetSessionTotalsIfQueueEmpty();
|
||||||
|
|
||||||
let totalFiles = 0;
|
|
||||||
for (const item of Object.values(this.session.items)) {
|
|
||||||
if (item.status === "completed") {
|
|
||||||
totalFiles += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalDownloaded: this.sessionDownloadedBytes,
|
totalDownloaded: this.sessionDownloadedBytes,
|
||||||
totalDownloadedAllTime: this.settings.totalDownloadedAllTime,
|
totalDownloadedAllTime: this.settings.totalDownloadedAllTime,
|
||||||
totalFiles,
|
totalFilesSession: this.sessionCompletedFiles,
|
||||||
|
totalFilesAllTime: this.settings.totalCompletedFilesAllTime,
|
||||||
totalPackages: this.session.packageOrder.length,
|
totalPackages: this.session.packageOrder.length,
|
||||||
sessionStartedAt: this.session.runStartedAt
|
sessionStartedAt: this.session.runStartedAt
|
||||||
};
|
};
|
||||||
@ -1416,6 +1436,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private invalidateStatsCache(): void {
|
||||||
|
this.statsCache = null;
|
||||||
|
this.statsCacheAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private resetSessionTotalsIfQueueEmpty(): void {
|
private resetSessionTotalsIfQueueEmpty(): void {
|
||||||
if (this.itemCount > 0 || this.session.packageOrder.length > 0) {
|
if (this.itemCount > 0 || this.session.packageOrder.length > 0) {
|
||||||
return;
|
return;
|
||||||
@ -1423,18 +1448,35 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) {
|
if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetSessionStats(): void {
|
||||||
this.session.totalDownloadedBytes = 0;
|
this.session.totalDownloadedBytes = 0;
|
||||||
this.sessionDownloadedBytes = 0;
|
this.sessionDownloadedBytes = 0;
|
||||||
this.session.runStartedAt = 0;
|
this.sessionCompletedFiles = 0;
|
||||||
|
this.session.runStartedAt = this.session.running ? nowMs() : 0;
|
||||||
|
this.session.summaryText = "";
|
||||||
this.lastGlobalProgressBytes = 0;
|
this.lastGlobalProgressBytes = 0;
|
||||||
this.lastGlobalProgressAt = nowMs();
|
this.lastGlobalProgressAt = nowMs();
|
||||||
this.speedEvents = [];
|
this.speedEvents = [];
|
||||||
this.speedEventsHead = 0;
|
this.speedEventsHead = 0;
|
||||||
this.speedBytesLastWindow = 0;
|
this.speedBytesLastWindow = 0;
|
||||||
this.speedBytesPerPackage.clear();
|
this.speedBytesPerPackage.clear();
|
||||||
this.statsCache = null;
|
this.summary = null;
|
||||||
this.statsCacheAt = 0;
|
this.invalidateStatsCache();
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetDownloadStats(): void {
|
||||||
|
this.settings.totalDownloadedAllTime = 0;
|
||||||
|
this.settings.totalCompletedFilesAllTime = 0;
|
||||||
|
this.settings.providerTotalUsageBytes = {};
|
||||||
|
this.settings.debridLinkApiKeyTotalUsageBytes = {};
|
||||||
|
this.lastSettingsPersistAt = nowMs();
|
||||||
|
saveSettings(this.storagePaths, this.settings);
|
||||||
|
this.invalidateStatsCache();
|
||||||
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renamePackage(packageId: string, newName: string): void {
|
public renamePackage(packageId: string, newName: string): void {
|
||||||
@ -3335,6 +3377,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.runStartedAt = nowMs();
|
this.session.runStartedAt = nowMs();
|
||||||
this.session.totalDownloadedBytes = 0;
|
this.session.totalDownloadedBytes = 0;
|
||||||
|
this.sessionCompletedFiles = 0;
|
||||||
this.session.summaryText = "";
|
this.session.summaryText = "";
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
@ -3442,6 +3485,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.runStartedAt = nowMs();
|
this.session.runStartedAt = nowMs();
|
||||||
this.session.totalDownloadedBytes = 0;
|
this.session.totalDownloadedBytes = 0;
|
||||||
|
this.sessionCompletedFiles = 0;
|
||||||
this.session.summaryText = "";
|
this.session.summaryText = "";
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
@ -3552,6 +3596,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.runStartedAt = 0;
|
this.session.runStartedAt = 0;
|
||||||
this.session.totalDownloadedBytes = 0;
|
this.session.totalDownloadedBytes = 0;
|
||||||
|
this.sessionCompletedFiles = 0;
|
||||||
this.session.summaryText = "";
|
this.session.summaryText = "";
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
@ -3583,6 +3628,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Only runStartedAt resets (for ETA/speed calculations relative to current run).
|
// Only runStartedAt resets (for ETA/speed calculations relative to current run).
|
||||||
this.session.runStartedAt = nowMs();
|
this.session.runStartedAt = nowMs();
|
||||||
this.session.totalDownloadedBytes = 0;
|
this.session.totalDownloadedBytes = 0;
|
||||||
|
this.sessionCompletedFiles = 0;
|
||||||
this.session.summaryText = "";
|
this.session.summaryText = "";
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
@ -4146,16 +4192,20 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!this.runItemIds.has(itemId)) {
|
if (!this.runItemIds.has(itemId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const previous = this.runOutcomes.get(itemId);
|
||||||
this.runOutcomes.set(itemId, status);
|
this.runOutcomes.set(itemId, status);
|
||||||
|
if (status === "completed" && previous !== "completed") {
|
||||||
|
this.sessionCompletedFiles += 1;
|
||||||
|
this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1;
|
||||||
|
this.invalidateStatsCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private dropItemContribution(itemId: string): void {
|
private dropItemContribution(itemId: string): void {
|
||||||
const contributed = this.itemContributedBytes.get(itemId) || 0;
|
|
||||||
if (contributed > 0) {
|
|
||||||
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - contributed);
|
|
||||||
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - contributed);
|
|
||||||
}
|
|
||||||
this.itemContributedBytes.delete(itemId);
|
this.itemContributedBytes.delete(itemId);
|
||||||
|
// Session totals are cumulative for the current app run and must not shrink
|
||||||
|
// just because an item/package is removed from the queue after completion.
|
||||||
|
this.invalidateStatsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string {
|
private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string {
|
||||||
@ -4289,6 +4339,103 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private tryFinalizeItemFromDisk(
|
||||||
|
pkg: PackageEntry,
|
||||||
|
item: DownloadItem,
|
||||||
|
source: string,
|
||||||
|
errorText = ""
|
||||||
|
): boolean {
|
||||||
|
const diskState = inspectPackageItemDiskState(pkg, item);
|
||||||
|
const normalizedError = compactErrorText(errorText).replace(/^Error:\s*/i, "");
|
||||||
|
const knownShortfall = item.totalBytes != null && item.totalBytes > 0
|
||||||
|
? Math.max(0, item.totalBytes - diskState.size)
|
||||||
|
: 0;
|
||||||
|
const underflowIndicated = normalizedError.includes("download_underflow")
|
||||||
|
|| normalizedError.includes("resume_download_underflow");
|
||||||
|
const archiveLikeTarget = String(item.fileName || diskState.diskPath || "").toLowerCase();
|
||||||
|
const archiveLike = /(?:\.part\d+\.rar|\.rar|\.r\d{2,3}|\.zip(?:\.\d+)?|\.7z(?:\.\d+)?|\.(?:tar(?:\.(?:gz|bz2|xz))?|tgz|tbz2|txz)|\.\d{3})$/i.test(archiveLikeTarget);
|
||||||
|
const looksComplete = diskState.exists
|
||||||
|
&& diskState.fullOnDisk
|
||||||
|
&& (
|
||||||
|
diskState.reason === "ok"
|
||||||
|
|| item.progressPercent >= 100
|
||||||
|
|| item.downloadedBytes >= diskState.minBytes
|
||||||
|
|| (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= item.totalBytes - ALLOCATION_UNIT_SIZE)
|
||||||
|
);
|
||||||
|
if (!looksComplete || (knownShortfall > 0 && (underflowIndicated || archiveLike))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${source}: ${item.fileName || item.id} ist bereits vollstaendig auf Disk ` +
|
||||||
|
`(${humanSize(diskState.size)}, erwartet mind. ${humanSize(diskState.minBytes)})`
|
||||||
|
);
|
||||||
|
this.logPackageForItem(item, "INFO", `${source}: Datei bereits vollstaendig`, {
|
||||||
|
fileSize: diskState.size,
|
||||||
|
expectedMin: diskState.minBytes,
|
||||||
|
diskReason: diskState.reason,
|
||||||
|
error: errorText || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
item.status = "completed";
|
||||||
|
item.fullStatus = this.settings.autoExtract
|
||||||
|
? "Entpacken - Ausstehend"
|
||||||
|
: `Fertig (${humanSize(diskState.size)})`;
|
||||||
|
item.downloadedBytes = diskState.size;
|
||||||
|
if (!item.totalBytes || item.totalBytes < diskState.size) {
|
||||||
|
item.totalBytes = diskState.size;
|
||||||
|
}
|
||||||
|
item.progressPercent = 100;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
this.recordRunOutcome(item.id, "completed");
|
||||||
|
|
||||||
|
if (this.session.running) {
|
||||||
|
void this.runPackagePostProcessing(pkg.id).catch((err) => {
|
||||||
|
logger.warn(`runPackagePostProcessing Fehler (${source}): ${compactErrorText(err)}`);
|
||||||
|
}).finally(() => {
|
||||||
|
this.applyCompletedCleanupPolicy(pkg.id, item.id);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
this.retryStateByItem.delete(item.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private areAllPackageItemRefsFinished(pkg: PackageEntry): boolean {
|
||||||
|
return pkg.itemIds.every((itemId) => {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
return item != null && isFinishedStatus(item.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findFullExtractArchiveSet(pkg: PackageEntry, completedItems: DownloadItem[]): Promise<Set<string>> {
|
||||||
|
const relevant = new Set<string>();
|
||||||
|
if (!pkg.outputDir || completedItems.length === 0) {
|
||||||
|
return relevant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await findArchiveCandidates(pkg.outputDir);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems);
|
||||||
|
if (archiveItems.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hasPendingExtract = archiveItems.some((item) => !isExtractedLabel(item.fullStatus || ""));
|
||||||
|
if (!hasPendingExtract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
relevant.add(pathKey(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return relevant;
|
||||||
|
}
|
||||||
|
|
||||||
private clearHybridArchiveState(packageId: string, archiveKey?: string): void {
|
private clearHybridArchiveState(packageId: string, archiveKey?: string): void {
|
||||||
if (!archiveKey) {
|
if (!archiveKey) {
|
||||||
this.hybridExtractedPaths.delete(packageId);
|
this.hybridExtractedPaths.delete(packageId);
|
||||||
@ -4822,7 +4969,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const success = items.filter((item) => item.status === "completed").length;
|
const success = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
const allDone = success + failed + cancelled >= items.length;
|
const allDone = this.areAllPackageItemRefsFinished(pkg);
|
||||||
|
if (!allDone && success + failed + cancelled >= items.length) {
|
||||||
|
logger.warn(
|
||||||
|
`Post-Processing wartet trotz gefiltert fertiger Items: ` +
|
||||||
|
`pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` +
|
||||||
|
`success=${success}, failed=${failed}, cancelled=${cancelled}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Hybrid extraction recovery: not all items done, but some completed
|
// Hybrid extraction recovery: not all items done, but some completed
|
||||||
// with pending extraction status → re-label and trigger post-processing
|
// with pending extraction status → re-label and trigger post-processing
|
||||||
@ -4906,7 +5060,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const success = items.filter((item) => item.status === "completed").length;
|
const success = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
const allDone = success + failed + cancelled >= items.length;
|
const allDone = this.areAllPackageItemRefsFinished(pkg);
|
||||||
|
if (!allDone && success + failed + cancelled >= items.length) {
|
||||||
|
logger.warn(
|
||||||
|
`Post-Processing wartet trotz gefiltert fertiger Items: ` +
|
||||||
|
`pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` +
|
||||||
|
`success=${success}, failed=${failed}, cancelled=${cancelled}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Full extraction: all items done, no failures
|
// Full extraction: all items done, no failures
|
||||||
if (allDone && failed === 0 && success > 0) {
|
if (allDone && failed === 0 && success > 0) {
|
||||||
@ -5180,12 +5341,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
||||||
const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta);
|
const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta);
|
||||||
|
const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta);
|
||||||
this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay;
|
this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay;
|
||||||
this.settings.providerDailyUsageBytes = nextUsage.providerDailyUsageBytes;
|
this.settings.providerDailyUsageBytes = nextUsage.providerDailyUsageBytes;
|
||||||
|
this.settings.providerTotalUsageBytes = nextTotalUsage.providerTotalUsageBytes;
|
||||||
if (effectiveProvider === "debridlink" && providerAccountId) {
|
if (effectiveProvider === "debridlink" && providerAccountId) {
|
||||||
const nextKeyUsage = addDebridLinkApiKeyDailyUsageBytes(this.settings, providerAccountId, byteDelta);
|
const nextKeyUsage = addDebridLinkApiKeyDailyUsageBytes(this.settings, providerAccountId, byteDelta);
|
||||||
|
const nextKeyTotalUsage = addDebridLinkApiKeyTotalUsageBytes(this.settings, providerAccountId, byteDelta);
|
||||||
this.settings.providerDailyUsageDay = nextKeyUsage.providerDailyUsageDay;
|
this.settings.providerDailyUsageDay = nextKeyUsage.providerDailyUsageDay;
|
||||||
this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes;
|
this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes;
|
||||||
|
this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5492,6 +5657,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
retryState.unrestrictRetries = 0;
|
retryState.unrestrictRetries = 0;
|
||||||
retryState.genericErrorRetries = 0;
|
retryState.genericErrorRetries = 0;
|
||||||
retryState.freshRetryUsed = false;
|
retryState.freshRetryUsed = false;
|
||||||
|
retryState.resumeHardResetUsed = false;
|
||||||
logger.info(`Soft-Reset: Retry-Counter zurückgesetzt für ${item.fileName || itemId} (${Math.floor(staleMs / 60000)} min stale)`);
|
logger.info(`Soft-Reset: Retry-Counter zurückgesetzt für ${item.fileName || itemId} (${Math.floor(staleMs / 60000)} min stale)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5850,6 +6016,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
active.abortReason = "none";
|
active.abortReason = "none";
|
||||||
this.retryStateByItem.set(item.id, {
|
this.retryStateByItem.set(item.id, {
|
||||||
freshRetryUsed: Boolean(active.freshRetryUsed),
|
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||||
|
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
||||||
stallRetries: Number(active.stallRetries || 0),
|
stallRetries: Number(active.stallRetries || 0),
|
||||||
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
||||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||||
@ -5860,7 +6027,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
stallRetries: Number(active.stallRetries || 0),
|
stallRetries: Number(active.stallRetries || 0),
|
||||||
unrestrictRetries: Number(active.unrestrictRetries || 0),
|
unrestrictRetries: Number(active.unrestrictRetries || 0),
|
||||||
genericRetries: Number(active.genericErrorRetries || 0),
|
genericRetries: Number(active.genericErrorRetries || 0),
|
||||||
freshRetryUsed: Boolean(active.freshRetryUsed)
|
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||||
|
resumeHardResetUsed: Boolean(active.resumeHardResetUsed)
|
||||||
});
|
});
|
||||||
// Caller returns immediately after this; startItem().finally releases the active slot,
|
// Caller returns immediately after this; startItem().finally releases the active slot,
|
||||||
// so the retry backoff never blocks a worker.
|
// so the retry backoff never blocks a worker.
|
||||||
@ -5940,12 +6108,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
const retryState = this.retryStateByItem.get(item.id) || {
|
const retryState = this.retryStateByItem.get(item.id) || {
|
||||||
freshRetryUsed: false,
|
freshRetryUsed: false,
|
||||||
|
resumeHardResetUsed: false,
|
||||||
stallRetries: 0,
|
stallRetries: 0,
|
||||||
genericErrorRetries: 0,
|
genericErrorRetries: 0,
|
||||||
unrestrictRetries: 0
|
unrestrictRetries: 0
|
||||||
};
|
};
|
||||||
this.retryStateByItem.set(item.id, retryState);
|
this.retryStateByItem.set(item.id, retryState);
|
||||||
active.freshRetryUsed = retryState.freshRetryUsed;
|
active.freshRetryUsed = retryState.freshRetryUsed;
|
||||||
|
active.resumeHardResetUsed = retryState.resumeHardResetUsed;
|
||||||
active.stallRetries = retryState.stallRetries;
|
active.stallRetries = retryState.stallRetries;
|
||||||
active.genericErrorRetries = retryState.genericErrorRetries;
|
active.genericErrorRetries = retryState.genericErrorRetries;
|
||||||
active.unrestrictRetries = retryState.unrestrictRetries;
|
active.unrestrictRetries = retryState.unrestrictRetries;
|
||||||
@ -6345,48 +6515,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// even though the download finished successfully.
|
// even though the download finished successfully.
|
||||||
if (item.downloadedBytes > 0) {
|
if (item.downloadedBytes > 0) {
|
||||||
const targetFile = this.claimedTargetPathByItem.get(item.id) || "";
|
const targetFile = this.claimedTargetPathByItem.get(item.id) || "";
|
||||||
const expectedMin = itemExpectedMinBytes(item);
|
if (this.tryFinalizeItemFromDisk(pkg, item, "Stall-Recovery", stallErrorText)) {
|
||||||
let fileAlreadyComplete = false;
|
|
||||||
if (targetFile && expectedMin > 10240) {
|
|
||||||
try {
|
|
||||||
const stallStat = fs.statSync(targetFile);
|
|
||||||
if (stallStat.size >= expectedMin) {
|
|
||||||
fileAlreadyComplete = true;
|
|
||||||
logger.info(`Stall-Recovery: ${item.fileName} ist bereits vollständig auf Disk (${humanSize(stallStat.size)}, erwartet mind. ${humanSize(expectedMin)}), überspringe Retry`);
|
|
||||||
this.logPackageForItem(item, "INFO", "Stall-Recovery: Datei bereits vollständig", {
|
|
||||||
fileSize: stallStat.size,
|
|
||||||
expectedMin
|
|
||||||
});
|
|
||||||
item.status = "completed";
|
|
||||||
item.fullStatus = this.settings.autoExtract
|
|
||||||
? "Entpacken - Ausstehend"
|
|
||||||
: `Fertig (${humanSize(stallStat.size)})`;
|
|
||||||
item.downloadedBytes = stallStat.size;
|
|
||||||
if (item.totalBytes && item.totalBytes > 0) {
|
|
||||||
item.progressPercent = 100;
|
|
||||||
}
|
|
||||||
item.speedBps = 0;
|
|
||||||
item.updatedAt = nowMs();
|
|
||||||
pkg.updatedAt = nowMs();
|
|
||||||
this.recordRunOutcome(item.id, "completed");
|
|
||||||
if (this.session.running && !active.abortController.signal.aborted) {
|
|
||||||
void this.runPackagePostProcessing(pkg.id).catch((err) => {
|
|
||||||
logger.warn(`runPackagePostProcessing Fehler (stallRecovery): ${compactErrorText(err)}`);
|
|
||||||
}).finally(() => {
|
|
||||||
this.applyCompletedCleanupPolicy(pkg.id, item.id);
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
this.retryStateByItem.delete(item.id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* file doesn't exist or not accessible */ }
|
if (targetFile) {
|
||||||
}
|
|
||||||
// Reset partial download so next attempt uses a fresh link
|
|
||||||
if (!fileAlreadyComplete && targetFile) {
|
|
||||||
try { fs.rmSync(targetFile, { force: true }); } catch { /* ignore */ }
|
try { fs.rmSync(targetFile, { force: true }); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
this.releaseTargetPath(item.id);
|
this.releaseTargetPath(item.id);
|
||||||
@ -6420,15 +6552,43 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
} else {
|
} else {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
|
if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", {
|
this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", {
|
||||||
error: errorText,
|
error: errorText,
|
||||||
abortReason: reason || "none"
|
abortReason: reason || "none"
|
||||||
});
|
});
|
||||||
const directLinkRetryMatch = errorText.match(/^direct_link_retry_exhausted:(.+)$/);
|
const directLinkRetryMatch = errorText.match(/^(?:Error:\s*)?direct_link_retry_exhausted:(.+)$/);
|
||||||
|
if (directLinkRetryMatch) {
|
||||||
|
const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, "");
|
||||||
|
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
||||||
|
active.resumeHardResetUsed = true;
|
||||||
|
item.retries += 1;
|
||||||
|
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
||||||
|
if (claimedTargetPath) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(claimedTargetPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.releaseTargetPath(item.id);
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
|
item.lastError = exhaustedReason;
|
||||||
|
item.downloadedBytes = 0;
|
||||||
|
item.totalBytes = null;
|
||||||
|
item.progressPercent = 0;
|
||||||
|
this.queueRetry(item, active, 300, "Resume-Fehler erkannt, kompletter Neuversuch");
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) {
|
if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) {
|
||||||
active.genericErrorRetries += 1;
|
active.genericErrorRetries += 1;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText);
|
const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, "");
|
||||||
const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Direktlink erschöpft: item=${item.fileName || item.id}, ` +
|
`Direktlink erschöpft: item=${item.fileName || item.id}, ` +
|
||||||
@ -6438,7 +6598,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
refreshDelayMs,
|
refreshDelayMs,
|
||||||
`Direktlink erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`
|
exhaustedReason.startsWith("range_ignored_on_resume:")
|
||||||
|
? `Resume-Link erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`
|
||||||
|
: `Direktlink erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`
|
||||||
);
|
);
|
||||||
item.lastError = exhaustedReason;
|
item.lastError = exhaustedReason;
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
@ -6838,20 +7000,26 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const resumable = response.status === 206 || acceptRanges;
|
const resumable = response.status === 206 || acceptRanges;
|
||||||
active.resumable = resumable;
|
active.resumable = resumable;
|
||||||
|
|
||||||
// CRITICAL: If we sent Range header but server responded 200 (not 206),
|
|
||||||
// it's sending the full file. We MUST write in truncate mode, not append.
|
|
||||||
const serverIgnoredRange = existingBytes > 0 && response.status === 200;
|
|
||||||
if (serverIgnoredRange) {
|
|
||||||
logger.warn(`Server ignorierte Range-Header (HTTP 200 statt 206), starte von vorne: ${item.fileName}`);
|
|
||||||
logAttemptEvent("WARN", "Server ignorierte Range-Header", {
|
|
||||||
attempt,
|
|
||||||
existingBytes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawContentLength = Number(response.headers.get("content-length") || 0);
|
const rawContentLength = Number(response.headers.get("content-length") || 0);
|
||||||
const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0;
|
const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0;
|
||||||
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
||||||
|
const serverIgnoredRange = existingBytes > 0 && response.status === 200;
|
||||||
|
if (serverIgnoredRange) {
|
||||||
|
logger.warn(`Server ignorierte Range-Header (HTTP 200 statt 206), verwerfe Direktlink und behalte Teil-Datei: ${item.fileName}`);
|
||||||
|
logAttemptEvent("WARN", "Server ignorierte Range-Header beim Resume", {
|
||||||
|
attempt,
|
||||||
|
existingBytes,
|
||||||
|
contentLength,
|
||||||
|
directUrl
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await response.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(`range_ignored_on_resume:${existingBytes}/${contentLength || 0}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (knownTotal && knownTotal > 0) {
|
if (knownTotal && knownTotal > 0) {
|
||||||
item.totalBytes = knownTotal;
|
item.totalBytes = knownTotal;
|
||||||
} else if (totalFromRange) {
|
} else if (totalFromRange) {
|
||||||
@ -7431,11 +7599,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
|
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
||||||
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
|
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
|
||||||
attempt,
|
attempt,
|
||||||
error: lastError,
|
error: lastError,
|
||||||
targetPath: effectiveTargetPath
|
targetPath: effectiveTargetPath
|
||||||
});
|
});
|
||||||
|
if (normalizedLastError.startsWith("range_ignored_on_resume:")) {
|
||||||
|
throw new Error(`direct_link_retry_exhausted:${normalizedLastError}`);
|
||||||
|
}
|
||||||
if (attempt < maxAttempts) {
|
if (attempt < maxAttempts) {
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
item.fullStatus = `Downloadfehler, retry ${attempt}/${maxAttempts} (Direktlink)`;
|
item.fullStatus = `Downloadfehler, retry ${attempt}/${maxAttempts} (Direktlink)`;
|
||||||
@ -7444,9 +7616,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (maxAttemptsBySetting > maxAttempts) {
|
if (maxAttemptsBySetting > maxAttempts) {
|
||||||
throw new Error(`direct_link_retry_exhausted:${lastError || "Download fehlgeschlagen"}`);
|
const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:")
|
||||||
|
? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}`
|
||||||
|
: (normalizedLastError || lastError || "Download fehlgeschlagen");
|
||||||
|
throw new Error(`direct_link_retry_exhausted:${exhaustedError}`);
|
||||||
}
|
}
|
||||||
throw new Error(lastError || "Download fehlgeschlagen");
|
throw new Error(normalizedLastError || lastError || "Download fehlgeschlagen");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -8250,12 +8425,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
||||||
: "";
|
: "";
|
||||||
const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
|
const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
|
||||||
|
const isFinalizing = archivePct >= 99;
|
||||||
let label: string;
|
let label: string;
|
||||||
if (progress.passwordFound) {
|
if (progress.passwordFound) {
|
||||||
label = `Passwort gefunden · ${progress.archiveName}`;
|
label = `Passwort gefunden · ${progress.archiveName}`;
|
||||||
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
||||||
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
||||||
label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`;
|
label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`;
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
label = `Finalisieren${archiveLabel}${elapsed}`;
|
||||||
} else {
|
} else {
|
||||||
label = `Entpacken ${archivePct}%${archiveLabel}${elapsed}`;
|
label = `Entpacken ${archivePct}%${archiveLabel}${elapsed}`;
|
||||||
}
|
}
|
||||||
@ -8276,6 +8454,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
||||||
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
||||||
pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`;
|
pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`;
|
||||||
|
} else if (Number(progress.archivePercent ?? 0) >= 99) {
|
||||||
|
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
|
||||||
|
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
|
||||||
|
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
||||||
|
: "";
|
||||||
|
pkg.postProcessLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`;
|
||||||
} else {
|
} else {
|
||||||
pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`;
|
pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`;
|
||||||
}
|
}
|
||||||
@ -8488,7 +8672,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
recoveryMs
|
recoveryMs
|
||||||
});
|
});
|
||||||
|
|
||||||
const allDone = success + failed + cancelled >= items.length;
|
const allDone = this.areAllPackageItemRefsFinished(pkg);
|
||||||
|
if (!allDone && success + failed + cancelled >= items.length) {
|
||||||
|
logger.warn(
|
||||||
|
`Post-Processing wartet trotz gefiltert fertiger Items: ` +
|
||||||
|
`pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` +
|
||||||
|
`success=${success}, failed=${failed}, cancelled=${cancelled}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) {
|
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) {
|
||||||
pkg.postProcessLabel = "Entpacken vorbereiten...";
|
pkg.postProcessLabel = "Entpacken vorbereiten...";
|
||||||
@ -8605,6 +8796,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
|
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir: pkg.outputDir,
|
packageDir: pkg.outputDir,
|
||||||
targetDir: pkg.extractDir,
|
targetDir: pkg.extractDir,
|
||||||
@ -8615,6 +8807,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
passwordList: this.settings.archivePasswordList,
|
passwordList: this.settings.archivePasswordList,
|
||||||
signal: extractAbortController.signal,
|
signal: extractAbortController.signal,
|
||||||
packageId,
|
packageId,
|
||||||
|
onlyArchives: fullArchiveSet,
|
||||||
skipPostCleanup: true,
|
skipPostCleanup: true,
|
||||||
maxParallel: this.settings.maxParallelExtract || 2,
|
maxParallel: this.settings.maxParallelExtract || 2,
|
||||||
// All downloads finished — use NORMAL OS priority so extraction runs at
|
// All downloads finished — use NORMAL OS priority so extraction runs at
|
||||||
@ -8698,12 +8891,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
||||||
: "";
|
: "";
|
||||||
const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
|
const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0))));
|
||||||
|
const isFinalizing = archivePct >= 99;
|
||||||
let label: string;
|
let label: string;
|
||||||
if (progress.passwordFound) {
|
if (progress.passwordFound) {
|
||||||
label = `Passwort gefunden · ${progress.archiveName}`;
|
label = `Passwort gefunden · ${progress.archiveName}`;
|
||||||
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
||||||
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
||||||
label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`;
|
label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`;
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
label = `Finalisieren${archiveTag}${elapsed}`;
|
||||||
} else {
|
} else {
|
||||||
label = `Entpacken ${archivePct}%${archiveTag}${elapsed}`;
|
label = `Entpacken ${archivePct}%${archiveTag}${elapsed}`;
|
||||||
}
|
}
|
||||||
@ -8729,6 +8925,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
|
||||||
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
|
||||||
overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`;
|
overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`;
|
||||||
|
} else if (Number(progress.archivePercent ?? 0) >= 99) {
|
||||||
|
overallLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`;
|
||||||
} else {
|
} else {
|
||||||
overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
|
overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
|
||||||
}
|
}
|
||||||
|
|||||||
1603
src/main/download/download-manager.ts
Normal file
1603
src/main/download/download-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
508
src/main/download/error-classifier.ts
Normal file
508
src/main/download/error-classifier.ts
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
/**
|
||||||
|
* error-classifier.ts — Typed error system for download pipeline.
|
||||||
|
*
|
||||||
|
* Every error gets classified ONCE at the point of origin into a
|
||||||
|
* DownloadErrorKind. No post-hoc string matching needed downstream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error Kinds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export enum DownloadErrorKind {
|
||||||
|
// Network
|
||||||
|
NetworkReset = "network_reset",
|
||||||
|
Timeout = "timeout",
|
||||||
|
DnsFailure = "dns_failure",
|
||||||
|
ConnectTimeout = "connect_timeout",
|
||||||
|
|
||||||
|
// 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 / Resume
|
||||||
|
FileCorrupt = "file_corrupt",
|
||||||
|
FileTruncated = "file_truncated",
|
||||||
|
ResumeUnderflow = "resume_underflow",
|
||||||
|
|
||||||
|
// Extraction
|
||||||
|
WrongPassword = "wrong_password",
|
||||||
|
ArchiveCorrupt = "archive_corrupt",
|
||||||
|
ExtractorCrash = "extractor_crash",
|
||||||
|
|
||||||
|
// Write / Drain
|
||||||
|
WriteDrainTimeout = "write_drain_timeout",
|
||||||
|
|
||||||
|
// Catchall
|
||||||
|
Unknown = "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permanent kinds — retrying is pointless
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PERMANENT_KINDS = new Set<DownloadErrorKind>([
|
||||||
|
DownloadErrorKind.LinkDead,
|
||||||
|
DownloadErrorKind.DiskFull,
|
||||||
|
DownloadErrorKind.PermissionDenied,
|
||||||
|
DownloadErrorKind.WrongPassword,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isPermanentKind(kind: DownloadErrorKind): boolean {
|
||||||
|
return PERMANENT_KINDS.has(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DownloadError class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class DownloadError extends Error {
|
||||||
|
readonly kind: DownloadErrorKind;
|
||||||
|
readonly retryable: boolean;
|
||||||
|
readonly permanent: boolean;
|
||||||
|
readonly httpStatus?: number;
|
||||||
|
readonly originalError?: Error;
|
||||||
|
/** Extra context (e.g. existing bytes, expected total). */
|
||||||
|
readonly context?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
kind: DownloadErrorKind,
|
||||||
|
message: string,
|
||||||
|
opts?: {
|
||||||
|
httpStatus?: number;
|
||||||
|
originalError?: Error;
|
||||||
|
retryable?: boolean;
|
||||||
|
permanent?: boolean;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
this.context = opts?.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact single-line representation for logging. */
|
||||||
|
toLogString(): string {
|
||||||
|
const parts = [`[${this.kind}]`, this.message];
|
||||||
|
if (this.httpStatus) parts.push(`(HTTP ${this.httpStatus})`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Classifier: raw fetch / network errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function classifyFetchError(error: unknown): DownloadError {
|
||||||
|
const text = errorText(error);
|
||||||
|
const lc = text.toLowerCase();
|
||||||
|
|
||||||
|
// Abort is not an error to classify — re-throw as-is
|
||||||
|
if (lc.includes("aborted:") || lc.includes("abort")) {
|
||||||
|
// Preserve abort errors unchanged so callers can check abortReason
|
||||||
|
throw error instanceof Error ? error : new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection timeout
|
||||||
|
if (lc.includes("connect_timeout") || lc.includes("etimedout") || lc.includes("connection timed out")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.ConnectTimeout, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS
|
||||||
|
if (lc.includes("enotfound") || lc.includes("getaddrinfo") || lc.includes("dns")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.DnsFailure, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network reset
|
||||||
|
if (
|
||||||
|
lc.includes("fetch failed") ||
|
||||||
|
lc.includes("socket hang up") ||
|
||||||
|
lc.includes("econnreset") ||
|
||||||
|
lc.includes("econnrefused") ||
|
||||||
|
lc.includes("epipe") ||
|
||||||
|
lc.includes("network error") ||
|
||||||
|
lc.includes("econnaborted") ||
|
||||||
|
lc.includes("socket closed") ||
|
||||||
|
lc.includes("connection reset")
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.NetworkReset, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stall / read timeout
|
||||||
|
if (lc.includes("stall_timeout") || lc.includes("read timeout")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.Timeout, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write drain timeout
|
||||||
|
if (lc.includes("write_drain_timeout")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.WriteDrainTimeout, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk full
|
||||||
|
if (lc.includes("enospc") || lc.includes("no space left")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.DiskFull, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
permanent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission denied
|
||||||
|
if (lc.includes("eacces") || lc.includes("eperm") || lc.includes("permission denied")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.PermissionDenied, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
permanent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// File locked (Windows)
|
||||||
|
if (lc.includes("ebusy") || lc.includes("file is locked") || lc.includes("being used by another process")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.FileLocked, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume underflow
|
||||||
|
if (lc.startsWith("resume_download_underflow:")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.ResumeUnderflow, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range ignored on resume
|
||||||
|
if (lc.startsWith("range_ignored_on_resume:")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.RangeIgnored, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadError(DownloadErrorKind.Unknown, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Classifier: HTTP response status codes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface HttpClassifyContext {
|
||||||
|
status: number;
|
||||||
|
statusText?: string;
|
||||||
|
responseText?: string;
|
||||||
|
existingBytes?: number;
|
||||||
|
rangeHeaderSent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyHttpStatus(ctx: HttpClassifyContext): DownloadError {
|
||||||
|
const { status, statusText, responseText } = ctx;
|
||||||
|
const body = responseText || statusText || "";
|
||||||
|
const msg = `HTTP ${status}${body ? ": " + compactText(body) : ""}`;
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case status === 416:
|
||||||
|
return new DownloadError(DownloadErrorKind.RangeNotSatisfied, msg, {
|
||||||
|
httpStatus: status,
|
||||||
|
context: { existingBytes: ctx.existingBytes },
|
||||||
|
});
|
||||||
|
|
||||||
|
case status === 429:
|
||||||
|
return new DownloadError(DownloadErrorKind.RateLimited, msg, {
|
||||||
|
httpStatus: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
case status === 403:
|
||||||
|
return new DownloadError(DownloadErrorKind.Forbidden, msg, {
|
||||||
|
httpStatus: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
case status === 404:
|
||||||
|
return new DownloadError(DownloadErrorKind.NotFound, msg, {
|
||||||
|
httpStatus: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
case status >= 500:
|
||||||
|
return new DownloadError(DownloadErrorKind.ServerError, msg, {
|
||||||
|
httpStatus: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new DownloadError(DownloadErrorKind.Unknown, msg, {
|
||||||
|
httpStatus: status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect when the server ignored a Range header (sent 200 instead of 206).
|
||||||
|
* Call this AFTER receiving a 200 response when a Range header was sent.
|
||||||
|
*/
|
||||||
|
export function classifyRangeIgnored(
|
||||||
|
existingBytes: number,
|
||||||
|
contentLength: number,
|
||||||
|
): DownloadError {
|
||||||
|
return new DownloadError(
|
||||||
|
DownloadErrorKind.RangeIgnored,
|
||||||
|
`range_ignored_on_resume:${existingBytes}/${contentLength}`,
|
||||||
|
{ context: { existingBytes, contentLength } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Classifier: unrestrict / debrid API errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function classifyUnrestrictError(error: unknown): DownloadError {
|
||||||
|
const text = errorText(error);
|
||||||
|
const lc = text.toLowerCase();
|
||||||
|
|
||||||
|
// Permanent: file is dead
|
||||||
|
if (
|
||||||
|
lc.includes("permanent ungültig") ||
|
||||||
|
/file.?not.?found/.test(lc) ||
|
||||||
|
/file.?unavailable/.test(lc) ||
|
||||||
|
/link.?is.?dead/.test(lc) ||
|
||||||
|
lc.includes("file has been removed") ||
|
||||||
|
lc.includes("file has been deleted") ||
|
||||||
|
lc.includes("file is no longer available") ||
|
||||||
|
lc.includes("file was removed") ||
|
||||||
|
lc.includes("file was deleted")
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.LinkDead, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
permanent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider busy / concurrent limit
|
||||||
|
if (
|
||||||
|
lc.includes("too many active") ||
|
||||||
|
lc.includes("too many concurrent") ||
|
||||||
|
lc.includes("too many downloads") ||
|
||||||
|
lc.includes("active download") ||
|
||||||
|
lc.includes("concurrent limit") ||
|
||||||
|
lc.includes("slot limit") ||
|
||||||
|
lc.includes("limit reached") ||
|
||||||
|
lc.includes("zu viele aktive") ||
|
||||||
|
lc.includes("zu viele gleichzeitige") ||
|
||||||
|
lc.includes("zu viele downloads")
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.ProviderBusy, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoster unavailable
|
||||||
|
if (lc.includes("hosternotavailable")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.HosterUnavailable, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota / traffic exceeded
|
||||||
|
if (
|
||||||
|
lc.includes("quota") ||
|
||||||
|
lc.includes("traffic") ||
|
||||||
|
lc.includes("bandwidth limit") ||
|
||||||
|
lc.includes("daily limit")
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.QuotaExceeded, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider temporarily down
|
||||||
|
if (
|
||||||
|
lc.includes("server error") ||
|
||||||
|
lc.includes("internal server error") ||
|
||||||
|
lc.includes("temporarily unavailable") ||
|
||||||
|
lc.includes("temporary unavailable") ||
|
||||||
|
lc.includes("temporarily disabled") ||
|
||||||
|
lc.includes("try again later") ||
|
||||||
|
lc.includes("service unavailable") ||
|
||||||
|
lc.includes("host is down") ||
|
||||||
|
lc.includes("maintenance") ||
|
||||||
|
lc.includes("bad gateway") ||
|
||||||
|
lc.includes("gateway timeout") ||
|
||||||
|
lc.includes("cloudflare") ||
|
||||||
|
lc.includes("worker error")
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.ProviderDown, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic unrestrict failure (session, login, etc.)
|
||||||
|
if (
|
||||||
|
lc.includes("unrestrict") ||
|
||||||
|
lc.includes("mega-web") ||
|
||||||
|
lc.includes("mega-debrid") ||
|
||||||
|
lc.includes("bestdebrid") ||
|
||||||
|
lc.includes("alldebrid") ||
|
||||||
|
lc.includes("kein debrid") ||
|
||||||
|
lc.includes("session-cookie") ||
|
||||||
|
lc.includes("session cookie") ||
|
||||||
|
lc.includes("session blockiert") ||
|
||||||
|
lc.includes("session expired") ||
|
||||||
|
lc.includes("invalid session") ||
|
||||||
|
lc.includes("login ungültig") ||
|
||||||
|
lc.includes("login liefert") ||
|
||||||
|
lc.includes("login required") ||
|
||||||
|
lc.includes("login failed")
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.UnrestrictFailed, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadError(DownloadErrorKind.Unknown, text, {
|
||||||
|
originalError: toError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Classifier: extraction errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function classifyExtractionError(
|
||||||
|
errorText_: string,
|
||||||
|
category?: string,
|
||||||
|
): DownloadError {
|
||||||
|
const lc = (errorText_ || "").toLowerCase();
|
||||||
|
|
||||||
|
if (lc.includes("wrong password") || lc.includes("falsches passwort") || category === "wrong_password") {
|
||||||
|
return new DownloadError(DownloadErrorKind.WrongPassword, errorText_, {
|
||||||
|
permanent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lc.includes("corrupt") ||
|
||||||
|
lc.includes("unexpected end") ||
|
||||||
|
lc.includes("broken header") ||
|
||||||
|
lc.includes("invalid archive") ||
|
||||||
|
lc.includes("bad signature") ||
|
||||||
|
lc.includes("beschädigt") ||
|
||||||
|
category === "archive_corrupt"
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.ArchiveCorrupt, errorText_);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lc.includes("process exited") ||
|
||||||
|
lc.includes("process crashed") ||
|
||||||
|
lc.includes("extractor failed") ||
|
||||||
|
lc.includes("segmentation fault") ||
|
||||||
|
category === "extractor_crash"
|
||||||
|
) {
|
||||||
|
return new DownloadError(DownloadErrorKind.ExtractorCrash, errorText_);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lc.includes("enospc") || lc.includes("no space left")) {
|
||||||
|
return new DownloadError(DownloadErrorKind.DiskFull, errorText_, {
|
||||||
|
permanent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadError(DownloadErrorKind.Unknown, errorText_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience: wrap any unknown error into a DownloadError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure any thrown value becomes a DownloadError.
|
||||||
|
* If already a DownloadError, return as-is.
|
||||||
|
*/
|
||||||
|
export function ensureDownloadError(error: unknown): DownloadError {
|
||||||
|
if (error instanceof DownloadError) return error;
|
||||||
|
return classifyFetchError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Human-readable error messages for UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<DownloadErrorKind, string> = {
|
||||||
|
[DownloadErrorKind.NetworkReset]: "Netzwerkfehler",
|
||||||
|
[DownloadErrorKind.Timeout]: "Zeitüberschreitung",
|
||||||
|
[DownloadErrorKind.DnsFailure]: "DNS-Fehler",
|
||||||
|
[DownloadErrorKind.ConnectTimeout]: "Verbindungs-Timeout",
|
||||||
|
[DownloadErrorKind.RangeNotSatisfied]: "Range-Konflikt (HTTP 416)",
|
||||||
|
[DownloadErrorKind.RangeIgnored]: "Server ignorierte Resume",
|
||||||
|
[DownloadErrorKind.ServerError]: "Serverfehler",
|
||||||
|
[DownloadErrorKind.RateLimited]: "Rate-Limit erreicht",
|
||||||
|
[DownloadErrorKind.Forbidden]: "Zugriff verweigert",
|
||||||
|
[DownloadErrorKind.NotFound]: "Nicht gefunden",
|
||||||
|
[DownloadErrorKind.UnrestrictFailed]: "Unrestrict fehlgeschlagen",
|
||||||
|
[DownloadErrorKind.ProviderBusy]: "Provider ausgelastet",
|
||||||
|
[DownloadErrorKind.ProviderDown]: "Provider nicht erreichbar",
|
||||||
|
[DownloadErrorKind.HosterUnavailable]: "Hoster nicht verfügbar",
|
||||||
|
[DownloadErrorKind.LinkDead]: "Link ungültig / gelöscht",
|
||||||
|
[DownloadErrorKind.QuotaExceeded]: "Tages-Limit erreicht",
|
||||||
|
[DownloadErrorKind.DiskFull]: "Festplatte voll",
|
||||||
|
[DownloadErrorKind.PermissionDenied]: "Zugriff verweigert (Dateisystem)",
|
||||||
|
[DownloadErrorKind.FileLocked]: "Datei gesperrt",
|
||||||
|
[DownloadErrorKind.FileCorrupt]: "Datei beschädigt (CRC-Fehler)",
|
||||||
|
[DownloadErrorKind.FileTruncated]: "Download unvollständig",
|
||||||
|
[DownloadErrorKind.ResumeUnderflow]: "Resume-Fehler",
|
||||||
|
[DownloadErrorKind.WrongPassword]: "Falsches Archiv-Passwort",
|
||||||
|
[DownloadErrorKind.ArchiveCorrupt]: "Archiv beschädigt",
|
||||||
|
[DownloadErrorKind.ExtractorCrash]: "Entpacker abgestürzt",
|
||||||
|
[DownloadErrorKind.WriteDrainTimeout]: "Schreibvorgang blockiert",
|
||||||
|
[DownloadErrorKind.Unknown]: "Unbekannter Fehler",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function errorKindLabel(kind: DownloadErrorKind): string {
|
||||||
|
return KIND_LABELS[kind] || KIND_LABELS[DownloadErrorKind.Unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function errorText(e: unknown): string {
|
||||||
|
if (typeof e === "string") return e;
|
||||||
|
if (e instanceof Error) return e.message || String(e);
|
||||||
|
return String(e ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toError(e: unknown): Error {
|
||||||
|
if (e instanceof Error) return e;
|
||||||
|
return new Error(String(e ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(s: string): string {
|
||||||
|
return s.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||||
|
}
|
||||||
7
src/main/download/index.ts
Normal file
7
src/main/download/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Download system v2 — public re-exports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DownloadManager } from "./download-manager";
|
||||||
|
export type { DownloadManagerOptions } from "./download-manager";
|
||||||
|
export { DownloadError, DownloadErrorKind, errorKindLabel } from "./error-classifier";
|
||||||
314
src/main/download/pipeline.ts
Normal file
314
src/main/download/pipeline.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* pipeline.ts — Single download lifecycle: unrestrict → stream → verify.
|
||||||
|
*
|
||||||
|
* The pipeline runs ONE download attempt. It does NOT handle retries —
|
||||||
|
* the caller (download-manager + retry-manager) decides what to do with errors.
|
||||||
|
* All errors thrown are typed DownloadErrors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DownloadError, DownloadErrorKind, classifyUnrestrictError, classifyFetchError, ensureDownloadError } from "./error-classifier";
|
||||||
|
import { streamToFile, type StreamResult } from "./stream-writer";
|
||||||
|
import type { DownloadItem, PackageEntry, AppSettings, DebridProvider } from "../../shared/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Unrestricted link result from debrid service. */
|
||||||
|
export interface UnrestrictedLink {
|
||||||
|
fileName: string;
|
||||||
|
directUrl: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
retriesUsed: number;
|
||||||
|
skipTlsVerify?: boolean;
|
||||||
|
provider: DebridProvider;
|
||||||
|
providerLabel?: string;
|
||||||
|
sourceAccountId?: string;
|
||||||
|
sourceAccountLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debrid service interface — the pipeline only needs unrestrict. */
|
||||||
|
export interface DebridUnrestrictor {
|
||||||
|
unrestrictLink(url: string, signal: AbortSignal): Promise<UnrestrictedLink>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integrity checker interface. */
|
||||||
|
export interface IntegrityChecker {
|
||||||
|
validateFile(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineContext {
|
||||||
|
item: DownloadItem;
|
||||||
|
pkg: PackageEntry;
|
||||||
|
settings: AppSettings;
|
||||||
|
debridService: DebridUnrestrictor;
|
||||||
|
integrityChecker?: IntegrityChecker;
|
||||||
|
signal: AbortSignal;
|
||||||
|
/** Reuse direct URL from previous attempt (skip unrestrict). */
|
||||||
|
cachedDirectUrl?: string;
|
||||||
|
cachedProvider?: DebridProvider;
|
||||||
|
cachedProviderLabel?: string;
|
||||||
|
cachedSkipTls?: boolean;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onStatus: (status: string, fullStatus: string) => void;
|
||||||
|
onProgress: (downloadedBytes: number, totalBytes: number | null, speedBps: number) => void;
|
||||||
|
onResumable: (resumable: boolean) => void;
|
||||||
|
onFileNameOverride: (newName: string, newTargetPath: string) => void;
|
||||||
|
onProviderInfo: (provider: DebridProvider, label?: string, accountId?: string, accountLabel?: string) => void;
|
||||||
|
onHeartbeat: () => void;
|
||||||
|
onDiskBusy?: (busy: boolean) => void;
|
||||||
|
onLog: (level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
// Path management
|
||||||
|
claimTargetPath: (itemId: string, preferredPath: string, keepExisting?: boolean) => string;
|
||||||
|
releaseTargetPath: (itemId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineResult {
|
||||||
|
success: boolean;
|
||||||
|
downloadedBytes: number;
|
||||||
|
totalBytes: number | null;
|
||||||
|
directUrl: string;
|
||||||
|
provider: DebridProvider;
|
||||||
|
providerLabel?: string;
|
||||||
|
resumable: boolean;
|
||||||
|
skipTlsVerify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_STALL_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT_MS = 25_000;
|
||||||
|
const DEFAULT_UNRESTRICT_TIMEOUT_MS = 60_000;
|
||||||
|
const DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS = 120_000;
|
||||||
|
const DEFAULT_LOW_THROUGHPUT_MIN_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
function getEnvMs(name: string, defaultMs: number): number {
|
||||||
|
const val = process.env[name];
|
||||||
|
if (!val) return defaultMs;
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : defaultMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LARGE_BINARY_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main pipeline function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runPipeline(ctx: PipelineContext): Promise<PipelineResult> {
|
||||||
|
const { item, pkg, settings, debridService, integrityChecker, signal } = ctx;
|
||||||
|
|
||||||
|
// Abort guard
|
||||||
|
if (signal.aborted) throw new Error("aborted");
|
||||||
|
|
||||||
|
// ----- Step 1: Unrestrict -----
|
||||||
|
let directUrl = ctx.cachedDirectUrl || "";
|
||||||
|
let provider = ctx.cachedProvider || item.provider;
|
||||||
|
let providerLabel = ctx.cachedProviderLabel || "";
|
||||||
|
let skipTlsVerify = ctx.cachedSkipTls || false;
|
||||||
|
|
||||||
|
if (!directUrl) {
|
||||||
|
ctx.onStatus("validating", "Link wird umgewandelt...");
|
||||||
|
ctx.onLog("INFO", "Unrestrict started", { url: item.url });
|
||||||
|
|
||||||
|
const unrestrictTimeoutMs = getEnvMs("RD_UNRESTRICT_TIMEOUT_MS", DEFAULT_UNRESTRICT_TIMEOUT_MS);
|
||||||
|
const timeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
|
||||||
|
const combinedSignal = AbortSignal.any([signal, timeoutSignal]);
|
||||||
|
|
||||||
|
let unrestricted: UnrestrictedLink;
|
||||||
|
try {
|
||||||
|
unrestricted = await debridService.unrestrictLink(item.url, combinedSignal);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw error;
|
||||||
|
if (timeoutSignal.aborted) {
|
||||||
|
throw new DownloadError(
|
||||||
|
DownloadErrorKind.ConnectTimeout,
|
||||||
|
`Unrestrict timeout after ${Math.ceil(unrestrictTimeoutMs / 1000)}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw classifyUnrestrictError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) throw new Error("aborted");
|
||||||
|
|
||||||
|
directUrl = unrestricted.directUrl;
|
||||||
|
provider = unrestricted.provider;
|
||||||
|
providerLabel = unrestricted.providerLabel || "";
|
||||||
|
skipTlsVerify = unrestricted.skipTlsVerify || false;
|
||||||
|
|
||||||
|
// Update item metadata
|
||||||
|
ctx.onProviderInfo(
|
||||||
|
unrestricted.provider,
|
||||||
|
unrestricted.providerLabel,
|
||||||
|
unrestricted.sourceAccountId,
|
||||||
|
unrestricted.sourceAccountLabel,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve target path
|
||||||
|
const fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
||||||
|
try { fs.mkdirSync(pkg.outputDir, { recursive: true }); } catch {}
|
||||||
|
|
||||||
|
const existingPath = (item.targetPath || "").trim();
|
||||||
|
const canReuse = existingPath
|
||||||
|
&& isPathInsideDir(existingPath, pkg.outputDir)
|
||||||
|
&& (item.downloadedBytes > 0 || fs.existsSync(existingPath));
|
||||||
|
const preferred = canReuse ? existingPath : path.join(pkg.outputDir, fileName);
|
||||||
|
const targetPath = ctx.claimTargetPath(item.id, preferred, Boolean(canReuse));
|
||||||
|
|
||||||
|
// Update item fields
|
||||||
|
item.fileName = fileName;
|
||||||
|
item.targetPath = targetPath;
|
||||||
|
item.totalBytes = unrestricted.fileSize;
|
||||||
|
item.provider = unrestricted.provider;
|
||||||
|
item.providerLabel = unrestricted.providerLabel;
|
||||||
|
item.providerAccountId = unrestricted.sourceAccountId;
|
||||||
|
item.providerAccountLabel = unrestricted.sourceAccountLabel;
|
||||||
|
item.retries += unrestricted.retriesUsed;
|
||||||
|
|
||||||
|
ctx.onLog("INFO", "Link unrestricted", {
|
||||||
|
provider: unrestricted.provider,
|
||||||
|
providerLabel: unrestricted.providerLabel || "",
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
fileSize: unrestricted.fileSize,
|
||||||
|
directUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Step 2: Stream download -----
|
||||||
|
ctx.onStatus("downloading", `Download läuft (${providerLabel || providerDisplayName(provider)})`);
|
||||||
|
|
||||||
|
const stallTimeoutMs = getEnvMs("RD_STALL_TIMEOUT_MS", DEFAULT_STALL_TIMEOUT_MS);
|
||||||
|
const connectTimeoutMs = getEnvMs("RD_CONNECT_TIMEOUT_MS", DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
|
const lowThroughputTimeoutMs = getEnvMs("RD_LOW_THROUGHPUT_TIMEOUT_MS", DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS);
|
||||||
|
const lowThroughputMinBytes = getEnvMs("RD_LOW_THROUGHPUT_MIN_BYTES", DEFAULT_LOW_THROUGHPUT_MIN_BYTES);
|
||||||
|
|
||||||
|
// Speed limit
|
||||||
|
let effectiveSpeedLimit = 0;
|
||||||
|
if (settings.speedLimitEnabled && settings.speedLimitKbps > 0) {
|
||||||
|
effectiveSpeedLimit = settings.speedLimitKbps * 1024;
|
||||||
|
if (settings.speedLimitMode === "global") {
|
||||||
|
// For global mode, caller divides by active download count
|
||||||
|
// Here we just pass the per-download share
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamResult: StreamResult;
|
||||||
|
try {
|
||||||
|
streamResult = await streamToFile({
|
||||||
|
url: directUrl,
|
||||||
|
targetPath: item.targetPath,
|
||||||
|
expectedBytes: item.totalBytes,
|
||||||
|
trackedDownloadedBytes: item.downloadedBytes,
|
||||||
|
stallTimeoutMs,
|
||||||
|
connectTimeoutMs,
|
||||||
|
skipTlsVerify,
|
||||||
|
speedLimitBps: effectiveSpeedLimit,
|
||||||
|
signal,
|
||||||
|
onProgress: ctx.onProgress,
|
||||||
|
onHeartbeat: ctx.onHeartbeat,
|
||||||
|
onResumable: ctx.onResumable,
|
||||||
|
onFileNameOverride: (newName) => {
|
||||||
|
const newPath = path.join(pkg.outputDir, newName);
|
||||||
|
ctx.releaseTargetPath(item.id);
|
||||||
|
const claimedPath = ctx.claimTargetPath(item.id, newPath);
|
||||||
|
item.fileName = newName;
|
||||||
|
item.targetPath = claimedPath;
|
||||||
|
ctx.onFileNameOverride(newName, claimedPath);
|
||||||
|
},
|
||||||
|
onLog: ctx.onLog,
|
||||||
|
onDiskBusy: ctx.onDiskBusy,
|
||||||
|
lowThroughputTimeoutMs,
|
||||||
|
lowThroughputMinBytes,
|
||||||
|
isLargeBinary: LARGE_BINARY_RE.test(item.fileName || ""),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw error;
|
||||||
|
throw ensureDownloadError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item after successful download
|
||||||
|
item.downloadedBytes = streamResult.downloadedBytes;
|
||||||
|
item.totalBytes = streamResult.totalBytes;
|
||||||
|
|
||||||
|
if (signal.aborted) throw new Error("aborted");
|
||||||
|
|
||||||
|
// ----- Step 3: Integrity check -----
|
||||||
|
if (integrityChecker && settings.enableIntegrityCheck) {
|
||||||
|
ctx.onStatus("integrity_check", "Integritätsprüfung...");
|
||||||
|
ctx.onLog("INFO", "Integrity check started", { targetPath: item.targetPath });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await integrityChecker.validateFile(item.targetPath, pkg.outputDir);
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.onLog("ERROR", "Integrity check failed", { message: result.message });
|
||||||
|
throw new DownloadError(DownloadErrorKind.FileCorrupt, result.message);
|
||||||
|
}
|
||||||
|
ctx.onLog("INFO", "Integrity check passed", { message: result.message });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DownloadError) throw error;
|
||||||
|
// Non-DownloadError from integrity check — classify
|
||||||
|
throw new DownloadError(DownloadErrorKind.FileCorrupt, String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Done -----
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
downloadedBytes: streamResult.downloadedBytes,
|
||||||
|
totalBytes: streamResult.totalBytes,
|
||||||
|
directUrl,
|
||||||
|
provider: provider!,
|
||||||
|
providerLabel,
|
||||||
|
resumable: streamResult.resumable,
|
||||||
|
skipTlsVerify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").replace(/\s+/g, " ").trim() || "download";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const pathParts = u.pathname.split("/").filter(Boolean);
|
||||||
|
const last = pathParts[pathParts.length - 1] || "download";
|
||||||
|
return decodeURIComponent(last);
|
||||||
|
} catch {
|
||||||
|
return "download";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInsideDir(filePath: string, dirPath: string): boolean {
|
||||||
|
const normalizedFile = path.resolve(filePath).toLowerCase();
|
||||||
|
const normalizedDir = path.resolve(dirPath).toLowerCase();
|
||||||
|
return normalizedFile.startsWith(normalizedDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerDisplayName(provider: DebridProvider | null): string {
|
||||||
|
if (!provider) return "Debrid";
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
realdebrid: "Real-Debrid",
|
||||||
|
"megadebrid-api": "Mega-Debrid API",
|
||||||
|
"megadebrid-web": "Mega-Debrid Web",
|
||||||
|
megadebrid: "Mega-Debrid",
|
||||||
|
bestdebrid: "BestDebrid",
|
||||||
|
alldebrid: "AllDebrid",
|
||||||
|
ddownload: "DDownload",
|
||||||
|
onefichier: "1Fichier",
|
||||||
|
debridlink: "DebridLink",
|
||||||
|
linksnappy: "LinkSnappy",
|
||||||
|
};
|
||||||
|
return names[provider] || provider;
|
||||||
|
}
|
||||||
409
src/main/download/post-processor.ts
Normal file
409
src/main/download/post-processor.ts
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* post-processor.ts — Extraction state machine with bounded retries.
|
||||||
|
*
|
||||||
|
* Each archive has a clear state (pending → extracting → done/failed).
|
||||||
|
* No infinite loops: hard cap on retry count per archive.
|
||||||
|
* Redownload requests are emitted as events, not handled internally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { DownloadError, DownloadErrorKind, classifyExtractionError } from "./error-classifier";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ArchiveExtractionState {
|
||||||
|
archiveName: string;
|
||||||
|
status: "pending" | "extracting" | "done" | "failed";
|
||||||
|
attempts: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
redownloaded: boolean;
|
||||||
|
lastError?: string;
|
||||||
|
lastErrorKind?: DownloadErrorKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackagePostProcessState {
|
||||||
|
packageId: string;
|
||||||
|
status: "idle" | "waiting" | "extracting" | "done" | "failed" | "aborted";
|
||||||
|
archives: Map<string, ArchiveExtractionState>;
|
||||||
|
startedAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostProcessOptions {
|
||||||
|
packageDir: string;
|
||||||
|
extractDir: string;
|
||||||
|
cleanupMode: "none" | "trash" | "delete";
|
||||||
|
conflictMode: "overwrite" | "skip" | "rename" | "ask";
|
||||||
|
removeLinks: boolean;
|
||||||
|
removeSamples: boolean;
|
||||||
|
passwordList: string;
|
||||||
|
hybridMode: boolean;
|
||||||
|
maxParallelExtract: number;
|
||||||
|
extractCpuPriority: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractProgressUpdate {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
archiveName: string;
|
||||||
|
archivePercent?: number;
|
||||||
|
phase: "extracting" | "done" | "preparing";
|
||||||
|
archiveDone?: boolean;
|
||||||
|
archiveSuccess?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractArchiveFailure {
|
||||||
|
archiveName: string;
|
||||||
|
errorText: string;
|
||||||
|
category: string;
|
||||||
|
suggestRedownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_MAX_EXTRACT_ATTEMPTS = 3;
|
||||||
|
const SLOT_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PostProcessor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PostProcessorEvents {
|
||||||
|
progress: [{ packageId: string; update: ExtractProgressUpdate }];
|
||||||
|
"package-done": [{ packageId: string; success: boolean; errors: string[] }];
|
||||||
|
"archive-redownload": [{ packageId: string; archiveName: string; error: string }];
|
||||||
|
status: [{ packageId: string; label: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostProcessor extends EventEmitter {
|
||||||
|
private states = new Map<string, PackagePostProcessState>();
|
||||||
|
private abortControllers = new Map<string, AbortController>();
|
||||||
|
private activeTasks = new Map<string, Promise<void>>();
|
||||||
|
private activeSlots = 0;
|
||||||
|
private maxSlots: number;
|
||||||
|
private slotWaiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
/** Extraction function — injected to avoid circular dependency. */
|
||||||
|
private extractFn: ((opts: any) => Promise<any>) | null = null;
|
||||||
|
/** Archive candidate finder. */
|
||||||
|
private findArchivesFn: ((dir: string) => string[] | Promise<string[]>) | null = null;
|
||||||
|
|
||||||
|
constructor(maxParallel: number = 2) {
|
||||||
|
super();
|
||||||
|
this.maxSlots = maxParallel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject the extraction function (from extractor.ts). */
|
||||||
|
setExtractor(
|
||||||
|
extractFn: (opts: any) => Promise<any>,
|
||||||
|
findArchivesFn: (dir: string) => string[] | Promise<string[]>,
|
||||||
|
): void {
|
||||||
|
this.extractFn = extractFn;
|
||||||
|
this.findArchivesFn = findArchivesFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaxParallel(n: number): void {
|
||||||
|
this.maxSlots = Math.max(1, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a package for post-processing.
|
||||||
|
* If already processing, mark for re-run (hybrid requeue).
|
||||||
|
*/
|
||||||
|
queuePackage(packageId: string, options: PostProcessOptions): void {
|
||||||
|
const existing = this.activeTasks.get(packageId);
|
||||||
|
if (existing) {
|
||||||
|
// Mark for requeue — current run will check after finishing
|
||||||
|
const state = this.states.get(packageId);
|
||||||
|
if (state) state.status = "waiting";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
this.abortControllers.set(packageId, ac);
|
||||||
|
|
||||||
|
const combinedSignal = AbortSignal.any([options.signal, ac.signal]);
|
||||||
|
|
||||||
|
const task = this.runPostProcessing(packageId, { ...options, signal: combinedSignal });
|
||||||
|
this.activeTasks.set(packageId, task);
|
||||||
|
|
||||||
|
task.finally(() => {
|
||||||
|
this.activeTasks.delete(packageId);
|
||||||
|
this.abortControllers.delete(packageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort processing for a specific package.
|
||||||
|
*/
|
||||||
|
abortPackage(packageId: string): void {
|
||||||
|
const ac = this.abortControllers.get(packageId);
|
||||||
|
if (ac) ac.abort();
|
||||||
|
const state = this.states.get(packageId);
|
||||||
|
if (state) state.status = "aborted";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort all active post-processing.
|
||||||
|
*/
|
||||||
|
abortAll(): void {
|
||||||
|
for (const [id, ac] of this.abortControllers) {
|
||||||
|
ac.abort();
|
||||||
|
const state = this.states.get(id);
|
||||||
|
if (state) state.status = "aborted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry extraction for a package (user-initiated).
|
||||||
|
*/
|
||||||
|
retryPackage(packageId: string, options: PostProcessOptions): void {
|
||||||
|
// Reset archive states
|
||||||
|
const state = this.states.get(packageId);
|
||||||
|
if (state) {
|
||||||
|
for (const archive of state.archives.values()) {
|
||||||
|
if (archive.status === "failed") {
|
||||||
|
archive.status = "pending";
|
||||||
|
archive.attempts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.status = "idle";
|
||||||
|
}
|
||||||
|
this.queuePackage(packageId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get state for a package.
|
||||||
|
*/
|
||||||
|
getState(packageId: string): PackagePostProcessState | undefined {
|
||||||
|
return this.states.get(packageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any processing is active.
|
||||||
|
*/
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.activeTasks.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for all active tasks to complete.
|
||||||
|
*/
|
||||||
|
async waitAll(): Promise<void> {
|
||||||
|
await Promise.allSettled([...this.activeTasks.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async runPostProcessing(packageId: string, options: PostProcessOptions): Promise<void> {
|
||||||
|
// Acquire slot
|
||||||
|
await this.acquireSlot(options.signal);
|
||||||
|
if (options.signal.aborted) return;
|
||||||
|
|
||||||
|
const state: PackagePostProcessState = this.states.get(packageId) || {
|
||||||
|
packageId,
|
||||||
|
status: "extracting",
|
||||||
|
archives: new Map(),
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
state.status = "extracting";
|
||||||
|
state.startedAt = Date.now();
|
||||||
|
this.states.set(packageId, state);
|
||||||
|
|
||||||
|
let round = 0;
|
||||||
|
const MAX_ROUNDS = 5; // Hard cap on requeue rounds
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
round++;
|
||||||
|
if (round > MAX_ROUNDS) {
|
||||||
|
state.label = `Max. Runden erreicht (${MAX_ROUNDS})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("status", { packageId, label: `Entpacken Runde ${round}...` });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.runExtractionRound(packageId, options, state);
|
||||||
|
} catch (error) {
|
||||||
|
if (options.signal.aborted) break;
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
state.label = `Fehler: ${msg}`;
|
||||||
|
this.emit("status", { packageId, label: state.label });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are pending archives for another round
|
||||||
|
const hasPending = [...state.archives.values()].some(a => a.status === "pending");
|
||||||
|
if (!hasPending) break;
|
||||||
|
|
||||||
|
} while (!options.signal.aborted);
|
||||||
|
|
||||||
|
// Determine final status
|
||||||
|
const archives = [...state.archives.values()];
|
||||||
|
const allDone = archives.every(a => a.status === "done");
|
||||||
|
const anyFailed = archives.some(a => a.status === "failed");
|
||||||
|
const errors = archives
|
||||||
|
.filter(a => a.status === "failed")
|
||||||
|
.map(a => `${a.archiveName}: ${a.lastError || "Unbekannt"}`);
|
||||||
|
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
state.status = "aborted";
|
||||||
|
} else if (allDone || archives.length === 0) {
|
||||||
|
state.status = "done";
|
||||||
|
} else {
|
||||||
|
state.status = "failed";
|
||||||
|
}
|
||||||
|
state.completedAt = Date.now();
|
||||||
|
|
||||||
|
this.emit("package-done", {
|
||||||
|
packageId,
|
||||||
|
success: state.status === "done",
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.releaseSlot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runExtractionRound(
|
||||||
|
packageId: string,
|
||||||
|
options: PostProcessOptions,
|
||||||
|
state: PackagePostProcessState,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.extractFn || !this.findArchivesFn) {
|
||||||
|
throw new Error("Extractor not configured — call setExtractor()");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find archives
|
||||||
|
const archivePaths = await this.findArchivesFn(options.packageDir);
|
||||||
|
if (archivePaths.length === 0) {
|
||||||
|
state.label = "Keine Archive gefunden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize archive states for new archives
|
||||||
|
for (const archivePath of archivePaths) {
|
||||||
|
const name = archivePath;
|
||||||
|
if (!state.archives.has(name)) {
|
||||||
|
state.archives.set(name, {
|
||||||
|
archiveName: name,
|
||||||
|
status: "pending",
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: DEFAULT_MAX_EXTRACT_ATTEMPTS,
|
||||||
|
redownloaded: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only extract pending archives
|
||||||
|
const pendingArchives = [...state.archives.values()]
|
||||||
|
.filter(a => a.status === "pending")
|
||||||
|
.map(a => a.archiveName);
|
||||||
|
|
||||||
|
if (pendingArchives.length === 0) return;
|
||||||
|
|
||||||
|
// Run extraction
|
||||||
|
const failures: ExtractArchiveFailure[] = [];
|
||||||
|
|
||||||
|
await this.extractFn({
|
||||||
|
packageDir: options.packageDir,
|
||||||
|
targetDir: options.extractDir,
|
||||||
|
cleanupMode: options.cleanupMode,
|
||||||
|
conflictMode: options.conflictMode,
|
||||||
|
removeLinks: options.removeLinks,
|
||||||
|
removeSamples: options.removeSamples,
|
||||||
|
passwordList: options.passwordList,
|
||||||
|
signal: options.signal,
|
||||||
|
hybridMode: options.hybridMode,
|
||||||
|
maxParallel: options.maxParallelExtract,
|
||||||
|
extractCpuPriority: options.extractCpuPriority,
|
||||||
|
packageId,
|
||||||
|
onlyArchives: new Set(pendingArchives),
|
||||||
|
onProgress: (update: ExtractProgressUpdate) => {
|
||||||
|
this.emit("progress", { packageId, update });
|
||||||
|
|
||||||
|
// Track individual archive completion
|
||||||
|
if (update.archiveDone) {
|
||||||
|
const archiveState = state.archives.get(update.archiveName);
|
||||||
|
if (archiveState) {
|
||||||
|
archiveState.attempts++;
|
||||||
|
if (update.archiveSuccess) {
|
||||||
|
archiveState.status = "done";
|
||||||
|
}
|
||||||
|
// If not success, onArchiveFailure will handle it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onArchiveFailure: (failure: ExtractArchiveFailure) => {
|
||||||
|
failures.push(failure);
|
||||||
|
const archiveState = state.archives.get(failure.archiveName);
|
||||||
|
if (!archiveState) return;
|
||||||
|
|
||||||
|
const error = classifyExtractionError(failure.errorText, failure.category);
|
||||||
|
archiveState.lastError = failure.errorText;
|
||||||
|
archiveState.lastErrorKind = error.kind;
|
||||||
|
archiveState.attempts++;
|
||||||
|
|
||||||
|
// Decide: retry, redownload, or fail permanently
|
||||||
|
if (archiveState.attempts >= archiveState.maxAttempts) {
|
||||||
|
// Max attempts reached
|
||||||
|
if (error.kind === DownloadErrorKind.ArchiveCorrupt && !archiveState.redownloaded && failure.suggestRedownload) {
|
||||||
|
// Request redownload (max once per archive)
|
||||||
|
archiveState.redownloaded = true;
|
||||||
|
archiveState.attempts = 0; // Reset for redownloaded archive
|
||||||
|
archiveState.status = "pending";
|
||||||
|
this.emit("archive-redownload", {
|
||||||
|
packageId,
|
||||||
|
archiveName: failure.archiveName,
|
||||||
|
error: failure.errorText,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
archiveState.status = "failed";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Still have attempts left — mark as pending for next round
|
||||||
|
archiveState.status = "pending";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Slot management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async acquireSlot(signal: AbortSignal): Promise<void> {
|
||||||
|
while (this.activeSlots >= this.maxSlots) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
this.slotWaiters.push(resolve);
|
||||||
|
// Also poll in case signal gets aborted
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const idx = this.slotWaiters.indexOf(resolve);
|
||||||
|
if (idx >= 0) this.slotWaiters.splice(idx, 1);
|
||||||
|
resolve();
|
||||||
|
}, SLOT_POLL_INTERVAL_MS);
|
||||||
|
// Clean up timer if resolved normally
|
||||||
|
const originalResolve = resolve;
|
||||||
|
// Just let the poll handle it
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.activeSlots++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseSlot(): void {
|
||||||
|
this.activeSlots = Math.max(0, this.activeSlots - 1);
|
||||||
|
const waiter = this.slotWaiters.shift();
|
||||||
|
if (waiter) waiter();
|
||||||
|
}
|
||||||
|
}
|
||||||
390
src/main/download/retry-manager.ts
Normal file
390
src/main/download/retry-manager.ts
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* retry-manager.ts — Declarative retry logic with per-error-kind policies.
|
||||||
|
*
|
||||||
|
* Each DownloadErrorKind has a RetryPolicy that determines max retries,
|
||||||
|
* backoff strategy, and actions (reset file, switch provider, etc.).
|
||||||
|
* The RetryManager tracks failure counts per item and decides whether
|
||||||
|
* to retry or fail permanently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DownloadError, DownloadErrorKind, errorKindLabel, isPermanentKind } from "./error-classifier";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Retry Policy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RetryPolicy {
|
||||||
|
/** Maximum retries for this error kind. 0 = fail immediately. */
|
||||||
|
maxRetries: number;
|
||||||
|
/** Backoff strategy. */
|
||||||
|
backoff: "fixed" | "exponential";
|
||||||
|
/** Base delay in milliseconds. */
|
||||||
|
baseDelayMs: number;
|
||||||
|
/** Maximum delay in milliseconds (cap for exponential). */
|
||||||
|
maxDelayMs: number;
|
||||||
|
/** Delete partial file before retry. */
|
||||||
|
resetFile: boolean;
|
||||||
|
/** Try a different debrid provider on retry. */
|
||||||
|
switchProvider: boolean;
|
||||||
|
/** Request a fresh direct link from debrid service. */
|
||||||
|
refreshLink: boolean;
|
||||||
|
/** Apply cooldown to current provider (ms). 0 = no cooldown. */
|
||||||
|
providerCooldownMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RETRY_POLICIES: Record<DownloadErrorKind, RetryPolicy> = {
|
||||||
|
// -- Network --
|
||||||
|
[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: 30_000,
|
||||||
|
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.ConnectTimeout]: {
|
||||||
|
maxRetries: 4, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 30_000,
|
||||||
|
resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- HTTP --
|
||||||
|
[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: 60_000,
|
||||||
|
resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0,
|
||||||
|
},
|
||||||
|
[DownloadErrorKind.RateLimited]: {
|
||||||
|
maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120_000,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Provider / Debrid --
|
||||||
|
[DownloadErrorKind.UnrestrictFailed]: {
|
||||||
|
maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120_000,
|
||||||
|
resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 20_000,
|
||||||
|
},
|
||||||
|
[DownloadErrorKind.ProviderBusy]: {
|
||||||
|
maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 60_000,
|
||||||
|
resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 12_000,
|
||||||
|
},
|
||||||
|
[DownloadErrorKind.ProviderDown]: {
|
||||||
|
maxRetries: 5, backoff: "exponential", baseDelayMs: 10_000, maxDelayMs: 180_000,
|
||||||
|
resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 30_000,
|
||||||
|
},
|
||||||
|
[DownloadErrorKind.HosterUnavailable]: {
|
||||||
|
maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 30_000,
|
||||||
|
resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 15_000,
|
||||||
|
},
|
||||||
|
[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: 30_000, maxDelayMs: 300_000,
|
||||||
|
resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 60_000,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Filesystem --
|
||||||
|
[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: 10_000,
|
||||||
|
resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Integrity / Resume --
|
||||||
|
[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,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Extraction --
|
||||||
|
[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,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Write / Drain --
|
||||||
|
[DownloadErrorKind.WriteDrainTimeout]: {
|
||||||
|
maxRetries: 3, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 30_000,
|
||||||
|
resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Catchall --
|
||||||
|
[DownloadErrorKind.Unknown]: {
|
||||||
|
maxRetries: 5, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 60_000,
|
||||||
|
resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Retry Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type RetryAction =
|
||||||
|
| "reset_file"
|
||||||
|
| "switch_provider"
|
||||||
|
| "refresh_link"
|
||||||
|
| "cooldown_provider"
|
||||||
|
| "shelve";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Retry State (per item)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RetryState {
|
||||||
|
failuresByKind: Partial<Record<DownloadErrorKind, number>>;
|
||||||
|
totalFailures: number;
|
||||||
|
shelveCount: number;
|
||||||
|
lastErrorKind?: DownloadErrorKind;
|
||||||
|
lastErrorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Retry Decision
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RetryDecision {
|
||||||
|
shouldRetry: boolean;
|
||||||
|
delayMs: number;
|
||||||
|
actions: RetryAction[];
|
||||||
|
/** Human-readable status message for UI (German). */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SHELVE_THRESHOLD = 15;
|
||||||
|
const SHELVE_DELAY_MS = 90_000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RetryManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class RetryManager {
|
||||||
|
private states = new Map<string, RetryState>();
|
||||||
|
private userRetryLimit: number;
|
||||||
|
|
||||||
|
constructor(retryLimit: number = 0) {
|
||||||
|
this.userRetryLimit = retryLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the user-configured retry limit. 0 = unlimited. */
|
||||||
|
setRetryLimit(limit: number): void {
|
||||||
|
this.userRetryLimit = Math.max(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failure and decide whether to retry.
|
||||||
|
*/
|
||||||
|
evaluate(itemId: string, error: DownloadError): RetryDecision {
|
||||||
|
const state = this.getOrCreateState(itemId);
|
||||||
|
const kind = error.kind;
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
state.failuresByKind[kind] = (state.failuresByKind[kind] || 0) + 1;
|
||||||
|
state.totalFailures += 1;
|
||||||
|
state.lastErrorKind = kind;
|
||||||
|
state.lastErrorMessage = error.message;
|
||||||
|
|
||||||
|
const kindCount = state.failuresByKind[kind]!;
|
||||||
|
const policy = RETRY_POLICIES[kind];
|
||||||
|
|
||||||
|
// Permanent errors — never retry
|
||||||
|
if (isPermanentKind(kind) || policy.maxRetries === 0) {
|
||||||
|
return {
|
||||||
|
shouldRetry: false,
|
||||||
|
delayMs: 0,
|
||||||
|
actions: [],
|
||||||
|
reason: errorKindLabel(kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine effective max retries (user limit overrides if set)
|
||||||
|
const effectiveMax = this.userRetryLimit > 0
|
||||||
|
? Math.min(policy.maxRetries, this.userRetryLimit)
|
||||||
|
: policy.maxRetries;
|
||||||
|
|
||||||
|
// Check shelving threshold BEFORE individual kind limits
|
||||||
|
if (state.totalFailures >= SHELVE_THRESHOLD) {
|
||||||
|
return this.shelve(state, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this specific kind exhausted its retries
|
||||||
|
if (kindCount > effectiveMax) {
|
||||||
|
return {
|
||||||
|
shouldRetry: false,
|
||||||
|
delayMs: 0,
|
||||||
|
actions: [],
|
||||||
|
reason: `${errorKindLabel(kind)} — Versuche erschöpft (${kindCount}/${effectiveMax})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry — compute delay and actions
|
||||||
|
const delayMs = this.computeDelay(policy, kindCount);
|
||||||
|
const actions = this.computeActions(policy);
|
||||||
|
const reason = `${errorKindLabel(kind)}, Retry ${kindCount}/${effectiveMax}`;
|
||||||
|
|
||||||
|
return { shouldRetry: true, delayMs, actions, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset retry state for an item (manual reset by user).
|
||||||
|
*/
|
||||||
|
resetItem(itemId: string): void {
|
||||||
|
this.states.delete(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current retry state for persistence.
|
||||||
|
*/
|
||||||
|
getState(itemId: string): RetryState | undefined {
|
||||||
|
return this.states.get(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore retry state from persisted session.
|
||||||
|
*/
|
||||||
|
restoreState(itemId: string, state: RetryState): void {
|
||||||
|
this.states.set(itemId, { ...state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all retry states for persistence.
|
||||||
|
*/
|
||||||
|
exportStates(): Record<string, RetryState> {
|
||||||
|
const out: Record<string, RetryState> = {};
|
||||||
|
for (const [id, state] of this.states) {
|
||||||
|
out[id] = { ...state, failuresByKind: { ...state.failuresByKind } };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import retry states from persistence.
|
||||||
|
*/
|
||||||
|
importStates(states: Record<string, RetryState>): void {
|
||||||
|
this.states.clear();
|
||||||
|
for (const [id, state] of Object.entries(states)) {
|
||||||
|
this.states.set(id, { ...state, failuresByKind: { ...state.failuresByKind } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove state for deleted/cancelled items.
|
||||||
|
*/
|
||||||
|
removeItem(itemId: string): void {
|
||||||
|
this.states.delete(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-reset stale retry state. Halves counters for items that haven't
|
||||||
|
* failed recently. Called periodically (e.g. every 10 minutes).
|
||||||
|
*/
|
||||||
|
softReset(): void {
|
||||||
|
for (const state of this.states.values()) {
|
||||||
|
if (state.totalFailures > 0) {
|
||||||
|
for (const kind of Object.keys(state.failuresByKind) as DownloadErrorKind[]) {
|
||||||
|
state.failuresByKind[kind] = Math.floor((state.failuresByKind[kind] || 0) / 2);
|
||||||
|
}
|
||||||
|
state.totalFailures = Object.values(state.failuresByKind).reduce(
|
||||||
|
(sum, v) => sum + (v || 0), 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private getOrCreateState(itemId: string): RetryState {
|
||||||
|
let state = this.states.get(itemId);
|
||||||
|
if (!state) {
|
||||||
|
state = { failuresByKind: {}, totalFailures: 0, shelveCount: 0 };
|
||||||
|
this.states.set(itemId, state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shelve(state: RetryState, lastKind: DownloadErrorKind): RetryDecision {
|
||||||
|
// Halve all counters to allow recovery
|
||||||
|
for (const kind of Object.keys(state.failuresByKind) as DownloadErrorKind[]) {
|
||||||
|
state.failuresByKind[kind] = Math.floor((state.failuresByKind[kind] || 0) / 2);
|
||||||
|
}
|
||||||
|
state.totalFailures = Object.values(state.failuresByKind).reduce(
|
||||||
|
(sum, v) => sum + (v || 0), 0,
|
||||||
|
);
|
||||||
|
state.shelveCount += 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldRetry: true,
|
||||||
|
delayMs: SHELVE_DELAY_MS,
|
||||||
|
actions: ["shelve", "switch_provider", "refresh_link"],
|
||||||
|
reason: `Viele Fehler (${SHELVE_THRESHOLD}+), pausiert für ${SHELVE_DELAY_MS / 1000}s`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeDelay(policy: RetryPolicy, attempt: number): number {
|
||||||
|
if (policy.backoff === "fixed") {
|
||||||
|
return policy.baseDelayMs;
|
||||||
|
}
|
||||||
|
// Exponential: base * 1.5^(attempt-1) with jitter, capped at max
|
||||||
|
const base = policy.baseDelayMs * Math.pow(1.5, attempt - 1);
|
||||||
|
const capped = Math.min(base, policy.maxDelayMs);
|
||||||
|
const jitter = capped * Math.random() * 0.5;
|
||||||
|
return Math.floor(Math.max(capped * 0.5, capped - jitter));
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeActions(policy: RetryPolicy): RetryAction[] {
|
||||||
|
const actions: RetryAction[] = [];
|
||||||
|
if (policy.resetFile) actions.push("reset_file");
|
||||||
|
if (policy.switchProvider) actions.push("switch_provider");
|
||||||
|
if (policy.refreshLink) actions.push("refresh_link");
|
||||||
|
if (policy.providerCooldownMs > 0) actions.push("cooldown_provider");
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
492
src/main/download/scheduler.ts
Normal file
492
src/main/download/scheduler.ts
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
/**
|
||||||
|
* scheduler.ts — Queue management, slot allocation, and stall detection.
|
||||||
|
*
|
||||||
|
* The scheduler runs a loop that fills download slots up to maxParallel,
|
||||||
|
* monitors heartbeats for stall detection, and provides a global watchdog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { DownloadItem, PackageEntry, PackagePriority, SessionState } from "../../shared/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SchedulerConfig {
|
||||||
|
maxParallel: number;
|
||||||
|
stallTimeoutMs: number;
|
||||||
|
globalStallWatchdogMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSlot {
|
||||||
|
itemId: string;
|
||||||
|
packageId: string;
|
||||||
|
abortController: AbortController;
|
||||||
|
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none";
|
||||||
|
resumable: boolean;
|
||||||
|
lastHeartbeatAt: number;
|
||||||
|
bytesAtHeartbeat: number;
|
||||||
|
blockedOnDiskWrite: boolean;
|
||||||
|
blockedOnDiskSince: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotRequest {
|
||||||
|
itemId: string;
|
||||||
|
packageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Scheduler extends EventEmitter {
|
||||||
|
private generation = 0;
|
||||||
|
private running = false;
|
||||||
|
private paused = false;
|
||||||
|
private config: SchedulerConfig;
|
||||||
|
|
||||||
|
// Active downloads
|
||||||
|
private slots = new Map<string, ActiveSlot>();
|
||||||
|
|
||||||
|
// Retry delays
|
||||||
|
private retryDelays = new Map<string, number>(); // itemId → readyAtEpochMs
|
||||||
|
|
||||||
|
// Provider cooldowns
|
||||||
|
private providerCooldowns = new Map<string, { cooldownUntil: number; failureCount: number }>();
|
||||||
|
|
||||||
|
// Reconnect state
|
||||||
|
private reconnectUntil = 0;
|
||||||
|
|
||||||
|
// Global watchdog state
|
||||||
|
private lastGlobalProgressBytes = 0;
|
||||||
|
private lastGlobalProgressAt = 0;
|
||||||
|
|
||||||
|
// Scoped run (only these packages)
|
||||||
|
private scopedPackageIds = new Set<string>();
|
||||||
|
|
||||||
|
constructor(config: SchedulerConfig) {
|
||||||
|
super();
|
||||||
|
this.config = { ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Update config at runtime (e.g. when user changes maxParallel). */
|
||||||
|
updateConfig(partial: Partial<SchedulerConfig>): void {
|
||||||
|
Object.assign(this.config, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the scheduler loop.
|
||||||
|
*
|
||||||
|
* @param session Live session state
|
||||||
|
* @param startItem Callback to start a download for a slot request
|
||||||
|
* @param scopedIds Optional: only run these package IDs
|
||||||
|
*/
|
||||||
|
async start(
|
||||||
|
session: SessionState,
|
||||||
|
startItem: (slot: SlotRequest) => void,
|
||||||
|
scopedIds?: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
this.generation++;
|
||||||
|
this.running = true;
|
||||||
|
this.paused = false;
|
||||||
|
this.scopedPackageIds = new Set(scopedIds || []);
|
||||||
|
this.lastGlobalProgressBytes = 0;
|
||||||
|
this.lastGlobalProgressAt = Date.now();
|
||||||
|
|
||||||
|
const myGeneration = this.generation;
|
||||||
|
const loopIntervalMs = 120;
|
||||||
|
let lastHeartbeatCheckAt = Date.now();
|
||||||
|
let lastSoftResetAt = Date.now();
|
||||||
|
|
||||||
|
while (this.running && this.generation === myGeneration) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Paused — just idle
|
||||||
|
if (this.paused) {
|
||||||
|
await sleep(loopIntervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect wait
|
||||||
|
if (this.reconnectUntil > now) {
|
||||||
|
await sleep(220);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill slots
|
||||||
|
const maxParallel = Math.max(1, this.config.maxParallel);
|
||||||
|
while (this.slots.size < maxParallel) {
|
||||||
|
const next = this.findNextItem(session, now);
|
||||||
|
if (!next) break;
|
||||||
|
startItem(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat / stall check (every 2s)
|
||||||
|
if (now - lastHeartbeatCheckAt >= 2000) {
|
||||||
|
this.checkStalls(now);
|
||||||
|
lastHeartbeatCheckAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global stall watchdog
|
||||||
|
this.runGlobalWatchdog(now);
|
||||||
|
|
||||||
|
// Soft-reset stale retry delays (every 10 min)
|
||||||
|
if (now - lastSoftResetAt >= 600_000) {
|
||||||
|
this.cleanupStaleRetryDelays(now);
|
||||||
|
lastSoftResetAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if run is complete
|
||||||
|
if (this.slots.size === 0) {
|
||||||
|
const hasQueued = this.hasQueuedItems(session, now);
|
||||||
|
const hasDelayed = this.hasDelayedItems(session, now);
|
||||||
|
if (!hasQueued && !hasDelayed) {
|
||||||
|
this.emit("run-complete");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(this.slots.size >= maxParallel ? 170 : loopIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scheduler loop (bumps generation to exit).
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.generation++;
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause/unpause slot allocation.
|
||||||
|
*/
|
||||||
|
setPaused(paused: boolean): void {
|
||||||
|
this.paused = paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPaused(): boolean {
|
||||||
|
return this.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Slot management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an item as actively downloading.
|
||||||
|
*/
|
||||||
|
claimSlot(itemId: string, packageId: string, abortController: AbortController): ActiveSlot {
|
||||||
|
const slot: ActiveSlot = {
|
||||||
|
itemId,
|
||||||
|
packageId,
|
||||||
|
abortController,
|
||||||
|
abortReason: "none",
|
||||||
|
resumable: true,
|
||||||
|
lastHeartbeatAt: Date.now(),
|
||||||
|
bytesAtHeartbeat: 0,
|
||||||
|
blockedOnDiskWrite: false,
|
||||||
|
blockedOnDiskSince: 0,
|
||||||
|
};
|
||||||
|
this.slots.set(itemId, slot);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a slot (download finished/failed/cancelled).
|
||||||
|
*/
|
||||||
|
releaseSlot(itemId: string): void {
|
||||||
|
this.slots.delete(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active slot for an item.
|
||||||
|
*/
|
||||||
|
getSlot(itemId: string): ActiveSlot | undefined {
|
||||||
|
return this.slots.get(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active slots.
|
||||||
|
*/
|
||||||
|
getActiveSlots(): Map<string, ActiveSlot> {
|
||||||
|
return this.slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeCount(): number {
|
||||||
|
return this.slots.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCapacity(): boolean {
|
||||||
|
return this.slots.size < Math.max(1, this.config.maxParallel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Heartbeat
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a heartbeat from an active download.
|
||||||
|
*/
|
||||||
|
heartbeat(itemId: string, downloadedBytes: number): void {
|
||||||
|
const slot = this.slots.get(itemId);
|
||||||
|
if (slot) {
|
||||||
|
slot.lastHeartbeatAt = Date.now();
|
||||||
|
slot.bytesAtHeartbeat = downloadedBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Retry scheduling
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a retry delay for an item.
|
||||||
|
*/
|
||||||
|
scheduleRetry(itemId: string, delayMs: number): void {
|
||||||
|
this.retryDelays.set(itemId, Date.now() + Math.max(0, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is still delayed.
|
||||||
|
*/
|
||||||
|
isDelayed(itemId: string, now?: number): boolean {
|
||||||
|
const readyAt = this.retryDelays.get(itemId);
|
||||||
|
if (!readyAt) return false;
|
||||||
|
return readyAt > (now ?? Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear retry delay for an item.
|
||||||
|
*/
|
||||||
|
clearRetryDelay(itemId: string): void {
|
||||||
|
this.retryDelays.delete(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Provider cooldowns
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a cooldown to a provider.
|
||||||
|
*/
|
||||||
|
applyProviderCooldown(provider: string, cooldownMs: number): void {
|
||||||
|
const existing = this.providerCooldowns.get(provider) || { cooldownUntil: 0, failureCount: 0 };
|
||||||
|
existing.cooldownUntil = Date.now() + cooldownMs;
|
||||||
|
existing.failureCount++;
|
||||||
|
this.providerCooldowns.set(provider, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining cooldown for a provider (ms). 0 = not in cooldown.
|
||||||
|
*/
|
||||||
|
getProviderCooldownRemaining(provider: string): number {
|
||||||
|
const entry = this.providerCooldowns.get(provider);
|
||||||
|
if (!entry) return 0;
|
||||||
|
const remaining = entry.cooldownUntil - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
entry.failureCount = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cooldown for a provider (after success).
|
||||||
|
*/
|
||||||
|
clearProviderCooldown(provider: string): void {
|
||||||
|
this.providerCooldowns.delete(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reconnect
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter reconnect wait mode (429/503 backoff).
|
||||||
|
*/
|
||||||
|
setReconnectWait(durationMs: number): void {
|
||||||
|
this.reconnectUntil = Date.now() + durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently in reconnect wait.
|
||||||
|
*/
|
||||||
|
isReconnecting(): boolean {
|
||||||
|
return this.reconnectUntil > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining reconnect wait time (ms).
|
||||||
|
*/
|
||||||
|
getReconnectRemaining(): number {
|
||||||
|
return Math.max(0, this.reconnectUntil - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Abort helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort a specific item's download.
|
||||||
|
*/
|
||||||
|
abortItem(itemId: string, reason: ActiveSlot["abortReason"]): void {
|
||||||
|
const slot = this.slots.get(itemId);
|
||||||
|
if (slot) {
|
||||||
|
slot.abortReason = reason;
|
||||||
|
slot.abortController.abort(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort all active downloads.
|
||||||
|
*/
|
||||||
|
abortAll(reason: ActiveSlot["abortReason"]): void {
|
||||||
|
for (const slot of this.slots.values()) {
|
||||||
|
slot.abortReason = reason;
|
||||||
|
slot.abortController.abort(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Private: item selection
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private findNextItem(session: SessionState, now: number): SlotRequest | null {
|
||||||
|
const priorities: PackagePriority[] = ["high", "normal", "low"];
|
||||||
|
|
||||||
|
for (const prio of priorities) {
|
||||||
|
for (const packageId of session.packageOrder) {
|
||||||
|
const pkg = session.packages[packageId];
|
||||||
|
if (!pkg || pkg.cancelled || !pkg.enabled) continue;
|
||||||
|
if ((pkg.priority || "normal") !== prio) continue;
|
||||||
|
if (this.scopedPackageIds.size > 0 && !this.scopedPackageIds.has(packageId)) continue;
|
||||||
|
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = session.items[itemId];
|
||||||
|
if (!item) continue;
|
||||||
|
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
|
||||||
|
if (this.slots.has(itemId)) continue;
|
||||||
|
|
||||||
|
// Check retry delay
|
||||||
|
const retryAt = this.retryDelays.get(itemId);
|
||||||
|
if (retryAt && retryAt > now) continue;
|
||||||
|
if (retryAt && retryAt <= now) this.retryDelays.delete(itemId);
|
||||||
|
|
||||||
|
return { itemId, packageId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasQueuedItems(session: SessionState, now: number): boolean {
|
||||||
|
for (const packageId of session.packageOrder) {
|
||||||
|
const pkg = session.packages[packageId];
|
||||||
|
if (!pkg || pkg.cancelled || !pkg.enabled) continue;
|
||||||
|
if (this.scopedPackageIds.size > 0 && !this.scopedPackageIds.has(packageId)) continue;
|
||||||
|
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = session.items[itemId];
|
||||||
|
if (!item) continue;
|
||||||
|
const retryAt = this.retryDelays.get(itemId);
|
||||||
|
if (retryAt && retryAt > now) continue;
|
||||||
|
if (item.status === "queued" || item.status === "reconnect_wait") return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasDelayedItems(session: SessionState, now: number): boolean {
|
||||||
|
for (const [itemId, readyAt] of this.retryDelays) {
|
||||||
|
if (readyAt <= now) continue;
|
||||||
|
const item = session.items[itemId];
|
||||||
|
if (!item) continue;
|
||||||
|
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
|
||||||
|
const pkg = session.packages[item.packageId];
|
||||||
|
if (!pkg || pkg.cancelled || !pkg.enabled) continue;
|
||||||
|
if (this.scopedPackageIds.size > 0 && !this.scopedPackageIds.has(item.packageId)) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Private: stall detection
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private checkStalls(now: number): void {
|
||||||
|
if (this.config.stallTimeoutMs <= 0) return;
|
||||||
|
|
||||||
|
for (const slot of this.slots.values()) {
|
||||||
|
if (slot.blockedOnDiskWrite) continue; // Don't count disk waits
|
||||||
|
const idleMs = now - slot.lastHeartbeatAt;
|
||||||
|
if (idleMs > this.config.stallTimeoutMs) {
|
||||||
|
this.emit("stall-detected", { itemId: slot.itemId, idleMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runGlobalWatchdog(now: number): void {
|
||||||
|
if (this.config.globalStallWatchdogMs <= 0) return;
|
||||||
|
if (this.slots.size === 0) return;
|
||||||
|
|
||||||
|
// Sum total bytes across all active downloads
|
||||||
|
let totalBytes = 0;
|
||||||
|
let allDiskBlocked = true;
|
||||||
|
for (const slot of this.slots.values()) {
|
||||||
|
totalBytes += slot.bytesAtHeartbeat;
|
||||||
|
if (!slot.blockedOnDiskWrite) allDiskBlocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all downloads are disk-blocked, don't trigger watchdog
|
||||||
|
if (allDiskBlocked) return;
|
||||||
|
|
||||||
|
if (totalBytes > this.lastGlobalProgressBytes) {
|
||||||
|
this.lastGlobalProgressBytes = totalBytes;
|
||||||
|
this.lastGlobalProgressAt = now;
|
||||||
|
} else if (now - this.lastGlobalProgressAt > this.config.globalStallWatchdogMs) {
|
||||||
|
const stalledIds = [...this.slots.values()]
|
||||||
|
.filter(s => !s.blockedOnDiskWrite)
|
||||||
|
.map(s => s.itemId);
|
||||||
|
this.emit("global-stall", { itemIds: stalledIds });
|
||||||
|
this.lastGlobalProgressAt = now; // Reset to avoid rapid-fire events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Private: cleanup
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private cleanupStaleRetryDelays(now: number): void {
|
||||||
|
for (const [itemId, readyAt] of this.retryDelays) {
|
||||||
|
if (readyAt <= now) {
|
||||||
|
this.retryDelays.delete(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cleanup stale provider cooldowns
|
||||||
|
for (const [provider, entry] of this.providerCooldowns) {
|
||||||
|
if (entry.cooldownUntil <= now) {
|
||||||
|
this.providerCooldowns.delete(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
732
src/main/download/stream-writer.ts
Normal file
732
src/main/download/stream-writer.ts
Normal file
@ -0,0 +1,732 @@
|
|||||||
|
/**
|
||||||
|
* stream-writer.ts — HTTP streaming with validated resume, NTFS-aligned
|
||||||
|
* buffered writing, stall detection, and speed limiting.
|
||||||
|
*
|
||||||
|
* This module is a pure function with no dependency on DownloadManager state.
|
||||||
|
* All side effects happen through callbacks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DownloadError, DownloadErrorKind, classifyFetchError, classifyHttpStatus, classifyRangeIgnored } from "./error-classifier";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WRITE_BUFFER_SIZE = 512 * 1024;
|
||||||
|
const ALLOCATION_UNIT_SIZE = 4096;
|
||||||
|
const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
||||||
|
const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
||||||
|
const DISK_BUSY_THRESHOLD_MS = 300;
|
||||||
|
const DEFAULT_DRAIN_TIMEOUT_MS = 300_000; // 5 min
|
||||||
|
const MIN_LEGITIMATE_FILE_BYTES = 512;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface StreamOptions {
|
||||||
|
/** Direct download URL. */
|
||||||
|
url: string;
|
||||||
|
/** Target file path on disk. */
|
||||||
|
targetPath: string;
|
||||||
|
/** Expected total file size (from unrestrict or previous response). null = unknown. */
|
||||||
|
expectedBytes: number | null;
|
||||||
|
/** Previously downloaded bytes (tracked by caller for resume validation). */
|
||||||
|
trackedDownloadedBytes: number;
|
||||||
|
/** Stall timeout: abort if no data received for this long (ms). 0 = disabled. */
|
||||||
|
stallTimeoutMs: number;
|
||||||
|
/** Connection timeout (ms). 0 = disabled. */
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
/** Skip TLS verification for this request. */
|
||||||
|
skipTlsVerify: boolean;
|
||||||
|
/** Speed limit in bytes/sec. 0 = no limit. */
|
||||||
|
speedLimitBps: number;
|
||||||
|
/** Abort signal from caller. */
|
||||||
|
signal: AbortSignal;
|
||||||
|
/** Called periodically with download progress. */
|
||||||
|
onProgress: (downloadedBytes: number, totalBytes: number | null, speedBps: number) => void;
|
||||||
|
/** Called every ~1-3s even during slow transfer, for watchdog purposes. */
|
||||||
|
onHeartbeat: () => void;
|
||||||
|
/** Called once after HTTP response to report resumability. */
|
||||||
|
onResumable: (resumable: boolean) => void;
|
||||||
|
/** Called if Content-Disposition provides a different filename. */
|
||||||
|
onFileNameOverride?: (newName: string) => void;
|
||||||
|
/** Called to log events. */
|
||||||
|
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>) => void;
|
||||||
|
/** Called when disk is busy (backpressure). */
|
||||||
|
onDiskBusy?: (busy: boolean) => void;
|
||||||
|
/** Maximum inner retries on same direct URL before escalating. Default: 3. */
|
||||||
|
maxDirectUrlRetries?: number;
|
||||||
|
/** Low throughput timeout: abort if < minBytes in this window (ms). 0 = disabled. */
|
||||||
|
lowThroughputTimeoutMs?: number;
|
||||||
|
/** Minimum bytes required in lowThroughput window. */
|
||||||
|
lowThroughputMinBytes?: number;
|
||||||
|
/** Whether the target filename looks like a large binary (archive, video, etc.). */
|
||||||
|
isLargeBinary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamResult {
|
||||||
|
/** Total bytes of the complete file. */
|
||||||
|
totalBytes: number | null;
|
||||||
|
/** Bytes written in this session (not counting resume). */
|
||||||
|
downloadedBytes: number;
|
||||||
|
/** Whether the server supports Range/resume. */
|
||||||
|
resumable: boolean;
|
||||||
|
/** If Content-Disposition provided a new filename. */
|
||||||
|
fileName?: string;
|
||||||
|
/** True if the download completed (all bytes received). */
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function streamToFile(opts: StreamOptions): Promise<StreamResult> {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
targetPath,
|
||||||
|
expectedBytes,
|
||||||
|
trackedDownloadedBytes,
|
||||||
|
stallTimeoutMs,
|
||||||
|
connectTimeoutMs,
|
||||||
|
skipTlsVerify,
|
||||||
|
speedLimitBps,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
onHeartbeat,
|
||||||
|
onResumable,
|
||||||
|
onFileNameOverride,
|
||||||
|
onLog,
|
||||||
|
onDiskBusy,
|
||||||
|
lowThroughputTimeoutMs = 0,
|
||||||
|
lowThroughputMinBytes = 64 * 1024,
|
||||||
|
isLargeBinary = false,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const maxAttempts = opts.maxDirectUrlRetries ?? 3;
|
||||||
|
const log = onLog ?? (() => {});
|
||||||
|
let lastError: DownloadError | null = null;
|
||||||
|
let overriddenFileName: string | undefined;
|
||||||
|
let effectiveTargetPath = targetPath;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
// ----- Pre-resume validation -----
|
||||||
|
let existingBytes = 0;
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(effectiveTargetPath);
|
||||||
|
existingBytes = stat.size;
|
||||||
|
} catch {
|
||||||
|
// file does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against pre-allocated sparse files: if file is much larger than
|
||||||
|
// what we actually wrote, truncate to tracked bytes.
|
||||||
|
if (existingBytes > 0 && trackedDownloadedBytes > 0 && existingBytes > trackedDownloadedBytes + 1_048_576) {
|
||||||
|
try {
|
||||||
|
await fs.promises.truncate(effectiveTargetPath, trackedDownloadedBytes);
|
||||||
|
existingBytes = trackedDownloadedBytes;
|
||||||
|
log("WARN", "Sparse file truncated to tracked bytes", {
|
||||||
|
existingBytes: existingBytes,
|
||||||
|
trackedDownloadedBytes,
|
||||||
|
});
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file is smaller than tracked bytes but nonzero — mismatch, could be
|
||||||
|
// corruption from a crash. For small mismatches (<1MB), restart fresh.
|
||||||
|
if (existingBytes > 0 && trackedDownloadedBytes > 0 && existingBytes < trackedDownloadedBytes - 1_048_576) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(effectiveTargetPath, { force: true });
|
||||||
|
existingBytes = 0;
|
||||||
|
log("WARN", "File smaller than tracked bytes — deleted for fresh start", {
|
||||||
|
existingBytes,
|
||||||
|
trackedDownloadedBytes,
|
||||||
|
});
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- HTTP request -----
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (existingBytes > 0) {
|
||||||
|
headers.Range = `bytes=${existingBytes}-`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("INFO", "HTTP download attempt", {
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
url,
|
||||||
|
targetPath: effectiveTargetPath,
|
||||||
|
existingBytes,
|
||||||
|
rangeHeader: headers.Range || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check abort before connecting
|
||||||
|
checkAborted(signal);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
let connectTimer: NodeJS.Timeout | null = null;
|
||||||
|
const connectAbortController = new AbortController();
|
||||||
|
|
||||||
|
// TLS skip management
|
||||||
|
if (skipTlsVerify) acquireTlsSkip();
|
||||||
|
try {
|
||||||
|
if (connectTimeoutMs > 0) {
|
||||||
|
connectTimer = setTimeout(() => connectAbortController.abort("connect_timeout"), connectTimeoutMs);
|
||||||
|
}
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.any([signal, connectAbortController.signal]),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Rethrow abort errors
|
||||||
|
if (signal.aborted) throw error;
|
||||||
|
if (String(error).includes("connect_timeout")) {
|
||||||
|
throw new DownloadError(DownloadErrorKind.ConnectTimeout, "Connection timeout", { originalError: error instanceof Error ? error : undefined });
|
||||||
|
}
|
||||||
|
lastError = classifyFetchError(error);
|
||||||
|
log("WARN", "HTTP connection failed", { attempt, error: lastError.message });
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(retryDelayWithJitter(attempt, 200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
} finally {
|
||||||
|
if (skipTlsVerify) releaseTlsSkip();
|
||||||
|
if (connectTimer) clearTimeout(connectTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- HTTP status handling -----
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
if (response.status === 416 && existingBytes > 0) {
|
||||||
|
const result = await handle416(response, existingBytes, expectedBytes, log);
|
||||||
|
if (result) {
|
||||||
|
onResumable(true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Not complete — delete and retry
|
||||||
|
try { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch {}
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(retryDelayWithJitter(attempt, 200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw classifyHttpStatus({ status: 416, existingBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text().catch(() => "");
|
||||||
|
lastError = classifyHttpStatus({
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
responseText,
|
||||||
|
existingBytes,
|
||||||
|
});
|
||||||
|
log("WARN", "HTTP response not OK", { attempt, status: response.status, error: lastError.message });
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(retryDelayWithJitter(attempt, 250));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Response analysis -----
|
||||||
|
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
|
||||||
|
const resumable = response.status === 206 || acceptRanges;
|
||||||
|
onResumable(resumable);
|
||||||
|
|
||||||
|
// Detect server ignoring Range header (200 instead of 206)
|
||||||
|
if (existingBytes > 0 && response.status === 200) {
|
||||||
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
||||||
|
try { await response.body?.cancel(); } catch {}
|
||||||
|
log("WARN", "Server ignored Range header", { existingBytes, contentLength });
|
||||||
|
throw classifyRangeIgnored(existingBytes, contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse total size
|
||||||
|
const rawContentLength = Number(response.headers.get("content-length") || 0);
|
||||||
|
const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0;
|
||||||
|
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
||||||
|
|
||||||
|
let totalBytes = expectedBytes;
|
||||||
|
if (!totalBytes || totalBytes <= 0) {
|
||||||
|
if (totalFromRange) totalBytes = totalFromRange;
|
||||||
|
else if (contentLength > 0) {
|
||||||
|
totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Disposition filename (only on fresh downloads)
|
||||||
|
if (existingBytes === 0 && onFileNameOverride) {
|
||||||
|
const rawName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim();
|
||||||
|
const fromHeader = rawName ? sanitizeFilename(rawName) : "";
|
||||||
|
if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== path.basename(targetPath)) {
|
||||||
|
overriddenFileName = fromHeader;
|
||||||
|
const newPath = path.join(path.dirname(targetPath), fromHeader);
|
||||||
|
effectiveTargetPath = newPath;
|
||||||
|
onFileNameOverride(fromHeader);
|
||||||
|
log("INFO", "Filename from Content-Disposition", { fromHeader, newPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
||||||
|
|
||||||
|
log("INFO", "HTTP response accepted", {
|
||||||
|
attempt, status: response.status, resumable, contentLength,
|
||||||
|
totalFromRange, totalBytes, writeMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If starting fresh, delete existing file
|
||||||
|
if (writeMode === "w" && existingBytes > 0) {
|
||||||
|
try { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(effectiveTargetPath), { recursive: true });
|
||||||
|
|
||||||
|
// ----- Sparse pre-allocation (Windows) -----
|
||||||
|
let preAllocated = false;
|
||||||
|
if (writeMode === "w" && totalBytes && totalBytes > 0 && process.platform === "win32") {
|
||||||
|
try {
|
||||||
|
const fd = await fs.promises.open(effectiveTargetPath, "w");
|
||||||
|
try { await fd.truncate(totalBytes); preAllocated = true; } finally { await fd.close(); }
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Streaming write -----
|
||||||
|
const stream = fs.createWriteStream(effectiveTargetPath, {
|
||||||
|
flags: preAllocated ? "r+" : writeMode === "a" ? "a" : "w",
|
||||||
|
start: preAllocated ? 0 : undefined,
|
||||||
|
highWaterMark: STREAM_HIGH_WATER_MARK,
|
||||||
|
});
|
||||||
|
let written = writeMode === "a" ? existingBytes : 0;
|
||||||
|
let windowBytes = 0;
|
||||||
|
let windowStarted = nowMs();
|
||||||
|
let bodyError: unknown = null;
|
||||||
|
|
||||||
|
// Write buffer with 4KB NTFS alignment
|
||||||
|
const writeBuf = Buffer.allocUnsafe(WRITE_BUFFER_SIZE);
|
||||||
|
let writeBufPos = 0;
|
||||||
|
let lastFlushAt = nowMs();
|
||||||
|
|
||||||
|
let diskBusySince = 0;
|
||||||
|
let diskBusyNotified = false;
|
||||||
|
const drainTimeoutMs = Math.max(30_000, Math.min(DEFAULT_DRAIN_TIMEOUT_MS, stallTimeoutMs > 0 ? stallTimeoutMs * 12 : 120_000));
|
||||||
|
|
||||||
|
// --- waitDrain ---
|
||||||
|
const waitDrain = (): Promise<void> => new Promise((resolve, reject) => {
|
||||||
|
if (signal.aborted) { reject(new Error("aborted")); return; }
|
||||||
|
|
||||||
|
if (onDiskBusy && !diskBusyNotified) {
|
||||||
|
onDiskBusy(true);
|
||||||
|
diskBusyNotified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new DownloadError(DownloadErrorKind.WriteDrainTimeout, "write_drain_timeout"));
|
||||||
|
}, drainTimeoutMs);
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (onDiskBusy && diskBusyNotified) {
|
||||||
|
onDiskBusy(false);
|
||||||
|
diskBusyNotified = false;
|
||||||
|
}
|
||||||
|
stream.off("drain", onDrain);
|
||||||
|
stream.off("error", onErr);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const onDrain = (): void => { if (!settled) { settled = true; cleanup(); resolve(); } };
|
||||||
|
const onErr = (e: Error): void => { if (!settled) { settled = true; cleanup(); reject(e); } };
|
||||||
|
const onAbort = (): void => { if (!settled) { settled = true; cleanup(); reject(new Error("aborted")); } };
|
||||||
|
|
||||||
|
stream.once("drain", onDrain);
|
||||||
|
stream.once("error", onErr);
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- aligned flush ---
|
||||||
|
const alignedFlush = async (final = false): Promise<void> => {
|
||||||
|
if (writeBufPos === 0) return;
|
||||||
|
let toWrite = writeBufPos;
|
||||||
|
if (!final && toWrite > ALLOCATION_UNIT_SIZE) {
|
||||||
|
toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE);
|
||||||
|
}
|
||||||
|
const slice = Buffer.from(writeBuf.subarray(0, toWrite));
|
||||||
|
if (!stream.write(slice)) {
|
||||||
|
await waitDrain();
|
||||||
|
}
|
||||||
|
if (toWrite < writeBufPos) {
|
||||||
|
writeBuf.copy(writeBuf, 0, toWrite, writeBufPos);
|
||||||
|
}
|
||||||
|
writeBufPos -= toWrite;
|
||||||
|
lastFlushAt = nowMs();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = response.body;
|
||||||
|
if (!body) throw new DownloadError(DownloadErrorKind.Unknown, "Empty response body");
|
||||||
|
|
||||||
|
const reader = body.getReader();
|
||||||
|
let lastDataAt = nowMs();
|
||||||
|
|
||||||
|
// Throughput window for low-throughput detection
|
||||||
|
let throughputWindowStart = nowMs();
|
||||||
|
let throughputWindowBytes = 0;
|
||||||
|
|
||||||
|
// Speed limiter state
|
||||||
|
let speedLimitWindowStart = nowMs();
|
||||||
|
let speedLimitWindowBytes = 0;
|
||||||
|
|
||||||
|
// Heartbeat timer
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
if (!signal.aborted) onHeartbeat();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// readWithTimeout
|
||||||
|
const readWithTimeout = async (): Promise<ReadableStreamReadResult<Uint8Array>> => {
|
||||||
|
if (stallTimeoutMs <= 0) return reader.read();
|
||||||
|
return new Promise<ReadableStreamReadResult<Uint8Array>>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
reject(new DownloadError(DownloadErrorKind.Timeout, "stall_timeout"));
|
||||||
|
}, stallTimeoutMs);
|
||||||
|
reader.read().then(result => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(result);
|
||||||
|
}).catch(err => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await readWithTimeout();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
lastDataAt = nowMs();
|
||||||
|
checkAborted(signal);
|
||||||
|
|
||||||
|
const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||||
|
|
||||||
|
// Speed limiting
|
||||||
|
if (speedLimitBps > 0) {
|
||||||
|
speedLimitWindowBytes += buffer.length;
|
||||||
|
const elapsed = (nowMs() - speedLimitWindowStart) / 1000;
|
||||||
|
if (elapsed > 0.1) {
|
||||||
|
const currentRate = speedLimitWindowBytes / elapsed;
|
||||||
|
if (currentRate > speedLimitBps) {
|
||||||
|
const sleepMs = Math.floor(((speedLimitWindowBytes / speedLimitBps) - elapsed) * 1000);
|
||||||
|
if (sleepMs > 10) await sleep(Math.min(sleepMs, 1000));
|
||||||
|
}
|
||||||
|
if (elapsed >= 1) {
|
||||||
|
speedLimitWindowStart = nowMs();
|
||||||
|
speedLimitWindowBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAborted(signal);
|
||||||
|
|
||||||
|
// Buffer incoming data for aligned writes
|
||||||
|
let srcOffset = 0;
|
||||||
|
while (srcOffset < buffer.length) {
|
||||||
|
const space = WRITE_BUFFER_SIZE - writeBufPos;
|
||||||
|
const toCopy = Math.min(space, buffer.length - srcOffset);
|
||||||
|
buffer.copy(writeBuf, writeBufPos, srcOffset, srcOffset + toCopy);
|
||||||
|
writeBufPos += toCopy;
|
||||||
|
srcOffset += toCopy;
|
||||||
|
if (writeBufPos >= Math.floor(WRITE_BUFFER_SIZE * 0.80)) {
|
||||||
|
await alignedFlush(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based flush
|
||||||
|
if (writeBufPos > 0 && nowMs() - lastFlushAt >= WRITE_FLUSH_TIMEOUT_MS) {
|
||||||
|
await alignedFlush(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proactive disk-busy detection
|
||||||
|
if (stream.writableLength > 0) {
|
||||||
|
if (diskBusySince === 0) diskBusySince = nowMs();
|
||||||
|
} else {
|
||||||
|
diskBusySince = 0;
|
||||||
|
if (diskBusyNotified && onDiskBusy) {
|
||||||
|
onDiskBusy(false);
|
||||||
|
diskBusyNotified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written += buffer.length;
|
||||||
|
windowBytes += buffer.length;
|
||||||
|
throughputWindowBytes += buffer.length;
|
||||||
|
|
||||||
|
// Early completion: all expected bytes received
|
||||||
|
const expectedTotal = totalBytes && totalBytes > 0 ? totalBytes : 0;
|
||||||
|
const expectedFromResponse = contentLength > 0 ? contentLength : 0;
|
||||||
|
if (expectedTotal > 0 && written >= expectedTotal) break;
|
||||||
|
if (expectedTotal === 0 && expectedFromResponse > 0 && (written - (writeMode === "a" ? existingBytes : 0)) >= expectedFromResponse) break;
|
||||||
|
|
||||||
|
// Low throughput check
|
||||||
|
const now = nowMs();
|
||||||
|
if (lowThroughputTimeoutMs > 0 && now - throughputWindowStart >= lowThroughputTimeoutMs) {
|
||||||
|
if (throughputWindowBytes < lowThroughputMinBytes) {
|
||||||
|
throw new DownloadError(DownloadErrorKind.Timeout, `slow_throughput:${throughputWindowBytes}/${lowThroughputMinBytes}`);
|
||||||
|
}
|
||||||
|
throughputWindowStart = now;
|
||||||
|
throughputWindowBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed calculation and progress reporting
|
||||||
|
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.2);
|
||||||
|
const speed = windowBytes / elapsed;
|
||||||
|
if (elapsed >= 0.5) {
|
||||||
|
windowStarted = nowMs();
|
||||||
|
windowBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskBusy = diskBusySince > 0 && nowMs() - diskBusySince >= DISK_BUSY_THRESHOLD_MS;
|
||||||
|
onProgress(written, totalBytes, diskBusy ? 0 : Math.floor(speed));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
try { await reader.cancel().catch(() => {}); reader.releaseLock(); } catch {}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
bodyError = error;
|
||||||
|
log("WARN", "Download body error", { attempt, error: errorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
// Flush remaining buffered data
|
||||||
|
try { await alignedFlush(true); } catch (e) { if (!bodyError) bodyError = e; }
|
||||||
|
|
||||||
|
// Close stream
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (stream.closed || stream.destroyed) { resolve(); return; }
|
||||||
|
const onDone = (): void => { stream.off("error", onErr); resolve(); };
|
||||||
|
const onErr = (e: Error): void => { stream.off("finish", onDone); stream.off("close", onDone); reject(e); };
|
||||||
|
stream.once("finish", onDone);
|
||||||
|
stream.once("close", onDone);
|
||||||
|
stream.once("error", onErr);
|
||||||
|
stream.end();
|
||||||
|
});
|
||||||
|
} catch (closeErr) {
|
||||||
|
if (!stream.destroyed) stream.destroy();
|
||||||
|
if (!bodyError) throw closeErr;
|
||||||
|
log("WARN", "Stream close error suppressed", { error: errorMessage(closeErr) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stream.destroyed) stream.destroy();
|
||||||
|
|
||||||
|
// fsync for pre-allocated files
|
||||||
|
if (!bodyError && preAllocated) {
|
||||||
|
try {
|
||||||
|
const fd = await fs.promises.open(effectiveTargetPath, "r");
|
||||||
|
try { await fd.datasync(); } finally { await fd.close(); }
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate pre-allocated file to actual written bytes on error
|
||||||
|
if (bodyError && preAllocated && totalBytes && written < totalBytes) {
|
||||||
|
try { await fs.promises.truncate(effectiveTargetPath, written); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyError) {
|
||||||
|
// On error: truncate pre-allocated sparse file
|
||||||
|
if (preAllocated && totalBytes && written < totalBytes) {
|
||||||
|
try { await fs.promises.truncate(effectiveTargetPath, written); } catch {}
|
||||||
|
}
|
||||||
|
if (signal.aborted) throw bodyError;
|
||||||
|
lastError = bodyError instanceof DownloadError ? bodyError : classifyFetchError(bodyError);
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
log("WARN", "Retrying after body error", { attempt, error: lastError.message });
|
||||||
|
await sleep(retryDelayWithJitter(attempt, 250));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Post-download validation -----
|
||||||
|
|
||||||
|
// Tiny file detection (hoster error pages disguised as downloads)
|
||||||
|
if (written > 0 && written < MIN_LEGITIMATE_FILE_BYTES) {
|
||||||
|
let snippet = "";
|
||||||
|
try { snippet = (await fs.promises.readFile(effectiveTargetPath, "utf8")).slice(0, 200).replace(/[\r\n]+/g, " ").trim(); } catch {}
|
||||||
|
try { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch {}
|
||||||
|
log("WARN", `Tiny download detected (${written} bytes)`, { snippet });
|
||||||
|
throw new DownloadError(DownloadErrorKind.ServerError,
|
||||||
|
`Download too small (${written} B) — hoster error page?${snippet ? ` Content: "${snippet}"` : ""}`,
|
||||||
|
{ httpStatus: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Underflow detection
|
||||||
|
if (totalBytes && totalBytes > 0 && written < totalBytes) {
|
||||||
|
const shortfall = totalBytes - written;
|
||||||
|
if (preAllocated) {
|
||||||
|
try { await fs.promises.truncate(effectiveTargetPath, written); } catch {}
|
||||||
|
}
|
||||||
|
if (isLargeBinary || shortfall > ALLOCATION_UNIT_SIZE) {
|
||||||
|
log("WARN", "Download underflow", { expected: totalBytes, received: written, shortfall });
|
||||||
|
throw new DownloadError(DownloadErrorKind.FileTruncated,
|
||||||
|
`download_underflow:${written}/${totalBytes}`,
|
||||||
|
{ context: { written, totalBytes, shortfall } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate pre-allocated file to actual size
|
||||||
|
if (preAllocated && totalBytes && written < totalBytes) {
|
||||||
|
try { await fs.promises.truncate(effectiveTargetPath, written); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("INFO", "Download complete", { attempt, resumable, written, totalBytes, targetPath: effectiveTargetPath });
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBytes,
|
||||||
|
downloadedBytes: written,
|
||||||
|
resumable,
|
||||||
|
fileName: overriddenFileName,
|
||||||
|
completed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attempts exhausted
|
||||||
|
throw lastError ?? new DownloadError(DownloadErrorKind.Unknown, "Download failed — all attempts exhausted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP 416 handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handle416(
|
||||||
|
response: Response,
|
||||||
|
existingBytes: number,
|
||||||
|
expectedBytes: number | null,
|
||||||
|
log: (level: "INFO" | "WARN" | "ERROR", msg: string, fields?: Record<string, unknown>) => void,
|
||||||
|
): Promise<StreamResult | null> {
|
||||||
|
await response.arrayBuffer().catch(() => undefined);
|
||||||
|
const rangeTotal = parseContentRangeTotal(response.headers.get("content-range"));
|
||||||
|
const resolvedTotal = (expectedBytes && expectedBytes > 0) ? expectedBytes : rangeTotal;
|
||||||
|
|
||||||
|
// File is already complete
|
||||||
|
if (resolvedTotal && existingBytes === resolvedTotal) {
|
||||||
|
log("INFO", "HTTP 416 treated as complete", { existingBytes, resolvedTotal });
|
||||||
|
return {
|
||||||
|
totalBytes: resolvedTotal,
|
||||||
|
downloadedBytes: existingBytes,
|
||||||
|
resumable: true,
|
||||||
|
completed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No size info but substantial data — assume complete to avoid deleting multi-GB files
|
||||||
|
if (!resolvedTotal && existingBytes > 1_048_576) {
|
||||||
|
log("WARN", "HTTP 416 without size info — assuming complete", { existingBytes });
|
||||||
|
return {
|
||||||
|
totalBytes: existingBytes,
|
||||||
|
downloadedBytes: existingBytes,
|
||||||
|
resumable: true,
|
||||||
|
completed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not complete — caller should delete and retry
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content-Disposition parser (RFC 2231 support)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseContentDispositionFilename(header: string | null): string {
|
||||||
|
if (!header) return "";
|
||||||
|
// filename*= (RFC 2231 extended notation)
|
||||||
|
const extMatch = /filename\*\s*=\s*(?:UTF-8|utf-8)?''(.+?)(?:;|$)/i.exec(header);
|
||||||
|
if (extMatch) {
|
||||||
|
try { return decodeURIComponent(extMatch[1]); } catch {}
|
||||||
|
}
|
||||||
|
// filename= (standard, possibly quoted)
|
||||||
|
const stdMatch = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||||
|
if (stdMatch) return stdMatch[1].trim();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentRangeTotal(header: string | null): number | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const match = /\/\s*(\d+)/.exec(header);
|
||||||
|
if (match) {
|
||||||
|
const total = Number(match[1]);
|
||||||
|
return total > 0 ? total : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filename utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeOpaqueFilename(name: string): boolean {
|
||||||
|
return /^[a-f0-9]{20,}(\.\w+)?$/i.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TLS skip reference counter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let tlsSkipRefCount = 0;
|
||||||
|
|
||||||
|
function acquireTlsSkip(): void {
|
||||||
|
tlsSkipRefCount++;
|
||||||
|
if (tlsSkipRefCount === 1) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseTlsSkip(): void {
|
||||||
|
tlsSkipRefCount--;
|
||||||
|
if (tlsSkipRefCount <= 0) {
|
||||||
|
tlsSkipRefCount = 0;
|
||||||
|
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function checkAborted(signal: AbortSignal): void {
|
||||||
|
if (signal.aborted) throw new Error("aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMs(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||||
|
const base = baseMs * Math.pow(1.5, attempt - 1);
|
||||||
|
const jitter = base * Math.random();
|
||||||
|
return Math.floor(Math.max(base * 0.5, base - jitter));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(e: unknown): string {
|
||||||
|
if (e instanceof Error) return e.message;
|
||||||
|
return String(e ?? "");
|
||||||
|
}
|
||||||
@ -22,7 +22,6 @@ const JVM_EXTRACTOR_REQUIRED_LIBS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ── subst drive mapping for long paths on Windows ──
|
// ── subst drive mapping for long paths on Windows ──
|
||||||
const SUBST_THRESHOLD = 200;
|
|
||||||
const activeSubstDrives = new Set<string>();
|
const activeSubstDrives = new Set<string>();
|
||||||
|
|
||||||
function findFreeSubstDrive(): string | null {
|
function findFreeSubstDrive(): string | null {
|
||||||
@ -43,7 +42,7 @@ function findFreeSubstDrive(): string | null {
|
|||||||
interface SubstMapping { drive: string; original: string; }
|
interface SubstMapping { drive: string; original: string; }
|
||||||
|
|
||||||
function createSubstMapping(targetDir: string): SubstMapping | null {
|
function createSubstMapping(targetDir: string): SubstMapping | null {
|
||||||
if (process.platform !== "win32" || targetDir.length < SUBST_THRESHOLD) return null;
|
if (process.platform !== "win32" || !path.isAbsolute(targetDir)) return null;
|
||||||
const drive = findFreeSubstDrive();
|
const drive = findFreeSubstDrive();
|
||||||
if (!drive) return null;
|
if (!drive) return null;
|
||||||
const result = spawnSync("subst", [`${drive}:`, targetDir], { stdio: "pipe", timeout: 5000 });
|
const result = spawnSync("subst", [`${drive}:`, targetDir], { stdio: "pipe", timeout: 5000 });
|
||||||
@ -595,11 +594,18 @@ export type ExtractErrorCategory =
|
|||||||
type ExtractionErrorWithHints = Error & {
|
type ExtractionErrorWithHints = Error & {
|
||||||
suggestRedownload?: boolean;
|
suggestRedownload?: boolean;
|
||||||
jvmFailureReason?: string;
|
jvmFailureReason?: string;
|
||||||
|
legacyBestPercent?: number;
|
||||||
|
legacyExtractor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function withExtractionErrorHints(
|
function withExtractionErrorHints(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
hints: { suggestRedownload?: boolean; jvmFailureReason?: string }
|
hints: {
|
||||||
|
suggestRedownload?: boolean;
|
||||||
|
jvmFailureReason?: string;
|
||||||
|
legacyBestPercent?: number;
|
||||||
|
legacyExtractor?: string;
|
||||||
|
}
|
||||||
): Error {
|
): Error {
|
||||||
const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen"));
|
const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen"));
|
||||||
const enhanced = base as ExtractionErrorWithHints;
|
const enhanced = base as ExtractionErrorWithHints;
|
||||||
@ -609,6 +615,12 @@ function withExtractionErrorHints(
|
|||||||
if (hints.jvmFailureReason) {
|
if (hints.jvmFailureReason) {
|
||||||
enhanced.jvmFailureReason = hints.jvmFailureReason;
|
enhanced.jvmFailureReason = hints.jvmFailureReason;
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(hints.legacyBestPercent)) {
|
||||||
|
enhanced.legacyBestPercent = Math.max(Number(enhanced.legacyBestPercent || 0), Number(hints.legacyBestPercent || 0));
|
||||||
|
}
|
||||||
|
if (hints.legacyExtractor) {
|
||||||
|
enhanced.legacyExtractor = hints.legacyExtractor;
|
||||||
|
}
|
||||||
return enhanced;
|
return enhanced;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -625,6 +637,37 @@ export function classifyExtractionError(errorText: string): ExtractErrorCategory
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldFallbackLegacyRarToJvm(
|
||||||
|
archivePath: string,
|
||||||
|
configuredMode: ExtractBackendMode,
|
||||||
|
backendMode: ExtractBackendMode,
|
||||||
|
errorText: string,
|
||||||
|
bestPercent = 0,
|
||||||
|
platform = process.platform
|
||||||
|
): boolean {
|
||||||
|
if (configuredMode !== "auto" || backendMode !== "legacy") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(platform || "").toLowerCase() !== "win32") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isRarArchivePath(archivePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = classifyExtractionError(errorText);
|
||||||
|
if (category === "aborted" || category === "timeout" || category === "no_extractor" || category === "missing_parts" || category === "disk_full") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(errorText || "").toLowerCase();
|
||||||
|
if (text.includes("cannot create")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPercent > 0 || category === "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function isExtractAbortError(errorText: string): boolean {
|
function isExtractAbortError(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped");
|
return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped");
|
||||||
@ -1039,10 +1082,33 @@ export function resolveExtractorBackendMode(
|
|||||||
return "auto";
|
return "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveExtractorBackendModeForArchive(
|
||||||
|
archivePath: string,
|
||||||
|
rawValue?: string | null,
|
||||||
|
isVitestEnv = Boolean(process.env.VITEST),
|
||||||
|
platform = process.platform
|
||||||
|
): ExtractBackendMode {
|
||||||
|
const requestedMode = resolveExtractorBackendMode(rawValue, isVitestEnv);
|
||||||
|
if (requestedMode !== "auto") {
|
||||||
|
return requestedMode;
|
||||||
|
}
|
||||||
|
// On Windows, multipart RAR extraction feels significantly snappier with the
|
||||||
|
// native CLI path than with the JVM backend, and we already harden that path
|
||||||
|
// with subst + flat-mode fallback.
|
||||||
|
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
||||||
|
return "legacy";
|
||||||
|
}
|
||||||
|
return requestedMode;
|
||||||
|
}
|
||||||
|
|
||||||
function extractorBackendMode(): ExtractBackendMode {
|
function extractorBackendMode(): ExtractBackendMode {
|
||||||
return resolveExtractorBackendMode(process.env.RD_EXTRACT_BACKEND);
|
return resolveExtractorBackendMode(process.env.RD_EXTRACT_BACKEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractorBackendModeForArchive(archivePath: string): ExtractBackendMode {
|
||||||
|
return resolveExtractorBackendModeForArchive(archivePath, process.env.RD_EXTRACT_BACKEND);
|
||||||
|
}
|
||||||
|
|
||||||
function isJvmRuntimeMissingError(errorText: string): boolean {
|
function isJvmRuntimeMissingError(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("could not find or load main class")
|
return text.includes("could not find or load main class")
|
||||||
@ -1962,14 +2028,15 @@ async function runExternalExtract(
|
|||||||
onLog?: ExtractOptions["onLog"]
|
onLog?: ExtractOptions["onLog"]
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const timeoutMs = await computeExtractTimeoutMs(archivePath);
|
const timeoutMs = await computeExtractTimeoutMs(archivePath);
|
||||||
const backendMode = extractorBackendMode();
|
const configuredBackendMode = extractorBackendMode();
|
||||||
|
const backendMode = extractorBackendModeForArchive(archivePath);
|
||||||
const archiveName = path.basename(archivePath);
|
const archiveName = path.basename(archivePath);
|
||||||
const totalStartedAt = Date.now();
|
const totalStartedAt = Date.now();
|
||||||
let jvmFailureReason = "";
|
let jvmFailureReason = "";
|
||||||
let jvmCodecError = false;
|
let jvmCodecError = false;
|
||||||
let fallbackFromJvm = false;
|
let fallbackFromJvm = false;
|
||||||
logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
||||||
onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
||||||
|
|
||||||
await fs.promises.mkdir(targetDir, { recursive: true });
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
@ -2046,9 +2113,15 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subst only needed for legacy UnRAR/7z (MAX_PATH limit)
|
// Use a short drive mapping for legacy native extractors on Windows.
|
||||||
|
// This avoids MAX_PATH issues and native CLI path handling edge-cases.
|
||||||
subst = createSubstMapping(targetDir);
|
subst = createSubstMapping(targetDir);
|
||||||
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
||||||
|
if (subst) {
|
||||||
|
onLog?.("INFO", `Legacy-Zielpfad verkuerzt via subst: archive=${archiveName}, originalTargetDir=${targetDir}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||||
|
} else {
|
||||||
|
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
const command = await resolveExtractorCommand(archivePath);
|
const command = await resolveExtractorCommand(archivePath);
|
||||||
const legacyStartedAt = Date.now();
|
const legacyStartedAt = Date.now();
|
||||||
@ -2107,22 +2180,22 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (legacyError) {
|
} catch (legacyError) {
|
||||||
const legacyText = String((legacyError as Error)?.message || legacyError || "");
|
const initialLegacyText = String((legacyError as Error)?.message || legacyError || "");
|
||||||
const legacyCategory = classifyExtractionError(legacyText);
|
const initialLegacyCategory = classifyExtractionError(initialLegacyText);
|
||||||
const isCrcOrWrongPw = legacyCategory === "crc_error" || legacyCategory === "wrong_password";
|
const initialLegacyHints = legacyError as ExtractionErrorWithHints;
|
||||||
|
const initialLegacyBestPercent = Number.isFinite(initialLegacyHints.legacyBestPercent)
|
||||||
|
? Number(initialLegacyHints.legacyBestPercent || 0)
|
||||||
|
: 0;
|
||||||
|
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
||||||
|
let finalLegacyError: Error;
|
||||||
|
|
||||||
// ── Retry once after 2s delay ──
|
// Retry once after a short delay to let Windows flush freshly completed archive parts.
|
||||||
// On Windows, freshly completed downloads may still have file handles not
|
|
||||||
// fully released by the OS. Encrypted RAR5 headers are especially sensitive:
|
|
||||||
// even a single unreadable byte causes "Checksum error in the encrypted file"
|
|
||||||
// at bestPercent=0, indistinguishable from a wrong password.
|
|
||||||
// A short delay allows the OS to finalise all handles and flush caches.
|
|
||||||
if (isCrcOrWrongPw && !signal?.aborted) {
|
if (isCrcOrWrongPw && !signal?.aborted) {
|
||||||
const retryDelayMs = 2500;
|
const retryDelayMs = 2500;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Legacy-Extraktion fehlgeschlagen (${legacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`
|
`Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`
|
||||||
);
|
);
|
||||||
onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${legacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`);
|
onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`);
|
||||||
await extractRetryDelay(retryDelayMs);
|
await extractRetryDelay(retryDelayMs);
|
||||||
if (!signal?.aborted) {
|
if (!signal?.aborted) {
|
||||||
try {
|
try {
|
||||||
@ -2146,27 +2219,86 @@ async function runExternalExtract(
|
|||||||
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
||||||
password = retryPassword;
|
password = retryPassword;
|
||||||
usedCommand = retryCmd;
|
usedCommand = retryCmd;
|
||||||
|
const retryExtractorName = path.basename(retryCmd).replace(/\.exe$/i, "");
|
||||||
|
const retryLegacyMs = Date.now() - legacyStartedAt;
|
||||||
|
if (jvmFailureReason) {
|
||||||
|
logger.info(`Entpackt via legacy/${retryExtractorName} (nach JVM-Fehler): ${archiveName}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Entpackt via legacy/${retryExtractorName} (nach Legacy-Retry): ${archiveName}`);
|
||||||
|
}
|
||||||
|
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
||||||
|
onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
||||||
|
return password;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
const retryText = String((retryError as Error)?.message || retryError || "");
|
const retryText = String((retryError as Error)?.message || retryError || "");
|
||||||
const retryCategory = classifyExtractionError(retryText);
|
const retryCategory = classifyExtractionError(retryText);
|
||||||
logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||||||
onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||||||
const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password");
|
const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password");
|
||||||
throw withExtractionErrorHints(retryError, {
|
finalLegacyError = withExtractionErrorHints(retryError, {
|
||||||
suggestRedownload,
|
suggestRedownload,
|
||||||
jvmFailureReason: jvmFailureReason || undefined
|
jvmFailureReason: jvmFailureReason || undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw legacyError;
|
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||||
|
jvmFailureReason: jvmFailureReason || undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const suggestRedownload = jvmCodecError && isCrcOrWrongPw;
|
const suggestRedownload = jvmCodecError && isCrcOrWrongPw;
|
||||||
throw withExtractionErrorHints(legacyError, {
|
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||||
suggestRedownload,
|
suggestRedownload,
|
||||||
jvmFailureReason: jvmFailureReason || undefined
|
jvmFailureReason: jvmFailureReason || undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalLegacyHints = finalLegacyError as ExtractionErrorWithHints;
|
||||||
|
const finalLegacyText = String(finalLegacyError?.message || finalLegacyError || "");
|
||||||
|
const finalLegacyBestPercent = Number.isFinite(finalLegacyHints.legacyBestPercent)
|
||||||
|
? Number(finalLegacyHints.legacyBestPercent || 0)
|
||||||
|
: initialLegacyBestPercent;
|
||||||
|
|
||||||
|
if (!signal?.aborted && shouldFallbackLegacyRarToJvm(archivePath, configuredBackendMode, backendMode, finalLegacyText, finalLegacyBestPercent)) {
|
||||||
|
const layout = resolveJvmExtractorLayout();
|
||||||
|
if (layout) {
|
||||||
|
logger.warn(`Legacy->JVM-Fallback: archive=${archiveName}, bestPercent=${finalLegacyBestPercent}, reason=${cleanErrorText(finalLegacyText)}`);
|
||||||
|
onLog?.("WARN", `Legacy->JVM-Fallback: archive=${archiveName}, bestPercent=${finalLegacyBestPercent}, reason=${cleanErrorText(finalLegacyText)}`);
|
||||||
|
const jvmStartedAt = Date.now();
|
||||||
|
const jvmResult = await runJvmExtractCommand(
|
||||||
|
layout,
|
||||||
|
archivePath,
|
||||||
|
targetDir,
|
||||||
|
conflictMode,
|
||||||
|
passwordCandidates,
|
||||||
|
onArchiveProgress,
|
||||||
|
signal,
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
const jvmMs = Date.now() - jvmStartedAt;
|
||||||
|
logger.info(`JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
||||||
|
onLog?.("INFO", `JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
||||||
|
if (jvmResult.ok) {
|
||||||
|
logger.info(`Entpackt via ${jvmResult.backend || "jvm"} (nach Legacy-Fallback): ${archiveName}`);
|
||||||
|
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
||||||
|
onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
||||||
|
return jvmResult.usedPassword;
|
||||||
|
}
|
||||||
|
if (jvmResult.aborted) {
|
||||||
|
throw new Error("aborted:extract");
|
||||||
|
}
|
||||||
|
finalLegacyError = withExtractionErrorHints(finalLegacyError, {
|
||||||
|
jvmFailureReason: jvmResult.errorText || "JVM-Extractor fehlgeschlagen"
|
||||||
|
});
|
||||||
|
logger.warn(`Legacy->JVM-Fallback ebenfalls fehlgeschlagen: ${archiveName} (${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")})`);
|
||||||
|
onLog?.("WARN", `Legacy->JVM-Fallback ebenfalls fehlgeschlagen: archive=${archiveName}, error=${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Legacy->JVM-Fallback uebersprungen: JVM-Extractor nicht verfuegbar fuer ${archiveName}`);
|
||||||
|
onLog?.("WARN", `Legacy->JVM-Fallback uebersprungen: archive=${archiveName}, reason=no_jvm_extractor`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw finalLegacyError;
|
||||||
}
|
}
|
||||||
const legacyMs = Date.now() - legacyStartedAt;
|
const legacyMs = Date.now() - legacyStartedAt;
|
||||||
const extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
const extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
||||||
@ -2211,6 +2343,8 @@ async function runExternalExtractInner(
|
|||||||
let passwordAttempt = 0;
|
let passwordAttempt = 0;
|
||||||
let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags();
|
let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags();
|
||||||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText).slice(0, 280);
|
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText).slice(0, 280);
|
||||||
|
let createErrorText = "";
|
||||||
|
let createErrorPassword = "";
|
||||||
|
|
||||||
// Skip normal extraction loop if flat mode is already known to be needed for this package
|
// Skip normal extraction loop if flat mode is already known to be needed for this package
|
||||||
if (forceFlatMode) {
|
if (forceFlatMode) {
|
||||||
@ -2236,7 +2370,7 @@ async function runExternalExtractInner(
|
|||||||
if (result.timedOut || result.missingCommand) break;
|
if (result.timedOut || result.missingCommand) break;
|
||||||
lastError = result.errorText;
|
lastError = result.errorText;
|
||||||
}
|
}
|
||||||
throw new Error(lastError || "Entpacken fehlgeschlagen (flat-mode)");
|
throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen (flat-mode)"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const password of passwords) {
|
for (const password of passwords) {
|
||||||
@ -2305,6 +2439,13 @@ async function runExternalExtractInner(
|
|||||||
return password;
|
return password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!createErrorText && result.errorText.includes("Cannot create")) {
|
||||||
|
createErrorText = result.errorText;
|
||||||
|
createErrorPassword = password;
|
||||||
|
logger.warn(`Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=${quotedPw}`);
|
||||||
|
onLog?.("WARN", `Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=${quotedPw}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.aborted) {
|
if (result.aborted) {
|
||||||
throw new Error("aborted:extract");
|
throw new Error("aborted:extract");
|
||||||
}
|
}
|
||||||
@ -2318,7 +2459,7 @@ async function runExternalExtractInner(
|
|||||||
resolvedExtractorCommand = null;
|
resolvedExtractorCommand = null;
|
||||||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||||||
resolveFailureAt = Date.now();
|
resolveFailureAt = Date.now();
|
||||||
throw new Error(NO_EXTRACTOR_MESSAGE);
|
throw withExtractionErrorHints(new Error(NO_EXTRACTOR_MESSAGE), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = result.errorText;
|
lastError = result.errorText;
|
||||||
@ -2327,16 +2468,22 @@ async function runExternalExtractInner(
|
|||||||
// Some archives (e.g. created by certain scene groups) store internal paths with a leading \,
|
// Some archives (e.g. created by certain scene groups) store internal paths with a leading \,
|
||||||
// causing UnRAR to construct invalid \\ double-separator paths on Windows. Retry in flat mode
|
// causing UnRAR to construct invalid \\ double-separator paths on Windows. Retry in flat mode
|
||||||
// ("e" instead of "x") which strips all archive paths and extracts files directly to targetDir.
|
// ("e" instead of "x") which strips all archive paths and extracts files directly to targetDir.
|
||||||
const isAbsoluteArchivePath = lastError.includes("Cannot create") && lastError.includes("\\\\");
|
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
||||||
if (isAbsoluteArchivePath) {
|
if (pathCreateError) {
|
||||||
logger.warn(`Entpack-Pfadfehler: absoluter Archivpfad erkannt, Wiederholung mit flachem Modus: ${path.basename(archivePath)}`);
|
const flatPasswords = createErrorPassword
|
||||||
|
? prioritizePassword(passwords, createErrorPassword)
|
||||||
|
: passwords;
|
||||||
|
logger.warn(`Entpack-Pfadfehler: Wiederholung mit flachem Modus: ${path.basename(archivePath)}`);
|
||||||
|
onLog?.("WARN", `Entpack-Pfadfehler: Wiederholung mit flachem Modus: ${path.basename(archivePath)}`);
|
||||||
bestPercent = 0;
|
bestPercent = 0;
|
||||||
passwordAttempt = 0;
|
passwordAttempt = 0;
|
||||||
for (const password of passwords) {
|
lastError = pathCreateError;
|
||||||
|
for (const password of flatPasswords) {
|
||||||
if (signal?.aborted) throw new Error("aborted:extract");
|
if (signal?.aborted) throw new Error("aborted:extract");
|
||||||
passwordAttempt += 1;
|
passwordAttempt += 1;
|
||||||
const quotedPw = password === "" ? '""' : `"${password}"`;
|
const quotedPw = password === "" ? '""' : `"${password}"`;
|
||||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`);
|
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`);
|
||||||
|
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=${quotedPw}`);
|
||||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||||||
const result = await runExtractCommand(command, args, (chunk) => {
|
const result = await runExtractCommand(command, args, (chunk) => {
|
||||||
const parsed = parseProgressPercent(chunk);
|
const parsed = parseProgressPercent(chunk);
|
||||||
@ -2345,6 +2492,7 @@ async function runExternalExtractInner(
|
|||||||
if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); }
|
if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); }
|
||||||
}, signal, timeoutMs);
|
}, signal, timeoutMs);
|
||||||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||||||
|
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||||||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||||||
if (result.aborted) throw new Error("aborted:extract");
|
if (result.aborted) throw new Error("aborted:extract");
|
||||||
if (result.timedOut || result.missingCommand) break;
|
if (result.timedOut || result.missingCommand) break;
|
||||||
@ -2352,7 +2500,7 @@ async function runExternalExtractInner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(lastError || "Entpacken fehlgeschlagen");
|
throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delay helper for extraction retries (allows file handles to be released on Windows)
|
// Delay helper for extraction retries (allows file handles to be released on Windows)
|
||||||
|
|||||||
@ -464,6 +464,8 @@ function registerIpcHandlers(): void {
|
|||||||
return result.canceled ? [] : result.filePaths;
|
return result.canceled ? [] : result.filePaths;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
|
|||||||
@ -298,6 +298,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
||||||
"sum"
|
"sum"
|
||||||
);
|
);
|
||||||
|
const providerTotalUsageBytes = normalizeProviderByteMap(
|
||||||
|
settings.providerTotalUsageBytes,
|
||||||
|
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
||||||
|
"sum"
|
||||||
|
);
|
||||||
const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap(
|
const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap(
|
||||||
settings.debridLinkApiKeyDailyLimitBytes,
|
settings.debridLinkApiKeyDailyLimitBytes,
|
||||||
debridLinkApiKeyIds
|
debridLinkApiKeyIds
|
||||||
@ -306,6 +311,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
settings.debridLinkApiKeyDailyUsageBytes,
|
settings.debridLinkApiKeyDailyUsageBytes,
|
||||||
debridLinkApiKeyIds
|
debridLinkApiKeyIds
|
||||||
);
|
);
|
||||||
|
const debridLinkApiKeyTotalUsageBytes = normalizeNamedByteMap(
|
||||||
|
settings.debridLinkApiKeyTotalUsageBytes,
|
||||||
|
debridLinkApiKeyIds
|
||||||
|
);
|
||||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
||||||
const normalized: AppSettings = {
|
const normalized: AppSettings = {
|
||||||
token: asText(settings.token),
|
token: asText(settings.token),
|
||||||
@ -374,6 +383,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
||||||
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
||||||
|
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
||||||
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
||||||
@ -387,8 +397,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
"max"
|
"max"
|
||||||
),
|
),
|
||||||
providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {},
|
providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {},
|
||||||
|
providerTotalUsageBytes,
|
||||||
debridLinkApiKeyDailyLimitBytes,
|
debridLinkApiKeyDailyLimitBytes,
|
||||||
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
|
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
|
||||||
|
debridLinkApiKeyTotalUsageBytes,
|
||||||
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
||||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
||||||
};
|
};
|
||||||
|
|||||||
@ -50,6 +50,8 @@ const api: ElectronApi = {
|
|||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||||
|
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||||
|
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||||
|
|||||||
@ -19,11 +19,13 @@ import type {
|
|||||||
UpdateInstallProgress
|
UpdateInstallProgress
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import {
|
import {
|
||||||
|
getDebridLinkApiKeyTotalUsageBytes,
|
||||||
getDebridLinkApiKeyDailyLimitBytes,
|
getDebridLinkApiKeyDailyLimitBytes,
|
||||||
getDebridLinkApiKeyDailyRemainingBytes,
|
getDebridLinkApiKeyDailyRemainingBytes,
|
||||||
getDebridLinkApiKeyDailyUsageBytes,
|
getDebridLinkApiKeyDailyUsageBytes,
|
||||||
getProviderDailyLimitBytes,
|
getProviderDailyLimitBytes,
|
||||||
getProviderDailyRemainingBytes,
|
getProviderDailyRemainingBytes,
|
||||||
|
getProviderTotalUsageBytes,
|
||||||
getProviderDailyUsageBytes,
|
getProviderDailyUsageBytes,
|
||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
@ -110,6 +112,7 @@ interface DebridLinkAccountKeyEntry {
|
|||||||
masked: string;
|
masked: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
dailyUsedBytes: number;
|
dailyUsedBytes: number;
|
||||||
|
totalUsedBytes: number;
|
||||||
dailyLimitBytes: number;
|
dailyLimitBytes: number;
|
||||||
dailyRemainingBytes: number | null;
|
dailyRemainingBytes: number | null;
|
||||||
dailyLimitReached: boolean;
|
dailyLimitReached: boolean;
|
||||||
@ -127,6 +130,7 @@ interface ConfiguredAccountEntry {
|
|||||||
note: string;
|
note: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
dailyUsedBytes: number;
|
dailyUsedBytes: number;
|
||||||
|
totalUsedBytes: number;
|
||||||
dailyLimitBytes: number;
|
dailyLimitBytes: number;
|
||||||
dailyRemainingBytes: number | null;
|
dailyRemainingBytes: number | null;
|
||||||
dailyLimitReached: boolean;
|
dailyLimitReached: boolean;
|
||||||
@ -686,7 +690,8 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
|
|||||||
const emptyStats = (): DownloadStats => ({
|
const emptyStats = (): DownloadStats => ({
|
||||||
totalDownloaded: 0,
|
totalDownloaded: 0,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
totalFiles: 0,
|
totalFilesSession: 0,
|
||||||
|
totalFilesAllTime: 0,
|
||||||
totalPackages: 0,
|
totalPackages: 0,
|
||||||
sessionStartedAt: 0
|
sessionStartedAt: 0
|
||||||
});
|
});
|
||||||
@ -707,15 +712,17 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||||
theme: "dark", collapseNewPackages: true, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true,
|
theme: "dark", collapseNewPackages: true, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0,
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
autoExtractWhenStopped: true,
|
autoExtractWhenStopped: true,
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
hosterRouting: {},
|
hosterRouting: {},
|
||||||
providerDailyLimitBytes: {},
|
providerDailyLimitBytes: {},
|
||||||
providerDailyUsageBytes: {},
|
providerDailyUsageBytes: {},
|
||||||
|
providerTotalUsageBytes: {},
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
debridLinkApiKeyDailyLimitBytes: {},
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
debridLinkApiKeyDailyUsageBytes: {},
|
||||||
|
debridLinkApiKeyTotalUsageBytes: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
},
|
},
|
||||||
@ -1163,11 +1170,11 @@ type PkgSortColumn = "name" | "size" | "hoster" | "progress";
|
|||||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
||||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"];
|
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"];
|
||||||
const COLUMN_DEFS: Record<string, { label: string; width: string; sortable?: PkgSortColumn }> = {
|
const COLUMN_DEFS: Record<string, { label: string; width: string; sortable?: PkgSortColumn }> = {
|
||||||
name: { label: "Name", width: "1fr", sortable: "name" },
|
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
|
||||||
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
|
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
|
||||||
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
|
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
|
||||||
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
|
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
|
||||||
account: { label: "Service", width: "110px" },
|
account: { label: "Service", width: "132px" },
|
||||||
prio: { label: "Priorität", width: "70px" },
|
prio: { label: "Priorität", width: "70px" },
|
||||||
status: { label: "Status", width: "160px" },
|
status: { label: "Status", width: "160px" },
|
||||||
speed: { label: "Geschwindigkeit", width: "90px" },
|
speed: { label: "Geschwindigkeit", width: "90px" },
|
||||||
@ -1252,6 +1259,8 @@ export function App(): ReactElement {
|
|||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null);
|
||||||
|
const [providerDropTarget, setProviderDropTarget] = useState<DebridProvider | null>(null);
|
||||||
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([
|
const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([
|
||||||
@ -1946,6 +1955,43 @@ export function App(): ReactElement {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onProviderDragStart = useCallback((event: DragEvent<HTMLDivElement>, provider: DebridProvider): void => {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", provider);
|
||||||
|
setDraggedProvider(provider);
|
||||||
|
setProviderDropTarget(provider);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onProviderDragOver = useCallback((event: DragEvent<HTMLDivElement>, provider: DebridProvider): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
if (providerDropTarget !== provider) {
|
||||||
|
setProviderDropTarget(provider);
|
||||||
|
}
|
||||||
|
}, [providerDropTarget]);
|
||||||
|
|
||||||
|
const onProviderDrop = useCallback((event: DragEvent<HTMLDivElement>, provider: DebridProvider): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!draggedProvider || draggedProvider === provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOrder = [...activeProviderOrder];
|
||||||
|
const fromIndex = currentOrder.indexOf(draggedProvider);
|
||||||
|
const toIndex = currentOrder.indexOf(provider);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentOrder.splice(fromIndex, 1);
|
||||||
|
currentOrder.splice(toIndex, 0, draggedProvider);
|
||||||
|
setProviderOrder(currentOrder);
|
||||||
|
setProviderDropTarget(provider);
|
||||||
|
}, [activeProviderOrder, draggedProvider, setProviderOrder]);
|
||||||
|
|
||||||
|
const onProviderDragEnd = useCallback((): void => {
|
||||||
|
setDraggedProvider(null);
|
||||||
|
setProviderDropTarget(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const normalizedSettingsDraft: AppSettings = useMemo(() => ({
|
const normalizedSettingsDraft: AppSettings = useMemo(() => ({
|
||||||
...settingsDraft,
|
...settingsDraft,
|
||||||
...normalizeProviderSelectionForSettings(settingsDraft)
|
...normalizeProviderSelectionForSettings(settingsDraft)
|
||||||
@ -1989,6 +2035,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
const provider = getAccountServiceProvider(service);
|
const provider = getAccountServiceProvider(service);
|
||||||
const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider);
|
const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider);
|
||||||
|
const totalUsedBytes = getProviderTotalUsageBytes(snapshot.settings, provider);
|
||||||
const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider);
|
const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider);
|
||||||
const dailyRemainingBytes = getProviderDailyRemainingBytes({
|
const dailyRemainingBytes = getProviderDailyRemainingBytes({
|
||||||
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
|
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
|
||||||
@ -2015,6 +2062,7 @@ export function App(): ReactElement {
|
|||||||
masked: key.masked,
|
masked: key.masked,
|
||||||
disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id),
|
disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id),
|
||||||
dailyUsedBytes: keyDailyUsedBytes,
|
dailyUsedBytes: keyDailyUsedBytes,
|
||||||
|
totalUsedBytes: getDebridLinkApiKeyTotalUsageBytes(snapshot.settings, key.id),
|
||||||
dailyLimitBytes: keyDailyLimitBytes,
|
dailyLimitBytes: keyDailyLimitBytes,
|
||||||
dailyRemainingBytes: keyDailyRemainingBytes,
|
dailyRemainingBytes: keyDailyRemainingBytes,
|
||||||
dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes
|
dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes
|
||||||
@ -2056,6 +2104,7 @@ export function App(): ReactElement {
|
|||||||
note,
|
note,
|
||||||
disabled: isDisabled,
|
disabled: isDisabled,
|
||||||
dailyUsedBytes,
|
dailyUsedBytes,
|
||||||
|
totalUsedBytes,
|
||||||
dailyLimitBytes,
|
dailyLimitBytes,
|
||||||
dailyRemainingBytes,
|
dailyRemainingBytes,
|
||||||
dailyLimitReached,
|
dailyLimitReached,
|
||||||
@ -2238,7 +2287,9 @@ export function App(): ReactElement {
|
|||||||
totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime),
|
totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime),
|
||||||
providerDailyUsageDay: result.providerDailyUsageDay,
|
providerDailyUsageDay: result.providerDailyUsageDay,
|
||||||
providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) },
|
providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) },
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) }
|
providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) },
|
||||||
|
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) },
|
||||||
|
debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) }
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2973,7 +3024,94 @@ export function App(): ReactElement {
|
|||||||
}, [selectedIds, snapshot.session.packages, showToast]);
|
}, [selectedIds, snapshot.session.packages, showToast]);
|
||||||
|
|
||||||
const onPackageToggle = useCallback((packageId: string): void => {
|
const onPackageToggle = useCallback((packageId: string): void => {
|
||||||
|
let previousEnabled: boolean | null = null;
|
||||||
|
setSnapshot((prev) => {
|
||||||
|
const pkg = prev.session.packages[packageId];
|
||||||
|
if (!pkg) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
previousEnabled = pkg.enabled;
|
||||||
|
const nextEnabled = !pkg.enabled;
|
||||||
|
const nextItems = { ...prev.session.items };
|
||||||
|
if (!nextEnabled) {
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = nextItems[itemId];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||||
|
nextItems[itemId] = {
|
||||||
|
...item,
|
||||||
|
fullStatus: "Paket gestoppt",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = nextItems[itemId];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
|
||||||
|
nextItems[itemId] = {
|
||||||
|
...item,
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextPkgStatus = !nextEnabled
|
||||||
|
? (pkg.status === "downloading" || pkg.status === "extracting" ? "paused" : pkg.status)
|
||||||
|
: (pkg.status === "paused" ? "queued" : pkg.status);
|
||||||
|
const nextSnapshot: UiSnapshot = {
|
||||||
|
...prev,
|
||||||
|
session: {
|
||||||
|
...prev.session,
|
||||||
|
items: nextItems,
|
||||||
|
packages: {
|
||||||
|
...prev.session.packages,
|
||||||
|
[packageId]: {
|
||||||
|
...pkg,
|
||||||
|
enabled: nextEnabled,
|
||||||
|
status: nextPkgStatus,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
latestStateRef.current = nextSnapshot;
|
||||||
|
return nextSnapshot;
|
||||||
|
});
|
||||||
void window.rd.togglePackage(packageId).catch((error) => {
|
void window.rd.togglePackage(packageId).catch((error) => {
|
||||||
|
if (previousEnabled !== null) {
|
||||||
|
setSnapshot((prev) => {
|
||||||
|
const pkg = prev.session.packages[packageId];
|
||||||
|
if (!pkg) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const revertedSnapshot: UiSnapshot = {
|
||||||
|
...prev,
|
||||||
|
session: {
|
||||||
|
...prev.session,
|
||||||
|
packages: {
|
||||||
|
...prev.session.packages,
|
||||||
|
[packageId]: {
|
||||||
|
...pkg,
|
||||||
|
enabled: previousEnabled,
|
||||||
|
status: previousEnabled && pkg.status === "paused" ? "queued" : pkg.status,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
latestStateRef.current = revertedSnapshot;
|
||||||
|
return revertedSnapshot;
|
||||||
|
});
|
||||||
|
}
|
||||||
showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400);
|
showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
@ -3913,6 +4051,7 @@ export function App(): ReactElement {
|
|||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
items={itemsByPackage.get(pkg.id) ?? []}
|
items={itemsByPackage.get(pkg.id) ?? []}
|
||||||
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
|
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
|
||||||
|
stripeVariant={idx % 2 === 0 ? "a" : "b"}
|
||||||
isFirst={idx === 0}
|
isFirst={idx === 0}
|
||||||
isLast={idx === visiblePackages.length - 1}
|
isLast={idx === visiblePackages.length - 1}
|
||||||
isEditing={editingPackageId === pkg.id}
|
isEditing={editingPackageId === pkg.id}
|
||||||
@ -4064,6 +4203,22 @@ export function App(): ReactElement {
|
|||||||
<section className="statistics-view">
|
<section className="statistics-view">
|
||||||
<article className="card stats-overview">
|
<article className="card stats-overview">
|
||||||
<h3>Session-Übersicht</h3>
|
<h3>Session-Übersicht</h3>
|
||||||
|
<div className="stats-actions">
|
||||||
|
<button className="btn btn-sm" onClick={() => {
|
||||||
|
void window.rd.resetSessionStats().then(() => {
|
||||||
|
showToast("Session-Statistik zurückgesetzt", 1800);
|
||||||
|
}).catch((error) => {
|
||||||
|
showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}}>Session zurücksetzen</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => {
|
||||||
|
void window.rd.resetDownloadStats().then(() => {
|
||||||
|
showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800);
|
||||||
|
}).catch((error) => {
|
||||||
|
showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}}>Heruntergeladen zurücksetzen</button>
|
||||||
|
</div>
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">Aktuelle Geschwindigkeit</span>
|
<span className="stat-label">Aktuelle Geschwindigkeit</span>
|
||||||
@ -4078,8 +4233,12 @@ export function App(): ReactElement {
|
|||||||
<span className="stat-value">{humanSize(snapshot.stats.totalDownloadedAllTime)}</span>
|
<span className="stat-value">{humanSize(snapshot.stats.totalDownloadedAllTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">Fertige Dateien</span>
|
<span className="stat-label">Fertige Dateien (Gesamt)</span>
|
||||||
<span className="stat-value">{snapshot.stats.totalFiles}</span>
|
<span className="stat-value">{snapshot.stats.totalFilesAllTime}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Fertige Dateien (Session)</span>
|
||||||
|
<span className="stat-value">{snapshot.stats.totalFilesSession}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">Pakete</span>
|
<span className="stat-label">Pakete</span>
|
||||||
@ -4310,10 +4469,14 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="account-cell account-info-cell">
|
<div className="account-cell account-info-cell">
|
||||||
{entry.debridLinkKeys.length > 0 ? (
|
{entry.debridLinkKeys.length > 0 ? (
|
||||||
|
<div className="account-usage-stack">
|
||||||
<button className="btn btn-sm" onClick={() => setKeyStatsPopup(entry.service)}>
|
<button className="btn btn-sm" onClick={() => setKeyStatsPopup(entry.service)}>
|
||||||
Statistik
|
Statistik
|
||||||
</button>
|
</button>
|
||||||
|
<span className="account-usage-total">Insgesamt: {humanSize(entry.totalUsedBytes)}</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="account-usage-stack">
|
||||||
<div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}>
|
<div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}>
|
||||||
<span>Heute: {humanSize(entry.dailyUsedBytes)}</span>
|
<span>Heute: {humanSize(entry.dailyUsedBytes)}</span>
|
||||||
<span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span>
|
<span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span>
|
||||||
@ -4321,6 +4484,8 @@ export function App(): ReactElement {
|
|||||||
<span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span>
|
<span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="account-usage-total">Insgesamt: {humanSize(entry.totalUsedBytes)}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="account-cell">
|
<div className="account-cell">
|
||||||
@ -4380,7 +4545,15 @@ export function App(): ReactElement {
|
|||||||
{activeProviderOrder.length > 0 && (
|
{activeProviderOrder.length > 0 && (
|
||||||
<div className="provider-order-list">
|
<div className="provider-order-list">
|
||||||
{activeProviderOrder.map((provider, idx) => (
|
{activeProviderOrder.map((provider, idx) => (
|
||||||
<div key={provider} className="provider-order-row">
|
<div
|
||||||
|
key={provider}
|
||||||
|
className={`provider-order-row${draggedProvider === provider ? " dragging" : ""}${providerDropTarget === provider && draggedProvider !== provider ? " drag-target" : ""}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => onProviderDragStart(event, provider)}
|
||||||
|
onDragOver={(event) => onProviderDragOver(event, provider)}
|
||||||
|
onDrop={(event) => onProviderDrop(event, provider)}
|
||||||
|
onDragEnd={onProviderDragEnd}
|
||||||
|
>
|
||||||
<span className="provider-order-num">{idx + 1}.</span>
|
<span className="provider-order-num">{idx + 1}.</span>
|
||||||
<span className="provider-order-label">{providerLabelWithMode(provider, settingsDraft)}</span>
|
<span className="provider-order-label">{providerLabelWithMode(provider, settingsDraft)}</span>
|
||||||
<div className="provider-order-actions">
|
<div className="provider-order-actions">
|
||||||
@ -5109,7 +5282,7 @@ export function App(): ReactElement {
|
|||||||
<div className="ctx-menu-sep" />
|
<div className="ctx-menu-sep" />
|
||||||
{hasPackages && !contextMenu.itemId && (
|
{hasPackages && !contextMenu.itemId && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" onClick={() => {
|
||||||
for (const id of selectedIds) { if (snapshot.session.packages[id]) void window.rd.togglePackage(id).catch(() => {}); }
|
for (const id of selectedIds) { if (snapshot.session.packages[id]) onPackageToggle(id); }
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}>
|
}}>
|
||||||
{multi ? `Alle ${selectedIds.size} umschalten` : (snapshot.session.packages[contextMenu.packageId]?.enabled ? "Deaktivieren" : "Aktivieren")}
|
{multi ? `Alle ${selectedIds.size} umschalten` : (snapshot.session.packages[contextMenu.packageId]?.enabled ? "Deaktivieren" : "Aktivieren")}
|
||||||
@ -5157,13 +5330,13 @@ export function App(): ReactElement {
|
|||||||
{hasPackages && !contextMenu.itemId && (<>
|
{hasPackages && !contextMenu.itemId && (<>
|
||||||
<div className="ctx-menu-sep" />
|
<div className="ctx-menu-sep" />
|
||||||
<div className="ctx-menu-sub">
|
<div className="ctx-menu-sub">
|
||||||
<button className="ctx-menu-item">Priorität ?</button>
|
<button className="ctx-menu-item">Priorität ></button>
|
||||||
<div className="ctx-menu-sub-items">
|
<div className="ctx-menu-sub-items">
|
||||||
{(["high", "normal", "low"] as const).map((p) => {
|
{(["high", "normal", "low"] as const).map((p) => {
|
||||||
const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard";
|
const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard";
|
||||||
const pkgIds = selectedPackageIds;
|
const pkgIds = selectedPackageIds;
|
||||||
const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p);
|
const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p);
|
||||||
return <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p).catch(() => {}); setContextMenu(null); }}>{allMatch ? `? ${label}` : label}</button>;
|
return <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p).catch(() => {}); setContextMenu(null); }}>{allMatch ? `[Aktiv] ${label}` : label}</button>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -5395,6 +5568,7 @@ interface PackageCardProps {
|
|||||||
pkg: PackageEntry;
|
pkg: PackageEntry;
|
||||||
items: DownloadItem[];
|
items: DownloadItem[];
|
||||||
packageSpeed: number;
|
packageSpeed: number;
|
||||||
|
stripeVariant: "a" | "b";
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
@ -5422,7 +5596,7 @@ interface PackageCardProps {
|
|||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||||
const done = items.filter((item) => item.status === "completed").length;
|
const done = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
@ -5460,7 +5634,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`package-card queue-package-card${pkg.enabled ? "" : " disabled-pkg"}${selectedIds.has(pkg.id) ? " pkg-selected" : ""}`}
|
className={`package-card queue-package-card pkg-stripe-${stripeVariant}${pkg.enabled ? "" : " disabled-pkg"}${selectedIds.has(pkg.id) ? " pkg-selected" : ""}`}
|
||||||
draggable
|
draggable
|
||||||
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, undefined, e.clientX, e.clientY); }}
|
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, undefined, e.clientX, e.clientY); }}
|
||||||
onClick={(e) => { if (e.ctrlKey || e.shiftKey) onSelect(pkg.id, e.ctrlKey, e.shiftKey); }}
|
onClick={(e) => { if (e.ctrlKey || e.shiftKey) onSelect(pkg.id, e.ctrlKey, e.shiftKey); }}
|
||||||
@ -5527,7 +5701,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
<span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span>
|
<span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span>
|
||||||
);
|
);
|
||||||
case "status": return (
|
case "status": return (
|
||||||
<span key={col} className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` ? ${failed} Fehler` : ""}{cancelled > 0 ? ` ? ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""}</span>
|
<span key={col} className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` | ${failed} Fehler` : ""}{cancelled > 0 ? ` | ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""}</span>
|
||||||
);
|
);
|
||||||
case "speed": return (
|
case "speed": return (
|
||||||
<span key={col} className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""}</span>
|
<span key={col} className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""}</span>
|
||||||
|
|||||||
@ -1336,6 +1336,15 @@ body,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-usage-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.account-service-cell strong {
|
.account-service-cell strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@ -1378,6 +1387,12 @@ body,
|
|||||||
color: color-mix(in srgb, #f59e0b 78%, white 8%);
|
color: color-mix(in srgb, #f59e0b 78%, white 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-usage-total {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.account-mode-pill,
|
.account-mode-pill,
|
||||||
.account-status-pill {
|
.account-status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -1703,6 +1718,19 @@ body,
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
cursor: grab;
|
||||||
|
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-order-row.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-order-row.drag-target {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 65%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, rgba(255, 255, 255, 0.04));
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.provider-order-num {
|
.provider-order-num {
|
||||||
@ -2051,6 +2079,14 @@ body,
|
|||||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-package-card.pkg-stripe-a {
|
||||||
|
background: color-mix(in srgb, var(--surface) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-package-card.pkg-stripe-b {
|
||||||
|
background: color-mix(in srgb, var(--card) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.queue-package-card:hover {
|
.queue-package-card:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 3%, transparent);
|
background: color-mix(in srgb, var(--accent) 3%, transparent);
|
||||||
}
|
}
|
||||||
@ -2444,6 +2480,13 @@ td {
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export const IPC_CHANNELS = {
|
|||||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||||
GET_SESSION_STATS: "stats:get-session-stats",
|
GET_SESSION_STATS: "stats:get-session-stats",
|
||||||
|
RESET_SESSION_STATS: "stats:reset-session",
|
||||||
|
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||||
RESTART: "app:restart",
|
RESTART: "app:restart",
|
||||||
QUIT: "app:quit",
|
QUIT: "app:quit",
|
||||||
EXPORT_BACKUP: "app:export-backup",
|
EXPORT_BACKUP: "app:export-backup",
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export interface ElectronApi {
|
|||||||
pickFolder: () => Promise<string | null>;
|
pickFolder: () => Promise<string | null>;
|
||||||
pickContainers: () => Promise<string[]>;
|
pickContainers: () => Promise<string[]>;
|
||||||
getSessionStats: () => Promise<SessionStats>;
|
getSessionStats: () => Promise<SessionStats>;
|
||||||
|
resetSessionStats: () => Promise<void>;
|
||||||
|
resetDownloadStats: () => Promise<void>;
|
||||||
restart: () => Promise<void>;
|
restart: () => Promise<void>;
|
||||||
quit: () => Promise<void>;
|
quit: () => Promise<void>;
|
||||||
exportBackup: () => Promise<{ saved: boolean }>;
|
exportBackup: () => Promise<{ saved: boolean }>;
|
||||||
|
|||||||
@ -7,6 +7,10 @@ type ProviderDailySettings =
|
|||||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>;
|
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>;
|
||||||
|
|
||||||
|
type ProviderUsageSettings =
|
||||||
|
ProviderDailySettings
|
||||||
|
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>;
|
||||||
|
|
||||||
function normalizePositiveBytes(value: unknown): number {
|
function normalizePositiveBytes(value: unknown): number {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
@ -59,6 +63,10 @@ export function isProviderDailyLimitReached(
|
|||||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
||||||
|
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
||||||
|
}
|
||||||
|
|
||||||
export function resetProviderDailyUsage(
|
export function resetProviderDailyUsage(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
provider?: DebridProvider,
|
provider?: DebridProvider,
|
||||||
@ -110,6 +118,26 @@ export function addProviderDailyUsageBytes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addProviderTotalUsageBytes(
|
||||||
|
settings: ProviderUsageSettings,
|
||||||
|
provider: DebridProvider,
|
||||||
|
byteDelta: number
|
||||||
|
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
||||||
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
|
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
||||||
|
if (increment <= 0) {
|
||||||
|
return {
|
||||||
|
providerTotalUsageBytes: currentUsageBytes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerTotalUsageBytes: currentUsageBytes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
||||||
}
|
}
|
||||||
@ -146,6 +174,10 @@ export function isDebridLinkApiKeyDailyLimitReached(
|
|||||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
||||||
|
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
||||||
|
}
|
||||||
|
|
||||||
export function resetDebridLinkApiKeyDailyUsage(
|
export function resetDebridLinkApiKeyDailyUsage(
|
||||||
settings: ProviderDailySettings,
|
settings: ProviderDailySettings,
|
||||||
keyId?: string,
|
keyId?: string,
|
||||||
@ -195,3 +227,23 @@ export function addDebridLinkApiKeyDailyUsageBytes(
|
|||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addDebridLinkApiKeyTotalUsageBytes(
|
||||||
|
settings: ProviderUsageSettings,
|
||||||
|
keyId: string,
|
||||||
|
byteDelta: number
|
||||||
|
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
||||||
|
const increment = normalizePositiveBytes(byteDelta);
|
||||||
|
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
||||||
|
if (increment <= 0) {
|
||||||
|
return {
|
||||||
|
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||||
|
|
||||||
|
return {
|
||||||
|
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -41,7 +41,8 @@ export interface BandwidthScheduleEntry {
|
|||||||
export interface DownloadStats {
|
export interface DownloadStats {
|
||||||
totalDownloaded: number;
|
totalDownloaded: number;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalFiles: number;
|
totalFilesSession: number;
|
||||||
|
totalFilesAllTime: number;
|
||||||
totalPackages: number;
|
totalPackages: number;
|
||||||
sessionStartedAt: number;
|
sessionStartedAt: number;
|
||||||
}
|
}
|
||||||
@ -108,6 +109,7 @@ export interface AppSettings {
|
|||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
|
totalCompletedFilesAllTime: number;
|
||||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||||
columnOrder: string[];
|
columnOrder: string[];
|
||||||
extractCpuPriority: ExtractCpuPriority;
|
extractCpuPriority: ExtractCpuPriority;
|
||||||
@ -116,8 +118,10 @@ export interface AppSettings {
|
|||||||
hosterRouting: Record<string, DebridProvider>;
|
hosterRouting: Record<string, DebridProvider>;
|
||||||
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
||||||
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||||
|
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||||
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
||||||
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
||||||
|
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
|
||||||
providerDailyUsageDay: string;
|
providerDailyUsageDay: string;
|
||||||
scheduledStartEpochMs: number;
|
scheduledStartEpochMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -551,6 +551,28 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS");
|
expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps compact code 319a to episode 19 in season 3 folder", () => {
|
||||||
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
|
[
|
||||||
|
"Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs"
|
||||||
|
],
|
||||||
|
"hrs-bpol.hdtv.7p-319a",
|
||||||
|
{ forceEpisodeForSeasonFolder: true }
|
||||||
|
);
|
||||||
|
expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E19.GERMAN.AC3.720p.HDTV.x264-hrs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps compact code 319b to next episode in season 3 folder", () => {
|
||||||
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
|
[
|
||||||
|
"Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs"
|
||||||
|
],
|
||||||
|
"hrs-bpol.hdtv.7p-319b",
|
||||||
|
{ forceEpisodeForSeasonFolder: true }
|
||||||
|
);
|
||||||
|
expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E20.GERMAN.AC3.720p.HDTV.x264-hrs");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps episode-only token e01 via season folder hint and keeps REPACK", () => {
|
it("maps episode-only token e01 via season folder hint and keeps REPACK", () => {
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -324,6 +324,361 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(256 * 1024, 21);
|
||||||
|
const pkgDir = path.join(root, "downloads", "resume-ignored");
|
||||||
|
fs.mkdirSync(pkgDir, { recursive: true });
|
||||||
|
const existingTargetPath = path.join(pkgDir, "resume-ignored.mkv");
|
||||||
|
const partialSize = 96 * 1024;
|
||||||
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
||||||
|
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
let ignoredRangeCalls = 0;
|
||||||
|
let resumeCalls = 0;
|
||||||
|
const resumeStarts: number[] = [];
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const route = req.url || "";
|
||||||
|
const range = String(req.headers.range || "");
|
||||||
|
const match = range.match(/bytes=(\d+)-/i);
|
||||||
|
const start = match ? Number(match[1]) : 0;
|
||||||
|
|
||||||
|
if (route === "/ignored-range") {
|
||||||
|
ignoredRangeCalls += 1;
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
|
res.end(binary);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === "/resume-ok") {
|
||||||
|
resumeCalls += 1;
|
||||||
|
resumeStarts.push(start);
|
||||||
|
const chunk = binary.subarray(start);
|
||||||
|
if (start > 0) {
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 200;
|
||||||
|
}
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(chunk.length));
|
||||||
|
res.end(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const ignoredRangeUrl = `http://127.0.0.1:${address.port}/ignored-range`;
|
||||||
|
const resumeUrl = `http://127.0.0.1:${address.port}/resume-ok`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: unrestrictCalls === 1 ? ignoredRangeUrl : resumeUrl,
|
||||||
|
filename: "resume-ignored.mkv",
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "resume-ignored-pkg";
|
||||||
|
const itemId = "resume-ignored-item";
|
||||||
|
const createdAt = Date.now() - 10_000;
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "resume-ignored",
|
||||||
|
outputDir: pkgDir,
|
||||||
|
extractDir: path.join(root, "extract", "resume-ignored"),
|
||||||
|
status: "queued",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/resume-ignored",
|
||||||
|
provider: null,
|
||||||
|
status: "queued",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: partialSize,
|
||||||
|
totalBytes: binary.length,
|
||||||
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
||||||
|
fileName: "resume-ignored.mkv",
|
||||||
|
targetPath: existingTargetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
|
||||||
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.downloadedBytes).toBe(binary.length);
|
||||||
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(ignoredRangeCalls).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(resumeCalls).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(resumeStarts).toContain(partialSize);
|
||||||
|
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not renew direct links when the file is already complete on disk", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(256 * 1024, 31);
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
let downloadCalls = 0;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: "https://dummy/direct-complete",
|
||||||
|
filename: "direct-complete.mkv",
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
||||||
|
downloadCalls += 1;
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
fs.writeFileSync(targetPath, binary);
|
||||||
|
throw new Error(`direct_link_retry_exhausted:range_ignored_on_resume:${binary.length}/${binary.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "direct-complete", links: ["https://dummy/direct-complete"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 12000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.progressPercent).toBe(100);
|
||||||
|
expect(item?.downloadedBytes).toBe(binary.length);
|
||||||
|
expect(unrestrictCalls).toBe(1);
|
||||||
|
expect(downloadCalls).toBe(1);
|
||||||
|
expect(fs.existsSync(item.targetPath)).toBe(true);
|
||||||
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts from zero after repeated resume underflow on fresh direct links", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(256 * 1024, 23);
|
||||||
|
const pkgDir = path.join(root, "downloads", "resume-underflow");
|
||||||
|
fs.mkdirSync(pkgDir, { recursive: true });
|
||||||
|
const existingTargetPath = path.join(pkgDir, "resume-underflow.mkv");
|
||||||
|
const partialSize = 96 * 1024;
|
||||||
|
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
|
||||||
|
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
const starts: number[] = [];
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const range = String(req.headers.range || "");
|
||||||
|
const match = range.match(/bytes=(\d+)-/i);
|
||||||
|
const start = match ? Number(match[1]) : 0;
|
||||||
|
starts.push(start);
|
||||||
|
|
||||||
|
if (start > 0) {
|
||||||
|
const chunk = binary.subarray(start, Math.min(start + 8192, binary.length));
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${start + chunk.length - 1}/${binary.length}`);
|
||||||
|
res.setHeader("Content-Length", String(chunk.length));
|
||||||
|
res.end(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
|
res.end(binary);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/resume-underflow`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: directUrl,
|
||||||
|
filename: "resume-underflow.mkv",
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "resume-underflow-pkg";
|
||||||
|
const itemId = "resume-underflow-item";
|
||||||
|
const createdAt = Date.now() - 10_000;
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "resume-underflow",
|
||||||
|
outputDir: pkgDir,
|
||||||
|
extractDir: path.join(root, "extract", "resume-underflow"),
|
||||||
|
status: "queued",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/resume-underflow",
|
||||||
|
provider: null,
|
||||||
|
status: "queued",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: partialSize,
|
||||||
|
totalBytes: binary.length,
|
||||||
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
||||||
|
fileName: "resume-underflow.mkv",
|
||||||
|
targetPath: existingTargetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 4,
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
|
||||||
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
|
if (item?.status !== "completed") {
|
||||||
|
throw new Error(JSON.stringify({
|
||||||
|
status: item?.status,
|
||||||
|
downloadedBytes: item?.downloadedBytes,
|
||||||
|
totalBytes: item?.totalBytes,
|
||||||
|
retries: item?.retries,
|
||||||
|
lastError: item?.lastError,
|
||||||
|
fullStatus: item?.fullStatus,
|
||||||
|
starts,
|
||||||
|
unrestrictCalls
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.downloadedBytes).toBe(binary.length);
|
||||||
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(starts).toContain(partialSize);
|
||||||
|
expect(starts).toContain(0);
|
||||||
|
expect(fs.readFileSync(existingTargetPath).equals(binary)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns unique target paths for same filenames in parallel", async () => {
|
it("assigns unique target paths for same filenames in parallel", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@ -468,7 +823,7 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
expect(item?.status).toBe("failed");
|
expect(item?.status).toBe("failed");
|
||||||
expect(item?.fullStatus || item?.lastError || "").toContain("download_underflow");
|
expect(item?.fullStatus || item?.lastError || "").toMatch(/download_underflow|range_ignored_on_resume/);
|
||||||
expect(item?.downloadedBytes).toBe(actual.length);
|
expect(item?.downloadedBytes).toBe(actual.length);
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
@ -3431,6 +3786,86 @@ describe("download manager", () => {
|
|||||||
expect(snapshot.session.runStartedAt).toBe(0);
|
expect(snapshot.session.runStartedAt).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps cumulative session totals when completed items are removed from the queue", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "pkg-complete-remove";
|
||||||
|
const itemId = "item-complete-remove";
|
||||||
|
const now = Date.now() - 1000;
|
||||||
|
const outputDir = path.join(root, "downloads", "pkg-complete-remove");
|
||||||
|
const extractDir = path.join(root, "extract", "pkg-complete-remove");
|
||||||
|
const targetPath = path.join(outputDir, "episode.mkv");
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "pkg-complete-remove",
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/item-complete-remove",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 3 * 1024,
|
||||||
|
totalBytes: 3 * 1024,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "episode.mkv",
|
||||||
|
targetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Fertig (3 KB)",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const internal = manager as unknown as {
|
||||||
|
session: { totalDownloadedBytes: number };
|
||||||
|
sessionDownloadedBytes: number;
|
||||||
|
sessionCompletedFiles: number;
|
||||||
|
itemContributedBytes: Map<string, number>;
|
||||||
|
removePackageFromSession: (packageId: string, itemIds: string[], reason?: "completed" | "deleted") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
internal.session.totalDownloadedBytes = 16 * 1024 * 1024 * 1024;
|
||||||
|
internal.sessionDownloadedBytes = 16 * 1024 * 1024 * 1024;
|
||||||
|
internal.sessionCompletedFiles = 1;
|
||||||
|
internal.itemContributedBytes.set(itemId, 3 * 1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
internal.removePackageFromSession(packageId, [itemId], "completed");
|
||||||
|
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
expect(snapshot.stats.totalPackages).toBe(0);
|
||||||
|
expect(snapshot.stats.totalDownloaded).toBe(16 * 1024 * 1024 * 1024);
|
||||||
|
expect(snapshot.stats.totalFilesSession).toBe(1);
|
||||||
|
expect(snapshot.session.totalDownloadedBytes).toBe(16 * 1024 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not start a run when queue is empty", async () => {
|
it("does not start a run when queue is empty", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@ -5681,7 +6116,8 @@ describe("download manager", () => {
|
|||||||
megaPassword: "mega-pass",
|
megaPassword: "mega-pass",
|
||||||
megaDebridApiEnabled: true,
|
megaDebridApiEnabled: true,
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
providerDailyUsageBytes: { realdebrid: 512 }
|
providerDailyUsageBytes: { realdebrid: 512 },
|
||||||
|
providerTotalUsageBytes: { realdebrid: 2048 }
|
||||||
},
|
},
|
||||||
emptySession(),
|
emptySession(),
|
||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
@ -5697,6 +6133,9 @@ describe("download manager", () => {
|
|||||||
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512);
|
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512);
|
||||||
expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024);
|
expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024);
|
||||||
expect((internal.settings.providerDailyUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
expect((internal.settings.providerDailyUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
||||||
|
expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(2048);
|
||||||
|
expect(internal.settings.providerTotalUsageBytes["megadebrid-api"]).toBe(1024);
|
||||||
|
expect((internal.settings.providerTotalUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
||||||
@ -5710,7 +6149,9 @@ describe("download manager", () => {
|
|||||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
providerDailyUsageBytes: { debridlink: 256 },
|
providerDailyUsageBytes: { debridlink: 256 },
|
||||||
debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 }
|
providerTotalUsageBytes: { debridlink: 4096 },
|
||||||
|
debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 },
|
||||||
|
debridLinkApiKeyTotalUsageBytes: { [secondKey.id]: 2048 }
|
||||||
},
|
},
|
||||||
emptySession(),
|
emptySession(),
|
||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
@ -5724,8 +6165,11 @@ describe("download manager", () => {
|
|||||||
internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id);
|
internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id);
|
||||||
|
|
||||||
expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280);
|
expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280);
|
||||||
|
expect(internal.settings.providerTotalUsageBytes.debridlink).toBe(5120);
|
||||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024);
|
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024);
|
||||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
||||||
|
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024);
|
||||||
|
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not hang when rapid stop, disable provider, start", async () => {
|
it("does not hang when rapid stop, disable provider, start", async () => {
|
||||||
|
|||||||
705
tests/error-classifier.test.ts
Normal file
705
tests/error-classifier.test.ts
Normal file
@ -0,0 +1,705 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DownloadError,
|
||||||
|
DownloadErrorKind,
|
||||||
|
classifyFetchError,
|
||||||
|
classifyHttpStatus,
|
||||||
|
classifyUnrestrictError,
|
||||||
|
classifyExtractionError,
|
||||||
|
classifyRangeIgnored,
|
||||||
|
ensureDownloadError,
|
||||||
|
errorKindLabel,
|
||||||
|
isPermanentKind,
|
||||||
|
} from "../src/main/download/error-classifier";
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// DownloadError construction and properties
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("DownloadError", () => {
|
||||||
|
it("stores kind, message, and defaults retryable/permanent from isPermanentKind", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.NetworkReset, "socket hang up");
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.name).toBe("DownloadError");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
|
||||||
|
expect(err.message).toBe("socket hang up");
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
expect(err.permanent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks permanent kinds as non-retryable by default", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.LinkDead, "file deleted");
|
||||||
|
expect(err.retryable).toBe(false);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores httpStatus when provided", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.ServerError, "HTTP 500", {
|
||||||
|
httpStatus: 500,
|
||||||
|
});
|
||||||
|
expect(err.httpStatus).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores originalError when provided", () => {
|
||||||
|
const orig = new Error("root cause");
|
||||||
|
const err = new DownloadError(DownloadErrorKind.Unknown, "wrapped", {
|
||||||
|
originalError: orig,
|
||||||
|
});
|
||||||
|
expect(err.originalError).toBe(orig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores arbitrary context", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.RangeNotSatisfied, "range", {
|
||||||
|
context: { existingBytes: 1024, expectedTotal: 2048 },
|
||||||
|
});
|
||||||
|
expect(err.context).toEqual({ existingBytes: 1024, expectedTotal: 2048 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows overriding retryable and permanent via opts", () => {
|
||||||
|
// Override a normally-permanent kind to be retryable
|
||||||
|
const err = new DownloadError(DownloadErrorKind.DiskFull, "disk full", {
|
||||||
|
retryable: true,
|
||||||
|
permanent: false,
|
||||||
|
});
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
expect(err.permanent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("httpStatus is undefined when not provided", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.Unknown, "x");
|
||||||
|
expect(err.httpStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toLogString produces a compact representation", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.ServerError, "Internal Server Error", {
|
||||||
|
httpStatus: 500,
|
||||||
|
});
|
||||||
|
const log = err.toLogString();
|
||||||
|
expect(log).toContain("[server_error]");
|
||||||
|
expect(log).toContain("Internal Server Error");
|
||||||
|
expect(log).toContain("(HTTP 500)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toLogString omits HTTP status when not set", () => {
|
||||||
|
const err = new DownloadError(DownloadErrorKind.Timeout, "stalled");
|
||||||
|
const log = err.toLogString();
|
||||||
|
expect(log).toBe("[timeout] stalled");
|
||||||
|
expect(log).not.toContain("HTTP");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// classifyFetchError
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("classifyFetchError", () => {
|
||||||
|
// ---- Network Reset ----
|
||||||
|
it.each([
|
||||||
|
"socket hang up",
|
||||||
|
"ECONNRESET",
|
||||||
|
"ECONNREFUSED",
|
||||||
|
"EPIPE broken pipe",
|
||||||
|
"network error on fetch",
|
||||||
|
"socket closed unexpectedly",
|
||||||
|
"connection reset by peer",
|
||||||
|
"fetch failed",
|
||||||
|
])("classifies '%s' as NetworkReset", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Connection Timeout ----
|
||||||
|
it.each([
|
||||||
|
"ETIMEDOUT",
|
||||||
|
"connect_timeout reached",
|
||||||
|
"Connection timed out after 30s",
|
||||||
|
])("classifies '%s' as ConnectTimeout", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- DNS Failure ----
|
||||||
|
it.each([
|
||||||
|
"getaddrinfo ENOTFOUND example.com",
|
||||||
|
"ENOTFOUND",
|
||||||
|
"DNS lookup failed",
|
||||||
|
])("classifies '%s' as DnsFailure", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.DnsFailure);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Stall / Read Timeout ----
|
||||||
|
it.each([
|
||||||
|
"stall_timeout after 60s",
|
||||||
|
"read timeout waiting for data",
|
||||||
|
])("classifies '%s' as Timeout", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Timeout);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Write Drain Timeout ----
|
||||||
|
it("classifies write_drain_timeout as WriteDrainTimeout", () => {
|
||||||
|
const err = classifyFetchError(new Error("write_drain_timeout: disk slow"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.WriteDrainTimeout);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Disk Full ----
|
||||||
|
it.each([
|
||||||
|
"ENOSPC: no space left on device",
|
||||||
|
"no space left on device",
|
||||||
|
])("classifies '%s' as DiskFull (permanent)", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
expect(err.retryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Permission Denied ----
|
||||||
|
it.each([
|
||||||
|
"EACCES: permission denied '/tmp/f'",
|
||||||
|
"EPERM: operation not permitted",
|
||||||
|
"Permission denied writing to output",
|
||||||
|
])("classifies '%s' as PermissionDenied (permanent)", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.PermissionDenied);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- File Locked ----
|
||||||
|
it.each([
|
||||||
|
"EBUSY: resource busy or locked",
|
||||||
|
"file is locked by another process",
|
||||||
|
"being used by another process",
|
||||||
|
])("classifies '%s' as FileLocked", (msg) => {
|
||||||
|
const err = classifyFetchError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.FileLocked);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Resume Underflow ----
|
||||||
|
it("classifies resume_download_underflow as ResumeUnderflow", () => {
|
||||||
|
const err = classifyFetchError(new Error("resume_download_underflow:512/1024"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ResumeUnderflow);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Range Ignored ----
|
||||||
|
it("classifies range_ignored_on_resume as RangeIgnored", () => {
|
||||||
|
const err = classifyFetchError(new Error("range_ignored_on_resume:512/2048"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.RangeIgnored);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Unknown ----
|
||||||
|
it("classifies an unrecognised message as Unknown", () => {
|
||||||
|
const err = classifyFetchError(new Error("something completely new"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Abort handling ----
|
||||||
|
it("re-throws abort errors instead of classifying", () => {
|
||||||
|
expect(() => classifyFetchError(new Error("Aborted: user cancelled"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws abort errors for a plain 'abort' message", () => {
|
||||||
|
expect(() => classifyFetchError(new Error("abort"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Non-Error inputs ----
|
||||||
|
it("handles a plain string as input", () => {
|
||||||
|
const err = classifyFetchError("ECONNRESET");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null/undefined gracefully", () => {
|
||||||
|
const err = classifyFetchError(null);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined gracefully", () => {
|
||||||
|
const err = classifyFetchError(undefined);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves originalError reference", () => {
|
||||||
|
const orig = new Error("ECONNRESET");
|
||||||
|
const err = classifyFetchError(orig);
|
||||||
|
expect(err.originalError).toBe(orig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// classifyHttpStatus
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("classifyHttpStatus", () => {
|
||||||
|
it("classifies 416 as RangeNotSatisfied", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 416 });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.RangeNotSatisfied);
|
||||||
|
expect(err.httpStatus).toBe(416);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores existingBytes in context for 416", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 416, existingBytes: 4096 });
|
||||||
|
expect(err.context).toEqual({ existingBytes: 4096 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 429 as RateLimited", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 429, statusText: "Too Many Requests" });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.RateLimited);
|
||||||
|
expect(err.httpStatus).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 403 as Forbidden", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 403 });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Forbidden);
|
||||||
|
expect(err.httpStatus).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 404 as NotFound", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 404, statusText: "Not Found" });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.NotFound);
|
||||||
|
expect(err.httpStatus).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 500 as ServerError", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 500 });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
||||||
|
expect(err.httpStatus).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 502 as ServerError", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 502, statusText: "Bad Gateway" });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
||||||
|
expect(err.httpStatus).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 503 as ServerError", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 503, statusText: "Service Unavailable" });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
||||||
|
expect(err.httpStatus).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 401 as Unknown (no special branch)", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 401 });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
expect(err.httpStatus).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes responseText in the message when provided", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 500, responseText: "Internal Server Error" });
|
||||||
|
expect(err.message).toContain("500");
|
||||||
|
expect(err.message).toContain("Internal Server Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses statusText as fallback when responseText is absent", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 500, statusText: "Server Error" });
|
||||||
|
expect(err.message).toContain("Server Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces message without body when neither responseText nor statusText is given", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 500 });
|
||||||
|
expect(err.message).toBe("HTTP 500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all server errors (5xx) are retryable", () => {
|
||||||
|
for (const code of [500, 502, 503, 504]) {
|
||||||
|
const err = classifyHttpStatus({ status: code });
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// classifyRangeIgnored
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("classifyRangeIgnored", () => {
|
||||||
|
it("returns RangeIgnored kind", () => {
|
||||||
|
const err = classifyRangeIgnored(1024, 4096);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.RangeIgnored);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes existingBytes and contentLength in the message", () => {
|
||||||
|
const err = classifyRangeIgnored(512, 2048);
|
||||||
|
expect(err.message).toContain("512");
|
||||||
|
expect(err.message).toContain("2048");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores existingBytes and contentLength in context", () => {
|
||||||
|
const err = classifyRangeIgnored(1024, 8192);
|
||||||
|
expect(err.context).toEqual({ existingBytes: 1024, contentLength: 8192 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is retryable by default", () => {
|
||||||
|
const err = classifyRangeIgnored(0, 100);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
expect(err.permanent).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// classifyUnrestrictError
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("classifyUnrestrictError", () => {
|
||||||
|
// ---- LinkDead (permanent) ----
|
||||||
|
it.each([
|
||||||
|
"File not found",
|
||||||
|
"file unavailable",
|
||||||
|
"Link is dead",
|
||||||
|
"File has been removed",
|
||||||
|
"file has been deleted",
|
||||||
|
"file is no longer available",
|
||||||
|
"file was removed from server",
|
||||||
|
"file was deleted by owner",
|
||||||
|
"permanent ungültig",
|
||||||
|
])("classifies '%s' as LinkDead (permanent)", (msg) => {
|
||||||
|
const err = classifyUnrestrictError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.LinkDead);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
expect(err.retryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- ProviderBusy ----
|
||||||
|
it.each([
|
||||||
|
"too many active downloads",
|
||||||
|
"too many concurrent sessions",
|
||||||
|
"too many downloads at once",
|
||||||
|
"active download limit",
|
||||||
|
"concurrent limit exceeded",
|
||||||
|
"slot limit reached for this host",
|
||||||
|
"limit reached try later",
|
||||||
|
"zu viele aktive Downloads",
|
||||||
|
"zu viele gleichzeitige Transfers",
|
||||||
|
"zu viele Downloads",
|
||||||
|
])("classifies '%s' as ProviderBusy", (msg) => {
|
||||||
|
const err = classifyUnrestrictError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ProviderBusy);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- HosterUnavailable ----
|
||||||
|
it("classifies 'hosternotavailable' as HosterUnavailable", () => {
|
||||||
|
const err = classifyUnrestrictError(new Error("hosternotavailable"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- QuotaExceeded ----
|
||||||
|
it.each([
|
||||||
|
"quota exceeded for today",
|
||||||
|
"bandwidth limit exceeded",
|
||||||
|
])("classifies '%s' as QuotaExceeded", (msg) => {
|
||||||
|
const err = classifyUnrestrictError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.QuotaExceeded);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- ProviderDown ----
|
||||||
|
it.each([
|
||||||
|
"server error occurred",
|
||||||
|
"internal server error",
|
||||||
|
"temporarily unavailable",
|
||||||
|
"temporary unavailable please wait",
|
||||||
|
"temporarily disabled",
|
||||||
|
"try again later",
|
||||||
|
"service unavailable",
|
||||||
|
"host is down",
|
||||||
|
"maintenance in progress",
|
||||||
|
"bad gateway",
|
||||||
|
"gateway timeout",
|
||||||
|
"cloudflare challenge detected",
|
||||||
|
"worker error at edge",
|
||||||
|
])("classifies '%s' as ProviderDown", (msg) => {
|
||||||
|
const err = classifyUnrestrictError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ProviderDown);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- UnrestrictFailed ----
|
||||||
|
it.each([
|
||||||
|
"unrestrict call failed",
|
||||||
|
"mega-web provider error",
|
||||||
|
"mega-debrid session lost",
|
||||||
|
"bestdebrid API error",
|
||||||
|
"alldebrid unrestrict failed",
|
||||||
|
"kein debrid-provider verfügbar",
|
||||||
|
"session-cookie expired",
|
||||||
|
"session cookie invalid",
|
||||||
|
"session blockiert",
|
||||||
|
"session expired please re-login",
|
||||||
|
"invalid session token",
|
||||||
|
"login ungültig",
|
||||||
|
"login liefert HTTP 401",
|
||||||
|
"login required for this host",
|
||||||
|
"login failed with credentials",
|
||||||
|
])("classifies '%s' as UnrestrictFailed", (msg) => {
|
||||||
|
const err = classifyUnrestrictError(new Error(msg));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.UnrestrictFailed);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Unknown ----
|
||||||
|
it("classifies unrecognised debrid error as Unknown", () => {
|
||||||
|
const err = classifyUnrestrictError(new Error("completely unknown debrid error"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Non-Error inputs ----
|
||||||
|
it("handles a plain string as input", () => {
|
||||||
|
const err = classifyUnrestrictError("hosternotavailable");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null input gracefully", () => {
|
||||||
|
const err = classifyUnrestrictError(null);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// classifyExtractionError
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("classifyExtractionError", () => {
|
||||||
|
// ---- WrongPassword (permanent) ----
|
||||||
|
it("classifies 'wrong password' as WrongPassword", () => {
|
||||||
|
const err = classifyExtractionError("Wrong password for archive.rar");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
expect(err.retryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies 'falsches Passwort' as WrongPassword", () => {
|
||||||
|
const err = classifyExtractionError("Falsches Passwort eingegeben");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies category 'wrong_password' as WrongPassword even with generic message", () => {
|
||||||
|
const err = classifyExtractionError("extraction error", "wrong_password");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- ArchiveCorrupt ----
|
||||||
|
it.each([
|
||||||
|
"archive is corrupt",
|
||||||
|
"unexpected end of archive",
|
||||||
|
"broken header in rar",
|
||||||
|
"invalid archive format",
|
||||||
|
"bad signature in header",
|
||||||
|
"Archiv beschädigt",
|
||||||
|
])("classifies '%s' as ArchiveCorrupt", (msg) => {
|
||||||
|
const err = classifyExtractionError(msg);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies category 'archive_corrupt' as ArchiveCorrupt", () => {
|
||||||
|
const err = classifyExtractionError("some error", "archive_corrupt");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- ExtractorCrash ----
|
||||||
|
it.each([
|
||||||
|
"process exited with code 1",
|
||||||
|
"process crashed unexpectedly",
|
||||||
|
"extractor failed to start",
|
||||||
|
"Segmentation fault (core dumped)",
|
||||||
|
])("classifies '%s' as ExtractorCrash", (msg) => {
|
||||||
|
const err = classifyExtractionError(msg);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash);
|
||||||
|
expect(err.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies category 'extractor_crash' as ExtractorCrash", () => {
|
||||||
|
const err = classifyExtractionError("unknown", "extractor_crash");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- DiskFull ----
|
||||||
|
it.each([
|
||||||
|
"ENOSPC: write failed",
|
||||||
|
"No space left on device",
|
||||||
|
])("classifies '%s' as DiskFull (permanent)", (msg) => {
|
||||||
|
const err = classifyExtractionError(msg);
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
|
||||||
|
expect(err.permanent).toBe(true);
|
||||||
|
expect(err.retryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Unknown ----
|
||||||
|
it("classifies unrecognised extraction error as Unknown", () => {
|
||||||
|
const err = classifyExtractionError("some new error we haven't seen");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string input", () => {
|
||||||
|
const err = classifyExtractionError("");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// ensureDownloadError
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("ensureDownloadError", () => {
|
||||||
|
it("returns existing DownloadError unchanged", () => {
|
||||||
|
const orig = new DownloadError(DownloadErrorKind.Timeout, "timed out");
|
||||||
|
const result = ensureDownloadError(orig);
|
||||||
|
expect(result).toBe(orig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a plain Error via classifyFetchError", () => {
|
||||||
|
const result = ensureDownloadError(new Error("ECONNRESET"));
|
||||||
|
expect(result).toBeInstanceOf(DownloadError);
|
||||||
|
expect(result.kind).toBe(DownloadErrorKind.NetworkReset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a string via classifyFetchError", () => {
|
||||||
|
const result = ensureDownloadError("ETIMEDOUT");
|
||||||
|
expect(result).toBeInstanceOf(DownloadError);
|
||||||
|
expect(result.kind).toBe(DownloadErrorKind.ConnectTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps null as Unknown", () => {
|
||||||
|
const result = ensureDownloadError(null);
|
||||||
|
expect(result).toBeInstanceOf(DownloadError);
|
||||||
|
expect(result.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws abort errors (inherits classifyFetchError behavior)", () => {
|
||||||
|
expect(() => ensureDownloadError(new Error("abort"))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// errorKindLabel
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("errorKindLabel", () => {
|
||||||
|
it("returns a non-empty string for every DownloadErrorKind", () => {
|
||||||
|
for (const kind of Object.values(DownloadErrorKind)) {
|
||||||
|
const label = errorKindLabel(kind);
|
||||||
|
expect(label).toBeTruthy();
|
||||||
|
expect(typeof label).toBe("string");
|
||||||
|
expect(label.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns specific labels for known kinds", () => {
|
||||||
|
expect(errorKindLabel(DownloadErrorKind.NetworkReset)).toBe("Netzwerkfehler");
|
||||||
|
expect(errorKindLabel(DownloadErrorKind.DiskFull)).toBe("Festplatte voll");
|
||||||
|
expect(errorKindLabel(DownloadErrorKind.WrongPassword)).toBe("Falsches Archiv-Passwort");
|
||||||
|
expect(errorKindLabel(DownloadErrorKind.RateLimited)).toBe("Rate-Limit erreicht");
|
||||||
|
expect(errorKindLabel(DownloadErrorKind.Unknown)).toBe("Unbekannter Fehler");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'Unbekannter Fehler' for an unrecognised kind", () => {
|
||||||
|
const label = errorKindLabel("made_up_kind" as DownloadErrorKind);
|
||||||
|
expect(label).toBe("Unbekannter Fehler");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// isPermanentKind
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("isPermanentKind", () => {
|
||||||
|
it("returns true for LinkDead", () => {
|
||||||
|
expect(isPermanentKind(DownloadErrorKind.LinkDead)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for DiskFull", () => {
|
||||||
|
expect(isPermanentKind(DownloadErrorKind.DiskFull)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for PermissionDenied", () => {
|
||||||
|
expect(isPermanentKind(DownloadErrorKind.PermissionDenied)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for WrongPassword", () => {
|
||||||
|
expect(isPermanentKind(DownloadErrorKind.WrongPassword)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for retryable kinds", () => {
|
||||||
|
const retryableKinds = [
|
||||||
|
DownloadErrorKind.NetworkReset,
|
||||||
|
DownloadErrorKind.Timeout,
|
||||||
|
DownloadErrorKind.DnsFailure,
|
||||||
|
DownloadErrorKind.ConnectTimeout,
|
||||||
|
DownloadErrorKind.RangeNotSatisfied,
|
||||||
|
DownloadErrorKind.RangeIgnored,
|
||||||
|
DownloadErrorKind.ServerError,
|
||||||
|
DownloadErrorKind.RateLimited,
|
||||||
|
DownloadErrorKind.Forbidden,
|
||||||
|
DownloadErrorKind.NotFound,
|
||||||
|
DownloadErrorKind.UnrestrictFailed,
|
||||||
|
DownloadErrorKind.ProviderBusy,
|
||||||
|
DownloadErrorKind.ProviderDown,
|
||||||
|
DownloadErrorKind.HosterUnavailable,
|
||||||
|
DownloadErrorKind.QuotaExceeded,
|
||||||
|
DownloadErrorKind.FileLocked,
|
||||||
|
DownloadErrorKind.FileCorrupt,
|
||||||
|
DownloadErrorKind.FileTruncated,
|
||||||
|
DownloadErrorKind.ResumeUnderflow,
|
||||||
|
DownloadErrorKind.ArchiveCorrupt,
|
||||||
|
DownloadErrorKind.ExtractorCrash,
|
||||||
|
DownloadErrorKind.WriteDrainTimeout,
|
||||||
|
DownloadErrorKind.Unknown,
|
||||||
|
];
|
||||||
|
for (const kind of retryableKinds) {
|
||||||
|
expect(isPermanentKind(kind)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Edge cases and priority
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("classifier priority / edge cases", () => {
|
||||||
|
it("classifyFetchError checks abort before other patterns", () => {
|
||||||
|
// "abort" appears before network patterns, so abort should win
|
||||||
|
expect(() => classifyFetchError(new Error("Aborted: ECONNRESET"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyFetchError: ETIMEDOUT wins over ECONNRESET when both keywords present", () => {
|
||||||
|
// ConnectTimeout is checked before NetworkReset in the code
|
||||||
|
const err = classifyFetchError(new Error("ETIMEDOUT ECONNRESET"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyFetchError: DNS checked before NetworkReset", () => {
|
||||||
|
const err = classifyFetchError(new Error("getaddrinfo ENOTFOUND fetch failed"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.DnsFailure);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyFetchError: ENOSPC checked before generic unknown", () => {
|
||||||
|
const err = classifyFetchError(new Error("write error ENOSPC"));
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyExtractionError: wrong_password category overrides message text", () => {
|
||||||
|
// Even if message contains 'corrupt', category should take priority
|
||||||
|
const err = classifyExtractionError("archive is corrupt", "wrong_password");
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyHttpStatus: treats status 599 as ServerError (>= 500 rule)", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 599 });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyHttpStatus: treats status 200 as Unknown", () => {
|
||||||
|
const err = classifyHttpStatus({ status: 200 });
|
||||||
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,7 +13,9 @@ import {
|
|||||||
classifyExtractionError,
|
classifyExtractionError,
|
||||||
findArchiveCandidates,
|
findArchiveCandidates,
|
||||||
orderExtractorCandidatesForArchive,
|
orderExtractorCandidatesForArchive,
|
||||||
|
resolveExtractorBackendModeForArchive,
|
||||||
resolveExtractorBackendMode,
|
resolveExtractorBackendMode,
|
||||||
|
shouldFallbackLegacyRarToJvm,
|
||||||
} from "../src/main/extractor";
|
} from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@ -1176,6 +1178,36 @@ describe("extractor", () => {
|
|||||||
expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm");
|
expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm");
|
||||||
expect(resolveExtractorBackendMode("auto", false)).toBe("auto");
|
expect(resolveExtractorBackendMode("auto", false)).toBe("auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers legacy for rar archives in auto mode on Windows", () => {
|
||||||
|
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", undefined, false, "win32")).toBe("legacy");
|
||||||
|
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from legacy rar to jvm after partial-progress failure in auto mode on Windows", () => {
|
||||||
|
expect(
|
||||||
|
shouldFallbackLegacyRarToJvm(
|
||||||
|
"C:\\Downloads\\episode.part01.rar",
|
||||||
|
"auto",
|
||||||
|
"legacy",
|
||||||
|
"Error: Extracting from C:\\Downloads\\episode.part01.rar",
|
||||||
|
38,
|
||||||
|
"win32"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips legacy rar to jvm fallback for explicit legacy mode and non-rar cases", () => {
|
||||||
|
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "legacy", "legacy", "checksum error", 38, "win32")).toBe(false);
|
||||||
|
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.zip", "auto", "legacy", "unknown failure", 38, "win32")).toBe(false);
|
||||||
|
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "auto", "legacy", "timeout", 38, "win32")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps auto for non-rar archives and respects explicit overrides", () => {
|
||||||
|
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto");
|
||||||
|
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm");
|
||||||
|
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "legacy", false, "win32")).toBe("legacy");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("orderExtractorCandidatesForArchive", () => {
|
describe("orderExtractorCandidatesForArchive", () => {
|
||||||
|
|||||||
812
tests/retry-manager.test.ts
Normal file
812
tests/retry-manager.test.ts
Normal file
@ -0,0 +1,812 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
DownloadError,
|
||||||
|
DownloadErrorKind,
|
||||||
|
} from "../src/main/download/error-classifier";
|
||||||
|
import {
|
||||||
|
RetryManager,
|
||||||
|
RETRY_POLICIES,
|
||||||
|
RetryPolicy,
|
||||||
|
RetryState,
|
||||||
|
} from "../src/main/download/retry-manager";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** All values of DownloadErrorKind. */
|
||||||
|
const ALL_KINDS = Object.values(DownloadErrorKind) as DownloadErrorKind[];
|
||||||
|
|
||||||
|
/** Convenience: create a DownloadError for a given kind. */
|
||||||
|
function mkError(kind: DownloadErrorKind, msg = "test error"): DownloadError {
|
||||||
|
return new DownloadError(kind, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed N failures of the same kind and return the last decision. */
|
||||||
|
function failNTimes(
|
||||||
|
mgr: RetryManager,
|
||||||
|
itemId: string,
|
||||||
|
kind: DownloadErrorKind,
|
||||||
|
n: number,
|
||||||
|
) {
|
||||||
|
let last;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
last = mgr.evaluate(itemId, mkError(kind));
|
||||||
|
}
|
||||||
|
return last!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1) RETRY_POLICIES — completeness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RETRY_POLICIES", () => {
|
||||||
|
it("has a policy defined for every DownloadErrorKind value", () => {
|
||||||
|
for (const kind of ALL_KINDS) {
|
||||||
|
expect(RETRY_POLICIES[kind], `missing policy for ${kind}`).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every policy has valid shape", () => {
|
||||||
|
for (const kind of ALL_KINDS) {
|
||||||
|
const p = RETRY_POLICIES[kind];
|
||||||
|
expect(p.maxRetries).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(["fixed", "exponential"]).toContain(p.backoff);
|
||||||
|
expect(p.baseDelayMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(p.maxDelayMs).toBeGreaterThanOrEqual(p.baseDelayMs);
|
||||||
|
expect(typeof p.resetFile).toBe("boolean");
|
||||||
|
expect(typeof p.switchProvider).toBe("boolean");
|
||||||
|
expect(typeof p.refreshLink).toBe("boolean");
|
||||||
|
expect(p.providerCooldownMs).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no unknown keys in RETRY_POLICIES beyond the enum values", () => {
|
||||||
|
const policyKeys = Object.keys(RETRY_POLICIES);
|
||||||
|
const enumValues = ALL_KINDS as string[];
|
||||||
|
for (const key of policyKeys) {
|
||||||
|
expect(enumValues, `unexpected key "${key}" in RETRY_POLICIES`).toContain(
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2) RetryManager.evaluate() — basic decisions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RetryManager.evaluate()", () => {
|
||||||
|
let mgr: RetryManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mgr = new RetryManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns shouldRetry=true on first retryable failure", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
expect(d.delayMs).toBeGreaterThan(0);
|
||||||
|
expect(d.reason).toContain("1/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks failure counts per kind", () => {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2);
|
||||||
|
expect(state.totalFailures).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks multiple error kinds independently", () => {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.ServerError));
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2);
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(1);
|
||||||
|
expect(state.totalFailures).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores last error kind and message on state", () => {
|
||||||
|
mgr.evaluate("x", mkError(DownloadErrorKind.ServerError, "500 oops"));
|
||||||
|
const state = mgr.getState("x")!;
|
||||||
|
expect(state.lastErrorKind).toBe(DownloadErrorKind.ServerError);
|
||||||
|
expect(state.lastErrorMessage).toBe("500 oops");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps separate state per item", () => {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("b", mkError(DownloadErrorKind.ServerError));
|
||||||
|
expect(mgr.getState("a")!.totalFailures).toBe(1);
|
||||||
|
expect(mgr.getState("b")!.totalFailures).toBe(1);
|
||||||
|
expect(mgr.getState("a")!.lastErrorKind).toBe(DownloadErrorKind.Timeout);
|
||||||
|
expect(mgr.getState("b")!.lastErrorKind).toBe(DownloadErrorKind.ServerError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects userRetryLimit when set", () => {
|
||||||
|
const limited = new RetryManager(2);
|
||||||
|
// Timeout normally has maxRetries=10, but user limit is 2
|
||||||
|
const d1 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d1.shouldRetry).toBe(true);
|
||||||
|
const d2 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d2.shouldRetry).toBe(true);
|
||||||
|
// Third attempt exceeds limit (kindCount=3 > effectiveMax=2)
|
||||||
|
const d3 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d3.shouldRetry).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRetryLimit updates limit dynamically", () => {
|
||||||
|
const m = new RetryManager(1);
|
||||||
|
m.evaluate("a", mkError(DownloadErrorKind.Timeout)); // 1/1, ok
|
||||||
|
const d2 = m.evaluate("a", mkError(DownloadErrorKind.Timeout)); // 2 > 1, fail
|
||||||
|
expect(d2.shouldRetry).toBe(false);
|
||||||
|
|
||||||
|
// Raise limit; new item should get more room
|
||||||
|
m.setRetryLimit(5);
|
||||||
|
const d3 = m.evaluate("b", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d3.shouldRetry).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRetryLimit clamps negative values to 0", () => {
|
||||||
|
const m = new RetryManager();
|
||||||
|
m.setRetryLimit(-5);
|
||||||
|
// 0 = unlimited, uses policy max
|
||||||
|
const d = m.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3) Exponential backoff — delays increase with attempts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("exponential backoff", () => {
|
||||||
|
it("delay increases with attempt count for exponential policies", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Timeout uses exponential backoff with baseDelayMs=200, maxDelayMs=30000
|
||||||
|
const delays: number[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
delays.push(d.delayMs);
|
||||||
|
}
|
||||||
|
// With jitter, exact values are nondeterministic, but the trend
|
||||||
|
// should be non-decreasing (or at worst slightly noisy).
|
||||||
|
// Check that the 5th delay >= 1st delay (accounting for the 1.5^n growth).
|
||||||
|
expect(delays[4]).toBeGreaterThanOrEqual(delays[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delay is capped at maxDelayMs", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Use Timeout: maxDelayMs=30_000. After many retries delay should cap.
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
}
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d.delayMs).toBeLessThanOrEqual(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fixed backoff returns the same delay every time", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// NetworkReset is fixed at 300ms
|
||||||
|
const d1 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
const d2 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d1.delayMs).toBe(300);
|
||||||
|
expect(d2.delayMs).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exponential delay is always >= 50% of the capped value", () => {
|
||||||
|
// computeDelay: max(capped*0.5, capped - jitter) where jitter = capped*random*0.5
|
||||||
|
// so result is always >= capped * 0.5
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
const policy = RETRY_POLICIES[DownloadErrorKind.Timeout];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
// On attempt i+1, base = 200 * 1.5^i, capped = min(base, 30000)
|
||||||
|
const base = policy.baseDelayMs * Math.pow(1.5, i);
|
||||||
|
const capped = Math.min(base, policy.maxDelayMs);
|
||||||
|
expect(d.delayMs).toBeGreaterThanOrEqual(Math.floor(capped * 0.5));
|
||||||
|
expect(d.delayMs).toBeLessThanOrEqual(capped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4) Max retries — shouldRetry=false after exhausting retries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("max retries exhaustion", () => {
|
||||||
|
it("shouldRetry becomes false after maxRetries+1 failures for a retryable kind", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
const kind = DownloadErrorKind.NetworkReset; // maxRetries=3
|
||||||
|
const policy = RETRY_POLICIES[kind];
|
||||||
|
|
||||||
|
for (let i = 0; i < policy.maxRetries; i++) {
|
||||||
|
const d = mgr.evaluate("a", mkError(kind));
|
||||||
|
expect(d.shouldRetry, `attempt ${i + 1} should be retryable`).toBe(true);
|
||||||
|
}
|
||||||
|
// Next failure exceeds limit
|
||||||
|
const final = mgr.evaluate("a", mkError(kind));
|
||||||
|
expect(final.shouldRetry).toBe(false);
|
||||||
|
expect(final.delayMs).toBe(0);
|
||||||
|
expect(final.actions).toEqual([]);
|
||||||
|
expect(final.reason).toContain("erschöpft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exhaustion message includes count and max", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
const kind = DownloadErrorKind.DnsFailure; // maxRetries=2
|
||||||
|
failNTimes(mgr, "a", kind, 2); // use up retries
|
||||||
|
const d = mgr.evaluate("a", mkError(kind)); // 3rd fail
|
||||||
|
expect(d.shouldRetry).toBe(false);
|
||||||
|
expect(d.reason).toMatch(/3\/2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each kind's retries are tracked independently", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Exhaust NetworkReset (3 retries)
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3);
|
||||||
|
const d1 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d1.shouldRetry).toBe(false);
|
||||||
|
|
||||||
|
// Timeout should still be retryable (different kind)
|
||||||
|
const d2 = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d2.shouldRetry).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5) Permanent errors — no retry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("permanent errors", () => {
|
||||||
|
const permanentKinds: DownloadErrorKind[] = [
|
||||||
|
DownloadErrorKind.LinkDead,
|
||||||
|
DownloadErrorKind.DiskFull,
|
||||||
|
DownloadErrorKind.PermissionDenied,
|
||||||
|
DownloadErrorKind.WrongPassword,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const kind of permanentKinds) {
|
||||||
|
it(`${kind} is never retried`, () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
const d = mgr.evaluate("a", mkError(kind));
|
||||||
|
expect(d.shouldRetry).toBe(false);
|
||||||
|
expect(d.delayMs).toBe(0);
|
||||||
|
expect(d.actions).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("permanent errors return shouldRetry=false even on first attempt", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
for (const kind of permanentKinds) {
|
||||||
|
const d = mgr.evaluate(kind, mkError(kind));
|
||||||
|
expect(d.shouldRetry, `${kind} should not retry`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permanent kinds also have maxRetries=0 in their policies", () => {
|
||||||
|
for (const kind of permanentKinds) {
|
||||||
|
expect(
|
||||||
|
RETRY_POLICIES[kind].maxRetries,
|
||||||
|
`${kind} should have maxRetries=0`,
|
||||||
|
).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 6) Retry actions — correct actions per policy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("retry actions", () => {
|
||||||
|
let mgr: RetryManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mgr = new RetryManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset_file action for NetworkReset (resetFile=true)", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d.actions).toContain("reset_file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no switch_provider for NetworkReset (switchProvider=false)", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d.actions).not.toContain("switch_provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switch_provider action for UnrestrictFailed (switchProvider=true)", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.UnrestrictFailed));
|
||||||
|
expect(d.actions).toContain("switch_provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cooldown_provider action for UnrestrictFailed (providerCooldownMs > 0)", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.UnrestrictFailed));
|
||||||
|
expect(d.actions).toContain("cooldown_provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh_link action for ConnectTimeout (refreshLink=true)", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.ConnectTimeout));
|
||||||
|
expect(d.actions).toContain("refresh_link");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no actions for permanent errors", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.LinkDead));
|
||||||
|
expect(d.actions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ProviderBusy yields switch_provider + cooldown_provider", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.ProviderBusy));
|
||||||
|
expect(d.actions).toContain("switch_provider");
|
||||||
|
expect(d.actions).toContain("cooldown_provider");
|
||||||
|
expect(d.actions).not.toContain("reset_file");
|
||||||
|
expect(d.actions).not.toContain("refresh_link");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FileCorrupt yields reset_file + refresh_link", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.FileCorrupt));
|
||||||
|
expect(d.actions).toContain("reset_file");
|
||||||
|
expect(d.actions).toContain("refresh_link");
|
||||||
|
expect(d.actions).not.toContain("switch_provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FileLocked has no special actions", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.FileLocked));
|
||||||
|
expect(d.actions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Timeout has no special actions", () => {
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(d.actions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("actions list matches policy flags for every retryable kind", () => {
|
||||||
|
for (const kind of ALL_KINDS) {
|
||||||
|
const policy = RETRY_POLICIES[kind];
|
||||||
|
if (policy.maxRetries === 0) continue; // permanent or zero-retry
|
||||||
|
|
||||||
|
const d = mgr.evaluate(`action-check-${kind}`, mkError(kind));
|
||||||
|
if (!d.shouldRetry) continue;
|
||||||
|
|
||||||
|
if (policy.resetFile) {
|
||||||
|
expect(d.actions, `${kind}: missing reset_file`).toContain("reset_file");
|
||||||
|
} else {
|
||||||
|
expect(d.actions, `${kind}: unexpected reset_file`).not.toContain("reset_file");
|
||||||
|
}
|
||||||
|
if (policy.switchProvider) {
|
||||||
|
expect(d.actions, `${kind}: missing switch_provider`).toContain("switch_provider");
|
||||||
|
} else {
|
||||||
|
expect(d.actions, `${kind}: unexpected switch_provider`).not.toContain("switch_provider");
|
||||||
|
}
|
||||||
|
if (policy.refreshLink) {
|
||||||
|
expect(d.actions, `${kind}: missing refresh_link`).toContain("refresh_link");
|
||||||
|
} else {
|
||||||
|
expect(d.actions, `${kind}: unexpected refresh_link`).not.toContain("refresh_link");
|
||||||
|
}
|
||||||
|
if (policy.providerCooldownMs > 0) {
|
||||||
|
expect(d.actions, `${kind}: missing cooldown_provider`).toContain("cooldown_provider");
|
||||||
|
} else {
|
||||||
|
expect(d.actions, `${kind}: unexpected cooldown_provider`).not.toContain("cooldown_provider");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 7) Shelving — triggers after SHELVE_THRESHOLD (15) total failures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("shelving", () => {
|
||||||
|
const SHELVE_THRESHOLD = 15;
|
||||||
|
const SHELVE_DELAY_MS = 90_000;
|
||||||
|
|
||||||
|
it("triggers shelving at exactly 15 total failures", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Use a kind with high maxRetries so we don't exhaust it first
|
||||||
|
const kind = DownloadErrorKind.Timeout; // maxRetries=10
|
||||||
|
// Mix in some ServerError too to stay under per-kind limits
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
mgr.evaluate("a", mkError(kind));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.ServerError));
|
||||||
|
}
|
||||||
|
// Next one is the 15th failure -> shelve
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.ServerError));
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
expect(d.delayMs).toBe(SHELVE_DELAY_MS);
|
||||||
|
expect(d.actions).toContain("shelve");
|
||||||
|
expect(d.actions).toContain("switch_provider");
|
||||||
|
expect(d.actions).toContain("refresh_link");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shelving halves all kind counters", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.ServerError));
|
||||||
|
}
|
||||||
|
// 15th failure -> shelve
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.ServerError));
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
// After halving: Timeout 10->5, ServerError 5->2, total=7
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(5);
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(2);
|
||||||
|
expect(state.totalFailures).toBe(7);
|
||||||
|
expect(state.shelveCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shelving increments shelveCount", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Trigger shelve twice
|
||||||
|
// First round: 15 failures -> shelve (halves to ~7)
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
|
||||||
|
}
|
||||||
|
const state1 = mgr.getState("a")!;
|
||||||
|
expect(state1.shelveCount).toBe(1);
|
||||||
|
|
||||||
|
// After halving, totalFailures is ~7. Need 8 more to reach 15 again.
|
||||||
|
const remaining = SHELVE_THRESHOLD - state1.totalFailures;
|
||||||
|
for (let i = 0; i < remaining; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
|
||||||
|
}
|
||||||
|
const state2 = mgr.getState("a")!;
|
||||||
|
expect(state2.shelveCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shelve decision always has shouldRetry=true", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
|
||||||
|
}
|
||||||
|
// The 15th call itself triggers shelve
|
||||||
|
// Let's re-check: the state now has halved counters.
|
||||||
|
// One more batch to trigger shelve again
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
const needed = SHELVE_THRESHOLD - state.totalFailures;
|
||||||
|
for (let i = 0; i < needed - 1; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
|
||||||
|
}
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
expect(d.delayMs).toBe(SHELVE_DELAY_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shelve is checked before per-kind exhaustion", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// NetworkReset has maxRetries=3. If we mix kinds to reach 15 total
|
||||||
|
// without exhausting any single kind, shelve takes priority.
|
||||||
|
// Use 5 kinds, 3 each = 15
|
||||||
|
const kinds = [
|
||||||
|
DownloadErrorKind.Timeout,
|
||||||
|
DownloadErrorKind.ServerError,
|
||||||
|
DownloadErrorKind.RateLimited,
|
||||||
|
DownloadErrorKind.Unknown,
|
||||||
|
DownloadErrorKind.WriteDrainTimeout,
|
||||||
|
];
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
|
||||||
|
}
|
||||||
|
// 15th failure -> shelve (not per-kind exhaustion)
|
||||||
|
const d = mgr.evaluate("a", mkError(kinds[4]));
|
||||||
|
expect(d.actions).toContain("shelve");
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 8) resetItem() — clears retry state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resetItem()", () => {
|
||||||
|
it("removes all state for the given item", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
expect(mgr.getState("a")).toBeDefined();
|
||||||
|
|
||||||
|
mgr.resetItem("a");
|
||||||
|
expect(mgr.getState("a")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after reset, the item starts fresh", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Accumulate some failures
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3);
|
||||||
|
mgr.resetItem("a");
|
||||||
|
|
||||||
|
// First failure after reset should be attempt 1
|
||||||
|
const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
expect(d.reason).toContain("1/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetting one item does not affect other items", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("b", mkError(DownloadErrorKind.Timeout));
|
||||||
|
|
||||||
|
mgr.resetItem("a");
|
||||||
|
expect(mgr.getState("a")).toBeUndefined();
|
||||||
|
expect(mgr.getState("b")).toBeDefined();
|
||||||
|
expect(mgr.getState("b")!.totalFailures).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetting a non-existent item is a no-op", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Should not throw
|
||||||
|
expect(() => mgr.resetItem("nonexistent")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 9) softReset() — halves counters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("softReset()", () => {
|
||||||
|
it("halves failure counts for all items", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.Timeout, 8);
|
||||||
|
failNTimes(mgr, "b", DownloadErrorKind.ServerError, 6);
|
||||||
|
|
||||||
|
mgr.softReset();
|
||||||
|
|
||||||
|
const stateA = mgr.getState("a")!;
|
||||||
|
expect(stateA.failuresByKind[DownloadErrorKind.Timeout]).toBe(4);
|
||||||
|
expect(stateA.totalFailures).toBe(4);
|
||||||
|
|
||||||
|
const stateB = mgr.getState("b")!;
|
||||||
|
expect(stateB.failuresByKind[DownloadErrorKind.ServerError]).toBe(3);
|
||||||
|
expect(stateB.totalFailures).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses floor division (odd counts lose the remainder)", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.Timeout, 5);
|
||||||
|
|
||||||
|
mgr.softReset();
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); // floor(5/2)
|
||||||
|
expect(state.totalFailures).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("totalFailures is recalculated from individual kind counts", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.Timeout, 7);
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.ServerError, 3);
|
||||||
|
// total = 10
|
||||||
|
|
||||||
|
mgr.softReset();
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
// Timeout: floor(7/2) = 3, ServerError: floor(3/2) = 1 => total = 4
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(3);
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(1);
|
||||||
|
expect(state.totalFailures).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("double softReset keeps halving", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.Timeout, 8);
|
||||||
|
|
||||||
|
mgr.softReset(); // 8 -> 4
|
||||||
|
mgr.softReset(); // 4 -> 2
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2);
|
||||||
|
expect(state.totalFailures).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("softReset on zero-failure items is a no-op", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.LinkDead)); // permanent, but state exists
|
||||||
|
const stateBefore = { ...mgr.getState("a")! };
|
||||||
|
|
||||||
|
// totalFailures is 1, so softReset will halve it
|
||||||
|
mgr.softReset();
|
||||||
|
const stateAfter = mgr.getState("a")!;
|
||||||
|
// floor(1/2) = 0
|
||||||
|
expect(stateAfter.totalFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("softReset does not remove items from the map", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.Timeout, 2);
|
||||||
|
|
||||||
|
mgr.softReset();
|
||||||
|
expect(mgr.getState("a")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("softReset allows previously exhausted kinds to retry", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// NetworkReset maxRetries=3. Exhaust it.
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3);
|
||||||
|
const exhausted = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(exhausted.shouldRetry).toBe(false);
|
||||||
|
|
||||||
|
// softReset: kindCount 4 -> 2, total 4 -> 2
|
||||||
|
mgr.softReset();
|
||||||
|
// Now kindCount=2, effectiveMax=3, so 2 <= 3 → retry
|
||||||
|
const recovered = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(recovered.shouldRetry).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 10) State export/import — roundtrip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("exportStates() and importStates()", () => {
|
||||||
|
it("roundtrips state faithfully", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.ServerError));
|
||||||
|
mgr.evaluate("b", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
|
||||||
|
const exported = mgr.exportStates();
|
||||||
|
|
||||||
|
const mgr2 = new RetryManager();
|
||||||
|
mgr2.importStates(exported);
|
||||||
|
|
||||||
|
expect(mgr2.getState("a")!.totalFailures).toBe(2);
|
||||||
|
expect(mgr2.getState("a")!.failuresByKind[DownloadErrorKind.Timeout]).toBe(1);
|
||||||
|
expect(mgr2.getState("a")!.failuresByKind[DownloadErrorKind.ServerError]).toBe(1);
|
||||||
|
expect(mgr2.getState("b")!.totalFailures).toBe(1);
|
||||||
|
expect(mgr2.getState("b")!.failuresByKind[DownloadErrorKind.NetworkReset]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exported states are deep copies (no shared references)", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
|
||||||
|
const exported = mgr.exportStates();
|
||||||
|
// Mutate the export
|
||||||
|
exported["a"].totalFailures = 999;
|
||||||
|
exported["a"].failuresByKind[DownloadErrorKind.Timeout] = 999;
|
||||||
|
|
||||||
|
// Original should be unaffected
|
||||||
|
const state = mgr.getState("a")!;
|
||||||
|
expect(state.totalFailures).toBe(1);
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("importStates clears previous state", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("b", mkError(DownloadErrorKind.ServerError));
|
||||||
|
|
||||||
|
// Import only "c"
|
||||||
|
mgr.importStates({
|
||||||
|
c: {
|
||||||
|
failuresByKind: { [DownloadErrorKind.DnsFailure]: 1 },
|
||||||
|
totalFailures: 1,
|
||||||
|
shelveCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mgr.getState("a")).toBeUndefined();
|
||||||
|
expect(mgr.getState("b")).toBeUndefined();
|
||||||
|
expect(mgr.getState("c")).toBeDefined();
|
||||||
|
expect(mgr.getState("c")!.totalFailures).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("importStates deep-copies input (no shared references)", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
const input: Record<string, RetryState> = {
|
||||||
|
x: {
|
||||||
|
failuresByKind: { [DownloadErrorKind.Timeout]: 3 },
|
||||||
|
totalFailures: 3,
|
||||||
|
shelveCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mgr.importStates(input);
|
||||||
|
// Mutate the input after import
|
||||||
|
input.x.totalFailures = 999;
|
||||||
|
input.x.failuresByKind[DownloadErrorKind.Timeout] = 999;
|
||||||
|
|
||||||
|
const state = mgr.getState("x")!;
|
||||||
|
expect(state.totalFailures).toBe(3);
|
||||||
|
expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty export for fresh manager", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
const exported = mgr.exportStates();
|
||||||
|
expect(exported).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import empty object clears all state", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.importStates({});
|
||||||
|
expect(mgr.getState("a")).toBeUndefined();
|
||||||
|
expect(mgr.exportStates()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shelveCount survives export/import roundtrip", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
// Trigger shelve
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
|
||||||
|
}
|
||||||
|
const originalShelve = mgr.getState("a")!.shelveCount;
|
||||||
|
expect(originalShelve).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const exported = mgr.exportStates();
|
||||||
|
const mgr2 = new RetryManager();
|
||||||
|
mgr2.importStates(exported);
|
||||||
|
expect(mgr2.getState("a")!.shelveCount).toBe(originalShelve);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continued evaluation works after import", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 2);
|
||||||
|
|
||||||
|
const exported = mgr.exportStates();
|
||||||
|
const mgr2 = new RetryManager();
|
||||||
|
mgr2.importStates(exported);
|
||||||
|
|
||||||
|
// 3rd attempt (maxRetries=3) should still be retryable
|
||||||
|
const d = mgr2.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d.shouldRetry).toBe(true);
|
||||||
|
|
||||||
|
// 4th attempt exceeds limit
|
||||||
|
const d2 = mgr2.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
|
||||||
|
expect(d2.shouldRetry).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// restoreState() and removeItem()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("restoreState()", () => {
|
||||||
|
it("restores a single item's state", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.restoreState("x", {
|
||||||
|
failuresByKind: { [DownloadErrorKind.Timeout]: 5 },
|
||||||
|
totalFailures: 5,
|
||||||
|
shelveCount: 1,
|
||||||
|
lastErrorKind: DownloadErrorKind.Timeout,
|
||||||
|
lastErrorMessage: "stalled",
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = mgr.getState("x")!;
|
||||||
|
expect(state.totalFailures).toBe(5);
|
||||||
|
expect(state.shelveCount).toBe(1);
|
||||||
|
expect(state.lastErrorKind).toBe(DownloadErrorKind.Timeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restoreState does not affect other items", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.restoreState("b", {
|
||||||
|
failuresByKind: {},
|
||||||
|
totalFailures: 0,
|
||||||
|
shelveCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mgr.getState("a")!.totalFailures).toBe(1);
|
||||||
|
expect(mgr.getState("b")!.totalFailures).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeItem()", () => {
|
||||||
|
it("removes state for a specific item", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
mgr.evaluate("a", mkError(DownloadErrorKind.Timeout));
|
||||||
|
mgr.evaluate("b", mkError(DownloadErrorKind.Timeout));
|
||||||
|
|
||||||
|
mgr.removeItem("a");
|
||||||
|
expect(mgr.getState("a")).toBeUndefined();
|
||||||
|
expect(mgr.getState("b")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removing non-existent item is a no-op", () => {
|
||||||
|
const mgr = new RetryManager();
|
||||||
|
expect(() => mgr.removeItem("nope")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -192,6 +192,10 @@ describe("settings storage", () => {
|
|||||||
realdebrid: 1024,
|
realdebrid: 1024,
|
||||||
megadebrid: 2048
|
megadebrid: 2048
|
||||||
} as AppSettings["providerDailyLimitBytes"],
|
} as AppSettings["providerDailyLimitBytes"],
|
||||||
|
providerTotalUsageBytes: {
|
||||||
|
realdebrid: 16384,
|
||||||
|
megadebrid: 32768
|
||||||
|
} as AppSettings["providerTotalUsageBytes"],
|
||||||
debridLinkApiKeyDailyLimitBytes: {
|
debridLinkApiKeyDailyLimitBytes: {
|
||||||
[debridLinkKey.id]: 3072,
|
[debridLinkKey.id]: 3072,
|
||||||
stale: 1234
|
stale: 1234
|
||||||
@ -204,6 +208,10 @@ describe("settings storage", () => {
|
|||||||
debridLinkApiKeyDailyUsageBytes: {
|
debridLinkApiKeyDailyUsageBytes: {
|
||||||
[debridLinkKey.id]: 8192,
|
[debridLinkKey.id]: 8192,
|
||||||
stale: 9999
|
stale: 9999
|
||||||
|
},
|
||||||
|
debridLinkApiKeyTotalUsageBytes: {
|
||||||
|
[debridLinkKey.id]: 12288,
|
||||||
|
stale: 9999
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -212,9 +220,16 @@ describe("settings storage", () => {
|
|||||||
expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({
|
expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({
|
||||||
[debridLinkKey.id]: 3072
|
[debridLinkKey.id]: 3072
|
||||||
});
|
});
|
||||||
|
expect(normalized.providerTotalUsageBytes).toEqual({
|
||||||
|
realdebrid: 16384,
|
||||||
|
"megadebrid-api": 32768
|
||||||
|
});
|
||||||
expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey());
|
expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey());
|
||||||
expect(normalized.providerDailyUsageBytes).toEqual({});
|
expect(normalized.providerDailyUsageBytes).toEqual({});
|
||||||
expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({});
|
expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({});
|
||||||
|
expect(normalized.debridLinkApiKeyTotalUsageBytes).toEqual({
|
||||||
|
[debridLinkKey.id]: 12288
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes archive password list line endings", () => {
|
it("normalizes archive password list line endings", () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user