diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 9d49cbb..dd22fe5 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -6,6 +6,7 @@ import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { logger } from "./logger"; import { logAccountRotation } from "./account-rotation-log"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; +import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; const API_TIMEOUT_MS = 30000; @@ -3576,6 +3577,29 @@ export class DebridService { } } + // Mega.nz Pre-Resolve via Public API (kein Mega-Debrid-Quota-Verbrauch). + // Liefert echten Filename sobald Links in die Queue kommen, anstatt erst + // beim Unrestrict. Concurrency 4 — Mega's Public API ist tolerant gegen + // kleine Bursts. + const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link)); + if (megaLinks.length > 0) { + await runWithConcurrency(megaLinks, 4, async (link) => { + try { + const info = await resolveMegaFilename(link, signal); + if (info?.name) { + reportResolved(link, info.name); + } + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + // Schluck — Public API kann fehlen oder rate-limiten; faellt auf + // den normalen Mega-Debrid Unrestrict-Pfad zurueck. + } + }); + } + const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link)); await runWithConcurrency(remaining, 6, async (link) => { const fromPage = await resolveRapidgatorFilename(link, signal); diff --git a/src/main/mega-public-api.ts b/src/main/mega-public-api.ts new file mode 100644 index 0000000..7387a83 --- /dev/null +++ b/src/main/mega-public-api.ts @@ -0,0 +1,148 @@ +// Mega.nz Public API: Filename + Size aus Public-Link ohne Mega-Debrid-Account. +// +// Erlaubt Pre-Resolve von Filenames sobald Links in die Queue kommen — ohne +// Mega-Debrid-Quota anzufassen. Funktioniert fuer jeden public mega.nz Link +// (mit Decryption-Key im URL-Fragment). +// +// Protokoll: https://g.api.mega.co.nz/cs +// Request: POST [{"a":"g","g":1,"p":""}] +// Response: [{"s": , "at": , ...}] +// Attribute-Decryption: AES-128-CBC, key = file-key[0..16], IV = 16x \0 +// Plaintext startet mit "MEGA" gefolgt von JSON: {"n": "filename.mkv", ...} +// +// Datei-Key im URL-Fragment ist 32 Bytes (base64url-encoded). Bytes 0-15 +// sind der AES-Schluessel, 16-23 der CTR-Nonce, 24-31 die Meta-MAC. Fuer +// Attribut-Decryption brauchen wir nur den AES-Teil. + +import crypto from "node:crypto"; + +const MEGA_API_BASE = "https://g.api.mega.co.nz/cs"; +const MEGA_API_TIMEOUT_MS = 12_000; + +export interface MegaFileInfo { + name: string; + size: number; +} + +const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i; +const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i; + +export function isMegaFileUrl(url: string): boolean { + const s = String(url || "").trim(); + return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s); +} + +function base64UrlDecode(s: string): Buffer | null { + let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 !== 0) b64 += "="; + try { + return Buffer.from(b64, "base64"); + } catch { + return null; + } +} + +export interface ParsedMegaLink { + id: string; + rawKey: Buffer; +} + +export function parseMegaUrl(url: string): ParsedMegaLink | null { + const s = String(url || "").trim(); + const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s); + if (!m) return null; + const id = m[1]; + const rawKey = base64UrlDecode(m[2]); + // Files: 32 Bytes (256 bit). Folders: 16 Bytes — wir behandeln nur Files. + if (!rawKey || rawKey.length !== 32) return null; + return { id, rawKey }; +} + +export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record | null { + if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null; + if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null; + let plain: Buffer; + try { + const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16)); + decipher.setAutoPadding(false); + plain = Buffer.concat([decipher.update(encrypted), decipher.final()]); + } catch { + return null; + } + const text = plain.toString("utf8").replace(/\0+$/, "").trim(); + if (!text.startsWith("MEGA{")) return null; + try { + return JSON.parse(text.slice(4)); + } catch { + return null; + } +} + +function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs); + if (parent) { + if (parent.aborted) { + controller.abort(parent.reason); + } else { + parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true }); + } + } + controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + return controller.signal; +} + +export async function resolveMegaFilename( + url: string, + signal?: AbortSignal +): Promise { + const parsed = parseMegaUrl(url); + if (!parsed) return null; + const aesKey = parsed.rawKey.subarray(0, 16); + + const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`; + const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]); + + let response: Response; + try { + response = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS) + }); + } catch { + return null; + } + if (!response.ok) return null; + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return null; + } + + // Mega gibt entweder ein Array mit File-Infos oder eine numerische Error-ID + // zurueck (z.B. -9 ENOENT, -11 EACCESS, -14 EKEY, -16 EBLOCKED, -25 EOVERQUOTA). + if (typeof payload === "number") return null; + if (!Array.isArray(payload) || payload.length === 0) return null; + + const first = payload[0]; + if (typeof first === "number") return null; + if (!first || typeof first !== "object") return null; + + const info = first as { s?: unknown; at?: unknown; e?: unknown }; + if (typeof info.e === "number" && info.e !== 0) return null; + + const size = typeof info.s === "number" && info.s > 0 ? info.s : 0; + if (typeof info.at !== "string" || !info.at.trim()) return null; + + const encryptedAttrs = base64UrlDecode(info.at); + if (!encryptedAttrs) return null; + + const attrs = decryptMegaAttributes(encryptedAttrs, aesKey); + if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null; + + return { name: attrs.n.trim(), size }; +} diff --git a/tests/mega-public-api.test.ts b/tests/mega-public-api.test.ts new file mode 100644 index 0000000..9cf0f58 --- /dev/null +++ b/tests/mega-public-api.test.ts @@ -0,0 +1,180 @@ +import crypto from "node:crypto"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + decryptMegaAttributes, + isMegaFileUrl, + parseMegaUrl, + resolveMegaFilename +} from "../src/main/mega-public-api"; + +function base64Url(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function makeRandomFileKey(): Buffer { + return crypto.randomBytes(32); +} + +function encryptAttributes(jsonAttrs: Record, aesKey: Buffer): string { + const plain = "MEGA" + JSON.stringify(jsonAttrs); + // Pad to 16-byte boundary with \0 (Mega convention). + const padded = Buffer.from(plain, "utf8"); + const padLen = (16 - (padded.length % 16)) % 16; + const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]); + const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16)); + cipher.setAutoPadding(false); + const enc = Buffer.concat([cipher.update(buf), cipher.final()]); + return base64Url(enc); +} + +describe("mega-public-api", () => { + describe("isMegaFileUrl", () => { + it("recognizes new format", () => { + expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true); + }); + it("recognizes legacy format", () => { + expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true); + }); + it("recognizes mega.co.nz", () => { + expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true); + }); + it("rejects folder URLs", () => { + expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false); + }); + it("rejects non-mega URLs", () => { + expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false); + }); + it("rejects garbage", () => { + expect(isMegaFileUrl("")).toBe(false); + expect(isMegaFileUrl("foo")).toBe(false); + }); + }); + + describe("parseMegaUrl", () => { + it("parses new-format URL into id + 32-byte key", () => { + const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo"; + const parsed = parseMegaUrl(url); + expect(parsed).not.toBeNull(); + expect(parsed?.id).toBe("pZl1wBRQ"); + expect(parsed?.rawKey.length).toBe(32); + }); + it("parses legacy-format URL", () => { + // Make a valid legacy URL with a 32-byte key. + const id = "abcDEF12"; + const key = makeRandomFileKey(); + const url = `https://mega.nz/#!${id}!${base64Url(key)}`; + const parsed = parseMegaUrl(url); + expect(parsed?.id).toBe(id); + expect(parsed?.rawKey.equals(key)).toBe(true); + }); + it("rejects URL with folder key (16 bytes)", () => { + const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`; + expect(parseMegaUrl(url)).toBeNull(); + }); + it("rejects malformed URLs", () => { + expect(parseMegaUrl("not-a-url")).toBeNull(); + expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull(); + }); + }); + + describe("decryptMegaAttributes", () => { + it("round-trips encrypted Mega attributes", () => { + const aesKey = crypto.randomBytes(16); + const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" }; + const enc = encryptAttributes(original, aesKey); + const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64"); + const decrypted = decryptMegaAttributes(decoded, aesKey); + expect(decrypted).not.toBeNull(); + expect(decrypted?.n).toBe(original.n); + }); + it("returns null for wrong key", () => { + const aesKey = crypto.randomBytes(16); + const wrongKey = crypto.randomBytes(16); + const enc = encryptAttributes({ n: "x" }, aesKey); + const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64"); + expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull(); + }); + it("returns null for non-multiple-of-16 input", () => { + const aesKey = crypto.randomBytes(16); + expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull(); + }); + it("returns null for wrong key length", () => { + expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull(); + }); + }); + + describe("resolveMegaFilename (mocked fetch)", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns filename + size for a valid Mega response", async () => { + const fileKey = makeRandomFileKey(); + const aesKey = fileKey.subarray(0, 16); + const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`; + const encrypted = encryptAttributes( + { n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" }, + aesKey + ); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + async json() { + return [{ s: 1234567890, at: encrypted, msd: 1 }]; + } + } as unknown as Response); + + const result = await resolveMegaFilename(url); + expect(result).not.toBeNull(); + expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv"); + expect(result?.size).toBe(1234567890); + }); + + it("returns null when Mega returns numeric error", async () => { + const fileKey = makeRandomFileKey(); + const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + async json() { + return -9; // ENOENT — file not found + } + } as unknown as Response); + + expect(await resolveMegaFilename(url)).toBeNull(); + }); + + it("returns null when response is array with error code", async () => { + const fileKey = makeRandomFileKey(); + const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + async json() { + return [-16]; // EBLOCKED + } + } as unknown as Response); + + expect(await resolveMegaFilename(url)).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + const fileKey = makeRandomFileKey(); + const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`; + global.fetch = vi.fn().mockRejectedValue(new Error("network down")); + expect(await resolveMegaFilename(url)).toBeNull(); + }); + + it("returns null for non-mega URL without making any fetch call", async () => { + const fetchSpy = vi.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); +});