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.
|
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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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={{
|
||||||
|
|||||||
@@ -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 } {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user