Fix session stats, extraction UX, and queue UI issues

This commit is contained in:
Sucukdeluxe 2026-03-08 03:42:06 +01:00
parent 842933e748
commit 38c9058beb
18 changed files with 759 additions and 64 deletions

View File

@ -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();

View File

@ -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
}; };

View File

@ -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 || {}) }
}; };
} }

View File

@ -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
@ -289,8 +296,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 || {}) }
}; };
} }
@ -573,7 +582,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 +714,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 +722,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 +1104,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;
@ -1254,6 +1272,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 +1416,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 +1429,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 +1441,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 +3370,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 +3478,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 +3589,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 +3621,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 +4185,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 {
@ -5180,12 +5223,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;
} }
} }
@ -6438,7 +6485,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 +6887,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) {
@ -7436,6 +7491,9 @@ export class DownloadManager extends EventEmitter {
error: lastError, error: lastError,
targetPath: effectiveTargetPath targetPath: effectiveTargetPath
}); });
if (lastError.startsWith("range_ignored_on_resume:")) {
throw new Error(`direct_link_retry_exhausted:${lastError}`);
}
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)`;
@ -8250,12 +8308,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 +8337,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})`;
} }
@ -8698,12 +8765,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 +8799,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}`;
} }

View File

@ -1038,10 +1038,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")
@ -1961,14 +1984,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 });

View File

@ -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();

View File

@ -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)
}; };

View File

@ -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),

View File

@ -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 &gt;</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>

View File

@ -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));

View File

@ -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",

View File

@ -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 }>;

View File

@ -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
};
}

View File

@ -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;
} }

View File

@ -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(
[ [

View File

@ -324,6 +324,155 @@ 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("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);
@ -3431,6 +3580,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 +5910,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 +5927,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 +5943,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 +5959,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 () => {

View File

@ -13,6 +13,7 @@ import {
classifyExtractionError, classifyExtractionError,
findArchiveCandidates, findArchiveCandidates,
orderExtractorCandidatesForArchive, orderExtractorCandidatesForArchive,
resolveExtractorBackendModeForArchive,
resolveExtractorBackendMode, resolveExtractorBackendMode,
} from "../src/main/extractor"; } from "../src/main/extractor";
@ -1176,6 +1177,17 @@ 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("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", () => {

View File

@ -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", () => {