From f3f2ac71d319f520840858b7c065de62efa21783 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:07:24 -0400 Subject: [PATCH] news, stock-news: rotate through a pool of stories instead of repeating the top few (1.7.0, 2.9.0) Both tickers fetched only as many stories as they showed (2 per feed, 1 per symbol), so every pass repeated the same headlines until a source published something new; rotation only reordered them. They now fetch global.headline_pool_size stories per source (default 10) and each pass or page shows the least-recently-shown ones, newest first. A story carried by two sources is shown once, and one older than global.max_headline_age_hours (default 48) is used only when nothing newer exists. Co-Authored-By: Claude Sonnet 5.5 --- plugins/news/README.md | 10 +- plugins/news/config_schema.json | 18 ++- plugins/news/manager.py | 140 ++++++++++++++++- plugins/news/manifest.json | 8 +- .../test_headlines_rotate_through_pool.py | 148 ++++++++++++++++++ plugins/stock-news/README.md | 8 +- plugins/stock-news/config_schema.json | 20 ++- plugins/stock-news/manager.py | 140 ++++++++++++++--- plugins/stock-news/manifest.json | 8 +- .../test_headlines_rotate_through_pool.py | 139 ++++++++++++++++ 10 files changed, 602 insertions(+), 37 deletions(-) create mode 100644 plugins/news/test_headlines_rotate_through_pool.py create mode 100644 plugins/stock-news/test_headlines_rotate_through_pool.py diff --git a/plugins/news/README.md b/plugins/news/README.md index 02842610..b80c0dd2 100644 --- a/plugins/news/README.md +++ b/plugins/news/README.md @@ -150,10 +150,18 @@ without `` elements will fetch successfully and yield nothing. | Option | Default | What it does | |--------|---------|--------------| -| `global.headlines_per_feed` | `2` | Headlines taken from each enabled feed per fetch | +| `global.headlines_per_feed` | `2` | Headlines shown from each enabled feed at a time | +| `global.headline_pool_size` | `10` | Stories fetched from each feed. The strip shows `headlines_per_feed` of them and moves on to the ones not yet shown, so the same top stories do not repeat. Advanced. | +| `global.max_headline_age_hours` | `48` | A story older than this is shown only when a feed has too few newer ones; `0` turns the check off. Advanced. | | `global.rotation_enabled` | `true` | Rotate the headline order after `rotation_threshold` passes. Only used when `headline_paging` is off (it is on by default) | | `global.rotation_threshold` | `3` | Complete passes of the strip before the order rotates — a count of scroll cycles, not of feeds | +Every cycle (or page, with `headline_paging` on) is built from the stories each +feed has shown least recently, newest first, so a feed's second, third and +fourth story come round instead of its top two repeating. A story carried by +two feeds is shown once. When a feed has nothing newer to offer, the rotation +falls back to changing which headline leads. + With three feeds and the default `headlines_per_feed: 2`, a cycle carries six headlines. Raising it makes the strip longer, and therefore each lap slower — the same trade as any ticker. diff --git a/plugins/news/config_schema.json b/plugins/news/config_schema.json index e9a98a6e..6dc9c30b 100644 --- a/plugins/news/config_schema.json +++ b/plugins/news/config_schema.json @@ -162,7 +162,23 @@ "default": 2, "minimum": 1, "maximum": 10, - "description": "Number of headlines to fetch per feed", + "description": "Number of headlines shown from each feed at a time", + "x-advanced": true + }, + "headline_pool_size": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 30, + "description": "Stories fetched from each feed. Only headlines_per_feed of them are shown at a time; the rest are the pool later cycles pick from, so the same top stories are not shown every time. Never fewer than headlines_per_feed.", + "x-advanced": true + }, + "max_headline_age_hours": { + "type": "number", + "default": 48, + "minimum": 0, + "maximum": 720, + "description": "A story older than this is only shown when a feed has too few newer ones. 0 turns the age check off.", "x-advanced": true }, "background_service": { diff --git a/plugins/news/manager.py b/plugins/news/manager.py index 495ac761..250ef7bf 100644 --- a/plugins/news/manager.py +++ b/plugins/news/manager.py @@ -140,6 +140,12 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.rotation_enabled = self.global_config.get('rotation_enabled', True) self.rotation_threshold = self.global_config.get('rotation_threshold', 3) self.headlines_per_feed = self.global_config.get('headlines_per_feed', 2) + # Stories fetched per feed, and how old one can be before a newer story + # always goes first. headlines_per_feed of them are shown at a time; + # the rest is the pool later cycles draw from (see _select_headlines). + self.headline_pool_size = max(1, int(self.global_config.get('headline_pool_size', 10))) + self.max_headline_age_hours = max( + 0.0, float(self.global_config.get('max_headline_age_hours', 48))) # PressStart2P's pixel grid is 8; 12 was off it and anti-aliased on the # panel. A stored 12 is the old schema default, not a choice. self.font_size = self._resolve_font_size(self.global_config) @@ -179,6 +185,13 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # State self.current_headlines = [] + # Each feed's whole fetched pool, in feed order, and what has been shown: + # story -> sequence number of the last cycle that showed it. Stories seen + # on a cycle that is still running wait in _pending_shown until it ends. + self._headline_pools = [] + self._shown_headlines = {} + self._pending_shown = set() + self._show_seq = 0 # Headline set the current scroll strip was rendered from self._headlines_signature = None self._last_good_headlines = {} # feed -> its last non-empty fetch; see update() @@ -846,6 +859,12 @@ def normalize_custom_feeds(feeds): self.rotation_enabled = self.global_config.get('rotation_enabled', True) self.rotation_threshold = self.global_config.get('rotation_threshold', 3) self.headlines_per_feed = self.global_config.get('headlines_per_feed', 2) + # Stories fetched per feed, and how old one can be before a newer story + # always goes first. headlines_per_feed of them are shown at a time; + # the rest is the pool later cycles draw from (see _select_headlines). + self.headline_pool_size = max(1, int(self.global_config.get('headline_pool_size', 10))) + self.max_headline_age_hours = max( + 0.0, float(self.global_config.get('max_headline_age_hours', 48))) # Same resolution as __init__ (default 16; it was 12 here). self.font_size = self._resolve_font_size(self.global_config) @@ -918,6 +937,7 @@ def update(self) -> None: try: self.current_headlines = [] + pools = [] feed_stats = {'success': 0, 'failed': 0, 'total': 0} # Each feed's last good headlines. A feed that fails now shows @@ -942,7 +962,7 @@ def take(key, headlines): if feed_name in self.DEFAULT_FEEDS: feed_stats['total'] += 1 headlines = self._fetch_feed_headlines(feed_name, self.DEFAULT_FEEDS[feed_name]) - self.current_headlines.extend(take(('default', feed_name), headlines)) + pools.append(take(('default', feed_name), headlines)) # Fetch from custom feeds (use array order) custom_feeds = self.feeds_config.get('custom_feeds', []) @@ -966,13 +986,17 @@ def take(key, headlines): continue feed_stats['total'] += 1 headlines = self._fetch_feed_headlines(feed_name, feed_url) - self.current_headlines.extend(take(('custom', feed_name, feed_url), headlines)) + pools.append(take(('custom', feed_name, feed_url), headlines)) + + self._headline_pools = pools + self.current_headlines = self._select_headlines(pools) # Log feed status summary if feed_stats['total'] > 0: self.logger.info( f"Feed update complete: {feed_stats['success']}/{feed_stats['total']} feeds successful, " - f"{len(self.current_headlines)} headlines retrieved" + f"{len(self.current_headlines)} headlines chosen from " + f"{sum(len(pool) for pool in pools)}" ) if feed_stats['failed'] > 0: self.logger.warning(f"{feed_stats['failed']} feed(s) failed to fetch headlines") @@ -1020,6 +1044,97 @@ def take(key, headlines): except Exception as e: self.logger.error(f"Error updating news headlines: {e}") + def _pool_size(self) -> int: + """Stories to fetch per feed: the pool, never fewer than are shown.""" + return max(int(getattr(self, 'headline_pool_size', 10)), + int(getattr(self, 'headlines_per_feed', 2))) + + @staticmethod + def _story_key(headline: Dict[str, Any]) -> str: + """Identity of a story: its title, so one carried by two feeds is one story.""" + return ' '.join(str(headline.get('title', '')).casefold().split()) + + def _is_stale_story(self, headline: Dict[str, Any]) -> bool: + """Older than max_headline_age_hours; an undated story is never stale.""" + limit = float(getattr(self, 'max_headline_age_hours', 0) or 0) + published = headline.get('published') + if limit <= 0 or not published: + return False + try: + from email.utils import parsedate_to_datetime + age_seconds = time.time() - parsedate_to_datetime(published).timestamp() + except (TypeError, ValueError, IndexError, OverflowError): + return False + return age_seconds > limit * 3600 + + def _select_headlines(self, pools: List[List[Dict]]) -> List[Dict]: + """Choose the headlines to show next, headlines_per_feed from each pool. + + Within a feed: fresh before stale, then never-shown before shown (the + one shown longest ago first), then the feed's own order (newest first). + A story another feed already supplied is skipped. Each feed's picks keep + the feed's order, and feeds keep theirs. + """ + per_feed = max(int(getattr(self, 'headlines_per_feed', 2)), 0) + shown = self.__dict__.setdefault('_shown_headlines', {}) + taken = set() + live = set() + chosen = [] + for pool in pools: + ranked = [] + for idx, headline in enumerate(pool): + key = self._story_key(headline) + if not key: + continue + live.add(key) + if key in taken: + continue + ranked.append((self._is_stale_story(headline), shown.get(key, 0), idx, key, headline)) + ranked.sort(key=lambda r: r[:3]) + picked = sorted(ranked[:per_feed], key=lambda r: r[2]) + taken.update(r[3] for r in picked) + chosen.extend(r[4] for r in picked) + + # Forget stories that have left every feed; the table stays small. + for key in [k for k in shown if k not in live]: + del shown[key] + return chosen + + def _note_shown(self, headlines: List[Dict]) -> None: + """Remember that these were on the panel; counted when the cycle ends.""" + pending = self.__dict__.setdefault('_pending_shown', set()) + pending.update(key for key in map(self._story_key, headlines) if key) + + def _reselect_headlines(self) -> bool: + """Close the cycle just shown and pick the next headlines. + + Returns True when the headline set changed. False means the feeds have + nothing newer or unseen to offer, so the caller falls back to + reordering what it has. + """ + shown = self.__dict__.setdefault('_shown_headlines', {}) + pending = self.__dict__.setdefault('_pending_shown', set()) + self._show_seq = getattr(self, '_show_seq', 0) + 1 + for key in pending: + shown[key] = self._show_seq + pending.clear() + + pools = getattr(self, '_headline_pools', None) + if not pools: + return False + chosen = self._select_headlines(pools) + if not chosen or set(map(self._story_key, chosen)) == set(map(self._story_key, self.current_headlines)): + return False + self.current_headlines = chosen + self._headlines_signature = self._headline_signature() + wanted = {(h.get('feed_name', ''), h.get('title', '')) for h in chosen} + self._headline_image_cache = { + k: v for k, v in self._headline_image_cache.items() if k in wanted + } + self.logger.info("Headlines moved on: %d chosen from %d fetched", + len(chosen), sum(len(pool) for pool in pools)) + return True + def _fetch_feed_headlines(self, feed_name: str, feed_url: str) -> List[Dict]: """Fetch headlines from a specific RSS feed.""" cache_key = f"news_{feed_name}_{datetime.now().strftime('%Y%m%d%H')}" @@ -1044,7 +1159,7 @@ def _fetch_feed_headlines(self, feed_name: str, feed_url: str) -> List[Dict]: headlines = [] # Extract headlines from RSS items - for item in root.findall('.//item')[:self.headlines_per_feed]: + for item in root.findall('.//item')[:self._pool_size()]: title = item.find('title') description = item.find('description') pub_date = item.find('pubDate') @@ -1182,7 +1297,9 @@ def _on_scroll_cycle_complete(self) -> None: self.logger.debug(f"Rotation count: {self.rotation_count}/{self.rotation_threshold}") if self.rotation_count >= self.rotation_threshold: - self._rotate_headlines() + self._note_shown(self.current_headlines) + if not self._reselect_headlines(): + self._rotate_headlines() self.rotation_count = 0 # Clear scroll cache to force recreation with new headline order self.scroll_helper.clear_cache() @@ -1249,7 +1366,16 @@ def _advance_page(self) -> None: if total == 0: return step = self._page_count if self._page_count > 0 else 1 - self._page_start = (self._page_start + step) % total + old_start = self._page_start + self._note_shown([ + self.current_headlines[(old_start + i) % total] for i in range(min(step, total)) + ]) + self._page_start = (old_start + step) % total + if self._page_start <= old_start and self._reselect_headlines(): + # The window came back round: everything chosen has had its turn, + # so start over on the next unseen stories. + self._page_start = 0 + total = len(self.current_headlines) self.logger.info( "Advanced to next headline page: starting at headline %d/%d ('%s')", self._page_start + 1, @@ -1672,6 +1798,8 @@ def get_info(self) -> Dict[str, Any]: 'scroll_speed': self.scroll_speed, 'rotation_enabled': self.rotation_enabled, 'rotation_threshold': self.rotation_threshold, + 'headline_pool_size': self.headline_pool_size, + 'max_headline_age_hours': self.max_headline_age_hours, 'headlines_per_feed': self.headlines_per_feed, 'font_size': self.font_size, 'text_color': self.text_color, diff --git a/plugins/news/manifest.json b/plugins/news/manifest.json index e57cff20..72dae6f4 100644 --- a/plugins/news/manifest.json +++ b/plugins/news/manifest.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News Ticker", - "version": "1.6.5", + "version": "1.7.0", "description": "Displays scrolling news headlines from RSS feeds including sports news from ESPN, NCAA updates, and custom RSS sources", "author": "ChuckBuilds", "category": "content", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/news", "versions": [ + { + "version": "1.7.0", + "released": "2026-10-02", + "changelog": "Headlines rotate through more stories. The ticker fetched only headlines_per_feed stories per feed (2 by default) and showed the same ones every cycle until the feed published something new; \"rotation\" only changed which of them led. It now fetches a pool of global.headline_pool_size stories per feed (default 10) and each cycle, or page, shows the ones shown least recently, newest first, so the 3rd to 10th story come round before any repeats. A story carried by two feeds is shown once, and one older than global.max_headline_age_hours (default 48) is used only when a feed has nothing newer. When a feed has nothing unseen, the old rotation of the lead headline still applies. headlines_per_feed now means headlines shown at a time.", + "ledmatrix_min_version": "3.4.0" + }, { "released": "2026-10-02", "version": "1.6.5", diff --git a/plugins/news/test_headlines_rotate_through_pool.py b/plugins/news/test_headlines_rotate_through_pool.py new file mode 100644 index 00000000..9f542156 --- /dev/null +++ b/plugins/news/test_headlines_rotate_through_pool.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Regression test: the ticker works through a pool of stories instead of +repeating each feed's top two. + +headlines_per_feed was both the number of stories fetched and the number +shown, so the strip carried the same six headlines every cycle until a feed +published something new, and "rotation" only changed which of them led. The +plugin now fetches headline_pool_size stories per feed and each cycle, or +page, shows the ones shown least recently. + +Run: /bin/python plugins/news/test_headlines_rotate_through_pool.py +""" + +import logging +import os +import sys +from email.utils import format_datetime +from datetime import datetime, timedelta, timezone +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +_candidates = [Path(_core)] if _core else [] +_candidates.append(plugin_dir.parents[2] / "LEDMatrix") +for candidate in _candidates: + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +from manager import NewsTickerPlugin # noqa: E402 + +failures = [] + + +def check(label, ok, detail=""): + print((" PASS " if ok else " FAIL ") + label + (f" <- {detail}" if detail and not ok else "")) + if not ok: + failures.append(label) + + +def story(feed, n, age_hours=1.0): + published = format_datetime(datetime.now(timezone.utc) - timedelta(hours=age_hours)) + return {"feed_name": feed, "title": f"{feed} story {n}", "published": published} + + +FEEDS = list(NewsTickerPlugin.DEFAULT_FEEDS)[:2] + + +def ticker(pools_by_feed, paging=False, per_feed=2): + t = object.__new__(NewsTickerPlugin) + t.logger = logging.getLogger("test-news-pool") + t.initialized = True + t.feeds_config = {"enabled_feeds": list(pools_by_feed), "custom_feeds": []} + t.headlines_per_feed = per_feed + t.headline_pool_size = 10 + t.max_headline_age_hours = 48 + t.current_headlines = [] + t._headlines_signature = None + t._page_start = 0 + t._page_count = 0 + t.rotation_count = 0 + t.rotation_threshold = 1 + t.rotation_enabled = True + t.paging_enabled = paging + t._headline_image_cache = {} + t._fetch_feed_headlines = lambda name, url: list(pools_by_feed.get(name, [])) + t.update() + return t + + +def titles(t): + return [h["title"] for h in t.current_headlines] + + +def pass_done(t): + # The completion handler logs scroll info; only the rotation branch matters here. + t.scroll_helper = type("S", (), { + "get_scroll_info": lambda self: {"elapsed_time": 0, "dynamic_duration": 0}, + "clear_cache": lambda self: None, + })() + t._on_scroll_cycle_complete() + + +# -- a cycle moves on to the next two stories of each feed +pools = {f: [story(f, n) for n in range(6)] for f in FEEDS} +t = ticker(pools) +first = titles(t) +check("the first cycle shows each feed's two newest", first == [ + f"{FEEDS[0]} story 0", f"{FEEDS[0]} story 1", f"{FEEDS[1]} story 0", f"{FEEDS[1]} story 1"], first) +pass_done(t) +second = titles(t) +check("the next cycle shows the next two of each", second == [ + f"{FEEDS[0]} story 2", f"{FEEDS[0]} story 3", f"{FEEDS[1]} story 2", f"{FEEDS[1]} story 3"], second) +pass_done(t) +pass_done(t) +check("after the pool is used up it starts over", titles(t) == first, titles(t)) + +# -- a refresh between cycles does not undo the rotation +t = ticker(pools) +pass_done(t) +moved = titles(t) +t.update() +check("update() keeps the stories the rotation moved to", titles(t) == moved, titles(t)) + +# -- a story two feeds carry is shown once +shared = {"feed_name": FEEDS[0], "title": "Big trade", "published": story(FEEDS[0], 0)["published"]} +pools = {FEEDS[0]: [shared, story(FEEDS[0], 1)], + FEEDS[1]: [dict(shared, feed_name=FEEDS[1]), story(FEEDS[1], 1)]} +t = ticker(pools, per_feed=1) +check("a duplicate across feeds is shown once", titles(t).count("Big trade") == 1, titles(t)) +check("the second feed shows its next story instead", f"{FEEDS[1]} story 1" in titles(t), titles(t)) + +# -- stale stories only fill the gaps +pools = {FEEDS[0]: [story(FEEDS[0], 0, age_hours=300), story(FEEDS[0], 1, age_hours=3)]} +t = ticker(pools, per_feed=1) +check("a fresh story beats a stale one", titles(t) == [f"{FEEDS[0]} story 1"], titles(t)) +t = ticker(pools, per_feed=2) +check("a stale story still fills a feed with too few fresh ones", len(titles(t)) == 2, titles(t)) + +# -- a feed with nothing unseen still changes its lead story +pools = {FEEDS[0]: [story(FEEDS[0], 0), story(FEEDS[0], 1)]} +t = ticker(pools, per_feed=2) +before = titles(t) +pass_done(t) +check("nothing unseen: the order rotates", titles(t) == before[1:] + before[:1], titles(t)) + +# -- paging walks the selection, then picks the next unseen stories +pools = {f: [story(f, n) for n in range(6)] for f in FEEDS} +t = ticker(pools, paging=True) +t._page_start, t._page_count = 0, 2 +t._advance_page() +check("a page advances through the chosen headlines", t._page_start == 2 and len(titles(t)) == 4) +t._page_count = 2 +t._advance_page() +check("coming back round picks the next unseen stories", titles(t) == [ + f"{FEEDS[0]} story 2", f"{FEEDS[0]} story 3", f"{FEEDS[1]} story 2", f"{FEEDS[1]} story 3"], titles(t)) +check("and starts that lap from the first of them", t._page_start == 0) + +# -- the fetch reads the pool, not just what is shown +check("the pool is wider than what is shown", t._pool_size() == 10) +t.headline_pool_size = 1 +check("never fewer than are shown", t._pool_size() == 2) + +print() +print("%d failed" % len(failures)) +sys.exit(1 if failures else 0) diff --git a/plugins/stock-news/README.md b/plugins/stock-news/README.md index 9d5ac015..c6fb0a1e 100644 --- a/plugins/stock-news/README.md +++ b/plugins/stock-news/README.md @@ -65,10 +65,12 @@ The full schema is [`config_schema.json`](config_schema.json). |---|---|---| | `global.rotation_enabled` | `true` | Rotate through headlines after each scroll cycle. Advanced. | | `global.rotation_threshold` | `1` | Scroll cycles before advancing to the next headline (1–10). Advanced. | -| `global.shuffle_headlines` | `true` | Randomise headline order on fetch and after each full rotation cycle. Advanced. | +| `global.shuffle_headlines` | `true` | Randomise headline order each time the strip is rebuilt. Advanced. | | `global.max_headline_length` | `120` | Maximum headline length in characters before truncating with '...'. Default 120 shows most headlines in full (40–300). Advanced. | -| `global.max_headlines_per_symbol` | `1` | Max headlines shown per stock symbol (1–5). Advanced. | -| `global.headlines_per_rotation` | `2` | Max headlines pulled from each custom RSS feed (1–10). Advanced. | +| `global.max_headlines_per_symbol` | `1` | Max headlines shown per stock symbol per pass (1–5). Advanced. | +| `global.headline_pool_size` | `10` | Stories fetched per symbol or custom feed (1–30). Each pass shows the ones shown least recently, so a symbol's top story does not repeat every pass. Advanced. | +| `global.max_headline_age_hours` | `48` | A story older than this is shown only when a symbol has too few newer ones; `0` turns the check off. Advanced. | +| `global.headlines_per_rotation` | `2` | Max headlines shown per pass from each custom RSS feed (1–10). Advanced. | | `global.eager_fetch_on_startup` | `true` | Fetch every configured symbol once immediately (ignoring the normal per-symbol spacing) so the ticker isn't left showing just 1-2 headlines for several minutes after a restart or config change. Falls back to the spread schedule once every symbol has data. Advanced. | | `global.sync_with_stocks_plugin` | `false` | Automatically track the same stocks watched in the Stock Ticker plugin (ledmatrix-stocks). Synced symbols are merged with any symbols configured above. | diff --git a/plugins/stock-news/config_schema.json b/plugins/stock-news/config_schema.json index 7541361e..36261643 100644 --- a/plugins/stock-news/config_schema.json +++ b/plugins/stock-news/config_schema.json @@ -203,7 +203,7 @@ "default": 1, "minimum": 1, "maximum": 5, - "description": "Max headlines shown per stock symbol (1–5)." + "description": "Max headlines shown per stock symbol per pass (1–5)." }, "headlines_per_rotation": { "x-advanced": true, @@ -211,7 +211,23 @@ "default": 2, "minimum": 1, "maximum": 10, - "description": "Max headlines pulled from each custom RSS feed" + "description": "Max headlines shown per pass from each custom RSS feed" + }, + "headline_pool_size": { + "x-advanced": true, + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 30, + "description": "Stories fetched for each symbol or custom feed. Only max_headlines_per_symbol (headlines_per_rotation for custom feeds) of them are shown per pass; the rest are the pool later passes pick from, so the same top story is not shown every time. Never fewer than the number shown." + }, + "max_headline_age_hours": { + "x-advanced": true, + "type": "number", + "default": 48, + "minimum": 0, + "maximum": 720, + "description": "A story older than this is only shown when a symbol has too few newer ones. 0 turns the age check off." }, "update_interval_seconds": { "x-advanced": true, diff --git a/plugins/stock-news/manager.py b/plugins/stock-news/manager.py index 90fa8d59..97ae94d2 100644 --- a/plugins/stock-news/manager.py +++ b/plugins/stock-news/manager.py @@ -79,6 +79,11 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self._last_symbol_fetch: float = 0 self._rotation_count: int = 0 self._items_rotated: int = 0 + # Story -> sequence number of the last pass that showed it (0 = never). + # Drives which stories are picked from each pool; see _pick_items(). + self._shown_headlines: Dict[str, int] = {} + self._show_seq: int = 0 + self._strip_items: list = [] # what the strip on screen was built from self._cycle_complete: bool = False self._vegas_cache: Optional[list] = None self._was_stale: bool = False @@ -176,6 +181,12 @@ def _apply_config(self) -> None: self.max_headlines_per_symbol = gc.get('max_headlines_per_symbol', 1) # int or dict self.headlines_per_rotation = gc.get('headlines_per_rotation', 2) self.max_headline_length = gc.get('max_headline_length', 120) + # How many stories to fetch per symbol/feed, and how old a story can be + # before one that is newer always goes first. The strip shows only + # max_headlines_per_symbol of them per pass; the rest is the pool the + # next passes draw from, so the same top story is not shown every time. + self.headline_pool_size = max(1, int(gc.get('headline_pool_size', 10))) + self.max_headline_age_hours = max(0.0, float(gc.get('max_headline_age_hours', 48))) # Rotation / shuffle self.rotation_enabled = gc.get('rotation_enabled', True) @@ -454,7 +465,7 @@ def update(self) -> None: last = self._feed_last_fetch.get(feed_name, 0) if now - last >= self.update_interval: items = self._fetch_rss_feed(feed_name, feed_url, - max_items=self.headlines_per_rotation) + max_items=self._pool_size(self.headlines_per_rotation)) fetched = self._store_items(f"_feed_{feed_name}", items) or fetched self._feed_last_fetch[feed_name] = now break # one feed per update() call @@ -515,24 +526,7 @@ def _get_stocks_plugin_symbols(self) -> List[str]: def _rebuild_all_news_items(self) -> None: """Rebuild all_news_items from per-symbol cached data.""" - configured = self.feeds_config.get('stock_symbols', []) - stock_symbols = list(dict.fromkeys(configured + self._get_stocks_plugin_symbols())) - custom_feeds = self._get_custom_feeds() - - all_items: list = [] - for sym in stock_symbols: - all_items.extend(self._symbol_data.get(sym, [])) - for fn in custom_feeds: - all_items.extend(self._symbol_data.get(f"_feed_{fn}", [])) - - max_total = ( - sum(self._get_symbol_max_headlines(s) for s in stock_symbols) - + len(custom_feeds) * self.headlines_per_rotation - ) - self.all_news_items = all_items[:max_total] if len(all_items) > max_total else all_items - - if self.shuffle_headlines and self.all_news_items: - random.shuffle(self.all_news_items) + self.all_news_items, n_symbols, n_feeds = self._select_items() self.last_update = time.time() # A new strip is built between passes, not now: this runs after every @@ -544,7 +538,82 @@ def _rebuild_all_news_items(self) -> None: self._rotation_count = 0 self._items_rotated = 0 self.logger.info("[Stock News] Rebuilt: %d items (%d symbols, %d custom feeds)", - len(self.all_news_items), len(stock_symbols), len(custom_feeds)) + len(self.all_news_items), n_symbols, n_feeds) + + # ------------------------------------------------------------------------- + # Choosing what to show + # ------------------------------------------------------------------------- + + def _pool_size(self, shown: int) -> int: + """Stories to fetch for a source that shows `shown` per pass.""" + return max(int(getattr(self, 'headline_pool_size', 10)), int(shown)) + + @staticmethod + def _story_key(item: Dict) -> str: + """Identity of a story: its title, so one syndicated under two tickers is one story.""" + return ' '.join(str(item.get('title', '')).casefold().split()) + + def _is_stale_story(self, item: Dict) -> bool: + limit = float(getattr(self, 'max_headline_age_hours', 0) or 0) + ts = item.get('published_ts') or 0 + if limit <= 0 or not ts: + return False + return (time.time() - float(ts)) > limit * 3600 + + def _pick_items(self, pool: List[Dict], n: int, taken: set) -> List[Dict]: + """The n stories of one pool to show next. + + Fresh before stale, then never-shown before shown (longest ago first), + then the order the source gave them in (newest first). A story already + picked for another symbol is skipped. Returned in the source's order. + """ + shown = self.__dict__.setdefault('_shown_headlines', {}) + ranked = [] + for idx, item in enumerate(pool): + key = self._story_key(item) + if not key or key in taken: + continue + ranked.append((self._is_stale_story(item), shown.get(key, 0), idx, key, item)) + ranked.sort(key=lambda r: r[:3]) + chosen = sorted(ranked[:max(int(n), 0)], key=lambda r: r[2]) + taken.update(r[3] for r in chosen) + return [r[4] for r in chosen] + + def _select_items(self) -> Tuple[List[Dict], int, int]: + """Pick the next strip's stories from every pool: (items, symbols, feeds).""" + configured = self.feeds_config.get('stock_symbols', []) + stock_symbols = list(dict.fromkeys(configured + self._get_stocks_plugin_symbols())) + custom_feeds = self._get_custom_feeds() + + taken: set = set() + items: List[Dict] = [] + live: set = set() + for sym in stock_symbols: + pool = self._symbol_data.get(sym, []) + live.update(self._story_key(i) for i in pool) + items.extend(self._pick_items(pool, self._get_symbol_max_headlines(sym), taken)) + for fn in custom_feeds: + pool = self._symbol_data.get(f"_feed_{fn}", []) + live.update(self._story_key(i) for i in pool) + items.extend(self._pick_items(pool, self.headlines_per_rotation, taken)) + + # Forget stories that have left every pool; the table stays small. + shown = self.__dict__.setdefault('_shown_headlines', {}) + for key in [k for k in shown if k not in live]: + del shown[key] + + if self.shuffle_headlines and items: + random.shuffle(items) + return items, len(stock_symbols), len(custom_feeds) + + def _commit_shown(self, items: List[Dict]) -> None: + """Record that `items` have been on the panel, so the next pick moves on.""" + self._show_seq = getattr(self, '_show_seq', 0) + 1 + shown = self.__dict__.setdefault('_shown_headlines', {}) + for item in items: + key = self._story_key(item) + if key: + shown[key] = self._show_seq # ------------------------------------------------------------------------- # Data fetching @@ -558,7 +627,8 @@ def _get_symbol_max_headlines(self, symbol: str) -> int: return int(cfg) def _fetch_stock_news(self, symbol: str, max_h: int) -> List[Dict]: - """Try YF search API first; fall back to RSS.""" + """Try YF search API first; fall back to RSS. Fetches the whole pool.""" + max_h = self._pool_size(max_h) items = self._fetch_yf_api(symbol, max_h) if items: return items @@ -572,7 +642,8 @@ def _fetch_stock_news(self, symbol: str, max_h: int) -> List[Dict]: def _fetch_yf_api(self, symbol: str, max_h: int) -> List[Dict]: """Yahoo Finance search API — returns publisher, timestamp, optional price.""" bucket = int(time.time() // self.update_interval) - cache_key = f"stock_yf_{symbol}_{bucket}" + max_h = self._pool_size(max_h) + cache_key = f"stock_yf_{symbol}_{max_h}_{bucket}" cached = self.cache_manager.get(cache_key) if cached: self.logger.debug("[Stock News] Cache hit (YF): %s", symbol) @@ -927,6 +998,7 @@ def _create_scrolling_image(self) -> None: self.scroll_helper.clear_cache() return self.scroll_helper.create_scrolling_image(item_images, item_gap=self.item_gap) + self._strip_items = list(self.all_news_items) self._cycle_complete = False # Any build reads every logo already on disk. self._strip_rebuild_pending = False @@ -1047,6 +1119,23 @@ def _render_news_item(self, news_item: dict) -> Optional[Image.Image]: return None def _rotate_headlines(self) -> None: + """Move on to the next stories once a pass has been shown. + + What the finished strip showed is recorded, then each symbol picks its + next least-recently-shown story. A source with nothing newer to offer + falls back to shifting the order, so the lead story still changes. + """ + self._commit_shown(getattr(self, '_strip_items', None) or self.all_news_items) + items, _, _ = self._select_items() + if set(map(self._story_key, items)) != set(map(self._story_key, self.all_news_items)): + self.all_news_items = items + self._items_rotated = 0 + self.logger.info("[Stock News] Rotated to %d fresh/unseen stories", len(items)) + self._vegas_cache = None + return + self._shift_headlines() + + def _shift_headlines(self) -> None: if len(self.all_news_items) <= 1: return first = self.all_news_items[0] @@ -1071,6 +1160,11 @@ def get_vegas_content(self) -> Optional[list]: if self._vegas_cache is None: rendered = [self._render_news_item(item) for item in self.all_news_items] self._vegas_cache = [img for img in rendered if img is not None] + # These stories are about to scroll: the next build picks others. + self._commit_shown(self.all_news_items) + upcoming = self._select_items()[0] + if upcoming: + self.all_news_items = upcoming total_px = sum(img.width for img in self._vegas_cache) self.logger.info("[Stock News] Vegas cache: %d items, %dpx total", len(self._vegas_cache), total_px) @@ -1234,6 +1328,8 @@ def get_info(self) -> Dict[str, Any]: 'shuffle_headlines': self.shuffle_headlines, 'rotation_enabled': self.rotation_enabled, 'rotation_threshold': self.rotation_threshold, + 'headline_pool_size': self.headline_pool_size, + 'max_headline_age_hours': self.max_headline_age_hours, 'logo_fetch_enabled': self.logo_fetch_enabled, 'logo_size': self.logo_size, 'respect_market_hours': self.respect_market_hours, diff --git a/plugins/stock-news/manifest.json b/plugins/stock-news/manifest.json index 0340ed33..8207c6a6 100644 --- a/plugins/stock-news/manifest.json +++ b/plugins/stock-news/manifest.json @@ -1,7 +1,7 @@ { "id": "stock-news", "name": "Stock News Ticker", - "version": "2.8.4", + "version": "2.9.0", "author": "ChuckBuilds", "description": "Live stock headlines via Yahoo Finance search API with RSS fallback, company logos, configurable display styles (logo+ticker, ticker only, logo only), Vegas scroll integration, and per-day/hour request budgeting", "entry_point": "manager.py", @@ -25,6 +25,12 @@ "branch": "main", "plugin_path": "plugins/stock-news", "versions": [ + { + "version": "2.9.0", + "released": "2026-10-02", + "notes": "The ticker rotates through more stories. Each symbol asked Yahoo for a single story (max_headlines_per_symbol) and showed it on every pass until Yahoo published another, so the strip repeated the same 8 headlines for hours, only reordered; a story carried by two tickers also showed twice. It now fetches a pool of global.headline_pool_size stories per symbol and custom feed (default 10, in the same request) and each pass shows the ones shown least recently, newest first. A story is shown once per pass even if several symbols carry it, and one older than global.max_headline_age_hours (default 48) is used only when a symbol has nothing newer. max_headlines_per_symbol and headlines_per_rotation now mean headlines shown per pass. The Vegas strip advances the same way.", + "ledmatrix_min_version": "3.4.0" + }, { "version": "2.8.4", "released": "2026-10-02", diff --git a/plugins/stock-news/test_headlines_rotate_through_pool.py b/plugins/stock-news/test_headlines_rotate_through_pool.py new file mode 100644 index 00000000..e4a1ef3e --- /dev/null +++ b/plugins/stock-news/test_headlines_rotate_through_pool.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Regression test: the ticker works through a pool of stories instead of +repeating the top one. + +Each symbol fetched max_headlines_per_symbol stories (1 by default) and the +strip showed all of them on every pass; "rotation" only reordered them. With +eight symbols that was the same eight headlines until Yahoo published +something new, and a story carried by two tickers showed twice. The plugin now +fetches headline_pool_size stories per symbol and each pass shows the ones +shown least recently. + +Run: LEDMATRIX_CORE=/path/to/LEDMatrix python plugins/stock-news/test_headlines_rotate_through_pool.py +Exit 0 pass, 1 fail, 2 skip. +""" + +import logging +import os +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and _core not in sys.path: + sys.path.insert(0, _core) +try: + from manager import StockNewsTickerPlugin # noqa: E402 +except ImportError as exc: + print(f"SKIP: {exc}") + sys.exit(2) + +results = [] + + +def check(case, passed, detail=""): + results.append((case, passed)) + print(f" [{'pass' if passed else 'FAIL'}] {case}" + (f" <- {detail}" if detail and not passed else "")) + + +def story(symbol, n, age_hours=1.0): + return {"symbol": symbol, "title": f"{symbol} story {n}", + "published_ts": time.time() - age_hours * 3600} + + +def plugin(pools, per_symbol=1): + p = object.__new__(StockNewsTickerPlugin) + p.logger = logging.getLogger("test-stock-news-pool") + p.feeds_config = {"stock_symbols": list(pools)} + p.sync_with_stocks_plugin = False + p.max_headlines_per_symbol = per_symbol + p.headlines_per_rotation = 2 + p.shuffle_headlines = False + p.headline_pool_size = 10 + p.max_headline_age_hours = 48 + p._get_custom_feeds = lambda: {} + p._symbol_data = {sym: list(items) for sym, items in pools.items()} + p.all_news_items = [] + p._vegas_cache = None + p._items_rotated = 0 + p._strip_items = [] + return p + + +def titles(p): + return [i["title"] for i in p.all_news_items] + + +# -- a pass moves on to the next story, and comes back round only after all of them +pools = {"AAPL": [story("AAPL", n) for n in range(3)], + "MSFT": [story("MSFT", n) for n in range(3)]} +p = plugin(pools) +p.all_news_items = p._select_items()[0] +seen = [titles(p)] +for _ in range(5): + p._strip_items = list(p.all_news_items) + p._rotate_headlines() + seen.append(titles(p)) +check("the first pass shows each symbol's newest story", seen[0] == ["AAPL story 0", "MSFT story 0"], seen[0]) +check("the next passes walk down each symbol's pool", + seen[1] == ["AAPL story 1", "MSFT story 1"] and seen[2] == ["AAPL story 2", "MSFT story 2"], seen[:3]) +check("after the pool is used up it starts over with the longest-unseen", + seen[3] == ["AAPL story 0", "MSFT story 0"], seen[3]) +check("no story repeats within one lap of the pool", + len({t for s in seen[:3] for t in s}) == 6) + +# -- a story carried by two tickers is one story +shared = story("NVDA", 0) +shared["title"] = "Chipmakers rally" +pools = {"NVDA": [shared, story("NVDA", 1)], + "SMCI": [dict(shared, symbol="SMCI"), story("SMCI", 1)]} +p = plugin(pools) +items, _, _ = p._select_items() +check("a story two symbols carry is shown once", + [i["title"] for i in items].count("Chipmakers rally") == 1, [i["title"] for i in items]) +check("the other symbol takes its next story instead", + "SMCI story 1" in [i["title"] for i in items], [i["title"] for i in items]) + +# -- newer stories beat a stale one that has never been shown +pools = {"VOO": [story("VOO", 0, age_hours=200), story("VOO", 1, age_hours=2)]} +p = plugin(pools) +check("a fresh story is picked over a never-shown stale one", + [i["title"] for i in p._select_items()[0]] == ["VOO story 1"]) +p.max_headline_age_hours = 0 +check("with the age check off the feed's own order wins", + [i["title"] for i in p._select_items()[0]] == ["VOO story 0"]) +p.max_headline_age_hours = 48 +p.max_headlines_per_symbol = 2 +check("a stale story still fills a symbol that has too few fresh ones", + sorted(i["title"] for i in p._select_items()[0]) == ["VOO story 0", "VOO story 1"]) + +# -- a pool with nothing new falls back to shifting the order +pools = {"AAPL": [story("AAPL", 0)], "MSFT": [story("MSFT", 0)]} +p = plugin(pools) +p.all_news_items = p._select_items()[0] +p._strip_items = list(p.all_news_items) +p._rotate_headlines() +check("with nothing unseen the lead headline still changes", titles(p) == ["MSFT story 0", "AAPL story 0"], titles(p)) + +# -- shown marks do not outlive the stories +pools = {"AAPL": [story("AAPL", 0), story("AAPL", 1)]} +p = plugin(pools) +p.all_news_items = p._select_items()[0] +p._strip_items = list(p.all_news_items) +p._rotate_headlines() +p._symbol_data["AAPL"] = [story("AAPL", 5)] +p._select_items() +check("a story that left the pool is forgotten", set(p._shown_headlines) <= {"aapl story 5"}, p._shown_headlines) + +# -- the pool is fetched deeper than what is shown +p = plugin({"AAPL": []}) +check("the fetch asks for the whole pool", p._pool_size(1) == 10) +p.headline_pool_size = 2 +check("never fewer than are shown", p._pool_size(5) == 5) + +print() +failed = [c for c, ok in results if not ok] +print(f"{len(results) - len(failed)}/{len(results)} passed") +sys.exit(1 if failed else 0)