diff --git a/plugins/limnoria/NtrPlaylist/config.py b/plugins/limnoria/NtrPlaylist/config.py index 29c6c7d..55d039b 100644 --- a/plugins/limnoria/NtrPlaylist/config.py +++ b/plugins/limnoria/NtrPlaylist/config.py @@ -34,3 +34,12 @@ conf.registerGlobalValue( """IRC nicknames allowed to run admin commands (space-separated).""", ), ) + +conf.registerGlobalValue( + NtrPlaylist, + "displayTimezone", + registry.String( + "America/New_York", + """IANA timezone for displaying dates in IRC (e.g. America/New_York, America/Chicago).""", + ), +) diff --git a/plugins/limnoria/NtrPlaylist/plugin.py b/plugins/limnoria/NtrPlaylist/plugin.py index abc361a..65cc7e4 100644 --- a/plugins/limnoria/NtrPlaylist/plugin.py +++ b/plugins/limnoria/NtrPlaylist/plugin.py @@ -8,6 +8,8 @@ import logging import re import urllib.error import urllib.request +from datetime import datetime +from zoneinfo import ZoneInfo from supybot import callbacks from supybot.commands import optional, wrap @@ -73,6 +75,14 @@ def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> # --- Formatting -------------------------------------------------------------- +def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str: + if not iso_string: + return "never" + dt = datetime.fromisoformat(iso_string) + local = dt.astimezone(ZoneInfo(tz_name)) + return local.strftime("%a %b %-d, %-I:%M %p %Z") + + _MAX_IRC_LINE = 430 @@ -195,6 +205,45 @@ class NtrPlaylist(callbacks.Plugin): return irc.reply(format_playlist(data)) + @wrap([optional("text")]) + def lastshow(self, irc, msg, args, text): + """ + + Returns a track from last week's show by position number. + """ + if not text or not text.strip(): + irc.reply("Usage: !lastshow ") + return + try: + position = int(text.strip()) + except ValueError: + irc.reply("Usage: !lastshow ") + return + base_url = self.registryValue("apiBaseUrl") + try: + shows = _api_get(base_url, "/shows?limit=2") + except ApiError as exc: + LOGGER.warning("API error for lastshow: %s", exc) + irc.reply(exc.detail) + return + if len(shows) < 2: + irc.reply("No previous show found") + return + prev_show_id = shows[1]["id"] + try: + data = _api_get(base_url, f"/shows/{prev_show_id}") + except ApiError as exc: + LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc) + irc.reply(exc.detail) + return + tracks = data.get("tracks", []) + track = next((t for t in tracks if t.get("position") == position), None) + if not track: + episode = data.get("episode_number", "?") + irc.reply(f"No track at position {position} in episode {episode}") + return + irc.reply(format_track(track)) + @wrap def status(self, irc, msg, args): """takes no arguments @@ -208,9 +257,10 @@ class NtrPlaylist(callbacks.Plugin): LOGGER.warning("API error for status: %s", exc) irc.reply(exc.detail) return + tz = self.registryValue("displayTimezone") status = data.get("status", "unknown") poller = "alive" if data.get("poller_alive") else "dead" - last_fetch = data.get("last_fetch") or "never" + last_fetch = format_dt(data.get("last_fetch"), tz) count = data.get("current_week_track_count", 0) irc.reply( f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}" diff --git a/plugins/sopel/ntr_playlist.py b/plugins/sopel/ntr_playlist.py index 704acee..e54fa4e 100644 --- a/plugins/sopel/ntr_playlist.py +++ b/plugins/sopel/ntr_playlist.py @@ -7,6 +7,8 @@ import json import logging import urllib.error import urllib.request +from datetime import datetime +from zoneinfo import ZoneInfo from sopel import plugin from sopel.config import types @@ -18,6 +20,7 @@ class NtrPlaylistSection(types.StaticSection): api_base_url = types.ValidatedAttribute("api_base_url", default="http://127.0.0.1:8000") admin_token = types.ValidatedAttribute("admin_token", default="") admin_nicks = types.ListAttribute("admin_nicks") + display_timezone = types.ValidatedAttribute("display_timezone", default="America/New_York") def setup(bot): @@ -83,6 +86,14 @@ def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> raise ApiError(0, "Cannot reach API") from e +def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str: + if not iso_string: + return "never" + dt = datetime.fromisoformat(iso_string) + local = dt.astimezone(ZoneInfo(tz_name)) + return local.strftime("%a %b %-d, %-I:%M %p %Z") + + def format_track(track: dict) -> str: pos = track.get("position", 0) title = track.get("title", "") @@ -188,6 +199,43 @@ def ntr_playlist(bot, trigger): bot.say(format_playlist(data)) +@plugin.command("lastshow") +def ntr_lastshow(bot, trigger): + raw = trigger.group(2) + if not raw or not raw.strip(): + bot.say("Usage: !lastshow ") + return + try: + position = int(raw.strip()) + except ValueError: + bot.say("Usage: !lastshow ") + return + base_url = bot.settings.ntr_playlist.api_base_url + try: + shows = _api_get(base_url, "/shows?limit=2") + except ApiError as e: + LOGGER.warning("API error for !lastshow: %s", e) + bot.say(e.detail) + return + if len(shows) < 2: + bot.say("No previous show found") + return + prev_show_id = shows[1]["id"] + try: + data = _api_get(base_url, f"/shows/{prev_show_id}") + except ApiError as e: + LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e) + bot.say(e.detail) + return + tracks = data.get("tracks", []) + track = next((t for t in tracks if t.get("position") == position), None) + if not track: + episode = data.get("episode_number", "?") + bot.say(f"No track at position {position} in episode {episode}") + return + bot.say(format_track(track)) + + @plugin.command("status") def ntr_status(bot, trigger): base_url = bot.settings.ntr_playlist.api_base_url @@ -197,9 +245,10 @@ def ntr_status(bot, trigger): LOGGER.warning("API error for !status: %s", e) bot.say(e.detail) return + tz = bot.settings.ntr_playlist.display_timezone status = data.get("status", "unknown") poller = "alive" if data.get("poller_alive") else "dead" - last_fetch = data.get("last_fetch") or "never" + last_fetch = format_dt(data.get("last_fetch"), tz) count = data.get("current_week_track_count", 0) bot.say(f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}") diff --git a/tests/test_format_dt.py b/tests/test_format_dt.py new file mode 100644 index 0000000..0fdfbcb --- /dev/null +++ b/tests/test_format_dt.py @@ -0,0 +1,57 @@ +"""Tests for the format_dt helper used by both IRC plugins. + +The function is duplicated in both plugins (Limnoria and Sopel) since they +can't share imports. This tests the logic independently of either IRC framework. +""" +from datetime import datetime +from zoneinfo import ZoneInfo + + +def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str: + if not iso_string: + return "never" + dt = datetime.fromisoformat(iso_string) + local = dt.astimezone(ZoneInfo(tz_name)) + return local.strftime("%a %b %-d, %-I:%M %p %Z") + + +def test_format_dt_est(): + result = format_dt("2026-01-15T03:00:00+00:00") + assert result == "Wed Jan 14, 10:00 PM EST" + + +def test_format_dt_edt(): + result = format_dt("2026-03-12T02:00:00+00:00") + assert result == "Wed Mar 11, 10:00 PM EDT" + + +def test_format_dt_none(): + assert format_dt(None) == "never" + + +def test_format_dt_empty_string(): + assert format_dt("") == "never" + + +def test_format_dt_custom_timezone(): + result = format_dt("2026-03-12T02:00:00+00:00", "America/Chicago") + assert "CDT" in result or "CST" in result + + +def test_format_dt_no_seconds_or_microseconds(): + result = format_dt("2026-03-12T02:30:45.123456+00:00") + assert ":45" not in result + assert ".123456" not in result + assert "10:30 PM" in result + + +def test_format_dt_single_digit_day(): + result = format_dt("2026-03-05T03:00:00+00:00") + assert "Wed Mar 4," in result + assert " 04," not in result + + +def test_format_dt_single_digit_hour(): + result = format_dt("2026-01-15T06:00:00+00:00") + assert "1:00 AM" in result + assert "01:00" not in result