code.pablomurad.com

xtrakt-iptv

Pulls a full M3U playlist from an Xtream Codes or XUI panel

add vod downloader
author
Pablo Murad
date
commit
18466cf16a52c6f8d2eeaffcb6bf75bb46546237
parent
d323fb3cfda9c707a2fa26fcd225cf655cab7797
changes
2 files +315 −0
@@ -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)
@@ -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())