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:
19
docs/api.md
19
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user