diff --git a/docs/api.md b/docs/api.md index 6f69cb9..f616b60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -399,6 +399,25 @@ All timestamps in API responses are UTC. The boundary shifts by 1 hour across DS | EST (Nov -- Mar) | Wed 22:00 | Thu 03:00 | | EDT (Mar -- Nov) | Wed 22:00 | Thu 02:00 | +### Show Rotation Delay + +By default, the "current" show rotates immediately at the like-window boundary (Wednesday 22:00 ET). Set `NTR_SHOW_ROTATION_DELAY_HOURS` to postpone when the new show becomes current. During the delay the previous week's show remains visible in `/playlist`, `/health`, and the dashboard. + +| Variable | Default | Description | +|----------|---------|-------------| +| `NTR_SHOW_ROTATION_DELAY_HOURS` | `0` | Hours to wait after the like-window boundary before rotating to a new show | + +With `NTR_SHOW_ROTATION_DELAY_HOURS=2` (recommended for the live recording window): + +| Time (ET) | "Current" show | Notes | +|-----------|----------------|-------| +| Wed 21:59 | This week | Like window still open | +| Wed 22:00 | **Still this week** | Like window closed; recording in progress | +| Wed 23:59 | **Still this week** | Gap continues | +| Thu 00:00 | Next week | New show created; likes since Wed 22:00 collected | + +Likes made during the gap are not lost -- they fall into the new show's like window and are collected once the rotation occurs. + --- ## WebSocket diff --git a/src/ntr_fetcher/api.py b/src/ntr_fetcher/api.py index d562522..a19effb 100644 --- a/src/ntr_fetcher/api.py +++ b/src/ntr_fetcher/api.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, HTTPException, Depends, Header from pydantic import BaseModel from ntr_fetcher.db import Database -from ntr_fetcher.week import get_show_week +from ntr_fetcher.week import get_current_show_week logger = logging.getLogger(__name__) @@ -29,6 +29,7 @@ def create_app( admin_token: str, show_day: int = 2, show_hour: int = 22, + rotation_delay_hours: float = 0, web_user: str | None = None, web_password: str | None = None, secret_key: str | None = None, @@ -44,7 +45,10 @@ def create_app( def _current_show(): now = datetime.now(timezone.utc) - week_start, week_end = get_show_week(now, show_day=show_day, show_hour=show_hour) + week_start, week_end = get_current_show_week( + now, show_day=show_day, show_hour=show_hour, + rotation_delay_hours=rotation_delay_hours, + ) return db.get_or_create_show(week_start, week_end) @app.get("/health") diff --git a/src/ntr_fetcher/config.py b/src/ntr_fetcher/config.py index 2afa83b..164ea10 100644 --- a/src/ntr_fetcher/config.py +++ b/src/ntr_fetcher/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): soundcloud_user: str = "nicktherat" show_day: int = 2 show_hour: int = 22 + show_rotation_delay_hours: int = 0 web_user: str | None = None web_password: str | None = None diff --git a/src/ntr_fetcher/main.py b/src/ntr_fetcher/main.py index 05f5a38..2fc5c71 100644 --- a/src/ntr_fetcher/main.py +++ b/src/ntr_fetcher/main.py @@ -72,6 +72,7 @@ def run() -> None: show_day=settings.show_day, show_hour=settings.show_hour, poll_interval=settings.poll_interval_seconds, + rotation_delay_hours=settings.show_rotation_delay_hours, ) app = create_app( @@ -80,6 +81,7 @@ def run() -> None: admin_token=settings.admin_token, show_day=settings.show_day, show_hour=settings.show_hour, + rotation_delay_hours=settings.show_rotation_delay_hours, web_user=settings.web_user, web_password=settings.web_password, secret_key=settings.secret_key, diff --git a/src/ntr_fetcher/poller.py b/src/ntr_fetcher/poller.py index e021db2..4308bee 100644 --- a/src/ntr_fetcher/poller.py +++ b/src/ntr_fetcher/poller.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from ntr_fetcher.db import Database from ntr_fetcher.soundcloud import SoundCloudClient -from ntr_fetcher.week import get_show_week +from ntr_fetcher.week import get_current_show_week logger = logging.getLogger(__name__) @@ -18,6 +18,7 @@ class Poller: show_day: int, show_hour: int, poll_interval: float, + rotation_delay_hours: float = 0, ): self._db = db self._sc = soundcloud @@ -25,6 +26,7 @@ class Poller: self._show_day = show_day self._show_hour = show_hour self._poll_interval = poll_interval + self._rotation_delay_hours = rotation_delay_hours self._user_id: int | None = None self.last_fetch: datetime | None = None self.alive = True @@ -37,7 +39,9 @@ class Poller: async def poll_once(self, full: bool = False) -> None: user_id = await self._get_user_id() now = datetime.now(timezone.utc) - week_start, week_end = get_show_week(now, self._show_day, self._show_hour) + week_start, week_end = get_current_show_week( + now, self._show_day, self._show_hour, self._rotation_delay_hours, + ) show = self._db.get_or_create_show(week_start, week_end) if show.episode_number is None: diff --git a/src/ntr_fetcher/week.py b/src/ntr_fetcher/week.py index f232561..4edfbe1 100644 --- a/src/ntr_fetcher/week.py +++ b/src/ntr_fetcher/week.py @@ -36,3 +36,23 @@ def get_show_week( week_end_utc = (candidate + timedelta(days=7)).astimezone(timezone.utc).replace(tzinfo=timezone.utc) return week_start_utc, week_end_utc + + +def get_current_show_week( + now_utc: datetime, + show_day: int = SHOW_DAY_DEFAULT, + show_hour: int = SHOW_HOUR_DEFAULT, + rotation_delay_hours: float = 0, +) -> tuple[datetime, datetime]: + """Return the show week that should be treated as "current" right now. + + When *rotation_delay_hours* > 0 the switchover to a new show is postponed + by that many hours after the like-window boundary. During the gap the + previous week's show remains current so the host can view it while + recording. Likes made during the gap are collected by the new show once + it rotates in. + """ + if rotation_delay_hours <= 0: + return get_show_week(now_utc, show_day, show_hour) + effective_now = now_utc - timedelta(hours=rotation_delay_hours) + return get_show_week(effective_now, show_day, show_hour) diff --git a/tests/test_week.py b/tests/test_week.py index c010b50..07b694e 100644 --- a/tests/test_week.py +++ b/tests/test_week.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from ntr_fetcher.week import get_show_week +from ntr_fetcher.week import get_current_show_week, get_show_week def test_mid_week_thursday(): @@ -36,3 +36,50 @@ def test_est_period_no_dst(): start, end = get_show_week(now, show_day=2, show_hour=22) assert start == datetime(2026, 1, 15, 3, 0, 0, tzinfo=timezone.utc) assert end == datetime(2026, 1, 22, 3, 0, 0, tzinfo=timezone.utc) + + +# --- get_current_show_week tests --- + + +def test_current_show_week_zero_delay_matches_get_show_week(): + """delay=0 is a passthrough to get_show_week.""" + now = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + assert get_current_show_week(now, show_day=2, show_hour=22, rotation_delay_hours=0) == \ + get_show_week(now, show_day=2, show_hour=22) + + +def test_current_show_week_in_gap_returns_old_week(): + """Wed 22:30 EST (in the 2-hour gap) should still show the previous week.""" + # Wed Jan 14 22:30 EST = Thu Jan 15 03:30 UTC + now = datetime(2026, 1, 15, 3, 30, 0, tzinfo=timezone.utc) + start, end = get_current_show_week(now, show_day=2, show_hour=22, rotation_delay_hours=2) + # Previous week: Wed Jan 7 22:00 EST = Jan 8 03:00 UTC + assert start == datetime(2026, 1, 8, 3, 0, 0, tzinfo=timezone.utc) + assert end == datetime(2026, 1, 15, 3, 0, 0, tzinfo=timezone.utc) + + +def test_current_show_week_at_rotation_boundary(): + """Thu 00:00 EST (exactly at midnight) should rotate to the new week.""" + # Thu Jan 15 00:00 EST = Thu Jan 15 05:00 UTC + now = datetime(2026, 1, 15, 5, 0, 0, tzinfo=timezone.utc) + start, end = get_current_show_week(now, show_day=2, show_hour=22, rotation_delay_hours=2) + # New week: Wed Jan 14 22:00 EST = Jan 15 03:00 UTC + assert start == datetime(2026, 1, 15, 3, 0, 0, tzinfo=timezone.utc) + assert end == datetime(2026, 1, 22, 3, 0, 0, tzinfo=timezone.utc) + + +def test_current_show_week_well_after_gap(): + """Thursday afternoon is well past the gap — new week regardless of delay.""" + now = datetime(2026, 1, 15, 16, 0, 0, tzinfo=timezone.utc) + start, end = get_current_show_week(now, show_day=2, show_hour=22, rotation_delay_hours=2) + assert start == datetime(2026, 1, 15, 3, 0, 0, tzinfo=timezone.utc) + assert end == datetime(2026, 1, 22, 3, 0, 0, tzinfo=timezone.utc) + + +def test_current_show_week_before_show_time_unaffected(): + """Wednesday before the show (e.g. 3pm ET) is unaffected by rotation delay.""" + # Wed Jan 14 15:00 EST = Wed Jan 14 20:00 UTC + now = datetime(2026, 1, 14, 20, 0, 0, tzinfo=timezone.utc) + with_delay = get_current_show_week(now, show_day=2, show_hour=22, rotation_delay_hours=2) + without_delay = get_show_week(now, show_day=2, show_hour=22) + assert with_delay == without_delay