Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion plugins/news/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,18 @@ without `<item>` 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.
Expand Down
18 changes: 17 additions & 1 deletion plugins/news/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
140 changes: 134 additions & 6 deletions plugins/news/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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', [])
Expand All @@ -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")
Expand Down Expand Up @@ -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')}"
Expand All @@ -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')
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion plugins/news/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading