feat: delay show rotation during live recording window

Decouple the like-window boundary (Wed 10pm ET) from when the system
rotates to a new show. NTR_SHOW_ROTATION_DELAY_HOURS=2 keeps the
previous week's show visible during the ~2 hour recording, then creates
the new show at midnight.

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-01 21:29:42 -04:00
parent a328684af0
commit 82049ab47f
7 changed files with 102 additions and 5 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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)

View File

@@ -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