File size: 6,784 Bytes
f0403fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/usr/bin/env python3
"""Paralelo: baixa um link publico de PASTA do mega.nz.

megadl e sequencial (~0,6 MB/s aqui). Varias conexoes contornam o throttle
por-conexao do Mega. Pula arquivos ja completos (compara tamanho).
"""
from __future__ import annotations
import argparse, base64, json, os, struct, sys, threading, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
from Crypto.Cipher import AES
from Crypto.Util import Counter

API = "https://g.api.mega.co.nz/cs"
_seq = [0]
_lock = threading.Lock()


def b64d(s: str) -> bytes:
    s += "=" * (-len(s) % 4)
    return base64.b64decode(s.replace("-", "+").replace("_", "/"))


def api(payload, folder_id, tries=6):
    for attempt in range(tries):
        with _lock:
            _seq[0] += 1
            sid = _seq[0]
        req = urllib.request.Request(
            f"{API}?id={sid}&n={folder_id}",
            data=json.dumps(payload).encode(),
            headers={"Content-Type": "application/json"},
        )
        try:
            out = json.loads(urllib.request.urlopen(req, timeout=90).read())
        except Exception:
            time.sleep(2 * (attempt + 1)); continue
        # Um inteiro negativo no lugar do objeto e um erro do Mega (-3 = tente de novo).
        if isinstance(out, int):
            out = [out]
        if isinstance(out[0], int):
            if out[0] in (-3, -4):
                time.sleep(3 * (attempt + 1)); continue
            raise RuntimeError(f"Mega API error {out[0]}")
        return out
    raise RuntimeError("Mega API: falhou apos varias tentativas")


def node_keys(k_field: str, master: bytes):
    """O campo `k` pode trazer varias entradas `handle:chave` separadas por `/`
    (a pasta e compartilhada por mais de um no). So uma decifra com a master key
    do link, entao devolvemos todas as candidatas e quem chama escolhe a que
    produz um atributo valido."""
    ecb = AES.new(master, AES.MODE_ECB)
    out = []
    for entry in k_field.split("/"):
        if ":" not in entry:
            continue
        enc = b64d(entry.split(":", 1)[1])
        if len(enc) < 16:
            continue
        out.append(ecb.decrypt(enc[: len(enc) // 16 * 16]))
    return out


def node_key(k_field: str, master: bytes) -> bytes:
    keys = node_keys(k_field, master)
    if not keys:
        raise ValueError("no key entry")
    return keys[0]


def file_key(nk: bytes):
    w = struct.unpack(">8I", nk[:32])
    key = struct.pack(">4I", w[0] ^ w[4], w[1] ^ w[5], w[2] ^ w[6], w[3] ^ w[7])
    iv = struct.pack(">2I", w[4], w[5])  # 8 bytes de nonce
    return key, iv


def attr_name(attr_b64: str, key: bytes) -> str | None:
    raw = b64d(attr_b64)
    dec = AES.new(key, AES.MODE_CBC, b"\0" * 16).decrypt(raw[: len(raw) // 16 * 16])
    dec = dec.rstrip(b"\0")
    if not dec.startswith(b"MEGA"):
        return None
    try:
        return json.loads(dec[4:].decode("utf-8", "replace")).get("n")
    except Exception:
        return None


def download(node, dest_root, folder_id, master, state):
    handle, key, iv, size, relpath = node
    out = os.path.join(dest_root, relpath)
    if os.path.exists(out) and os.path.getsize(out) == size:
        with _lock:
            state["skipped"] += 1
        return
    os.makedirs(os.path.dirname(out), exist_ok=True)
    tmp = out + ".part"
    url = api([{"a": "g", "g": 1, "n": handle}], folder_id)[0]["g"]
    ctr = Counter.new(128, initial_value=int.from_bytes(iv + b"\0" * 8, "big"))
    aes = AES.new(key, AES.MODE_CTR, counter=ctr)
    with urllib.request.urlopen(url, timeout=180) as r, open(tmp, "wb") as f:
        while True:
            chunk = r.read(1 << 20)
            if not chunk:
                break
            f.write(aes.decrypt(chunk))
            with _lock:
                state["bytes"] += len(chunk)
    os.replace(tmp, out)
    with _lock:
        state["done"] += 1


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("link")
    ap.add_argument("--dest", required=True)
    ap.add_argument("--workers", type=int, default=12)
    a = ap.parse_args()

    folder_id, key_b64 = a.link.split("/folder/")[1].split("#")
    master = b64d(key_b64)
    nodes = api([{"a": "f", "c": 1, "r": 1, "ca": 1}], folder_id)[0]["f"]

    names, parents = {}, {}
    for n in nodes:
        parents[n["h"]] = n.get("p")
        if n["t"] == 1:
            names[n["h"]] = next(
                (nm for nk in node_keys(n["k"], master)
                 if (nm := attr_name(n["a"], nk[:16]))), n["h"])

    # Handle da pasta raiz do link: seu nome nao entra no caminho local
    # (megadl tambem o omite; manter igual permite retomar o que ele ja baixou).
    roots = {h for h in names if parents.get(h) not in names}

    def relpath_of(h):
        parts = []
        while h in names and h not in roots:
            parts.append(names[h]); h = parents.get(h)
        return os.path.join(*reversed(parts)) if parts else ""

    jobs, undecodable = [], []
    for n in nodes:
        if n["t"] != 0:
            continue
        chosen = None
        for nk in node_keys(n["k"], master):
            if len(nk) < 32:
                continue
            key, iv = file_key(nk)
            name = attr_name(n["a"], key)
            if name:
                chosen = (key, iv, name)
                break
        if chosen is None:
            undecodable.append(n["h"])
            continue
        key, iv, name = chosen
        jobs.append((n["h"], key, iv, n["s"], os.path.join(relpath_of(n["p"]), name)))
    if undecodable:
        print(f"AVISO: {len(undecodable)} nos sem chave decifravel, ignorados", flush=True)

    total = sum(j[3] for j in jobs)
    print(f"{len(jobs)} arquivos, {total/1e9:.2f} GB, {a.workers} conexoes", flush=True)
    state = {"bytes": 0, "done": 0, "skipped": 0}
    t0 = time.time()

    stop = threading.Event()
    def ticker():
        while not stop.wait(30):
            el = time.time() - t0
            print(f"[{el:6.0f}s] ok={state['done']} skip={state['skipped']}/{len(jobs)} "
                  f"{state['bytes']/1e6:8.0f} MB  {state['bytes']/1e6/el:5.2f} MB/s", flush=True)
    threading.Thread(target=ticker, daemon=True).start()

    errors = []
    with ThreadPoolExecutor(max_workers=a.workers) as ex:
        futs = {ex.submit(download, j, a.dest, folder_id, master, state): j for j in jobs}
        for f, j in futs.items():
            try:
                f.result()
            except Exception as e:
                errors.append((j[4], repr(e)))
    stop.set()
    print(f"FIM em {time.time()-t0:.0f}s  ok={state['done']} skip={state['skipped']} err={len(errors)}", flush=True)
    for p, e in errors[:40]:
        print("ERRO", p, e, flush=True)
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()