release: ship v2.0.23 queue and account reliability fixes

Keep active packages in activation order, remove status-driven package expansion, refresh parked Mega-Debrid work after account-pool changes, normalize RapidGator aliases, aggregate missed update notes, isolate new account credentials, and update regression coverage and release documentation.
This commit is contained in:
Sucukdeluxe
2026-08-11 17:34:31 +02:00
parent b5b1ff88e5
commit f865a1e415
22 changed files with 852 additions and 395 deletions
+30
View File
@@ -2,6 +2,36 @@
All notable changes to Multi-Debrid Downloader are documented in this file. 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
- Applied added, edited, disabled, and re-enabled Mega-Debrid accounts to the active scheduler without requiring an application restart.
- 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.
- 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, 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 ## [2.0.22] - 2026-08-11
### History management ### History management
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.22", "version": "2.0.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.22", "version": "2.0.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.22", "version": "2.0.23",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+1 -10
View File
@@ -1,5 +1,6 @@
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { extractHosterFromUrl } from "../shared/hoster";
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
@@ -557,16 +558,6 @@ const PROVIDER_LABELS: Record<DebridProvider, string> = {
linksnappy: "LinkSnappy" 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; provider: DebridProvider;
providerLabel: string; providerLabel: string;
+53 -7
View File
@@ -22,6 +22,7 @@ import {
StartConflictResolutionResult, StartConflictResolutionResult,
UiSnapshot, DebridAccountStatus } from "../shared/types"; UiSnapshot, DebridAccountStatus } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { extractHosterFromUrl } from "../shared/hoster";
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { import {
addDebridLinkApiKeyDailyUsageBytes, addDebridLinkApiKeyDailyUsageBytes,
@@ -471,13 +472,7 @@ function isArchiveLikePath(filePath: string): boolean {
} }
function extractHosterKey(link: string): string { function extractHosterKey(link: string): string {
try { return extractHosterFromUrl(link);
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 isLargeBinaryLikePath(filePath: string): boolean { function isLargeBinaryLikePath(filePath: string): boolean {
@@ -2176,6 +2171,16 @@ export class DownloadManager extends EventEmitter {
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void { public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
const previous = this.settings; const previous = this.settings;
const previousMegaPool = getAvailableMegaDebridAccounts(previous)
.map((account) => `${account.id}:${account.password}`)
.sort()
.join("\n");
const nextMegaAccounts = getAvailableMegaDebridAccounts(next);
const nextMegaPool = nextMegaAccounts
.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.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
const now = nowMs(); const now = nowMs();
@@ -2236,6 +2241,10 @@ export class DownloadManager extends EventEmitter {
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
} }
if (!opts?.settingsOnlyImport && megaPoolChanged) {
this.releaseMegaDebridResetParks();
}
if (!opts?.settingsOnlyImport) { if (!opts?.settingsOnlyImport) {
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
@@ -2246,6 +2255,41 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
private releaseMegaDebridResetParks(): number {
const activeItemIds = new Set([...this.activeTasks.values()].map((task) => task.itemId));
const affectedPackageIds = new Set<string>();
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 { public getSettings(): AppSettings {
return this.settings; return this.settings;
} }
@@ -8795,6 +8839,8 @@ export class DownloadManager extends EventEmitter {
resumeHardResetUsed: Boolean(active.resumeHardResetUsed) 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( private scheduleHttp416Retry(
+103 -5
View File
@@ -126,7 +126,7 @@ function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSig
return AbortSignal.any([primary, secondary]); return AbortSignal.any([primary, secondary]);
} }
async function readJsonBody(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> { async function readJsonValue(response: Response, timeoutMs: number): Promise<unknown> {
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => { const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => { timer = setTimeout(() => {
@@ -136,17 +136,27 @@ async function readJsonBody(response: Response, timeoutMs: number): Promise<Reco
}); });
try { try {
const data = await Promise.race([ return await Promise.race([
response.json().catch(() => null) as Promise<unknown>, response.json().catch(() => null) as Promise<unknown>,
timeoutPromise, timeoutPromise,
]); ]);
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
return data as Record<string, unknown>;
} finally { } finally {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
} }
} }
async function readJsonBody(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> {
const data = await readJsonValue(response, timeoutMs);
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
return data as Record<string, unknown>;
}
async function readJsonArray(response: Response, timeoutMs: number): Promise<Array<Record<string, unknown>> | null> {
const data = await readJsonValue(response, timeoutMs);
if (!Array.isArray(data)) return null;
return data.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
}
async function readTextBody(response: Response, timeoutMs: number): Promise<string> { async function readTextBody(response: Response, timeoutMs: number): Promise<string> {
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => { const timeoutPromise = new Promise<never>((_resolve, reject) => {
@@ -375,6 +385,24 @@ async function fetchRelease(repo: string, endpoint: string): Promise<{
} }
} }
async function fetchReleasePage(repo: string, page: number): Promise<{
ok: boolean;
status: number;
payload: Array<Record<string, unknown>> | 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<string, unknown>): ReleaseAsset[] { function readAssets(payload: Record<string, unknown>): ReleaseAsset[] {
const raw = Array.isArray(payload.assets) ? (payload.assets as Array<Record<string, unknown>>) : []; const raw = Array.isArray(payload.assets) ? (payload.assets as Array<Record<string, unknown>>) : [];
return raw return raw
@@ -428,6 +456,71 @@ function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: stri
}; };
} }
function combineReleaseNotes(
payloads: Array<Record<string, unknown>>,
currentVersion: string,
latestVersion: string,
): string {
const seen = new Set<string>();
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<string, unknown>,
latestVersion: string,
): Promise<string> {
const fallbackBody = typeof latestPayload.body === "string" ? latestPayload.body.trim() : "";
const payloads: Array<Record<string, unknown>> = [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[] { function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const out: string[] = []; const out: string[] = [];
@@ -821,7 +914,12 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
const reason = String((payload?.message as string) || `HTTP ${status}`); const reason = String((payload?.message as string) || `HTTP ${status}`);
return { ...fallback, error: reason }; return { ...fallback, error: reason };
} }
return parseReleasePayload(payload, fallbackUrl); const result = parseReleasePayload(payload, fallbackUrl);
if (!result.updateAvailable) return result;
return {
...result,
releaseNotes: await resolveMissingReleaseNotes(safeRepo, payload, result.latestVersion),
};
} catch (error) { } catch (error) {
return { ...fallback, error: compactErrorText(error) }; return { ...fallback, error: compactErrorText(error) };
} }
+32 -74
View File
@@ -447,8 +447,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "Mega-Debrid", serviceLabel: "Mega-Debrid",
title: "Mega-Debrid API", title: "Mega-Debrid API",
modeLabel: "API", modeLabel: "API",
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.", pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account."
needsToken: true
}, },
{ {
kind: "megadebrid-web", kind: "megadebrid-web",
@@ -456,8 +455,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "Mega-Debrid", serviceLabel: "Mega-Debrid",
title: "Mega-Debrid Web-Login", title: "Mega-Debrid Web-Login",
modeLabel: "Web-Login", modeLabel: "Web-Login",
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.", pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account."
needsToken: true
}, },
{ {
kind: "bestdebrid-api", kind: "bestdebrid-api",
@@ -782,7 +780,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<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] };
if (!kind) { if (!kind) {
return { return {
@@ -815,7 +813,7 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password }));
const loadedIds = new Set(parsed.map((a) => a.id)); const loadedIds = new Set(parsed.map((a) => a.id));
const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id)); const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id));
return { mode, kind, service, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds };
} }
case "bestdebrid-api": case "bestdebrid-api":
return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
@@ -848,6 +846,33 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
} }
} }
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."
}
];
}
export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings {
if (!dialog.kind) { if (!dialog.kind) {
return settings; return settings;
@@ -1753,8 +1778,6 @@ export function App(): ReactElement {
const snapshotRef = useRef(snapshot); const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot; snapshotRef.current = snapshot;
const tabRef = useRef(tab); const tabRef = useRef(tab);
const autoExpandedPkgsRef = useRef(new Set<string>());
const manualCollapsedPkgsRef = useRef(new Set<string>());
tabRef.current = tab; tabRef.current = tab;
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -2270,11 +2293,6 @@ export function App(): ReactElement {
changed = true; 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]); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
@@ -2338,36 +2356,6 @@ export function App(): ReactElement {
void loadAllDebridHostInfo(true); void loadAllDebridHostInfo(true);
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
useEffect(() => {
const extractingPkgIds: string[] = [];
const currentlyExtracting = new Set<string>();
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(() => ( const allPackagesCollapsed = useMemo(() => (
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
), [packages, collapsedPackages]); ), [packages, collapsedPackages]);
@@ -4032,12 +4020,6 @@ export function App(): ReactElement {
const onPackageToggleCollapse = useCallback((packageId: string): void => { const onPackageToggleCollapse = useCallback((packageId: string): void => {
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => {
const nextCollapsed = !(prev[packageId] ?? false); const nextCollapsed = !(prev[packageId] ?? false);
if (nextCollapsed) {
manualCollapsedPkgsRef.current.add(packageId);
} else {
manualCollapsedPkgsRef.current.delete(packageId);
autoExpandedPkgsRef.current.delete(packageId);
}
return { ...prev, [packageId]: nextCollapsed }; return { ...prev, [packageId]: nextCollapsed };
}); });
}, []); }, []);
@@ -5014,11 +4996,6 @@ export function App(): ReactElement {
const next = { ...current }; const next = { ...current };
for (const entry of packages) { for (const entry of packages) {
next[entry.id] = targetState; next[entry.id] = targetState;
if (targetState) manualCollapsedPkgsRef.current.add(entry.id);
else {
manualCollapsedPkgsRef.current.delete(entry.id);
autoExpandedPkgsRef.current.delete(entry.id);
}
} }
return next; return next;
}); });
@@ -5480,26 +5457,7 @@ export function App(): ReactElement {
multi: option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", multi: option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api",
icon: ACCOUNT_SERVICE_ICONS[option.service] icon: ACCOUNT_SERVICE_ICONS[option.service]
})); }));
const accountAddFields: AccountDialogField[] = accountDialog && accountDialogOption ? [ const accountAddFields = buildAccountAddFields(accountDialog);
...((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 accountAddDialog = ( const accountAddDialog = (
<AccountAddDialog <AccountAddDialog
actions={{ actions={{
+2 -7
View File
@@ -1,4 +1,5 @@
import type { AudioStripSummary, DebridProvider } from "../shared/types"; import type { AudioStripSummary, DebridProvider } from "../shared/types";
import { extractHosterFromUrl } from "../shared/hoster";
import { hosterIconSources } from "./hoster-icons"; import { hosterIconSources } from "./hoster-icons";
export const providerLabels: Record<DebridProvider, string> = { export const providerLabels: Record<DebridProvider, string> = {
@@ -48,13 +49,7 @@ export function formatDateTime(timestamp: number): string {
} }
export function extractHoster(url: string): string { export function extractHoster(url: string): string {
try { return extractHosterFromUrl(url);
const host = new URL(url).hostname.replace(/^www\./, "");
const parts = host.split(".");
return parts.length >= 2 ? parts[parts.length - 2] : host;
} catch {
return "";
}
} }
export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } { export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } {
+23 -16
View File
@@ -2,6 +2,13 @@ import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]); const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
function isPackageActive(pkg: PackageEntry, itemsById: Record<string, DownloadItem>): 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[] { export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
const fromIndex = order.indexOf(draggedPackageId); const fromIndex = order.indexOf(draggedPackageId);
const toIndex = order.indexOf(targetPackageId); const toIndex = order.indexOf(targetPackageId);
@@ -36,24 +43,24 @@ export function sortPackagesForDisplay(
return packages; return packages;
} }
const active: PackageEntry[] = []; const active = packages
const rest: PackageEntry[] = []; .map((pkg, index) => ({ pkg, index }))
.filter(({ pkg }) => isPackageActive(pkg, itemsById))
// Float packages that have an active item to the top, but keep BOTH groups in .sort((left, right) => {
// their original (queue) order. Earlier this sorted the active group by live const leftStartedAt = left.pkg.downloadStartedAt || 0;
// completedRatio/downloadedBytes — which change on every progress tick (every const rightStartedAt = right.pkg.downloadStartedAt || 0;
// 150-700ms), so active packages visibly reshuffled the whole time. A package if (leftStartedAt > 0 && rightStartedAt > 0 && leftStartedAt !== rightStartedAt) {
// entering/leaving the active bucket is a real, discrete event (start/finish); return leftStartedAt - rightStartedAt;
// 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 (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 || active.length === packages.length) { if (active.length === 0) {
return packages; return packages;
} }
+8 -3
View File
@@ -164,8 +164,8 @@
justify-content: center; justify-content: center;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
background: var(--ui-primary); background: var(--ui-update);
color: var(--ui-primary-text); color: var(--ui-update-text);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -174,7 +174,7 @@
} }
.md-update-trigger:hover { .md-update-trigger:hover {
background: var(--ui-primary-hover); background: var(--ui-update-hover);
} }
.md-update-trigger:focus-visible, .md-update-trigger:focus-visible,
@@ -310,9 +310,14 @@
.md-update-release-notes pre { .md-update-release-notes pre {
margin: 10px 0 0; margin: 10px 0 0;
max-height: min(360px, 45vh);
overflow-y: auto;
padding-right: 8px;
color: var(--ui-text-secondary); color: var(--ui-text-secondary);
font: inherit; font: inherit;
line-height: 20px; line-height: 20px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
white-space: pre-wrap; white-space: pre-wrap;
} }
+6
View File
@@ -15,6 +15,9 @@
--ui-primary: #D6D6D6; --ui-primary: #D6D6D6;
--ui-primary-hover: #E6E6E6; --ui-primary-hover: #E6E6E6;
--ui-primary-text: #181A1F; --ui-primary-text: #181A1F;
--ui-update: #BAD0FC;
--ui-update-hover: #8AA5DC;
--ui-update-text: #181A1F;
--ui-accent: #4A4A4A; --ui-accent: #4A4A4A;
--ui-focus: #9AB8E8; --ui-focus: #9AB8E8;
--ui-speed-accent: #4ADE80; --ui-speed-accent: #4ADE80;
@@ -48,6 +51,9 @@
--ui-primary: #3A3A3A; --ui-primary: #3A3A3A;
--ui-primary-hover: #202020; --ui-primary-hover: #202020;
--ui-primary-text: #FFFFFF; --ui-primary-text: #FFFFFF;
--ui-update: #BAD0FC;
--ui-update-hover: #8AA5DC;
--ui-update-text: #181A1F;
--ui-accent: #5E5E5E; --ui-accent: #5E5E5E;
--ui-focus: #24558D; --ui-focus: #24558D;
--ui-speed-accent: #1E9E55; --ui-speed-accent: #1E9E55;
@@ -139,6 +139,10 @@ export function compactDownloadStatus(value: string): string {
if (/Download running\b/i.test(status)) return "Download running"; if (/Download running\b/i.test(status)) return "Download running";
if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden"; if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden";
if (/^Password found\b/i.test(status)) return "Password found"; 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 (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler";
if (/^Extraction error\b/i.test(status)) return "Extraction error"; 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); const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i);
+2 -2
View File
@@ -420,8 +420,8 @@
} }
.downloads-cell-slot > :is(.downloads-status-cell, .downloads-service-cell) { .downloads-cell-slot > :is(.downloads-status-cell, .downloads-service-cell) {
justify-content: flex-start; justify-content: center;
text-align: left; text-align: center;
} }
.downloads-cell, .downloads-cell,
+24
View File
@@ -0,0 +1,24 @@
const DOMAIN_ALIASES: Readonly<Record<string, string>> = 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 "";
}
}
+2 -2
View File
@@ -430,7 +430,7 @@ describe("debug-server", () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
headers: { headers: {
"X-Forwarded-For": "159.195.63.46" "X-Forwarded-For": "203.0.113.46"
} }
}); });
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
@@ -439,7 +439,7 @@ describe("debug-server", () => {
const traceLogPath = getTraceLogPath(); const traceLogPath = getTraceLogPath();
expect(traceLogPath).toBeTruthy(); expect(traceLogPath).toBeTruthy();
const traceText = fs.readFileSync(traceLogPath!, "utf8"); 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 () => { it("serves package details and package log by package query", async () => {
+160
View File
@@ -252,6 +252,166 @@ describe("download manager", () => {
expect(failures.has("realdebrid")).toBe(true); expect(failures.has("realdebrid")).toBe(true);
}); });
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<string, number>;
const retryState = (manager as any).retryStateByItem as Map<string, unknown>;
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", () => { it("records history duration from the first actual package start", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
tempDirs.push(root); tempDirs.push(root);
+28 -2
View File
@@ -36,7 +36,12 @@ import {
getPackageProgress, getPackageProgress,
getPackageSizeProgress getPackageSizeProgress
} from "../src/renderer/views/downloads/DownloadsTable"; } 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"; import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); 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).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); 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", () => { 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("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("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("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("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden");
expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend"); expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend");
expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler"); 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"); 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", () => { it("removes duplicated access-mode wording from service labels", () => {
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)"); expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)"); 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-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-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-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(/: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-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); 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);
+32 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types"; import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { sortPackagesForDisplay } from "../src/renderer/package-order"; import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[]): PackageEntry { function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
const now = Date.now(); const now = Date.now();
return { return {
id, id,
@@ -15,7 +15,8 @@ function createPackage(id: string, itemIds: string[]): PackageEntry {
enabled: true, enabled: true,
priority: "normal", priority: "normal",
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
downloadStartedAt
}; };
} }
@@ -106,4 +107,33 @@ describe("sortPackagesForDisplay", () => {
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<string, DownloadItem> = {
"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<string, DownloadItem> = {
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"]);
});
}); });
+19
View File
@@ -3,6 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App";
import { import {
applyAccountEdit, applyAccountEdit,
createAccountEditState, createAccountEditState,
@@ -905,6 +906,24 @@ describe("account workspace", () => {
}); });
describe("settings App integration", () => { 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", () => { it("keeps specific persistence revision-safe when the draft changes in flight", () => {
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction"); const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
expect(block).toContain("revisionAtStart"); expect(block).toContain("revisionAtStart");
+1 -1
View File
@@ -438,7 +438,7 @@ describe("bandwidth chart palette", () => {
const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8"); 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(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); expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3);
}); });
+12
View File
@@ -146,6 +146,18 @@ describe("update experience", () => {
expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s); expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s);
}); });
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("keeps forward and reverse tabbing inside the update dialog", () => { it("keeps forward and reverse tabbing inside the update dialog", () => {
expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0); expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3); expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3);
+46
View File
@@ -108,6 +108,52 @@ describe("update", () => {
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.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<Response> => {
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", () => { it("uses silent NSIS install flags with auto-run after update", () => {
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]); expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
}); });