add vod downloader
Pablo Murad pablo@pablomurad.com
Sun, 12 Jul 2026 15:34:21 -0300
2 files changed,
315 insertions(+),
0 deletions(-)
M
README.md
→
README.md
@@ -31,3 +31,29 @@
Live channels are written as HLS (.m3u8). Movies and series keep the extension the panel reports for each file. Series are read one show at a time, so a big catalog will take a while to finish. + +# xtrakt-stuff + +Search a panel for a movie or a show and download it. Files land in `Movies/` +and `Series/<show>/Season NN/` under the current folder. Downloads resume: if a +connection drops it reconnects and picks up where it left off, and re-running +continues a half-finished file. + +## Usage + + python xtrakt-stuff.py + +Or straight from the command line: + + python xtrakt-stuff.py -s http://your-server:port -u USER -p PASS -T movie -S "matrix" + python xtrakt-stuff.py -s http://your-server:port -u USER -p PASS -T movie -S "matrix" --pick 3 + +Options: + + -T movie|series what to look for + -S TEXT search term + --pick N download result number N + --season N only that season (series) + --limit N how many results to list (default: 30) + --refresh reload the catalog cache + -o DIR where to save (default: current folder)
A
xtrakt-stuff.py
@@ -0,0 +1,289 @@
+import argparse +import json +import os +import re +import sys +import time +from getpass import getpass + +import requests +from requests.adapters import HTTPAdapter + +try: + from urllib3.util.retry import Retry +except Exception: + Retry = None + +AGENT = "IPTVSmarters/1.0" +CHUNK = 1 << 20 +CACHE_TTL = 12 * 3600 +NET_ERRORS = (requests.exceptions.ChunkedEncodingError, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + requests.exceptions.Timeout) + + +def build_session(): + s = requests.Session() + s.headers["User-Agent"] = AGENT + if Retry: + retry = Retry(total=4, backoff_factor=1.0, + status_forcelist=(429, 500, 502, 503, 504)) + ad = HTTPAdapter(max_retries=retry, pool_maxsize=16) + s.mount("http://", ad) + s.mount("https://", ad) + return s + + +def call(sess, base, user, pwd, action=None, **extra): + q = {"username": user, "password": pwd} + if action: + q["action"] = action + q.update(extra) + r = sess.get(base + "/player_api.php", params=q, timeout=(15, 180)) + r.raise_for_status() + try: + return r.json() + except ValueError: + return None + + +def catalog(sess, base, user, pwd, kind, outdir, refresh=False): + action = "get_vod_streams" if kind == "movie" else "get_series" + cache = os.path.join(outdir, ".cache_%s.json" % kind) + if not refresh and os.path.exists(cache): + if time.time() - os.path.getmtime(cache) < CACHE_TTL: + with open(cache, encoding="utf-8") as fh: + return json.load(fh) + sys.stderr.write("Loading catalog...\n") + data = call(sess, base, user, pwd, action) or [] + os.makedirs(outdir, exist_ok=True) + with open(cache, "w", encoding="utf-8") as fh: + json.dump(data, fh) + return data + + +def find(items, term): + term = term.lower().strip() + return [it for it in items if term in str(it.get("name", "")).lower()] + + +def safe(name): + name = re.sub(r'[\\/:*?"<>|]', " ", str(name)) + return re.sub(r"\s+", " ", name).strip()[:180] or "untitled" + + +def human(n): + n = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return "%.1f %s" % (n, unit) + n /= 1024 + + +def media_url(base, user, pwd, kind, sid, ext): + path = "movie" if kind == "movie" else "series" + return "%s/%s/%s/%s/%s.%s" % (base, path, user, pwd, sid, ext) + + +def download(sess, url, dest, tries=12): + os.makedirs(os.path.dirname(dest), exist_ok=True) + if os.path.exists(dest) and os.path.getsize(dest) > 0: + sys.stderr.write(" already there, skipping: %s\n" % os.path.basename(dest)) + return True + part = dest + ".part" + start = time.time() + last = 0.0 + for attempt in range(1, tries + 1): + pos = os.path.getsize(part) if os.path.exists(part) else 0 + headers = {"Range": "bytes=%d-" % pos} if pos else {} + try: + r = sess.get(url, stream=True, headers=headers, timeout=(15, 120), + allow_redirects=True) + if r.status_code not in (200, 206): + sys.stderr.write(" failed (HTTP %s)\n" % r.status_code) + return False + if pos and r.status_code == 200: + pos = 0 + total = int(r.headers.get("Content-Length", 0)) + if r.status_code == 206: + total += pos + mode = "ab" if (pos and r.status_code == 206) else "wb" + done = pos if mode == "ab" else 0 + with open(part, mode) as fh: + for chunk in r.iter_content(CHUNK): + if not chunk: + continue + fh.write(chunk) + done += len(chunk) + now = time.time() + if now - last >= 0.5: + last = now + spd = done / max(now - start, 0.001) + if total: + sys.stderr.write("\r %5.1f%% %s / %s %s/s " + % (done * 100.0 / total, human(done), + human(total), human(spd))) + else: + sys.stderr.write("\r %s %s/s " % (human(done), human(spd))) + sys.stderr.flush() + if total and done < total: + sys.stderr.write("\n stream ended early, resuming...\n") + continue + sys.stderr.write("\n") + os.replace(part, dest) + return True + except NET_ERRORS: + sys.stderr.write("\n connection dropped, resuming (%d/%d)...\n" % (attempt, tries)) + time.sleep(min(2 * attempt, 15)) + sys.stderr.write(" gave up after %d tries (partial file kept).\n" % tries) + return False + + +def pull_movie(sess, base, user, pwd, item, outdir): + sid = item.get("stream_id") + ext = item.get("container_extension") or "mp4" + name = safe(item.get("name")) + dest = os.path.join(outdir, "Movies", "%s.%s" % (name, ext)) + sys.stderr.write("Movie: %s\n" % name) + return download(sess, media_url(base, user, pwd, "movie", sid, ext), dest) + + +def pull_series(sess, base, user, pwd, item, outdir, season_filter=None): + sid = item.get("series_id") + show = safe(item.get("name")) + info = call(sess, base, user, pwd, "get_series_info", series_id=sid) or {} + seasons = info.get("episodes") + if not isinstance(seasons, dict): + sys.stderr.write(" no episodes.\n") + return + total = ok = 0 + for season, eps in sorted(seasons.items(), key=lambda kv: str(kv[0])): + try: + sn = int(season) + except (TypeError, ValueError): + sn = 0 + if season_filter is not None and sn != season_filter: + continue + if not isinstance(eps, list): + continue + for ep in eps: + eid = ep.get("id") + if eid is None: + continue + ext = ep.get("container_extension") or "mp4" + try: + en = int(ep.get("episode_num") or 0) + except (TypeError, ValueError): + en = 0 + tag = "S%02dE%02d" % (sn, en) + dest = os.path.join(outdir, "Series", show, "Season %02d" % sn, + "%s %s.%s" % (show, tag, ext)) + total += 1 + sys.stderr.write("[%s] %s\n" % (tag, show)) + if download(sess, media_url(base, user, pwd, "series", eid, ext), dest): + ok += 1 + sys.stderr.write("Series: %d/%d episodes.\n" % (ok, total)) + + +def show_list(results, kind, limit): + for i, it in enumerate(results[:limit], 1): + extra = " [%s]" % (it.get("container_extension") or "?") if kind == "movie" else "" + print("%3d. %s%s" % (i, str(it.get("name", "")).strip(), extra)) + if len(results) > limit: + print("... (+%d more, use --limit)" % (len(results) - limit)) + + +def normalize(server): + server = server.strip() + if not server.startswith("http://") and not server.startswith("https://"): + server = "http://" + server + return server.rstrip("/") + + +def prompt(value, text, secret=False): + if value: + return value + return getpass(text) if secret else input(text).strip() + + +def interactive(sess, base, user, pwd, outdir): + while True: + kind = input("\nType [movie/series] (enter=quit): ").strip().lower() + if not kind: + return + if kind not in ("movie", "series"): + print("bad type") + continue + term = input("Search: ").strip() + if not term: + continue + results = find(catalog(sess, base, user, pwd, kind, outdir), term) + if not results: + print("nothing found.") + continue + show_list(results, kind, 30) + pick = input("Number to download (enter=back): ").strip() + if not pick.isdigit(): + continue + idx = int(pick) - 1 + if not (0 <= idx < len(results)): + continue + if kind == "movie": + pull_movie(sess, base, user, pwd, results[idx], outdir) + else: + season = input("Only one season? (enter=all): ").strip() + sf = int(season) if season.isdigit() else None + pull_series(sess, base, user, pwd, results[idx], outdir, sf) + + +def main(): + ap = argparse.ArgumentParser(description="Search and download VOD from an Xtream Codes server.") + ap.add_argument("-s", "--server") + ap.add_argument("-u", "--username") + ap.add_argument("-p", "--password") + ap.add_argument("-T", "--type", choices=["movie", "series"]) + ap.add_argument("-S", "--search") + ap.add_argument("--pick", type=int) + ap.add_argument("--season", type=int) + ap.add_argument("--limit", type=int, default=30) + ap.add_argument("--refresh", action="store_true") + ap.add_argument("-o", "--out", default=".") + a = ap.parse_args() + + base = normalize(prompt(a.server, "Server (host:port): ")) + user = prompt(a.username, "Username: ") + pwd = prompt(a.password, "Password: ", secret=True) + outdir = a.out + + sess = build_session() + info = call(sess, base, user, pwd) or {} + if str(info.get("user_info", {}).get("auth")) != "1": + print("Login failed. Check the server, username and password.", file=sys.stderr) + return 1 + + if not (a.type and a.search): + interactive(sess, base, user, pwd, outdir) + return 0 + + results = find(catalog(sess, base, user, pwd, a.type, outdir, a.refresh), a.search) + if not results: + print("nothing found.") + return 1 + if not a.pick: + show_list(results, a.type, a.limit) + print("\nUse --pick N to download one.") + return 0 + idx = a.pick - 1 + if not (0 <= idx < len(results)): + print("bad number.") + return 1 + if a.type == "movie": + pull_movie(sess, base, user, pwd, results[idx], outdir) + else: + pull_series(sess, base, user, pwd, results[idx], outdir, a.season) + return 0 + + +if __name__ == "__main__": + sys.exit(main())