Compare commits
No commits in common. "541860db0a9ff36b3ebc9e15f928039f5ae0776e" and "01b6ef7bddeb307db085abdb5ad2b2a906c4db6a" have entirely different histories.
541860db0a
...
01b6ef7bdd
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.6.99",
|
"version": "1.6.98",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridProvider,
|
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
@ -18,7 +16,6 @@ import {
|
|||||||
UpdateInstallProgress,
|
UpdateInstallProgress,
|
||||||
UpdateInstallResult
|
UpdateInstallResult
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
|
||||||
import { importDlcContainers } from "./container";
|
import { importDlcContainers } from "./container";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
import { DownloadManager } from "./download-manager";
|
import { DownloadManager } from "./download-manager";
|
||||||
@ -179,11 +176,6 @@ export class AppController {
|
|||||||
// Preserve the live totalDownloadedAllTime from the download manager
|
// Preserve the live totalDownloadedAllTime 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.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
|
||||||
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
|
||||||
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
|
||||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).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);
|
||||||
@ -201,30 +193,6 @@ export class AppController {
|
|||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
|
|
||||||
const liveSettings = this.manager.getSettings();
|
|
||||||
const nextSettings = normalizeSettings({
|
|
||||||
...liveSettings,
|
|
||||||
...resetProviderDailyUsage(liveSettings, provider)
|
|
||||||
});
|
|
||||||
this.settings = nextSettings;
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
|
||||||
this.manager.setSettings(this.settings);
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
|
|
||||||
const liveSettings = this.manager.getSettings();
|
|
||||||
const nextSettings = normalizeSettings({
|
|
||||||
...liveSettings,
|
|
||||||
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
|
|
||||||
});
|
|
||||||
this.settings = nextSettings;
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
|
||||||
this.manager.setSettings(this.settings);
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async openRealDebridLoginWindow(): Promise<void> {
|
public async openRealDebridLoginWindow(): Promise<void> {
|
||||||
await this.realDebridWebFallback.openLoginWindow();
|
await this.realDebridWebFallback.openLoginWindow();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { AppSettings } from "../shared/types";
|
import { AppSettings } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
|
||||||
import packageJson from "../../package.json";
|
import packageJson from "../../package.json";
|
||||||
|
|
||||||
export const APP_NAME = "Multi Debrid Downloader";
|
export const APP_NAME = "Multi Debrid Downloader";
|
||||||
@ -95,8 +94,6 @@ export function defaultSettings(): AppSettings {
|
|||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
theme: "dark" as const,
|
theme: "dark" as const,
|
||||||
collapseNewPackages: true,
|
collapseNewPackages: true,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
|
||||||
autoSortPackagesByProgress: true,
|
|
||||||
autoSkipExtracted: false,
|
autoSkipExtracted: false,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
@ -106,11 +103,6 @@ export function defaultSettings(): AppSettings {
|
|||||||
autoExtractWhenStopped: true,
|
autoExtractWhenStopped: true,
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
hosterRouting: {},
|
hosterRouting: {},
|
||||||
providerDailyLimitBytes: {},
|
|
||||||
providerDailyUsageBytes: {},
|
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|
||||||
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
|
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
|
||||||
import { isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
|
|
||||||
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||||
@ -67,20 +65,10 @@ interface DebridServiceOptions {
|
|||||||
function cloneSettings(settings: AppSettings): AppSettings {
|
function cloneSettings(settings: AppSettings): AppSettings {
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
|
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry }))
|
||||||
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
|
|
||||||
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
|
|
||||||
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) {
|
|
||||||
return parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter(
|
|
||||||
(entry) => !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
||||||
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
|
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
|
||||||
}
|
}
|
||||||
@ -1317,31 +1305,27 @@ export async function fetchAllDebridHostInfo(token: string, host = "rapidgator",
|
|||||||
// ── Debrid-Link Client ──
|
// ── Debrid-Link Client ──
|
||||||
|
|
||||||
class DebridLinkClient {
|
class DebridLinkClient {
|
||||||
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
|
private apiKeys: string[];
|
||||||
private currentKeyIndex: number = 0;
|
private currentKeyIndex: number = 0;
|
||||||
|
|
||||||
public constructor(apiKeysRaw: string) {
|
public constructor(apiKeysRaw: string) {
|
||||||
this.apiKeys = parseDebridLinkApiKeys(apiKeysRaw);
|
this.apiKeys = apiKeysRaw
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map((k) => k.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, settings: AppSettings, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
if (this.apiKeys.length === 0) {
|
if (this.apiKeys.length === 0) {
|
||||||
throw new Error("Debrid-Link: Kein API-Key konfiguriert");
|
throw new Error("Debrid-Link: Kein API-Key konfiguriert");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getAvailableDebridLinkApiKeys(settings).length === 0) {
|
const startIndex = this.currentKeyIndex;
|
||||||
throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Tageslimit erreicht`);
|
let triedAll = false;
|
||||||
}
|
|
||||||
|
|
||||||
let checkedKeys = 0;
|
while (!triedAll) {
|
||||||
while (checkedKeys < this.apiKeys.length) {
|
|
||||||
const apiKey = this.apiKeys[this.currentKeyIndex];
|
const apiKey = this.apiKeys[this.currentKeyIndex];
|
||||||
checkedKeys += 1;
|
const keyLabel = this.apiKeys.length > 1 ? ` #${this.currentKeyIndex + 1}` : "";
|
||||||
const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : "";
|
|
||||||
if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) {
|
|
||||||
this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
@ -1351,7 +1335,7 @@ class DebridLinkClient {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
Authorization: `Bearer ${apiKey.token}`
|
Authorization: `Bearer ${apiKey}`
|
||||||
},
|
},
|
||||||
body: `url=${encodeURIComponent(link)}`,
|
body: `url=${encodeURIComponent(link)}`,
|
||||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
@ -1399,9 +1383,7 @@ class DebridLinkClient {
|
|||||||
directUrl,
|
directUrl,
|
||||||
fileSize,
|
fileSize,
|
||||||
retriesUsed: attempt - 1,
|
retriesUsed: attempt - 1,
|
||||||
sourceLabel: apiKey.label,
|
sourceLabel: keyLabel ? `#${this.currentKeyIndex + 1}` : "API"
|
||||||
sourceAccountId: apiKey.id,
|
|
||||||
sourceAccountLabel: apiKey.label
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
@ -1418,6 +1400,9 @@ class DebridLinkClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
|
this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
|
||||||
|
if (this.currentKeyIndex === startIndex) {
|
||||||
|
triedAll = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Limit erreicht`);
|
throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Limit erreicht`);
|
||||||
@ -1930,29 +1915,6 @@ export class DebridService {
|
|||||||
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
|
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean {
|
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
|
||||||
if (effectiveProvider === "debridlink") {
|
|
||||||
const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
|
|
||||||
if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isProviderDailyLimitReached(settings, effectiveProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isProviderSelectableFor(settings: AppSettings, provider: DebridProvider): boolean {
|
|
||||||
return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string {
|
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
|
||||||
if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
|
|
||||||
return "Debrid-Link Tageslimit erreicht (alle API-Keys ausgeschopft)";
|
|
||||||
}
|
|
||||||
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
|
|
||||||
@ -1961,7 +1923,7 @@ export class DebridService {
|
|||||||
const hosterKey = extractHosterFromUrl(link);
|
const hosterKey = extractHosterFromUrl(link);
|
||||||
if (hosterKey && routing[hosterKey]) {
|
if (hosterKey && routing[hosterKey]) {
|
||||||
const routedProvider = routing[hosterKey];
|
const routedProvider = routing[hosterKey];
|
||||||
if (this.isProviderSelectableFor(settings, routedProvider)) {
|
if (this.isProviderConfiguredFor(settings, routedProvider)) {
|
||||||
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
||||||
@ -1987,8 +1949,6 @@ export class DebridService {
|
|||||||
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
|
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
|
||||||
// Fall through to normal provider chain
|
// Fall through to normal provider chain
|
||||||
}
|
}
|
||||||
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
|
|
||||||
logger.info(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`);
|
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`);
|
||||||
}
|
}
|
||||||
@ -1996,7 +1956,7 @@ export class DebridService {
|
|||||||
|
|
||||||
// 1Fichier is a direct file hoster. If the link is a 1fichier.com URL
|
// 1Fichier is a direct file hoster. If the link is a 1fichier.com URL
|
||||||
// and the API key is configured, use 1Fichier directly before debrid providers.
|
// and the API key is configured, use 1Fichier directly before debrid providers.
|
||||||
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
if (ONEFICHIER_URL_RE.test(link) && this.isProviderConfiguredFor(settings, "onefichier")) {
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
||||||
return {
|
return {
|
||||||
@ -2016,7 +1976,7 @@ export class DebridService {
|
|||||||
// DDownload is a direct file hoster, not a debrid service.
|
// DDownload is a direct file hoster, not a debrid service.
|
||||||
// If the link is a ddownload.com/ddl.to URL and the account is configured,
|
// If the link is a ddownload.com/ddl.to URL and the account is configured,
|
||||||
// use DDownload directly before trying any debrid providers.
|
// use DDownload directly before trying any debrid providers.
|
||||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderConfiguredFor(settings, "ddownload")) {
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
||||||
return {
|
return {
|
||||||
@ -2043,14 +2003,8 @@ export class DebridService {
|
|||||||
if (!this.isProviderConfiguredFor(settings, primary)) {
|
if (!this.isProviderConfiguredFor(settings, primary)) {
|
||||||
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
|
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
|
||||||
}
|
}
|
||||||
const selectedProvider = this.isProviderDailyLimited(settings, primary)
|
|
||||||
? order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider))
|
|
||||||
: primary;
|
|
||||||
if (!selectedProvider) {
|
|
||||||
throw new Error(this.formatProviderLimitMessage(settings, primary));
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, primary, link, signal);
|
||||||
let fileName = result.fileName;
|
let fileName = result.fileName;
|
||||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||||
const fromPage = await resolveRapidgatorFilename(link, signal);
|
const fromPage = await resolveRapidgatorFilename(link, signal);
|
||||||
@ -2061,20 +2015,19 @@ export class DebridService {
|
|||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
fileName,
|
fileName,
|
||||||
provider: selectedProvider,
|
provider: primary,
|
||||||
providerLabel: PROVIDER_LABELS[selectedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
|
providerLabel: PROVIDER_LABELS[primary] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[selectedProvider]}: ${errorText}`);
|
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${errorText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let configuredFound = false;
|
let configuredFound = false;
|
||||||
let limitReachedFound = false;
|
|
||||||
const attempts: string[] = [];
|
const attempts: string[] = [];
|
||||||
|
|
||||||
for (const provider of order) {
|
for (const provider of order) {
|
||||||
@ -2082,11 +2035,6 @@ export class DebridService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
configuredFound = true;
|
configuredFound = true;
|
||||||
if (this.isProviderDailyLimited(settings, provider)) {
|
|
||||||
limitReachedFound = true;
|
|
||||||
attempts.push(this.formatProviderLimitMessage(settings, provider));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
||||||
@ -2115,9 +2063,6 @@ export class DebridService {
|
|||||||
if (!configuredFound) {
|
if (!configuredFound) {
|
||||||
throw new Error("Kein Debrid-Provider konfiguriert");
|
throw new Error("Kein Debrid-Provider konfiguriert");
|
||||||
}
|
}
|
||||||
if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) {
|
|
||||||
throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht");
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
|
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
|
||||||
}
|
}
|
||||||
@ -2193,7 +2138,7 @@ export class DebridService {
|
|||||||
return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal);
|
return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (effectiveProvider === "debridlink") {
|
if (effectiveProvider === "debridlink") {
|
||||||
const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, settings, signal);
|
const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, signal);
|
||||||
dlResult.sourceLabel = dlResult.sourceLabel || "API";
|
dlResult.sourceLabel = dlResult.sourceLabel || "API";
|
||||||
return dlResult;
|
return dlResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,8 +20,6 @@ import {
|
|||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot
|
UiSnapshot
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|
||||||
import { addDebridLinkApiKeyDailyUsageBytes, addProviderDailyUsageBytes, getProviderUsageDayKey, isDebridLinkApiKeyDailyLimitReached, 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
|
||||||
@ -79,7 +77,7 @@ const DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS = 120000;
|
|||||||
|
|
||||||
const DEFAULT_LOW_THROUGHPUT_MIN_BYTES = 64 * 1024;
|
const DEFAULT_LOW_THROUGHPUT_MIN_BYTES = 64 * 1024;
|
||||||
|
|
||||||
const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 100 * 1024;
|
const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
const ALLDEBRID_HOST_INFO_TTL_MS = 60000;
|
const ALLDEBRID_HOST_INFO_TTL_MS = 60000;
|
||||||
|
|
||||||
@ -200,11 +198,7 @@ function cloneSession(session: SessionState): SessionState {
|
|||||||
function cloneSettings(settings: AppSettings): AppSettings {
|
function cloneSettings(settings: AppSettings): AppSettings {
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
|
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry }))
|
||||||
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
|
|
||||||
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
|
|
||||||
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1075,7 +1069,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
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);
|
||||||
this.settings = next;
|
this.settings = next;
|
||||||
this.ensureProviderDailyUsageFresh(nowMs());
|
|
||||||
this.debridService.setSettings(next);
|
this.debridService.setSettings(next);
|
||||||
this.allDebridHostInfoCache.clear();
|
this.allDebridHostInfoCache.clear();
|
||||||
|
|
||||||
@ -1143,7 +1136,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public getSnapshot(): UiSnapshot {
|
public getSnapshot(): UiSnapshot {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
this.ensureProviderDailyUsageFresh(now, true);
|
|
||||||
this.pruneSpeedEvents(now);
|
this.pruneSpeedEvents(now);
|
||||||
const paused = this.session.running && this.session.paused;
|
const paused = this.session.running && this.session.paused;
|
||||||
const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS;
|
const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS;
|
||||||
@ -4418,46 +4410,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return remaining;
|
return remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureProviderDailyUsageFresh(now = nowMs(), persist = false): void {
|
|
||||||
const currentDay = getProviderUsageDayKey(now);
|
|
||||||
if (this.settings.providerDailyUsageDay === currentDay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.settings.providerDailyUsageDay = currentDay;
|
|
||||||
this.settings.providerDailyUsageBytes = {};
|
|
||||||
this.settings.debridLinkApiKeyDailyUsageBytes = {};
|
|
||||||
this.statsCache = null;
|
|
||||||
this.statsCacheAt = 0;
|
|
||||||
if (persist) {
|
|
||||||
this.lastSettingsPersistAt = now;
|
|
||||||
void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private recordProviderDownloadedBytes(provider: DownloadItem["provider"], byteDelta: number, providerAccountId?: string): void {
|
|
||||||
if (!provider) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
|
||||||
const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta);
|
|
||||||
this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay;
|
|
||||||
this.settings.providerDailyUsageBytes = nextUsage.providerDailyUsageBytes;
|
|
||||||
if (effectiveProvider === "debridlink" && providerAccountId) {
|
|
||||||
const nextKeyUsage = addDebridLinkApiKeyDailyUsageBytes(this.settings, providerAccountId, byteDelta);
|
|
||||||
this.settings.providerDailyUsageDay = nextKeyUsage.providerDailyUsageDay;
|
|
||||||
this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isProviderConfigured(provider: DebridProvider): boolean {
|
private isProviderConfigured(provider: DebridProvider): boolean {
|
||||||
this.ensureProviderDailyUsageFresh(nowMs());
|
|
||||||
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
||||||
if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) {
|
if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isProviderDailyLimitReached(this.settings, effectiveProvider)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (effectiveProvider === "realdebrid") {
|
if (effectiveProvider === "realdebrid") {
|
||||||
return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
|
return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
|
||||||
}
|
}
|
||||||
@ -4482,8 +4439,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return Boolean(this.settings.oneFichierApiKey.trim());
|
return Boolean(this.settings.oneFichierApiKey.trim());
|
||||||
}
|
}
|
||||||
if (effectiveProvider === "debridlink") {
|
if (effectiveProvider === "debridlink") {
|
||||||
const configuredKeys = parseDebridLinkApiKeys(this.settings.debridLinkApiKeys);
|
return Boolean(this.settings.debridLinkApiKeys.trim());
|
||||||
return configuredKeys.some((entry) => !isDebridLinkApiKeyDailyLimitReached(this.settings, entry.id));
|
|
||||||
}
|
}
|
||||||
if (provider === "linksnappy") {
|
if (provider === "linksnappy") {
|
||||||
return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim());
|
return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim());
|
||||||
@ -4515,10 +4471,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private getExpectedProviderForItem(item: DownloadItem): DebridProvider | null {
|
private getExpectedProviderForItem(item: DownloadItem): DebridProvider | null {
|
||||||
if (item.provider) {
|
if (item.provider) {
|
||||||
const resolvedProvider = resolveMegaDebridProvider(this.settings, item.provider);
|
return resolveMegaDebridProvider(this.settings, item.provider);
|
||||||
if (resolvedProvider && this.isProviderConfigured(resolvedProvider)) {
|
|
||||||
return resolvedProvider;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosterKey = extractHosterKey(item.url);
|
const hosterKey = extractHosterKey(item.url);
|
||||||
@ -5279,8 +5232,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.recordProviderSuccess(this.getProviderFailureKeyForItem(item, unrestricted.provider));
|
this.recordProviderSuccess(this.getProviderFailureKeyForItem(item, unrestricted.provider));
|
||||||
item.provider = unrestricted.provider;
|
item.provider = unrestricted.provider;
|
||||||
item.providerLabel = unrestricted.providerLabel;
|
item.providerLabel = unrestricted.providerLabel;
|
||||||
item.providerAccountId = unrestricted.sourceAccountId;
|
|
||||||
item.providerAccountLabel = unrestricted.sourceAccountLabel;
|
|
||||||
item.retries += unrestricted.retriesUsed;
|
item.retries += unrestricted.retriesUsed;
|
||||||
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
||||||
try {
|
try {
|
||||||
@ -5390,7 +5341,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
|
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
throw new Error(`Datei zu klein (${humanSize(fileSizeOnDisk)}, erwartet ${item.totalBytes ? humanSize(item.totalBytes) : ">= 100 KB"})`);
|
throw new Error(`Datei zu klein (${humanSize(fileSizeOnDisk)}, erwartet ${item.totalBytes ? humanSize(item.totalBytes) : ">= 1 MB"})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
done = true;
|
done = true;
|
||||||
@ -6203,7 +6154,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.totalDownloadedBytes += buffer.length;
|
this.session.totalDownloadedBytes += buffer.length;
|
||||||
this.sessionDownloadedBytes += buffer.length;
|
this.sessionDownloadedBytes += buffer.length;
|
||||||
this.settings.totalDownloadedAllTime += buffer.length;
|
this.settings.totalDownloadedAllTime += buffer.length;
|
||||||
this.recordProviderDownloadedBytes(item.provider, buffer.length, item.providerAccountId);
|
|
||||||
this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length);
|
this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length);
|
||||||
this.recordSpeed(buffer.length, item.packageId);
|
this.recordSpeed(buffer.length, item.packageId);
|
||||||
throughputWindowBytes += buffer.length;
|
throughputWindowBytes += buffer.length;
|
||||||
@ -7048,7 +6998,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Show transitional label while next archive initializes
|
// Show transitional label while next archive initializes
|
||||||
const done = currentCount;
|
const done = currentCount;
|
||||||
if (done < progress.total) {
|
if (done < progress.total) {
|
||||||
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`;
|
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Naechstes Archiv...`;
|
||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -7425,7 +7375,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Show transitional label while next archive initializes
|
// Show transitional label while next archive initializes
|
||||||
const done = currentCount;
|
const done = currentCount;
|
||||||
if (done < progress.total) {
|
if (done < progress.total) {
|
||||||
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true);
|
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Naechstes Archiv...`, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update this archive's items with per-archive progress
|
// Update this archive's items with per-archive progress
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||||
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types";
|
import { AddLinksPayload, AppSettings, UpdateInstallProgress } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { getLogFilePath, logger } from "./logger";
|
import { getLogFilePath, logger } from "./logger";
|
||||||
@ -26,17 +26,6 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
|
|||||||
|
|
||||||
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const RENAME_PACKAGE_MAX_CHARS = 240;
|
const RENAME_PACKAGE_MAX_CHARS = 240;
|
||||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
|
||||||
"realdebrid",
|
|
||||||
"megadebrid-api",
|
|
||||||
"megadebrid-web",
|
|
||||||
"bestdebrid",
|
|
||||||
"alldebrid",
|
|
||||||
"ddownload",
|
|
||||||
"onefichier",
|
|
||||||
"debridlink",
|
|
||||||
"linksnappy"
|
|
||||||
]);
|
|
||||||
function validateStringArray(value: unknown, name: string): string[] {
|
function validateStringArray(value: unknown, name: string): string[] {
|
||||||
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
||||||
throw new Error(`${name} muss ein String-Array sein`);
|
throw new Error(`${name} muss ein String-Array sein`);
|
||||||
@ -300,20 +289,6 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
|
||||||
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
|
||||||
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
|
|
||||||
throw new Error("provider ist ungültig");
|
|
||||||
}
|
|
||||||
return controller.resetProviderDailyUsage(validatedProvider);
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => {
|
|
||||||
const validatedKeyId = validateString(keyId, "keyId").trim();
|
|
||||||
if (!validatedKeyId) {
|
|
||||||
throw new Error("keyId ist ungültig");
|
|
||||||
}
|
|
||||||
return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId);
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
||||||
validatePlainObject(payload ?? {}, "payload");
|
validatePlainObject(payload ?? {}, "payload");
|
||||||
validateString(payload?.rawText, "rawText");
|
validateString(payload?.rawText, "rawText");
|
||||||
|
|||||||
@ -10,8 +10,6 @@ export interface UnrestrictedLink {
|
|||||||
retriesUsed: number;
|
retriesUsed: number;
|
||||||
skipTlsVerify?: boolean;
|
skipTlsVerify?: boolean;
|
||||||
sourceLabel?: string;
|
sourceLabel?: string;
|
||||||
sourceAccountId?: string;
|
|
||||||
sourceAccountLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRetryStatus(status: number): boolean {
|
function shouldRetryStatus(status: number): boolean {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import fsp from "node:fs/promises";
|
import fsp from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
|
||||||
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
|
||||||
import { defaultSettings } from "./constants";
|
import { defaultSettings } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
@ -145,57 +143,6 @@ function normalizeDisabledProviders(raw: unknown): DebridProvider[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProviderByteMap(
|
|
||||||
raw: unknown,
|
|
||||||
megaDebridPreferApi: boolean,
|
|
||||||
megaDebridApiEnabled: boolean,
|
|
||||||
megaDebridWebEnabled: boolean,
|
|
||||||
mergeMode: "max" | "sum"
|
|
||||||
): Partial<Record<DebridProvider, number>> {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Partial<Record<DebridProvider, number>> = {};
|
|
||||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
||||||
const provider = normalizeConfiguredProvider(key, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
||||||
if (!provider) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
||||||
if (bytes <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (mergeMode === "sum") {
|
|
||||||
result[provider] = (result[provider] || 0) + bytes;
|
|
||||||
} else {
|
|
||||||
result[provider] = Math.max(result[provider] || 0, bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Record<string, number> {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = new Set(allowedKeys);
|
|
||||||
const result: Record<string, number> = {};
|
|
||||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
||||||
const normalizedKey = String(key || "").trim();
|
|
||||||
if (!normalizedKey || !allowed.has(normalizedKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
||||||
if (bytes <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result[normalizedKey] = bytes;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
|
function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
const result: Record<string, DebridProvider> = {};
|
const result: Record<string, DebridProvider> = {};
|
||||||
@ -258,7 +205,6 @@ function migrateUpdateRepo(raw: string, fallback: string): string {
|
|||||||
|
|
||||||
export function normalizeSettings(settings: AppSettings): AppSettings {
|
export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||||
const defaults = defaultSettings();
|
const defaults = defaultSettings();
|
||||||
const currentUsageDay = getProviderUsageDayKey();
|
|
||||||
const megaLogin = asText(settings.megaLogin);
|
const megaLogin = asText(settings.megaLogin);
|
||||||
const megaPassword = asText(settings.megaPassword);
|
const megaPassword = asText(settings.megaPassword);
|
||||||
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
||||||
@ -269,24 +215,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
||||||
? Boolean(settings.megaDebridWebEnabled)
|
? Boolean(settings.megaDebridWebEnabled)
|
||||||
: (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
: (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
||||||
const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay);
|
|
||||||
const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw)
|
|
||||||
? providerDailyUsageDayRaw
|
|
||||||
: currentUsageDay;
|
|
||||||
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
|
|
||||||
const providerDailyUsageBytes = normalizeProviderByteMap(
|
|
||||||
settings.providerDailyUsageBytes,
|
|
||||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
||||||
"sum"
|
|
||||||
);
|
|
||||||
const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap(
|
|
||||||
settings.debridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyIds
|
|
||||||
);
|
|
||||||
const debridLinkApiKeyDailyUsageBytes = normalizeNamedByteMap(
|
|
||||||
settings.debridLinkApiKeyDailyUsageBytes,
|
|
||||||
debridLinkApiKeyIds
|
|
||||||
);
|
|
||||||
const normalized: AppSettings = {
|
const normalized: AppSettings = {
|
||||||
token: asText(settings.token),
|
token: asText(settings.token),
|
||||||
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||||
@ -345,10 +273,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
clipboardWatch: Boolean(settings.clipboardWatch),
|
clipboardWatch: Boolean(settings.clipboardWatch),
|
||||||
minimizeToTray: Boolean(settings.minimizeToTray),
|
minimizeToTray: Boolean(settings.minimizeToTray),
|
||||||
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
|
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
|
||||||
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
|
|
||||||
? Boolean(settings.accountListShowDetailedDebridLinkKeys)
|
|
||||||
: defaults.accountListShowDetailedDebridLinkKeys,
|
|
||||||
autoSortPackagesByProgress: settings.autoSortPackagesByProgress !== undefined ? Boolean(settings.autoSortPackagesByProgress) : defaults.autoSortPackagesByProgress,
|
|
||||||
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
||||||
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,
|
||||||
@ -358,17 +282,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
extractCpuPriority: settings.extractCpuPriority,
|
extractCpuPriority: settings.extractCpuPriority,
|
||||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
||||||
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
||||||
hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
|
hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled)
|
||||||
providerDailyLimitBytes: normalizeProviderByteMap(
|
|
||||||
settings.providerDailyLimitBytes,
|
|
||||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
||||||
"max"
|
|
||||||
),
|
|
||||||
providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {},
|
|
||||||
debridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
|
|
||||||
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
|
||||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
||||||
@ -500,9 +414,6 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
packageId,
|
packageId,
|
||||||
url,
|
url,
|
||||||
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
|
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
|
||||||
providerLabel: asText(item.providerLabel) || undefined,
|
|
||||||
providerAccountId: asText(item.providerAccountId) || undefined,
|
|
||||||
providerAccountLabel: asText(item.providerAccountLabel) || undefined,
|
|
||||||
status,
|
status,
|
||||||
retries: clampNumber(item.retries, 0, 0, 1_000_000),
|
retries: clampNumber(item.retries, 0, 0, 1_000_000),
|
||||||
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
|
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridProvider,
|
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
@ -24,8 +23,6 @@ const api: ElectronApi = {
|
|||||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
|
||||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
|
|
||||||
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||||
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|
||||||
import type {
|
import type {
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@ -17,16 +16,7 @@ import type {
|
|||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
UpdateInstallProgress
|
UpdateInstallProgress
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import {
|
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "./package-order";
|
||||||
getDebridLinkApiKeyDailyLimitBytes,
|
|
||||||
getDebridLinkApiKeyDailyRemainingBytes,
|
|
||||||
getDebridLinkApiKeyDailyUsageBytes,
|
|
||||||
getProviderDailyLimitBytes,
|
|
||||||
getProviderDailyRemainingBytes,
|
|
||||||
getProviderDailyUsageBytes,
|
|
||||||
getProviderUsageDayKey
|
|
||||||
} from "../shared/provider-daily-limits";
|
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
|
||||||
|
|
||||||
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
|
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
|
||||||
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
|
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
|
||||||
@ -98,36 +88,17 @@ interface AccountDialogState {
|
|||||||
token: string;
|
token: string;
|
||||||
login: string;
|
login: string;
|
||||||
password: string;
|
password: string;
|
||||||
dailyLimitGb: string;
|
|
||||||
keyDailyLimitGbById: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DebridLinkAccountKeyEntry {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
masked: string;
|
|
||||||
dailyUsedBytes: number;
|
|
||||||
dailyLimitBytes: number;
|
|
||||||
dailyRemainingBytes: number | null;
|
|
||||||
dailyLimitReached: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfiguredAccountEntry {
|
interface ConfiguredAccountEntry {
|
||||||
kind: AccountKind;
|
kind: AccountKind;
|
||||||
service: AccountService;
|
service: AccountService;
|
||||||
provider: DebridProvider;
|
|
||||||
serviceLabel: string;
|
serviceLabel: string;
|
||||||
modeLabel: string;
|
modeLabel: string;
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
summaryLines: string[];
|
|
||||||
note: string;
|
note: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
dailyUsedBytes: number;
|
|
||||||
dailyLimitBytes: number;
|
|
||||||
dailyRemainingBytes: number | null;
|
|
||||||
dailyLimitReached: boolean;
|
|
||||||
debridLinkKeys: DebridLinkAccountKeyEntry[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCOUNT_OPTIONS: AccountOption[] = [
|
const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||||
@ -231,15 +202,14 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
|||||||
kind: "linksnappy-login",
|
kind: "linksnappy-login",
|
||||||
service: "linksnappy",
|
service: "linksnappy",
|
||||||
serviceLabel: "LinkSnappy",
|
serviceLabel: "LinkSnappy",
|
||||||
title: "LinkSnappy Web",
|
title: "LinkSnappy Login",
|
||||||
modeLabel: "Web",
|
modeLabel: "Login",
|
||||||
pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.",
|
pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.",
|
||||||
needsCredentials: true
|
needsCredentials: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||||
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
|
|
||||||
const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths";
|
const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths";
|
||||||
const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = {
|
const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = {
|
||||||
service: 220,
|
service: 220,
|
||||||
@ -283,40 +253,6 @@ function findAccountOption(kind: AccountKind): AccountOption {
|
|||||||
return option;
|
return option;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccountServiceProvider(service: AccountService): DebridProvider {
|
|
||||||
return service as DebridProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAccountDailyLimitInput(limitBytes: number): string {
|
|
||||||
if (limitBytes <= 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB;
|
|
||||||
const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2;
|
|
||||||
return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAccountDailyLimitInputBytes(value: string): number | null {
|
|
||||||
const normalized = value.trim().replace(",", ".");
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record<string, string>, settings?: AppSettings): Record<string, string> {
|
|
||||||
const next: Record<string, string> = {};
|
|
||||||
for (const key of parseDebridLinkApiKeys(rawKeys)) {
|
|
||||||
next[key.id] = values?.[key.id]
|
|
||||||
?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAccountPickerFunctionLabel(option: AccountOption): string {
|
function getAccountPickerFunctionLabel(option: AccountOption): string {
|
||||||
switch (option.kind) {
|
switch (option.kind) {
|
||||||
case "realdebrid-api":
|
case "realdebrid-api":
|
||||||
@ -475,16 +411,6 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] {
|
|
||||||
if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) {
|
|
||||||
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
|
||||||
if (keys.length > 1) {
|
|
||||||
return keys.map((entry) => `${entry.label}: ${entry.masked}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [summarizeAccount(kind, settings)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
|
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
return {
|
return {
|
||||||
@ -492,47 +418,35 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
kind: null,
|
kind: null,
|
||||||
token: "",
|
token: "",
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: ""
|
||||||
dailyLimitGb: "",
|
|
||||||
keyDailyLimitGbById: {}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const provider = getAccountServiceProvider(findAccountOption(kind).service);
|
|
||||||
const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider));
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "realdebrid-api":
|
case "realdebrid-api":
|
||||||
return { mode, kind, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: settings.token, login: "", password: "" };
|
||||||
case "realdebrid-web":
|
case "realdebrid-web":
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: "", password: "" };
|
||||||
case "megadebrid-api":
|
case "megadebrid-api":
|
||||||
case "megadebrid-web":
|
case "megadebrid-web":
|
||||||
return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword, dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword };
|
||||||
case "bestdebrid-api":
|
case "bestdebrid-api":
|
||||||
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: settings.bestToken, login: "", password: "" };
|
||||||
case "bestdebrid-web":
|
case "bestdebrid-web":
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: "", password: "" };
|
||||||
case "alldebrid-api":
|
case "alldebrid-api":
|
||||||
return { mode, kind, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: settings.allDebridToken, login: "", password: "" };
|
||||||
case "alldebrid-web":
|
case "alldebrid-web":
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: "", password: "" };
|
||||||
case "ddownload-login":
|
case "ddownload-login":
|
||||||
return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword };
|
||||||
case "onefichier-api":
|
case "onefichier-api":
|
||||||
return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "" };
|
||||||
case "debridlink-api":
|
case "debridlink-api":
|
||||||
return {
|
return { mode, kind, token: settings.debridLinkApiKeys || "", login: "", password: "" };
|
||||||
mode,
|
|
||||||
kind,
|
|
||||||
token: settings.debridLinkApiKeys || "",
|
|
||||||
login: "",
|
|
||||||
password: "",
|
|
||||||
dailyLimitGb,
|
|
||||||
keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings)
|
|
||||||
};
|
|
||||||
case "linksnappy-login":
|
case "linksnappy-login":
|
||||||
return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "" };
|
||||||
default:
|
default:
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} };
|
return { mode, kind, token: "", login: "", password: "" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -543,101 +457,60 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial
|
|||||||
const token = dialog.token.trim();
|
const token = dialog.token.trim();
|
||||||
const login = dialog.login.trim();
|
const login = dialog.login.trim();
|
||||||
const password = dialog.password;
|
const password = dialog.password;
|
||||||
const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service);
|
|
||||||
const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) };
|
|
||||||
const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api"
|
|
||||||
? Object.fromEntries(
|
|
||||||
parseDebridLinkApiKeys(dialog.token).flatMap((entry) => {
|
|
||||||
const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || "");
|
|
||||||
return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : [];
|
|
||||||
})
|
|
||||||
) as Record<string, number>
|
|
||||||
: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) };
|
|
||||||
const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb);
|
|
||||||
if (dailyLimitBytes && dailyLimitBytes > 0) {
|
|
||||||
nextProviderDailyLimitBytes[provider] = dailyLimitBytes;
|
|
||||||
} else {
|
|
||||||
delete nextProviderDailyLimitBytes[provider];
|
|
||||||
}
|
|
||||||
switch (dialog.kind) {
|
switch (dialog.kind) {
|
||||||
case "realdebrid-api":
|
case "realdebrid-api":
|
||||||
return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, token, realDebridUseWebLogin: false };
|
||||||
case "realdebrid-web":
|
case "realdebrid-web":
|
||||||
return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, token: "", realDebridUseWebLogin: true };
|
||||||
case "megadebrid-api":
|
case "megadebrid-api":
|
||||||
return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true };
|
||||||
case "megadebrid-web":
|
case "megadebrid-web":
|
||||||
return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
||||||
case "bestdebrid-api":
|
case "bestdebrid-api":
|
||||||
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, bestToken: token, bestDebridUseWebLogin: false };
|
||||||
case "bestdebrid-web":
|
case "bestdebrid-web":
|
||||||
return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, bestToken: "", bestDebridUseWebLogin: true };
|
||||||
case "alldebrid-api":
|
case "alldebrid-api":
|
||||||
return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, allDebridToken: token, allDebridUseWebLogin: false };
|
||||||
case "alldebrid-web":
|
case "alldebrid-web":
|
||||||
return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, allDebridToken: "", allDebridUseWebLogin: true };
|
||||||
case "ddownload-login":
|
case "ddownload-login":
|
||||||
return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, ddownloadLogin: login, ddownloadPassword: password };
|
||||||
case "onefichier-api":
|
case "onefichier-api":
|
||||||
return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, oneFichierApiKey: token };
|
||||||
case "debridlink-api":
|
case "debridlink-api":
|
||||||
return {
|
return { ...settings, debridLinkApiKeys: token };
|
||||||
...settings,
|
|
||||||
debridLinkApiKeys: token,
|
|
||||||
providerDailyLimitBytes: nextProviderDailyLimitBytes,
|
|
||||||
debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes
|
|
||||||
};
|
|
||||||
case "linksnappy-login":
|
case "linksnappy-login":
|
||||||
return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password };
|
||||||
default:
|
default:
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings {
|
function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings {
|
||||||
const provider = getAccountServiceProvider(service);
|
|
||||||
const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) };
|
|
||||||
const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) };
|
|
||||||
const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) };
|
|
||||||
const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) };
|
|
||||||
delete nextProviderDailyLimitBytes[provider];
|
|
||||||
delete nextProviderDailyUsageBytes[provider];
|
|
||||||
if (service === "debridlink") {
|
|
||||||
for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) {
|
|
||||||
delete nextDebridLinkApiKeyDailyLimitBytes[key.id];
|
|
||||||
delete nextDebridLinkApiKeyDailyUsageBytes[key.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (service) {
|
switch (service) {
|
||||||
case "realdebrid":
|
case "realdebrid":
|
||||||
return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
return { ...settings, token: "", realDebridUseWebLogin: false };
|
||||||
case "megadebrid-api":
|
case "megadebrid-api":
|
||||||
return settings.megaDebridWebEnabled
|
return settings.megaDebridWebEnabled
|
||||||
? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }
|
? { ...settings, megaDebridApiEnabled: false }
|
||||||
: { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
: { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false };
|
||||||
case "megadebrid-web":
|
case "megadebrid-web":
|
||||||
return settings.megaDebridApiEnabled
|
return settings.megaDebridApiEnabled
|
||||||
? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }
|
? { ...settings, megaDebridWebEnabled: false }
|
||||||
: { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
: { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false };
|
||||||
case "bestdebrid":
|
case "bestdebrid":
|
||||||
return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
return { ...settings, bestToken: "", bestDebridUseWebLogin: false };
|
||||||
case "alldebrid":
|
case "alldebrid":
|
||||||
return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
return { ...settings, allDebridToken: "", allDebridUseWebLogin: false };
|
||||||
case "ddownload":
|
case "ddownload":
|
||||||
return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
return { ...settings, ddownloadLogin: "", ddownloadPassword: "" };
|
||||||
case "onefichier":
|
case "onefichier":
|
||||||
return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
return { ...settings, oneFichierApiKey: "" };
|
||||||
case "debridlink":
|
case "debridlink":
|
||||||
return {
|
return { ...settings, debridLinkApiKeys: "" };
|
||||||
...settings,
|
|
||||||
debridLinkApiKeys: "",
|
|
||||||
providerDailyLimitBytes: nextProviderDailyLimitBytes,
|
|
||||||
providerDailyUsageBytes: nextProviderDailyUsageBytes,
|
|
||||||
debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes
|
|
||||||
};
|
|
||||||
case "linksnappy":
|
case "linksnappy":
|
||||||
return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes };
|
return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "" };
|
||||||
default:
|
default:
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@ -659,24 +532,6 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
|
|||||||
return `${option.title}: Bitte Passwort eintragen.`;
|
return `${option.title}: Bitte Passwort eintragen.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dialog.dailyLimitGb.trim()) {
|
|
||||||
const parsed = Number(dialog.dailyLimitGb.trim().replace(",", "."));
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
||||||
return `${option.title}: Tageslimit muss eine Zahl >= 0 sein.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dialog.kind === "debridlink-api") {
|
|
||||||
for (const key of parseDebridLinkApiKeys(dialog.token)) {
|
|
||||||
const raw = dialog.keyDailyLimitGbById?.[key.id] || "";
|
|
||||||
if (!raw.trim()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsed = Number(raw.trim().replace(",", "."));
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
||||||
return `${option.title}: ${key.label} Limit muss eine Zahl >= 0 sein.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -701,19 +556,12 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||||
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, autoSkipExtracted: false, confirmDeleteSelection: true,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 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: {},
|
|
||||||
providerDailyUsageBytes: {},
|
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
||||||
scheduledStartEpochMs: 0
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
|
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
|
||||||
@ -1069,8 +917,8 @@ function sortPackageOrderBySize(order: string[], packages: Record<string, Packag
|
|||||||
function sortPackageOrderByHoster(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
function sortPackageOrderByHoster(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
||||||
const sorted = [...order];
|
const sorted = [...order];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase();
|
const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase();
|
||||||
const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase();
|
const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase();
|
||||||
const cmp = hosterA.localeCompare(hosterB);
|
const cmp = hosterA.localeCompare(hosterB);
|
||||||
return descending ? -cmp : cmp;
|
return descending ? -cmp : cmp;
|
||||||
});
|
});
|
||||||
@ -1636,13 +1484,41 @@ export function App(): ReactElement {
|
|||||||
? Math.max(0, totalPackageCount - packages.length)
|
? Math.max(0, totalPackageCount - packages.length)
|
||||||
: 0;
|
: 0;
|
||||||
const visiblePackages = useMemo(() => {
|
const visiblePackages = useMemo(() => {
|
||||||
return sortPackagesForDisplay(
|
if (!snapshot.session.running || packages.length <= 1) {
|
||||||
packages,
|
return packages;
|
||||||
snapshot.session.items,
|
}
|
||||||
snapshot.session.running,
|
const activeStatuses = new Set(["downloading", "validating", "integrity_check", "extracting"]);
|
||||||
settingsDraft.autoSortPackagesByProgress
|
const active: PackageEntry[] = [];
|
||||||
);
|
const rest: PackageEntry[] = [];
|
||||||
}, [packages, settingsDraft.autoSortPackagesByProgress, snapshot.session.running, snapshot.session.items]);
|
for (const pkg of packages) {
|
||||||
|
const hasActive = pkg.itemIds.some((id) => {
|
||||||
|
const item = snapshot.session.items[id];
|
||||||
|
return item && activeStatuses.has(item.status);
|
||||||
|
});
|
||||||
|
if (hasActive) {
|
||||||
|
active.push(pkg);
|
||||||
|
} else {
|
||||||
|
rest.push(pkg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active.length === 0 || active.length === packages.length) {
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
// Sort active packages: highest completion percentage first
|
||||||
|
active.sort((a, b) => {
|
||||||
|
const aItems = a.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean);
|
||||||
|
const bItems = b.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean);
|
||||||
|
const aPct = aItems.length > 0 ? aItems.filter((i) => i.status === "completed").length / aItems.length : 0;
|
||||||
|
const bPct = bItems.length > 0 ? bItems.filter((i) => i.status === "completed").length / bItems.length : 0;
|
||||||
|
if (aPct !== bPct) {
|
||||||
|
return bPct - aPct;
|
||||||
|
}
|
||||||
|
const aBytes = aItems.reduce((s, i) => s + (i.downloadedBytes || 0), 0);
|
||||||
|
const bBytes = bItems.reduce((s, i) => s + (i.downloadedBytes || 0), 0);
|
||||||
|
return bBytes - aBytes;
|
||||||
|
});
|
||||||
|
return [...active, ...rest];
|
||||||
|
}, [packages, snapshot.session.running, snapshot.session.items]);
|
||||||
|
|
||||||
const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim());
|
const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim());
|
||||||
const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin
|
const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin
|
||||||
@ -1771,76 +1647,23 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (kind === "debridlink-api") {
|
if (kind === "debridlink-api") {
|
||||||
const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length;
|
const keyCount = (settingsDraft.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()).length;
|
||||||
statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Konfiguriert";
|
statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Konfiguriert";
|
||||||
}
|
}
|
||||||
const provider = getAccountServiceProvider(service);
|
const isDisabled = (settingsDraft.disabledProviders || []).includes(service as DebridProvider);
|
||||||
const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider);
|
|
||||||
const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider);
|
|
||||||
const dailyRemainingBytes = getProviderDailyRemainingBytes({
|
|
||||||
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
|
|
||||||
providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes,
|
|
||||||
providerDailyUsageDay: snapshot.settings.providerDailyUsageDay
|
|
||||||
}, provider);
|
|
||||||
let dailyLimitReached = dailyLimitBytes > 0 && dailyUsedBytes >= dailyLimitBytes;
|
|
||||||
const isDisabled = (settingsDraft.disabledProviders || []).includes(provider);
|
|
||||||
const debridLinkKeys = kind === "debridlink-api"
|
|
||||||
? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => {
|
|
||||||
const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id);
|
|
||||||
const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id);
|
|
||||||
const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({
|
|
||||||
debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes,
|
|
||||||
providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes,
|
|
||||||
providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes,
|
|
||||||
providerDailyUsageDay: snapshot.settings.providerDailyUsageDay
|
|
||||||
}, key.id);
|
|
||||||
return {
|
|
||||||
id: key.id,
|
|
||||||
label: key.label,
|
|
||||||
masked: key.masked,
|
|
||||||
dailyUsedBytes: keyDailyUsedBytes,
|
|
||||||
dailyLimitBytes: keyDailyLimitBytes,
|
|
||||||
dailyRemainingBytes: keyDailyRemainingBytes,
|
|
||||||
dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
if (kind === "debridlink-api" && debridLinkKeys.length > 0) {
|
|
||||||
const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length;
|
|
||||||
if (limitedCount > 0) {
|
|
||||||
const limitNote = `${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`;
|
|
||||||
note = note ? `${limitNote} ${note}` : limitNote;
|
|
||||||
}
|
|
||||||
if (limitedCount === debridLinkKeys.length) {
|
|
||||||
dailyLimitReached = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dailyLimitReached) {
|
|
||||||
note = note
|
|
||||||
? `Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster. ${note}`
|
|
||||||
: "Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.";
|
|
||||||
}
|
|
||||||
entries.push({
|
entries.push({
|
||||||
kind,
|
kind,
|
||||||
service,
|
service,
|
||||||
provider,
|
|
||||||
serviceLabel: option.serviceLabel,
|
serviceLabel: option.serviceLabel,
|
||||||
modeLabel: option.modeLabel,
|
modeLabel: option.modeLabel,
|
||||||
statusLabel: isDisabled ? "Deaktiviert" : statusLabel,
|
statusLabel: isDisabled ? "Deaktiviert" : statusLabel,
|
||||||
summary: summarizeAccount(kind, settingsDraft),
|
summary: summarizeAccount(kind, settingsDraft),
|
||||||
summaryLines: summarizeAccountLines(kind, settingsDraft),
|
|
||||||
note,
|
note,
|
||||||
disabled: isDisabled,
|
disabled: isDisabled
|
||||||
dailyUsedBytes,
|
|
||||||
dailyLimitBytes,
|
|
||||||
dailyRemainingBytes,
|
|
||||||
dailyLimitReached,
|
|
||||||
debridLinkKeys
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]);
|
}, [settingsDraft, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]);
|
||||||
|
|
||||||
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]);
|
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]);
|
||||||
const availableAccountOptions = useMemo(() => (
|
const availableAccountOptions = useMemo(() => (
|
||||||
@ -2004,21 +1827,6 @@ export function App(): ReactElement {
|
|||||||
applyTheme(result.theme);
|
applyTheme(result.theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncLiveProviderUsageSettings = (result: AppSettings): void => {
|
|
||||||
setSnapshot((prev) => ({ ...prev, settings: result }));
|
|
||||||
if (!settingsDirtyRef.current) {
|
|
||||||
applyPersistedSettings(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSettingsDraft((prev) => ({
|
|
||||||
...prev,
|
|
||||||
totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime),
|
|
||||||
providerDailyUsageDay: result.providerDailyUsageDay,
|
|
||||||
providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) },
|
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistSpecificSettings = async (nextDraft: AppSettings): Promise<AppSettings> => {
|
const persistSpecificSettings = async (nextDraft: AppSettings): Promise<AppSettings> => {
|
||||||
const normalizedDraft = {
|
const normalizedDraft = {
|
||||||
...nextDraft,
|
...nextDraft,
|
||||||
@ -2033,16 +1841,16 @@ export function App(): ReactElement {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case "realdebrid-login":
|
case "realdebrid-login":
|
||||||
await window.rd.openRealDebridLogin();
|
await window.rd.openRealDebridLogin();
|
||||||
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
|
showToast("Real-Debrid Login-Fenster geoeffnet", 2200);
|
||||||
return;
|
return;
|
||||||
case "bestdebrid-cookies": {
|
case "bestdebrid-cookies": {
|
||||||
const count = await window.rd.importBestDebridCookies();
|
const count = await window.rd.importBestDebridCookies();
|
||||||
showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewählt", 2200);
|
showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewaehlt", 2200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "alldebrid-login":
|
case "alldebrid-login":
|
||||||
await window.rd.openAllDebridLogin();
|
await window.rd.openAllDebridLogin();
|
||||||
showToast("AllDebrid Login-Fenster geöffnet", 2200);
|
showToast("AllDebrid Login-Fenster geoeffnet", 2200);
|
||||||
return;
|
return;
|
||||||
case "alldebrid-status":
|
case "alldebrid-status":
|
||||||
await loadAllDebridHostInfo(false);
|
await loadAllDebridHostInfo(false);
|
||||||
@ -2090,7 +1898,6 @@ export function App(): ReactElement {
|
|||||||
next.login = prev.login;
|
next.login = prev.login;
|
||||||
next.password = prev.password;
|
next.password = prev.password;
|
||||||
}
|
}
|
||||||
next.dailyLimitGb = prev.dailyLimitGb;
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -2147,26 +1954,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
|
||||||
await performQuickAction(async () => {
|
|
||||||
const result = await window.rd.resetProviderDailyUsage(getAccountServiceProvider(entry.service));
|
|
||||||
syncLiveProviderUsageSettings(result);
|
|
||||||
showToast(`${entry.serviceLabel}: Tageszähler zurückgesetzt`, 2200);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`${entry.serviceLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise<void> => {
|
|
||||||
await performQuickAction(async () => {
|
|
||||||
const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId);
|
|
||||||
syncLiveProviderUsageSettings(result);
|
|
||||||
showToast(`${entry.serviceLabel} ${keyLabel}: Tageszähler zurückgesetzt`, 2200);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`${entry.serviceLabel} ${keyLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
||||||
const meta = getAccountQuickActionMeta(entry.kind);
|
const meta = getAccountQuickActionMeta(entry.kind);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
@ -2708,7 +2495,7 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = [...order];
|
pendingPackageOrderRef.current = [...order];
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
packageOrderRef.current = [...order];
|
packageOrderRef.current = [...order];
|
||||||
// Optimistic UI update ? apply the new order immediately so the user
|
// Optimistic UI update — apply the new order immediately so the user
|
||||||
// sees the change without waiting for the backend round-trip.
|
// sees the change without waiting for the backend round-trip.
|
||||||
setSnapshot((prev) => {
|
setSnapshot((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@ -3025,7 +2812,7 @@ export function App(): ReactElement {
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||||
// Don't clear selection if an overlay is open ? let the overlay close first
|
// Don't clear selection if an overlay is open — let the overlay close first
|
||||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||||
@ -3436,8 +3223,8 @@ export function App(): ReactElement {
|
|||||||
<div className="schedule-ctrl">
|
<div className="schedule-ctrl">
|
||||||
{(snapshot.settings.scheduledStartEpochMs || 0) > 0 ? (
|
{(snapshot.settings.scheduledStartEpochMs || 0) > 0 ? (
|
||||||
<div className="schedule-active">
|
<div className="schedule-active">
|
||||||
<span className="schedule-badge" title="Geplanter Start">â° {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
|
<span className="schedule-badge" title="Geplanter Start">⏰ {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
<button className="schedule-cancel" title="Geplanten Start abbrechen" onClick={() => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }}>?</button>
|
<button className="schedule-cancel" title="Geplanten Start abbrechen" onClick={() => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@ -3902,7 +3689,6 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSortPackagesByProgress} onChange={(e) => setBool("autoSortPackagesByProgress", e.target.checked)} /> Automatisches Sortieren laufender Pakete nach Fortschritt</label>
|
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
||||||
@ -3923,31 +3709,22 @@ export function App(): ReactElement {
|
|||||||
<div className="account-board-header">
|
<div className="account-board-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>Accounts</h3>
|
<h3>Accounts</h3>
|
||||||
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
|
<div className="hint">Accounts werden als Liste verwaltet. Neue Eintraege kommen ueber den Dialog oben rechts dazu.</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
||||||
Account hinzufügen
|
Account hinzufuegen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="account-board-summary">
|
<div className="account-board-summary">
|
||||||
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
|
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
|
||||||
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
|
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfuegbar</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="toggle-line account-display-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settingsDraft.accountListShowDetailedDebridLinkKeys}
|
|
||||||
onChange={(e) => setBool("accountListShowDetailedDebridLinkKeys", e.target.checked)}
|
|
||||||
/>
|
|
||||||
Debrid-Link-Keys im Feld "Zugang" einzeln untereinander anzeigen
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{configuredAccounts.length === 0 && (
|
{configuredAccounts.length === 0 && (
|
||||||
<div className="account-empty-state">
|
<div className="account-empty-state">
|
||||||
<strong>Noch keine Accounts hinterlegt</strong>
|
<strong>Noch keine Accounts hinterlegt</strong>
|
||||||
<span>Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span>
|
<span>Fuege ueber "Account hinzufuegen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -4018,57 +3795,9 @@ export function App(): ReactElement {
|
|||||||
<div className="account-cell account-status-cell">
|
<div className="account-cell account-status-cell">
|
||||||
<span className={`account-status-pill${allDebridStateClass}`}>{entry.statusLabel}</span>
|
<span className={`account-status-pill${allDebridStateClass}`}>{entry.statusLabel}</span>
|
||||||
{entry.note && <span className="account-note">{entry.note}</span>}
|
{entry.note && <span className="account-note">{entry.note}</span>}
|
||||||
<div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}>
|
|
||||||
<span>Heute: {humanSize(entry.dailyUsedBytes)}</span>
|
|
||||||
<span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span>
|
|
||||||
{entry.dailyLimitBytes > 0 && (
|
|
||||||
<span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span>
|
|
||||||
)}
|
|
||||||
{entry.dailyLimitBytes <= 0 && entry.dailyLimitReached && entry.debridLinkKeys.length > 0 && (
|
|
||||||
<span>Fallback aktiv</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{entry.debridLinkKeys.length > 0 && (
|
|
||||||
<div className="account-subkey-list">
|
|
||||||
{entry.debridLinkKeys.map((key) => (
|
|
||||||
<div key={key.id} className={`account-subkey-row${key.dailyLimitReached ? " warning" : ""}`}>
|
|
||||||
<div className="account-subkey-main">
|
|
||||||
<div className="account-subkey-head">
|
|
||||||
<strong>{key.label}</strong>
|
|
||||||
<span>{key.masked}</span>
|
|
||||||
</div>
|
|
||||||
<div className="account-subkey-stats">
|
|
||||||
<span>Heute: {humanSize(key.dailyUsedBytes)}</span>
|
|
||||||
<span>{key.dailyLimitBytes > 0 ? `Limit: ${humanSize(key.dailyLimitBytes)}` : "Kein Limit"}</span>
|
|
||||||
{key.dailyLimitBytes > 0 && (
|
|
||||||
<span>{key.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(key.dailyRemainingBytes || 0)}`}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="account-subkey-actions">
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
disabled={actionBusy || key.dailyUsedBytes <= 0}
|
|
||||||
onClick={() => { void onResetDebridLinkApiKeyDailyUsage(entry, key.id, key.label); }}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="account-cell">
|
<div className="account-cell">
|
||||||
{entry.summaryLines.length > 1 ? (
|
|
||||||
<div className="account-secret account-secret-multiline">
|
|
||||||
{entry.summaryLines.map((line) => (
|
|
||||||
<span key={line}>{line}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="account-secret">{entry.summary}</span>
|
<span className="account-secret">{entry.summary}</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="account-cell account-row-actions">
|
<div className="account-cell account-row-actions">
|
||||||
{showStatusButton && (
|
{showStatusButton && (
|
||||||
@ -4084,9 +3813,6 @@ export function App(): ReactElement {
|
|||||||
<button className="btn" disabled={actionBusy} onClick={() => { void onToggleAccountEnabled(entry); }}>
|
<button className="btn" disabled={actionBusy} onClick={() => { void onToggleAccountEnabled(entry); }}>
|
||||||
{entry.disabled ? "Aktivieren" : "Deaktivieren"}
|
{entry.disabled ? "Aktivieren" : "Deaktivieren"}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" disabled={actionBusy || entry.dailyUsedBytes <= 0} onClick={() => { void onResetAccountDailyUsage(entry); }}>
|
|
||||||
Reset Heute
|
|
||||||
</button>
|
|
||||||
<button className="btn" disabled={actionBusy} onClick={() => openEditAccountDialog(entry.kind)}>
|
<button className="btn" disabled={actionBusy} onClick={() => openEditAccountDialog(entry.kind)}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
@ -4129,7 +3855,7 @@ export function App(): ReactElement {
|
|||||||
setProviderOrder(next);
|
setProviderOrder(next);
|
||||||
}}
|
}}
|
||||||
title="Nach oben"
|
title="Nach oben"
|
||||||
>?</button>
|
>▲</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
disabled={idx === activeProviderOrder.length - 1}
|
disabled={idx === activeProviderOrder.length - 1}
|
||||||
@ -4139,7 +3865,7 @@ export function App(): ReactElement {
|
|||||||
setProviderOrder(next);
|
setProviderOrder(next);
|
||||||
}}
|
}}
|
||||||
title="Nach unten"
|
title="Nach unten"
|
||||||
>?</button>
|
>▼</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -4229,7 +3955,7 @@ export function App(): ReactElement {
|
|||||||
{availableHosters.map((h) => (
|
{availableHosters.map((h) => (
|
||||||
<option key={h.id} value={h.id}>{h.label}</option>
|
<option key={h.id} value={h.id}>{h.label}</option>
|
||||||
))}
|
))}
|
||||||
<option value="" disabled>───────────</option>
|
<option value="" disabled>───────────</option>
|
||||||
<option value="__custom">Eigener Hoster...</option>
|
<option value="__custom">Eigener Hoster...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -4347,7 +4073,7 @@ export function App(): ReactElement {
|
|||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped ?? true} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
|
||||||
<div><label>Parallele Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /></div>
|
<div><label>Parallele Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /></div>
|
||||||
<div><label>Extraktions-Priorität</label><select value={settingsDraft.extractCpuPriority} onChange={(e) => setText("extractCpuPriority", e.target.value)}>
|
<div><label>Extraktions-Priorität</label><select value={settingsDraft.extractCpuPriority} onChange={(e) => setText("extractCpuPriority", e.target.value)}>
|
||||||
<option value="high">Hoch (80% CPU)</option>
|
<option value="high">Hoch (80% CPU)</option>
|
||||||
@ -4484,7 +4210,7 @@ export function App(): ReactElement {
|
|||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Bist Du Dir sicher?</h3>
|
<h3>Bist Du Dir sicher?</h3>
|
||||||
<p>Möchtest Du wirklich diese Aufräumaktion(en) durchführen?<br />Ausgewählte Links löschen</p>
|
<p>Möchtest Du wirklich diese Aufräumaktion(en) durchführen?<br />Ausgewählte Links löschen</p>
|
||||||
<p><strong>Zu erledigende Aufgaben:</strong><br />{parts.join(" + ")} löschen ? {totalRemaining} Link(s) verbleiben!</p>
|
<p><strong>Zu erledigende Aufgaben:</strong><br />{parts.join(" + ")} löschen – {totalRemaining} Link(s) verbleiben!</p>
|
||||||
<label className="toggle-line">
|
<label className="toggle-line">
|
||||||
<input type="checkbox" checked={deleteConfirm.dontAsk} onChange={(e) => setDeleteConfirm((prev) => prev ? { ...prev, dontAsk: e.target.checked } : prev)} />
|
<input type="checkbox" checked={deleteConfirm.dontAsk} onChange={(e) => setDeleteConfirm((prev) => prev ? { ...prev, dontAsk: e.target.checked } : prev)} />
|
||||||
Nicht mehr anzeigen
|
Nicht mehr anzeigen
|
||||||
@ -4512,7 +4238,7 @@ export function App(): ReactElement {
|
|||||||
<p>
|
<p>
|
||||||
<strong>{startConflictPrompt.entry.packageName}</strong> ist im Ziel bereits vorhanden.
|
<strong>{startConflictPrompt.entry.packageName}</strong> ist im Ziel bereits vorhanden.
|
||||||
</p>
|
</p>
|
||||||
<p>Bei "überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.</p>
|
<p>Bei "Überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.</p>
|
||||||
<p className="modal-path" title={startConflictPrompt.entry.extractDir}>{startConflictPrompt.entry.extractDir}</p>
|
<p className="modal-path" title={startConflictPrompt.entry.extractDir}>{startConflictPrompt.entry.extractDir}</p>
|
||||||
<label className="toggle-line">
|
<label className="toggle-line">
|
||||||
<input
|
<input
|
||||||
@ -4537,7 +4263,7 @@ export function App(): ReactElement {
|
|||||||
className="btn danger"
|
className="btn danger"
|
||||||
onClick={() => closeStartConflictPrompt({ policy: "overwrite", applyToAll: startConflictPrompt.applyToAll })}
|
onClick={() => closeStartConflictPrompt({ policy: "overwrite", applyToAll: startConflictPrompt.applyToAll })}
|
||||||
>
|
>
|
||||||
überschreiben
|
Überschreiben
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4549,7 +4275,7 @@ export function App(): ReactElement {
|
|||||||
<div className="modal-card account-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal-card account-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<div className="account-modal-header">
|
<div className="account-modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufügen"}</h3>
|
<h3>{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufuegen"}</h3>
|
||||||
<p>Wie in JDownloader: oben Account-Typ auswaehlen, unten Zugangsdaten direkt eintragen.</p>
|
<p>Wie in JDownloader: oben Account-Typ auswaehlen, unten Zugangsdaten direkt eintragen.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4573,7 +4299,7 @@ export function App(): ReactElement {
|
|||||||
<strong>Kein passender Account-Typ gefunden</strong>
|
<strong>Kein passender Account-Typ gefunden</strong>
|
||||||
<span>
|
<span>
|
||||||
{accountDialogSelectableOptions.length === 0
|
{accountDialogSelectableOptions.length === 0
|
||||||
? "Alle verfügbaren Typen sind bereits vorhanden."
|
? "Alle verfuegbaren Typen sind bereits vorhanden."
|
||||||
: "Passe den Suchbegriff an oder waehle einen Eintrag aus der Liste."}
|
: "Passe den Suchbegriff an oder waehle einen Eintrag aus der Liste."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -4621,17 +4347,7 @@ export function App(): ReactElement {
|
|||||||
<div>
|
<div>
|
||||||
<label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label>
|
<label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label>
|
||||||
{accountDialogOption.service === "debridlink" ? (
|
{accountDialogOption.service === "debridlink" ? (
|
||||||
<textarea
|
<textarea rows={4} placeholder="Ein API-Key pro Zeile" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} style={{ fontFamily: "monospace", resize: "vertical" }} />
|
||||||
rows={4}
|
|
||||||
placeholder="Ein API-Key pro Zeile"
|
|
||||||
value={accountDialog.token}
|
|
||||||
onChange={(event) => setAccountDialog((prev) => prev ? {
|
|
||||||
...prev,
|
|
||||||
token: event.target.value,
|
|
||||||
keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(event.target.value, prev.keyDailyLimitGbById, settingsDraft)
|
|
||||||
} : prev)}
|
|
||||||
style={{ fontFamily: "monospace", resize: "vertical" }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
|
<input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
|
||||||
)}
|
)}
|
||||||
@ -4651,54 +4367,14 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
|
||||||
<label>Tageslimit (GB, optional)</label>
|
|
||||||
<input
|
|
||||||
inputMode="decimal"
|
|
||||||
placeholder="z.B. 250"
|
|
||||||
value={accountDialog.dailyLimitGb}
|
|
||||||
onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, dailyLimitGb: event.target.value } : prev)}
|
|
||||||
/>
|
|
||||||
<div className="account-modal-note">Ab 00:00 wird der Zähler automatisch zurückgesetzt. Wenn das Limit erreicht ist, nutzt die App den nächsten Hoster aus der Reihenfolge.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{accountDialog.kind === "debridlink-api" && parseDebridLinkApiKeys(accountDialog.token).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label>API-Key Limits (GB, optional pro Key)</label>
|
|
||||||
<div className="account-dl-key-limit-list">
|
|
||||||
{parseDebridLinkApiKeys(accountDialog.token).map((key) => (
|
|
||||||
<div key={key.id} className="account-dl-key-limit-row">
|
|
||||||
<div className="account-dl-key-meta">
|
|
||||||
<strong>{key.label}</strong>
|
|
||||||
<span>{key.masked}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
inputMode="decimal"
|
|
||||||
placeholder="Kein Limit"
|
|
||||||
value={accountDialog.keyDailyLimitGbById[key.id] || ""}
|
|
||||||
onChange={(event) => setAccountDialog((prev) => prev ? {
|
|
||||||
...prev,
|
|
||||||
keyDailyLimitGbById: {
|
|
||||||
...prev.keyDailyLimitGbById,
|
|
||||||
[key.id]: event.target.value
|
|
||||||
}
|
|
||||||
} : prev)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="account-modal-note">Leer lassen = unbegrenzt. Die Limits gelten pro API-Key und werden täglich um 00:00 zurückgesetzt.</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{accountDialog.kind === "realdebrid-web" && (
|
{accountDialog.kind === "realdebrid-web" && (
|
||||||
<div className="account-modal-note">Nach dem Speichern kannst Du direkt das Browserfenster für den Web-Login öffnen.</div>
|
<div className="account-modal-note">Nach dem Speichern kannst Du direkt das Browserfenster für den Web-Login öffnen.</div>
|
||||||
)}
|
)}
|
||||||
{accountDialog.kind === "bestdebrid-web" && (
|
{accountDialog.kind === "bestdebrid-web" && (
|
||||||
<div className="account-modal-note">Der Web-Account arbeitet über einen Cookies.txt-Import aus dem Browser.</div>
|
<div className="account-modal-note">Der Web-Account arbeitet ueber einen Cookies.txt-Import aus dem Browser.</div>
|
||||||
)}
|
)}
|
||||||
{accountDialog.kind === "alldebrid-web" && (
|
{accountDialog.kind === "alldebrid-web" && (
|
||||||
<div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber läuft.</div>
|
<div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber laeuft.</div>
|
||||||
)}
|
)}
|
||||||
{accountDialog.kind === "megadebrid-api" && (
|
{accountDialog.kind === "megadebrid-api" && (
|
||||||
<div className="account-modal-note">Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div>
|
<div className="account-modal-note">Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div>
|
||||||
@ -4787,7 +4463,7 @@ export function App(): ReactElement {
|
|||||||
}}>Leeren</button>
|
}}>Leeren</button>
|
||||||
)}
|
)}
|
||||||
{snapshot.clipboardActive && (
|
{snapshot.clipboardActive && (
|
||||||
<button className="btn footer-btn btn-active" title="Zwischenablage-Überwachung ist aktiv ? kopierte Links werden automatisch erkannt und zur Queue hinzugefügt. Zum Deaktivieren: Einstellungen ? Zwischenablage überwachen" disabled={actionBusy} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
|
<button className="btn footer-btn btn-active" title="Zwischenablage-Überwachung ist aktiv — kopierte Links werden automatisch erkannt und zur Queue hinzugefügt. Zum Deaktivieren: Einstellungen → Zwischenablage überwachen" disabled={actionBusy} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
|
||||||
Clipboard: An
|
Clipboard: An
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -4813,18 +4489,16 @@ export function App(): ReactElement {
|
|||||||
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
|
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
|
||||||
{contextMenu && (() => {
|
{contextMenu && (() => {
|
||||||
const multi = selectedIds.size > 1;
|
const multi = selectedIds.size > 1;
|
||||||
const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
|
const hasPackages = [...selectedIds].some((id) => snapshot.session.packages[id]);
|
||||||
const selectedItemIds = [...selectedIds].filter((id) => snapshot.session.items[id]);
|
|
||||||
const hasPackages = selectedPackageIds.length > 0;
|
|
||||||
const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]);
|
const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]);
|
||||||
const hasStartableItems = [...selectedIds].some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
|
const hasStartableItems = [...selectedIds].some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
|
||||||
const hasItems = selectedItemIds.length > 0;
|
const hasItems = [...selectedIds].some((id) => snapshot.session.items[id]);
|
||||||
return (
|
return (
|
||||||
<div ref={ctxMenuRef} className="ctx-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(e) => e.stopPropagation()}>
|
<div ref={ctxMenuRef} className="ctx-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(e) => e.stopPropagation()}>
|
||||||
{(hasPackages || hasStartableItems) && (
|
{(hasPackages || hasStartableItems) && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" onClick={() => {
|
||||||
const pkgIds = selectedPackageIds;
|
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
|
||||||
const itemIds = selectedItemIds.filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
|
const itemIds = [...selectedIds].filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
|
||||||
if (pkgIds.length > 0) void window.rd.startPackages(pkgIds).catch(() => {});
|
if (pkgIds.length > 0) void window.rd.startPackages(pkgIds).catch(() => {});
|
||||||
if (itemIds.length > 0) void window.rd.startItems(itemIds).catch(() => {});
|
if (itemIds.length > 0) void window.rd.startItems(itemIds).catch(() => {});
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@ -4850,26 +4524,29 @@ export function App(): ReactElement {
|
|||||||
else { executeDeleteSelection(ids); }
|
else { executeDeleteSelection(ids); }
|
||||||
}}>Entfernen</button>
|
}}>Entfernen</button>
|
||||||
)}
|
)}
|
||||||
{selectedItemIds.length > 1 && !hasPackages && (
|
{multi && hasItems && (
|
||||||
<button className="ctx-menu-item ctx-danger" onClick={() => {
|
<button className="ctx-menu-item ctx-danger" onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
const ids = new Set(selectedItemIds);
|
const ids = new Set([...selectedIds].filter((id) => snapshot.session.items[id]));
|
||||||
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
|
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
|
||||||
else { executeDeleteSelection(ids); }
|
else { executeDeleteSelection(ids); }
|
||||||
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
|
}}>Ausgewählte entfernen ({[...selectedIds].filter((id) => snapshot.session.items[id]).length})</button>
|
||||||
)}
|
)}
|
||||||
{hasPackages && !contextMenu.itemId && (
|
{hasPackages && !contextMenu.itemId && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" onClick={() => {
|
||||||
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
|
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
|
||||||
|
for (const id of pkgIds) void window.rd.resetPackage(id).catch(() => {});
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
}}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.packages[id]).length})` : ""}</button>
|
||||||
)}
|
)}
|
||||||
{contextMenu.itemId && (
|
{contextMenu.itemId && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" onClick={() => {
|
||||||
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
const itemIds = multi
|
||||||
|
? [...selectedIds].filter((id) => snapshot.session.items[id])
|
||||||
|
: [contextMenu.itemId!];
|
||||||
void window.rd.resetItems(itemIds).catch(() => {});
|
void window.rd.resetItems(itemIds).catch(() => {});
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
|
}}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.items[id]).length})` : ""}</button>
|
||||||
)}
|
)}
|
||||||
{hasPackages && !multi && (() => {
|
{hasPackages && !multi && (() => {
|
||||||
const pkg = snapshot.session.packages[contextMenu.packageId];
|
const pkg = snapshot.session.packages[contextMenu.packageId];
|
||||||
@ -4884,30 +4561,30 @@ 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 = [...selectedIds].filter((id) => snapshot.session.packages[id]);
|
||||||
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 ? `✓ ${label}` : label}</button>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
{hasItems && (() => {
|
{hasItems && (() => {
|
||||||
const itemIds = selectedItemIds;
|
const itemIds = [...selectedIds].filter((id) => snapshot.session.items[id]);
|
||||||
const skippable = itemIds.filter((id) => { const it = snapshot.session.items[id]; return it && (it.status === "queued" || it.status === "reconnect_wait"); });
|
const skippable = itemIds.filter((id) => { const it = snapshot.session.items[id]; return it && (it.status === "queued" || it.status === "reconnect_wait"); });
|
||||||
if (skippable.length === 0) return null;
|
if (skippable.length === 0) return null;
|
||||||
return <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable).catch(() => {}); setContextMenu(null); }}>überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>;
|
return <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable).catch(() => {}); setContextMenu(null); }}>Überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>;
|
||||||
})()}
|
})()}
|
||||||
{hasPackages && (
|
{hasPackages && (
|
||||||
<button className="ctx-menu-item ctx-danger" onClick={() => {
|
<button className="ctx-menu-item ctx-danger" onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
const ids = new Set(selectedPackageIds);
|
const ids = new Set([...selectedIds].filter((id) => snapshot.session.packages[id]));
|
||||||
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
|
if (settingsDraft.confirmDeleteSelection) { setDeleteConfirm({ ids, dontAsk: false }); }
|
||||||
else { executeDeleteSelection(ids); }
|
else { executeDeleteSelection(ids); }
|
||||||
}}>{multi ? `Ausgewählte entfernen (${selectedPackageIds.length})` : "Paket entfernen"}</button>
|
}}>{multi ? `Ausgewählte löschen (${[...selectedIds].filter((id) => snapshot.session.packages[id]).length})` : "Löschen"}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -5101,7 +4778,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${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) onSelect(pkg.id, true); }}
|
onClick={(e) => { if (e.ctrlKey) onSelect(pkg.id, true); }}
|
||||||
@ -5168,7 +4845,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>
|
||||||
@ -5225,7 +4902,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
|
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
|
||||||
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
|
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
|
||||||
case "status": return (
|
case "status": return (
|
||||||
<span key={col} className="pkg-col pkg-col-status" title={item.retries > 0 ? `${item.fullStatus} ? R${item.retries}` : item.fullStatus}>
|
<span key={col} className="pkg-col pkg-col-status" title={item.retries > 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}>
|
||||||
{item.fullStatus}
|
{item.fullStatus}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -5298,5 +4975,3 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
import type { PackageEntry } from "../shared/types";
|
||||||
|
|
||||||
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
|
||||||
|
|
||||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||||
const fromIndex = order.indexOf(draggedPackageId);
|
const fromIndex = order.indexOf(draggedPackageId);
|
||||||
@ -25,49 +23,3 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
|||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortPackagesForDisplay(
|
|
||||||
packages: PackageEntry[],
|
|
||||||
itemsById: Record<string, DownloadItem>,
|
|
||||||
running: boolean,
|
|
||||||
autoSortPackagesByProgress: boolean
|
|
||||||
): PackageEntry[] {
|
|
||||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
|
|
||||||
const rest: PackageEntry[] = [];
|
|
||||||
|
|
||||||
packages.forEach((pkg, index) => {
|
|
||||||
const items = pkg.itemIds
|
|
||||||
.map((id) => itemsById[id])
|
|
||||||
.filter((item): item is DownloadItem => Boolean(item));
|
|
||||||
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
|
|
||||||
if (!hasActive) {
|
|
||||||
rest.push(pkg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const completedRatio = items.length > 0
|
|
||||||
? items.filter((item) => item.status === "completed").length / items.length
|
|
||||||
: 0;
|
|
||||||
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
|
||||||
active.push({ pkg, index, completedRatio, downloadedBytes });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (active.length === 0 || active.length === packages.length) {
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
active.sort((a, b) => {
|
|
||||||
if (a.completedRatio !== b.completedRatio) {
|
|
||||||
return b.completedRatio - a.completedRatio;
|
|
||||||
}
|
|
||||||
if (a.downloadedBytes !== b.downloadedBytes) {
|
|
||||||
return b.downloadedBytes - a.downloadedBytes;
|
|
||||||
}
|
|
||||||
return a.index - b.index;
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...active.map((entry) => entry.pkg), ...rest];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -624,7 +624,7 @@ body,
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-toolbar {
|
.downloads-toolbar {
|
||||||
@ -632,7 +632,6 @@ body,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-toolbar-actions {
|
.downloads-toolbar-actions {
|
||||||
@ -650,16 +649,14 @@ body,
|
|||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns set via inline style from columnOrder */
|
/* grid-template-columns set via inline style from columnOrder */
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 10px;
|
padding: 5px 12px;
|
||||||
background: color-mix(in srgb, var(--card) 58%, transparent);
|
background: var(--card);
|
||||||
border: 0;
|
border: 1px solid var(--border);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
|
border-radius: 8px;
|
||||||
border-radius: 0;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pkg-column-header .pkg-col-progress,
|
.pkg-column-header .pkg-col-progress,
|
||||||
@ -1153,10 +1150,6 @@ body,
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-display-toggle {
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-inline-stat {
|
.account-inline-stat {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1297,19 +1290,6 @@ body,
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-usage-stats {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-usage-stats.warning {
|
|
||||||
color: color-mix(in srgb, #f59e0b 78%, white 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-mode-pill,
|
.account-mode-pill,
|
||||||
.account-status-pill {
|
.account-status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -1386,74 +1366,6 @@ body,
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-subkey-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--field) 72%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-row.warning {
|
|
||||||
border-color: color-mix(in srgb, #f59e0b 42%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-main {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-head strong {
|
|
||||||
font-size: 12px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-head span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-stats {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-actions .btn {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hoster-routing-table {
|
.hoster-routing-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -1769,54 +1681,6 @@ body,
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dl-key-limit-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-dl-key-limit-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--field) 84%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-dl-key-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-dl-key-meta strong {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-dl-key-meta span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-secret.account-secret-multiline {
|
|
||||||
display: grid;
|
|
||||||
align-items: start;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-secret.account-secret-multiline span {
|
|
||||||
display: block;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-status-grid {
|
.account-status-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
@ -1866,22 +1730,9 @@ body,
|
|||||||
|
|
||||||
.package-card {
|
.package-card {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 11px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent));
|
||||||
padding: 6px 10px;
|
padding: 8px 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.queue-package-card {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-package-card:hover {
|
|
||||||
background: color-mix(in srgb, var(--accent) 3%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-card[draggable="true"] {
|
.package-card[draggable="true"] {
|
||||||
@ -1901,12 +1752,6 @@ body,
|
|||||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-package-card.pkg-selected {
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: inset 2px 0 0 0 var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-selected {
|
.item-selected {
|
||||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
}
|
}
|
||||||
@ -1914,23 +1759,18 @@ body,
|
|||||||
.package-card header {
|
.package-card header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-package-card header {
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-card h4 {
|
.package-card h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-card header span {
|
.package-card header span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-card header .progress-inline-text-filled,
|
.package-card header .progress-inline-text-filled,
|
||||||
@ -1958,15 +1798,6 @@ body,
|
|||||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-package-card .pkg-toggle {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: transparent;
|
|
||||||
border-color: color-mix(in srgb, var(--border) 42%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pkg-toggle:hover {
|
.pkg-toggle:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@ -2006,21 +1837,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
height: 6px;
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--progress-track);
|
background: var(--progress-track);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-package-card .progress {
|
|
||||||
margin-top: 0;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 0;
|
|
||||||
background: color-mix(in srgb, var(--progress-track) 80%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-dl {
|
.progress-dl {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
||||||
@ -2121,22 +1945,12 @@ td {
|
|||||||
/* grid-template-columns set via inline style from columnOrder */
|
/* grid-template-columns set via inline style from columnOrder */
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0 -10px;
|
margin: 0 -12px;
|
||||||
padding: 3px 10px;
|
padding: 4px 12px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-package-card .item-row {
|
|
||||||
margin: 0;
|
|
||||||
padding: 2px 10px 2px 10px;
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-package-card .item-row + .item-row {
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--border) 24%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-row:hover {
|
.item-row:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
}
|
}
|
||||||
@ -2146,7 +1960,7 @@ td {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2156,7 +1970,7 @@ td {
|
|||||||
|
|
||||||
.item-row .pkg-col-name {
|
.item-row .pkg-col-name {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding-left: 28px;
|
padding-left: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-status-dot {
|
.link-status-dot {
|
||||||
@ -2708,17 +2522,6 @@ td {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-subkey-row,
|
|
||||||
.account-dl-key-limit-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-subkey-head {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-picker-list {
|
.account-picker-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
export interface DebridLinkApiKeyEntry {
|
|
||||||
id: string;
|
|
||||||
token: string;
|
|
||||||
index: number;
|
|
||||||
label: string;
|
|
||||||
masked: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
|
||||||
const FNV64_PRIME = 0x100000001b3n;
|
|
||||||
const FNV64_MASK = 0xffffffffffffffffn;
|
|
||||||
|
|
||||||
function fnv1a64(text: string): string {
|
|
||||||
let hash = FNV64_OFFSET_BASIS;
|
|
||||||
for (const char of text) {
|
|
||||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
|
||||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
|
||||||
}
|
|
||||||
return hash.toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskDebridLinkApiKey(token: string): string {
|
|
||||||
const trimmed = token.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return "Nicht hinterlegt";
|
|
||||||
}
|
|
||||||
if (trimmed.length <= 6) {
|
|
||||||
return "*".repeat(trimmed.length);
|
|
||||||
}
|
|
||||||
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyId(token: string): string {
|
|
||||||
return `dlk_${fnv1a64(token.trim())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyLabel(index: number): string {
|
|
||||||
return `Key ${index + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const tokens = String(raw || "")
|
|
||||||
.split(/[\n,]+/)
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter((token) => {
|
|
||||||
if (seen.has(token)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seen.add(token);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return tokens.map((token, index) => ({
|
|
||||||
id: getDebridLinkApiKeyId(token),
|
|
||||||
token,
|
|
||||||
index,
|
|
||||||
label: getDebridLinkApiKeyLabel(index),
|
|
||||||
masked: maskDebridLinkApiKey(token)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
|
||||||
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
|
||||||
}
|
|
||||||
@ -6,8 +6,6 @@ export const IPC_CHANNELS = {
|
|||||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||||
OPEN_EXTERNAL: "app:open-external",
|
OPEN_EXTERNAL: "app:open-external",
|
||||||
UPDATE_SETTINGS: "app:update-settings",
|
UPDATE_SETTINGS: "app:update-settings",
|
||||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
|
||||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
|
||||||
ADD_LINKS: "queue:add-links",
|
ADD_LINKS: "queue:add-links",
|
||||||
ADD_CONTAINERS: "queue:add-containers",
|
ADD_CONTAINERS: "queue:add-containers",
|
||||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import type {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridProvider,
|
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
@ -22,8 +21,6 @@ export interface ElectronApi {
|
|||||||
installUpdate: () => Promise<UpdateInstallResult>;
|
installUpdate: () => Promise<UpdateInstallResult>;
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||||
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
|
||||||
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
|
||||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
import type { AppSettings, DebridProvider } from "./types";
|
|
||||||
|
|
||||||
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
|
||||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
|
||||||
|
|
||||||
type ProviderDailySettings =
|
|
||||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
|
||||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>;
|
|
||||||
|
|
||||||
function normalizePositiveBytes(value: unknown): number {
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.floor(numeric);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
|
||||||
const current = new Date(epochMs);
|
|
||||||
const year = current.getFullYear();
|
|
||||||
const month = String(current.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(current.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
|
||||||
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number {
|
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDailyRemainingBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number | null {
|
|
||||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
|
||||||
if (limit <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProviderDailyLimitReached(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): boolean {
|
|
||||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
|
||||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetProviderDailyUsage(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider?: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
if (!provider) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
delete nextUsageBytes[provider];
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: nextUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addProviderDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
byteDelta: number,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUsageBytes = currentUsageBytes;
|
|
||||||
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: nextUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number {
|
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyRemainingBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number | null {
|
|
||||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
|
||||||
if (limit <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDebridLinkApiKeyDailyLimitReached(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): boolean {
|
|
||||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
|
||||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetDebridLinkApiKeyDailyUsage(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId?: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
if (!keyId) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
delete nextUsageBytes[keyId];
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addDebridLinkApiKeyDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
byteDelta: number,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -101,8 +101,6 @@ export interface AppSettings {
|
|||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
collapseNewPackages: boolean;
|
collapseNewPackages: boolean;
|
||||||
accountListShowDetailedDebridLinkKeys: boolean;
|
|
||||||
autoSortPackagesByProgress: boolean;
|
|
||||||
autoSkipExtracted: boolean;
|
autoSkipExtracted: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
@ -112,11 +110,6 @@ export interface AppSettings {
|
|||||||
autoExtractWhenStopped: boolean;
|
autoExtractWhenStopped: boolean;
|
||||||
disabledProviders: DebridProvider[];
|
disabledProviders: DebridProvider[];
|
||||||
hosterRouting: Record<string, DebridProvider>;
|
hosterRouting: Record<string, DebridProvider>;
|
||||||
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
|
||||||
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
|
||||||
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
|
||||||
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
|
||||||
providerDailyUsageDay: string;
|
|
||||||
scheduledStartEpochMs: number;
|
scheduledStartEpochMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,8 +119,6 @@ export interface DownloadItem {
|
|||||||
url: string;
|
url: string;
|
||||||
provider: DebridProvider | null;
|
provider: DebridProvider | null;
|
||||||
providerLabel?: string;
|
providerLabel?: string;
|
||||||
providerAccountId?: string;
|
|
||||||
providerAccountLabel?: string;
|
|
||||||
status: DownloadStatus;
|
status: DownloadStatus;
|
||||||
retries: number;
|
retries: number;
|
||||||
speedBps: number;
|
speedBps: number;
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
||||||
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@ -83,100 +81,6 @@ describe("debrid service", () => {
|
|||||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips a provider whose daily limit is already reached and uses the next provider", async () => {
|
|
||||||
const calledUrls: string[] = [];
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "rd-token",
|
|
||||||
debridLinkApiKeys: "dl-token",
|
|
||||||
providerOrder: ["realdebrid", "debridlink"] as const,
|
|
||||||
providerPrimary: "realdebrid" as const,
|
|
||||||
providerSecondary: "debridlink" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: true,
|
|
||||||
providerDailyLimitBytes: { realdebrid: 100 },
|
|
||||||
providerDailyUsageBytes: { realdebrid: 100 },
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey()
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
calledUrls.push(url);
|
|
||||||
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
value: {
|
|
||||||
downloadUrl: "https://debrid-link.example/file.bin",
|
|
||||||
name: "file.bin",
|
|
||||||
size: 1234
|
|
||||||
}
|
|
||||||
}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
||||||
throw new Error("Real-Debrid should have been skipped due to daily limit");
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
||||||
expect(result.provider).toBe("debridlink");
|
|
||||||
expect(result.directUrl).toBe("https://debrid-link.example/file.bin");
|
|
||||||
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
|
|
||||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
||||||
let usedAuthHeader = "";
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
||||||
providerOrder: ["debridlink"] as const,
|
|
||||||
providerPrimary: "debridlink" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
debridLinkApiKeyDailyLimitBytes: {
|
|
||||||
[keys[0].id]: 100
|
|
||||||
},
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {
|
|
||||||
[keys[0].id]: 100
|
|
||||||
},
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey()
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
||||||
const headers = init?.headers;
|
|
||||||
if (headers instanceof Headers) {
|
|
||||||
usedAuthHeader = headers.get("Authorization") || "";
|
|
||||||
} else if (Array.isArray(headers)) {
|
|
||||||
usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
||||||
} else {
|
|
||||||
usedAuthHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
value: {
|
|
||||||
downloadUrl: "https://debrid-link.example/file.bin",
|
|
||||||
name: "file.bin",
|
|
||||||
size: 1234
|
|
||||||
}
|
|
||||||
}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
||||||
|
|
||||||
expect(usedAuthHeader).toBe("Bearer dl-key-two");
|
|
||||||
expect(result.provider).toBe("debridlink");
|
|
||||||
expect(result.providerLabel).toContain("Key 2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses BestDebrid auth header without token query fallback", async () => {
|
it("uses BestDebrid auth header without token query fallback", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import AdmZip from "adm-zip";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { DownloadManager } from "../src/main/download-manager";
|
import { DownloadManager } from "../src/main/download-manager";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
||||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@ -2837,7 +2835,7 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries suspicious mini files under 100 KB until the full file arrives", async () => {
|
it("retries suspicious mini files under 1 MB until the full file arrives", 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);
|
||||||
const binary = Buffer.alloc(2 * 1024 * 1024, 21);
|
const binary = Buffer.alloc(2 * 1024 * 1024, 21);
|
||||||
@ -4859,62 +4857,4 @@ describe("download manager", () => {
|
|||||||
expect(internal.speedEventsHead).toBe(0);
|
expect(internal.speedEventsHead).toBe(0);
|
||||||
expect(internal.speedBytesLastWindow).toBe(0);
|
expect(internal.speedBytesLastWindow).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks daily usage on the actual provider key without touching other providers", () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
|
|
||||||
const manager = new DownloadManager(
|
|
||||||
{
|
|
||||||
...defaultSettings(),
|
|
||||||
megaLogin: "mega-user",
|
|
||||||
megaPassword: "mega-pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
||||||
providerDailyUsageBytes: { realdebrid: 512 }
|
|
||||||
},
|
|
||||||
emptySession(),
|
|
||||||
createStoragePaths(path.join(root, "state"))
|
|
||||||
);
|
|
||||||
|
|
||||||
const internal = manager as unknown as {
|
|
||||||
recordProviderDownloadedBytes: (provider: "megadebrid", bytes: number) => void;
|
|
||||||
settings: ReturnType<typeof defaultSettings>;
|
|
||||||
};
|
|
||||||
|
|
||||||
internal.recordProviderDownloadedBytes("megadebrid", 1024);
|
|
||||||
|
|
||||||
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512);
|
|
||||||
expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024);
|
|
||||||
expect((internal.settings.providerDailyUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
||||||
|
|
||||||
const manager = new DownloadManager(
|
|
||||||
{
|
|
||||||
...defaultSettings(),
|
|
||||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
||||||
providerDailyUsageBytes: { debridlink: 256 },
|
|
||||||
debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 }
|
|
||||||
},
|
|
||||||
emptySession(),
|
|
||||||
createStoragePaths(path.join(root, "state"))
|
|
||||||
);
|
|
||||||
|
|
||||||
const internal = manager as unknown as {
|
|
||||||
recordProviderDownloadedBytes: (provider: "debridlink", bytes: number, providerAccountId?: string) => void;
|
|
||||||
settings: ReturnType<typeof defaultSettings>;
|
|
||||||
};
|
|
||||||
|
|
||||||
internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id);
|
|
||||||
|
|
||||||
expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280);
|
|
||||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024);
|
|
||||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { DownloadItem, PackageEntry } from "../src/shared/types";
|
|
||||||
import { sortPackagesForDisplay } from "../src/renderer/package-order";
|
|
||||||
|
|
||||||
function createPackage(id: string, itemIds: string[]): PackageEntry {
|
|
||||||
const now = Date.now();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: id,
|
|
||||||
outputDir: "",
|
|
||||||
extractDir: "",
|
|
||||||
status: "queued",
|
|
||||||
itemIds,
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
priority: "normal",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
|
|
||||||
const now = Date.now();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
packageId,
|
|
||||||
url: `https://hoster.example/${id}`,
|
|
||||||
provider: null,
|
|
||||||
status,
|
|
||||||
retries: 0,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes,
|
|
||||||
totalBytes: downloadedBytes,
|
|
||||||
progressPercent: downloadedBytes > 0 ? 50 : 0,
|
|
||||||
fileName: `${id}.bin`,
|
|
||||||
targetPath: "",
|
|
||||||
resumable: true,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("sortPackagesForDisplay", () => {
|
|
||||||
it("moves active packages with more progress to the top when auto sort is enabled", () => {
|
|
||||||
const packages = [
|
|
||||||
createPackage("pkg-a", ["a1", "a2"]),
|
|
||||||
createPackage("pkg-b", ["b1", "b2"]),
|
|
||||||
createPackage("pkg-c", ["c1"])
|
|
||||||
];
|
|
||||||
const items: Record<string, DownloadItem> = {
|
|
||||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
|
||||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
|
||||||
b2: createItem("b2", "pkg-b", "completed", 900),
|
|
||||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
|
||||||
|
|
||||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps package order untouched when auto sort is disabled", () => {
|
|
||||||
const packages = [
|
|
||||||
createPackage("pkg-a", ["a1"]),
|
|
||||||
createPackage("pkg-b", ["b1"]),
|
|
||||||
createPackage("pkg-c", ["c1"])
|
|
||||||
];
|
|
||||||
const items: Record<string, DownloadItem> = {
|
|
||||||
a1: createItem("a1", "pkg-a", "queued", 0),
|
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 500),
|
|
||||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = sortPackagesForDisplay(packages, items, true, false);
|
|
||||||
|
|
||||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,8 +2,6 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
||||||
import { AppSettings } from "../src/shared/types";
|
import { AppSettings } from "../src/shared/types";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
||||||
@ -122,8 +120,7 @@ describe("settings storage", () => {
|
|||||||
retryLimit: "-3",
|
retryLimit: "-3",
|
||||||
reconnectWaitSeconds: "1",
|
reconnectWaitSeconds: "1",
|
||||||
speedLimitMode: "not-valid",
|
speedLimitMode: "not-valid",
|
||||||
updateRepo: "",
|
updateRepo: ""
|
||||||
autoSortPackagesByProgress: false
|
|
||||||
}),
|
}),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
@ -136,7 +133,6 @@ describe("settings storage", () => {
|
|||||||
expect(loaded.reconnectWaitSeconds).toBe(10);
|
expect(loaded.reconnectWaitSeconds).toBe(10);
|
||||||
expect(loaded.speedLimitMode).toBe("global");
|
expect(loaded.speedLimitMode).toBe("global");
|
||||||
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
|
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
|
||||||
expect(loaded.autoSortPackagesByProgress).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps explicit none as fallback provider choice", () => {
|
it("keeps explicit none as fallback provider choice", () => {
|
||||||
@ -180,43 +176,6 @@ describe("settings storage", () => {
|
|||||||
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
|
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes provider daily limits and resets stale daily usage", () => {
|
|
||||||
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
|
|
||||||
const normalized = normalizeSettings({
|
|
||||||
...defaultSettings(),
|
|
||||||
megaLogin: "mega-user",
|
|
||||||
megaPassword: "mega-pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
debridLinkApiKeys: "dl-key-one",
|
|
||||||
providerDailyLimitBytes: {
|
|
||||||
realdebrid: 1024,
|
|
||||||
megadebrid: 2048
|
|
||||||
} as AppSettings["providerDailyLimitBytes"],
|
|
||||||
debridLinkApiKeyDailyLimitBytes: {
|
|
||||||
[debridLinkKey.id]: 3072,
|
|
||||||
stale: 1234
|
|
||||||
},
|
|
||||||
providerDailyUsageDay: "2001-01-01",
|
|
||||||
providerDailyUsageBytes: {
|
|
||||||
realdebrid: 4096,
|
|
||||||
megadebrid: 8192
|
|
||||||
} as AppSettings["providerDailyUsageBytes"],
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {
|
|
||||||
[debridLinkKey.id]: 8192,
|
|
||||||
stale: 9999
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(normalized.providerDailyLimitBytes.realdebrid).toBe(1024);
|
|
||||||
expect(normalized.providerDailyLimitBytes["megadebrid-api"]).toBe(2048);
|
|
||||||
expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({
|
|
||||||
[debridLinkKey.id]: 3072
|
|
||||||
});
|
|
||||||
expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey());
|
|
||||||
expect(normalized.providerDailyUsageBytes).toEqual({});
|
|
||||||
expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes archive password list line endings", () => {
|
it("normalizes archive password list line endings", () => {
|
||||||
const normalized = normalizeSettings({
|
const normalized = normalizeSettings({
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user