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:
@@ -2,6 +2,36 @@
|
||||
|
||||
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
|
||||
|
||||
### History management
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-10
@@ -1,5 +1,6 @@
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } 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,16 +558,6 @@ const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
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 {
|
||||
provider: DebridProvider;
|
||||
providerLabel: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { extractHosterFromUrl } from "../shared/hoster";
|
||||
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||
import {
|
||||
addDebridLinkApiKeyDailyUsageBytes,
|
||||
@@ -471,13 +472,7 @@ function isArchiveLikePath(filePath: string): boolean {
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
return extractHosterFromUrl(link);
|
||||
}
|
||||
|
||||
function isLargeBinaryLikePath(filePath: string): boolean {
|
||||
@@ -2176,6 +2171,16 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
||||
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.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||
const now = nowMs();
|
||||
@@ -2236,6 +2241,10 @@ export class DownloadManager extends EventEmitter {
|
||||
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport && megaPoolChanged) {
|
||||
this.releaseMegaDebridResetParks();
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport) {
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
|
||||
@@ -2246,6 +2255,41 @@ export class DownloadManager extends EventEmitter {
|
||||
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 {
|
||||
return this.settings;
|
||||
}
|
||||
@@ -8795,6 +8839,8 @@ export class DownloadManager extends EventEmitter {
|
||||
resumeHardResetUsed: Boolean(active.resumeHardResetUsed)
|
||||
});
|
||||
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (pkg) this.refreshPackageStatus(pkg);
|
||||
}
|
||||
|
||||
private scheduleHttp416Retry(
|
||||
|
||||
+103
-5
@@ -126,7 +126,7 @@ function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSig
|
||||
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;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
@@ -136,17 +136,27 @@ async function readJsonBody(response: Response, timeoutMs: number): Promise<Reco
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await Promise.race([
|
||||
return await Promise.race([
|
||||
response.json().catch(() => null) as Promise<unknown>,
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
||||
return data as Record<string, unknown>;
|
||||
} finally {
|
||||
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> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
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[] {
|
||||
const raw = Array.isArray(payload.assets) ? (payload.assets as Array<Record<string, unknown>>) : [];
|
||||
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[] {
|
||||
const seen = new Set<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}`);
|
||||
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) {
|
||||
return { ...fallback, error: compactErrorText(error) };
|
||||
}
|
||||
|
||||
+32
-74
@@ -447,8 +447,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Mega-Debrid",
|
||||
title: "Mega-Debrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.",
|
||||
needsToken: true
|
||||
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account."
|
||||
},
|
||||
{
|
||||
kind: "megadebrid-web",
|
||||
@@ -456,8 +455,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Mega-Debrid",
|
||||
title: "Mega-Debrid Web-Login",
|
||||
modeLabel: "Web-Login",
|
||||
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.",
|
||||
needsToken: true
|
||||
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account."
|
||||
},
|
||||
{
|
||||
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: [] };
|
||||
if (!kind) {
|
||||
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 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 };
|
||||
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 };
|
||||
@@ -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 {
|
||||
if (!dialog.kind) {
|
||||
return settings;
|
||||
@@ -1753,8 +1778,6 @@ export function App(): ReactElement {
|
||||
const snapshotRef = useRef(snapshot);
|
||||
snapshotRef.current = snapshot;
|
||||
const tabRef = useRef(tab);
|
||||
const autoExpandedPkgsRef = useRef(new Set<string>());
|
||||
const manualCollapsedPkgsRef = useRef(new Set<string>());
|
||||
tabRef.current = tab;
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -2270,11 +2293,6 @@ 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;
|
||||
});
|
||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||
@@ -2338,36 +2356,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<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(() => (
|
||||
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
||||
), [packages, collapsedPackages]);
|
||||
@@ -4032,12 +4020,6 @@ export function App(): ReactElement {
|
||||
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 };
|
||||
});
|
||||
}, []);
|
||||
@@ -5014,11 +4996,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 +5457,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 = (
|
||||
<AccountAddDialog
|
||||
actions={{
|
||||
|
||||
@@ -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<DebridProvider, string> = {
|
||||
@@ -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 } {
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types
|
||||
|
||||
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[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
@@ -36,24 +43,24 @@ export function sortPackagesForDisplay(
|
||||
return packages;
|
||||
}
|
||||
|
||||
const active: PackageEntry[] = [];
|
||||
const rest: PackageEntry[] = [];
|
||||
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));
|
||||
|
||||
// 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) {
|
||||
if (active.length === 0) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -174,7 +174,7 @@
|
||||
}
|
||||
|
||||
.md-update-trigger:hover {
|
||||
background: var(--ui-primary-hover);
|
||||
background: var(--ui-update-hover);
|
||||
}
|
||||
|
||||
.md-update-trigger:focus-visible,
|
||||
@@ -310,9 +310,14 @@
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -252,6 +252,166 @@ describe("download manager", () => {
|
||||
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", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,4 +107,33 @@ describe("sortPackagesForDisplay", () => {
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0);
|
||||
expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3);
|
||||
|
||||
@@ -108,6 +108,52 @@ describe("update", () => {
|
||||
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", () => {
|
||||
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user