Compare commits

..

3 Commits

Author SHA1 Message Date
cottongin
a5f77187b3 feat: add dashboard ping button and .env file support
Add a "Send IRC Message" section to the admin dashboard that sends a
configurable privmsg to any IRC nick or channel via all connected bots.
New POST /admin/ping endpoint broadcasts a "privmsg" WebSocket message
type, handled by both Limnoria and Sopel plugins.

Also enable pydantic-settings .env file loading (python-dotenv) and
add .env.example documenting all NTR_* configuration variables.

Made-with: Cursor
2026-04-01 22:20:18 -04:00
cottongin
82049ab47f 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
2026-04-01 21:29:42 -04:00
cottongin
a328684af0 fix: handle SoundCloud API 5xx errors with client_id refresh, backoff, and cursor fallback
SoundCloud began rejecting the fabricated pagination cursor with 500
errors. Fixed cursor user_id padding (zfill 22→20) to match the
documented format, added 5xx retry with exponential backoff in _api_get,
and added a fallback in fetch_likes that drops the fabricated cursor
when it causes persistent 500s.

Made-with: Cursor
2026-03-25 08:20:20 -04:00
16 changed files with 349 additions and 13 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# NtR SoundCloud Fetcher — environment variables
# Copy to .env and fill in values. Shell env vars override these.
# --- Required -----------------------------------------------------------
NTR_ADMIN_TOKEN=changeme
# --- Server --------------------------------------------------------------
# NTR_HOST=127.0.0.1
# NTR_PORT=8000
# NTR_DB_PATH=./ntr_fetcher.db
# NTR_POLL_INTERVAL_SECONDS=3600
# --- SoundCloud -----------------------------------------------------------
# NTR_SOUNDCLOUD_USER=nicktherat
# --- Show schedule --------------------------------------------------------
# NTR_SHOW_DAY=2 # 0=Mon … 6=Sun (default: 2=Wed)
# NTR_SHOW_HOUR=22 # Hour in Eastern time
# NTR_SHOW_ROTATION_DELAY_HOURS=0
# --- Dashboard (all three required to enable) -----------------------------
# NTR_WEB_USER=
# NTR_WEB_PASSWORD=
# NTR_SECRET_KEY=
# --- Ping (optional defaults for the dashboard ping form) -----------------
# NTR_PING_TARGET=
# NTR_PING_MESSAGE=

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ build/
# Environment / secrets # Environment / secrets
.env .env
.env.* .env.*
!.env.example
# AI session artifacts # AI session artifacts
chat-summaries/ chat-summaries/

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 | | EST (Nov -- Mar) | Wed 22:00 | Thu 03:00 |
| EDT (Mar -- Nov) | Wed 22:00 | Thu 02: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 ## WebSocket

View File

@@ -185,6 +185,10 @@ class NtrPlaylist(callbacks.Plugin):
msg = ircmsgs.privmsg(channel, data["message"]) msg = ircmsgs.privmsg(channel, data["message"])
self._irc.queueMsg(msg) self._irc.queueMsg(msg)
LOGGER.info("Announced to %s: %s", channel, data["message"]) LOGGER.info("Announced to %s: %s", channel, data["message"])
elif data.get("type") == "privmsg" and "target" in data and "message" in data:
msg = ircmsgs.privmsg(data["target"], data["message"])
self._irc.queueMsg(msg)
LOGGER.info("Sent privmsg to %s: %s", data["target"], data["message"])
elif data.get("type") == "status": elif data.get("type") == "status":
LOGGER.info( LOGGER.info(
"Status update: %d bot(s) connected, clients=%s", "Status update: %d bot(s) connected, clients=%s",

View File

@@ -95,6 +95,9 @@ def _ws_listener(bot):
if data.get("type") == "announce" and "message" in data: if data.get("type") == "announce" and "message" in data:
bot.say(data["message"], channel) bot.say(data["message"], channel)
LOGGER.info("Announced to %s: %s", channel, data["message"]) LOGGER.info("Announced to %s: %s", channel, data["message"])
elif data.get("type") == "privmsg" and "target" in data and "message" in data:
bot.say(data["message"], data["target"])
LOGGER.info("Sent privmsg to %s: %s", data["target"], data["message"])
elif data.get("type") == "status": elif data.get("type") == "status":
LOGGER.info( LOGGER.info(
"Status update: %d bot(s) connected, clients=%s", "Status update: %d bot(s) connected, clients=%s",

View File

@@ -12,6 +12,7 @@ dependencies = [
"uvicorn[standard]", "uvicorn[standard]",
"httpx", "httpx",
"pydantic-settings", "pydantic-settings",
"python-dotenv",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -5,7 +5,7 @@ from fastapi import FastAPI, HTTPException, Depends, Header
from pydantic import BaseModel from pydantic import BaseModel
from ntr_fetcher.db import Database 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__) logger = logging.getLogger(__name__)
@@ -29,9 +29,12 @@ def create_app(
admin_token: str, admin_token: str,
show_day: int = 2, show_day: int = 2,
show_hour: int = 22, show_hour: int = 22,
rotation_delay_hours: float = 0,
web_user: str | None = None, web_user: str | None = None,
web_password: str | None = None, web_password: str | None = None,
secret_key: str | None = None, secret_key: str | None = None,
ping_target: str | None = None,
ping_message: str | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="NtR SoundCloud Fetcher") app = FastAPI(title="NtR SoundCloud Fetcher")
@@ -44,7 +47,10 @@ def create_app(
def _current_show(): def _current_show():
now = datetime.now(timezone.utc) 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) return db.get_or_create_show(week_start, week_end)
@app.get("/health") @app.get("/health")
@@ -163,6 +169,8 @@ def create_app(
secret_key=secret_key, secret_key=secret_key,
show_day=show_day, show_day=show_day,
show_hour=show_hour, show_hour=show_hour,
ping_target=ping_target or "",
ping_message=ping_message or "",
) )
app.include_router(dashboard_router) app.include_router(dashboard_router)

View File

@@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = {"env_prefix": "NTR_"} model_config = {"env_prefix": "NTR_", "env_file": ".env", "env_file_encoding": "utf-8"}
port: int = 8000 port: int = 8000
host: str = "127.0.0.1" host: str = "127.0.0.1"
@@ -12,11 +12,15 @@ class Settings(BaseSettings):
soundcloud_user: str = "nicktherat" soundcloud_user: str = "nicktherat"
show_day: int = 2 show_day: int = 2
show_hour: int = 22 show_hour: int = 22
show_rotation_delay_hours: int = 0
web_user: str | None = None web_user: str | None = None
web_password: str | None = None web_password: str | None = None
secret_key: str | None = None secret_key: str | None = None
ping_target: str | None = None
ping_message: str | None = None
@property @property
def dashboard_enabled(self) -> bool: def dashboard_enabled(self) -> bool:
return all([self.web_user, self.web_password, self.secret_key]) return all([self.web_user, self.web_password, self.secret_key])

View File

@@ -44,6 +44,11 @@ class AnnounceRequest(BaseModel):
position: int position: int
class PingRequest(BaseModel):
target: str
message: str
def create_dashboard_router( def create_dashboard_router(
db: Database, db: Database,
manager: AnnounceManager, manager: AnnounceManager,
@@ -53,6 +58,8 @@ def create_dashboard_router(
secret_key: str, secret_key: str,
show_day: int = 2, show_day: int = 2,
show_hour: int = 22, show_hour: int = 22,
ping_target: str = "",
ping_message: str = "",
) -> APIRouter: ) -> APIRouter:
router = APIRouter() router = APIRouter()
@@ -98,6 +105,8 @@ def create_dashboard_router(
return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/login", status_code=303)
html = (STATIC_DIR / "dashboard.html").read_text() html = (STATIC_DIR / "dashboard.html").read_text()
html = html.replace("{{WS_TOKEN}}", admin_token) html = html.replace("{{WS_TOKEN}}", admin_token)
html = html.replace("{{PING_TARGET}}", ping_target)
html = html.replace("{{PING_MESSAGE}}", ping_message)
return HTMLResponse(html) return HTMLResponse(html)
@router.post("/admin/announce") @router.post("/admin/announce")
@@ -119,6 +128,22 @@ def create_dashboard_router(
await manager.broadcast({"type": "announce", "message": message}) await manager.broadcast({"type": "announce", "message": message})
return {"status": "announced", "message": message} return {"status": "announced", "message": message}
@router.post("/admin/ping")
async def ping(body: PingRequest, request: Request):
user = _get_session_user(request)
if user is None:
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer ") or auth_header.removeprefix("Bearer ") != admin_token:
raise HTTPException(status_code=401, detail="Unauthorized")
target = body.target.strip()
message = body.message.strip()
if not target or not message:
raise HTTPException(status_code=422, detail="Target and message must not be empty")
await manager.broadcast({"type": "privmsg", "target": target, "message": message})
return {"status": "sent", "target": target, "message": message}
@router.websocket("/ws/announce") @router.websocket("/ws/announce")
async def ws_announce(websocket: WebSocket): async def ws_announce(websocket: WebSocket):
await websocket.accept() await websocket.accept()

View File

@@ -72,6 +72,7 @@ def run() -> None:
show_day=settings.show_day, show_day=settings.show_day,
show_hour=settings.show_hour, show_hour=settings.show_hour,
poll_interval=settings.poll_interval_seconds, poll_interval=settings.poll_interval_seconds,
rotation_delay_hours=settings.show_rotation_delay_hours,
) )
app = create_app( app = create_app(
@@ -80,9 +81,12 @@ def run() -> None:
admin_token=settings.admin_token, admin_token=settings.admin_token,
show_day=settings.show_day, show_day=settings.show_day,
show_hour=settings.show_hour, show_hour=settings.show_hour,
rotation_delay_hours=settings.show_rotation_delay_hours,
web_user=settings.web_user, web_user=settings.web_user,
web_password=settings.web_password, web_password=settings.web_password,
secret_key=settings.secret_key, secret_key=settings.secret_key,
ping_target=settings.ping_target,
ping_message=settings.ping_message,
) )
@app.on_event("startup") @app.on_event("startup")

View File

@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from ntr_fetcher.db import Database from ntr_fetcher.db import Database
from ntr_fetcher.soundcloud import SoundCloudClient 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__) logger = logging.getLogger(__name__)
@@ -18,6 +18,7 @@ class Poller:
show_day: int, show_day: int,
show_hour: int, show_hour: int,
poll_interval: float, poll_interval: float,
rotation_delay_hours: float = 0,
): ):
self._db = db self._db = db
self._sc = soundcloud self._sc = soundcloud
@@ -25,6 +26,7 @@ class Poller:
self._show_day = show_day self._show_day = show_day
self._show_hour = show_hour self._show_hour = show_hour
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._rotation_delay_hours = rotation_delay_hours
self._user_id: int | None = None self._user_id: int | None = None
self.last_fetch: datetime | None = None self.last_fetch: datetime | None = None
self.alive = True self.alive = True
@@ -37,7 +39,9 @@ class Poller:
async def poll_once(self, full: bool = False) -> None: async def poll_once(self, full: bool = False) -> None:
user_id = await self._get_user_id() user_id = await self._get_user_id()
now = datetime.now(timezone.utc) 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) show = self._db.get_or_create_show(week_start, week_end)
if show.episode_number is None: if show.episode_number is None:

View File

@@ -1,3 +1,4 @@
import asyncio
import json import json
import logging import logging
import re import re
@@ -17,7 +18,7 @@ HYDRATION_PATTERN = re.compile(r"__sc_hydration\s*=\s*(\[.*?\])\s*;", re.DOTALL)
def _build_cursor(until: datetime, user_id: int) -> str: def _build_cursor(until: datetime, user_id: int) -> str:
ts = until.strftime("%Y-%m-%dT%H:%M:%S.000Z") ts = until.strftime("%Y-%m-%dT%H:%M:%S.000Z")
padded_user = str(user_id).zfill(22) padded_user = str(user_id).zfill(20)
return f"{ts},user-track-likes,000-{padded_user}-99999999999999999999" return f"{ts},user-track-likes,000-{padded_user}-99999999999999999999"
@@ -55,19 +56,27 @@ class SoundCloudClient:
params = dict(params or {}) params = dict(params or {})
params["client_id"] = client_id params["client_id"] = client_id
for attempt in range(3): max_attempts = 3
for attempt in range(max_attempts):
resp = await self._http.get(url, params=params) resp = await self._http.get(url, params=params)
if resp.status_code == 401:
logger.warning("Got 401 from SoundCloud API, refreshing client_id (attempt %d)", attempt + 1) if resp.status_code == 401 or resp.status_code >= 500:
logger.warning(
"Got %d from SoundCloud API, refreshing client_id (attempt %d/%d)",
resp.status_code, attempt + 1, max_attempts,
)
self.invalidate_client_id() self.invalidate_client_id()
if resp.status_code >= 500:
await asyncio.sleep(2 ** attempt)
client_id = await self._extract_client_id() client_id = await self._extract_client_id()
params["client_id"] = client_id params["client_id"] = client_id
continue continue
resp.raise_for_status() resp.raise_for_status()
return resp return resp
raise httpx.HTTPStatusError( raise httpx.HTTPStatusError(
"Failed after 3 attempts (401)", f"Failed after {max_attempts} attempts (last status: {resp.status_code})",
request=resp.request, request=resp.request,
response=resp, response=resp,
) )
@@ -86,15 +95,24 @@ class SoundCloudClient:
until: datetime, until: datetime,
limit: int = 50, limit: int = 50,
) -> list[Track]: ) -> list[Track]:
cursor = _build_cursor(until, user_id) cursor: str | None = _build_cursor(until, user_id)
collected: list[Track] = [] collected: list[Track] = []
used_fabricated_cursor = True
while True: while True:
params: dict = {"limit": limit} params: dict = {"limit": limit}
if cursor: if cursor:
params["offset"] = cursor params["offset"] = cursor
resp = await self._api_get(f"{API_BASE}/users/{user_id}/likes", params=params) try:
resp = await self._api_get(f"{API_BASE}/users/{user_id}/likes", params=params)
except httpx.HTTPStatusError as exc:
if used_fabricated_cursor and cursor and exc.response.status_code >= 500:
logger.warning("Fabricated cursor rejected (HTTP %d), retrying without cursor", exc.response.status_code)
cursor = None
used_fabricated_cursor = False
continue
raise
data = resp.json() data = resp.json()
collection = data.get("collection", []) collection = data.get("collection", [])

View File

@@ -67,6 +67,21 @@
font-weight: 600; font-weight: 600;
} }
.btn-group { display: flex; gap: 4px; } .btn-group { display: flex; gap: 4px; }
.ping-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
}
.ping-form {
display: flex;
gap: 8px;
align-items: end;
flex-wrap: wrap;
}
.ping-form label { margin-bottom: 0; }
.ping-form input { margin-bottom: 0; }
.ping-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ping-btn.success { background: #4caf50; border-color: #4caf50; pointer-events: none; }
</style> </style>
</head> </head>
<body> <body>
@@ -89,6 +104,22 @@
<p>Loading shows...</p> <p>Loading shows...</p>
</section> </section>
<section class="ping-section">
<h4>Send IRC Message</h4>
<div class="ping-form">
<label>
Target
<input type="text" id="ping-target" value="{{PING_TARGET}}" placeholder="nick or #channel">
</label>
<label>
Message
<input type="text" id="ping-message" value="{{PING_MESSAGE}}" placeholder="message text">
</label>
<button class="ping-btn" id="ping-btn" disabled title="No bots connected"
onclick="sendPing(this)">Send</button>
</div>
</section>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
</main> </main>
@@ -256,6 +287,39 @@
} }
} }
async function sendPing(btn) {
const target = document.getElementById("ping-target").value.trim();
const message = document.getElementById("ping-message").value.trim();
if (!target || !message) {
showToast("Target and message are required", true);
return;
}
btn.disabled = true;
btn.textContent = "...";
try {
const resp = await fetch("/admin/ping", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({target, message}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || "Send failed");
}
btn.textContent = "\u2713";
btn.classList.add("success");
setTimeout(() => {
btn.textContent = "Send";
btn.classList.remove("success");
btn.disabled = subscriberCount === 0;
}, 2000);
} catch (e) {
showToast(e.message, true);
btn.textContent = "Send";
btn.disabled = subscriberCount === 0;
}
}
function updateStatus(count, clients) { function updateStatus(count, clients) {
subscriberCount = count; subscriberCount = count;
const dot = document.getElementById("status-dot"); const dot = document.getElementById("status-dot");
@@ -288,6 +352,11 @@
btn.title = ""; btn.title = "";
} }
}); });
const pingBtn = document.getElementById("ping-btn");
if (pingBtn && !pingBtn.classList.contains("success")) {
pingBtn.disabled = count === 0;
pingBtn.title = count === 0 ? "No bots connected" : "";
}
} }
let wsBackoff = 1000; let wsBackoff = 1000;

View File

@@ -36,3 +36,23 @@ def get_show_week(
week_end_utc = (candidate + timedelta(days=7)).astimezone(timezone.utc).replace(tzinfo=timezone.utc) week_end_utc = (candidate + timedelta(days=7)).astimezone(timezone.utc).replace(tzinfo=timezone.utc)
return week_start_utc, week_end_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,8 @@
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch
import httpx
import pytest import pytest
from ntr_fetcher.soundcloud import SoundCloudClient from ntr_fetcher.soundcloud import SoundCloudClient
@@ -151,3 +153,76 @@ async def test_fetch_likes_retries_on_401(httpx_mock):
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc), until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
) )
assert len(tracks) == 1 assert len(tracks) == 1
@pytest.mark.asyncio
@patch("ntr_fetcher.soundcloud.asyncio.sleep", return_value=None)
async def test_fetch_likes_retries_on_500(mock_sleep, httpx_mock):
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
httpx_mock.add_response(
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
status_code=500,
)
httpx_mock.add_response(
url="https://soundcloud.com",
text=FAKE_HTML.replace("test_client_id_abc123", "fresh_client_id_789"),
)
httpx_mock.add_response(
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
json=FAKE_LIKES_RESPONSE,
)
client = SoundCloudClient()
tracks = await client.fetch_likes(
user_id=206979918,
since=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc),
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
)
assert len(tracks) == 1
mock_sleep.assert_called_once_with(1)
@pytest.mark.asyncio
@patch("ntr_fetcher.soundcloud.asyncio.sleep", return_value=None)
async def test_fetch_likes_falls_back_to_no_cursor_on_persistent_500(mock_sleep, httpx_mock):
"""When the fabricated cursor causes persistent 500s, fall back to no cursor."""
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
for _ in range(3):
httpx_mock.add_response(
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
status_code=500,
)
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
httpx_mock.add_response(
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
json=FAKE_LIKES_RESPONSE,
)
client = SoundCloudClient()
tracks = await client.fetch_likes(
user_id=206979918,
since=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc),
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
)
assert len(tracks) == 1
assert mock_sleep.call_count == 3
@pytest.mark.asyncio
@patch("ntr_fetcher.soundcloud.asyncio.sleep", return_value=None)
async def test_fetch_likes_raises_when_all_requests_fail_500(mock_sleep, httpx_mock):
"""When both fabricated cursor and cursorless fallback fail, the error propagates."""
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
# 3 retries for fabricated cursor + 3 retries for cursorless fallback = 6 API calls
for _ in range(6):
httpx_mock.add_response(
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
status_code=500,
)
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
client = SoundCloudClient()
with pytest.raises(httpx.HTTPStatusError, match="500"):
await client.fetch_likes(
user_id=206979918,
since=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc),
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
)
assert mock_sleep.call_count == 6

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timezone 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(): 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) 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 start == datetime(2026, 1, 15, 3, 0, 0, tzinfo=timezone.utc)
assert end == datetime(2026, 1, 22, 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