Fix session stats, extraction UX, and queue UI issues
This commit is contained in:
parent
842933e748
commit
38c9058beb
@ -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 || {}) }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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,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 () => {
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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