release: ship v2.0.23 queue and account reliability fixes
Normalize RapidGator aliases, stabilize active queue ordering, preserve user-controlled package expansion, and align compact queue status presentation. Separate Mega-Debrid API and Web credential pools, migrate legacy credentials and disabled states safely, refresh live account availability without restart, honor disabled credential persistence, and invalidate Web sessions without allowing stale in-flight, retry, or queued logins to restore old cookies. Expand regression coverage for account isolation, legacy migration, session races, live scheduler refresh, update notes, and responsive queue behavior.
This commit is contained in:
@@ -46,9 +46,11 @@ export function defaultSettings(): AppSettings {
|
||||
language: "en",
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: true,
|
||||
@@ -133,7 +135,9 @@ export function defaultSettings(): AppSettings {
|
||||
debridLinkApiKeyDailyLimitBytes: {},
|
||||
debridLinkApiKeyDailyUsageBytes: {},
|
||||
debridLinkApiKeyTotalUsageBytes: {},
|
||||
megaDebridDisabledAccountIds: [],
|
||||
megaDebridDisabledAccountIds: [],
|
||||
megaDebridApiDisabledAccountIds: [],
|
||||
megaDebridWebDisabledAccountIds: [],
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
|
||||
+41
-43
@@ -1,5 +1,6 @@
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, type MegaDebridAccountEntry, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts";
|
||||
import { extractHosterFromUrl } from "../shared/hoster";
|
||||
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
||||
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
|
||||
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||
@@ -557,17 +558,7 @@ const PROVIDER_LABELS: Record<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 {
|
||||
interface ProviderUnrestrictedLink extends UnrestrictedLink {
|
||||
provider: DebridProvider;
|
||||
providerLabel: string;
|
||||
}
|
||||
@@ -595,7 +586,9 @@ function cloneSettings(settings: AppSettings): AppSettings {
|
||||
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
||||
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
|
||||
debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) },
|
||||
megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])],
|
||||
megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])],
|
||||
megaDebridApiDisabledAccountIds: [...(settings.megaDebridApiDisabledAccountIds || [])],
|
||||
megaDebridWebDisabledAccountIds: [...(settings.megaDebridWebDisabledAccountIds || [])],
|
||||
megaDebridAccountDailyLimitBytes: { ...(settings.megaDebridAccountDailyLimitBytes || {}) },
|
||||
megaDebridAccountDailyUsageBytes: { ...(settings.megaDebridAccountDailyUsageBytes || {}) },
|
||||
megaDebridAccountTotalUsageBytes: { ...(settings.megaDebridAccountTotalUsageBytes || {}) }
|
||||
@@ -612,26 +605,27 @@ export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = D
|
||||
);
|
||||
}
|
||||
|
||||
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
|
||||
return getMegaDebridAccountList(settings).filter(
|
||||
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
|
||||
);
|
||||
}
|
||||
|
||||
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
|
||||
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||
if (multiAccounts.length > 0) {
|
||||
return multiAccounts;
|
||||
}
|
||||
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
|
||||
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
||||
return getMegaDebridAccountList(settings).length > 0;
|
||||
}
|
||||
export function getAvailableMegaDebridAccounts(settings: AppSettings, mode: MegaDebridAccountMode, epochMs = Date.now()): MegaDebridAccountEntry[] {
|
||||
return getMegaDebridAccountList(settings, mode).filter(
|
||||
(entry) => !isMegaDebridAccountDisabled(settings, entry.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
|
||||
);
|
||||
}
|
||||
|
||||
function getMegaDebridAccountList(settings: AppSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] {
|
||||
const multiAccounts = getMegaDebridAccountsForMode(settings, mode);
|
||||
if (multiAccounts.length > 0) {
|
||||
return multiAccounts;
|
||||
}
|
||||
const hasDedicatedPools = typeof settings.megaDebridApiCredentials === "string" || typeof settings.megaDebridWebCredentials === "string";
|
||||
if (!hasDedicatedPools && settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
|
||||
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
||||
return parseMegaDebridAccounts(mergeMegaDebridCredentialPools(settings.megaDebridApiCredentials || "", settings.megaDebridWebCredentials || "") || settings.megaCredentials || "").length > 0;
|
||||
}
|
||||
|
||||
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
|
||||
if (mode === "api") {
|
||||
@@ -2039,12 +2033,12 @@ class MegaDebridClient {
|
||||
megaWebUnrestrict: MegaWebUnrestrictor | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<UnrestrictedLink> {
|
||||
const accounts = getMegaDebridAccountList(settings);
|
||||
const accounts = getMegaDebridAccountList(settings, mode);
|
||||
if (accounts.length === 0) {
|
||||
throw new Error("Mega-Debrid: Kein Account konfiguriert");
|
||||
}
|
||||
|
||||
if (getAvailableMegaDebridAccounts(settings).length === 0) {
|
||||
if (getAvailableMegaDebridAccounts(settings, mode).length === 0) {
|
||||
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)");
|
||||
}
|
||||
|
||||
@@ -2087,7 +2081,7 @@ class MegaDebridClient {
|
||||
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
||||
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
||||
|
||||
if (isMegaDebridAccountDisabled(settings, account.id)) {
|
||||
if (isMegaDebridAccountDisabled(settings, account.id, mode)) {
|
||||
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
|
||||
continue;
|
||||
@@ -2172,7 +2166,7 @@ class MegaDebridClient {
|
||||
if (candidate.account.id === account.id) {
|
||||
return count;
|
||||
}
|
||||
if (isMegaDebridAccountDisabled(settings, candidate.account.id)) {
|
||||
if (isMegaDebridAccountDisabled(settings, candidate.account.id, mode)) {
|
||||
return count;
|
||||
}
|
||||
if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) {
|
||||
@@ -2254,7 +2248,7 @@ class MegaDebridClient {
|
||||
let nextLabel = "ENDE";
|
||||
for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
|
||||
const nextAcc = orderedEntries[nextPos].account;
|
||||
if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
|
||||
if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
|
||||
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
|
||||
break;
|
||||
}
|
||||
@@ -3824,9 +3818,10 @@ export class DebridService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
|
||||
const configuredAccounts = getMegaDebridAccountList(settings);
|
||||
if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
|
||||
if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
|
||||
const mode = effectiveProvider === "megadebrid-web" ? "web" : "api";
|
||||
const configuredAccounts = getMegaDebridAccountList(settings, mode);
|
||||
if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings, mode).length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3842,8 +3837,11 @@ export class DebridService {
|
||||
if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
|
||||
return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)";
|
||||
}
|
||||
if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && getMegaDebridAccountList(settings).length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
|
||||
return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)";
|
||||
if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
|
||||
const mode = effectiveProvider === "megadebrid-web" ? "web" : "api";
|
||||
if (getMegaDebridAccountList(settings, mode).length > 0 && getAvailableMegaDebridAccounts(settings, mode).length === 0) {
|
||||
return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)";
|
||||
}
|
||||
}
|
||||
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { extractHosterFromUrl } from "../shared/hoster";
|
||||
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||
import {
|
||||
addDebridLinkApiKeyDailyUsageBytes,
|
||||
addDebridLinkApiKeyTotalUsageBytes,
|
||||
@@ -470,15 +472,9 @@ function isArchiveLikePath(filePath: string): boolean {
|
||||
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower);
|
||||
}
|
||||
|
||||
function extractHosterKey(link: string): string {
|
||||
try {
|
||||
const host = new URL(link).hostname.replace(/^www\./, "").toLowerCase();
|
||||
const parts = host.split(".");
|
||||
return parts.length >= 2 ? parts[parts.length - 2] : host;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function extractHosterKey(link: string): string {
|
||||
return extractHosterFromUrl(link);
|
||||
}
|
||||
|
||||
function isLargeBinaryLikePath(filePath: string): boolean {
|
||||
const lower = path.basename(String(filePath || "")).toLowerCase();
|
||||
@@ -2175,7 +2171,26 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
||||
const previous = this.settings;
|
||||
const previous = this.settings;
|
||||
const previousMegaPool = (["api", "web"] as const)
|
||||
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`))
|
||||
.sort()
|
||||
.join("\n");
|
||||
const nextMegaAccounts = (["api", "web"] as const)
|
||||
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode));
|
||||
const nextMegaPool = (["api", "web"] as const)
|
||||
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`))
|
||||
.sort()
|
||||
.join("\n");
|
||||
const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web")
|
||||
.map((account) => `${account.id}:${account.password}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web")
|
||||
.map((account) => `${account.id}:${account.password}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0;
|
||||
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||
const now = nowMs();
|
||||
@@ -2184,8 +2199,11 @@ export class DownloadManager extends EventEmitter {
|
||||
this.invalidateSettingsSnapshotCache();
|
||||
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
|
||||
this.runtimePersistedAt = now;
|
||||
this.ensureProviderDailyUsageFresh(nowMs());
|
||||
this.debridService.setSettings(next);
|
||||
this.ensureProviderDailyUsageFresh(nowMs());
|
||||
if (previousMegaWebCredentials !== nextMegaWebCredentials) {
|
||||
this.invalidateMegaSessionFn?.();
|
||||
}
|
||||
this.debridService.setSettings(next);
|
||||
this.allDebridHostInfoCache.clear();
|
||||
|
||||
const prevOrder = JSON.stringify(previous.providerOrder ?? []);
|
||||
@@ -2218,7 +2236,11 @@ export class DownloadManager extends EventEmitter {
|
||||
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
|
||||
{ prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
|
||||
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
|
||||
{ prev: previous.megaCredentials + "|" + previous.megaPassword, next: next.megaCredentials + "|" + next.megaPassword, providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] }
|
||||
{
|
||||
prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}`,
|
||||
next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}`,
|
||||
providers: ["megadebrid", "megadebrid-api", "megadebrid-web"]
|
||||
}
|
||||
];
|
||||
let clearedProviderFailures = 0;
|
||||
for (const change of credChanges) {
|
||||
@@ -2232,9 +2254,13 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (clearedProviderFailures > 0) {
|
||||
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
|
||||
}
|
||||
if (clearedProviderFailures > 0) {
|
||||
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport && megaPoolChanged) {
|
||||
this.releaseMegaDebridResetParks();
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport) {
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
@@ -2243,8 +2269,43 @@ export class DownloadManager extends EventEmitter {
|
||||
this.applyRetroactiveCleanupPolicy();
|
||||
}
|
||||
}
|
||||
this.emitState();
|
||||
}
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private releaseMegaDebridResetParks(): number {
|
||||
const activeItemIds = new Set([...this.activeTasks.values()].map((task) => task.itemId));
|
||||
const affectedPackageIds = new Set<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;
|
||||
@@ -8019,12 +8080,12 @@ export class DownloadManager extends EventEmitter {
|
||||
if (effectiveProvider === "realdebrid") {
|
||||
return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-api") {
|
||||
const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim()));
|
||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled));
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-web") {
|
||||
const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim()));
|
||||
if (effectiveProvider === "megadebrid-api") {
|
||||
const hasMegaCreds = getMegaDebridAccountsForMode(this.settings, "api").length > 0;
|
||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled));
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-web") {
|
||||
const hasMegaCreds = getMegaDebridAccountsForMode(this.settings, "web").length > 0;
|
||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled));
|
||||
}
|
||||
if (effectiveProvider === "bestdebrid") {
|
||||
@@ -8209,7 +8270,7 @@ export class DownloadManager extends EventEmitter {
|
||||
private getSerializedValidatingLimit(provider: DebridProvider | null): number {
|
||||
if (provider === "megadebrid-web" || provider === "megadebrid-api") {
|
||||
const mode = provider === "megadebrid-web" ? "web" : "api";
|
||||
const usableAccounts = getAvailableMegaDebridAccounts(this.settings)
|
||||
const usableAccounts = getAvailableMegaDebridAccounts(this.settings, mode)
|
||||
.filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:${mode}`))
|
||||
.length;
|
||||
return Math.max(1, usableAccounts);
|
||||
@@ -8793,9 +8854,11 @@ export class DownloadManager extends EventEmitter {
|
||||
genericRetries: Number(active.genericErrorRetries || 0),
|
||||
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||
resumeHardResetUsed: Boolean(active.resumeHardResetUsed)
|
||||
});
|
||||
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
|
||||
}
|
||||
});
|
||||
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (pkg) this.refreshPackageStatus(pkg);
|
||||
}
|
||||
|
||||
private scheduleHttp416Retry(
|
||||
item: DownloadItem,
|
||||
|
||||
@@ -226,7 +226,9 @@ export class MegaWebFallback {
|
||||
|
||||
private getCredentials: () => MegaCredentials;
|
||||
|
||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||
|
||||
private sessionGeneration = 0;
|
||||
|
||||
public constructor(getCredentials: () => MegaCredentials) {
|
||||
this.getCredentials = getCredentials;
|
||||
@@ -241,18 +243,19 @@ export class MegaWebFallback {
|
||||
const creds = (account && account.login.trim() && account.password.trim())
|
||||
? account
|
||||
: this.getCredentials();
|
||||
if (!creds.login.trim() || !creds.password.trim()) {
|
||||
return null;
|
||||
}
|
||||
const key = creds.login.trim().toLowerCase();
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
|
||||
let generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
this.sessions.delete(key);
|
||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
if (!creds.login.trim() || !creds.password.trim()) {
|
||||
return null;
|
||||
}
|
||||
const key = creds.login.trim().toLowerCase();
|
||||
const sessionGeneration = this.sessionGeneration;
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
|
||||
|
||||
let generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
this.sessions.delete(key);
|
||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
|
||||
generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
return null;
|
||||
@@ -267,19 +270,28 @@ export class MegaWebFallback {
|
||||
}, key, overallSignal);
|
||||
}
|
||||
|
||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
private async ensureSession(
|
||||
key: string,
|
||||
login: string,
|
||||
password: string,
|
||||
signal?: AbortSignal,
|
||||
generation = this.sessionGeneration
|
||||
): Promise<string> {
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
||||
return existing.cookie;
|
||||
}
|
||||
const cookie = await this.login(login, password, signal);
|
||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
public invalidateSession(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
const cookie = await this.login(login, password, signal);
|
||||
if (generation === this.sessionGeneration) {
|
||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
public invalidateSession(): void {
|
||||
this.sessionGeneration += 1;
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||
const megaAccountIds = new Set(getMegaDebridAccountIds(target.megaCredentials || "", target.megaPassword || ""));
|
||||
const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountIds]);
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
||||
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
||||
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
||||
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
||||
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => debridLinkKeyIds.has(keyId))
|
||||
);
|
||||
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => debridLinkKeyIds.has(keyId))
|
||||
);
|
||||
target.megaDebridAccountDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.megaDebridAccountDailyUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
|
||||
);
|
||||
target.megaDebridAccountTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.megaDebridAccountTotalUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
|
||||
);
|
||||
target.debridAccountStatuses = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridAccountStatuses || {}).filter(([accountId]) => validAccountIds.has(accountId))
|
||||
);
|
||||
}
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
|
||||
const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountIds]);
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
||||
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
||||
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
||||
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
||||
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => debridLinkKeyIds.has(keyId))
|
||||
);
|
||||
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => debridLinkKeyIds.has(keyId))
|
||||
);
|
||||
target.megaDebridAccountDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.megaDebridAccountDailyUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
|
||||
);
|
||||
target.megaDebridAccountTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.megaDebridAccountTotalUsageBytes || {}).filter(([accountId]) => megaAccountIds.has(accountId))
|
||||
);
|
||||
target.debridAccountStatuses = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridAccountStatuses || {}).filter(([accountId]) => validAccountIds.has(accountId))
|
||||
);
|
||||
}
|
||||
|
||||
+79
-34
@@ -3,7 +3,7 @@ import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import { defaultSettings } from "./constants";
|
||||
@@ -381,21 +381,49 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
const defaults = defaultSettings();
|
||||
const directorySettings = migrateLegacyDefaultDirectories(settings, defaults);
|
||||
const currentUsageDay = getProviderUsageDayKey();
|
||||
const megaLogin = asText(settings.megaLogin);
|
||||
const megaPassword = asText(settings.megaPassword);
|
||||
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
|
||||
if (!megaCredentials && megaLogin && megaPassword) {
|
||||
megaCredentials = `${megaLogin}:${megaPassword}`;
|
||||
}
|
||||
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
|
||||
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
||||
const hasMegaCreds = Boolean(megaLogin && megaPassword);
|
||||
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
|
||||
? Boolean(settings.megaDebridApiEnabled)
|
||||
: (hasMegaCreds ? megaDebridPreferApi : defaults.megaDebridApiEnabled);
|
||||
const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
||||
? Boolean(settings.megaDebridWebEnabled)
|
||||
: (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
||||
const legacyMegaLogin = asText(settings.megaLogin);
|
||||
const legacyMegaPassword = asText(settings.megaPassword);
|
||||
let legacyMegaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
|
||||
if (!legacyMegaCredentials && legacyMegaLogin && legacyMegaPassword) {
|
||||
legacyMegaCredentials = `${legacyMegaLogin}:${legacyMegaPassword}`;
|
||||
}
|
||||
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
||||
const hasLegacyMegaCredentials = getMegaDebridAccountIds(legacyMegaCredentials).length > 0;
|
||||
const requestedMegaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
|
||||
? Boolean(settings.megaDebridApiEnabled)
|
||||
: (hasLegacyMegaCredentials ? megaDebridPreferApi : defaults.megaDebridApiEnabled);
|
||||
const requestedMegaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
||||
? Boolean(settings.megaDebridWebEnabled)
|
||||
: (hasLegacyMegaCredentials ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
||||
const legacyMegaMode = requestedMegaDebridApiEnabled !== requestedMegaDebridWebEnabled
|
||||
? requestedMegaDebridApiEnabled ? "api" : "web"
|
||||
: megaDebridPreferApi ? "api" : "web";
|
||||
const rawMegaDebridApiCredentials = String(settings.megaDebridApiCredentials || "").replace(/\r\n|\r/g, "\n").trim();
|
||||
const rawMegaDebridWebCredentials = String(settings.megaDebridWebCredentials || "").replace(/\r\n|\r/g, "\n").trim();
|
||||
const hasDedicatedMegaPools = Boolean(rawMegaDebridApiCredentials || rawMegaDebridWebCredentials) || !legacyMegaCredentials;
|
||||
const megaDebridApiCredentials = hasDedicatedMegaPools
|
||||
? rawMegaDebridApiCredentials
|
||||
: legacyMegaMode === "api" ? legacyMegaCredentials : "";
|
||||
const megaDebridWebCredentials = hasDedicatedMegaPools
|
||||
? rawMegaDebridWebCredentials
|
||||
: legacyMegaMode === "web" ? legacyMegaCredentials : "";
|
||||
const megaDebridApiAccountIds = getMegaDebridAccountIds(megaDebridApiCredentials);
|
||||
const megaDebridWebAccountIds = getMegaDebridAccountIds(megaDebridWebCredentials);
|
||||
const megaCredentials = mergeMegaDebridCredentialPools(megaDebridApiCredentials, megaDebridWebCredentials);
|
||||
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
|
||||
const primaryMegaAccount = parseMegaDebridAccounts(megaCredentials)[0];
|
||||
const megaLogin = primaryMegaAccount?.login || "";
|
||||
const megaPassword = primaryMegaAccount?.password || "";
|
||||
const megaDebridApiEnabled = requestedMegaDebridApiEnabled && megaDebridApiAccountIds.length > 0;
|
||||
const megaDebridWebEnabled = requestedMegaDebridWebEnabled && megaDebridWebAccountIds.length > 0;
|
||||
const legacyMegaDisabledAccountIds = normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds);
|
||||
const megaDebridApiDisabledAccountIds = Array.isArray(settings.megaDebridApiDisabledAccountIds)
|
||||
? normalizeStringList(settings.megaDebridApiDisabledAccountIds, megaDebridApiAccountIds)
|
||||
: legacyMegaDisabledAccountIds.filter((id) => megaDebridApiAccountIds.includes(id));
|
||||
const megaDebridWebDisabledAccountIds = Array.isArray(settings.megaDebridWebDisabledAccountIds)
|
||||
? normalizeStringList(settings.megaDebridWebDisabledAccountIds, megaDebridWebAccountIds)
|
||||
: legacyMegaDisabledAccountIds.filter((id) => megaDebridWebAccountIds.includes(id));
|
||||
const megaDebridDisabledAccountIds = [...new Set([...megaDebridApiDisabledAccountIds, ...megaDebridWebDisabledAccountIds])];
|
||||
const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay);
|
||||
const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw)
|
||||
? providerDailyUsageDayRaw
|
||||
@@ -429,8 +457,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
token: asText(settings.token),
|
||||
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||
megaLogin,
|
||||
megaPassword,
|
||||
megaCredentials,
|
||||
megaPassword,
|
||||
megaCredentials,
|
||||
megaDebridApiCredentials,
|
||||
megaDebridWebCredentials,
|
||||
megaDebridApiEnabled,
|
||||
megaDebridWebEnabled,
|
||||
megaDebridPreferApi,
|
||||
@@ -527,7 +557,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
debridLinkApiKeyDailyLimitBytes,
|
||||
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
|
||||
debridLinkApiKeyTotalUsageBytes,
|
||||
megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds),
|
||||
megaDebridDisabledAccountIds,
|
||||
megaDebridApiDisabledAccountIds,
|
||||
megaDebridWebDisabledAccountIds,
|
||||
megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds),
|
||||
megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
|
||||
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
||||
@@ -580,9 +612,11 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
|
||||
...settings,
|
||||
token: "",
|
||||
realDebridUseWebLogin: settings.realDebridUseWebLogin,
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "",
|
||||
bestToken: "",
|
||||
bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
|
||||
allDebridToken: "",
|
||||
@@ -668,11 +702,14 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
|
||||
};
|
||||
}
|
||||
|
||||
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
||||
return parsed;
|
||||
}
|
||||
const hasMegaCreds = Boolean(asText(parsed.megaLogin) && asText(parsed.megaPassword));
|
||||
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
||||
return parsed;
|
||||
}
|
||||
const hasMegaCreds = Boolean(
|
||||
getMegaDebridAccountIds(asText(parsed.megaCredentials), asText(parsed.megaPassword)).length
|
||||
|| (asText(parsed.megaLogin) && asText(parsed.megaPassword))
|
||||
);
|
||||
if (!hasMegaCreds) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -680,16 +717,24 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||
return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi };
|
||||
}
|
||||
|
||||
function readSettingsFile(filePath: string): AppSettings | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||
const migratedLanguage = (parsed as Partial<AppSettings>).language === undefined ? "de" : parsed.language;
|
||||
const merged = normalizeSettings({
|
||||
function readSettingsFile(filePath: string): AppSettings | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||
const migratedLanguage = (parsed as Partial<AppSettings>).language === undefined ? "de" : parsed.language;
|
||||
const migrated = migrateLegacyMegaEnableFlags(parsed);
|
||||
const mergedInput = {
|
||||
...defaultSettings(),
|
||||
...migrateLegacyMegaEnableFlags(parsed),
|
||||
...migrated,
|
||||
language: migratedLanguage,
|
||||
columnOrderVersion: parsed.columnOrderVersion
|
||||
});
|
||||
} as AppSettings;
|
||||
if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridApiDisabledAccountIds")) {
|
||||
delete (mergedInput as Partial<AppSettings>).megaDebridApiDisabledAccountIds;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridWebDisabledAccountIds")) {
|
||||
delete (mergedInput as Partial<AppSettings>).megaDebridWebDisabledAccountIds;
|
||||
}
|
||||
const merged = normalizeSettings(mergedInput);
|
||||
return sanitizeCredentialPersistence(merged);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||
|
||||
+130
-32
@@ -126,26 +126,36 @@ function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSig
|
||||
return AbortSignal.any([primary, secondary]);
|
||||
}
|
||||
|
||||
async function readJsonBody(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
async function readJsonValue(response: Response, timeoutMs: number): Promise<unknown> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
reject(new Error(`timeout:${timeoutMs}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await Promise.race([
|
||||
response.json().catch(() => null) as Promise<unknown>,
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
||||
return data as Record<string, unknown>;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
response.json().catch(() => null) as Promise<unknown>,
|
||||
timeoutPromise,
|
||||
]);
|
||||
} 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;
|
||||
@@ -357,7 +367,7 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P
|
||||
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
|
||||
}
|
||||
|
||||
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
||||
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
payload: Record<string, unknown> | null;
|
||||
@@ -372,8 +382,26 @@ async function fetchRelease(repo: string, endpoint: string): Promise<{
|
||||
return { ok: response.ok, status: response.status, payload };
|
||||
} finally {
|
||||
tc.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReleasePage(repo: string, page: number): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
payload: Array<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>>) : [];
|
||||
@@ -408,7 +436,7 @@ function isDraftOrPrerelease(payload: Record<string, unknown>): boolean {
|
||||
return Boolean(payload.draft) || Boolean(payload.prerelease);
|
||||
}
|
||||
|
||||
function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: string): UpdateCheckResult {
|
||||
function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: string): UpdateCheckResult {
|
||||
const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim();
|
||||
const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION;
|
||||
const releaseUrl = String(payload.html_url || fallbackUrl);
|
||||
@@ -425,8 +453,73 @@ function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: stri
|
||||
setupAssetName: setup?.name || "",
|
||||
setupAssetDigest: setup?.digest || "",
|
||||
releaseNotes: body || undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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>();
|
||||
@@ -804,7 +897,7 @@ export function buildInstallerLaunchArgs(): string[] {
|
||||
return ["/S", "--updated", "--force-run"];
|
||||
}
|
||||
|
||||
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
|
||||
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
|
||||
const safeRepo = normalizeUpdateRepo(repo);
|
||||
const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`;
|
||||
const fallback: UpdateCheckResult = {
|
||||
@@ -817,15 +910,20 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
|
||||
|
||||
try {
|
||||
const { ok, status, payload } = await fetchRelease(safeRepo, "releases/latest");
|
||||
if (!ok || !payload) {
|
||||
const reason = String((payload?.message as string) || `HTTP ${status}`);
|
||||
return { ...fallback, error: reason };
|
||||
}
|
||||
return parseReleasePayload(payload, fallbackUrl);
|
||||
} catch (error) {
|
||||
return { ...fallback, error: compactErrorText(error) };
|
||||
}
|
||||
}
|
||||
if (!ok || !payload) {
|
||||
const reason = String((payload?.message as string) || `HTTP ${status}`);
|
||||
return { ...fallback, error: reason };
|
||||
}
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function installLatestUpdate(
|
||||
repo: string,
|
||||
|
||||
Reference in New Issue
Block a user