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.
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
import { UnrestrictedLink } from "./realdebrid";
|
|
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
|
import { traceConversionPhase } from "./conversion-trace";
|
|
|
|
type MegaCredentials = {
|
|
login: string;
|
|
password: string;
|
|
};
|
|
|
|
type CodeEntry = {
|
|
code: string;
|
|
linkHint: string;
|
|
};
|
|
|
|
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
|
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
|
|
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
|
|
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
|
|
|
|
export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i;
|
|
|
|
function normalizeLink(link: string): string {
|
|
return link.trim().toLowerCase();
|
|
}
|
|
|
|
function parseSetCookieFromHeaders(headers: Headers): string {
|
|
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
|
if (typeof getSetCookie === "function") {
|
|
const values = getSetCookie.call(headers)
|
|
.map((entry) => entry.split(";")[0].trim())
|
|
.filter(Boolean);
|
|
if (values.length > 0) {
|
|
return values.join("; ");
|
|
}
|
|
}
|
|
|
|
const raw = headers.get("set-cookie") || "";
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
return raw
|
|
.split(/,(?=[^;=]+?=)/g)
|
|
.map((chunk) => chunk.split(";")[0].trim())
|
|
.filter(Boolean)
|
|
.join("; ");
|
|
}
|
|
|
|
const PERMANENT_HOSTER_ERRORS = [
|
|
"hosternotavailable",
|
|
"filenotfound",
|
|
"file_unavailable",
|
|
"file not found",
|
|
"link is dead",
|
|
"file has been removed",
|
|
"file has been deleted",
|
|
"file was deleted",
|
|
"file was removed",
|
|
"not available",
|
|
"file is no longer available"
|
|
];
|
|
|
|
function parsePageErrors(html: string): string[] {
|
|
const errors: string[] = [];
|
|
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = errorRegex.exec(html)) !== null) {
|
|
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
|
if (text) {
|
|
errors.push(text);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function isPermanentHosterError(errors: string[]): string | null {
|
|
for (const err of errors) {
|
|
const lower = err.toLowerCase();
|
|
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
|
if (lower.includes(pattern)) {
|
|
return err;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseCodes(html: string): CodeEntry[] {
|
|
const entries: CodeEntry[] = [];
|
|
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
|
let cardMatch: RegExpExecArray | null;
|
|
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
|
const block = cardMatch[0];
|
|
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
|
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
|
if (!code) {
|
|
continue;
|
|
}
|
|
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
|
}
|
|
|
|
if (entries.length === 0) {
|
|
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = fallbackRegex.exec(html)) !== null) {
|
|
entries.push({ code: m[1], linkHint: "" });
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function pickCode(entries: CodeEntry[], link: string): string {
|
|
if (entries.length === 0) {
|
|
return "";
|
|
}
|
|
const target = normalizeLink(link);
|
|
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
|
return (match?.code || entries[0].code || "").trim();
|
|
}
|
|
|
|
function parseDebridJson(text: string): { link: string; text: string } | null {
|
|
try {
|
|
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
|
return {
|
|
link: String(parsed.link || ""),
|
|
text: String(parsed.text || "")
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function abortError(): Error {
|
|
return new Error("aborted:mega-web");
|
|
}
|
|
|
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
if (!signal) {
|
|
return timeoutSignal;
|
|
}
|
|
return AbortSignal.any([signal, timeoutSignal]);
|
|
}
|
|
|
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
if (signal?.aborted) {
|
|
throw abortError();
|
|
}
|
|
}
|
|
|
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
|
if (!signal) {
|
|
await sleep(ms);
|
|
return;
|
|
}
|
|
if (signal.aborted) {
|
|
throw abortError();
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
|
timer = null;
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
}, Math.max(0, ms));
|
|
|
|
const onAbort = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(abortError());
|
|
};
|
|
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
|
if (!signal) {
|
|
return promise;
|
|
}
|
|
if (signal.aborted) {
|
|
throw abortErrorFactory();
|
|
}
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
let settled = false;
|
|
|
|
const onAbort = (): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(abortErrorFactory());
|
|
};
|
|
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
|
|
promise.then((value) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(value);
|
|
}, (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
export class MegaWebFallback {
|
|
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
|
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
|
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
|
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
|
private queues = new Map<string, Promise<unknown>>();
|
|
|
|
private getCredentials: () => MegaCredentials;
|
|
|
|
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
|
|
|
private sessionGeneration = 0;
|
|
|
|
public constructor(getCredentials: () => MegaCredentials) {
|
|
this.getCredentials = getCredentials;
|
|
}
|
|
|
|
public async unrestrict(
|
|
link: string,
|
|
signal?: AbortSignal,
|
|
account?: { login: string; password: string }
|
|
): Promise<UnrestrictedLink | null> {
|
|
const overallSignal = withTimeoutSignal(signal, 180000);
|
|
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();
|
|
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;
|
|
}
|
|
}
|
|
return {
|
|
directUrl: generated.directUrl,
|
|
fileName: generated.fileName || filenameFromUrl(link),
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
};
|
|
}, key, overallSignal);
|
|
}
|
|
|
|
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);
|
|
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();
|
|
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
|
let workStarted = false;
|
|
const guardedJob = async (): Promise<T> => {
|
|
throwIfAborted(signal);
|
|
const waited = Date.now() - queuedAt;
|
|
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
|
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
|
|
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
|
}
|
|
workStarted = true;
|
|
const workStartedAt = Date.now();
|
|
try {
|
|
const result = await job();
|
|
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
|
return result;
|
|
} catch (jobError) {
|
|
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
|
throw jobError;
|
|
}
|
|
};
|
|
const prev = this.queues.get(key) ?? Promise.resolve();
|
|
const run = prev.then(guardedJob, guardedJob);
|
|
this.queues.set(key, run.then(() => undefined, () => undefined));
|
|
return raceWithAbort(run, signal, () =>
|
|
workStarted
|
|
? abortError()
|
|
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
|
|
);
|
|
}
|
|
|
|
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
|
throwIfAborted(signal);
|
|
const response = await fetch(LOGIN_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "Mozilla/5.0"
|
|
},
|
|
body: new URLSearchParams({
|
|
login,
|
|
password,
|
|
remember: "on"
|
|
}),
|
|
redirect: "manual",
|
|
signal: withTimeoutSignal(signal, 30000)
|
|
});
|
|
|
|
const cookie = parseSetCookieFromHeaders(response.headers);
|
|
if (!cookie) {
|
|
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
|
}
|
|
|
|
const verify = await fetch(DEBRID_REFERER, {
|
|
method: "GET",
|
|
headers: {
|
|
"User-Agent": "Mozilla/5.0",
|
|
Cookie: cookie,
|
|
Referer: DEBRID_REFERER
|
|
},
|
|
signal: withTimeoutSignal(signal, 30000)
|
|
});
|
|
const verifyHtml = await verify.text();
|
|
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
|
if (!hasDebridForm) {
|
|
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
|
}
|
|
|
|
return cookie;
|
|
}
|
|
|
|
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
|
throwIfAborted(signal);
|
|
const page = await fetch(DEBRID_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "Mozilla/5.0",
|
|
Cookie: cookie,
|
|
Referer: DEBRID_REFERER
|
|
},
|
|
body: new URLSearchParams({
|
|
links: link,
|
|
password: "",
|
|
showLinks: "1"
|
|
}),
|
|
signal: withTimeoutSignal(signal, 30000)
|
|
});
|
|
|
|
const html = await page.text();
|
|
|
|
const pageErrors = parsePageErrors(html);
|
|
const permanentError = isPermanentHosterError(pageErrors);
|
|
if (permanentError) {
|
|
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
|
}
|
|
|
|
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
|
if (noServerError) {
|
|
throw new Error(`Mega-Web: ${noServerError}`);
|
|
}
|
|
|
|
const code = pickCode(parseCodes(html), link);
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
|
|
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
|
throwIfAborted(signal);
|
|
const res = await fetch(DEBRID_AJAX_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "Mozilla/5.0",
|
|
Cookie: cookie,
|
|
Referer: DEBRID_REFERER
|
|
},
|
|
body: new URLSearchParams({
|
|
code,
|
|
autodl: "0"
|
|
}),
|
|
signal: withTimeoutSignal(signal, 15000)
|
|
});
|
|
|
|
const text = (await res.text()).trim();
|
|
if (text === "reload") {
|
|
await sleepWithSignal(650, signal);
|
|
continue;
|
|
}
|
|
if (text === "false") {
|
|
return null;
|
|
}
|
|
|
|
const parsed = parseDebridJson(text);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
|
|
if (!parsed.link) {
|
|
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
|
await sleepWithSignal(1200, signal);
|
|
continue;
|
|
}
|
|
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
|
throw new Error(`Mega-Web: ${serverMsg}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const fromText = parsed.text
|
|
.replace(/<[^>]*>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
|
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
|
return {
|
|
directUrl: parsed.link,
|
|
fileName
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public dispose(): void {
|
|
this.sessions.clear();
|
|
}
|
|
}
|
|
|
|
export function compactMegaWebError(error: unknown): string {
|
|
return compactErrorText(error);
|
|
}
|