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