#!/usr/bin/env python3 """ Teletext TUI for api.teletext.ch """ import argparse import base64 import curses import textwrap import time from dataclasses import dataclass from typing import List, Optional, Tuple import requests API_BASE = "https://api.teletext.ch" CHANNELS_DEFAULT = ["srf1", "srfzwei", "srfinfo"] # ───────── Models ───────── @dataclass class Subpage: subpageNumber: int contentText: str ep1Info: Optional[dict] = None @dataclass class PageData: channel: str pageNumber: int nextPage: Optional[int] subpages: List[Subpage] previousPage: Optional[int] = None # ───────── Fetch ───────── def fetch_page(channel: str, page: int, timeout: float = 6.0) -> PageData: """Fetch a Teletext page JSON from the API.""" url = f"{API_BASE}/channels/{channel}/pages/{page}" r = requests.get(url, timeout=timeout, headers={"Accept": "application/json"}) r.raise_for_status() j = r.json() subs: List[Subpage] = [ Subpage( subpageNumber=sp.get("subpageNumber", 0), contentText=sp.get("contentText") or "", ep1Info=sp.get("ep1Info"), ) for sp in j.get("subpages", []) or [] ] if not subs: subs = [Subpage(0, "", None)] return PageData( channel=j.get("channel", channel), pageNumber=j.get("pageNumber", page), nextPage=j.get("nextPage"), subpages=subs, previousPage=j.get("previousPage"), ) # ───────── Teletext decode ───────── GERMAN_NATIONAL_MAP = { 0x5B: "Ä", 0x5C: "Ö", 0x5D: "Ü", 0x7B: "ä", 0x7C: "ö", 0x7D: "ü", 0x7E: "ß", } def alpha_char(b: int) -> str: """Map teletext character codes to Unicode.""" return GERMAN_NATIONAL_MAP.get(b, chr(b)) def ep1_rows_text_only(ep1_info: dict) -> List[List[Tuple[str, int, int, bool]]]: """Decode EP1-format data into rows of (text, fg, bg, bold) segments.""" ep1 = ep1_info.get("data", {}).get("ep1Format", {}) header_b = base64.b64decode(ep1.get("header", "") or b"") content_b = base64.b64decode(ep1.get("content", "") or b"") def render_row(bs: bytes) -> List[Tuple[str, int, int, bool]]: alpha = True fg, bg = 7, -1 double = False segs: List[Tuple[str, int, int, bool]] = [] buf: List[str] = [] def flush(): nonlocal buf if buf: segs.append(("".join(buf), fg, bg, double)) buf = [] i = 0 while i < len(bs): b = bs[i] if b < 0x20: if 0x00 <= b <= 0x07: flush() fg = b alpha = True elif b == 0x0C: flush() double = False elif b == 0x0D: flush() double = True elif b == 0x0F: flush() alpha = False elif 0x10 <= b <= 0x17: flush() fg = b - 0x10 alpha = False elif b == 0x1C: flush() bg = -1 elif b == 0x1D: flush() bg = fg elif b == 0x1F: alpha = True i += 1 continue buf.append(alpha_char(b) if alpha else " ") i += 1 flush() total = sum(len(s) for s, _, _, _ in segs) if total < 40: segs.append((" " * (40 - total), fg, bg, double)) elif total > 40: over = total - 40 s, cf, cb, db = segs[-1] segs[-1] = (s[:-over], cf, cb, db) return segs rows = [] if len(header_b) >= 40: rows.append(render_row(header_b[:40])) for i in range(0, len(content_b), 40): chunk = content_b[i : i + 40] if not chunk: break rows.append(render_row(chunk)) def is_blank(row): return "".join(s for s, _, _, _ in row).strip() == "" while rows and is_blank(rows[-1]): rows.pop() return rows # ───────── UI helpers ───────── def wrap_plain(text: str, width: int) -> List[str]: """Wrap plain text into lines without breaking whitespace.""" out = [] for line in text.splitlines(): out.extend( textwrap.wrap( line, width=width, replace_whitespace=False, drop_whitespace=False ) or [""] ) return out def draw_bar(stdscr, msg, row, width, pair=0): """Draw a single-line colored bar.""" msg = (msg[: width - 1]).ljust(width - 1) if pair: stdscr.attron(curses.color_pair(pair)) stdscr.addstr(row, 0, msg) if pair: stdscr.attroff(curses.color_pair(pair)) # ───────── TUI ───────── def run_tui(initial_channel: str, initial_page: int, channels: List[str]): def main(stdscr): curses.curs_set(0) stdscr.nodelay(True) stdscr.timeout(100) curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) # header curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE) # footer curses.init_pair(3, curses.COLOR_RED, -1) # error pair_base = 20 pair_idx = {} palette = [ curses.COLOR_BLACK, curses.COLOR_RED, curses.COLOR_GREEN, curses.COLOR_YELLOW, curses.COLOR_BLUE, curses.COLOR_MAGENTA, curses.COLOR_CYAN, curses.COLOR_WHITE, ] def pair_for(fg: int, bg: int) -> int: key = (fg, bg) if key in pair_idx: return pair_idx[key] pid = pair_base + len(pair_idx) curses.init_pair( pid, palette[max(0, min(7, fg))], -1 if bg == -1 else palette[max(0, min(7, bg))], ) pair_idx[key] = pid return pid channel_idx = next( (i for i, c in enumerate(channels) if c == initial_channel), 0 ) channel = channels[channel_idx] page = initial_page sub_idx = 0 input_buf = "" status = "Ready." last_status_at = time.time() try: pd = fetch_page(channel, page) status = f"Loaded {channel}:{page}" except Exception as e: pd = PageData(channel, page, None, [Subpage(0, f"Error: {e}", None)]) status = f"Error loading {channel}:{page} – {e}" while True: stdscr.erase() h, w = stdscr.getmaxyx() if h < 8 or w < 42: stdscr.addstr(0, 0, "Please resize terminal (>= 42x8).") stdscr.refresh() time.sleep(0.1) continue header = ( f" Teletext {pd.channel} Page {pd.pageNumber} " f"Sub {min(sub_idx + 1, len(pd.subpages))}/{max(1, len(pd.subpages))} " "[q] quit [0..9 Enter] goto [h/l] prev/next [c] channel [r] reload" ) draw_bar(stdscr, header, 0, w, 1) body_h, body_w = h - 3, w sp = pd.subpages[min(sub_idx, len(pd.subpages) - 1)] if sp.contentText: for i, line in enumerate(wrap_plain(sp.contentText, body_w)[:body_h]): stdscr.addnstr(1 + i, 0, line, body_w - 1) elif sp.ep1Info: rows = ep1_rows_text_only(sp.ep1Info) r_i = 0 for row in rows: if r_i >= body_h: break col = 0 is_double = any(b for _, _, _, b in row) for txt, fg, bg, bold in row: if col >= body_w - 1: break chunk = txt[: max(0, body_w - 1 - col)] attrs = curses.color_pair(pair_for(fg, bg)) | ( curses.A_BOLD if bold else 0 ) stdscr.addstr(1 + r_i, col, chunk, attrs) col += len(chunk) r_i += 1 if is_double and r_i < body_h: stdscr.addstr(1 + r_i, 0, " " * min(40, body_w - 1)) r_i += 1 else: stdscr.addstr(1, 0, "(empty)") draw_bar(stdscr, f"Go to page: {input_buf}", h - 2, w, 2) if time.time() - last_status_at > 3: status = "" draw_bar( stdscr, status, h - 1, w, 0 if not status.startswith("Error") else 3 ) stdscr.refresh() ch = stdscr.getch() if ch == -1: continue if ch in (ord("q"), ord("Q")): break if ch in (ord("h"), curses.KEY_LEFT): if sub_idx > 0: sub_idx -= 1 else: if isinstance(pd.previousPage, int): page = pd.previousPage sub_idx = 0 try: pd = fetch_page(channel, page) status = f"Loaded {channel}:{page}" except Exception as e: status = f"Error loading {channel}:{page} – {e}" last_status_at = time.time() continue if ch in (ord("l"), curses.KEY_RIGHT): if sub_idx + 1 < len(pd.subpages): sub_idx += 1 else: if isinstance(pd.nextPage, int): page = pd.nextPage sub_idx = 0 try: pd = fetch_page(channel, page) status = f"Loaded {channel}:{page}" except Exception as e: status = f"Error loading {channel}:{page} – {e}" last_status_at = time.time() continue if ch in (ord("c"), ord("C")): channel_idx = (channel_idx + 1) % len(channels) channel = channels[channel_idx] sub_idx = 0 try: pd = fetch_page(channel, page) status = f"Channel {channel}" except Exception as e: status = f"Error loading {channel}:{page} – {e}" last_status_at = time.time() continue if ch in (ord("r"), ord("R")): try: pd = fetch_page(channel, page) sub_idx = min(sub_idx, len(pd.subpages) - 1) status = f"Reloaded {channel}:{page}" except Exception as e: status = f"Error reloading {channel}:{page} – {e}" last_status_at = time.time() continue if ch in (curses.KEY_BACKSPACE, 127, 8): input_buf = input_buf[:-1] continue if ch in (curses.KEY_ENTER, 10, 13): if input_buf.strip().isdigit(): page = int(input_buf) sub_idx = 0 try: pd = fetch_page(channel, page) status = f"Loaded {channel}:{page}" except Exception as e: status = f"Error loading {channel}:{page} – {e}" last_status_at = time.time() input_buf = "" continue if 48 <= ch <= 57 and len(input_buf) < 4: input_buf += chr(ch) curses.wrapper(main) # ───────── CLI ───────── def parse_args(): ap = argparse.ArgumentParser(description="Teletext TUI for api.teletext.ch") ap.add_argument("--channel", default="srf1") ap.add_argument("--page", type=int, default=100) return ap.parse_args() def main(): args = parse_args() run_tui(args.channel, args.page, CHANNELS_DEFAULT) if __name__ == "__main__": main()