#!/usr/bin/env python3 """TiviGrid Recorder — an always-on companion that records scheduled IPTV programmes. iOS cannot start a live recording while the app is closed, so the TiviGrid app sends timers here. The recorder opens the provider stream at the scheduled time, writes it to disk (MPEG-TS) and serves the files back to the app. Runs on anything with Python 3.9+: a Mac, a Windows PC, a NAS, a Raspberry Pi or a VPS. No third-party packages are needed. python3 xtreamtv_recorder.py --data ./recorder-data --port 8765 On first start an access token is generated and printed; enter the address and token in TiviGrid › Settings › Recording Server, scan the pairing QR code, or open the dashboard at http://:8765/ on the phone and tap "Open in TiviGrid". python3 xtreamtv_recorder.py --data ./recorder-data --pairing # print address, token, link, QR """ import argparse import atexit import http.client import json import os import re import secrets import shutil import signal import socket import subprocess import sys import threading import time import urllib.error import urllib.parse import urllib.request import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer VERSION = "1.1" USER_AGENT = "TiviGrid-Recorder/" + VERSION CHUNK = 64 * 1024 READ_TIMEOUT = 25 # seconds without data before reconnecting BONJOUR_TYPE = "_xtreamtv._tcp" # what the app browses for; TXT record "v=1" PUBLIC_FIELDS = ("id", "title", "summary", "channel_id", "channel_name", "channel_icon", "program_start", "start", "end", "status", "bytes", "failure", "created", "started_at", "finished_at", "interrupted") def log(message): print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True) class Store: """Recordings persisted as JSON. Every mutation is written atomically.""" def __init__(self, root): self.root = root self.media_root = os.path.join(root, "recordings") os.makedirs(self.media_root, exist_ok=True) self.path = os.path.join(root, "recordings.json") self.lock = threading.RLock() self.items = {} if os.path.exists(self.path): with open(self.path, "r", encoding="utf-8") as f: for item in json.load(f): self.items[item["id"]] = item def save(self): with self.lock: tmp = self.path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(list(self.items.values()), f, indent=1) os.replace(tmp, self.path) def get(self, rid): with self.lock: item = self.items.get(rid) return dict(item) if item else None def all(self): with self.lock: return [dict(i) for i in self.items.values()] def put(self, item): with self.lock: self.items[item["id"]] = item self.save() def update(self, rid, **changes): with self.lock: item = self.items.get(rid) if item is None: return None item.update(changes) self.save() return dict(item) def remove(self, rid): with self.lock: self.items.pop(rid, None) self.save() shutil.rmtree(self.folder(rid), ignore_errors=True) def folder(self, rid): return os.path.join(self.media_root, rid) def media(self, rid): return os.path.join(self.folder(rid), "media.ts") def index_path(self, rid): return os.path.join(self.folder(rid), "index.json") def load_index(self, rid): try: with open(self.index_path(rid), "r", encoding="utf-8") as f: return json.load(f) except (OSError, ValueError): return None def save_index(self, rid, segments, complete): path = self.index_path(rid) if not os.path.isdir(os.path.dirname(path)): return tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump({"segments": segments, "complete": complete}, f) os.replace(tmp, path) def public(item): out = {k: item.get(k) for k in PUBLIC_FIELDS} return out # ---------------------------------------------------------------------------------------------- # HLS indexing: a port of XtreamKit's TSSegmenter. Recordings are stored as one MPEG-TS file and # served as an HLS playlist of byte ranges cut at keyframes, so AVPlayer can stream and seek them. class TSSegmenter: PACKET = 188 WRAP = 1 << 33 CLOCK = 90000 VIDEO_TYPES = {0x01, 0x02, 0x10, 0x1B, 0x24, 0x42, 0xEA} AUDIO_TYPES = {0x03, 0x04, 0x0F, 0x11, 0x81, 0x87} def __init__(self, target=6.0): self.target = target self.segments = [] # [offset, length, duration, discontinuity] self.processed = 0 self.carry = b"" self.carry_offset = 0 self.pmt_pid = None self.video_pid = None self.audio_pid = None self.hevc = False self.last_pat = -1 self.seg_start = 0 self.seg_pts = None self.seg_max = 0 self.last_pts = None self.frame = 3600 self.pending_disc = False self.next_disc = False self.finished = False def feed(self, data): if not data or self.finished: return if self.carry: buf = self.carry + data base = self.carry_offset else: buf = data base = self.processed self.processed += len(data) consumed = self._process(buf, base) self.carry = bytes(buf[consumed:]) self.carry_offset = base + consumed def mark_discontinuity(self): if self.seg_pts is not None: self.pending_disc = True def finish(self): if self.finished: return self.finished = True if self.seg_pts is None: return length = self.processed - self.seg_start duration = (self.seg_max + self.frame) / self.CLOCK if length > 0 and duration > 0: self.segments.append([self.seg_start, length, duration, self.next_disc]) self.seg_pts = None def _process(self, buf, base): n = len(buf) size = self.PACKET i = 0 while i + size <= n: if buf[i] != 0x47: j = i + 1 found = -1 while j + size < n: if buf[j] == 0x47 and buf[j + size] == 0x47: found = j break j += 1 if found < 0: return max(i, n - size) i = found continue b1 = buf[i + 1] pid = ((b1 & 0x1F) << 8) | buf[i + 2] if pid == 0 or pid == self.pmt_pid or (b1 & 0x40 and pid == (self.video_pid if self.video_pid is not None else self.audio_pid)): self._packet(buf[i:i + size], base + i) i += size return i def _packet(self, p, offset): pusi = p[1] & 0x40 pid = ((p[1] & 0x1F) << 8) | p[2] afc = (p[3] >> 4) & 3 start = 4 rai = False if afc & 2: af_len = p[4] if af_len > 0: rai = bool(p[5] & 0x40) start = 5 + af_len if not afc & 1 or start >= len(p): return if pid == 0: if pusi: self.last_pat = offset self._pat(p, start) return if pid == self.pmt_pid: if pusi: self._pmt(p, start) return if not pusi: return pes = self._pes(p, start) if pes is None: return pts, es = pes key = (rai or self._keyframe(p, es)) if self.video_pid is not None else True self._timing(pts, key, offset) def _pat(self, p, s): t = s + 1 + p[s] if t + 8 >= len(p) or p[t] != 0: return length = ((p[t + 1] & 0x0F) << 8) | p[t + 2] end = min(t + 3 + length - 4, len(p)) i = t + 8 while i + 4 <= end: program = (p[i] << 8) | p[i + 1] if program != 0: self.pmt_pid = ((p[i + 2] & 0x1F) << 8) | p[i + 3] return i += 4 def _pmt(self, p, s): t = s + 1 + p[s] if t + 12 >= len(p) or p[t] != 2: return length = ((p[t + 1] & 0x0F) << 8) | p[t + 2] info = ((p[t + 10] & 0x0F) << 8) | p[t + 11] end = min(t + 3 + length - 4, len(p)) i = t + 12 + info video = audio = None hevc = False while i + 5 <= end: stype = p[i] epid = ((p[i + 1] & 0x1F) << 8) | p[i + 2] es_len = ((p[i + 3] & 0x0F) << 8) | p[i + 4] if stype in self.VIDEO_TYPES and video is None: video, hevc = epid, stype == 0x24 elif stype in self.AUDIO_TYPES and audio is None: audio = epid i += 5 + es_len if video is not None or audio is not None: self.video_pid, self.audio_pid, self.hevc = video, audio, hevc @staticmethod def _pes(p, s): if s + 14 > len(p) or p[s] != 0 or p[s + 1] != 0 or p[s + 2] != 1 or not p[s + 7] & 0x80: return None pts = (((p[s + 9] >> 1) & 7) << 30) | (p[s + 10] << 22) | ((p[s + 11] >> 1) << 15) | (p[s + 12] << 7) | (p[s + 13] >> 1) return pts, s + 9 + p[s + 8] def _keyframe(self, p, i): n = len(p) while i + 3 < n: if p[i] == 0 and p[i + 1] == 0 and p[i + 2] == 1: h = p[i + 3] if self.hevc: t = (h >> 1) & 0x3F if 16 <= t <= 21 or t in (32, 33): return True else: if (h & 0x1F) in (5, 7) or h in (0xB3, 0xB8): return True i += 3 else: i += 1 return False def _delta(self, a, b): d = (a - b) % self.WRAP if d > self.WRAP // 2: d -= self.WRAP return d def _cut(self, offset, minimum): if minimum <= self.last_pat <= offset and offset - self.last_pat <= 64 * self.PACKET: return self.last_pat return offset def _timing(self, pts, key, offset): if self.last_pts is not None: step = self._delta(pts, self.last_pts) if step < -2 * self.CLOCK or step > 10 * self.CLOCK: if self.seg_pts is not None: self.pending_disc = True elif 0 < step < self.CLOCK: self.frame = step self.last_pts = pts if self.seg_pts is None: if key: self.seg_start = self._cut(offset, 0) self.seg_pts = pts self.seg_max = 0 return d = self._delta(pts, self.seg_pts) if not self.pending_disc and d > self.seg_max: self.seg_max = d if not key: return if not self.pending_disc and d / self.CLOCK < self.target: return cut = self._cut(offset, self.seg_start + 1) duration = (self.seg_max + self.frame) / self.CLOCK if self.pending_disc else d / self.CLOCK if cut > self.seg_start and duration > 0: self.segments.append([self.seg_start, cut - self.seg_start, duration, self.next_disc]) self.next_disc = self.pending_disc elif self.pending_disc: self.next_disc = True self.pending_disc = False self.seg_start = cut self.seg_pts = pts self.seg_max = 0 def index_file(path, target=6.0): seg = TSSegmenter(target) with open(path, "rb") as f: while True: chunk = f.read(4 * 1024 * 1024) if not chunk: break seg.feed(chunk) seg.finish() return seg def build_playlist(segments, media_uri, complete): longest = max((s[2] for s in segments), default=0) target = max(1, int(-(-longest // 1))) if complete else max(12, int(-(-longest // 1))) lines = ["#EXTM3U", "#EXT-X-VERSION:4", f"#EXT-X-TARGETDURATION:{target}", "#EXT-X-MEDIA-SEQUENCE:0", f"#EXT-X-PLAYLIST-TYPE:{'VOD' if complete else 'EVENT'}"] for n, (offset, length, duration, disc) in enumerate(segments): if disc and n > 0: lines.append("#EXT-X-DISCONTINUITY") lines.append(f"#EXTINF:{duration:.3f},") lines.append(f"#EXT-X-BYTERANGE:{length}@{offset}") lines.append(media_uri) if complete: lines.append("#EXT-X-ENDLIST") return "\n".join(lines) + "\n" # ---------------------------------------------------------------------------------------------- # Recording class StreamError(Exception): pass def open_url(url, timeout=20): request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: response = urllib.request.urlopen(request, timeout=timeout) except urllib.error.HTTPError as exc: raise StreamError(f"HTTP {exc.code}") from exc except (urllib.error.URLError, OSError, http.client.HTTPException) as exc: raise StreamError(str(getattr(exc, "reason", exc))) from exc ctype = (response.headers.get("Content-Type") or "").lower() return response, ctype def stream_urls(item): p = item["provider"] base = p["server"].rstrip("/") user = urllib.parse.quote(p["username"], safe="") password = urllib.parse.quote(p["password"], safe="") cid = item["channel_id"] return f"{base}/live/{user}/{password}/{cid}.ts", f"{base}/live/{user}/{password}/{cid}.m3u8" class Recorder(threading.Thread): def __init__(self, store, rid): super().__init__(daemon=True, name=f"rec-{rid[:8]}") self.store = store self.rid = rid self.stop_event = threading.Event() self.bytes = 0 self.last_error = None self.segmenter = TSSegmenter() self.index_lock = threading.Lock() self.last_index_save = 0.0 def snapshot(self): """Segments recorded so far, for a growing (EVENT) playlist.""" with self.index_lock: return [list(seg) for seg in self.segmenter.segments] def stop(self): self.stop_event.set() def finished(self, item): return self.stop_event.is_set() or time.time() >= item["end"] def run(self): item = self.store.get(self.rid) path = self.store.media(self.rid) os.makedirs(os.path.dirname(path), exist_ok=True) self.bytes = os.path.getsize(path) if os.path.exists(path) else 0 if self.bytes: # Resuming after a restart: index what is already on disk, then continue the timeline. self.segmenter = index_file(path) self.segmenter.finished = False self.segmenter.seg_pts = None self.segmenter.carry = b"" self.segmenter.next_disc = True formats = [f.lower() for f in item["provider"].get("formats") or []] allow_ts = not formats or "ts" in formats allow_hls = not formats or "m3u8" in formats ts_url, hls_url = stream_urls(item) mode = "ts" if allow_ts else "hls" failures = 0 log(f"recording started: {item['title']} ({item['channel_name']}) until {time.ctime(item['end'])}") with open(path, "ab") as out: while not self.finished(item): before = self.bytes try: if mode == "ts": self.record_ts(ts_url, out, item) else: self.record_hls(hls_url, out, item) except StreamError as exc: self.last_error = str(exc) except Exception as exc: # noqa: BLE001 - never let one recording kill the thread silently self.last_error = f"{type(exc).__name__}: {exc}" if self.finished(item): break failures = 0 if self.bytes > before else failures + 1 with self.index_lock: self.segmenter.mark_discontinuity() if self.bytes == 0 and failures >= 2 and mode == "ts" and allow_hls: log(f"switching {item['title']} to HLS after: {self.last_error}") mode = "hls" failures = 0 continue self.store.update(self.rid, bytes=self.bytes) self.stop_event.wait(min(10, 1 + failures * 2)) with self.index_lock: self.segmenter.finish() segments = [list(seg) for seg in self.segmenter.segments] final = self.store.get(self.rid) if final is not None: self.store.save_index(self.rid, segments, True) if final is None: # deleted while recording shutil.rmtree(self.store.folder(self.rid), ignore_errors=True) return if self.bytes > 0: self.store.update(self.rid, status="completed", bytes=self.bytes, finished_at=time.time(), failure=None) log(f"recording finished: {item['title']} ({self.bytes / 1e6:.1f} MB)") else: self.store.update(self.rid, status="failed", finished_at=time.time(), failure=self.last_error or "No data was received from the provider.") log(f"recording failed: {item['title']}: {self.last_error}") def write(self, out, data): out.write(data) out.flush() with self.index_lock: self.segmenter.feed(data) if time.time() - self.last_index_save > 15: self.last_index_save = time.time() self.store.save_index(self.rid, [list(seg) for seg in self.segmenter.segments], False) self.bytes += len(data) if self.bytes // (20 * 1024 * 1024) != (self.bytes - len(data)) // (20 * 1024 * 1024): self.store.update(self.rid, bytes=self.bytes) def record_ts(self, url, out, item): response, ctype = open_url(url, timeout=READ_TIMEOUT) with response: if ctype.startswith("text/") or "json" in ctype: raise StreamError(f"provider returned {ctype}") while not self.finished(item): try: data = response.read1(CHUNK) if hasattr(response, "read1") else response.read(CHUNK) except (socket.timeout, TimeoutError) as exc: raise StreamError("stream stalled") from exc except (OSError, http.client.HTTPException) as exc: raise StreamError(str(exc)) from exc if not data: raise StreamError("provider closed the stream") self.write(out, data) def record_hls(self, url, out, item): playlist_url = url last_seq = None while not self.finished(item): response, _ = open_url(playlist_url) with response: text = response.read().decode("utf-8", "replace") final_url = response.geturl() if not text.startswith("#EXTM3U"): raise StreamError("invalid HLS playlist") lines = [l.strip() for l in text.splitlines() if l.strip()] if any(l.startswith("#EXT-X-STREAM-INF") for l in lines): best, best_bw = None, -1 for i, line in enumerate(lines): if line.startswith("#EXT-X-STREAM-INF"): m = re.search(r"BANDWIDTH=(\d+)", line) bw = int(m.group(1)) if m else 0 nxt = next((l for l in lines[i + 1:] if not l.startswith("#")), None) if nxt and bw > best_bw: best, best_bw = nxt, bw if not best: raise StreamError("empty master playlist") playlist_url = urllib.parse.urljoin(final_url, best) continue if any(l.startswith("#EXT-X-MAP") for l in lines): raise StreamError("fMP4 HLS is not supported") target, seq, segments = 6.0, 0, [] for line in lines: if line.startswith("#EXT-X-TARGETDURATION:"): target = float(line.split(":", 1)[1] or 6) elif line.startswith("#EXT-X-MEDIA-SEQUENCE:"): seq = int(line.split(":", 1)[1] or 0) elif not line.startswith("#"): segments.append((seq, line)) seq += 1 if last_seq is None: fresh = segments[-2:] elif segments and segments[-1][0] < last_seq: fresh = segments[-2:] # sequence restarted else: fresh = [s for s in segments if s[0] > last_seq] for number, uri in fresh: if self.finished(item): return seg_response, _ = open_url(urllib.parse.urljoin(final_url, uri)) with seg_response: self.write(out, seg_response.read()) last_seq = number self.stop_event.wait(max(1.0, min(target / 2, 5))) class Scheduler(threading.Thread): def __init__(self, store): super().__init__(daemon=True, name="scheduler") self.store = store self.active = {} self.indexing = set() self.lock = threading.Lock() def recover(self): """Resume or close recordings that were running when the recorder stopped.""" now = time.time() for item in self.store.all(): if item["status"] != "recording": continue if now < item["end"] - 30: log(f"resuming interrupted recording: {item['title']}") self.store.update(item["id"], status="scheduled", interrupted=True) else: size = os.path.getsize(self.store.media(item["id"])) if os.path.exists(self.store.media(item["id"])) else 0 self.store.update(item["id"], status="completed" if size else "failed", bytes=size, interrupted=True, failure=None if size else "The recorder stopped before any data was saved.") if size: self.ensure_index(item["id"]) def run(self): self.recover() while True: now = time.time() for item in self.store.all(): if item["status"] != "scheduled": continue if now >= item["end"]: self.store.update(item["id"], status="failed", failure="Missed: the recorder was not running at the scheduled time.") elif now >= item["start"]: self.start_recording(item["id"]) with self.lock: for rid in [r for r, t in self.active.items() if not t.is_alive()]: del self.active[rid] time.sleep(1) def start_recording(self, rid): with self.lock: if rid in self.active: return self.store.update(rid, status="recording", started_at=time.time()) recorder = Recorder(self.store, rid) self.active[rid] = recorder recorder.start() def stop_recording(self, rid): with self.lock: recorder = self.active.get(rid) if recorder: recorder.stop() return True return False def live_segments(self, rid): with self.lock: recorder = self.active.get(rid) if recorder is None or recorder.segmenter.finished or not recorder.is_alive(): return None return recorder.snapshot() def ensure_index(self, rid): """Builds a missing index in the background (e.g. for a recording interrupted by a restart).""" with self.lock: if rid in self.indexing: return self.indexing.add(rid) def work(): try: seg = index_file(self.store.media(rid)) self.store.save_index(rid, seg.segments, True) except OSError as exc: log(f"indexing failed for {rid}: {exc}") finally: with self.lock: self.indexing.discard(rid) threading.Thread(target=work, daemon=True, name=f"index-{rid[:8]}").start() def live_bytes(self, rid): with self.lock: recorder = self.active.get(rid) return recorder.bytes if recorder else None # ---------------------------------------------------------------------------------------------- # HTTP API def content_disposition(title): """A download header that works for any title: HTTP headers must be Latin-1, so non-Latin titles (Hebrew, Arabic…) go in the RFC 5987 `filename*` form with a plain ASCII fallback.""" name = re.sub(r"[^\w\- ]+", "", title).strip() or "recording" ascii_name = re.sub(r"[^A-Za-z0-9\- ]+", "", name).strip(" -") or "recording" return f"attachment; filename=\"{ascii_name}.ts\"; filename*=UTF-8''{urllib.parse.quote(name + '.ts')}" class RecorderHTTPServer(ThreadingHTTPServer): daemon_threads = True def handle_error(self, request, client_address): # Phones drop connections all the time (app in the background, network change): not an error. if isinstance(sys.exc_info()[1], (ConnectionResetError, BrokenPipeError, TimeoutError)): return super().handle_error(request, client_address) def make_handler(store, scheduler, token, public_url=None): class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "XtreamTVRecorder/" + VERSION def log_message(self, fmt, *args): if os.environ.get("RECORDER_VERBOSE"): sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) # -- helpers ---------------------------------------------------------------------------- def send_json(self, obj, status=200): body = json.dumps(obj).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def error(self, status, message): self.send_json({"error": message}, status) def send_dashboard(self): """The web dashboard. A static page with no data in it; it asks for the token and then calls the API like the app does, so it is served without authentication.""" body = DASHBOARD_HTML.encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-cache") self.send_header("X-Frame-Options", "DENY") self.send_header("Referrer-Policy", "no-referrer") self.end_headers() if self.command != "HEAD": self.wfile.write(body) def authorized(self, query): header = self.headers.get("Authorization", "") supplied = header[7:] if header.startswith("Bearer ") else query.get("token", "") return secrets.compare_digest(supplied, token) def read_json(self): length = int(self.headers.get("Content-Length") or 0) if length <= 0 or length > 1_000_000: raise ValueError("missing or oversized body") return json.loads(self.rfile.read(length)) def route(self): url = urllib.parse.urlparse(self.path) query = {k: v[0] for k, v in urllib.parse.parse_qs(url.query).items()} parts = [p for p in url.path.split("/") if p] return parts, query # -- verbs ------------------------------------------------------------------------------ def do_HEAD(self): self.do_GET() def do_GET(self): parts, query = self.route() if parts == ["api", "ping"]: return self.send_json({"ok": True, "version": VERSION}) if parts in ([], ["dashboard"]): return self.send_dashboard() if not self.authorized(query): return self.error(401, "invalid token") if parts == ["api", "pairing"]: address = server_address(self.server.server_address[1], public_url, self.headers.get("Host")) info = pairing_info(address, token) info["qr"] = ["".join("1" if dark else "0" for dark in row) for row in qr_matrix(info["link"])] return self.send_json(info) if parts == ["api", "info"]: usage = shutil.disk_usage(store.root) return self.send_json({"name": socket.gethostname(), "version": VERSION, "time": time.time(), "free_bytes": usage.free, "total_bytes": usage.total}) if parts == ["api", "recordings"]: items = [] for item in sorted(store.all(), key=lambda i: i["start"]): live = scheduler.live_bytes(item["id"]) if live is not None: item["bytes"] = live out = public(item) segments = scheduler.live_segments(item["id"]) if segments is None: index = store.load_index(item["id"]) segments = index["segments"] if index else [] out["duration"] = round(sum(seg[2] for seg in segments), 1) items.append(out) return self.send_json(items) if len(parts) == 4 and parts[:2] == ["api", "recordings"] and parts[3] == "media.ts": return self.send_media(parts[2]) if len(parts) == 4 and parts[:2] == ["api", "recordings"] and parts[3] == "index.m3u8": return self.send_playlist(parts[2], query.get("token", "")) return self.error(404, "not found") def do_POST(self): parts, query = self.route() if not self.authorized(query): return self.error(401, "invalid token") if parts == ["api", "recordings"]: try: return self.create(self.read_json()) except (ValueError, KeyError, TypeError) as exc: return self.error(400, f"invalid request: {exc}") if len(parts) == 4 and parts[:2] == ["api", "recordings"] and parts[3] == "stop": item = store.get(parts[2]) if not item: return self.error(404, "not found") if item["status"] == "recording": scheduler.stop_recording(item["id"]) return self.send_json(public(item)) return self.error(409, "not recording") return self.error(404, "not found") def do_DELETE(self): parts, query = self.route() if not self.authorized(query): return self.error(401, "invalid token") if len(parts) == 3 and parts[:2] == ["api", "recordings"]: item = store.get(parts[2]) if not item: return self.error(404, "not found") scheduler.stop_recording(item["id"]) store.remove(item["id"]) return self.send_json({"deleted": item["id"]}) return self.error(404, "not found") # -- actions ---------------------------------------------------------------------------- def create(self, body): now = time.time() start, end = float(body["start"]), float(body["end"]) if end <= start: raise ValueError("end must be after start") if end <= now: raise ValueError("end is in the past") if end - start > 12 * 3600: raise ValueError("recordings are limited to 12 hours") provider = body["provider"] for key in ("server", "username", "password"): if not str(provider.get(key, "")).strip(): raise ValueError(f"provider.{key} is required") item = { "id": str(uuid.uuid4()), "title": str(body.get("title") or body.get("channel_name") or "Recording")[:200], "summary": str(body.get("summary") or "")[:2000], "channel_id": int(body["channel_id"]), "channel_name": str(body.get("channel_name") or "")[:200], "channel_icon": body.get("channel_icon"), "program_start": body.get("program_start"), "start": max(start, now) if start < now else start, "end": end, "provider": {"server": str(provider["server"]).rstrip("/"), "username": str(provider["username"]), "password": str(provider["password"]), "formats": [str(f) for f in provider.get("formats") or []], "max_connections": provider.get("max_connections")}, "status": "scheduled", "bytes": 0, "failure": None, "created": now, "started_at": None, "finished_at": None, "interrupted": False, } # Warn about overlaps: providers often allow a single connection. overlapping = [i for i in store.all() if i["status"] in ("scheduled", "recording") and i["start"] < end and start < i["end"] and i["provider"]["server"] == item["provider"]["server"] and i["provider"]["username"] == item["provider"]["username"]] store.put(item) if item["start"] <= now: scheduler.start_recording(item["id"]) response = public(store.get(item["id"])) limit = provider.get("max_connections") if overlapping and isinstance(limit, int) and len(overlapping) + 1 > limit: response["warning"] = (f"This overlaps {len(overlapping)} other recording(s) and your provider allows " f"{limit} connection(s); one of them may fail.") log(f"timer added: {item['title']} {time.ctime(item['start'])} – {time.ctime(item['end'])}") return self.send_json(response, 201) def send_playlist(self, rid, query_token): """HLS playlist for streaming a recording (growing while it is still being recorded).""" item = store.get(rid) if not item or not os.path.exists(store.media(rid)): return self.error(404, "no media") live = scheduler.live_segments(rid) if live is not None: segments, complete = live, False else: index = store.load_index(rid) if not index or not index.get("complete"): scheduler.ensure_index(rid) self.send_response(503) self.send_header("Retry-After", "5") self.send_header("Content-Length", "0") self.end_headers() return segments, complete = index["segments"], True # Media requests carry the token in the query string because AVPlayer can't add headers. uri = "media.ts?token=" + urllib.parse.quote(query_token or token, safe="") body = build_playlist(segments, uri, complete).encode() self.send_response(200) self.send_header("Content-Type", "application/vnd.apple.mpegurl") self.send_header("Cache-Control", "no-cache") self.send_header("Content-Length", str(len(body))) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def send_media(self, rid): item = store.get(rid) path = store.media(rid) if not item or not os.path.exists(path): return self.error(404, "no media") size = os.path.getsize(path) start, end = 0, size - 1 status = 200 header = self.headers.get("Range") if header and header.startswith("bytes="): spec = header[6:].split(",")[0].strip() a, _, b = spec.partition("-") try: if a == "": start = max(0, size - int(b)) else: start = int(a) if b: end = min(int(b), size - 1) except ValueError: return self.error(400, "bad range") if start >= size: self.send_response(416) self.send_header("Content-Range", f"bytes */{size}") self.send_header("Content-Length", "0") self.end_headers() return status = 206 length = max(0, end - start + 1) self.send_response(status) self.send_header("Content-Type", "video/mp2t") self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(length)) if status == 206: self.send_header("Content-Range", f"bytes {start}-{end}/{size}") self.send_header("Content-Disposition", content_disposition(item["title"])) self.end_headers() if self.command == "HEAD": return try: with open(path, "rb") as f: f.seek(start) remaining = length while remaining > 0: data = f.read(min(1024 * 1024, remaining)) if not data: break self.wfile.write(data) remaining -= len(data) except (BrokenPipeError, ConnectionResetError): pass return Handler def load_token(root, override): config_path = os.path.join(root, "config.json") config = {} if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) if override: config["token"] = override if not config.get("token"): config["token"] = secrets.token_urlsafe(18) with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f) try: os.chmod(config_path, 0o600) except OSError: pass return config["token"] # ---------------------------------------------------------------------------------------------- # Network address, pairing link and Bonjour def lan_address(): """The address other devices on the network reach this computer at. Connecting a UDP socket sends nothing; it only makes the OS pick the outgoing interface. Falls back to the hostname's address, then to loopback when the computer is offline.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 80)) address = s.getsockname()[0] finally: s.close() if address and not address.startswith(("127.", "0.")): return address except OSError: pass try: address = socket.gethostbyname(socket.gethostname()) if address and not address.startswith(("127.", "0.")): return address except OSError: pass return "127.0.0.1" def local_addresses(): """Every IPv4 address of this computer worth showing (the LAN one first), e.g. also a Tailscale one.""" addresses = [lan_address()] try: for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): address = info[4][0] if not address.startswith(("127.", "0.", "169.254.")) and address not in addresses: addresses.append(address) except OSError: pass if len(addresses) > 1 and addresses[0] == "127.0.0.1": addresses.pop(0) return addresses def is_loopback_host(host): return host in ("localhost", "0.0.0.0", "::1", "::") or host.startswith("127.") or host.endswith(".localhost") def pairing_link(address, token): """The contract with the app: xtreamtv://recorder?address=&token=.""" query = urllib.parse.urlencode({"address": address, "token": token}, quote_via=urllib.parse.quote) return "xtreamtv://recorder?" + query def pairing_info(address, token): return {"address": address, "token": token, "link": pairing_link(address, token)} def server_address(port, public_url=None, host_header=None): """The base URL to hand to the phone: --public-url if set; else the host the request came in on (whoever opened the dashboard reached us there); else this computer's LAN address.""" if public_url: return public_url.rstrip("/") if host_header: try: hostname = urllib.parse.urlsplit("//" + host_header).hostname except ValueError: hostname = None if hostname and not is_loopback_host(hostname): return "http://" + host_header return f"http://{lan_address()}:{port}" class Bonjour: """Advertises the recorder on the local network (service type _xtreamtv._tcp, TXT v=1) so the app can find it without typing an address. Best effort: uses the system's `dns-sd` (macOS) or `avahi-publish` (Linux, package avahi-utils) when present and silently does nothing otherwise.""" def __init__(self, port): self.port = port self.process = None def command(self): name = "TiviGrid Recorder on " + (socket.gethostname().split(".")[0] or "server") if sys.platform == "darwin" and shutil.which("dns-sd"): return ["dns-sd", "-R", name, BONJOUR_TYPE, "local", str(self.port), "v=1"] if shutil.which("avahi-publish"): return ["avahi-publish", "-s", name, BONJOUR_TYPE, str(self.port), "v=1"] return None def start(self): command = self.command() if not command: return False try: self.process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError: return False atexit.register(self.stop) log(f"advertising {BONJOUR_TYPE} on port {self.port} via {command[0]}") return True def stop(self): if self.process and self.process.poll() is None: try: self.process.terminate() self.process.wait(timeout=3) except (OSError, subprocess.TimeoutExpired): pass # ---------------------------------------------------------------------------------------------- # QR code (byte mode, error correction M, versions 1-20) for the pairing link: printed in the terminal # and drawn by the dashboard, so the iPhone camera can open the app directly. Follows ISO/IEC 18004. _QR_ECC_PER_BLOCK = (0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26) _QR_BLOCKS = (0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16) _QR_MASKS = ( lambda x, y: (x + y) % 2 == 0, lambda x, y: y % 2 == 0, lambda x, y: x % 3 == 0, lambda x, y: (x + y) % 3 == 0, lambda x, y: (x // 3 + y // 2) % 2 == 0, lambda x, y: x * y % 2 + x * y % 3 == 0, lambda x, y: (x * y % 2 + x * y % 3) % 2 == 0, lambda x, y: ((x + y) % 2 + x * y % 3) % 2 == 0, ) def _qr_raw_modules(version): """Modules available for data + error correction once the function patterns are drawn.""" result = (16 * version + 128) * version + 64 if version >= 2: n = version // 7 + 2 result -= (25 * n - 10) * n - 55 if version >= 7: result -= 36 return result def _gf_mul(x, y): z = 0 for i in reversed(range(8)): z = (z << 1) ^ ((z >> 7) * 0x11D) z ^= ((y >> i) & 1) * x return z def _rs_divisor(degree): result = [0] * (degree - 1) + [1] root = 1 for _ in range(degree): for j in range(degree): result[j] = _gf_mul(result[j], root) if j + 1 < degree: result[j] ^= result[j + 1] root = _gf_mul(root, 0x02) return result def _rs_remainder(data, divisor): result = [0] * len(divisor) for b in data: factor = b ^ result.pop(0) result.append(0) for i, coefficient in enumerate(divisor): result[i] ^= _gf_mul(coefficient, factor) return result def _qr_codewords(data, version): capacity = _qr_raw_modules(version) // 8 - _QR_ECC_PER_BLOCK[version] * _QR_BLOCKS[version] bits = [] def put(value, length): bits.extend((value >> i) & 1 for i in reversed(range(length))) put(0b0100, 4) # byte mode put(len(data), 8 if version < 10 else 16) for b in data: put(b, 8) put(0, min(4, capacity * 8 - len(bits))) put(0, -len(bits) % 8) words = [int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)] pad = 0xEC while len(words) < capacity: words.append(pad) pad ^= 0xEC ^ 0x11 # Split into blocks, add Reed-Solomon error correction to each, then interleave. blocks_count, ecc_len = _QR_BLOCKS[version], _QR_ECC_PER_BLOCK[version] raw = _qr_raw_modules(version) // 8 short_blocks, short_len = blocks_count - raw % blocks_count, raw // blocks_count divisor = _rs_divisor(ecc_len) blocks, k = [], 0 for i in range(blocks_count): chunk = words[k:k + short_len - ecc_len + (0 if i < short_blocks else 1)] k += len(chunk) ecc = _rs_remainder(chunk, divisor) if i < short_blocks: chunk.append(0) # placeholder, skipped when interleaving blocks.append(chunk + ecc) out = [] for i in range(len(blocks[0])): for j, block in enumerate(blocks): if i != short_len - ecc_len or j >= short_blocks: out.append(block[i]) return out def _qr_penalty(grid): size = len(grid) score = 0 lines = ["".join("1" if v else "0" for v in row) for row in grid] lines += ["".join("1" if grid[y][x] else "0" for y in range(size)) for x in range(size)] for line in lines: for run in re.finditer(r"0{5,}|1{5,}", line): score += 3 + len(run.group()) - 5 padded = "0000" + line + "0000" score += 40 * (len(re.findall(r"(?=10111010000)", padded)) + len(re.findall(r"(?=00001011101)", padded))) for y in range(size - 1): for x in range(size - 1): if grid[y][x] == grid[y][x + 1] == grid[y + 1][x] == grid[y + 1][x + 1]: score += 3 dark = sum(map(sum, grid)) total = size * size score += 10 * max(0, (abs(dark * 20 - total * 10) + total - 1) // total - 1) # dark/light balance return score def qr_matrix(text): """QR code for `text` as rows of booleans (True = dark), without the quiet zone.""" data = text.encode("utf-8") for version in range(1, 21): capacity = _qr_raw_modules(version) // 8 - _QR_ECC_PER_BLOCK[version] * _QR_BLOCKS[version] if 4 + (8 if version < 10 else 16) + 8 * len(data) <= capacity * 8: break else: raise ValueError("text too long for a QR code") size = version * 4 + 17 grid = [[False] * size for _ in range(size)] reserved = [[False] * size for _ in range(size)] def fn(x, y, dark): grid[y][x] = dark reserved[y][x] = True for i in range(size): fn(6, i, i % 2 == 0) fn(i, 6, i % 2 == 0) for cx, cy in ((3, 3), (size - 4, 3), (3, size - 4)): for dy in range(-4, 5): for dx in range(-4, 5): if 0 <= cx + dx < size and 0 <= cy + dy < size: fn(cx + dx, cy + dy, max(abs(dx), abs(dy)) not in (2, 4)) if version > 1: count = version // 7 + 2 step = (version * 8 + count * 3 + 5) // (count * 4 - 4) * 2 positions = [6] + sorted(size - 7 - i * step for i in range(count - 1)) last = len(positions) - 1 for i, ax in enumerate(positions): for j, ay in enumerate(positions): if (i, j) in ((0, 0), (0, last), (last, 0)): continue for dy in range(-2, 3): for dx in range(-2, 3): fn(ax + dx, ay + dy, max(abs(dx), abs(dy)) != 1) def draw_format(mask): value = mask # error correction level M has format bits 00 rem = value for _ in range(10): rem = (rem << 1) ^ ((rem >> 9) * 0x537) bits = ((value << 10) | rem) ^ 0x5412 bit = [(bits >> i) & 1 == 1 for i in range(15)] for i in range(6): fn(8, i, bit[i]) fn(8, 7, bit[6]) fn(8, 8, bit[7]) fn(7, 8, bit[8]) for i in range(9, 15): fn(14 - i, 8, bit[i]) for i in range(8): fn(size - 1 - i, 8, bit[i]) for i in range(8, 15): fn(8, size - 15 + i, bit[i]) fn(8, size - 8, True) draw_format(0) # reserve the area; redrawn with the chosen mask if version >= 7: rem = version for _ in range(12): rem = (rem << 1) ^ ((rem >> 11) * 0x1F25) bits = (version << 12) | rem for i in range(18): a, b = size - 11 + i % 3, i // 3 fn(a, b, (bits >> i) & 1 == 1) fn(b, a, (bits >> i) & 1 == 1) codewords = _qr_codewords(data, version) i = 0 right = size - 1 while right >= 1: if right == 6: right = 5 for vertical in range(size): for j in range(2): x = right - j upward = ((right + 1) & 2) == 0 y = size - 1 - vertical if upward else vertical if not reserved[y][x] and i < len(codewords) * 8: grid[y][x] = (codewords[i >> 3] >> (7 - (i & 7))) & 1 == 1 i += 1 right -= 2 def masked(mask): rule = _QR_MASKS[mask] for y in range(size): for x in range(size): if not reserved[y][x] and rule(x, y): grid[y][x] = not grid[y][x] best, best_score = 0, None for mask in range(8): masked(mask) draw_format(mask) score = _qr_penalty(grid) if best_score is None or score < best_score: best, best_score = mask, score masked(mask) # XOR again to undo masked(best) draw_format(best) return grid def qr_terminal(grid, border=4): """The QR code drawn with half-block characters, black on white whatever the terminal theme.""" size = len(grid) dark = lambda x, y: 0 <= x < size and 0 <= y < size and grid[y][x] # noqa: E731 lines = [] for y in range(-border, size + border, 2): row = "" for x in range(-border, size + border): top, bottom = dark(x, y), dark(x, y + 1) row += "█" if top and bottom else "▀" if top else "▄" if bottom else " " lines.append("\x1b[30;47m" + row + "\x1b[0m") return "\n".join(lines) def print_pairing(port, token, public_url=None, qr=False): address = server_address(port, public_url) info = pairing_info(address, token) print(f"TiviGrid Recorder {VERSION}") print(f" Server address: {address}") if not public_url: for other in local_addresses(): if f"http://{other}:{port}" != address: print(f" Also reachable: http://{other}:{port}") print(f" Access token: {token}") print(f" Dashboard: {address}/?token={urllib.parse.quote(token, safe='')}") print(f" Pairing link: {info['link']}") if qr: print("\nScan with the iPhone camera to pair TiviGrid:\n") print(qr_terminal(qr_matrix(info["link"]))) sys.stdout.flush() # ---------------------------------------------------------------------------------------------- # Web dashboard served at / and /dashboard. Kept inline so the recorder stays a single file. It holds # no data: it asks for the token, then uses the same API as the app (provider credentials are never # returned by the API). hls.js is loaded from a CDN only for browsers without native HLS (not Safari). DASHBOARD_HTML = r""" TiviGrid Recorder

TiviGrid Recorder

Recording now 0

Upcoming 0

Recordings 0

Download
""" def main(): parser = argparse.ArgumentParser(description="TiviGrid Recorder") parser.add_argument("--data", default=os.environ.get("RECORDER_DATA", "./recorder-data"), help="folder for recordings and settings") parser.add_argument("--host", default=os.environ.get("RECORDER_HOST", "0.0.0.0")) parser.add_argument("--port", type=int, default=int(os.environ.get("RECORDER_PORT", "8765"))) parser.add_argument("--token", default=os.environ.get("RECORDER_TOKEN"), help="access token (default: generated once and saved)") parser.add_argument("--public-url", default=os.environ.get("RECORDER_PUBLIC_URL"), help="address the phone should use, e.g. http://192.168.1.20:8765 (Docker, Tailscale)") parser.add_argument("--no-mdns", action="store_true", default=bool(os.environ.get("RECORDER_NO_MDNS")), help="don't advertise the recorder on the local network (Bonjour)") parser.add_argument("--pairing", action="store_true", help="print the address, token and pairing link (and a QR code in a terminal), then exit") parser.add_argument("--qr", action="store_true", help="with --pairing: print the QR code even when not in a terminal") args = parser.parse_args() root = os.path.abspath(args.data) os.makedirs(root, exist_ok=True) token = load_token(root, args.token) if args.pairing: print_pairing(args.port, token, args.public_url, qr=args.qr or sys.stdout.isatty()) return store = Store(root) scheduler = Scheduler(store) scheduler.start() server = RecorderHTTPServer((args.host, args.port), make_handler(store, scheduler, token, args.public_url)) server.daemon_threads = True port = server.server_address[1] # systemd and launchd stop services with SIGTERM: exit cleanly so the Bonjour helper is stopped too. signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) if not args.no_mdns: Bonjour(port).start() address = server_address(port, args.public_url) log(f"TiviGrid Recorder {VERSION} — data in {root}") log(f"Server address: {address}") log(f"Access token: {token}") log(f"Dashboard: {address}/") log(f"Pairing link: {pairing_link(address, token)}") print(f"READY {port}", flush=True) try: server.serve_forever() except (KeyboardInterrupt, SystemExit): log("stopping") if __name__ == "__main__": main()