Align provider selection and abort observation
Share one pure provider selection plan between real unrestrict routing and cooldown projection, including direct 1Fichier and DDownload paths, disabled Mega aliases, and the secondary-provider exception used when Real-Debrid is cooling down. Attach terminal observers to raw serialized web jobs before evaluating already-aborted signals so late rejections stay handled while the queue remains available. Add RED-to-GREEN regressions for each provider-selection counterexample and pre-aborted Real-Debrid, AllDebrid, and BestDebrid jobs.
This commit is contained in:
@@ -71,3 +71,33 @@ Umgesetzt und fokussiert verifiziert.
|
|||||||
|
|
||||||
- Der vollständige Manager-Testlauf wurde in dieser Fixrunde nicht erneut ausgeführt; die geänderten Lifecycle-/Cooldown-Fälle sowie alle drei vollständigen Providerdateien und der vollständige Download-Renderer-Test wurden fokussiert geprüft.
|
- Der vollständige Manager-Testlauf wurde in dieser Fixrunde nicht erneut ausgeführt; die geänderten Lifecycle-/Cooldown-Fälle sowie alle drei vollständigen Providerdateien und der vollständige Download-Renderer-Test wurden fokussiert geprüft.
|
||||||
- Der bestehende Renderer-Buildhinweis für einen JavaScript-Chunk über 500 kB bleibt unverändert.
|
- Der bestehende Renderer-Buildhinweis für einen JavaScript-Chunk über 500 kB bleibt unverändert.
|
||||||
|
|
||||||
|
## Fixrunde 2/5
|
||||||
|
|
||||||
|
### Korrekturen
|
||||||
|
|
||||||
|
- Eine gemeinsame pure Providerplanung liefert Hosterroute, direkte 1Fichier-/DDownload-Pfade und die geordnete Providerkette sowohl an den echten Unrestrictpfad als auch an die Cooldown-Projektion.
|
||||||
|
- Bei deaktiviertem automatischem Fehlerfallback bildet die Projektion weiterhin den realen Sonderfall ab, dass ein wegen Real-Debrid-Cooldown nicht auswählbarer Primärprovider auf einen sofort verfügbaren Sekundärprovider wechseln darf.
|
||||||
|
- Die gemeinsame Provider-Deaktivierungsprüfung behandelt `megadebrid` als Alias für beide konkreten Mega-Debrid-Modi und wird auch vom Manager-Startgate verwendet.
|
||||||
|
- Die äußere Abort-Race hängt ihren terminalen Observer an den rohen Queue-Promise, bevor ein bereits abgebrochenes Signal ausgewertet wird. Dadurch bleibt auch die spätere rohe Rejection behandelt und die Queue sofort für den Folgejob frei.
|
||||||
|
|
||||||
|
### RED→GREEN
|
||||||
|
|
||||||
|
- `autoProviderFallback=false`: Vorher blieb ein falsches Real-Debrid-`retryAt` trotz sofort verfügbarem AllDebrid-Zweitprovider; danach `retryAt=null`.
|
||||||
|
- Direkte 1Fichier-/DDownload-Links: Vorher überlagerte der Real-Debrid-Cooldown beide Direktpfade; danach bleiben sie startfähig. Der Direktpfad wurde zusätzlich mit einer gezielten Mutation wieder rot und nach Wiederherstellung grün belegt.
|
||||||
|
- `disabledProviders: ["megadebrid"]`: Vorher waren `canStart=true` und ein Mega-Retry sichtbar; danach `canStart=false`, `phase=idle`, `retryAt=null`.
|
||||||
|
- Bereits abgebrochenes Signal: Real-Debrid, AllDebrid und BestDebrid erzeugten vor dem Fix jeweils eine rohe `unhandledRejection`; danach keine, und der jeweilige Folgejob schloss erfolgreich ab.
|
||||||
|
|
||||||
|
### Verifikation
|
||||||
|
|
||||||
|
- 12/12 fokussierte Manager-Lifecycle-/Cooldown-Tests bestanden.
|
||||||
|
- 6/6 bestehende Debrid-Providerwahltests bestanden.
|
||||||
|
- 155/155 vollständige Webprovider-/Download-Renderer-Tests bestanden.
|
||||||
|
- `npx tsc --noEmit` bestand.
|
||||||
|
- `npm run build` bestand für Main und Renderer.
|
||||||
|
- `git diff --check` bestand.
|
||||||
|
|
||||||
|
### Bedenken
|
||||||
|
|
||||||
|
- Die vollständigen Manager- und Debrid-Testdateien wurden nicht komplett ausgeführt; die geänderten Auswahl-, Lifecycle- und Queuepfade wurden fokussiert abgedeckt.
|
||||||
|
- Der bestehende Renderer-Buildhinweis für einen JavaScript-Chunk über 500 kB bleibt unverändert.
|
||||||
|
|||||||
@@ -75,9 +75,6 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
|
|||||||
if (!signal) {
|
if (!signal) {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
|
||||||
throw abortError();
|
|
||||||
}
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
@@ -104,6 +101,9 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
|
|||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
|
|||||||
if (!signal) {
|
if (!signal) {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
|
||||||
throw abortError();
|
|
||||||
}
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
@@ -62,6 +59,9 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
|
|||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+119
-66
@@ -1636,14 +1636,50 @@ function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvi
|
|||||||
return uniqueProviderOrder(order);
|
return uniqueProviderOrder(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] {
|
export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] {
|
||||||
if (!preferred || !order.includes(preferred)) {
|
if (!preferred || !order.includes(preferred)) {
|
||||||
return [...order];
|
return [...order];
|
||||||
}
|
}
|
||||||
return [preferred, ...order.filter((provider) => provider !== preferred)];
|
return [preferred, ...order.filter((provider) => provider !== preferred)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRapidgatorLink(link: string): boolean {
|
type ProviderSelectionPlan = {
|
||||||
|
hosterKey: string;
|
||||||
|
routedProvider?: DebridProvider;
|
||||||
|
directProviders: DebridProvider[];
|
||||||
|
order: DebridProvider[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildProviderSelectionPlan(
|
||||||
|
settings: AppSettings,
|
||||||
|
link: string,
|
||||||
|
preferredLeadProvider?: DebridProvider | null
|
||||||
|
): ProviderSelectionPlan {
|
||||||
|
const configuredOrder = settings.providerOrder && settings.providerOrder.length > 0
|
||||||
|
? uniqueProviderOrder(settings.providerOrder)
|
||||||
|
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
||||||
|
const order = leadProviderChainWith(configuredOrder, preferredLeadProvider);
|
||||||
|
const hosterKey = extractHosterFromUrl(link);
|
||||||
|
const routedProvider = hosterKey ? settings.hosterRouting?.[hosterKey] : undefined;
|
||||||
|
const directProviders: DebridProvider[] = [];
|
||||||
|
if (isOneFichierLink(link)) {
|
||||||
|
directProviders.push("onefichier");
|
||||||
|
}
|
||||||
|
if (DDOWNLOAD_URL_RE.test(link)) {
|
||||||
|
directProviders.push("ddownload");
|
||||||
|
}
|
||||||
|
return { hosterKey, routedProvider, directProviders, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProviderDisabledForSelection(settings: AppSettings, provider: DebridProvider): boolean {
|
||||||
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
|
const disabled = settings.disabledProviders || [];
|
||||||
|
return disabled.includes(provider)
|
||||||
|
|| disabled.includes(effectiveProvider)
|
||||||
|
|| ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && disabled.includes("megadebrid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRapidgatorLink(link: string): boolean {
|
||||||
try {
|
try {
|
||||||
const hostname = new URL(link).hostname.toLowerCase();
|
const hostname = new URL(link).hostname.toLowerCase();
|
||||||
return hostname === "rapidgator.net"
|
return hostname === "rapidgator.net"
|
||||||
@@ -4151,25 +4187,62 @@ export class DebridService {
|
|||||||
|
|
||||||
public getBlockingProviderRetryAt(link: string, preferredLeadProvider: DebridProvider | null = null, now = Date.now()): number | null {
|
public getBlockingProviderRetryAt(link: string, preferredLeadProvider: DebridProvider | null = null, now = Date.now()): number | null {
|
||||||
const settings = cloneSettings(this.settings);
|
const settings = cloneSettings(this.settings);
|
||||||
const configuredOrder = settings.providerOrder && settings.providerOrder.length > 0
|
const plan = buildProviderSelectionPlan(settings, link, preferredLeadProvider);
|
||||||
? uniqueProviderOrder(settings.providerOrder)
|
|
||||||
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
|
||||||
const orderedProviders = leadProviderChainWith(configuredOrder, preferredLeadProvider);
|
|
||||||
const hosterKey = extractHosterFromUrl(link);
|
|
||||||
const routedProvider = hosterKey ? settings.hosterRouting?.[hosterKey] : undefined;
|
|
||||||
const routedPlan = routedProvider
|
|
||||||
? [routedProvider, ...orderedProviders.filter((provider) => provider !== routedProvider)]
|
|
||||||
: orderedProviders;
|
|
||||||
const plan = settings.autoProviderFallback || routedProvider ? routedPlan : routedPlan.slice(0, 1);
|
|
||||||
const deadlines: number[] = [];
|
const deadlines: number[] = [];
|
||||||
|
if (plan.routedProvider) {
|
||||||
|
const routedState = this.getProviderRuntimeWaitState(settings, plan.routedProvider, plan.hosterKey, now);
|
||||||
|
if (routedState.configured) {
|
||||||
|
if (routedState.retryAt === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
deadlines.push(routedState.retryAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const provider of plan.directProviders) {
|
||||||
|
const state = this.getProviderRuntimeWaitState(settings, provider, plan.hosterKey, now);
|
||||||
|
if (state.configured && state.retryAt === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.retryAt) {
|
||||||
|
deadlines.push(state.retryAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!settings.autoProviderFallback) {
|
||||||
|
const primary = plan.order[0];
|
||||||
|
if (!primary || !this.isProviderConfiguredFor(settings, primary)) {
|
||||||
|
return deadlines.length > 0 ? Math.min(...deadlines) : null;
|
||||||
|
}
|
||||||
|
if (this.isProviderDailyLimited(settings, primary)) {
|
||||||
|
const secondary = plan.order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider));
|
||||||
|
if (secondary) {
|
||||||
|
const secondaryState = this.getProviderRuntimeWaitState(settings, secondary, plan.hosterKey, now);
|
||||||
|
if (secondaryState.retryAt === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
deadlines.push(secondaryState.retryAt);
|
||||||
|
} else {
|
||||||
|
const primaryState = this.getProviderRuntimeWaitState(settings, primary, plan.hosterKey, now);
|
||||||
|
if (primaryState.retryAt) {
|
||||||
|
deadlines.push(primaryState.retryAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const primaryState = this.getProviderRuntimeWaitState(settings, primary, plan.hosterKey, now);
|
||||||
|
if (primaryState.retryAt === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
deadlines.push(primaryState.retryAt);
|
||||||
|
}
|
||||||
|
return deadlines.length > 0 ? Math.min(...deadlines) : null;
|
||||||
|
}
|
||||||
const seen = new Set<DebridProvider>();
|
const seen = new Set<DebridProvider>();
|
||||||
for (const provider of plan) {
|
for (const provider of plan.order) {
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
if (seen.has(effectiveProvider)) {
|
if (seen.has(effectiveProvider)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.add(effectiveProvider);
|
seen.add(effectiveProvider);
|
||||||
const state = this.getProviderRuntimeWaitState(settings, effectiveProvider, hosterKey, now);
|
const state = this.getProviderRuntimeWaitState(settings, provider, plan.hosterKey, now);
|
||||||
if (!state.configured) {
|
if (!state.configured) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -4187,10 +4260,11 @@ export class DebridService {
|
|||||||
hosterKey: string,
|
hosterKey: string,
|
||||||
now: number
|
now: number
|
||||||
): { configured: boolean; retryAt: number | null } {
|
): { configured: boolean; retryAt: number | null } {
|
||||||
if ((settings.disabledProviders || []).includes(provider)) {
|
if (isProviderDisabledForSelection(settings, provider)) {
|
||||||
return { configured: false, retryAt: null };
|
return { configured: false, retryAt: null };
|
||||||
}
|
}
|
||||||
if (provider === "realdebrid") {
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
|
if (effectiveProvider === "realdebrid") {
|
||||||
const accounts = this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled
|
const accounts = this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled
|
||||||
&& !isRealDebridAccountDailyLimitReached(settings, account.id, now));
|
&& !isRealDebridAccountDailyLimitReached(settings, account.id, now));
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
@@ -4201,8 +4275,8 @@ export class DebridService {
|
|||||||
? { configured: true, retryAt: null }
|
? { configured: true, retryAt: null }
|
||||||
: { configured: true, retryAt: Math.min(...deadlines as number[]) };
|
: { configured: true, retryAt: Math.min(...deadlines as number[]) };
|
||||||
}
|
}
|
||||||
if (provider === "megadebrid-api" || provider === "megadebrid-web") {
|
if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
|
||||||
const mode = provider === "megadebrid-web" ? "web" : "api";
|
const mode = effectiveProvider === "megadebrid-web" ? "web" : "api";
|
||||||
if (!isMegaDebridModeEnabled(settings, mode) || (mode === "web" && !this.options.megaWebUnrestrict)) {
|
if (!isMegaDebridModeEnabled(settings, mode) || (mode === "web" && !this.options.megaWebUnrestrict)) {
|
||||||
return { configured: false, retryAt: null };
|
return { configured: false, retryAt: null };
|
||||||
}
|
}
|
||||||
@@ -4216,7 +4290,7 @@ export class DebridService {
|
|||||||
? { configured: true, retryAt: null }
|
? { configured: true, retryAt: null }
|
||||||
: { configured: true, retryAt: Math.min(...deadlines as number[]) };
|
: { configured: true, retryAt: Math.min(...deadlines as number[]) };
|
||||||
}
|
}
|
||||||
if (provider === "debridlink") {
|
if (effectiveProvider === "debridlink") {
|
||||||
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter((key) => !isDebridLinkApiKeyDisabled(settings, key.id)
|
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter((key) => !isDebridLinkApiKeyDisabled(settings, key.id)
|
||||||
&& !isDebridLinkApiKeyDailyLimitReached(settings, key.id, now));
|
&& !isDebridLinkApiKeyDailyLimitReached(settings, key.id, now));
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
@@ -4398,13 +4472,11 @@ export class DebridService {
|
|||||||
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
|
const selectionPlan = buildProviderSelectionPlan(settings, link, preferredLeadProvider);
|
||||||
const routing = settings.hosterRouting || {};
|
const { hosterKey, routedProvider } = selectionPlan;
|
||||||
const hosterKey = extractHosterFromUrl(link);
|
if (hosterKey && routedProvider) {
|
||||||
if (hosterKey && routing[hosterKey]) {
|
|
||||||
const routedProvider = routing[hosterKey];
|
|
||||||
if (this.isProviderSelectableFor(settings, routedProvider)) {
|
if (this.isProviderSelectableFor(settings, routedProvider)) {
|
||||||
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
||||||
try {
|
try {
|
||||||
@@ -4437,14 +4509,17 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOneFichierLink(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
for (const directProvider of selectionPlan.directProviders) {
|
||||||
try {
|
if (!this.isProviderSelectableFor(settings, directProvider)) {
|
||||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
continue;
|
||||||
return {
|
}
|
||||||
...result,
|
try {
|
||||||
provider: "onefichier",
|
const result = await this.unrestrictViaProvider(settings, directProvider, link, signal);
|
||||||
providerLabel: PROVIDER_LABELS["onefichier"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
|
return {
|
||||||
};
|
...result,
|
||||||
|
provider: directProvider,
|
||||||
|
providerLabel: PROVIDER_LABELS[directProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
@@ -4453,32 +4528,10 @@ export class DebridService {
|
|||||||
if (!settings.autoProviderFallback) {
|
if (!settings.autoProviderFallback) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
const order = selectionPlan.order;
|
||||||
try {
|
|
||||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
provider: "ddownload",
|
|
||||||
providerLabel: PROVIDER_LABELS["ddownload"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorText = compactErrorText(error);
|
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (!settings.autoProviderFallback) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseOrder: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
|
|
||||||
? uniqueProviderOrder(settings.providerOrder)
|
|
||||||
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
|
||||||
const order = leadProviderChainWith(baseOrder, preferredLeadProvider);
|
|
||||||
|
|
||||||
const primary = order[0];
|
const primary = order[0];
|
||||||
if (!settings.autoProviderFallback) {
|
if (!settings.autoProviderFallback) {
|
||||||
@@ -4584,9 +4637,9 @@ export class DebridService {
|
|||||||
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
|
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
|
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false;
|
if (isProviderDisabledForSelection(settings, provider)) return false;
|
||||||
if (effectiveProvider === "realdebrid") {
|
if (effectiveProvider === "realdebrid") {
|
||||||
return this.getConfiguredRealDebridAccounts(settings).some((account) => account.enabled);
|
return this.getConfiguredRealDebridAccounts(settings).some((account) => account.enabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isProviderDisabledForSelection, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
|
||||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
|
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { classifyDiskError } from "./fs-error";
|
import { classifyDiskError } from "./fs-error";
|
||||||
@@ -9017,7 +9017,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private isProviderConfigured(provider: DebridProvider): boolean {
|
private isProviderConfigured(provider: DebridProvider): boolean {
|
||||||
this.ensureProviderDailyUsageFresh(nowMs());
|
this.ensureProviderDailyUsageFresh(nowMs());
|
||||||
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
||||||
if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) {
|
if (isProviderDisabledForSelection(this.settings, provider)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isProviderDailyLimitReached(this.settings, effectiveProvider)) {
|
if (isProviderDailyLimitReached(this.settings, effectiveProvider)) {
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
|
|||||||
if (!signal) {
|
if (!signal) {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
|
||||||
throw abortError();
|
|
||||||
}
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const onAbort = (): void => {
|
const onAbort = (): void => {
|
||||||
@@ -109,6 +106,9 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
|
|||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,33 @@ describe("alldebrid-web", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
link: "https://alldebrid.direct/next.bin",
|
||||||
|
filename: "next.bin",
|
||||||
|
filesize: 666
|
||||||
|
}), { status: 200 }));
|
||||||
|
const fallback = new AllDebridWebFallback(() => true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort("before-queue");
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||||
|
process.on("unhandledRejection", onUnhandled);
|
||||||
|
try {
|
||||||
|
await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal))
|
||||||
|
.rejects.toThrow("aborted:alldebrid-web");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({
|
||||||
|
directUrl: "https://alldebrid.direct/next.bin",
|
||||||
|
fileName: "next.bin"
|
||||||
|
});
|
||||||
|
expect(unhandled).toEqual([]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
process.off("unhandledRejection", onUnhandled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the login window after login_required and retries generation with the same session partition", async () => {
|
it("opens the login window after login_required and retries generation with the same session partition", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(new Response("login", { status: 200 }))
|
.mockResolvedValueOnce(new Response("login", { status: 200 }))
|
||||||
|
|||||||
@@ -210,4 +210,38 @@ describe("bestdebrid-web", () => {
|
|||||||
rejectRequest(new Error("late bestdebrid rejection"));
|
rejectRequest(new Error("late bestdebrid rejection"));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
|
||||||
|
const filePath = createCookieFile([
|
||||||
|
"# Netscape HTTP Cookie File",
|
||||||
|
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
||||||
|
].join("\n"));
|
||||||
|
tempFiles.push(filePath);
|
||||||
|
const fallback = new BestDebridWebFallback(() => true);
|
||||||
|
await fallback.importCookiesFromFile(filePath);
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
error: 0,
|
||||||
|
link: "https://bestdebrid.direct/next.bin",
|
||||||
|
filename: "next.bin",
|
||||||
|
size: "777 B"
|
||||||
|
}), { status: 200 }));
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort("before-queue");
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||||
|
process.on("unhandledRejection", onUnhandled);
|
||||||
|
try {
|
||||||
|
await expect(fallback.unrestrict("https://1fichier.com/?pre-aborted", controller.signal))
|
||||||
|
.rejects.toThrow("aborted:bestdebrid-web");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await expect(fallback.unrestrict("https://1fichier.com/?next")).resolves.toMatchObject({
|
||||||
|
directUrl: "https://bestdebrid.direct/next.bin",
|
||||||
|
fileName: "next.bin"
|
||||||
|
});
|
||||||
|
expect(unhandled).toEqual([]);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
process.off("unhandledRejection", onUnhandled);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -998,6 +998,93 @@ describe("deterministic stop and restart lifecycle", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses an available secondary provider for cooldown projection even when automatic failure fallback is disabled", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-fallback-provider-context-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const accountId = "rda_disabled_fallback_secondary";
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
|
||||||
|
allDebridToken: "all-debrid-token",
|
||||||
|
providerOrder: ["realdebrid", "alldebrid"],
|
||||||
|
autoProviderFallback: false,
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "secondary", links: ["https://rapidgator.net/file/secondary"] }]);
|
||||||
|
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
|
||||||
|
|
||||||
|
expect(manager.getSnapshot()).toMatchObject({
|
||||||
|
canStart: true,
|
||||||
|
lifecycle: { phase: "idle", retryAt: null }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "1Fichier",
|
||||||
|
link: "https://1fichier.com/?direct123",
|
||||||
|
settings: { oneFichierApiKey: "onefichier-key" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DDownload",
|
||||||
|
link: "https://ddownload.com/abc12345/direct.bin",
|
||||||
|
settings: { ddownloadLogin: "user", ddownloadPassword: "password" }
|
||||||
|
}
|
||||||
|
])("does not project a Real-Debrid cooldown over the direct $name path", ({ link, settings: directSettings }) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-provider-context-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const accountId = `rda_direct_${path.basename(root)}`;
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
...directSettings,
|
||||||
|
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
|
||||||
|
providerOrder: ["realdebrid"],
|
||||||
|
autoProviderFallback: false,
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "direct", links: [link] }]);
|
||||||
|
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
|
||||||
|
|
||||||
|
expect(manager.getSnapshot()).toMatchObject({
|
||||||
|
canStart: true,
|
||||||
|
lifecycle: { phase: "idle", retryAt: null }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not project a Mega-Debrid retry when the shared Mega provider alias is disabled", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-mega-alias-context-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const login = "disabled-mega@example.test";
|
||||||
|
const accountId = getMegaDebridAccountId(login);
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
megaDebridApiCredentials: `${login}:password`,
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
disabledProviders: ["megadebrid"],
|
||||||
|
providerOrder: ["megadebrid"],
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "mega-disabled", links: ["https://rapidgator.net/file/mega-disabled"] }]);
|
||||||
|
primeMegaDebridRuntimeCooldownForTests(`${accountId}:api`, 60_000);
|
||||||
|
|
||||||
|
expect(manager.getSnapshot()).toMatchObject({
|
||||||
|
canStart: false,
|
||||||
|
lifecycle: { phase: "idle", retryAt: null }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores cooldowns for another provider or hoster and for pure post-processing", () => {
|
it("ignores cooldowns for another provider or hoster and for pure post-processing", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@@ -231,6 +231,34 @@ describe("realdebrid-web", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
|
||||||
|
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"next-token\">", { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
download: "https://cdn.real-debrid.example/next.bin",
|
||||||
|
filename: "next.bin",
|
||||||
|
filesize: 555
|
||||||
|
}), { status: 200 })));
|
||||||
|
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_pre_aborted", () => true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort("before-queue");
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||||
|
process.on("unhandledRejection", onUnhandled);
|
||||||
|
try {
|
||||||
|
await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal))
|
||||||
|
.rejects.toThrow("aborted:realdebrid-web");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({
|
||||||
|
directUrl: "https://cdn.real-debrid.example/next.bin",
|
||||||
|
fileName: "next.bin"
|
||||||
|
});
|
||||||
|
expect(unhandled).toEqual([]);
|
||||||
|
expect(mockSessionFetch).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
process.off("unhandledRejection", onUnhandled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not open a login window for an authenticated account with a fair-use error", async () => {
|
it("does not open a login window for an authenticated account with a fair-use error", async () => {
|
||||||
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
|
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
|
||||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user