diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef5128..4baab27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.23] - 2026-08-11 + +### Update experience + +- Changed the available-update header action to a dedicated light-blue treatment with dark high-contrast text. +- Added cumulative release notes for every stable version newer than the installed application, ordered from newest to oldest. +- Excluded draft releases, prereleases, the installed version, and older versions from cumulative update notes. +- Added a bounded vertical scroll area so long multi-version changelogs remain usable without overflowing the update dialog. + +### Download queue + +- Unified RapidGator main and short-link domains under one host identity for icons, host counts, routing, limits, and cooldowns. +- Centered service and status values beneath their corresponding column headings. +- Removed archive filenames from visible password-cracking progress while retaining full technical details in the status tooltip. +- Kept active packages in their activation order and appended newly active packages behind downloads that were already running. +- Removed status-driven automatic package expansion so collapse state changes only through explicit user actions. + +### Account handling + +- Separated Mega-Debrid API and Web Login credential pools so adding, editing, disabling, or removing one mode no longer changes the other mode. +- Migrated legacy shared Mega-Debrid credentials and disabled-account states into the explicitly enabled access mode, falling back to the preferred mode only for ambiguous legacy configurations. +- Kept mode-specific account availability and provider selection synchronized with live settings changes. +- Applied added, edited, disabled, and re-enabled Mega-Debrid accounts to the active scheduler without requiring an application restart. +- Invalidated cached Mega-Debrid Web Login sessions immediately when Web credentials change, prevented older in-flight, retried, and queued requests from restoring invalidated cookies, and left API-only changes isolated. +- Released only Mega-Debrid reset-parked queue items when a usable account pool becomes available while preserving unrelated retry delays. +- Prevented new Mega-Debrid account forms from exposing stored credentials in an unrelated token field. +- Removed both mode-specific Mega-Debrid credential pools from persisted settings when credential storage is disabled. +- Synchronized item and package status immediately when a provider retry is queued. + +### Reliability and testing + +- Kept the latest release notes as a fallback when the release history cannot be loaded. +- Added regression coverage for cumulative version filtering, ordering, update colors, changelog scrolling, RapidGator aliases, centered queue cells, compact password progress, independent Mega-Debrid API and Web Login pools, credential persistence, legacy migration, live session invalidation, live account-pool refresh, stable active ordering, and user-controlled package expansion. +- Verified the update dialog with twelve version sections at a 1120 by 760 pixel viewport. + ## [2.0.22] - 2026-08-11 ### History management diff --git a/package-lock.json b/package-lock.json index c47ce4f..a1f37dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.22", + "version": "2.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.22", + "version": "2.0.23", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 6eec620..bddb8b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.22", + "version": "2.0.23", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index f9aba6a..ff491c0 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -46,9 +46,11 @@ export function defaultSettings(): AppSettings { language: "en", token: "", realDebridUseWebLogin: false, - megaLogin: "", - megaPassword: "", - megaCredentials: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, @@ -133,7 +135,9 @@ export function defaultSettings(): AppSettings { debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyUsageBytes: {}, debridLinkApiKeyTotalUsageBytes: {}, - megaDebridDisabledAccountIds: [], + megaDebridDisabledAccountIds: [], + megaDebridApiDisabledAccountIds: [], + megaDebridWebDisabledAccountIds: [], megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, diff --git a/src/main/debrid.ts b/src/main/debrid.ts index aa702ed..57ee98d 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,5 +1,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, type MegaDebridAccountEntry, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; +import { extractHosterFromUrl } from "../shared/hoster"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; @@ -557,17 +558,7 @@ const PROVIDER_LABELS: Record = { linksnappy: "LinkSnappy" }; -function extractHosterFromUrl(url: string): string { - try { - const host = new URL(url).hostname.replace(/^www\./, "").toLowerCase(); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { - return ""; - } -} - -interface ProviderUnrestrictedLink extends UnrestrictedLink { +interface ProviderUnrestrictedLink extends UnrestrictedLink { provider: DebridProvider; providerLabel: string; } @@ -595,7 +586,9 @@ function cloneSettings(settings: AppSettings): AppSettings { debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }, - megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])], + megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])], + megaDebridApiDisabledAccountIds: [...(settings.megaDebridApiDisabledAccountIds || [])], + megaDebridWebDisabledAccountIds: [...(settings.megaDebridWebDisabledAccountIds || [])], megaDebridAccountDailyLimitBytes: { ...(settings.megaDebridAccountDailyLimitBytes || {}) }, megaDebridAccountDailyUsageBytes: { ...(settings.megaDebridAccountDailyUsageBytes || {}) }, megaDebridAccountTotalUsageBytes: { ...(settings.megaDebridAccountTotalUsageBytes || {}) } @@ -612,26 +605,27 @@ export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = D ); } -export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] { - return getMegaDebridAccountList(settings).filter( - (entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs) - ); -} - -function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] { - const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || ""); - if (multiAccounts.length > 0) { - return multiAccounts; - } - if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) { - return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim()); - } - return []; -} - -function hasMegaDebridCredentials(settings: AppSettings): boolean { - return getMegaDebridAccountList(settings).length > 0; -} +export function getAvailableMegaDebridAccounts(settings: AppSettings, mode: MegaDebridAccountMode, epochMs = Date.now()): MegaDebridAccountEntry[] { + return getMegaDebridAccountList(settings, mode).filter( + (entry) => !isMegaDebridAccountDisabled(settings, entry.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs) + ); +} + +function getMegaDebridAccountList(settings: AppSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] { + const multiAccounts = getMegaDebridAccountsForMode(settings, mode); + if (multiAccounts.length > 0) { + return multiAccounts; + } + const hasDedicatedPools = typeof settings.megaDebridApiCredentials === "string" || typeof settings.megaDebridWebCredentials === "string"; + if (!hasDedicatedPools && settings.megaLogin?.trim() && settings.megaPassword?.trim()) { + return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim()); + } + return []; +} + +function hasMegaDebridCredentials(settings: AppSettings): boolean { + return parseMegaDebridAccounts(mergeMegaDebridCredentialPools(settings.megaDebridApiCredentials || "", settings.megaDebridWebCredentials || "") || settings.megaCredentials || "").length > 0; +} function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean { if (mode === "api") { @@ -2039,12 +2033,12 @@ class MegaDebridClient { megaWebUnrestrict: MegaWebUnrestrictor | undefined, signal?: AbortSignal ): Promise { - const accounts = getMegaDebridAccountList(settings); + const accounts = getMegaDebridAccountList(settings, mode); if (accounts.length === 0) { throw new Error("Mega-Debrid: Kein Account konfiguriert"); } - if (getAvailableMegaDebridAccounts(settings).length === 0) { + if (getAvailableMegaDebridAccounts(settings, mode).length === 0) { throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)"); } @@ -2087,7 +2081,7 @@ class MegaDebridClient { const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`; - if (isMegaDebridAccountDisabled(settings, account.id)) { + if (isMegaDebridAccountDisabled(settings, account.id, mode)) { logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`); logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" }); continue; @@ -2172,7 +2166,7 @@ class MegaDebridClient { if (candidate.account.id === account.id) { return count; } - if (isMegaDebridAccountDisabled(settings, candidate.account.id)) { + if (isMegaDebridAccountDisabled(settings, candidate.account.id, mode)) { return count; } if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) { @@ -2254,7 +2248,7 @@ class MegaDebridClient { let nextLabel = "ENDE"; for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) { const nextAcc = orderedEntries[nextPos].account; - if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) { + if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) { nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`; break; } @@ -3824,9 +3818,10 @@ export class DebridService { return true; } } - if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") { - const configuredAccounts = getMegaDebridAccountList(settings); - if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) { + if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") { + const mode = effectiveProvider === "megadebrid-web" ? "web" : "api"; + const configuredAccounts = getMegaDebridAccountList(settings, mode); + if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings, mode).length === 0) { return true; } } @@ -3842,8 +3837,11 @@ export class DebridService { if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)"; } - if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && getMegaDebridAccountList(settings).length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) { - return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)"; + if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") { + const mode = effectiveProvider === "megadebrid-web" ? "web" : "api"; + if (getMegaDebridAccountList(settings, mode).length > 0 && getAvailableMegaDebridAccounts(settings, mode).length === 0) { + return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)"; + } } return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3e43862..5df4ff5 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -21,8 +21,10 @@ import { StartConflictEntry, StartConflictResolutionResult, UiSnapshot, DebridAccountStatus } from "../shared/types"; -import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { extractHosterFromUrl } from "../shared/hoster"; +import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; +import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { addDebridLinkApiKeyDailyUsageBytes, addDebridLinkApiKeyTotalUsageBytes, @@ -470,15 +472,9 @@ function isArchiveLikePath(filePath: string): boolean { return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower); } -function extractHosterKey(link: string): string { - try { - const host = new URL(link).hostname.replace(/^www\./, "").toLowerCase(); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { - return ""; - } -} +function extractHosterKey(link: string): string { + return extractHosterFromUrl(link); +} function isLargeBinaryLikePath(filePath: string): boolean { const lower = path.basename(String(filePath || "")).toLowerCase(); @@ -2175,7 +2171,26 @@ export class DownloadManager extends EventEmitter { } public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void { - const previous = this.settings; + const previous = this.settings; + const previousMegaPool = (["api", "web"] as const) + .flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`)) + .sort() + .join("\n"); + const nextMegaAccounts = (["api", "web"] as const) + .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode)); + const nextMegaPool = (["api", "web"] as const) + .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`)) + .sort() + .join("\n"); + const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web") + .map((account) => `${account.id}:${account.password}`) + .sort() + .join("\n"); + const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web") + .map((account) => `${account.id}:${account.password}`) + .sort() + .join("\n"); + const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); const now = nowMs(); @@ -2184,8 +2199,11 @@ export class DownloadManager extends EventEmitter { this.invalidateSettingsSnapshotCache(); this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0; this.runtimePersistedAt = now; - this.ensureProviderDailyUsageFresh(nowMs()); - this.debridService.setSettings(next); + this.ensureProviderDailyUsageFresh(nowMs()); + if (previousMegaWebCredentials !== nextMegaWebCredentials) { + this.invalidateMegaSessionFn?.(); + } + this.debridService.setSettings(next); this.allDebridHostInfoCache.clear(); const prevOrder = JSON.stringify(previous.providerOrder ?? []); @@ -2218,7 +2236,11 @@ export class DownloadManager extends EventEmitter { { prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] }, { prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] }, { prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] }, - { prev: previous.megaCredentials + "|" + previous.megaPassword, next: next.megaCredentials + "|" + next.megaPassword, providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] } + { + prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}`, + next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}`, + providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] + } ]; let clearedProviderFailures = 0; for (const change of credChanges) { @@ -2232,9 +2254,13 @@ export class DownloadManager extends EventEmitter { } } } - if (clearedProviderFailures > 0) { - logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); - } + if (clearedProviderFailures > 0) { + logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); + } + + if (!opts?.settingsOnlyImport && megaPoolChanged) { + this.releaseMegaDebridResetParks(); + } if (!opts?.settingsOnlyImport) { this.resolveExistingQueuedOpaqueFilenames(); @@ -2243,8 +2269,43 @@ export class DownloadManager extends EventEmitter { this.applyRetroactiveCleanupPolicy(); } } - this.emitState(); - } + this.emitState(); + } + + private releaseMegaDebridResetParks(): number { + const activeItemIds = new Set([...this.activeTasks.values()].map((task) => task.itemId)); + const affectedPackageIds = new Set(); + let released = 0; + for (const item of Object.values(this.session.items)) { + if (activeItemIds.has(item.id)) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + if (!/Mega-Debrid bis Tagesreset gesperrt/i.test(item.fullStatus || "")) continue; + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.provider = null; + item.providerLabel = undefined; + item.providerAccountId = undefined; + item.providerAccountLabel = undefined; + item.updatedAt = nowMs(); + affectedPackageIds.add(item.packageId); + released += 1; + } + for (const packageId of affectedPackageIds) { + const pkg = this.session.packages[packageId]; + if (pkg) this.refreshPackageStatus(pkg); + } + if (released > 0) { + logger.info(`Settings-Update: ${released} Mega-Debrid-Tagesreset-Warteitem(s) wegen geaendertem Account-Pool freigegeben`); + this.persistSoon(); + if (this.session.running) { + void this.ensureScheduler().catch((error) => logger.error(`Scheduler nach Account-Update fehlgeschlagen: ${compactErrorText(error)}`)); + } + } + return released; + } public getSettings(): AppSettings { return this.settings; @@ -8019,12 +8080,12 @@ export class DownloadManager extends EventEmitter { if (effectiveProvider === "realdebrid") { return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); } - if (effectiveProvider === "megadebrid-api") { - const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim())); - return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled)); - } - if (effectiveProvider === "megadebrid-web") { - const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim())); + if (effectiveProvider === "megadebrid-api") { + const hasMegaCreds = getMegaDebridAccountsForMode(this.settings, "api").length > 0; + return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled)); + } + if (effectiveProvider === "megadebrid-web") { + const hasMegaCreds = getMegaDebridAccountsForMode(this.settings, "web").length > 0; return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled)); } if (effectiveProvider === "bestdebrid") { @@ -8209,7 +8270,7 @@ export class DownloadManager extends EventEmitter { private getSerializedValidatingLimit(provider: DebridProvider | null): number { if (provider === "megadebrid-web" || provider === "megadebrid-api") { const mode = provider === "megadebrid-web" ? "web" : "api"; - const usableAccounts = getAvailableMegaDebridAccounts(this.settings) + const usableAccounts = getAvailableMegaDebridAccounts(this.settings, mode) .filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:${mode}`)) .length; return Math.max(1, usableAccounts); @@ -8793,9 +8854,11 @@ export class DownloadManager extends EventEmitter { genericRetries: Number(active.genericErrorRetries || 0), freshRetryUsed: Boolean(active.freshRetryUsed), resumeHardResetUsed: Boolean(active.resumeHardResetUsed) - }); - this.retryAfterByItem.set(item.id, nowMs() + waitMs); - } + }); + this.retryAfterByItem.set(item.id, nowMs() + waitMs); + const pkg = this.session.packages[item.packageId]; + if (pkg) this.refreshPackageStatus(pkg); + } private scheduleHttp416Retry( item: DownloadItem, diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 347bf46..92f06de 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -226,7 +226,9 @@ export class MegaWebFallback { private getCredentials: () => MegaCredentials; - private sessions = new Map(); + private sessions = new Map(); + + private sessionGeneration = 0; public constructor(getCredentials: () => MegaCredentials) { this.getCredentials = getCredentials; @@ -241,18 +243,19 @@ export class MegaWebFallback { const creds = (account && account.login.trim() && account.password.trim()) ? account : this.getCredentials(); - if (!creds.login.trim() || !creds.password.trim()) { - return null; - } - const key = creds.login.trim().toLowerCase(); - return this.runExclusive(async () => { - throwIfAborted(overallSignal); - let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); - - let generated = await this.generate(link, cookie, overallSignal); - if (!generated) { - this.sessions.delete(key); - cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); + if (!creds.login.trim() || !creds.password.trim()) { + return null; + } + const key = creds.login.trim().toLowerCase(); + const sessionGeneration = this.sessionGeneration; + return this.runExclusive(async () => { + throwIfAborted(overallSignal); + let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); + + let generated = await this.generate(link, cookie, overallSignal); + if (!generated) { + this.sessions.delete(key); + cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); generated = await this.generate(link, cookie, overallSignal); if (!generated) { return null; @@ -267,19 +270,28 @@ export class MegaWebFallback { }, key, overallSignal); } - private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise { + private async ensureSession( + key: string, + login: string, + password: string, + signal?: AbortSignal, + generation = this.sessionGeneration + ): Promise { const existing = this.sessions.get(key); if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) { return existing.cookie; } - const cookie = await this.login(login, password, signal); - this.sessions.set(key, { cookie, setAt: Date.now() }); - return cookie; - } - - public invalidateSession(): void { - this.sessions.clear(); - } + const cookie = await this.login(login, password, signal); + if (generation === this.sessionGeneration) { + this.sessions.set(key, { cookie, setAt: Date.now() }); + } + return cookie; + } + + public invalidateSession(): void { + this.sessionGeneration += 1; + this.sessions.clear(); + } private async runExclusive(job: () => Promise, key: string, signal?: AbortSignal): Promise { const queuedAt = Date.now(); diff --git a/src/main/settings-live-overlay.ts b/src/main/settings-live-overlay.ts index bc7a4ce..8658294 100644 --- a/src/main/settings-live-overlay.ts +++ b/src/main/settings-live-overlay.ts @@ -1,30 +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)) - ); -} +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } 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(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || 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)) + ); +} diff --git a/src/main/storage.ts b/src/main/storage.ts index 7e6523c..cf86313 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -3,7 +3,7 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { defaultSettings } from "./constants"; @@ -381,21 +381,49 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const defaults = defaultSettings(); const directorySettings = migrateLegacyDefaultDirectories(settings, defaults); const currentUsageDay = getProviderUsageDayKey(); - const megaLogin = asText(settings.megaLogin); - const megaPassword = asText(settings.megaPassword); - let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); - if (!megaCredentials && megaLogin && megaPassword) { - megaCredentials = `${megaLogin}:${megaPassword}`; - } - const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials); - const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true; - const hasMegaCreds = Boolean(megaLogin && megaPassword); - const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined - ? Boolean(settings.megaDebridApiEnabled) - : (hasMegaCreds ? megaDebridPreferApi : defaults.megaDebridApiEnabled); - const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined - ? Boolean(settings.megaDebridWebEnabled) - : (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled); + const legacyMegaLogin = asText(settings.megaLogin); + const legacyMegaPassword = asText(settings.megaPassword); + let legacyMegaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); + if (!legacyMegaCredentials && legacyMegaLogin && legacyMegaPassword) { + legacyMegaCredentials = `${legacyMegaLogin}:${legacyMegaPassword}`; + } + const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true; + const hasLegacyMegaCredentials = getMegaDebridAccountIds(legacyMegaCredentials).length > 0; + const requestedMegaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined + ? Boolean(settings.megaDebridApiEnabled) + : (hasLegacyMegaCredentials ? megaDebridPreferApi : defaults.megaDebridApiEnabled); + const requestedMegaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined + ? Boolean(settings.megaDebridWebEnabled) + : (hasLegacyMegaCredentials ? !megaDebridPreferApi : defaults.megaDebridWebEnabled); + const legacyMegaMode = requestedMegaDebridApiEnabled !== requestedMegaDebridWebEnabled + ? requestedMegaDebridApiEnabled ? "api" : "web" + : megaDebridPreferApi ? "api" : "web"; + const rawMegaDebridApiCredentials = String(settings.megaDebridApiCredentials || "").replace(/\r\n|\r/g, "\n").trim(); + const rawMegaDebridWebCredentials = String(settings.megaDebridWebCredentials || "").replace(/\r\n|\r/g, "\n").trim(); + const hasDedicatedMegaPools = Boolean(rawMegaDebridApiCredentials || rawMegaDebridWebCredentials) || !legacyMegaCredentials; + const megaDebridApiCredentials = hasDedicatedMegaPools + ? rawMegaDebridApiCredentials + : legacyMegaMode === "api" ? legacyMegaCredentials : ""; + const megaDebridWebCredentials = hasDedicatedMegaPools + ? rawMegaDebridWebCredentials + : legacyMegaMode === "web" ? legacyMegaCredentials : ""; + const megaDebridApiAccountIds = getMegaDebridAccountIds(megaDebridApiCredentials); + const megaDebridWebAccountIds = getMegaDebridAccountIds(megaDebridWebCredentials); + const megaCredentials = mergeMegaDebridCredentialPools(megaDebridApiCredentials, megaDebridWebCredentials); + const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials); + const primaryMegaAccount = parseMegaDebridAccounts(megaCredentials)[0]; + const megaLogin = primaryMegaAccount?.login || ""; + const megaPassword = primaryMegaAccount?.password || ""; + const megaDebridApiEnabled = requestedMegaDebridApiEnabled && megaDebridApiAccountIds.length > 0; + const megaDebridWebEnabled = requestedMegaDebridWebEnabled && megaDebridWebAccountIds.length > 0; + const legacyMegaDisabledAccountIds = normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds); + const megaDebridApiDisabledAccountIds = Array.isArray(settings.megaDebridApiDisabledAccountIds) + ? normalizeStringList(settings.megaDebridApiDisabledAccountIds, megaDebridApiAccountIds) + : legacyMegaDisabledAccountIds.filter((id) => megaDebridApiAccountIds.includes(id)); + const megaDebridWebDisabledAccountIds = Array.isArray(settings.megaDebridWebDisabledAccountIds) + ? normalizeStringList(settings.megaDebridWebDisabledAccountIds, megaDebridWebAccountIds) + : legacyMegaDisabledAccountIds.filter((id) => megaDebridWebAccountIds.includes(id)); + const megaDebridDisabledAccountIds = [...new Set([...megaDebridApiDisabledAccountIds, ...megaDebridWebDisabledAccountIds])]; const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay); const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw) ? providerDailyUsageDayRaw @@ -429,8 +457,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings { token: asText(settings.token), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), megaLogin, - megaPassword, - megaCredentials, + megaPassword, + megaCredentials, + megaDebridApiCredentials, + megaDebridWebCredentials, megaDebridApiEnabled, megaDebridWebEnabled, megaDebridPreferApi, @@ -527,7 +557,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings { debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {}, debridLinkApiKeyTotalUsageBytes, - megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds), + megaDebridDisabledAccountIds, + megaDebridApiDisabledAccountIds, + megaDebridWebDisabledAccountIds, megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds), megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds) @@ -580,9 +612,11 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { ...settings, token: "", realDebridUseWebLogin: settings.realDebridUseWebLogin, - megaLogin: "", - megaPassword: "", - megaCredentials: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "", bestToken: "", bestDebridUseWebLogin: settings.bestDebridUseWebLogin, allDebridToken: "", @@ -668,11 +702,14 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined }; } -function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings { - if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) { - return parsed; - } - const hasMegaCreds = Boolean(asText(parsed.megaLogin) && asText(parsed.megaPassword)); +function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings { + if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) { + return parsed; + } + const hasMegaCreds = Boolean( + getMegaDebridAccountIds(asText(parsed.megaCredentials), asText(parsed.megaPassword)).length + || (asText(parsed.megaLogin) && asText(parsed.megaPassword)) + ); if (!hasMegaCreds) { return parsed; } @@ -680,16 +717,24 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings { return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi }; } -function readSettingsFile(filePath: string): AppSettings | null { - try { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings; - const migratedLanguage = (parsed as Partial).language === undefined ? "de" : parsed.language; - const merged = normalizeSettings({ +function readSettingsFile(filePath: string): AppSettings | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings; + const migratedLanguage = (parsed as Partial).language === undefined ? "de" : parsed.language; + const migrated = migrateLegacyMegaEnableFlags(parsed); + const mergedInput = { ...defaultSettings(), - ...migrateLegacyMegaEnableFlags(parsed), + ...migrated, language: migratedLanguage, columnOrderVersion: parsed.columnOrderVersion - }); + } as AppSettings; + if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridApiDisabledAccountIds")) { + delete (mergedInput as Partial).megaDebridApiDisabledAccountIds; + } + if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridWebDisabledAccountIds")) { + delete (mergedInput as Partial).megaDebridWebDisabledAccountIds; + } + const merged = normalizeSettings(mergedInput); return sanitizeCredentialPersistence(merged); } catch (error) { const code = (error as NodeJS.ErrnoException)?.code || ""; diff --git a/src/main/update.ts b/src/main/update.ts index 852c765..9ef4948 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -126,26 +126,36 @@ function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSig return AbortSignal.any([primary, secondary]); } -async function readJsonBody(response: Response, timeoutMs: number): Promise | null> { - let timer: NodeJS.Timeout | null = null; - const timeoutPromise = new Promise((_resolve, reject) => { +async function readJsonValue(response: Response, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { timer = setTimeout(() => { void response.body?.cancel().catch(() => undefined); reject(new Error(`timeout:${timeoutMs}`)); }, timeoutMs); - }); - - try { - const data = await Promise.race([ - response.json().catch(() => null) as Promise, - timeoutPromise, - ]); - if (!data || typeof data !== "object" || Array.isArray(data)) return null; - return data as Record; - } finally { - if (timer) clearTimeout(timer); - } -} + }); + + try { + return await Promise.race([ + response.json().catch(() => null) as Promise, + timeoutPromise, + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function readJsonBody(response: Response, timeoutMs: number): Promise | null> { + const data = await readJsonValue(response, timeoutMs); + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + return data as Record; +} + +async function readJsonArray(response: Response, timeoutMs: number): Promise> | null> { + const data = await readJsonValue(response, timeoutMs); + if (!Array.isArray(data)) return null; + return data.filter((entry): entry is Record => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)); +} async function readTextBody(response: Response, timeoutMs: number): Promise { let timer: NodeJS.Timeout | null = null; @@ -357,7 +367,7 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`); } -async function fetchRelease(repo: string, endpoint: string): Promise<{ +async function fetchRelease(repo: string, endpoint: string): Promise<{ ok: boolean; status: number; payload: Record | null; @@ -372,8 +382,26 @@ async function fetchRelease(repo: string, endpoint: string): Promise<{ return { ok: response.ok, status: response.status, payload }; } finally { tc.clear(); - } -} + } +} + +async function fetchReleasePage(repo: string, page: number): Promise<{ + ok: boolean; + status: number; + payload: Array> | null; +}> { + const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS); + try { + const response = await fetch(`${API_BASE}/repos/${repo}/releases?per_page=100&page=${page}`, { + headers: { Accept: "application/vnd.github+json", "User-Agent": USER_AGENT }, + signal: tc.signal, + }); + const payload = await readJsonArray(response, RELEASE_FETCH_TIMEOUT_MS); + return { ok: response.ok, status: response.status, payload }; + } finally { + tc.clear(); + } +} function readAssets(payload: Record): ReleaseAsset[] { const raw = Array.isArray(payload.assets) ? (payload.assets as Array>) : []; @@ -408,7 +436,7 @@ function isDraftOrPrerelease(payload: Record): boolean { return Boolean(payload.draft) || Boolean(payload.prerelease); } -function parseReleasePayload(payload: Record, fallbackUrl: string): UpdateCheckResult { +function parseReleasePayload(payload: Record, fallbackUrl: string): UpdateCheckResult { const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim(); const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION; const releaseUrl = String(payload.html_url || fallbackUrl); @@ -425,8 +453,73 @@ function parseReleasePayload(payload: Record, fallbackUrl: stri setupAssetName: setup?.name || "", setupAssetDigest: setup?.digest || "", releaseNotes: body || undefined, - }; -} + }; +} + +function combineReleaseNotes( + payloads: Array>, + currentVersion: string, + latestVersion: string, +): string { + const seen = new Set(); + const releases = payloads + .filter((payload) => !isDraftOrPrerelease(payload)) + .map((payload) => { + const tag = String(payload.tag_name || "").trim(); + return { + tag, + version: tag.replace(/^v/i, ""), + body: typeof payload.body === "string" ? payload.body.trim() : "", + }; + }) + .filter((release) => ( + release.tag + && release.body + && isRemoteNewer(currentVersion, release.version) + && !isRemoteNewer(latestVersion, release.version) + )) + .sort((first, second) => { + if (isRemoteNewer(first.version, second.version)) return 1; + if (isRemoteNewer(second.version, first.version)) return -1; + return 0; + }); + + return releases + .filter((release) => { + const key = release.version.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map((release) => `${release.tag}\n${release.body}`) + .join("\n\n"); +} + +async function resolveMissingReleaseNotes( + repo: string, + latestPayload: Record, + latestVersion: string, +): Promise { + const fallbackBody = typeof latestPayload.body === "string" ? latestPayload.body.trim() : ""; + const payloads: Array> = [latestPayload]; + + try { + for (let page = 1; page <= 10; page += 1) { + const result = await fetchReleasePage(repo, page); + if (!result.ok || !result.payload) break; + payloads.push(...result.payload); + const reachedInstalledVersion = result.payload.some((payload) => { + const version = String(payload.tag_name || "").trim().replace(/^v/i, ""); + return version && !isRemoteNewer(APP_VERSION, version); + }); + if (result.payload.length < 100 || reachedInstalledVersion) break; + } + } catch { + return fallbackBody; + } + + return combineReleaseNotes(payloads, APP_VERSION, latestVersion) || fallbackBody; +} function uniqueStrings(values: string[]): string[] { const seen = new Set(); @@ -804,7 +897,7 @@ export function buildInstallerLaunchArgs(): string[] { return ["/S", "--updated", "--force-run"]; } -export async function checkGitHubUpdate(repo: string): Promise { +export async function checkGitHubUpdate(repo: string): Promise { const safeRepo = normalizeUpdateRepo(repo); const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`; const fallback: UpdateCheckResult = { @@ -817,15 +910,20 @@ export async function checkGitHubUpdate(repo: string): Promise 0) return true; - return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); -} +function hasMegaDebridCredentials(settings: AppSettings, mode?: MegaDebridAccountMode): boolean { + if (mode) { + return getMegaDebridAccountsForMode(settings, mode).length > 0; + } + return getMegaDebridAccountsForMode(settings, "api").length > 0 || getMegaDebridAccountsForMode(settings, "web").length > 0; +} function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] { const list: DebridProvider[] = []; if (settings.token.trim() || settings.realDebridUseWebLogin) { list.push("realdebrid"); } - if (hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled) { + if (hasMegaDebridCredentials(settings, "api") && settings.megaDebridApiEnabled) { list.push("megadebrid-api"); } - if (hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled) { + if (hasMegaDebridCredentials(settings, "web") && settings.megaDebridWebEnabled) { list.push("megadebrid-web"); } if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) { @@ -682,9 +682,9 @@ function getConfiguredAccountKind(settings: AppSettings, service: AccountService if (settings.realDebridUseWebLogin) return "realdebrid-web"; return settings.token.trim() ? "realdebrid-api" : null; case "megadebrid-api": - return hasMegaDebridCredentials(settings) && settings.megaDebridApiEnabled ? "megadebrid-api" : null; + return hasMegaDebridCredentials(settings, "api") && settings.megaDebridApiEnabled ? "megadebrid-api" : null; case "megadebrid-web": - return hasMegaDebridCredentials(settings) && settings.megaDebridWebEnabled ? "megadebrid-web" : null; + return hasMegaDebridCredentials(settings, "web") && settings.megaDebridWebEnabled ? "megadebrid-web" : null; case "bestdebrid": if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; return settings.bestToken.trim() ? "bestdebrid-api" : null; @@ -719,9 +719,10 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string { return maskValue(settings.token, 3, 3); case "realdebrid-web": return "Browser-Login"; - case "megadebrid-api": - case "megadebrid-web": { - const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); + case "megadebrid-api": + case "megadebrid-web": { + const mode = kind === "megadebrid-web" ? "web" : "api"; + const megaAccounts = getMegaDebridAccountsForMode(settings, mode); if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`; if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin; return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt"; @@ -757,8 +758,8 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string return keys.map((entry) => `${entry.label}: ${entry.masked}`); } } - if (kind === "megadebrid-api" || kind === "megadebrid-web") { - const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); + if (kind === "megadebrid-api" || kind === "megadebrid-web") { + const accounts = getMegaDebridAccountsForMode(settings, kind === "megadebrid-web" ? "web" : "api"); if (accounts.length > 1) { return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`); } @@ -782,7 +783,7 @@ function getStoredAccountUsername(kind: AccountKind, settings: AppSettings): str } } -function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { +export function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { const baseMega: Pick = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; if (!kind) { return { @@ -805,17 +806,14 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n return { mode, kind, service, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "realdebrid-web": return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "megadebrid-api": - case "megadebrid-web": { - let megaToken = (settings.megaCredentials || "").trim(); - if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) { - megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`; - } - const parsed = parseMegaDebridAccounts(megaToken); - const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); - const loadedIds = new Set(parsed.map((a) => a.id)); - const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id)); - return { mode, kind, service, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; + case "megadebrid-api": + case "megadebrid-web": { + const megaMode = kind === "megadebrid-web" ? "web" : "api"; + const parsed = getMegaDebridAccountsForMode(settings, megaMode); + const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); + const loadedIds = new Set(parsed.map((a) => a.id)); + const megaDisabledIds = getMegaDebridDisabledAccountIdsForMode(settings, megaMode).filter((id) => loadedIds.has(id)); + return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; } case "bestdebrid-api": return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; @@ -846,9 +844,86 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n default: return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; } -} - -export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { +} + +export function buildAccountAddFields(dialog: AccountDialogState | null): AccountDialogField[] { + if (!dialog?.kind) { + return []; + } + const option = findAccountOption(dialog.kind); + return [ + ...((dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") ? [ + { id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: dialog.megaNewLogin }, + { id: "megaNewPassword", label: "Passwort", type: "password" as const, value: dialog.megaNewPassword } + ] : option.needsCredentials ? [ + { id: "login", label: "Login / E-Mail", type: "text" as const, value: dialog.login }, + { id: "password", label: "Passwort", type: "password" as const, value: dialog.password } + ] : []), + ...(option.needsToken ? [ + { id: "token", label: dialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: dialog.token } + ] : []), + { + id: "dailyLimitGb", + label: "Tageslimit (GB, optional)", + type: "number" as const, + value: dialog.dailyLimitGb, + placeholder: "Kein Limit", + help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt." + } + ]; +} + +function applyMegaDebridDialogToSettings(settings: AppSettings, dialog: AccountDialogState, mode: MegaDebridAccountMode, providerDailyLimitBytes: AppSettings["providerDailyLimitBytes"]): AppSettings { + const selectedCredentials = serializeMegaDebridAccounts(dialog.megaAccounts); + const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); + const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); + const first = parseMegaDebridAccounts(mergedCredentials)[0]; + const selectedIds = new Set(parseMegaDebridAccounts(selectedCredentials).map((account) => account.id)); + const selectedDisabledIds = (dialog.megaDisabledIds || []).filter((id) => selectedIds.has(id)); + const apiDisabledIds = mode === "api" ? selectedDisabledIds : getMegaDebridDisabledAccountIdsForMode(settings, "api"); + const webDisabledIds = mode === "web" ? selectedDisabledIds : getMegaDebridDisabledAccountIdsForMode(settings, "web"); + return { + ...settings, + megaCredentials: mergedCredentials, + megaLogin: first?.login || "", + megaPassword: first?.password || "", + megaDebridApiCredentials: apiCredentials, + megaDebridWebCredentials: webCredentials, + megaDebridApiEnabled: mode === "api" ? selectedIds.size > 0 : settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, + megaDebridWebEnabled: mode === "web" ? selectedIds.size > 0 : settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, + megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], + megaDebridApiDisabledAccountIds: apiDisabledIds, + megaDebridWebDisabledAccountIds: webDisabledIds, + providerDailyLimitBytes + }; +} + +function clearMegaDebridModeFromSettings(settings: AppSettings, mode: MegaDebridAccountMode, providerDailyLimitBytes: AppSettings["providerDailyLimitBytes"], providerDailyUsageBytes: AppSettings["providerDailyUsageBytes"]): AppSettings { + const apiCredentials = mode === "api" ? "" : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? "" : getMegaDebridCredentialsForMode(settings, "web"); + const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); + const first = parseMegaDebridAccounts(mergedCredentials)[0]; + const apiDisabledIds = mode === "api" ? [] : getMegaDebridDisabledAccountIdsForMode(settings, "api"); + const webDisabledIds = mode === "web" ? [] : getMegaDebridDisabledAccountIdsForMode(settings, "web"); + return { + ...settings, + megaCredentials: mergedCredentials, + megaLogin: first?.login || "", + megaPassword: first?.password || "", + megaDebridApiCredentials: apiCredentials, + megaDebridWebCredentials: webCredentials, + megaDebridApiEnabled: mode === "api" ? false : settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, + megaDebridWebEnabled: mode === "web" ? false : settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, + megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], + megaDebridApiDisabledAccountIds: apiDisabledIds, + megaDebridWebDisabledAccountIds: webDisabledIds, + providerDailyLimitBytes, + providerDailyUsageBytes + }; +} + +export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { if (!dialog.kind) { return settings; } @@ -876,23 +951,11 @@ export function applyAccountDialogToSettings(settings: AppSettings, dialog: Acco return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "realdebrid-web": return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "megadebrid-api": { - const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts); - const megaParsed = parseMegaDebridAccounts(megaSerialized); - const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : ""; - const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : ""; - const validIds = new Set(megaParsed.map((a) => a.id)); - const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id)); - return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - } - case "megadebrid-web": { - const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts); - const megaParsed = parseMegaDebridAccounts(megaSerialized); - const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : ""; - const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : ""; - const validIds = new Set(megaParsed.map((a) => a.id)); - const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id)); - return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "megadebrid-api": { + return applyMegaDebridDialogToSettings(settings, dialog, "api", nextProviderDailyLimitBytes); + } + case "megadebrid-web": { + return applyMegaDebridDialogToSettings(settings, dialog, "web", nextProviderDailyLimitBytes); } case "bestdebrid-api": return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; @@ -937,14 +1000,10 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account switch (service) { case "realdebrid": return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "megadebrid-api": - return settings.megaDebridWebEnabled - ? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } - : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "megadebrid-web": - return settings.megaDebridApiEnabled - ? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } - : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + case "megadebrid-api": + return clearMegaDebridModeFromSettings(settings, "api", nextProviderDailyLimitBytes, nextProviderDailyUsageBytes); + case "megadebrid-web": + return clearMegaDebridModeFromSettings(settings, "web", nextProviderDailyLimitBytes, nextProviderDailyUsageBytes); case "bestdebrid": return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "alldebrid": @@ -1029,7 +1088,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", + language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiCredentials: "", megaDebridWebCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", debridLinkDisabledKeyIds: [], archivePasswordList: "", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", @@ -1056,7 +1115,9 @@ const emptySnapshot = (): UiSnapshot => ({ debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyUsageBytes: {}, debridLinkApiKeyTotalUsageBytes: {}, - megaDebridDisabledAccountIds: [], + megaDebridDisabledAccountIds: [], + megaDebridApiDisabledAccountIds: [], + megaDebridWebDisabledAccountIds: [], megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, @@ -1753,8 +1814,6 @@ export function App(): ReactElement { const snapshotRef = useRef(snapshot); snapshotRef.current = snapshot; const tabRef = useRef(tab); - const autoExpandedPkgsRef = useRef(new Set()); - const manualCollapsedPkgsRef = useRef(new Set()); tabRef.current = tab; const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); @@ -2270,12 +2329,7 @@ export function App(): ReactElement { changed = true; } } - for (const packageId of Array.from(manualCollapsedPkgsRef.current)) { - if (!snapshot.session.packages[packageId]) { - manualCollapsedPkgsRef.current.delete(packageId); - } - } - return changed ? next : prev; + return changed ? next : prev; }); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); @@ -2287,18 +2341,18 @@ export function App(): ReactElement { }, [snapshot.session.packages, snapshot.session.items]); const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) - ? snapshot.session.items - : null; + ? snapshot.session.items + : null; const visiblePackages = useMemo(() => { if (!sortRelevantItems) { return packages; } return sortPackagesForDisplay( - packages, - sortRelevantItems, - true, - true - ); + packages, + sortRelevantItems, + true, + true + ); }, [packages, sortRelevantItems]); const downloadsViewCore = useMemo(() => buildDownloadsViewModel({ @@ -2338,36 +2392,6 @@ export function App(): ReactElement { void loadAllDebridHostInfo(true); }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); - useEffect(() => { - const extractingPkgIds: string[] = []; - const currentlyExtracting = new Set(); - for (const pkg of packages) { - const items = (pkg.itemIds ?? []).map((id) => snapshot.session.items[id]).filter(Boolean); - const isExtracting = items.some((item) => item.fullStatus?.startsWith("Entpacken -") && !item.fullStatus?.includes("Done")); - if (isExtracting) { - currentlyExtracting.add(pkg.id); - if (collapsedPackages[pkg.id] - && !manualCollapsedPkgsRef.current.has(pkg.id) - && !autoExpandedPkgsRef.current.has(pkg.id)) { - extractingPkgIds.push(pkg.id); - autoExpandedPkgsRef.current.add(pkg.id); - } - } - } - for (const id of autoExpandedPkgsRef.current) { - if (!currentlyExtracting.has(id)) { - autoExpandedPkgsRef.current.delete(id); - } - } - if (extractingPkgIds.length > 0) { - setCollapsedPackages((prev) => { - const next = { ...prev }; - for (const id of extractingPkgIds) next[id] = false; - return next; - }); - } - }, [packages, snapshot.session.items, collapsedPackages]); - const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) ), [packages, collapsedPackages]); @@ -2453,12 +2477,12 @@ export function App(): ReactElement { const option = findAccountOption(kind); let statusLabel = "Aktiviert"; let note = ""; - if (kind === "megadebrid-api") { - const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; + if (kind === "megadebrid-api") { + const megaAccountCount = getMegaDebridAccountsForMode(settingsDraft, "api").length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur API aktiv. Kein Web-Fallback."; - } else if (kind === "megadebrid-web") { - const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; + } else if (kind === "megadebrid-web") { + const megaAccountCount = getMegaDebridAccountsForMode(settingsDraft, "web").length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur Web aktiv. Kein API-Fallback."; } else if (kind === "realdebrid-web") { @@ -2569,8 +2593,10 @@ export function App(): ReactElement { const accountRows = useMemo(() => { const rows: AccountTableRow[] = []; for (const entry of configuredAccounts) { - if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { - const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || ""); + if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { + const mode = entry.kind === "megadebrid-web" ? "web" : "api"; + const accounts = getMegaDebridAccountsForMode(settingsDraft, mode); + const disabledAccountIds = getMegaDebridDisabledAccountIdsForMode(settingsDraft, mode); for (const acc of accounts) { const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id); const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id); @@ -2583,7 +2609,7 @@ export function App(): ReactElement { credentialLabel: "••••••", accountId: acc.id, checkable: true, - disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id), + disabled: disabledAccountIds.includes(acc.id), dailyUsedBytes: used, dailyLimitBytes: limit, dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, @@ -3095,9 +3121,23 @@ export function App(): ReactElement { megaCredentials: serializeMegaDebridAccounts([newMegaCredentials]), megaLogin: newMegaCredentials.login, megaPassword: newMegaCredentials.password, + megaDebridApiCredentials: selectedOption?.service === "megadebrid-web" ? "" : serializeMegaDebridAccounts([newMegaCredentials]), + megaDebridWebCredentials: selectedOption?.service === "megadebrid-web" ? serializeMegaDebridAccounts([newMegaCredentials]) : "", + megaDebridApiEnabled: selectedOption?.service !== "megadebrid-web", + megaDebridWebEnabled: selectedOption?.service === "megadebrid-web", debridLinkApiKeys: "" } - : { ...nextDraft, megaCredentials: "", megaLogin: "", megaPassword: "", debridLinkApiKeys: newDebridLinkToken }; + : { + ...nextDraft, + megaCredentials: "", + megaLogin: "", + megaPassword: "", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "", + megaDebridApiEnabled: false, + megaDebridWebEnabled: false, + debridLinkApiKeys: newDebridLinkToken + }; const statuses = await window.rd.checkDebridAccounts(checkSettings, true, targetedCheck.expectedStatusId); const status = statuses.length === 1 && statuses[0].accountId === targetedCheck.expectedStatusId ? statuses[0] : null; if (!status || !status.valid) { @@ -3193,30 +3233,26 @@ export function App(): ReactElement { }); }; - const onToggleMegaAccountEnabled = async (login: string, currentlyDisabled: boolean): Promise => { - const accId = getMegaDebridAccountId(login.trim()); - await performQuickAction(async () => { - const current = settingsDraft.megaDebridDisabledAccountIds || []; - const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId]; - await persistSpecificSettings({ ...settingsDraft, megaDebridDisabledAccountIds: next }); + const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", login: string, currentlyDisabled: boolean): Promise => { + const accId = getMegaDebridAccountId(login.trim()); + await performQuickAction(async () => { + const mode = kind === "megadebrid-web" ? "web" : "api"; + const current = getMegaDebridDisabledAccountIdsForMode(settingsDraft, mode); + const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId]; + const apiDisabledIds = mode === "api" ? next : getMegaDebridDisabledAccountIdsForMode(settingsDraft, "api"); + const webDisabledIds = mode === "web" ? next : getMegaDebridDisabledAccountIdsForMode(settingsDraft, "web"); + await persistSpecificSettings({ + ...settingsDraft, + megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], + megaDebridApiDisabledAccountIds: apiDisabledIds, + megaDebridWebDisabledAccountIds: webDisabledIds + }); showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000); }, (error) => { showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); }); }; - const onRemoveMegaAccount = async (login: string): Promise => { - const confirmed = await askConfirmPrompt({ title: "Account entfernen", message: `Soll der Mega-Debrid-Account ${maskMegaDebridLogin(login)} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); - if (!confirmed) return; - await performQuickAction(async () => { - const remaining = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "") - .filter((a) => a.login.trim().toLowerCase() !== login.trim().toLowerCase()); - const nextCreds = serializeMegaDebridAccounts(remaining.map((a) => ({ login: a.login, password: a.password }))); - await persistSpecificSettings({ ...settingsDraft, megaCredentials: nextCreds, megaLogin: remaining[0]?.login ?? "", megaPassword: remaining[0]?.password ?? "" }); - showToast("Account entfernt", 2000); - }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); - }; - const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); if (!confirmed) return; @@ -3253,7 +3289,7 @@ export function App(): ReactElement { const toggleAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); if (row.toggleKind === "mega" && row.megaLogin) { - void onToggleMegaAccountEnabled(row.megaLogin, row.disabled); + void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.megaLogin, row.disabled); } else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); } else { @@ -3273,7 +3309,9 @@ export function App(): ReactElement { ); const nextDraft: AppSettings = { ...settingsDraft, - ...nextEnabledState + ...nextEnabledState, + megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), + megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string) }; await persistSpecificSettings(nextDraft); showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200); @@ -3310,7 +3348,7 @@ export function App(): ReactElement { const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); if (row.toggleKind === "mega" && row.megaLogin) { - const account = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "") + const account = getMegaDebridAccountsForMode(settingsDraft, row.entry.kind === "megadebrid-web" ? "web" : "api") .find((entry) => entry.id === row.accountId); if (account) { void runMegaAccountCheck(account.login, account.password); @@ -4029,17 +4067,11 @@ export function App(): ReactElement { } }, [showToast]); - const onPackageToggleCollapse = useCallback((packageId: string): void => { - setCollapsedPackages((prev) => { - const nextCollapsed = !(prev[packageId] ?? false); - if (nextCollapsed) { - manualCollapsedPkgsRef.current.add(packageId); - } else { - manualCollapsedPkgsRef.current.delete(packageId); - autoExpandedPkgsRef.current.delete(packageId); - } - return { ...prev, [packageId]: nextCollapsed }; - }); + const onPackageToggleCollapse = useCallback((packageId: string): void => { + setCollapsedPackages((prev) => { + const nextCollapsed = !(prev[packageId] ?? false); + return { ...prev, [packageId]: nextCollapsed }; + }); }, []); const onPackageCancel = useCallback((packageId: string): void => { @@ -5014,11 +5046,6 @@ export function App(): ReactElement { const next = { ...current }; for (const entry of packages) { next[entry.id] = targetState; - if (targetState) manualCollapsedPkgsRef.current.add(entry.id); - else { - manualCollapsedPkgsRef.current.delete(entry.id); - autoExpandedPkgsRef.current.delete(entry.id); - } } return next; }); @@ -5480,26 +5507,7 @@ export function App(): ReactElement { multi: option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", icon: ACCOUNT_SERVICE_ICONS[option.service] })); - const accountAddFields: AccountDialogField[] = accountDialog && accountDialogOption ? [ - ...((accountDialog.kind === "megadebrid-api" || accountDialog.kind === "megadebrid-web") ? [ - { id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: accountDialog.megaNewLogin }, - { id: "megaNewPassword", label: "Passwort", type: "password" as const, value: accountDialog.megaNewPassword } - ] : accountDialogOption.needsCredentials ? [ - { id: "login", label: "Login / E-Mail", type: "text" as const, value: accountDialog.login }, - { id: "password", label: "Passwort", type: "password" as const, value: accountDialog.password } - ] : []), - ...(accountDialogOption.needsToken ? [ - { id: "token", label: accountDialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: accountDialog.token } - ] : []), - { - id: "dailyLimitGb", - label: "Tageslimit (GB, optional)", - type: "number" as const, - value: accountDialog.dailyLimitGb, - placeholder: "Kein Limit", - help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt." - } - ] : []; + const accountAddFields = buildAccountAddFields(accountDialog); const accountAddDialog = ( ; - -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; -} - +import { parseDebridLinkApiKeys, getDebridLinkApiKeyId } from "../shared/debrid-link-keys"; +import { getMegaDebridAccountId, getMegaDebridAccountsForMode, getMegaDebridCredentialsForMode, getMegaDebridDisabledAccountIdsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, serializeMegaDebridAccounts, type MegaDebridAccountMode } 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; + +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 getMegaTargetMode(target: Extract): MegaDebridAccountMode { + return target.kind === "megadebrid-web" ? "web" : "api"; } - -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(record: Record, ...keys: string[]): Record { - 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, oldId: string, newId: string, value: string, originalBytes: number): Record { - 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, 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); - } + +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(record: Record, ...keys: string[]): Record { + 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, oldId: string, newId: string, value: string, originalBytes: number): Record { + 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, 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 }): 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 }; - } -} - + const account = getMegaDebridAccountsForMode(settings, getMegaTargetMode(target)) + .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 = getMegaDebridAccountsForMode(settings, getMegaTargetMode(target)); + 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 }): 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 }): AppSettings { - const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); + const mode = getMegaTargetMode(state.target); + const otherMode: MegaDebridAccountMode = mode === "api" ? "web" : "api"; + const accounts = getMegaDebridAccountsForMode(settings, mode); 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); + 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: "" }; + const selectedCredentials = serializeMegaDebridAccounts(nextAccounts); + const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); + const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); + const first = parseMegaDebridAccounts(mergedCredentials)[0]; + const oldIdStillUsed = idChanged && getMegaDebridAccountsForMode(settings, otherMode).some((entry) => entry.id === oldId); + const selectedDisabledIds = idChanged + ? migrateDisabledId(getMegaDebridDisabledAccountIdsForMode(settings, mode), oldId, newId) + : getMegaDebridDisabledAccountIdsForMode(settings, mode); + const otherDisabledIds = getMegaDebridDisabledAccountIdsForMode(settings, otherMode); + const apiDisabledIds = mode === "api" ? selectedDisabledIds : otherDisabledIds; + const webDisabledIds = mode === "web" ? selectedDisabledIds : otherDisabledIds; + const nextDailyLimits = updateTargetLimit(settings.megaDebridAccountDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes); + if (oldIdStillUsed && settings.megaDebridAccountDailyLimitBytes?.[oldId]) { + nextDailyLimits[oldId] = settings.megaDebridAccountDailyLimitBytes[oldId]; + } + const retainOldMetadata = (record: Record): Record => idChanged + ? oldIdStillUsed ? withoutRecordKeys(record, newId) : withoutRecordKeys(record, oldId, newId) + : { ...record }; 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 || {}) } + megaCredentials: mergedCredentials, + megaLogin: first?.login || "", + megaPassword: first?.password || "", + megaDebridApiCredentials: apiCredentials, + megaDebridWebCredentials: webCredentials, + megaDebridApiEnabled: settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, + megaDebridWebEnabled: settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, + megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], + megaDebridApiDisabledAccountIds: apiDisabledIds, + megaDebridWebDisabledAccountIds: webDisabledIds, + megaDebridAccountDailyLimitBytes: nextDailyLimits, + megaDebridAccountDailyUsageBytes: retainOldMetadata(settings.megaDebridAccountDailyUsageBytes || {}), + megaDebridAccountTotalUsageBytes: retainOldMetadata(settings.megaDebridAccountTotalUsageBytes || {}), + debridAccountStatuses: retainOldMetadata(settings.debridAccountStatuses || {}) }; } - -function applyDebridLinkEdit(settings: AppSettings, state: AccountEditState & { target: Extract }): 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 }); - } - if (state.target.type === "mega") { - return applyMegaEdit(settings, state as AccountEditState & { target: Extract }); - } - return applyDebridLinkEdit(settings, state as AccountEditState & { target: Extract }); -} - -function clearSingleAccount(settings: AppSettings, target: Extract): 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); + +function applyDebridLinkEdit(settings: AppSettings, state: AccountEditState & { target: Extract }): 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 }); + } + if (state.target.type === "mega") { + return applyMegaEdit(settings, state as AccountEditState & { target: Extract }); + } + return applyDebridLinkEdit(settings, state as AccountEditState & { target: Extract }); +} + +function clearSingleAccount(settings: AppSettings, target: Extract): 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 || "") + const mode = getMegaTargetMode(target); + const selectedAccounts = getMegaDebridAccountsForMode(settings, mode) .filter((entry) => entry.id !== target.accountId) .map((entry) => ({ login: entry.login, password: entry.password })); - const first = accounts[0] || { login: "", password: "" }; + const selectedCredentials = serializeMegaDebridAccounts(selectedAccounts); + const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); + const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); + const first = parseMegaDebridAccounts(mergedCredentials)[0]; + const accountStillUsed = parseMegaDebridAccounts(mode === "api" ? webCredentials : apiCredentials).some((entry) => entry.id === target.accountId); + const apiDisabledIds = (mode === "api" ? getMegaDebridDisabledAccountIdsForMode(settings, "api").filter((id) => id !== target.accountId) : getMegaDebridDisabledAccountIdsForMode(settings, "api")); + const webDisabledIds = (mode === "web" ? getMegaDebridDisabledAccountIdsForMode(settings, "web").filter((id) => id !== target.accountId) : getMegaDebridDisabledAccountIdsForMode(settings, "web")); + const keepOrRemoveMetadata = (record: Record): Record => accountStillUsed ? { ...record } : withoutRecordKeys(record, target.accountId); 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 { + megaCredentials: mergedCredentials, + megaLogin: first?.login || "", + megaPassword: first?.password || "", + megaDebridApiCredentials: apiCredentials, + megaDebridWebCredentials: webCredentials, + megaDebridApiEnabled: parseMegaDebridAccounts(apiCredentials).length > 0 && settings.megaDebridApiEnabled, + megaDebridWebEnabled: parseMegaDebridAccounts(webCredentials).length > 0 && settings.megaDebridWebEnabled, + megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], + megaDebridApiDisabledAccountIds: apiDisabledIds, + megaDebridWebDisabledAccountIds: webDisabledIds, + megaDebridAccountDailyLimitBytes: keepOrRemoveMetadata(settings.megaDebridAccountDailyLimitBytes || {}), + megaDebridAccountDailyUsageBytes: keepOrRemoveMetadata(settings.megaDebridAccountDailyUsageBytes || {}), + megaDebridAccountTotalUsageBytes: keepOrRemoveMetadata(settings.megaDebridAccountTotalUsageBytes || {}), + debridAccountStatuses: keepOrRemoveMetadata(settings.debridAccountStatuses || {}) + }; + } + 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(); + const credentials = serializeMegaDebridAccounts([{ login, password: state.password }]); + const mode = getMegaTargetMode(state.target); return { ...settings, - megaCredentials: serializeMegaDebridAccounts([{ login, password: state.password }]), + megaCredentials: credentials, megaLogin: login, megaPassword: state.password, + megaDebridApiCredentials: mode === "api" ? credentials : "", + megaDebridWebCredentials: mode === "web" ? credentials : "", + megaDebridApiEnabled: mode === "api", + megaDebridWebEnabled: mode === "web", + megaDebridApiDisabledAccountIds: [], + megaDebridWebDisabledAccountIds: [], debridLinkApiKeys: "" - }; - } - if (state.target.type === "debridlink") { - return { + }; + } + if (state.target.type === "debridlink") { + return { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "", debridLinkApiKeys: state.token.trim() - }; - } - return { - ...settings, + }; + } + return { + ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "", 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; -} + }; +} + +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; +} diff --git a/src/renderer/download-format.ts b/src/renderer/download-format.ts index caafbf1..b49424c 100644 --- a/src/renderer/download-format.ts +++ b/src/renderer/download-format.ts @@ -1,4 +1,5 @@ import type { AudioStripSummary, DebridProvider } from "../shared/types"; +import { extractHosterFromUrl } from "../shared/hoster"; import { hosterIconSources } from "./hoster-icons"; export const providerLabels: Record = { @@ -48,13 +49,7 @@ export function formatDateTime(timestamp: number): string { } export function extractHoster(url: string): string { - try { - const host = new URL(url).hostname.replace(/^www\./, ""); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { - return ""; - } + return extractHosterFromUrl(url); } export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } { diff --git a/src/renderer/package-order.ts b/src/renderer/package-order.ts index a38bb52..1b563e3 100644 --- a/src/renderer/package-order.ts +++ b/src/renderer/package-order.ts @@ -1,6 +1,13 @@ import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types"; -const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); +const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); + +function isPackageActive(pkg: PackageEntry, itemsById: Record): boolean { + return pkg.itemIds.some((id) => { + const item = itemsById[id]; + return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status); + }); +} export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { const fromIndex = order.indexOf(draggedPackageId); @@ -26,36 +33,36 @@ export function sortPackageOrderByName(order: string[], packages: Record, - running: boolean, - autoSortPackagesByProgress: boolean -): PackageEntry[] { +export function sortPackagesForDisplay( + packages: PackageEntry[], + itemsById: Record, + running: boolean, + autoSortPackagesByProgress: boolean +): PackageEntry[] { if (!running || !autoSortPackagesByProgress || packages.length <= 1) { return packages; } - const active: PackageEntry[] = []; - const rest: PackageEntry[] = []; - - // Float packages that have an active item to the top, but keep BOTH groups in - // their original (queue) order. Earlier this sorted the active group by live - // completedRatio/downloadedBytes — which change on every progress tick (every - // 150-700ms), so active packages visibly reshuffled the whole time. A package - // entering/leaving the active bucket is a real, discrete event (start/finish); - // ranking *within* the bucket by live bytes was pure jitter nobody needs. - for (const pkg of packages) { - const hasActive = pkg.itemIds.some((id) => { - const item = itemsById[id]; - return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status); - }); - (hasActive ? active : rest).push(pkg); - } - - if (active.length === 0 || active.length === packages.length) { - return packages; - } + const active = packages + .map((pkg, index) => ({ pkg, index })) + .filter(({ pkg }) => isPackageActive(pkg, itemsById)) + .sort((left, right) => { + const leftStartedAt = left.pkg.downloadStartedAt || 0; + const rightStartedAt = right.pkg.downloadStartedAt || 0; + if (leftStartedAt > 0 && rightStartedAt > 0 && leftStartedAt !== rightStartedAt) { + return leftStartedAt - rightStartedAt; + } + if (leftStartedAt > 0 && rightStartedAt <= 0) return -1; + if (leftStartedAt <= 0 && rightStartedAt > 0) return 1; + return left.index - right.index; + }) + .map(({ pkg }) => pkg); + const activeSet = new Set(active.map((pkg) => pkg.id)); + const rest = packages.filter((pkg) => !activeSet.has(pkg.id)); + + if (active.length === 0) { + return packages; + } return [...active, ...rest]; } diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 17060ed..d8559e3 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -155,7 +155,7 @@ align-items: center; } -.md-update-trigger { +.md-update-trigger { display: inline-flex; height: 40px; min-width: 90px; @@ -164,8 +164,8 @@ justify-content: center; border: 0; border-radius: 6px; - background: var(--ui-primary); - color: var(--ui-primary-text); + background: var(--ui-update); + color: var(--ui-update-text); cursor: pointer; font-size: 14px; font-weight: 600; @@ -173,8 +173,8 @@ white-space: nowrap; } -.md-update-trigger:hover { - background: var(--ui-primary-hover); +.md-update-trigger:hover { + background: var(--ui-update-hover); } .md-update-trigger:focus-visible, @@ -308,13 +308,18 @@ line-height: 16px; } -.md-update-release-notes pre { - margin: 10px 0 0; - color: var(--ui-text-secondary); - font: inherit; - line-height: 20px; - white-space: pre-wrap; -} +.md-update-release-notes pre { + margin: 10px 0 0; + max-height: min(360px, 45vh); + overflow-y: auto; + padding-right: 8px; + color: var(--ui-text-secondary); + font: inherit; + line-height: 20px; + overscroll-behavior: contain; + scrollbar-gutter: stable; + white-space: pre-wrap; +} .md-update-dialog-actions { display: flex; diff --git a/src/renderer/theme.css b/src/renderer/theme.css index ab0e20a..d7f370e 100644 --- a/src/renderer/theme.css +++ b/src/renderer/theme.css @@ -15,6 +15,9 @@ --ui-primary: #D6D6D6; --ui-primary-hover: #E6E6E6; --ui-primary-text: #181A1F; + --ui-update: #BAD0FC; + --ui-update-hover: #8AA5DC; + --ui-update-text: #181A1F; --ui-accent: #4A4A4A; --ui-focus: #9AB8E8; --ui-speed-accent: #4ADE80; @@ -48,6 +51,9 @@ --ui-primary: #3A3A3A; --ui-primary-hover: #202020; --ui-primary-text: #FFFFFF; + --ui-update: #BAD0FC; + --ui-update-hover: #8AA5DC; + --ui-update-text: #181A1F; --ui-accent: #5E5E5E; --ui-focus: #24558D; --ui-speed-accent: #1E9E55; diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index b81aab9..210f3d1 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -139,6 +139,10 @@ export function compactDownloadStatus(value: string): string { if (/Download running\b/i.test(status)) return "Download running"; if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden"; if (/^Password found\b/i.test(status)) return "Password found"; + const passwordCracking = status.match(/^(Passwort knacken|Cracking password):?\s*(\d+)%\s*(?:\((\d+\/\d+)\))?/i); + if (passwordCracking) { + return `${passwordCracking[1]}: ${passwordCracking[2]}%${passwordCracking[3] ? ` (${passwordCracking[3]})` : ""}`; + } if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler"; if (/^Extraction error\b/i.test(status)) return "Extraction error"; const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i); diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 79499b3..17b7f25 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -420,8 +420,8 @@ } .downloads-cell-slot > :is(.downloads-status-cell, .downloads-service-cell) { - justify-content: flex-start; - text-align: left; + justify-content: center; + text-align: center; } .downloads-cell, diff --git a/src/shared/hoster.ts b/src/shared/hoster.ts new file mode 100644 index 0000000..2694171 --- /dev/null +++ b/src/shared/hoster.ts @@ -0,0 +1,24 @@ +const DOMAIN_ALIASES: Readonly> = Object.freeze({ + "rapidgator.net": "rapidgator", + "rapidgator.asia": "rapidgator", + "rg.to": "rapidgator" +}); + +export function normalizeHosterHostname(hostname: string): string { + const normalized = hostname.trim().toLowerCase().replace(/^www\./, "").replace(/\.$/, ""); + for (const [domain, hoster] of Object.entries(DOMAIN_ALIASES)) { + if (normalized === domain || normalized.endsWith(`.${domain}`)) { + return hoster; + } + } + const parts = normalized.split(".").filter(Boolean); + return parts.length >= 2 ? parts[parts.length - 2] : normalized; +} + +export function extractHosterFromUrl(url: string): string { + try { + return normalizeHosterHostname(new URL(url).hostname); + } catch { + return ""; + } +} diff --git a/src/shared/mega-debrid-accounts.ts b/src/shared/mega-debrid-accounts.ts index f682da0..f295f49 100644 --- a/src/shared/mega-debrid-accounts.ts +++ b/src/shared/mega-debrid-accounts.ts @@ -1,11 +1,26 @@ -export interface MegaDebridAccountEntry { +export interface MegaDebridAccountEntry { id: string; login: string; password: string; index: number; label: string; maskedLogin: string; -} +} + +export type MegaDebridAccountMode = "api" | "web"; + +type MegaDebridModeSettings = { + megaCredentials?: string; + megaPassword?: string; + megaDebridApiCredentials?: string; + megaDebridWebCredentials?: string; + megaDebridApiEnabled?: boolean; + megaDebridWebEnabled?: boolean; + megaDebridPreferApi?: boolean; + megaDebridDisabledAccountIds?: string[]; + megaDebridApiDisabledAccountIds?: string[]; + megaDebridWebDisabledAccountIds?: string[]; +}; const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n; const FNV64_PRIME = 0x100000001b3n; @@ -85,6 +100,52 @@ export function serializeMegaDebridAccounts(accounts: { login: string; password: .join("\n"); } -export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] { - return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id); -} +export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] { + return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id); +} + +export function getMegaDebridCredentialsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): string { + const dedicated = mode === "api" ? settings.megaDebridApiCredentials : settings.megaDebridWebCredentials; + const otherDedicated = mode === "api" ? settings.megaDebridWebCredentials : settings.megaDebridApiCredentials; + if (String(dedicated || "").trim() || String(otherDedicated || "").trim()) { + return String(dedicated || "").trim(); + } + const legacy = String(settings.megaCredentials || "").trim(); + if (!legacy) { + return ""; + } + const apiEnabled = Boolean(settings.megaDebridApiEnabled); + const webEnabled = Boolean(settings.megaDebridWebEnabled); + const preferredMode: MegaDebridAccountMode = settings.megaDebridPreferApi === false ? "web" : "api"; + if (apiEnabled !== webEnabled) { + return mode === (apiEnabled ? "api" : "web") ? legacy : ""; + } + return mode === preferredMode ? legacy : ""; +} + +export function getMegaDebridAccountsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] { + return parseMegaDebridAccounts(getMegaDebridCredentialsForMode(settings, mode), settings.megaPassword || ""); +} + +export function getMegaDebridDisabledAccountIdsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): string[] { + const dedicated = mode === "api" ? settings.megaDebridApiDisabledAccountIds : settings.megaDebridWebDisabledAccountIds; + const hasDedicatedCredentials = Boolean( + String(settings.megaDebridApiCredentials || "").trim() + || String(settings.megaDebridWebCredentials || "").trim() + ); + if ((hasDedicatedCredentials || !getMegaDebridCredentialsForMode(settings, mode)) && Array.isArray(dedicated)) { + return [...dedicated]; + } + return Array.isArray(settings.megaDebridDisabledAccountIds) ? [...settings.megaDebridDisabledAccountIds] : []; +} + +export function mergeMegaDebridCredentialPools(apiCredentials: string, webCredentials: string): string { + const merged = new Map(); + for (const entry of [...parseMegaDebridAccounts(apiCredentials), ...parseMegaDebridAccounts(webCredentials)]) { + const key = entry.login.trim().toLowerCase(); + if (!merged.has(key)) { + merged.set(key, { login: entry.login, password: entry.password }); + } + } + return serializeMegaDebridAccounts([...merged.values()]); +} diff --git a/src/shared/provider-daily-limits.ts b/src/shared/provider-daily-limits.ts index ab168f8..7baeb13 100644 --- a/src/shared/provider-daily-limits.ts +++ b/src/shared/provider-daily-limits.ts @@ -1,4 +1,5 @@ -import type { AppSettings, DebridProvider } from "./types"; +import type { AppSettings, DebridProvider } from "./types"; +import { getMegaDebridDisabledAccountIdsForMode, type MegaDebridAccountMode } from "./mega-debrid-accounts"; export type ProviderByteMap = Partial>; export type DebridLinkKeyByteMap = Record; @@ -6,7 +7,7 @@ export type DebridLinkKeyByteMap = Record; type ProviderDailySettings = Pick & Partial> - & Partial>; + & Partial>; type ProviderUsageSettings = ProviderDailySettings @@ -250,9 +251,12 @@ export function addDebridLinkApiKeyTotalUsageBytes( }; } -export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean { - return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId); -} +export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string, mode?: MegaDebridAccountMode): boolean { + if (mode) { + return getMegaDebridDisabledAccountIdsForMode(settings, mode).includes(accountId); + } + return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId); +} export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number { return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]); diff --git a/src/shared/types.ts b/src/shared/types.ts index 87d03fd..2b1c355 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -71,10 +71,12 @@ export interface AppSettings { language: AppLanguage; token: string; realDebridUseWebLogin: boolean; - megaLogin: string; - megaPassword: string; - megaCredentials: string; - megaDebridApiEnabled: boolean; + megaLogin: string; + megaPassword: string; + megaCredentials: string; + megaDebridApiCredentials: string; + megaDebridWebCredentials: string; + megaDebridApiEnabled: boolean; megaDebridWebEnabled: boolean; megaDebridPreferApi: boolean; bestToken: string; @@ -158,7 +160,9 @@ export interface AppSettings { debridLinkApiKeyDailyLimitBytes: Record; debridLinkApiKeyDailyUsageBytes: Record; debridLinkApiKeyTotalUsageBytes: Record; - megaDebridDisabledAccountIds: string[]; + megaDebridDisabledAccountIds: string[]; + megaDebridApiDisabledAccountIds: string[]; + megaDebridWebDisabledAccountIds: string[]; megaDebridAccountDailyLimitBytes: Record; megaDebridAccountDailyUsageBytes: Record; megaDebridAccountTotalUsageBytes: Record; diff --git a/tests/account-dialog-prefer-api.test.ts b/tests/account-dialog-prefer-api.test.ts index 7b6610b..b00b946 100644 --- a/tests/account-dialog-prefer-api.test.ts +++ b/tests/account-dialog-prefer-api.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; -import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App"; -import { defaultSettings } from "../src/main/constants"; +import { applyAccountDialogToSettings, createAccountDialogState, AccountDialogState } from "../src/renderer/App"; +import { defaultSettings } from "../src/main/constants"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { isMegaDebridAccountDisabled } from "../src/shared/provider-daily-limits"; function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState { return { @@ -27,10 +29,53 @@ describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choic expect(next.megaDebridPreferApi).toBe(false); }); - it("does not flip megaDebridPreferApi to false when editing the Web account", () => { + it("does not flip megaDebridPreferApi to false when editing the Web account", () => { const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true }; const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web")); expect(next.megaDebridWebEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(true); - }); -}); + expect(next.megaDebridPreferApi).toBe(true); + }); + + it("adds a Web account without copying it into the API account pool", () => { + const settings = { + ...defaultSettings(), + megaCredentials: "api@example.test:api-pass", + megaDebridApiCredentials: "api@example.test:api-pass", + megaDebridWebCredentials: "", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false + }; + const dialog = createAccountDialogState("create", "megadebrid-web", settings); + + expect(dialog.megaAccounts).toEqual([]); + + const next = applyAccountDialogToSettings(settings, { + ...dialog, + megaAccounts: [{ login: "web@example.test", password: "web-pass" }] + }); + + expect(next.megaDebridApiCredentials).toBe("api@example.test:api-pass"); + expect(next.megaDebridWebCredentials).toBe("web@example.test:web-pass"); + expect(next.megaDebridApiEnabled).toBe(true); + expect(next.megaDebridWebEnabled).toBe(true); + }); + + it("disables an API account without disabling the matching Web account", () => { + const accountId = getMegaDebridAccountId("shared@example.test"); + const settings = { + ...defaultSettings(), + megaCredentials: "shared@example.test:api-pass", + megaDebridApiCredentials: "shared@example.test:api-pass", + megaDebridWebCredentials: "shared@example.test:web-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true + }; + const next = applyAccountDialogToSettings(settings, { + ...createAccountDialogState("edit", "megadebrid-api", settings), + megaDisabledIds: [accountId] + }); + + expect(isMegaDebridAccountDisabled(next, accountId, "api")).toBe(true); + expect(isMegaDebridAccountDisabled(next, accountId, "web")).toBe(false); + }); +}); diff --git a/tests/account-edit.test.ts b/tests/account-edit.test.ts index bd081b4..c5bb1d1 100644 --- a/tests/account-edit.test.ts +++ b/tests/account-edit.test.ts @@ -1,298 +1,305 @@ -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"); +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 webId = getMegaDebridAccountId("web@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 } - }); - }); -}); + megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass\nweb@example.com:web-pass", + megaDebridApiCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass", + megaDebridWebCredentials: "web@example.com:web-pass", + megaLogin: "first@example.com", + megaPassword: "first-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridPreferApi: false, + megaDebridDisabledAccountIds: [oldId, firstId, webId], + megaDebridApiDisabledAccountIds: [oldId, firstId], + megaDebridWebDisabledAccountIds: [webId], + 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\nweb@example.com:web-pass"); + expect(next.megaDebridApiCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass"); + expect(next.megaDebridWebCredentials).toBe("web@example.com:web-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, webId]); + 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 } + }); + }); +}); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index abb34c4..48a10e6 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -36,10 +36,10 @@ describe("debrid service", () => { const settings = { ...defaultSettings(), token: "rd-token", - megaLogin: "user", - megaPassword: "pass", - megaCredentials: "user:pass", - bestToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + bestToken: "", providerOrder: [] as const, providerPrimary: "realdebrid" as const, providerSecondary: "megadebrid" as const, @@ -1621,16 +1621,18 @@ describe("debrid service", () => { expect(megaGetLinkCalled).toBe(false); }); - it("uses Mega Web only when it is configured as a separate fallback provider", async () => { + it("uses Mega Web only when it is configured as a separate fallback provider", async () => { const settings = { ...defaultSettings(), token: "", bestToken: "", allDebridToken: "", - megaLogin: "user", - megaPassword: "pass", - megaCredentials: "user:pass", - megaDebridApiEnabled: true, + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + megaDebridApiCredentials: "user:pass", + megaDebridWebCredentials: "user:pass", + megaDebridApiEnabled: true, megaDebridWebEnabled: true, providerOrder: [] as const, providerPrimary: "megadebrid-api" as const, diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 96a896e..17b4039 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -430,7 +430,7 @@ describe("debug-server", () => { const fixture = await createFixture(); const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { headers: { - "X-Forwarded-For": "159.195.63.46" + "X-Forwarded-For": "203.0.113.46" } }); expect(response.ok).toBe(true); @@ -439,7 +439,7 @@ describe("debug-server", () => { const traceLogPath = getTraceLogPath(); expect(traceLogPath).toBeTruthy(); const traceText = fs.readFileSync(traceLogPath!, "utf8"); - expect(traceText).toContain("clientIp=159.195.63.46"); + expect(traceText).toContain("clientIp=203.0.113.46"); }); it("serves package details and package log by package query", async () => { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1636b4a..94c6b38 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -252,6 +252,187 @@ describe("download manager", () => { expect(failures.has("realdebrid")).toBe(true); }); + it("invalidates only the Mega-Debrid Web session when Web credentials change", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + megaCredentials: "api-user:api-pass\nweb-user:web-pass", + megaDebridApiCredentials: "api-user:api-pass", + megaDebridWebCredentials: "web-user:web-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true + }; + const invalidateMegaSession = vi.fn(); + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession }); + + manager.setSettings({ ...settings, megaDebridApiCredentials: "api-user:new-api-pass" }); + expect(invalidateMegaSession).not.toHaveBeenCalled(); + + manager.setSettings({ ...settings, megaDebridApiCredentials: "api-user:new-api-pass", megaDebridWebCredentials: "web-user:new-web-pass" }); + expect(invalidateMegaSession).toHaveBeenCalledTimes(1); + }); + + it("releases only Mega-Debrid reset parks when a newly usable account appears", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + const previousSettings = { + ...defaultSettings(), + megaCredentials: "old@example.test:old-secret", + megaDebridApiEnabled: true + }; + const session = emptySession(); + const megaPackageId = "mega-refresh-package"; + const otherPackageId = "other-refresh-package"; + const megaItemId = "mega-refresh-item"; + const otherItemId = "other-refresh-item"; + const createdAt = Date.now(); + session.packageOrder = [megaPackageId, otherPackageId]; + session.packages[megaPackageId] = { + id: megaPackageId, + name: "Mega refresh", + status: "downloading", + itemIds: [megaItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + } as any; + session.packages[otherPackageId] = { + id: otherPackageId, + name: "Other refresh", + status: "queued", + itemIds: [otherItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + } as any; + session.items[megaItemId] = { + id: megaItemId, + packageId: megaPackageId, + url: "https://rapidgator.net/file/mega-refresh", + provider: "megadebrid-api", + status: "queued", + retries: 1, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "mega-refresh.rar", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "limit", + fullStatus: "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s", + createdAt, + updatedAt: createdAt + } as any; + session.items[otherItemId] = { + ...session.items[megaItemId], + id: otherItemId, + packageId: otherPackageId, + url: "https://ddownload.com/file/other-refresh", + provider: "realdebrid", + fileName: "other-refresh.rar", + fullStatus: "Netzwerk-Retry in 60s" + } as any; + session.running = true; + const manager = new DownloadManager(previousSettings, session, storagePaths); + session.running = true; + session.packages[megaPackageId].status = "downloading"; + session.items[megaItemId].status = "queued"; + session.items[megaItemId].fullStatus = "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s"; + session.items[megaItemId].provider = "megadebrid-api"; + session.items[otherItemId].status = "queued"; + session.items[otherItemId].fullStatus = "Netzwerk-Retry in 60s"; + session.items[otherItemId].provider = "realdebrid"; + const retryAfter = (manager as any).retryAfterByItem as Map; + const retryState = (manager as any).retryStateByItem as Map; + retryAfter.set(megaItemId, Date.now() + 3_600_000); + retryAfter.set(otherItemId, Date.now() + 60_000); + retryState.set(megaItemId, { unrestrictRetries: 1 }); + retryState.set(otherItemId, { genericErrorRetries: 1 }); + const scheduler = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined); + vi.spyOn(manager as any, "cleanupExistingExtractedArchives").mockResolvedValue(0); + + manager.setSettings({ + ...previousSettings, + megaCredentials: "old@example.test:old-secret\nnew@example.test:new-secret", + megaDebridDisabledAccountIds: [getMegaDebridAccountId("old@example.test")] + }); + + expect(retryAfter.has(megaItemId)).toBe(false); + expect(retryState.has(megaItemId)).toBe(false); + expect(session.items[megaItemId].fullStatus).toBe("Wartet"); + expect(session.packages[megaPackageId].status).toBe("queued"); + expect(retryAfter.has(otherItemId)).toBe(true); + expect(retryState.has(otherItemId)).toBe(true); + expect(session.items[otherItemId].fullStatus).toBe("Netzwerk-Retry in 60s"); + expect(scheduler).toHaveBeenCalledTimes(1); + }); + + it("updates the package status atomically when an active item is queued for retry", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-package-status-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + const session = emptySession(); + const packageId = "retry-status-package"; + const itemId = "retry-status-item"; + const createdAt = Date.now(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Retry status", + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + } as any; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://rapidgator.net/file/retry-status", + provider: "megadebrid-api", + status: "downloading", + retries: 1, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "retry-status.rar", + targetPath: "", + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Download läuft", + createdAt, + updatedAt: createdAt + } as any; + const manager = new DownloadManager(defaultSettings(), session, storagePaths); + session.packages[packageId].status = "downloading"; + const active = { + itemId, + packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0 + }; + + (manager as any).queueRetry(session.items[itemId], active, 60_000, "Mega-Debrid bis Tagesreset gesperrt, Pause 60s"); + + expect(session.items[itemId].status).toBe("queued"); + expect(session.packages[packageId].status).toBe("queued"); + }); + it("records history duration from the first actual package start", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 00c51e1..bf7fcc4 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -36,7 +36,12 @@ import { getPackageProgress, getPackageSizeProgress } from "../src/renderer/views/downloads/DownloadsTable"; -import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format"; +import { + compactDownloadServiceLabel, + extractHoster, + formatHosterLabel, + normalizeDownloadServiceLabel +} from "../src/renderer/download-format"; import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue"; const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); @@ -71,6 +76,13 @@ describe("Downloadtabellen-Spalten", () => { expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s); expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s); }); + + it("changes package collapse state only through user actions", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/App.tsx"), "utf8"); + + expect(source).not.toContain("autoExpandedPkgsRef"); + expect(source).not.toMatch(/isExtracting[\s\S]{0,800}setCollapsedPackages/); + }); }); describe("rollende Downloadkennzahlen", () => { @@ -175,12 +187,26 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => { expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%"); expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%"); expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%"); + expect(compactDownloadStatus("Passwort knacken: 75% (3/4) · sau-geheim.part1.rar")).toBe("Passwort knacken: 75% (3/4)"); expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden"); expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend"); expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler"); expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error"); }); + it("normalizes every supported RapidGator domain to one hoster identity", () => { + const hosters = [ + extractHoster("https://rapidgator.net/file/one"), + extractHoster("https://rg.to/file/two"), + extractHoster("https://cdn.rg.to/file/three"), + extractHoster("https://rapidgator.asia/file/four") + ]; + + expect(hosters).toEqual(["rapidgator", "rapidgator", "rapidgator", "rapidgator"]); + expect(new Set(hosters).size).toBe(1); + expect(formatHosterLabel(hosters[1])).toEqual(expect.objectContaining({ compact: "RG", title: "RapidGator", iconSrc: expect.any(String) })); + }); + it("removes duplicated access-mode wording from service labels", () => { expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)"); expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)"); @@ -690,7 +716,7 @@ describe("downloads view", () => { expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s); expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s); expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s); - expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s); + expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s); expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s); expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s); expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s); diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts index 7a852e4..6ac568c 100644 --- a/tests/mega-web-fallback.test.ts +++ b/tests/mega-web-fallback.test.ts @@ -10,13 +10,95 @@ describe("mega-web-fallback", () => { }); describe("MegaWebFallback class", () => { - it("returns null when credentials are empty", async () => { - const fallback = new MegaWebFallback(() => ({ login: "", password: "" })); - const result = await fallback.unrestrict("https://mega.debrid/test"); - expect(result).toBeNull(); - }); - - it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => { + it("returns null when credentials are empty", async () => { + const fallback = new MegaWebFallback(() => ({ login: "", password: "" })); + const result = await fallback.unrestrict("https://mega.debrid/test"); + expect(result).toBeNull(); + }); + + it("does not restore an invalidated session when an older login finishes later", async () => { + let finishLogin: (cookie: string) => void = () => {}; + const loginResult = new Promise((resolve) => { + finishLogin = resolve; + }); + const fallback = new MegaWebFallback(() => ({ login: "old-user", password: "old-pass" })); + const internals = fallback as unknown as { + ensureSession: (key: string, login: string, password: string) => Promise; + login: (login: string, password: string) => Promise; + sessions: Map; + }; + vi.spyOn(internals, "login").mockReturnValue(loginResult); + + const pending = internals.ensureSession("old-user", "old-user", "old-pass"); + await Promise.resolve(); + fallback.invalidateSession(); + finishLogin("stale-old-cookie"); + + await expect(pending).resolves.toBe("stale-old-cookie"); + expect(internals.sessions.size).toBe(0); + }); + + it("does not cache an old session when an invalidated request retries login", async () => { + const fallback = new MegaWebFallback(() => ({ login: "old-user", password: "old-pass" })); + const internals = fallback as unknown as { + login: (login: string, password: string) => Promise; + generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>; + sessions: Map; + }; + vi.spyOn(internals, "login") + .mockResolvedValueOnce("first-stale-cookie") + .mockResolvedValueOnce("second-stale-cookie"); + vi.spyOn(internals, "generate") + .mockImplementationOnce(async () => { + fallback.invalidateSession(); + return null; + }) + .mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" }); + + const result = await fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" }); + + expect(result?.directUrl).toBe("https://mega.direct/retry"); + expect(internals.sessions.size).toBe(0); + }); + + it("does not cache old credentials from a queued request after invalidation", async () => { + let releaseFirstLogin: () => void = () => {}; + let markFirstLoginStarted: () => void = () => {}; + const firstLoginGate = new Promise((resolve) => { + releaseFirstLogin = resolve; + }); + const firstLoginStarted = new Promise((resolve) => { + markFirstLoginStarted = resolve; + }); + const fallback = new MegaWebFallback(() => ({ login: "old-user", password: "old-pass" })); + const internals = fallback as unknown as { + login: (login: string, password: string) => Promise; + generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string }>; + sessions: Map; + }; + let loginCount = 0; + vi.spyOn(internals, "login").mockImplementation(async () => { + loginCount += 1; + if (loginCount === 1) { + markFirstLoginStarted(); + await firstLoginGate; + } + return `old-cookie-${loginCount}`; + }); + vi.spyOn(internals, "generate").mockResolvedValue({ directUrl: "https://mega.direct/queued", fileName: "queued.bin" }); + + const first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" }); + await firstLoginStarted; + const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" }); + fallback.invalidateSession(); + releaseFirstLogin(); + + await expect(Promise.all([first, queued])).resolves.toHaveLength(2); + expect(loginCount).toBe(2); + expect(internals.sessions.size).toBe(0); + }); + + it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => { let fetchCallCount = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); diff --git a/tests/package-order.test.ts b/tests/package-order.test.ts index b9200af..703f452 100644 --- a/tests/package-order.test.ts +++ b/tests/package-order.test.ts @@ -1,8 +1,8 @@ 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 { +import { sortPackagesForDisplay } from "../src/renderer/package-order"; + +function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry { const now = Date.now(); return { id, @@ -15,7 +15,8 @@ function createPackage(id: string, itemIds: string[]): PackageEntry { enabled: true, priority: "normal", createdAt: now, - updatedAt: now + updatedAt: now, + downloadStartedAt }; } @@ -90,7 +91,7 @@ describe("sortPackagesForDisplay", () => { expect(orderAfter).toEqual(orderBefore); }); - it("keeps package order untouched when auto sort is disabled", () => { + it("keeps package order untouched when auto sort is disabled", () => { const packages = [ createPackage("pkg-a", ["a1"]), createPackage("pkg-b", ["b1"]), @@ -104,6 +105,35 @@ describe("sortPackagesForDisplay", () => { const sorted = sortPackagesForDisplay(packages, items, true, false); - expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); - }); -}); + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); + }); + + it("keeps every active package in activation order when a new package starts", () => { + const packages = [ + createPackage("pkg-new", ["new-item"], 200), + createPackage("pkg-existing", ["existing-item"], 100) + ]; + const items: Record = { + "new-item": createItem("new-item", "pkg-new", "downloading", 100), + "existing-item": createItem("existing-item", "pkg-existing", "downloading", 200) + }; + const sorted = sortPackagesForDisplay(packages, items, true, true); + + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-existing", "pkg-new"]); + }); + + it("keeps queue order for active packages without a recorded start time", () => { + const packages = [ + createPackage("pkg-a", ["a1"]), + createPackage("pkg-b", ["b1"]), + createPackage("pkg-c", ["c1"]) + ]; + const items: Record = { + a1: createItem("a1", "pkg-a", "completed", 500), + b1: createItem("b1", "pkg-b", "downloading", 200), + c1: createItem("c1", "pkg-c", "downloading", 100) + }; + + expect(sortPackagesForDisplay(packages, items, true, true).map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-c", "pkg-a"]); + }); +}); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index ad97008..7f71ae7 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -3,6 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { defaultSettings } from "../src/main/constants"; +import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App"; import { applyAccountEdit, createAccountEditState, @@ -905,6 +906,24 @@ describe("account workspace", () => { }); describe("settings App integration", () => { + it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => { + const settings = { + ...defaultSettings(), + megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret", + debridLinkApiKeys: "existing-debrid-link-key" + }; + const megaDialog = createAccountDialogState("create", "megadebrid-api", settings); + const megaFields = buildAccountAddFields(megaDialog); + const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", settings)); + + expect(megaDialog.megaNewLogin).toBe(""); + expect(megaDialog.megaNewPassword).toBe(""); + expect(megaDialog.token).toBe(""); + expect(megaFields.map((field) => field.id)).toEqual(["megaNewLogin", "megaNewPassword", "dailyLimitGb"]); + expect(megaFields.map((field) => field.label)).not.toContain("Token / API-Key"); + expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" })); + }); + it("keeps specific persistence revision-safe when the draft changes in flight", () => { const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction"); expect(block).toContain("revisionAtStart"); diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 66c56f0..3f5c100 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -438,7 +438,7 @@ describe("bandwidth chart palette", () => { const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); expect(theme).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--ui-focus\);/s); - expect(shell).toContain("color: var(--ui-primary-text);"); + expect(shell).toContain("color: var(--ui-update-text);"); expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index e2dd859..9a17085 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; @@ -44,11 +45,32 @@ describe("settings storage", () => { expect(normalizeSettings({ ...normalized, columnOrder: ["name", "speed"] }).columnOrder).toEqual(["name", "speed"]); }); - it("uses English for new installations and preserves only supported languages", () => { + it("uses English for new installations and preserves only supported languages", () => { expect(defaultSettings().language).toBe("en"); expect(normalizeSettings({ ...defaultSettings(), language: "de" }).language).toBe("de"); - expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en"); - }); + expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en"); + }); + + it("migrates a legacy shared Mega-Debrid pool into only the preferred mode", () => { + const legacy = { ...defaultSettings() } as Partial; + legacy.megaCredentials = "legacy@example.test:legacy-pass"; + legacy.megaLogin = "legacy@example.test"; + legacy.megaPassword = "legacy-pass"; + legacy.megaDebridApiEnabled = true; + legacy.megaDebridWebEnabled = true; + legacy.megaDebridPreferApi = true; + delete legacy.megaDebridApiCredentials; + delete legacy.megaDebridWebCredentials; + delete legacy.megaDebridApiDisabledAccountIds; + delete legacy.megaDebridWebDisabledAccountIds; + + const normalized = normalizeSettings(legacy as AppSettings); + + expect(normalized.megaDebridApiCredentials).toBe("legacy@example.test:legacy-pass"); + expect(normalized.megaDebridWebCredentials).toBe(""); + expect(normalized.megaDebridApiEnabled).toBe(true); + expect(normalized.megaDebridWebEnabled).toBe(false); + }); it("keeps German for existing settings files created before language selection existed", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); @@ -109,17 +131,23 @@ describe("settings storage", () => { saveSettings(paths, { ...defaultSettings(), rememberToken: false, - token: "rd-token", - megaLogin: "mega-user", - megaPassword: "mega-pass", - bestToken: "best-token", + token: "rd-token", + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaCredentials: "mega-user:mega-pass", + megaDebridApiCredentials: "mega-user:mega-pass", + megaDebridWebCredentials: "web-user:web-pass", + bestToken: "best-token", allDebridToken: "all-token" }); const raw = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record; expect(raw.token).toBe(""); expect(raw.megaLogin).toBe(""); - expect(raw.megaPassword).toBe(""); + expect(raw.megaPassword).toBe(""); + expect(raw.megaCredentials).toBe(""); + expect(raw.megaDebridApiCredentials).toBe(""); + expect(raw.megaDebridWebCredentials).toBe(""); expect(raw.bestToken).toBe(""); expect(raw.allDebridToken).toBe(""); @@ -127,7 +155,10 @@ describe("settings storage", () => { expect(loaded.rememberToken).toBe(false); expect(loaded.token).toBe(""); expect(loaded.megaLogin).toBe(""); - expect(loaded.megaPassword).toBe(""); + expect(loaded.megaPassword).toBe(""); + expect(loaded.megaCredentials).toBe(""); + expect(loaded.megaDebridApiCredentials).toBe(""); + expect(loaded.megaDebridWebCredentials).toBe(""); expect(loaded.bestToken).toBe(""); expect(loaded.allDebridToken).toBe(""); }); @@ -275,7 +306,7 @@ describe("settings storage", () => { expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web"); }); - it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => { + it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); const paths = createStoragePaths(dir); @@ -304,10 +335,72 @@ describe("settings storage", () => { fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8"); const loadedWeb = loadSettings(paths2); expect(loadedWeb.megaDebridApiEnabled).toBe(false); - expect(loadedWeb.megaDebridWebEnabled).toBe(true); - }); - - it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => { + expect(loadedWeb.megaDebridWebEnabled).toBe(true); + }); + + it("migrates an explicitly Web-only legacy account even when API remains preferred", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + fs.writeFileSync(paths.configFile, JSON.stringify({ + rememberToken: true, + megaCredentials: "web-user:web-pass", + megaDebridPreferApi: true, + megaDebridApiEnabled: false, + megaDebridWebEnabled: true + }), "utf8"); + + const loaded = loadSettings(paths); + + expect(loaded.megaDebridApiCredentials).toBe(""); + expect(loaded.megaDebridWebCredentials).toBe("web-user:web-pass"); + expect(loaded.megaDebridApiEnabled).toBe(false); + expect(loaded.megaDebridWebEnabled).toBe(true); + }); + + it("migrates legacy disabled Mega-Debrid accounts into the selected mode", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const accountId = getMegaDebridAccountId("disabled-user"); + fs.writeFileSync(paths.configFile, JSON.stringify({ + rememberToken: true, + megaCredentials: "disabled-user:disabled-pass", + megaDebridPreferApi: true, + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridDisabledAccountIds: [accountId] + }), "utf8"); + + const loaded = loadSettings(paths); + + expect(loaded.megaDebridApiDisabledAccountIds).toEqual([accountId]); + expect(loaded.megaDebridWebDisabledAccountIds).toEqual([]); + }); + + it("preserves explicit mode-specific disabled Mega-Debrid accounts", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const accountId = getMegaDebridAccountId("shared-user"); + fs.writeFileSync(paths.configFile, JSON.stringify({ + rememberToken: true, + megaDebridApiCredentials: "shared-user:api-pass", + megaDebridWebCredentials: "shared-user:web-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridDisabledAccountIds: [accountId], + megaDebridApiDisabledAccountIds: [], + megaDebridWebDisabledAccountIds: [accountId] + }), "utf8"); + + const loaded = loadSettings(paths); + + expect(loaded.megaDebridApiDisabledAccountIds).toEqual([]); + expect(loaded.megaDebridWebDisabledAccountIds).toEqual([accountId]); + }); + + it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); const paths = createStoragePaths(dir); diff --git a/tests/update-experience.test.tsx b/tests/update-experience.test.tsx index e68c7f5..1af1c80 100644 --- a/tests/update-experience.test.tsx +++ b/tests/update-experience.test.tsx @@ -1,144 +1,144 @@ -import { readFileSync } from "node:fs"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App"; -import type { UpdateCheckResult } from "../src/shared/types"; -import { AppHeader } from "../src/renderer/shell/AppHeader"; -import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience"; - -const callbacks = { - onOpen: () => {}, - onClose: () => {}, - onInstall: () => {}, - onLater: () => {} -}; - -describe("update experience", () => { - it("renders the available update and prompt as one accessible experience", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("aria-label=\"Update verfügbar\""); - expect(html).toContain("role=\"tooltip\""); - expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren."); - expect(html).toContain("role=\"dialog\""); - expect(html).toContain("aria-modal=\"true\""); - expect(html).toContain("Update installieren"); - expect(html).toContain("Jetzt aktualisieren"); - expect(html).toContain("Später"); - expect(html).toContain("Changes"); - expect(html).toContain(" { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("aria-label=\"Update verfügbar\""); - expect(html).not.toContain("role=\"dialog\""); - }); - - it("renders active progress without controls that could close the installation", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)"); - expect(html).toContain("aria-valuenow=\"47\""); - expect(html).not.toContain("Später"); - expect(html).not.toContain("Jetzt aktualisieren"); - expect(html).not.toContain("aria-label=\"Schließen\""); - }); - - it("preserves the original installation error in the reusable dialog", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung"); - expect(html).toContain("aria-label=\"Schließen\""); - }); - - it("renders nothing when no update is available and no dialog is active", () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toBe(""); - }); - - it("places the update affordance in the accessible global header action group", () => { - const html = renderToStaticMarkup( - - )} - onViewChange={() => {}} - /> - ); - - expect(html).toContain("role=\"group\""); - expect(html).toContain("aria-label=\"Globale Aktionen\""); - expect(html).toContain("aria-label=\"Update verfügbar\""); - }); - +import { readFileSync } from "node:fs"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App"; +import type { UpdateCheckResult } from "../src/shared/types"; +import { AppHeader } from "../src/renderer/shell/AppHeader"; +import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience"; + +const callbacks = { + onOpen: () => {}, + onClose: () => {}, + onInstall: () => {}, + onLater: () => {} +}; + +describe("update experience", () => { + it("renders the available update and prompt as one accessible experience", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("aria-label=\"Update verfügbar\""); + expect(html).toContain("role=\"tooltip\""); + expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren."); + expect(html).toContain("role=\"dialog\""); + expect(html).toContain("aria-modal=\"true\""); + expect(html).toContain("Update installieren"); + expect(html).toContain("Jetzt aktualisieren"); + expect(html).toContain("Später"); + expect(html).toContain("Changes"); + expect(html).toContain(" { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("aria-label=\"Update verfügbar\""); + expect(html).not.toContain("role=\"dialog\""); + }); + + it("renders active progress without controls that could close the installation", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)"); + expect(html).toContain("aria-valuenow=\"47\""); + expect(html).not.toContain("Später"); + expect(html).not.toContain("Jetzt aktualisieren"); + expect(html).not.toContain("aria-label=\"Schließen\""); + }); + + it("preserves the original installation error in the reusable dialog", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung"); + expect(html).toContain("aria-label=\"Schließen\""); + }); + + it("renders nothing when no update is available and no dialog is active", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toBe(""); + }); + + it("places the update affordance in the accessible global header action group", () => { + const html = renderToStaticMarkup( + + )} + onViewChange={() => {}} + /> + ); + + expect(html).toContain("role=\"group\""); + expect(html).toContain("aria-label=\"Globale Aktionen\""); + expect(html).toContain("aria-label=\"Update verfügbar\""); + }); + it("uses the specified transient and modal elevation tokens", () => { const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8"); @@ -146,37 +146,49 @@ describe("update experience", () => { expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s); }); - it("keeps forward and reverse tabbing inside the update dialog", () => { - expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0); - expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3); - expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0); - expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3); - expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull(); - expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull(); + it("uses a light-blue update affordance and a bounded scrollable changelog", () => { + const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8"); + const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8"); + + expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/); + expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/); + expect(theme).toMatch(/--ui-update-text:\s*#181A1F;/); + expect(css).toMatch(/\.md-update-trigger\s*\{[^}]*background:\s*var\(--ui-update\);[^}]*color:\s*var\(--ui-update-text\);/s); + expect(css).toMatch(/\.md-update-trigger:hover\s*\{[^}]*background:\s*var\(--ui-update-hover\);/s); + expect(css).toMatch(/\.md-update-release-notes pre\s*\{[^}]*max-height:\s*min\(360px, 45vh\);[^}]*overflow-y:\s*auto;/s); }); - - it("rejects stale update-check completions without discarding the latest state", () => { - expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true); - expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false); - expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false); - }); - - it("applies only the latest result when update checks complete out of order", async () => { - const generation = { current: 0 }; - const applied: string[] = []; - let finishStartup: ((result: UpdateCheckResult) => void) | undefined; - let finishManual: ((result: UpdateCheckResult) => void) | undefined; - const startup = new Promise((resolve) => { finishStartup = resolve; }); - const manual = new Promise((resolve) => { finishManual = resolve; }); - const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); }; - - const startupRun = runLatestUpdateCheck(generation, () => startup, apply); - const manualRun = runLatestUpdateCheck(generation, () => manual, apply); - finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" }); - await manualRun; - finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" }); - await startupRun; - - expect(applied).toEqual(["v9.9.9"]); - }); -}); + + it("keeps forward and reverse tabbing inside the update dialog", () => { + expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0); + expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3); + expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0); + expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3); + expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull(); + expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull(); + }); + + it("rejects stale update-check completions without discarding the latest state", () => { + expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true); + expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false); + expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false); + }); + + it("applies only the latest result when update checks complete out of order", async () => { + const generation = { current: 0 }; + const applied: string[] = []; + let finishStartup: ((result: UpdateCheckResult) => void) | undefined; + let finishManual: ((result: UpdateCheckResult) => void) | undefined; + const startup = new Promise((resolve) => { finishStartup = resolve; }); + const manual = new Promise((resolve) => { finishManual = resolve; }); + const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); }; + + const startupRun = runLatestUpdateCheck(generation, () => startup, apply); + const manualRun = runLatestUpdateCheck(generation, () => manual, apply); + finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" }); + await manualRun; + finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" }); + await startupRun; + + expect(applied).toEqual(["v9.9.9"]); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index 455fdb7..aa662b3 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -79,7 +79,7 @@ describe("update", () => { expect(result.updateAvailable).toBe(false); }); - it("picks setup executable asset from release list", async () => { + it("picks setup executable asset from release list", async () => { globalThis.fetch = (async (): Promise => new Response( JSON.stringify({ tag_name: "v9.9.9", @@ -106,7 +106,53 @@ describe("update", () => { expect(result.updateAvailable).toBe(true); expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe"); - }); + }); + + it("combines every stable release note newer than the installed version", async () => { + const [major = 2, minor = 0, patch = 0] = parseVersionParts(APP_VERSION); + const version = (offset: number): string => `${major}.${minor}.${patch + offset}`; + const requestedUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + requestedUrls.push(url); + if (url.endsWith("/releases/latest")) { + return new Response(JSON.stringify({ + tag_name: `v${version(3)}`, + html_url: `https://github.com/owner/repo/releases/tag/v${version(3)}`, + body: "Latest changes", + assets: [] + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify([ + { tag_name: `v${version(3)}`, body: "Latest changes", draft: false, prerelease: false }, + { tag_name: `v${version(2)}`, body: "Middle changes", draft: false, prerelease: false }, + { tag_name: `v${version(1)}`, body: "First missed changes", draft: false, prerelease: false }, + { tag_name: `v${version(4)}`, body: "Draft changes", draft: true, prerelease: false }, + { tag_name: `v${version(5)}`, body: "Prerelease changes", draft: false, prerelease: true }, + { tag_name: `v${version(0)}`, body: "Installed changes", draft: false, prerelease: false }, + { tag_name: `v${version(-1)}`, body: "Older changes", draft: false, prerelease: false } + ]), { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + const result = await checkGitHubUpdate("owner/repo"); + + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/owner/repo/releases/latest", + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=1" + ]); + expect(result.releaseNotes).toBe([ + `v${version(3)}`, + "Latest changes", + "", + `v${version(2)}`, + "Middle changes", + "", + `v${version(1)}`, + "First missed changes" + ].join("\n")); + }); it("uses silent NSIS install flags with auto-run after update", () => { expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index f0e54ae..74f3e97 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -60,6 +60,8 @@ function createSettings(): AppSettings { megaPassword: "visual-password", language: "de", megaCredentials: `${megaLogin}:visual-password`, + megaDebridApiCredentials: `${megaLogin}:visual-password`, + megaDebridWebCredentials: "", megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true, @@ -173,7 +175,9 @@ function createSettings(): AppSettings { [debridLinkKeys[0].id]: 1099511627776, [debridLinkKeys[1].id]: 549755813888 }, - megaDebridDisabledAccountIds: [], + megaDebridDisabledAccountIds: [], + megaDebridApiDisabledAccountIds: [], + megaDebridWebDisabledAccountIds: [], megaDebridAccountDailyLimitBytes: { [megaAccountId]: 322122547200 },