diff --git a/engine/fetch.py b/engine/fetch.py new file mode 100644 index 0000000..906a8b3 --- /dev/null +++ b/engine/fetch.py @@ -0,0 +1,133 @@ +""" +RSS feed fetching, Project Gutenberg parsing, and headline caching. +Depends on: config, sources, filter, terminal. +""" + +import re +import json +import pathlib +import urllib.request +from datetime import datetime + +import feedparser + +from engine import config +from engine.sources import FEEDS, POETRY_SOURCES +from engine.filter import strip_tags, skip +from engine.terminal import boot_ln + +# ─── SINGLE FEED ────────────────────────────────────────── +def fetch_feed(url): + try: + req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"}) + resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT) + return feedparser.parse(resp.read()) + except Exception: + return None + + +# ─── ALL RSS FEEDS ──────────────────────────────────────── +def fetch_all(): + items = [] + linked = failed = 0 + for src, url in FEEDS.items(): + feed = fetch_feed(url) + if feed is None or (feed.bozo and not feed.entries): + boot_ln(src, "DARK", False) + failed += 1 + continue + n = 0 + for e in feed.entries: + t = strip_tags(e.get("title", "")) + if not t or skip(t): + continue + pub = e.get("published_parsed") or e.get("updated_parsed") + try: + ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——" + except Exception: + ts = "——:——" + items.append((t, src, ts)) + n += 1 + if n: + boot_ln(src, f"LINKED [{n}]", True) + linked += 1 + else: + boot_ln(src, "EMPTY", False) + failed += 1 + return items, linked, failed + + +# ─── PROJECT GUTENBERG ──────────────────────────────────── +def _fetch_gutenberg(url, label): + """Download and parse stanzas/passages from a Project Gutenberg text.""" + try: + req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"}) + resp = urllib.request.urlopen(req, timeout=15) + text = resp.read().decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n') + # Strip PG boilerplate + m = re.search(r'\*\*\*\s*START OF[^\n]*\n', text) + if m: + text = text[m.end():] + m = re.search(r'\*\*\*\s*END OF', text) + if m: + text = text[:m.start()] + # Split on blank lines into stanzas/passages + blocks = re.split(r'\n{2,}', text.strip()) + items = [] + for blk in blocks: + blk = ' '.join(blk.split()) # flatten to one line + if len(blk) < 20 or len(blk) > 280: + continue + if blk.isupper(): # skip all-caps headers + continue + if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals + continue + items.append((blk, label, '')) + return items + except Exception: + return [] + + +def fetch_poetry(): + """Fetch all poetry/literature sources.""" + items = [] + linked = failed = 0 + for label, url in POETRY_SOURCES.items(): + stanzas = _fetch_gutenberg(url, label) + if stanzas: + boot_ln(label, f"LOADED [{len(stanzas)}]", True) + items.extend(stanzas) + linked += 1 + else: + boot_ln(label, "DARK", False) + failed += 1 + return items, linked, failed + + +# ─── CACHE ──────────────────────────────────────────────── +_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent + + +def _cache_path(): + return _CACHE_DIR / f".mainline_cache_{config.MODE}.json" + + +def load_cache(): + """Load cached items from disk if available.""" + p = _cache_path() + if not p.exists(): + return None + try: + data = json.loads(p.read_text()) + items = [tuple(i) for i in data["items"]] + return items if items else None + except Exception: + return None + + +def save_cache(items): + """Save fetched items to disk for fast subsequent runs.""" + try: + _cache_path().write_text(json.dumps({"items": items})) + except Exception: + pass diff --git a/mainline.py b/mainline.py index 8bd8fc0..b862d95 100755 --- a/mainline.py +++ b/mainline.py @@ -848,6 +848,7 @@ def stream(items): sh = h - fh # ── Check for ntfy message ──────────────────────── + msg_h = 0 # rows consumed by message zone at top msg_active = False with _ntfy_lock: if _ntfy_message is not None: @@ -857,10 +858,9 @@ def stream(items): else: _ntfy_message = None # expired + buf = [] if msg_active: - # ── MESSAGE state: freeze scroll, render message ── - buf = [] - # Render message text with OTF font (cached across frames) + # ── Message zone: pinned to top, scroll continues below ── display_text = m_body or m_title or "(empty)" display_text = re.sub(r"\s+", " ", display_text.upper()) cache_key = (display_text, w) @@ -870,50 +870,33 @@ def stream(items): else: msg_rows = _msg_cache[1] msg_rows = _lr_gradient(msg_rows, (time.monotonic() * GRAD_SPEED) % 1.0) - # Center vertically in scroll zone - total_h = len(msg_rows) + 4 # +4 for border + meta + padding - y_off = max(0, (sh - total_h) // 2) - for r in range(sh): - ri = r - y_off - if ri == 0 or ri == total_h - 1: - # Border lines - bar = "─" * (w - 4) - buf.append(f"\033[{r+1};1H {MSG_BORDER}{bar}{RST}\033[K") - elif 1 <= ri <= len(msg_rows): - ln = _vis_trunc(msg_rows[ri - 1], w) - buf.append(f"\033[{r+1};1H {ln}{RST}\033[K") - elif ri == len(msg_rows) + 1: - # Title line (if present and different from body) - if m_title and m_title != m_body: - meta = f" {MSG_META}\u2591 {m_title}{RST}" - else: - meta = "" - buf.append(f"\033[{r+1};1H{meta}\033[K") - elif ri == len(msg_rows) + 2: - # Source + timestamp - elapsed_s = int(time.monotonic() - m_ts) - remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s) - ts_str = datetime.now().strftime("%H:%M:%S") - meta = f" {MSG_META}\u2591 ntfy \u00b7 {ts_str} \u00b7 {remaining}s{RST}" - buf.append(f"\033[{r+1};1H{meta}\033[K") - else: - # Sparse noise outside the message - if random.random() < 0.06: - buf.append(f"\033[{r+1};1H{noise(w)}") - else: - buf.append(f"\033[{r+1};1H\033[K") - # Firehose keeps running during messages - if FIREHOSE and fh > 0: - for fr in range(fh): - fline = _firehose_line(items, w) - buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K") - sys.stdout.buffer.write("".join(buf).encode()) - sys.stdout.flush() - elapsed = time.monotonic() - t0 - time.sleep(max(0, _FRAME_DT - elapsed)) - continue + # Layout: rendered text + meta + border + elapsed_s = int(time.monotonic() - m_ts) + remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s) + ts_str = datetime.now().strftime("%H:%M:%S") + row_idx = 0 + for mr in msg_rows: + ln = _vis_trunc(mr, w) + buf.append(f"\033[{row_idx+1};1H {ln}{RST}\033[K") + row_idx += 1 + # Meta line: title (if distinct) + source + countdown + meta_parts = [] + if m_title and m_title != m_body: + meta_parts.append(m_title) + meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s") + meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0] + buf.append(f"\033[{row_idx+1};1H{MSG_META}{meta}{RST}\033[K") + row_idx += 1 + # Border — constant boundary between message and scroll + bar = "\u2500" * (w - 4) + buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K") + row_idx += 1 + msg_h = row_idx - # ── SCROLL state: normal headline rendering ─────── + # Effective scroll zone: below message, above firehose + scroll_h = sh - msg_h + + # ── Scroll: headline rendering (always runs) ────── # Advance scroll on schedule scroll_accum += _FRAME_DT while scroll_accum >= scroll_interval: @@ -935,15 +918,16 @@ def stream(items): if k < cam: del noise_cache[k] - # Draw scroll zone - top_zone = max(1, int(sh * 0.25)) - bot_zone = max(1, int(sh * 0.10)) + # Draw scroll zone (below message zone, above firehose) + top_zone = max(1, int(scroll_h * 0.25)) + bot_zone = max(1, int(scroll_h * 0.10)) grad_offset = (time.monotonic() * GRAD_SPEED) % 1.0 - buf = [] - for r in range(sh): + scroll_buf_start = len(buf) # track where scroll rows start in buf + for r in range(scroll_h): + scr_row = msg_h + r + 1 # 1-indexed ANSI screen row cy = cam + r - top_f = min(1.0, r / top_zone) - bot_f = min(1.0, (sh - 1 - r) / bot_zone) + top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0 + bot_f = min(1.0, (scroll_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0 row_fade = min(top_f, bot_f) drawn = False for content, hc, by, midx in active: @@ -958,11 +942,11 @@ def stream(items): if row_fade < 1.0: ln = _fade_line(ln, row_fade) if cr == midx: - buf.append(f"\033[{r+1};1H{W_COOL}{ln}{RST}\033[K") + buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K") elif ln.strip(): - buf.append(f"\033[{r+1};1H{ln}{RST}\033[K") + buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K") else: - buf.append(f"\033[{r+1};1H\033[K") + buf.append(f"\033[{scr_row};1H\033[K") drawn = True break if not drawn: @@ -970,9 +954,9 @@ def stream(items): if row_fade < 1.0 and n: n = _fade_line(n, row_fade) if n: - buf.append(f"\033[{r+1};1H{n}") + buf.append(f"\033[{scr_row};1H{n}") else: - buf.append(f"\033[{r+1};1H\033[K") + buf.append(f"\033[{scr_row};1H\033[K") # Draw firehose zone if FIREHOSE and fh > 0: @@ -984,11 +968,12 @@ def stream(items): mic_excess = max(0.0, _mic_db - MIC_THRESHOLD_DB) glitch_prob = 0.32 + min(0.9, mic_excess * 0.16) n_hits = 4 + int(mic_excess / 2) - g_limit = sh if FIREHOSE else len(buf) - if random.random() < glitch_prob and g_limit > 0: - for _ in range(min(n_hits, g_limit)): - gi = random.randint(0, g_limit - 1) - buf[gi] = f"\033[{gi+1};1H{glitch_bar(w)}" + scroll_buf_len = len(buf) - scroll_buf_start + if random.random() < glitch_prob and scroll_buf_len > 0: + for _ in range(min(n_hits, scroll_buf_len)): + gi = random.randint(0, scroll_buf_len - 1) + scr_row = msg_h + gi + 1 + buf[scroll_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}" sys.stdout.buffer.write("".join(buf).encode()) sys.stdout.flush()