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())