feat: refine account management and targeted editing

Add local provider icons, larger compact account rows, the requested column order, and full username display while keeping secrets masked. Replace provider-wide editing with a row-specific editor that preserves sibling accounts, exact limits, live usage counters, and status metadata. Add targeted credential validation, release notices, and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-09 21:32:09 +02:00
parent cf72ea2db1
commit 9f0140dd3c
30 changed files with 1356 additions and 130 deletions
+13 -2
View File
@@ -10,9 +10,18 @@ Desktop downloader for Windows with package-based queue management, multi-provid
## Changelog
### 2.0.10
- Add locally bundled provider logos throughout account management.
- Reorder the account table to Hoster, Status, Download Traffic Remaining, Username, Expiry Date, and Password / Access.
- Increase account-row and status text sizes while keeping the table compact.
- Show full account usernames while continuing to protect passwords and API keys.
- Replace the provider-wide edit form with a compact editor for the selected account or API key.
- Preserve sibling accounts, usage counters, limits, and status metadata during account-specific edits.
### 2.0.9
- Rebuild the Accounts view as a viewport-filling, JDownloader-style table with flat 24-pixel rows and bottom-aligned actions.
- Rebuild the Accounts view as a viewport-filling compact table with flat rows and bottom-aligned actions.
- Separate account usage rules from the overview so rotation and routing controls no longer reduce the visible account list.
- Redesign the account dialog with one-line provider rows, an immediate default selection, compact credential fields, and split cancel/save actions.
- Add a global account enable/disable control while preserving unrelated provider state.
@@ -35,7 +44,7 @@ Desktop downloader for Windows with package-based queue management, multi-provid
## Why this tool?
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
- Package-based workflow with progress, extraction, history, and clean post-processing.
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
- Includes an in-app updater for releases published on GitHub.
@@ -344,3 +353,5 @@ Detailed release history is published on [GitHub Releases](https://github.com/Su
The project is licensed under the MIT License. See `LICENSE`.
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.
Bundled provider icon notices are available in `THIRD_PARTY_NOTICES.md`.
+14
View File
@@ -0,0 +1,14 @@
# Third-Party Notices
The provider names, logos, and favicons bundled with this application are trademarks or brand assets of their respective owners. They are included unchanged solely to identify the supported services and are not covered by this project's MIT license. Inclusion does not imply endorsement or affiliation.
| Provider | Source | Retrieved | SHA-256 |
| --- | --- | --- | --- |
| Real-Debrid | https://fcdn.real-debrid.com/0831/favicons/favicon-32x32.png | 2026-08-09 | `542D80BDDB948F240B100C1872E83B0228BC1BFE5C46E8FFD95AFA419CB11F52` |
| Mega-Debrid | https://cdn.mega-debrid.eu/images/favicon.png | 2026-08-09 | `12D3DE939965D13F852853A2A5BB873B0D7A89D411BEAFA02518B48356803AE4` |
| BestDebrid | https://bestdebrid.com/en/files/assets/images/favicon.ico | 2026-08-09 | `11E54B1B0DCBBB8F5BCAA21D0EDC0CF182276C513ED733ACEEEBC35120CD9F33` |
| AllDebrid | https://cdn.alldebrid.com/lib/images/default/favicon.png | 2026-08-09 | `AE296FBE43A6CCEABC85033E5C063BFCA01932EB3677FB679AF63C23535CD9EF` |
| DDownload | https://ddownload.com/images/favicon.ico | 2026-08-09 | `E0413F73F8A2A5AD85BE8082B0897E82AE3B354FE2490884D7A992BB18F34DF3` |
| 1Fichier | https://img.1fichier.com/favicon.png | 2026-08-09 | `C3567E0299879FE67924C6119BF082BE128A5E8962FC0D61A0D8AAA0B1304CB5` |
| Debrid-Link | https://cdn.debrid-link.com/img/fav/favicon.ico?i=2 | 2026-08-09 | `1AB89ADA0EB9CB665F5E05209F27B50869FCA1972620C90D57BF682E87F98FA4` |
| LinkSnappy | https://linksnappy.com/templates/v3/img/favicon.png | 2026-08-09 | `D9DB0F5E4BDB2BF659ADD6D666F8464455196EC47201F27B126083754126497E` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.9",
"version": "2.0.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.9",
"version": "2.0.10",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.9",
"version": "2.0.10",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
@@ -65,12 +65,17 @@
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"package.json"
],
"extraResources": [
{
"from": "LICENSE",
"to": "LICENSE"
},
{
"from": "THIRD_PARTY_NOTICES.md",
"to": "THIRD_PARTY_NOTICES.md"
}
],
"asarUnpack": [
+7 -1
View File
@@ -16,7 +16,8 @@ const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
const REQUIRED_BUILD_FILES = Object.freeze([
"resources/extractor-jvm/**/*",
"LICENSE"
"LICENSE",
"THIRD_PARTY_NOTICES.md"
]);
const EXPECTED_EXTRA_RESOURCE = Object.freeze({
from: "LICENSE",
@@ -28,6 +29,11 @@ const REDISTRIBUTION_FILES = Object.freeze([
packagedPath: "resources/LICENSE",
sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6"
}),
Object.freeze({
sourcePath: "THIRD_PARTY_NOTICES.md",
packagedPath: "resources/THIRD_PARTY_NOTICES.md",
sha256: "b5d923900e9bf932fbf173191973d0892a091ac5afdc4907b8b9f4f846e8126c"
}),
Object.freeze({
sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt",
+8
View File
@@ -0,0 +1,8 @@
import type { DebridAccountStatus } from "../shared/types";
export function canPersistExpectedAccountStatus(statuses: readonly DebridAccountStatus[], expectedAccountId: string | undefined): boolean {
return Boolean(expectedAccountId)
&& statuses.length === 1
&& statuses[0].valid
&& statuses[0].accountId === expectedAccountId;
}
+9 -18
View File
@@ -2,7 +2,6 @@ import path from "node:path";
import os from "node:os";
import v8 from "node:v8";
import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import {
AddLinksPayload,
AllDebridHostInfo,
@@ -29,7 +28,7 @@ import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web";
@@ -59,6 +58,8 @@ import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-b
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
import { overlayLiveUsageCounters } from "./settings-live-overlay";
import { canPersistExpectedAccountStatus } from "./account-status-persistence";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -417,20 +418,8 @@ export class AppController {
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
const liveSettings = this.manager.getSettings();
overlayLiveUsageCounters(target, liveSettings, this.manager.getLiveTotalRuntimeMs());
}
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
@@ -546,10 +535,12 @@ export class AppController {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
}
public async checkDebridAccounts(settingsOverride?: AppSettings): Promise<DebridAccountStatus[]> {
public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(settingsOverride ? normalizeSettings(settingsOverride) : this.settings);
if (!settingsOverride) {
if (!settingsOverride || (persistValidOverride && canPersistExpectedAccountStatus(statuses, expectedAccountId))) {
this.manager.applyDebridAccountStatuses(statuses);
}
if (!settingsOverride) {
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
+2 -2
View File
@@ -747,8 +747,8 @@ function registerIpcHandlers(): void {
return controller.getDebridLinkHostLimits();
});
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, settings?: AppSettings) => {
return controller.checkDebridAccounts(settings);
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string) => {
return controller.checkDebridAccounts(settings, persistValidOverride === true, expectedAccountId);
});
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
+30
View File
@@ -0,0 +1,30 @@
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
import type { AppSettings } from "../shared/types";
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
const megaAccountIds = new Set(getMegaDebridAccountIds(target.megaCredentials || "", target.megaPassword || ""));
const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountIds]);
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => debridLinkKeyIds.has(keyId))
);
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => debridLinkKeyIds.has(keyId))
);
target.megaDebridAccountDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.megaDebridAccountDailyUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
);
target.megaDebridAccountTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.megaDebridAccountTotalUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
);
target.debridAccountStatuses = Object.fromEntries(
Object.entries(liveSettings.debridAccountStatuses || {}).filter(([accountId]) => validAccountIds.has(accountId))
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ const api: ElectronApi = {
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
checkDebridAccounts: (settings?: AppSettings): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, settings),
checkDebridAccounts: (settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, settings, persistValidOverride, expectedAccountId),
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
+248 -84
View File
@@ -38,8 +38,11 @@ import {
} from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { pruneSelection } from "./selection";
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, isAccountRowSelectionKey, matchesAccountModeFilter, pruneAccountRowSelection, resolveVisibleAccountKind } from "./account-ui";
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, isAccountRowSelectionKey, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
import type { AccountModeFilter } from "./account-ui";
import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
import type { DownloadSpeedHistoryState } from "./download-speed-state";
@@ -87,22 +90,7 @@ interface LinkPopupState {
isPackage: boolean;
}
type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
type AccountKind =
| "realdebrid-api"
| "realdebrid-web"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid-api"
| "bestdebrid-web"
| "alldebrid-api"
| "alldebrid-web"
| "ddownload-login"
| "onefichier-api"
| "debridlink-api"
| "linksnappy-login";
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
interface AccountOption {
kind: AccountKind;
service: AccountService;
@@ -183,6 +171,7 @@ interface AccountTableRow {
toggleKind: "mega" | "dl" | "single";
megaLogin?: string;
dlKey?: DebridLinkAccountKeyEntry;
editTarget: AccountEditTarget;
}
interface AccountContextMenuState {
@@ -190,6 +179,25 @@ interface AccountContextMenuState {
y: number;
row: AccountTableRow;
}
function AccountServiceLogo({ service, className }: { service: AccountService; className: string }): ReactElement {
return <img className={className} src={ACCOUNT_SERVICE_ICONS[service]} alt="" aria-hidden="true" draggable={false} />;
}
function getAccountQuickActionMeta(kind: AccountKind): { label: string; action: AccountQuickAction } | null {
switch (kind) {
case "realdebrid-web":
return { label: "Login", action: "realdebrid-login" };
case "bestdebrid-web":
return { label: "Cookies", action: "bestdebrid-cookies" };
case "alldebrid-api":
return { label: "Status", action: "alldebrid-status" };
case "alldebrid-web":
return { label: "Login", action: "alldebrid-login" };
default:
return null;
}
}
function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => {
@@ -586,7 +594,7 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
}
}
function summarizeAccountLines(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) {
@@ -599,8 +607,24 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string
return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`);
}
}
return [summarizeAccount(kind, settings)];
}
return [summarizeAccount(kind, settings)];
}
function getStoredAccountUsername(kind: AccountKind, settings: AppSettings): string {
switch (kind) {
case "ddownload-login":
return settings.ddownloadLogin;
case "linksnappy-login":
return settings.linkSnappyLogin;
case "realdebrid-web":
case "alldebrid-web":
return "Browser-Login";
case "bestdebrid-web":
return "Cookies.txt";
default:
return "API-Account";
}
}
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] };
@@ -1795,6 +1819,7 @@ export function App(): ReactElement {
const accountContextMenuRef = useRef<HTMLDivElement>(null);
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
const [accountEditDialog, setAccountEditDialog] = useState<AccountEditState | null>(null);
const [accountDialogSearch, setAccountDialogSearch] = useState("");
const [accountDialogModeFilter, setAccountDialogModeFilter] = useState<AccountModeFilter>("all");
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
@@ -2525,10 +2550,10 @@ export function App(): ReactElement {
const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id);
rows.push({
rowKey: `mega-${entry.kind}-${acc.id}`,
entry,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: acc.maskedLogin,
username: acc.login,
credentialLabel: "••••••",
accountId: acc.id,
checkable: true,
@@ -2537,8 +2562,15 @@ export function App(): ReactElement {
dailyLimitBytes: limit,
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
totalUsedBytes: getMegaDebridAccountTotalUsageBytes(snapshot.settings, acc.id),
toggleKind: "mega",
megaLogin: acc.login
toggleKind: "mega",
megaLogin: acc.login,
editTarget: {
type: "mega",
rowKey: `mega-${entry.kind}-${acc.id}`,
kind: entry.kind,
service: entry.service as "megadebrid-api" | "megadebrid-web",
accountId: acc.id
}
});
}
} else if (entry.kind === "debridlink-api") {
@@ -2548,7 +2580,7 @@ export function App(): ReactElement {
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: key.masked,
username: "",
credentialLabel: "API-Key",
accountId: key.id,
checkable: true,
@@ -2557,8 +2589,15 @@ export function App(): ReactElement {
dailyLimitBytes: key.dailyLimitBytes,
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
totalUsedBytes: key.totalUsedBytes,
toggleKind: "dl",
dlKey: key
toggleKind: "dl",
dlKey: key,
editTarget: {
type: "debridlink",
rowKey: `dl-${key.id}`,
kind: "debridlink-api",
service: "debridlink",
keyId: key.id
}
});
}
} else {
@@ -2567,7 +2606,7 @@ export function App(): ReactElement {
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: entry.summary,
username: getStoredAccountUsername(entry.kind, settingsDraft),
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null,
checkable: false,
@@ -2576,7 +2615,14 @@ export function App(): ReactElement {
dailyLimitBytes: entry.dailyLimitBytes,
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
totalUsedBytes: entry.totalUsedBytes,
toggleKind: "single"
toggleKind: "single",
editTarget: {
type: "single",
rowKey: `svc-${entry.service}`,
kind: entry.kind as SingleAccountKind,
service: entry.service,
provider: entry.provider
}
});
}
}
@@ -2624,8 +2670,8 @@ export function App(): ReactElement {
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
const isProblem = statusCls === "invalid";
const username = row.username;
const usernameTitle = st && st.email && st.email.toLowerCase() !== row.username.toLowerCase() ? `${row.username} (Mail: ${st.email})` : row.username;
const username = resolveAccountUsername(row.username, st?.email);
const usernameTitle = username;
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
const traffic = row.dailyLimitBytes > 0
? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
@@ -2644,7 +2690,7 @@ export function App(): ReactElement {
setSelectedAccountRowKey(row.rowKey);
}
}}
onDoubleClick={() => openEditAccountDialog(row.entry.kind)}
onDoubleClick={() => openEditAccountDialog(row)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -2654,30 +2700,31 @@ export function App(): ReactElement {
<span className="acct2-c-check">
<input
type="checkbox"
checked={!row.disabled}
disabled={actionBusy}
title={row.disabled ? "Account aktivieren" : "Account deaktivieren (bleibt gespeichert)"}
checked={!row.disabled}
disabled={actionBusy}
title={row.disabled ? "Account aktivieren" : "Account deaktivieren (bleibt gespeichert)"}
aria-label={`${row.hosterLabel} ${row.modeLabel} ${row.disabled ? "aktivieren" : "deaktivieren"}`}
onChange={() => {
if (row.toggleKind === "mega" && row.megaLogin) { void onToggleMegaAccountEnabled(row.megaLogin, row.disabled); }
else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); }
else { void onToggleAccountEnabled(row.entry); }
}}
/>
</span>
</span>
<span className="acct2-hoster" title={`${row.hosterLabel} · ${row.modeLabel}`}>
<span className={`acct2-service-icon service-${row.entry.service}`}>{row.hosterLabel.slice(0, 1).toUpperCase()}</span>
<AccountServiceLogo service={row.entry.service} className="acct2-service-icon" />
<strong>{row.hosterLabel}</strong>
<span className="acct2-mode">{row.modeLabel}</span>
</span>
<span className="acct2-traffic">{traffic}</span>
<span className="acct2-status">
{statusCls === "none"
? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung"></span>
: <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>}
</span>
<span className="acct2-status">
{statusCls === "none"
? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung"></span>
: <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>}
</span>
<span className="acct2-traffic">{traffic}</span>
<span className="acct2-user" title={usernameTitle}>{username}</span>
<span className="acct2-expiry">{expiry}</span>
<span className="acct2-credential" title="Zugangsdaten werden niemals im Klartext angezeigt">{row.credentialLabel}</span>
<span className="acct2-credential" title="Passwörter und API-Schlüssel bleiben geschützt">{row.credentialLabel}</span>
<span className="acct2-c-actions">
<button
className="btn btn-sm acct2-menu-button"
@@ -2696,10 +2743,14 @@ export function App(): ReactElement {
</div>
);
};
const availableAccountOptions = useMemo(() => (
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
), [configuredAccountServices]);
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
const availableAccountOptions = useMemo(() => (
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
), [configuredAccountServices]);
const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null;
const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null;
const accountEditStatus = accountEditRow?.accountId ? snapshot.settings.debridAccountStatuses?.[accountEditRow.accountId] ?? null : null;
const accountEditQuickAction = accountEditOption ? getAccountQuickActionMeta(accountEditOption.kind) : null;
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
const accountDialogSelectableOptions = useMemo(() => {
if (!accountDialog) {
return [];
@@ -2851,11 +2902,13 @@ export function App(): ReactElement {
totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime),
providerDailyUsageDay: result.providerDailyUsageDay,
providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) },
providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) },
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) }
}));
};
providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) },
debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) },
megaDebridAccountDailyUsageBytes: { ...(result.megaDebridAccountDailyUsageBytes || {}) },
megaDebridAccountTotalUsageBytes: { ...(result.megaDebridAccountTotalUsageBytes || {}) }
}));
};
const persistSpecificSettings = async (nextDraft: AppSettings): Promise<AppSettings> => {
const normalizedDraft = {
@@ -2890,21 +2943,6 @@ export function App(): ReactElement {
}
};
const getAccountQuickActionMeta = (kind: AccountKind): { label: string; action: AccountQuickAction } | null => {
switch (kind) {
case "realdebrid-web":
return { label: "Login", action: "realdebrid-login" };
case "bestdebrid-web":
return { label: "Cookies", action: "bestdebrid-cookies" };
case "alldebrid-api":
return { label: "Status", action: "alldebrid-status" };
case "alldebrid-web":
return { label: "Login", action: "alldebrid-login" };
default:
return null;
}
};
const checkAllAccounts = useCallback(async (): Promise<void> => {
setAccountCheckBusy(true);
try {
@@ -2947,10 +2985,12 @@ export function App(): ReactElement {
setAccountDialog(createAccountDialogState("create", availableAccountOptions[0]?.kind ?? null, settingsDraft));
};
const openEditAccountDialog = (kind: AccountKind): void => {
setAccountDialogSearch("");
setAccountDialogModeFilter("all");
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
const openEditAccountDialog = (row: AccountTableRow): void => {
try {
setAccountEditDialog(createAccountEditState(row.editTarget, settingsDraft));
} catch (error) {
showToast(String(error), 3200);
}
};
const updateAccountDialogKind = useCallback((kind: AccountKind): void => {
@@ -2999,6 +3039,42 @@ export function App(): ReactElement {
setAccountDialogSearch("");
setAccountDialogModeFilter("all");
}, []);
const closeAccountEditDialog = useCallback((): void => {
setAccountEditDialog(null);
}, []);
const onSaveAccountEditDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
if (!accountEditDialog) {
return;
}
const editSnapshot = accountEditDialog;
const validationError = validateAccountEdit(editSnapshot, settingsDraft);
if (validationError) {
showToast(validationError, 2800);
return;
}
await performQuickAction(async () => {
const nextDraft = applyAccountEdit(settingsDraft, editSnapshot);
if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") {
const statuses = await window.rd.checkDebridAccounts(buildAccountEditCheckSettings(nextDraft, editSnapshot), true, getAccountEditExpectedStatusId(editSnapshot) || undefined);
const statusError = validateAccountEditStatuses(editSnapshot, statuses);
if (statusError) {
showToast(`Prüfung fehlgeschlagen: ${statusError}`, 4200);
return;
}
}
await persistSpecificSettings(nextDraft);
closeAccountEditDialog();
if (quickAction) {
await runAccountQuickAction(quickAction);
} else {
showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200);
}
}, (error) => {
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
});
};
const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
if (!accountDialog) {
@@ -3207,13 +3283,27 @@ export function App(): ReactElement {
const removeAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
void onRemoveMegaAccount(row.megaLogin);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onRemoveDebridLinkKey(row.dlKey);
} else {
void onRemoveAccount(row.entry);
}
void (async () => {
const username = resolveAccountUsername(row.username, row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId]?.email : undefined);
const confirmed = await askConfirmPrompt({
title: `${row.hosterLabel} entfernen`,
message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`,
confirmLabel: "Entfernen",
danger: true
});
if (!confirmed) {
return;
}
await performQuickAction(async () => {
await persistSpecificSettings(removeAccountTarget(settingsDraft, row.editTarget));
if (row.entry.service === "alldebrid") {
setAllDebridHostInfo(null);
}
showToast(`${row.hosterLabel} entfernt`, 2200);
}, (error) => {
showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200);
});
})();
};
const checkAccountTableRow = (row: AccountTableRow): void => {
@@ -5621,8 +5711,8 @@ export function App(): ReactElement {
<div className="acct2-head" role="row">
<span className="acct2-c-check" role="columnheader" aria-label="Aktiviert" />
<span role="columnheader">Hoster</span>
<span role="columnheader">Download-Traffic übrig</span>
<span role="columnheader"><button type="button" className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</button></span>
<span role="columnheader">Download-Traffic übrig</span>
<span role="columnheader">Benutzername</span>
<span role="columnheader">Verfallsdatum</span>
<span role="columnheader">Passwort / Zugang</span>
@@ -6306,8 +6396,82 @@ export function App(): ReactElement {
</div>
</div>
</div>
)}
)}
{accountEditDialog && accountEditOption && (
<div className="modal-backdrop" onClick={closeAccountEditDialog}>
<div className="modal-card account-edit-modal" role="dialog" aria-modal="true" aria-labelledby="account-edit-dialog-title" onClick={(event) => event.stopPropagation()}>
<div className="account-edit-header">
<AccountServiceLogo service={accountEditOption.service} className="account-edit-logo" />
<div>
<h3 id="account-edit-dialog-title">Account bearbeiten</h3>
<span>{accountEditOption.serviceLabel} · {accountEditOption.modeLabel}</span>
</div>
</div>
<div className="account-edit-summary">
<div>
<span>Benutzername</span>
<strong>{resolveAccountUsername(accountEditRow?.username || accountEditDialog.login, accountEditStatus?.email)}</strong>
</div>
<div>
<span>Status</span>
<strong>{accountEditRow?.disabled
? "Deaktiviert"
: accountEditStatus
? accountEditStatus.valid
? accountEditStatus.isPremium ? accountEditStatus.message || "Premium Account" : "Free Account"
: accountEditStatus.message || "Ungültig"
: accountEditRow?.entry.statusLabel || "Noch nicht geprüft"}</strong>
</div>
</div>
<div className="account-edit-fields">
{(accountEditDialog.target.type === "mega" || accountEditOption.needsCredentials) && (
<div className="field-grid two">
<div>
<label htmlFor="account-edit-login">Login / E-Mail</label>
<input id="account-edit-login" autoComplete="username" value={accountEditDialog.login} onChange={(event) => setAccountEditDialog((prev) => prev ? { ...prev, login: event.target.value } : prev)} />
</div>
<div>
<label htmlFor="account-edit-password">Passwort</label>
<input id="account-edit-password" type="password" autoComplete="current-password" value={accountEditDialog.password} onChange={(event) => setAccountEditDialog((prev) => prev ? { ...prev, password: event.target.value } : prev)} />
</div>
</div>
)}
{(accountEditDialog.target.type === "debridlink" || (accountEditOption.needsToken && accountEditDialog.target.type === "single")) && (
<div>
<label htmlFor="account-edit-token">{accountEditDialog.target.type === "debridlink" ? "API-Key" : "Token / API-Key"}</label>
<input id="account-edit-token" type="password" autoComplete="off" value={accountEditDialog.token} onChange={(event) => setAccountEditDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
</div>
)}
<div>
<label htmlFor="account-edit-daily-limit">Tageslimit (GB, optional)</label>
<input id="account-edit-daily-limit" inputMode="decimal" placeholder="z.B. 250" value={accountEditDialog.dailyLimitGb} onChange={(event) => setAccountEditDialog((prev) => prev ? { ...prev, dailyLimitGb: event.target.value } : prev)} />
<div className="account-edit-note">Leer oder 0 bedeutet unbegrenzt. Der Zähler wird täglich um 00:00 Uhr zurückgesetzt.</div>
</div>
{!accountEditOption.needsCredentials && !accountEditOption.needsToken && accountEditDialog.target.type === "single" && (
<div className="account-edit-note prominent">Dieser Account verwendet den gespeicherten Browser- oder Cookie-Login.</div>
)}
{accountEditQuickAction && (
<button className="btn account-edit-quick-action" disabled={actionBusy} onClick={() => { void runAccountQuickAction(accountEditQuickAction.action); }}>
{accountEditQuickAction.label === "Cookies" ? "Cookies importieren" : accountEditQuickAction.label === "Login" ? "Login öffnen" : "Status prüfen"}
</button>
)}
</div>
<div className="modal-actions account-edit-actions">
<button className="btn" onClick={closeAccountEditDialog}>Abbrechen</button>
<button className="btn accent" disabled={actionBusy} onClick={() => { void onSaveAccountEditDialog(); }}>Prüfen und speichern</button>
</div>
</div>
</div>
)}
{accountDialog && (
<div className="modal-backdrop" onClick={closeAccountDialog}>
<div className="modal-card account-modal" role="dialog" aria-modal="true" aria-labelledby="account-dialog-title" onClick={(event) => event.stopPropagation()}>
@@ -6358,7 +6522,7 @@ export function App(): ReactElement {
className={`account-picker-row${accountDialog.kind === option.kind ? " active" : ""}`}
onClick={() => updateAccountDialogKind(option.kind)}
>
<span className="account-picker-domain"><span className={`account-picker-icon service-${option.service}`}>{option.serviceLabel.slice(0, 1).toUpperCase()}</span><strong>{option.title}</strong></span>
<span className="account-picker-domain"><AccountServiceLogo service={option.service} className="account-picker-icon" /><strong>{option.title}</strong></span>
<span className="account-picker-function">{getAccountPickerFunctionLabel(option)}</span>
</button>
))}
@@ -6702,7 +6866,7 @@ export function App(): ReactElement {
<span>{accountContextMenu.row.modeLabel} · {accountContextMenu.row.username}</span>
</div>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => { const row = accountContextMenu.row; setAccountContextMenu(null); openEditAccountDialog(row.entry.kind); }}>
<button className="ctx-menu-item" onClick={() => { const row = accountContextMenu.row; setAccountContextMenu(null); openEditAccountDialog(row); }}>
Bearbeiten
</button>
<button className="ctx-menu-item" onClick={() => checkAccountTableRow(accountContextMenu.row)}>
+458
View File
@@ -0,0 +1,458 @@
import { parseDebridLinkApiKeys, getDebridLinkApiKeyId } from "../shared/debrid-link-keys";
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import type { AppSettings, DebridAccountStatus, DebridProvider } from "../shared/types";
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
export type AccountKind =
| "realdebrid-api"
| "realdebrid-web"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid-api"
| "bestdebrid-web"
| "alldebrid-api"
| "alldebrid-web"
| "ddownload-login"
| "onefichier-api"
| "debridlink-api"
| "linksnappy-login";
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
export type AccountEditTarget =
| {
type: "single";
rowKey: string;
kind: SingleAccountKind;
service: AccountService;
provider: DebridProvider;
}
| {
type: "mega";
rowKey: string;
kind: "megadebrid-api" | "megadebrid-web";
service: "megadebrid-api" | "megadebrid-web";
accountId: string;
}
| {
type: "debridlink";
rowKey: string;
kind: "debridlink-api";
service: "debridlink";
keyId: string;
};
export interface AccountEditState {
target: AccountEditTarget;
login: string;
password: string;
token: string;
dailyLimitGb: string;
originalDailyLimitBytes: number;
}
const BYTES_PER_GIB = 1024 * 1024 * 1024;
function formatDailyLimit(limitBytes: number): string {
if (!Number.isFinite(limitBytes) || limitBytes <= 0) {
return "";
}
const gib = limitBytes / BYTES_PER_GIB;
const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2;
return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function parseDailyLimit(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 * BYTES_PER_GIB);
}
function withoutRecordKeys<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
const blocked = new Set(keys);
return Object.fromEntries(Object.entries(record || {}).filter(([key]) => !blocked.has(key)));
}
function resolveDailyLimit(value: string, originalBytes: number): number | null {
return value === formatDailyLimit(originalBytes) ? (originalBytes > 0 ? originalBytes : null) : parseDailyLimit(value);
}
function updateTargetLimit(record: Record<string, number>, oldId: string, newId: string, value: string, originalBytes: number): Record<string, number> {
const next = withoutRecordKeys(record || {}, oldId, newId);
const limit = resolveDailyLimit(value, originalBytes);
if (limit && limit > 0) {
next[newId] = limit;
}
return next;
}
function migrateDisabledId(ids: readonly string[], oldId: string, newId: string): string[] {
const wasDisabled = ids.includes(oldId);
const next = ids.filter((id) => id !== oldId && id !== newId);
if (wasDisabled) {
next.push(newId);
}
return next;
}
function updateProviderLimit(settings: AppSettings, provider: DebridProvider, value: string, originalBytes: number): AppSettings["providerDailyLimitBytes"] {
const next = { ...(settings.providerDailyLimitBytes || {}) };
const limit = resolveDailyLimit(value, originalBytes);
if (limit && limit > 0) {
next[provider] = limit;
} else {
delete next[provider];
}
return next;
}
function createSingleEditState(target: Extract<AccountEditTarget, { type: "single" }>, settings: AppSettings): AccountEditState {
const originalDailyLimitBytes = settings.providerDailyLimitBytes?.[target.provider] || 0;
const base = {
target,
login: "",
password: "",
token: "",
dailyLimitGb: formatDailyLimit(originalDailyLimitBytes),
originalDailyLimitBytes
};
switch (target.kind) {
case "realdebrid-api":
return { ...base, token: settings.token };
case "bestdebrid-api":
return { ...base, token: settings.bestToken };
case "alldebrid-api":
return { ...base, token: settings.allDebridToken };
case "onefichier-api":
return { ...base, token: settings.oneFichierApiKey };
case "ddownload-login":
return { ...base, login: settings.ddownloadLogin, password: settings.ddownloadPassword };
case "linksnappy-login":
return { ...base, login: settings.linkSnappyLogin, password: settings.linkSnappyPassword };
default:
return base;
}
}
export function createAccountEditState(target: AccountEditTarget, settings: AppSettings): AccountEditState {
if (target.type === "single") {
return createSingleEditState(target, settings);
}
if (target.type === "mega") {
const account = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "")
.find((entry) => entry.id === target.accountId);
if (!account) {
throw new Error("Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.");
}
return {
target,
login: account.login,
password: account.password,
token: "",
dailyLimitGb: formatDailyLimit(settings.megaDebridAccountDailyLimitBytes?.[account.id] || 0),
originalDailyLimitBytes: settings.megaDebridAccountDailyLimitBytes?.[account.id] || 0
};
}
const key = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "").find((entry) => entry.id === target.keyId);
if (!key) {
throw new Error("Der ausgewählte Debrid-Link-Key wurde nicht gefunden.");
}
return {
target,
login: "",
password: "",
token: key.token,
dailyLimitGb: formatDailyLimit(settings.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0),
originalDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0
};
}
function validateDailyLimit(value: string): string | null {
const normalized = value.trim().replace(",", ".");
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed >= 0 ? null : "Das Tageslimit muss eine positive Zahl oder 0 sein.";
}
export function validateAccountEdit(state: AccountEditState, settings: AppSettings): string | null {
const limitError = validateDailyLimit(state.dailyLimitGb);
if (limitError) {
return limitError;
}
if (state.target.type === "mega") {
const target = state.target;
const login = state.login.trim();
if (!login || !state.password.trim()) {
return "Login und Passwort werden benötigt.";
}
if (/[:\r\n]/.test(login)) {
return "Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten.";
}
if (/[\r\n]/.test(state.password)) {
return "Das Passwort darf keinen Zeilenumbruch enthalten.";
}
const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
if (!accounts.some((entry) => entry.id === target.accountId)) {
return "Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.";
}
if (accounts.some((entry) => entry.id !== target.accountId && entry.login.toLowerCase() === login.toLowerCase())) {
return "Dieser Mega-Debrid-Login ist bereits vorhanden.";
}
return null;
}
if (state.target.type === "debridlink") {
const target = state.target;
const token = state.token.trim();
if (!token) {
return "Der API-Key wird benötigt.";
}
if (/[,\r\n]/.test(token)) {
return "Beim Bearbeiten ist genau ein API-Key erlaubt.";
}
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
if (!keys.some((entry) => entry.id === target.keyId)) {
return "Der ausgewählte Debrid-Link-Key wurde nicht gefunden.";
}
if (keys.some((entry) => entry.id !== target.keyId && entry.token === token)) {
return "Dieser Debrid-Link-Key ist bereits vorhanden.";
}
return null;
}
if (["realdebrid-api", "bestdebrid-api", "alldebrid-api", "onefichier-api"].includes(state.target.kind) && !state.token.trim()) {
return "Der Zugangstoken wird benötigt.";
}
if (["ddownload-login", "linksnappy-login"].includes(state.target.kind)) {
if (!state.login.trim() || !state.password.trim()) {
return "Login und Passwort werden benötigt.";
}
}
return null;
}
function applySingleEdit(settings: AppSettings, state: AccountEditState & { target: Extract<AccountEditTarget, { type: "single" }> }): AppSettings {
const providerDailyLimitBytes = updateProviderLimit(settings, state.target.provider, state.dailyLimitGb, state.originalDailyLimitBytes);
const token = state.token.trim();
const login = state.login.trim();
switch (state.target.kind) {
case "realdebrid-api":
return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes };
case "realdebrid-web":
return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes };
case "bestdebrid-api":
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes };
case "bestdebrid-web":
return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes };
case "alldebrid-api":
return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes };
case "alldebrid-web":
return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes };
case "ddownload-login":
return { ...settings, ddownloadLogin: login, ddownloadPassword: state.password, providerDailyLimitBytes };
case "onefichier-api":
return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes };
case "linksnappy-login":
return { ...settings, linkSnappyLogin: login, linkSnappyPassword: state.password, providerDailyLimitBytes };
}
}
function applyMegaEdit(settings: AppSettings, state: AccountEditState & { target: Extract<AccountEditTarget, { type: "mega" }> }): AppSettings {
const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const index = accounts.findIndex((entry) => entry.id === state.target.accountId);
if (index < 0) {
throw new Error("Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.");
}
const oldId = state.target.accountId;
const login = state.login.trim();
const newId = getMegaDebridAccountId(login);
const nextAccounts = accounts.map((entry, accountIndex) => accountIndex === index
? { login, password: state.password }
: { login: entry.login, password: entry.password });
const idChanged = oldId !== newId;
const first = nextAccounts[0] || { login: "", password: "" };
return {
...settings,
megaCredentials: serializeMegaDebridAccounts(nextAccounts),
megaLogin: first.login,
megaPassword: first.password,
megaDebridDisabledAccountIds: idChanged
? migrateDisabledId(settings.megaDebridDisabledAccountIds || [], oldId, newId)
: [...(settings.megaDebridDisabledAccountIds || [])],
megaDebridAccountDailyLimitBytes: updateTargetLimit(settings.megaDebridAccountDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes),
megaDebridAccountDailyUsageBytes: idChanged
? withoutRecordKeys(settings.megaDebridAccountDailyUsageBytes || {}, oldId, newId)
: { ...(settings.megaDebridAccountDailyUsageBytes || {}) },
megaDebridAccountTotalUsageBytes: idChanged
? withoutRecordKeys(settings.megaDebridAccountTotalUsageBytes || {}, oldId, newId)
: { ...(settings.megaDebridAccountTotalUsageBytes || {}) },
debridAccountStatuses: idChanged
? withoutRecordKeys(settings.debridAccountStatuses || {}, oldId, newId)
: { ...(settings.debridAccountStatuses || {}) }
};
}
function applyDebridLinkEdit(settings: AppSettings, state: AccountEditState & { target: Extract<AccountEditTarget, { type: "debridlink" }> }): AppSettings {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
const index = keys.findIndex((entry) => entry.id === state.target.keyId);
if (index < 0) {
throw new Error("Der ausgewählte Debrid-Link-Key wurde nicht gefunden.");
}
const oldId = state.target.keyId;
const token = state.token.trim();
const newId = getDebridLinkApiKeyId(token);
const tokens = keys.map((entry, keyIndex) => keyIndex === index ? token : entry.token);
const idChanged = oldId !== newId;
return {
...settings,
debridLinkApiKeys: tokens.join("\n"),
debridLinkDisabledKeyIds: idChanged
? migrateDisabledId(settings.debridLinkDisabledKeyIds || [], oldId, newId)
: [...(settings.debridLinkDisabledKeyIds || [])],
debridLinkApiKeyDailyLimitBytes: updateTargetLimit(settings.debridLinkApiKeyDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes),
debridLinkApiKeyDailyUsageBytes: idChanged
? withoutRecordKeys(settings.debridLinkApiKeyDailyUsageBytes || {}, oldId, newId)
: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: idChanged
? withoutRecordKeys(settings.debridLinkApiKeyTotalUsageBytes || {}, oldId, newId)
: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) },
debridAccountStatuses: idChanged
? withoutRecordKeys(settings.debridAccountStatuses || {}, oldId, newId)
: { ...(settings.debridAccountStatuses || {}) }
};
}
export function applyAccountEdit(settings: AppSettings, state: AccountEditState): AppSettings {
if (state.target.type === "single") {
return applySingleEdit(settings, state as AccountEditState & { target: Extract<AccountEditTarget, { type: "single" }> });
}
if (state.target.type === "mega") {
return applyMegaEdit(settings, state as AccountEditState & { target: Extract<AccountEditTarget, { type: "mega" }> });
}
return applyDebridLinkEdit(settings, state as AccountEditState & { target: Extract<AccountEditTarget, { type: "debridlink" }> });
}
function clearSingleAccount(settings: AppSettings, target: Extract<AccountEditTarget, { type: "single" }>): AppSettings {
const providerDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) };
const providerDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) };
const providerTotalUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
delete providerDailyLimitBytes[target.provider];
delete providerDailyUsageBytes[target.provider];
delete providerTotalUsageBytes[target.provider];
const base = { ...settings, providerDailyLimitBytes, providerDailyUsageBytes, providerTotalUsageBytes };
switch (target.service) {
case "realdebrid":
return { ...base, token: "", realDebridUseWebLogin: false };
case "bestdebrid":
return { ...base, bestToken: "", bestDebridUseWebLogin: false };
case "alldebrid":
return { ...base, allDebridToken: "", allDebridUseWebLogin: false };
case "ddownload":
return { ...base, ddownloadLogin: "", ddownloadPassword: "" };
case "onefichier":
return { ...base, oneFichierApiKey: "" };
case "linksnappy":
return { ...base, linkSnappyLogin: "", linkSnappyPassword: "" };
default:
return base;
}
}
export function removeAccountTarget(settings: AppSettings, target: AccountEditTarget): AppSettings {
if (target.type === "single") {
return clearSingleAccount(settings, target);
}
if (target.type === "mega") {
const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "")
.filter((entry) => entry.id !== target.accountId)
.map((entry) => ({ login: entry.login, password: entry.password }));
const first = accounts[0] || { login: "", password: "" };
return {
...settings,
megaCredentials: serializeMegaDebridAccounts(accounts),
megaLogin: first.login,
megaPassword: first.password,
megaDebridApiEnabled: accounts.length > 0 && settings.megaDebridApiEnabled,
megaDebridWebEnabled: accounts.length > 0 && settings.megaDebridWebEnabled,
megaDebridDisabledAccountIds: (settings.megaDebridDisabledAccountIds || []).filter((id) => id !== target.accountId),
megaDebridAccountDailyLimitBytes: withoutRecordKeys(settings.megaDebridAccountDailyLimitBytes || {}, target.accountId),
megaDebridAccountDailyUsageBytes: withoutRecordKeys(settings.megaDebridAccountDailyUsageBytes || {}, target.accountId),
megaDebridAccountTotalUsageBytes: withoutRecordKeys(settings.megaDebridAccountTotalUsageBytes || {}, target.accountId),
debridAccountStatuses: withoutRecordKeys(settings.debridAccountStatuses || {}, target.accountId)
};
}
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "").filter((entry) => entry.id !== target.keyId);
return {
...settings,
debridLinkApiKeys: keys.map((entry) => entry.token).join("\n"),
debridLinkDisabledKeyIds: (settings.debridLinkDisabledKeyIds || []).filter((id) => id !== target.keyId),
debridLinkApiKeyDailyLimitBytes: withoutRecordKeys(settings.debridLinkApiKeyDailyLimitBytes || {}, target.keyId),
debridLinkApiKeyDailyUsageBytes: withoutRecordKeys(settings.debridLinkApiKeyDailyUsageBytes || {}, target.keyId),
debridLinkApiKeyTotalUsageBytes: withoutRecordKeys(settings.debridLinkApiKeyTotalUsageBytes || {}, target.keyId),
debridAccountStatuses: withoutRecordKeys(settings.debridAccountStatuses || {}, target.keyId)
};
}
export function buildAccountEditCheckSettings(settings: AppSettings, state: AccountEditState): AppSettings {
if (state.target.type === "mega") {
const login = state.login.trim();
return {
...settings,
megaCredentials: serializeMegaDebridAccounts([{ login, password: state.password }]),
megaLogin: login,
megaPassword: state.password,
debridLinkApiKeys: ""
};
}
if (state.target.type === "debridlink") {
return {
...settings,
megaCredentials: "",
megaLogin: "",
megaPassword: "",
debridLinkApiKeys: state.token.trim()
};
}
return {
...settings,
megaCredentials: "",
megaLogin: "",
megaPassword: "",
debridLinkApiKeys: ""
};
}
export function validateAccountEditStatuses(state: AccountEditState, statuses: readonly DebridAccountStatus[]): string | null {
if (state.target.type === "single") {
return null;
}
if (statuses.length === 0) {
return "Die Prüfung hat keinen Account zurückgegeben.";
}
if (statuses.length !== 1) {
return "Die Prüfung hat mehr als den ausgewählten Account zurückgegeben.";
}
const expectedId = getAccountEditExpectedStatusId(state);
const status = statuses[0];
if (status.accountId !== expectedId) {
return "Die Prüfung hat den falschen Account zurückgegeben.";
}
return status.valid ? null : status.message || "Zugangsdaten ungültig";
}
export function getAccountEditExpectedStatusId(state: AccountEditState): string | null {
if (state.target.type === "mega") {
return getMegaDebridAccountId(state.login);
}
if (state.target.type === "debridlink") {
return getDebridLinkApiKeyId(state.token);
}
return null;
}
+13
View File
@@ -0,0 +1,13 @@
import type { AccountService } from "./account-edit";
export const ACCOUNT_SERVICE_ICONS = {
realdebrid: "./provider-icons/real-debrid.png",
"megadebrid-api": "./provider-icons/mega-debrid.png",
"megadebrid-web": "./provider-icons/mega-debrid.png",
bestdebrid: "./provider-icons/bestdebrid.ico",
alldebrid: "./provider-icons/alldebrid.png",
ddownload: "./provider-icons/ddownload.ico",
onefichier: "./provider-icons/onefichier.png",
debridlink: "./provider-icons/debrid-link.ico",
linksnappy: "./provider-icons/linksnappy.png"
} satisfies Record<AccountService, string>;
+4
View File
@@ -55,6 +55,10 @@ export function isAccountRowSelectionKey(key: string, originatedOnRow: boolean):
return originatedOnRow && (key === "Enter" || key === " ");
}
export function resolveAccountUsername(storedUsername: string, checkedEmail?: string): string {
return checkedEmail?.trim() || storedUsername.trim() || "—";
}
export function buildBulkAccountEnabledState(
currentDisabledProviders: DebridProvider[],
configuredProviders: DebridProvider[],
+114 -18
View File
@@ -2207,16 +2207,12 @@ body,
.account-picker-icon,
.acct2-service-icon {
display: inline-grid;
place-items: center;
width: 15px;
height: 15px;
flex: 0 0 15px;
display: block;
width: 18px;
height: 18px;
flex: 0 0 18px;
border-radius: 3px;
background: color-mix(in srgb, var(--accent) 24%, var(--field));
color: color-mix(in srgb, var(--accent) 78%, var(--text));
font-size: 9px;
font-weight: 800;
object-fit: contain;
}
.account-modal-fields {
@@ -3491,7 +3487,7 @@ td {
.acct2-head,
.acct2-row {
display: grid;
grid-template-columns: 24px minmax(150px, 1.25fr) minmax(165px, 1.3fr) minmax(190px, 1.5fr) minmax(165px, 1.3fr) minmax(100px, 0.72fr) minmax(140px, 0.9fr) 30px;
grid-template-columns: 24px minmax(170px, 1.25fr) minmax(210px, 1.5fr) minmax(180px, 1.3fr) minmax(180px, 1.3fr) minmax(110px, 0.72fr) minmax(145px, 0.9fr) 30px;
align-items: center;
gap: 6px;
min-width: 1120px;
@@ -3519,9 +3515,10 @@ td {
color: var(--muted);
}
.acct2-row {
min-height: 24px;
min-height: 28px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
font-size: 11px;
font-size: 14px;
line-height: 1.2;
cursor: default;
}
.acct2-row:last-child { border-bottom: 0; }
@@ -3536,20 +3533,119 @@ td {
.acct2-c-check input { cursor: pointer; }
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
.acct2-menu-button { width: 22px; min-width: 22px; height: 19px; padding: 0; font-size: 14px; line-height: 1; }
.acct2-hoster { display: grid; grid-template-columns: 15px auto minmax(0, 1fr); align-items: center; gap: 5px; min-width: 0; }
.acct2-hoster strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-mode { overflow: hidden; color: var(--muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-hoster { display: grid; grid-template-columns: 18px auto minmax(0, 1fr); align-items: center; gap: 6px; min-width: 0; }
.acct2-hoster strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-mode { overflow: hidden; color: var(--muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-traffic,
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
.acct2-head > span:nth-child(3),
.acct2-traffic { text-align: center; }
.acct2-head > span:nth-child(4),
.acct2-traffic { text-align: center; }
.acct2-sortable { width: 100%; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; letter-spacing: inherit; text-align: left; text-transform: inherit; cursor: pointer; user-select: none; }
.acct2-sortable:hover { color: var(--text); }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-credential { color: var(--muted); white-space: nowrap; }
.acct2-status .account-validity-badge { height: 16px; margin-top: 0; padding: 0 5px; font-size: 9px; line-height: 14px; }
.acct2-status .account-validity-badge { max-width: 100%; height: 20px; margin-top: 0; padding: 0 7px; overflow: hidden; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
.account-edit-modal {
width: min(600px, calc(100vw - 36px));
max-height: calc(100vh - 36px);
gap: 14px;
overflow-y: auto;
padding: 18px;
}
.account-edit-header {
display: flex;
align-items: center;
gap: 12px;
}
.account-edit-logo {
width: 34px;
height: 34px;
flex: 0 0 34px;
border-radius: 6px;
object-fit: contain;
}
.account-edit-header h3 {
margin: 0 0 2px;
font-size: 18px;
}
.account-edit-header span {
color: var(--muted);
font-size: 13px;
}
.account-edit-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.account-edit-summary > div {
display: grid;
min-width: 0;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--field) 70%, transparent);
}
.account-edit-summary span {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.account-edit-summary strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-edit-fields {
display: grid;
gap: 12px;
}
.account-edit-fields label {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: 700;
}
.account-edit-note {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.account-edit-note.prominent {
margin: 0;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 8px;
color: var(--text);
background: color-mix(in srgb, var(--accent) 7%, transparent);
}
.account-edit-quick-action {
justify-self: start;
}
.account-edit-actions {
justify-content: space-between;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.account-table-actions {
display: flex;
align-items: center;
+1 -1
View File
@@ -84,7 +84,7 @@ export interface ElectronApi {
importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: (settings?: AppSettings) => Promise<DebridAccountStatus[]>;
checkDebridAccounts: (settings?: AppSettings, persistValidOverride?: boolean, expectedAccountId?: string) => Promise<DebridAccountStatus[]>;
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
+298
View File
@@ -0,0 +1,298 @@
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
applyAccountEdit,
buildAccountEditCheckSettings,
createAccountEditState,
removeAccountTarget,
validateAccountEdit,
validateAccountEditStatuses,
type AccountEditTarget
} from "../src/renderer/account-edit";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const GIB = 1024 * 1024 * 1024;
function megaTarget(login: string): AccountEditTarget {
return {
type: "mega",
rowKey: `mega-${getMegaDebridAccountId(login)}`,
kind: "megadebrid-api",
service: "megadebrid-api",
accountId: getMegaDebridAccountId(login)
};
}
function debridLinkTarget(token: string): AccountEditTarget {
return {
type: "debridlink",
rowKey: `dl-${getDebridLinkApiKeyId(token)}`,
kind: "debridlink-api",
service: "debridlink",
keyId: getDebridLinkApiKeyId(token)
};
}
describe("account-specific editing", () => {
it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => {
const oldId = getMegaDebridAccountId("second@example.com");
const newId = getMegaDebridAccountId("renamed@example.com");
const firstId = getMegaDebridAccountId("first@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridPreferApi: false,
megaDebridDisabledAccountIds: [oldId, firstId],
megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB },
megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB },
megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB },
debridAccountStatuses: {
[oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const state = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "renamed@example.com",
password: "new-pass",
dailyLimitGb: "25,5"
};
expect(validateAccountEdit(state, settings)).toBeNull();
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass");
expect(next.megaLogin).toBe("first@example.com");
expect(next.megaPassword).toBe("first-pass");
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(false);
expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB });
expect(next.debridAccountStatuses).toEqual({
[firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
it("keeps Mega-Debrid usage when only the password changes", () => {
const id = getMegaDebridAccountId("user@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "user@example.com:old-pass",
megaLogin: "user@example.com",
megaPassword: "old-pass",
megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB },
megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB },
megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB }
};
const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" };
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("user@example.com:new-pass");
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB });
});
it("preserves exact account limits when the rounded display value is left unchanged", () => {
const megaLogin = "user@example.com";
const megaId = getMegaDebridAccountId(megaLogin);
const key = "debrid-link-token";
const keyId = getDebridLinkApiKeyId(key);
const exactLimit = Math.floor(10.05 * GIB);
const settings = {
...defaultSettings(),
megaCredentials: `${megaLogin}:pass`,
megaLogin,
megaPassword: "pass",
megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit },
debridLinkApiKeys: key,
debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit }
};
const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings));
const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings));
expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit);
expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit);
});
it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => {
const login = "user@example.com";
const settings = {
...defaultSettings(),
megaCredentials: `${login}:pass`,
megaLogin: login,
megaPassword: "pass"
};
const state = { ...createAccountEditState(megaTarget(login), settings), password: " " };
expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i);
expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i);
expect(validateAccountEditStatuses(state, [{
accountId: "mda_other",
provider: "megadebrid",
label: "Account 1",
maskedLogin: "ot***er",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}])).toMatch(/falschen Account/i);
});
it("rejects duplicate Mega-Debrid logins and missing row targets", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass"
};
const duplicate = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "first@example.com"
};
expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i);
expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i);
});
it("replaces only the selected Debrid-Link key and migrates its own metadata", () => {
const keyA = "token-a";
const keyB = "token-b";
const keyC = "token-c";
const newKey = "token-b-new";
const idA = getDebridLinkApiKeyId(keyA);
const idB = getDebridLinkApiKeyId(keyB);
const idNew = getDebridLinkApiKeyId(newKey);
const settings = {
...defaultSettings(),
debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`,
debridLinkDisabledKeyIds: [idB, idA],
debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB },
debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB },
debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB }
};
const state = {
...createAccountEditState(debridLinkTarget(keyB), settings),
token: newKey,
dailyLimitGb: "12"
};
const next = applyAccountEdit(settings, state);
expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`);
expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]);
expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB });
expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB });
expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB });
});
it("edits a single login without changing unrelated credentials", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "old@example.com",
ddownloadPassword: "old-pass",
linkSnappyLogin: "keep@example.com",
linkSnappyPassword: "keep-pass"
};
const target: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const state = {
...createAccountEditState(target, settings),
login: "new@example.com",
password: "new-pass",
dailyLimitGb: "7"
};
const next = applyAccountEdit(settings, state);
expect(next.ddownloadLogin).toBe("new@example.com");
expect(next.ddownloadPassword).toBe("new-pass");
expect(next.linkSnappyLogin).toBe("keep@example.com");
expect(next.linkSnappyPassword).toBe("keep-pass");
expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB);
});
it("rejects whitespace-only passwords for direct login accounts", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "user@example.com",
ddownloadPassword: "password",
linkSnappyLogin: "member@example.com",
linkSnappyPassword: "secret"
};
const ddownloadTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const linkSnappyTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-linksnappy",
kind: "linksnappy-login",
service: "linksnappy",
provider: "linksnappy"
};
expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i);
expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i);
});
it("builds a targeted check snapshot without invalid sibling accounts", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
debridLinkApiKeys: "one\ntwo"
};
const target = megaTarget("second@example.com");
const state = createAccountEditState(target, settings);
const checkSettings = buildAccountEditCheckSettings(settings, state);
expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass");
expect(checkSettings.megaLogin).toBe("second@example.com");
expect(checkSettings.megaPassword).toBe("second-pass");
expect(checkSettings.debridLinkApiKeys).toBe("");
});
it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => {
const removeId = getMegaDebridAccountId("second@example.com");
const keepId = getMegaDebridAccountId("first@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridDisabledAccountIds: [removeId, keepId],
megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 },
megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 },
megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 },
debridAccountStatuses: {
[removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
[keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const next = removeAccountTarget(settings, megaTarget("second@example.com"));
expect(next.megaCredentials).toBe("first@example.com:first-pass");
expect(next.megaDebridDisabledAccountIds).toEqual([keepId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 });
expect(next.debridAccountStatuses).toEqual({
[keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ACCOUNT_SERVICE_ICONS } from "../src/renderer/account-service-icons";
const services = [
"realdebrid",
"megadebrid-api",
"megadebrid-web",
"bestdebrid",
"alldebrid",
"ddownload",
"onefichier",
"debridlink",
"linksnappy"
] as const;
describe("account service icons", () => {
it("bundles a valid local PNG or ICO for every account service", async () => {
expect(Object.keys(ACCOUNT_SERVICE_ICONS).sort()).toEqual([...services].sort());
for (const service of services) {
const relativePath = ACCOUNT_SERVICE_ICONS[service];
expect(relativePath.startsWith("./provider-icons/")).toBe(true);
const bytes = await readFile(path.resolve("assets", relativePath.slice(2)));
const png = bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const ico = bytes.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00]));
expect(png || ico).toBe(true);
}
});
it("uses the same Mega-Debrid icon for API and Web accounts", () => {
expect(ACCOUNT_SERVICE_ICONS["megadebrid-api"]).toBe(ACCOUNT_SERVICE_ICONS["megadebrid-web"]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { canPersistExpectedAccountStatus } from "../src/main/account-status-persistence";
import type { DebridAccountStatus } from "../src/shared/types";
function status(accountId: string, valid = true): DebridAccountStatus {
return {
accountId,
provider: "megadebrid",
label: "Account 1",
maskedLogin: "us***om",
valid,
isPremium: true,
premiumUntilMs: null,
message: valid ? "OK" : "Ungültig",
checkedAt: 1
};
}
describe("account status persistence", () => {
it("accepts only one valid status for the expected account", () => {
expect(canPersistExpectedAccountStatus([status("expected")], "expected")).toBe(true);
expect(canPersistExpectedAccountStatus([], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("other")], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("expected", false)], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("expected"), status("other")], "expected")).toBe(false);
expect(canPersistExpectedAccountStatus([status("expected")], "")).toBe(false);
});
});
+9
View File
@@ -6,6 +6,7 @@ import {
isAccountRowSelectionKey,
matchesAccountModeFilter,
pruneAccountRowSelection,
resolveAccountUsername,
resolveVisibleAccountKind
} from "../src/renderer/account-ui";
@@ -89,3 +90,11 @@ describe("account selection", () => {
expect(isAccountRowSelectionKey(" ", false)).toBe(false);
});
});
describe("account usernames", () => {
it("shows the full stored login and prefers a checked provider email", () => {
expect(resolveAccountUsername("member@example.com", undefined)).toBe("member@example.com");
expect(resolveAccountUsername("stored@example.com", "verified@example.com")).toBe("verified@example.com");
expect(resolveAccountUsername("", undefined)).toBe("—");
});
});
+10
View File
@@ -41,6 +41,7 @@ const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl)
const fixtureRoots: string[] = [];
const redistributionFiles = [
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
"resources/extractor-jvm/licenses/7-Zip-license.txt",
"resources/extractor-jvm/licenses/Apache-2.0.txt",
@@ -59,6 +60,8 @@ function writeRedistributionFiles(rootDir: string, packaged = false): void {
let targetPath: string = relativePath;
if (packaged && relativePath === "LICENSE") {
targetPath = "win-unpacked/resources/LICENSE";
} else if (packaged && relativePath === "THIRD_PARTY_NOTICES.md") {
targetPath = "win-unpacked/resources/THIRD_PARTY_NOTICES.md";
} else if (packaged) {
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
}
@@ -74,6 +77,8 @@ function writeArchivePayload(outputDir: string, omittedName = ""): void {
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
const targetPath = relativePath === "LICENSE"
? "resources/LICENSE"
: relativePath === "THIRD_PARTY_NOTICES.md"
? "resources/THIRD_PARTY_NOTICES.md"
: `resources/app.asar.unpacked/${relativePath}`;
writeFile(outputDir, targetPath, content);
}
@@ -117,12 +122,17 @@ function createReleaseFixture(): string {
"build/renderer/**/*",
"resources/extractor-jvm/**/*",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"package.json"
],
extraResources: [
{
from: "LICENSE",
to: "LICENSE"
},
{
from: "THIRD_PARTY_NOTICES.md",
to: "THIRD_PARTY_NOTICES.md"
}
],
nsis: {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { overlayLiveUsageCounters } from "../src/main/settings-live-overlay";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
describe("live settings overlay", () => {
it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => {
const keepMegaId = getMegaDebridAccountId("keep@example.com");
const removedMegaId = getMegaDebridAccountId("removed@example.com");
const keepKeyId = getDebridLinkApiKeyId("keep-key");
const removedKeyId = getDebridLinkApiKeyId("removed-key");
const target = {
...defaultSettings(),
megaCredentials: "keep@example.com:pass",
megaLogin: "keep@example.com",
megaPassword: "pass",
debridLinkApiKeys: "keep-key",
megaDebridAccountDailyUsageBytes: { [keepMegaId]: 1 },
megaDebridAccountTotalUsageBytes: { [keepMegaId]: 2 }
};
const live = {
...defaultSettings(),
megaCredentials: "keep@example.com:pass\nremoved@example.com:pass",
debridLinkApiKeys: "keep-key\nremoved-key",
megaDebridAccountDailyUsageBytes: { [keepMegaId]: 300, [removedMegaId]: 400 },
megaDebridAccountTotalUsageBytes: { [keepMegaId]: 3_000, [removedMegaId]: 4_000 },
debridLinkApiKeyDailyUsageBytes: { [keepKeyId]: 500, [removedKeyId]: 600 },
debridLinkApiKeyTotalUsageBytes: { [keepKeyId]: 5_000, [removedKeyId]: 6_000 },
debridAccountStatuses: {
[keepMegaId]: { accountId: keepMegaId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[removedMegaId]: { accountId: removedMegaId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[keepKeyId]: { accountId: keepKeyId, provider: "debridlink" as const, label: "Key 1", maskedLogin: "kee***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
[removedKeyId]: { accountId: removedKeyId, provider: "debridlink" as const, label: "Key 2", maskedLogin: "rem***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }
}
};
overlayLiveUsageCounters(target, live, 9_000);
expect(target.megaDebridAccountDailyUsageBytes).toEqual({ [keepMegaId]: 300 });
expect(target.megaDebridAccountTotalUsageBytes).toEqual({ [keepMegaId]: 3_000 });
expect(target.debridLinkApiKeyDailyUsageBytes).toEqual({ [keepKeyId]: 500 });
expect(target.debridLinkApiKeyTotalUsageBytes).toEqual({ [keepKeyId]: 5_000 });
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
});
});