Compare commits

...

7 Commits

Author SHA1 Message Date
cottongin
d014a14cbe docs: update README with public endpoints, auth changes, and new features
Reflects all changes since the initial README: Bearer auth on internal
endpoints, public index page and /public/* API, dashboard enhancements
(announced checkbox, ping button, tabbed UI, copy-to-clipboard), .env
file support, and new config variables.

Made-with: Cursor
2026-04-02 12:42:29 -04:00
cottongin
4dd3f43ae3 feat: lock playlist/shows endpoints behind Bearer token auth
The five internal endpoints (/playlist, /playlist/{position}, /shows,
/shows/by-episode/{ep}, /shows/{id}) now require admin authentication.
Dashboard JS, Sopel plugin, and Limnoria plugin updated to send the
token on GET requests. Six new 401 tests added.

Made-with: Cursor
2026-04-02 12:06:27 -04:00
cottongin
425a7047c3 feat: add public index page with censored playlist and live reveals
Public-facing page at / shows the current show's playlist with tracks
obscured until the admin marks them as announced. Tracks reveal in
real-time via a new unauthenticated /ws/public WebSocket. Server-side
censorship on /public/playlist strips track details from unannounced
items and sanitizes announced tracks to only expose frontend-needed
fields (no raw_json, track_id, etc). Past episodes are browsable with
fully revealed but sanitized tracklists.

Also fixes RuntimeError on backfill shutdown by closing the httpx
client on the same event loop that created it.

Made-with: Cursor
2026-04-01 23:50:41 -04:00
cottongin
11f13c86b5 feat: add announced checkbox to track rows
Add a persistent "announced" checkbox after each track's Announce button.
The state is stored in a new `announced` column on `show_tracks` and is
auto-set when the Announce button is pressed. The checkbox is also freely
togglable, and announced tracks have their Announce button disabled.

Also fixes .env leakage in test_config.py (pass _env_file=None) and adds
tests for the new DB method, API endpoint, and announce side-effect.

Made-with: Cursor
2026-04-01 23:00:09 -04:00
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
25 changed files with 1488 additions and 89 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=

2
.gitignore vendored
View File

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

View File

@@ -11,7 +11,7 @@ API.
```bash
pip install -e ".[dev]"
export NTR_ADMIN_TOKEN="your-secret-here"
cp .env.example .env # edit with your settings
ntr-fetcher
```
@@ -36,12 +36,16 @@ Full documentation: [`docs/api.md`](docs/api.md)
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/` | GET | -- | Public index page |
| `/health` | GET | -- | Service health check |
| `/playlist` | GET | -- | Current week's playlist |
| `/playlist/{position}` | GET | -- | Single track by position (1-indexed) |
| `/shows` | GET | -- | List all shows (paginated) |
| `/shows/by-episode/{episode_number}` | GET | -- | Look up show by episode number |
| `/shows/{show_id}` | GET | -- | Specific show by internal ID |
| `/public/playlist` | GET | -- | Current week's playlist (unannounced tracks censored) |
| `/public/shows` | GET | -- | List all shows (paginated) |
| `/public/shows/{show_id}` | GET | -- | Past show with full tracklist |
| `/playlist` | GET | Bearer | Current week's full playlist |
| `/playlist/{position}` | GET | Bearer | Single track by position (1-indexed) |
| `/shows` | GET | Bearer | List all shows (paginated) |
| `/shows/by-episode/{episode_number}` | GET | Bearer | Look up show by episode number |
| `/shows/{show_id}` | GET | Bearer | Specific show by internal ID |
| `/login` | GET/POST | -- | Login page |
| `/logout` | GET | Session | Clear session |
| `/dashboard` | GET | Session | Live playlist dashboard |
@@ -50,10 +54,16 @@ Full documentation: [`docs/api.md`](docs/api.md)
| `/admin/tracks/{track_id}` | DELETE | Bearer | Remove track from current show |
| `/admin/tracks/{track_id}/position` | PUT | Bearer | Move track to new position |
| `/admin/announce` | POST | Bearer/Session | Announce track to IRC |
| `/admin/announced` | POST | Bearer/Session | Toggle track announced state |
| `/admin/ping` | POST | Bearer/Session | Send IRC message via connected bots |
| `/ws/announce` | WS | Bearer | Bot WebSocket for announcements |
| `/ws/public` | WS | -- | Public WebSocket for live track reveals |
## Configuration
Environment variables (prefix `NTR_`):
All settings are read from environment variables (prefix `NTR_`). A `.env`
file in the project root is loaded automatically. See
[`.env.example`](.env.example) for a template with all available variables.
| Variable | Default | Description |
|----------|---------|-------------|
@@ -65,13 +75,35 @@ Environment variables (prefix `NTR_`):
| `NTR_SOUNDCLOUD_USER` | `nicktherat` | SoundCloud username to track |
| `NTR_SHOW_DAY` | `2` | Day of week for show (0=Mon, 2=Wed) |
| `NTR_SHOW_HOUR` | `22` | Hour (Eastern Time) when the show starts |
| `NTR_SHOW_ROTATION_DELAY_HOURS` | `0` | Hours to wait after the show boundary before rotating to the next episode (recommended `2` for live recording window) |
| `NTR_PING_TARGET` | *(empty)* | Default nick/target for the dashboard ping feature |
| `NTR_PING_MESSAGE` | *(empty)* | Default message for the dashboard ping feature |
## Public Index Page
The root URL (`/`) serves a public-facing page showing the current show's
playlist. Unannounced tracks are hidden behind silhouette cards until the
admin announces them from the dashboard. Reveals happen in real time over
WebSocket (`/ws/public`), so listeners following along see tracks appear
as they are played.
Past episodes are browsable from the same page with their full tracklists
visible.
## Dashboard
An optional web dashboard for announcing tracks to IRC during live shows.
Nick opens the dashboard, sees the current and previous week's playlist, and
clicks "Announce" next to a track to push it to the configured IRC channel
via connected bot plugins.
An optional web dashboard for managing tracks during live shows. Features:
- **Tabbed show interface** -- switch between the current and previous week's
playlists in one view.
- **Announce button** -- push a track to IRC via connected bot plugins. Also
reveals the track on the public index page in real time.
- **Announced checkbox** -- persistent per-track flag, auto-checked on
announce. Can be toggled manually.
- **Copy to clipboard** -- one-click copy of a formatted track line.
- **Send IRC Message** -- ping a nick or channel with a custom message through
all connected bots. Default target and message are configurable via
`NTR_PING_TARGET` / `NTR_PING_MESSAGE`.
Enable by setting these environment variables:

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

@@ -28,9 +28,12 @@ class ApiError(Exception):
super().__init__(f"{status_code}: {detail}")
def _api_get(base_url: str, path: str) -> dict:
def _api_get(base_url: str, path: str, token: str = "") -> dict:
url = f"{base_url.rstrip('/')}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
@@ -185,6 +188,10 @@ class NtrPlaylist(callbacks.Plugin):
msg = ircmsgs.privmsg(channel, data["message"])
self._irc.queueMsg(msg)
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":
LOGGER.info(
"Status update: %d bot(s) connected, clients=%s",
@@ -230,8 +237,9 @@ class NtrPlaylist(callbacks.Plugin):
if match:
position = match.group(1)
base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
try:
data = _api_get(base_url, f"/playlist/{position}")
data = _api_get(base_url, f"/playlist/{position}", token)
irc.reply(format_track(data))
except ApiError as exc:
LOGGER.warning("API error for !%s: %s", position, exc)
@@ -256,8 +264,9 @@ class NtrPlaylist(callbacks.Plugin):
irc.reply("Usage: !song <episode> <position>")
return
base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
except ApiError as exc:
LOGGER.warning("API error for !song %s %s: %s", episode, position, exc)
irc.reply(exc.detail)
@@ -276,6 +285,7 @@ class NtrPlaylist(callbacks.Plugin):
Returns the playlist for the current show, or a specific episode.
"""
base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
if text and text.strip():
try:
episode = int(text.strip())
@@ -283,14 +293,14 @@ class NtrPlaylist(callbacks.Plugin):
irc.reply("Usage: !playlist [episode]")
return
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
except ApiError as exc:
LOGGER.warning("API error for playlist: %s", exc)
irc.reply(exc.detail)
return
else:
try:
data = _api_get(base_url, "/playlist")
data = _api_get(base_url, "/playlist", token)
except ApiError as exc:
LOGGER.warning("API error for playlist: %s", exc)
irc.reply(exc.detail)
@@ -312,8 +322,9 @@ class NtrPlaylist(callbacks.Plugin):
irc.reply("Usage: !lastshow <position>")
return
base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
try:
shows = _api_get(base_url, "/shows?limit=2")
shows = _api_get(base_url, "/shows?limit=2", token)
except ApiError as exc:
LOGGER.warning("API error for lastshow: %s", exc)
irc.reply(exc.detail)
@@ -323,7 +334,7 @@ class NtrPlaylist(callbacks.Plugin):
return
prev_show_id = shows[1]["id"]
try:
data = _api_get(base_url, f"/shows/{prev_show_id}")
data = _api_get(base_url, f"/shows/{prev_show_id}", token)
except ApiError as exc:
LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc)
irc.reply(exc.detail)

View File

@@ -95,6 +95,9 @@ def _ws_listener(bot):
if data.get("type") == "announce" and "message" in data:
bot.say(data["message"], channel)
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":
LOGGER.info(
"Status update: %d bot(s) connected, clients=%s",
@@ -140,9 +143,12 @@ class ApiError(Exception):
super().__init__(f"{status_code}: {detail}")
def _api_get(base_url: str, path: str) -> dict:
def _api_get(base_url: str, path: str, token: str = "") -> dict:
url = f"{base_url.rstrip('/')}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
@@ -232,9 +238,10 @@ def _is_admin(bot, nick: str) -> bool:
@plugin.rule(r"^!(\d+)$")
def ntr_playlist_position(bot, trigger):
base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
position = trigger.group(1)
try:
data = _api_get(base_url, f"/playlist/{position}")
data = _api_get(base_url, f"/playlist/{position}", token)
bot.say(format_track(data))
except ApiError as e:
LOGGER.warning("API error for !%s: %s", position, e)
@@ -258,8 +265,9 @@ def ntr_song(bot, trigger):
bot.say("Usage: !song <episode> <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
except ApiError as e:
LOGGER.warning("API error for !song %s %s: %s", episode, position, e)
bot.say(e.detail)
@@ -276,6 +284,7 @@ def ntr_song(bot, trigger):
def ntr_playlist(bot, trigger):
raw = trigger.group(2)
base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
if raw and raw.strip():
try:
episode = int(raw.strip())
@@ -283,14 +292,14 @@ def ntr_playlist(bot, trigger):
bot.say("Usage: !playlist [episode]")
return
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail)
return
else:
try:
data = _api_get(base_url, "/playlist")
data = _api_get(base_url, "/playlist", token)
except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail)
@@ -310,8 +319,9 @@ def ntr_lastshow(bot, trigger):
bot.say("Usage: !lastshow <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
try:
shows = _api_get(base_url, "/shows?limit=2")
shows = _api_get(base_url, "/shows?limit=2", token)
except ApiError as e:
LOGGER.warning("API error for !lastshow: %s", e)
bot.say(e.detail)
@@ -321,7 +331,7 @@ def ntr_lastshow(bot, trigger):
return
prev_show_id = shows[1]["id"]
try:
data = _api_get(base_url, f"/shows/{prev_show_id}")
data = _api_get(base_url, f"/shows/{prev_show_id}", token)
except ApiError as e:
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
bot.say(e.detail)

View File

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

View File

@@ -1,11 +1,15 @@
import logging
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, HTTPException, Depends, Header
from fastapi.responses import HTMLResponse
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
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -29,9 +33,12 @@ 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,
ping_target: str | None = None,
ping_message: str | None = None,
) -> FastAPI:
app = FastAPI(title="NtR SoundCloud Fetcher")
@@ -44,7 +51,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")
@@ -59,7 +69,7 @@ def create_app(
}
@app.get("/playlist")
def playlist():
def playlist(_=Depends(_require_admin)):
show = _current_show()
tracks = db.get_show_tracks(show.id)
return {
@@ -71,7 +81,7 @@ def create_app(
}
@app.get("/playlist/{position}")
def playlist_track(position: int):
def playlist_track(position: int, _=Depends(_require_admin)):
show = _current_show()
track = db.get_show_track_by_position(show.id, position)
if track is None:
@@ -79,7 +89,7 @@ def create_app(
return track
@app.get("/shows")
def list_shows(limit: int = 20, offset: int = 0):
def list_shows(limit: int = 20, offset: int = 0, _=Depends(_require_admin)):
shows = db.list_shows(limit=limit, offset=offset)
return [
{
@@ -93,7 +103,7 @@ def create_app(
]
@app.get("/shows/by-episode/{episode_number}")
def show_by_episode(episode_number: int):
def show_by_episode(episode_number: int, _=Depends(_require_admin)):
show = db.get_show_by_episode_number(episode_number)
if show is None:
raise HTTPException(status_code=404, detail=f"No show with episode number {episode_number}")
@@ -107,7 +117,7 @@ def create_app(
}
@app.get("/shows/{show_id}")
def show_detail(show_id: int):
def show_detail(show_id: int, _=Depends(_require_admin)):
shows = db.list_shows(limit=1000, offset=0)
show = next((s for s in shows if s.id == show_id), None)
if show is None:
@@ -121,6 +131,70 @@ def create_app(
"tracks": tracks,
}
# --- Public endpoints (no auth) ---
_PUBLIC_TRACK_FIELDS = ("position", "announced", "title", "artist",
"artwork_url", "permalink_url", "duration_ms")
def _sanitize_track(t: dict) -> dict:
"""Return only the fields the public frontend needs."""
return {k: t[k] for k in _PUBLIC_TRACK_FIELDS if k in t}
def _censor_tracks(tracks: list[dict]) -> list[dict]:
"""Strip details from unannounced tracks, sanitize announced ones."""
result = []
for t in tracks:
if t.get("announced"):
result.append(_sanitize_track(t))
else:
result.append({"position": t["position"], "announced": 0})
return result
@app.get("/", response_class=HTMLResponse)
def index_page():
html = (STATIC_DIR / "index.html").read_text()
return HTMLResponse(html)
@app.get("/public/playlist")
def public_playlist():
show = _current_show()
tracks = db.get_show_tracks(show.id)
return {
"show_id": show.id,
"episode_number": show.episode_number,
"week_start": show.week_start.isoformat(),
"week_end": show.week_end.isoformat(),
"tracks": _censor_tracks(tracks),
}
@app.get("/public/shows")
def public_list_shows(limit: int = 50, offset: int = 0):
shows = db.list_shows(limit=limit, offset=offset)
return [
{
"id": s.id,
"episode_number": s.episode_number,
"week_start": s.week_start.isoformat(),
"week_end": s.week_end.isoformat(),
}
for s in shows
]
@app.get("/public/shows/{show_id}")
def public_show_detail(show_id: int):
shows = db.list_shows(limit=1000, offset=0)
show = next((s for s in shows if s.id == show_id), None)
if show is None:
raise HTTPException(status_code=404, detail="Show not found")
tracks = db.get_show_tracks(show.id)
return {
"show_id": show.id,
"episode_number": show.episode_number,
"week_start": show.week_start.isoformat(),
"week_end": show.week_end.isoformat(),
"tracks": [_sanitize_track(t) for t in tracks],
}
@app.post("/admin/refresh")
async def admin_refresh(body: RefreshRequest = RefreshRequest(), _=Depends(_require_admin)):
await poller.poll_once(full=body.full)
@@ -152,17 +226,21 @@ def create_app(
if all([web_user, web_password, secret_key]):
from ntr_fetcher.dashboard import create_dashboard_router
from ntr_fetcher.websocket import AnnounceManager
from ntr_fetcher.websocket import AnnounceManager, PublicManager
manager = AnnounceManager()
public_manager = PublicManager()
dashboard_router = create_dashboard_router(
db=db,
manager=manager,
public_manager=public_manager,
admin_token=admin_token,
web_user=web_user,
web_password=web_password,
secret_key=secret_key,
show_day=show_day,
show_hour=show_hour,
ping_target=ping_target or "",
ping_message=ping_message or "",
)
app.include_router(dashboard_router)

View File

@@ -2,7 +2,7 @@ from pydantic_settings import 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
host: str = "127.0.0.1"
@@ -12,11 +12,15 @@ 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
secret_key: str | None = None
ping_target: str | None = None
ping_message: str | None = None
@property
def dashboard_enabled(self) -> bool:
return all([self.web_user, self.web_password, self.secret_key])

View File

@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from ntr_fetcher.db import Database
from ntr_fetcher.websocket import AnnounceManager
from ntr_fetcher.websocket import AnnounceManager, PublicManager
logger = logging.getLogger(__name__)
@@ -44,15 +44,29 @@ class AnnounceRequest(BaseModel):
position: int
class AnnouncedRequest(BaseModel):
show_id: int
position: int
announced: bool
class PingRequest(BaseModel):
target: str
message: str
def create_dashboard_router(
db: Database,
manager: AnnounceManager,
public_manager: PublicManager,
admin_token: str,
web_user: str,
web_password: str,
secret_key: str,
show_day: int = 2,
show_hour: int = 22,
ping_target: str = "",
ping_message: str = "",
) -> APIRouter:
router = APIRouter()
@@ -98,6 +112,8 @@ def create_dashboard_router(
return RedirectResponse(url="/login", status_code=303)
html = (STATIC_DIR / "dashboard.html").read_text()
html = html.replace("{{WS_TOKEN}}", admin_token)
html = html.replace("{{PING_TARGET}}", ping_target)
html = html.replace("{{PING_MESSAGE}}", ping_message)
return HTMLResponse(html)
@router.post("/admin/announce")
@@ -117,8 +133,68 @@ def create_dashboard_router(
f"{track['title']} by {track['artist']} - {track['permalink_url']}"
)
await manager.broadcast({"type": "announce", "message": message})
db.set_track_announced(body.show_id, body.position, True)
await public_manager.broadcast({
"type": "reveal",
"position": track["position"],
"track": {
"title": track["title"],
"artist": track["artist"],
"artwork_url": track["artwork_url"],
"permalink_url": track["permalink_url"],
"duration_ms": track["duration_ms"],
},
})
return {"status": "announced", "message": message}
@router.post("/admin/announced")
async def set_announced(body: AnnouncedRequest, 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")
track = db.get_show_track_by_position(body.show_id, body.position)
if track is None:
raise HTTPException(status_code=404, detail=f"No track at position {body.position}")
db.set_track_announced(body.show_id, body.position, body.announced)
if body.announced:
await public_manager.broadcast({
"type": "reveal",
"position": track["position"],
"track": {
"title": track["title"],
"artist": track["artist"],
"artwork_url": track["artwork_url"],
"permalink_url": track["permalink_url"],
"duration_ms": track["duration_ms"],
},
})
else:
await public_manager.broadcast({
"type": "hide",
"position": track["position"],
})
return {"status": "ok"}
@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")
async def ws_announce(websocket: WebSocket):
await websocket.accept()
@@ -150,4 +226,16 @@ def create_dashboard_router(
except Exception:
pass
@router.websocket("/ws/public")
async def ws_public(websocket: WebSocket):
await websocket.accept()
public_manager.add_client(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
public_manager.remove_client(websocket)
return router

View File

@@ -55,6 +55,11 @@ class Database:
conn.commit()
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE show_tracks ADD COLUMN announced INTEGER NOT NULL DEFAULT 0")
conn.commit()
except sqlite3.OperationalError:
pass
conn.close()
def upsert_track(self, track: Track) -> None:
@@ -154,9 +159,9 @@ class Database:
conn = self._connect()
rows = conn.execute(
"""
SELECT st.show_id, st.track_id, st.position, t.title, t.artist,
t.permalink_url, t.artwork_url, t.duration_ms, t.license,
t.liked_at, t.raw_json
SELECT st.show_id, st.track_id, st.position, st.announced,
t.title, t.artist, t.permalink_url, t.artwork_url,
t.duration_ms, t.license, t.liked_at, t.raw_json
FROM show_tracks st
JOIN tracks t ON st.track_id = t.id
WHERE st.show_id = ?
@@ -173,9 +178,9 @@ class Database:
conn = self._connect()
row = conn.execute(
"""
SELECT st.show_id, st.track_id, st.position, t.title, t.artist,
t.permalink_url, t.artwork_url, t.duration_ms, t.license,
t.liked_at, t.raw_json
SELECT st.show_id, st.track_id, st.position, st.announced,
t.title, t.artist, t.permalink_url, t.artwork_url,
t.duration_ms, t.license, t.liked_at, t.raw_json
FROM show_tracks st
JOIN tracks t ON st.track_id = t.id
WHERE st.show_id = ? AND st.position = ?
@@ -185,6 +190,17 @@ class Database:
conn.close()
return dict(row) if row else None
def set_track_announced(
self, show_id: int, position: int, announced: bool
) -> None:
conn = self._connect()
conn.execute(
"UPDATE show_tracks SET announced = ? WHERE show_id = ? AND position = ?",
(int(announced), show_id, position),
)
conn.commit()
conn.close()
def set_show_tracks(self, show_id: int, track_ids: list[int]) -> None:
conn = self._connect()
if track_ids:

View File

@@ -49,18 +49,22 @@ def run() -> None:
if args.init:
sc = SoundCloudClient()
asyncio.run(
run_backfill(
db=db,
soundcloud=sc,
soundcloud_user=settings.soundcloud_user,
show_day=settings.show_day,
show_hour=settings.show_hour,
anchor_episode=args.show,
anchor_aired=args.aired,
)
)
asyncio.run(sc.close())
async def _backfill_and_close():
try:
await run_backfill(
db=db,
soundcloud=sc,
soundcloud_user=settings.soundcloud_user,
show_day=settings.show_day,
show_hour=settings.show_hour,
anchor_episode=args.show,
anchor_aired=args.aired,
)
finally:
await sc.close()
asyncio.run(_backfill_and_close())
logger.info("Backfill complete")
return
@@ -72,6 +76,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,9 +85,12 @@ 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,
ping_target=settings.ping_target,
ping_message=settings.ping_message,
)
@app.on_event("startup")

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

@@ -1,3 +1,4 @@
import asyncio
import json
import logging
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:
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"
@@ -55,19 +56,27 @@ class SoundCloudClient:
params = dict(params or {})
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)
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()
if resp.status_code >= 500:
await asyncio.sleep(2 ** attempt)
client_id = await self._extract_client_id()
params["client_id"] = client_id
continue
resp.raise_for_status()
return resp
raise httpx.HTTPStatusError(
"Failed after 3 attempts (401)",
f"Failed after {max_attempts} attempts (last status: {resp.status_code})",
request=resp.request,
response=resp,
)
@@ -86,15 +95,24 @@ class SoundCloudClient:
until: datetime,
limit: int = 50,
) -> list[Track]:
cursor = _build_cursor(until, user_id)
cursor: str | None = _build_cursor(until, user_id)
collected: list[Track] = []
used_fabricated_cursor = True
while True:
params: dict = {"limit": limit}
if 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()
collection = data.get("collection", [])

View File

@@ -66,7 +66,23 @@
border-bottom-color: #4caf50;
font-weight: 600;
}
.btn-group { display: flex; gap: 4px; }
.btn-group { display: flex; gap: 8px; align-items: center; }
.announced-check { accent-color: #4caf50; cursor: pointer; width: 28px; height: 28px; margin: 0; }
.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>
</head>
<body>
@@ -89,6 +105,22 @@
<p>Loading shows...</p>
</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>
</main>
@@ -99,6 +131,10 @@
let showCache = {};
let activeShowId = null;
function authFetch(url) {
return fetch(url, { headers: { "Authorization": "Bearer " + WS_TOKEN } });
}
function showToast(msg, isError) {
const t = document.getElementById("toast");
t.textContent = msg;
@@ -118,7 +154,10 @@
html += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th></th>';
html += '</tr></thead><tbody>';
for (const t of tracks) {
const disabled = subscriberCount === 0 ? 'disabled title="No bots connected"' : "";
const noBot = subscriberCount === 0;
const announced = !!t.announced;
const annDisabled = (noBot || announced) ? 'disabled' : '';
const annTitle = noBot ? 'title="No bots connected"' : announced ? 'title="Already announced"' : '';
const copyText = `${t.title} by ${t.artist} - ${t.permalink_url}`;
html += `<tr>
<td class="track-num">${t.position}</td>
@@ -128,8 +167,11 @@
<div class="btn-group">
<button class="btn-sm copy-btn outline"
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
<button class="btn-sm announce-btn" ${disabled}
<button class="btn-sm announce-btn" ${annDisabled} ${annTitle}
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
<input type="checkbox" class="announced-check"
${announced ? 'checked' : ''}
onchange="toggleAnnounced(${showId}, ${t.position}, this)">
</div>
</td>
</tr>`;
@@ -140,7 +182,7 @@
async function loadAllShows() {
try {
const resp = await fetch("/shows?limit=200");
const resp = await authFetch("/shows?limit=200");
if (!resp.ok) throw new Error("Failed to load shows");
allShows = await resp.json();
} catch (e) {
@@ -148,7 +190,7 @@
}
try {
const resp = await fetch("/playlist");
const resp = await authFetch("/playlist");
if (!resp.ok) throw new Error("Failed to load current playlist");
const current = await resp.json();
showCache[current.show_id] = current;
@@ -197,7 +239,7 @@
document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
try {
const resp = await fetch(`/shows/${showId}`);
const resp = await authFetch(`/shows/${showId}`);
if (!resp.ok) throw new Error("Failed to load show");
const data = await resp.json();
showCache[showId] = data;
@@ -244,10 +286,18 @@
}
btn.textContent = "\u2713";
btn.classList.add("success");
const cb = btn.closest("tr").querySelector(".announced-check");
if (cb) cb.checked = true;
const cached = showCache[showId];
if (cached) {
const track = cached.tracks.find(t => t.position === position);
if (track) track.announced = 1;
}
setTimeout(() => {
btn.textContent = "Announce";
btn.classList.remove("success");
btn.disabled = false;
btn.disabled = true;
btn.title = "Already announced";
}, 2000);
} catch (e) {
showToast(e.message, true);
@@ -256,6 +306,72 @@
}
}
async function toggleAnnounced(showId, position, cb) {
const newVal = cb.checked;
try {
const resp = await fetch("/admin/announced", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({show_id: showId, position: position, announced: newVal}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || "Failed to update");
}
const cached = showCache[showId];
if (cached) {
const track = cached.tracks.find(t => t.position === position);
if (track) track.announced = newVal ? 1 : 0;
}
const annBtn = cb.closest("tr").querySelector(".announce-btn");
if (annBtn) {
if (newVal) {
annBtn.disabled = true;
annBtn.title = "Already announced";
} else if (subscriberCount > 0) {
annBtn.disabled = false;
annBtn.title = "";
}
}
} catch (e) {
cb.checked = !newVal;
showToast(e.message, true);
}
}
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) {
subscriberCount = count;
const dot = document.getElementById("status-dot");
@@ -280,14 +396,22 @@
detail.style.display = "none";
}
document.querySelectorAll(".announce-btn").forEach(btn => {
if (count === 0) {
const row = btn.closest("tr");
const cb = row ? row.querySelector(".announced-check") : null;
const isAnnounced = cb && cb.checked;
if (count === 0 || isAnnounced) {
btn.disabled = true;
btn.title = "No bots connected";
btn.title = isAnnounced ? "Already announced" : "No bots connected";
} else {
btn.disabled = false;
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;

View File

@@ -0,0 +1,435 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NtR Playlist</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Work+Sans:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--ntr-bg: #000;
--ntr-card: #111;
--ntr-card-hidden: #0a0a0a;
--ntr-text: #ededed;
--ntr-muted: #888;
--ntr-dim: #444;
--ntr-accent: #f70;
--ntr-border: #222;
}
body {
background: var(--ntr-bg);
color: var(--ntr-text);
font-family: 'Work Sans', sans-serif;
padding-bottom: 2rem;
}
h1, h2, h3, h4 {
font-family: 'Press Start 2P', cursive;
}
header { text-align: center; margin-bottom: 1.5rem; }
header h1 {
margin-bottom: 0.5rem;
font-size: 1.3rem;
letter-spacing: 1px;
}
header small { color: var(--ntr-muted); font-family: 'Work Sans', sans-serif; }
.track-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.track-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
border-radius: 6px;
background: var(--ntr-card);
border: 1px solid var(--ntr-border);
min-height: 80px;
transition: background 0.3s, opacity 0.3s, transform 0.3s;
}
.track-card.hidden-track {
background: var(--ntr-card-hidden);
border-color: #181818;
}
.track-card.revealing {
animation: revealCard 0.5s ease-out;
}
@keyframes revealCard {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.track-num {
font-family: 'Press Start 2P', cursive;
font-size: 0.85rem;
color: var(--ntr-dim);
min-width: 36px;
text-align: center;
flex-shrink: 0;
}
.hidden-track .track-num { color: #2a2a2a; }
.track-art {
width: 56px;
height: 56px;
border-radius: 4px;
object-fit: cover;
background: #1a1a1a;
flex-shrink: 0;
}
.track-art-placeholder {
width: 56px;
height: 56px;
border-radius: 4px;
background: #1a1a1a;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #2a2a2a;
font-size: 1.2rem;
}
.track-info { flex: 1; min-width: 0; }
.track-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
color: var(--ntr-muted);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hidden-track .track-info-placeholder {
height: 1em;
width: 60%;
background: #181818;
border-radius: 3px;
margin-bottom: 6px;
}
.hidden-track .track-info-placeholder.short {
width: 35%;
margin-bottom: 0;
}
.track-meta {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.track-duration { color: #666; font-size: 0.85rem; }
.track-link {
color: var(--ntr-accent);
text-decoration: none;
font-size: 0.85rem;
white-space: nowrap;
}
.track-link:hover { text-decoration: underline; color: #ff9933; }
.hidden-track .track-meta-placeholder {
width: 50px;
height: 1em;
background: #181818;
border-radius: 3px;
}
.section-divider {
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--ntr-border);
}
.section-divider h3 {
font-size: 0.75rem;
letter-spacing: 1px;
margin-bottom: 1rem;
}
.past-episodes {
list-style: none !important;
padding: 0 !important;
margin: 0 0 0.5rem 0 !important;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.past-episodes li {
list-style: none !important;
margin: 0 !important;
padding: 0 !important;
}
.past-episodes li::before {
content: none !important;
display: none !important;
}
.past-episodes li::marker {
content: none;
display: none;
}
.ep-btn {
display: inline-block;
padding: 5px 12px;
border-radius: 4px;
background: var(--ntr-card);
border: 1px solid var(--ntr-border);
color: #bbb;
text-decoration: none;
font-family: 'Work Sans', sans-serif;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.ep-btn:hover { background: #1a1a1a; border-color: #333; color: #fff; }
.ep-btn.active { background: var(--ntr-accent); border-color: var(--ntr-accent); color: #fff; }
#past-show-content { margin-top: 1rem; }
#past-show-content h4 {
font-size: 0.65rem;
letter-spacing: 0.5px;
}
#past-show-content h4 small {
font-family: 'Work Sans', sans-serif;
font-size: 0.85rem;
}
.ws-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #333;
margin-left: 6px;
vertical-align: middle;
}
.ws-status.connected { background: #4caf50; }
</style>
</head>
<body>
<main class="container">
<header>
<h1>NtR Playlist <span class="ws-status" id="ws-dot" title="Live connection"></span></h1>
<small id="show-subtitle">Loading...</small>
</header>
<section id="current-show">
<ul class="track-list" id="track-list">
<li>Loading playlist...</li>
</ul>
</section>
<section class="section-divider" id="past-section" style="display:none">
<h3>Past Episodes</h3>
<ul class="past-episodes" id="past-list"></ul>
<div id="past-show-content"></div>
</section>
</main>
<script>
let currentShowId = null;
let trackData = {};
let activePastShowId = null;
function esc(s) {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
function formatDuration(ms) {
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return min + ":" + String(sec).padStart(2, "0");
}
function formatDateRange(start, end) {
const opts = { month: "short", day: "numeric" };
const s = new Date(start);
const e = new Date(end);
return s.toLocaleDateString(undefined, opts) + " \u2013 " + e.toLocaleDateString(undefined, opts);
}
function renderTrackCard(t) {
if (!t.announced) {
return '<li class="track-card hidden-track" data-position="' + t.position + '">'
+ '<span class="track-num">' + t.position + '</span>'
+ '<div class="track-art-placeholder"></div>'
+ '<div class="track-info">'
+ '<div class="track-info-placeholder"></div>'
+ '<div class="track-info-placeholder short"></div>'
+ '</div>'
+ '<div class="track-meta">'
+ '<span class="track-meta-placeholder"></span>'
+ '</div>'
+ '</li>';
}
const artHtml = t.artwork_url
? '<img class="track-art" src="' + esc(t.artwork_url) + '" alt="">'
: '<div class="track-art-placeholder">&#9835;</div>';
return '<li class="track-card" data-position="' + t.position + '">'
+ '<span class="track-num">' + t.position + '</span>'
+ artHtml
+ '<div class="track-info">'
+ '<div class="track-title">' + esc(t.title) + '</div>'
+ '<div class="track-artist">' + esc(t.artist) + '</div>'
+ '</div>'
+ '<div class="track-meta">'
+ '<span class="track-duration">' + formatDuration(t.duration_ms) + '</span>'
+ '<a class="track-link" href="' + esc(t.permalink_url) + '" target="_blank" rel="noopener">SoundCloud</a>'
+ '</div>'
+ '</li>';
}
function renderCurrentShow(data) {
currentShowId = data.show_id;
trackData = {};
for (const t of data.tracks) {
trackData[t.position] = t;
}
const ep = data.episode_number ? "Episode " + data.episode_number : "This Week\u2019s Show";
const range = formatDateRange(data.week_start, data.week_end);
document.getElementById("show-subtitle").textContent = ep + " \u2014 " + range;
const list = document.getElementById("track-list");
if (!data.tracks.length) {
list.innerHTML = "<li>No tracks yet.</li>";
return;
}
list.innerHTML = data.tracks.map(renderTrackCard).join("");
}
function revealTrack(position, track) {
trackData[position] = Object.assign({}, trackData[position] || { position: position }, track, { announced: 1 });
const card = document.querySelector('[data-position="' + position + '"]');
if (!card) return;
const temp = document.createElement("div");
temp.innerHTML = renderTrackCard(trackData[position]);
const newCard = temp.firstChild;
newCard.classList.add("revealing");
card.replaceWith(newCard);
}
function hideTrack(position) {
if (trackData[position]) {
trackData[position] = { position: position, announced: 0 };
}
const card = document.querySelector('[data-position="' + position + '"]');
if (!card) return;
const temp = document.createElement("div");
temp.innerHTML = renderTrackCard({ position: position, announced: 0 });
card.replaceWith(temp.firstChild);
}
async function loadCurrentShow() {
try {
const resp = await fetch("/public/playlist");
if (!resp.ok) throw new Error("Failed to load playlist");
const data = await resp.json();
renderCurrentShow(data);
} catch (e) {
document.getElementById("track-list").innerHTML = "<li>Failed to load playlist.</li>";
}
}
async function loadPastEpisodes() {
try {
const resp = await fetch("/public/shows?limit=100");
if (!resp.ok) return;
const shows = await resp.json();
const past = shows.filter(s => s.id !== currentShowId);
if (!past.length) return;
document.getElementById("past-section").style.display = "";
const list = document.getElementById("past-list");
list.innerHTML = past.map(s => {
const label = s.episode_number ? "Ep " + s.episode_number : "Show " + s.id;
return '<li><button class="ep-btn" data-show-id="' + s.id + '">' + esc(label) + '</button></li>';
}).join("");
list.addEventListener("click", function(e) {
const btn = e.target.closest("button[data-show-id]");
if (!btn) return;
const showId = parseInt(btn.dataset.showId, 10);
if (activePastShowId === showId) {
btn.classList.remove("active");
activePastShowId = null;
document.getElementById("past-show-content").innerHTML = "";
return;
}
list.querySelectorAll(".ep-btn").forEach(el => el.classList.remove("active"));
btn.classList.add("active");
activePastShowId = showId;
loadPastShow(showId);
});
} catch (e) { /* ignore */ }
}
async function loadPastShow(showId) {
const content = document.getElementById("past-show-content");
content.innerHTML = "<p>Loading...</p>";
try {
const resp = await fetch("/public/shows/" + showId);
if (!resp.ok) throw new Error("Failed to load show");
const data = await resp.json();
const ep = data.episode_number ? "Episode " + data.episode_number : "Show " + data.show_id;
const range = formatDateRange(data.week_start, data.week_end);
let html = '<h4>' + esc(ep) + ' <small>(' + esc(range) + ')</small></h4>';
html += '<ul class="track-list">';
for (const t of data.tracks) {
const full = Object.assign({}, t, { announced: 1 });
html += renderTrackCard(full);
}
html += '</ul>';
content.innerHTML = html;
} catch (e) {
content.innerHTML = "<p>Failed to load show.</p>";
}
}
let wsBackoff = 1000;
function connectWS() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(proto + "//" + location.host + "/ws/public");
const dot = document.getElementById("ws-dot");
ws.onopen = function() {
dot.classList.add("connected");
dot.title = "Live";
wsBackoff = 1000;
};
ws.onmessage = function(e) {
try {
const msg = JSON.parse(e.data);
if (msg.type === "reveal") {
revealTrack(msg.position, msg.track);
} else if (msg.type === "hide") {
hideTrack(msg.position);
}
} catch (err) { /* ignore malformed */ }
};
ws.onclose = function() {
dot.classList.remove("connected");
dot.title = "Disconnected \u2014 reconnecting...";
setTimeout(connectWS, wsBackoff);
wsBackoff = Math.min(wsBackoff * 2, 60000);
};
ws.onerror = function() { ws.close(); };
}
loadCurrentShow().then(loadPastEpisodes);
connectWS();
</script>
</body>
</html>

View File

@@ -72,3 +72,33 @@ class AnnounceManager:
"subscribers": self.bot_count,
"clients": self.bot_clients,
})
class PublicManager:
"""Lightweight broadcast-only manager for unauthenticated public viewers."""
def __init__(self):
self._websockets: list[object] = []
@property
def client_count(self) -> int:
return len(self._websockets)
def add_client(self, websocket) -> None:
self._websockets.append(websocket)
logger.info("Public client connected (%d total)", self.client_count)
def remove_client(self, websocket) -> None:
self._websockets = [ws for ws in self._websockets if ws is not websocket]
logger.info("Public client disconnected (%d total)", self.client_count)
async def broadcast(self, message: dict) -> None:
dead = []
for ws in self._websockets:
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
logger.warning("Removing dead public client")
for ws in dead:
self.remove_client(ws)

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

@@ -57,9 +57,12 @@ def test_health(client):
assert data["poller_alive"] is True
AUTH = {"Authorization": "Bearer test-token"}
def test_playlist(client, db):
_seed_show(db)
resp = client.get("/playlist")
resp = client.get("/playlist", headers=AUTH)
assert resp.status_code == 200
data = resp.json()
assert "episode_number" in data
@@ -70,20 +73,20 @@ def test_playlist(client, db):
def test_playlist_by_position(client, db):
_seed_show(db)
resp = client.get("/playlist/2")
resp = client.get("/playlist/2", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["title"] == "Song B"
def test_playlist_by_position_not_found(client, db):
_seed_show(db)
resp = client.get("/playlist/99")
resp = client.get("/playlist/99", headers=AUTH)
assert resp.status_code == 404
def test_shows_list(client, db):
_seed_show(db)
resp = client.get("/shows")
resp = client.get("/shows", headers=AUTH)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
@@ -92,7 +95,7 @@ def test_shows_list(client, db):
def test_shows_detail(client, db):
show = _seed_show(db)
resp = client.get(f"/shows/{show.id}")
resp = client.get(f"/shows/{show.id}", headers=AUTH)
assert resp.status_code == 200
data = resp.json()
assert "episode_number" in data
@@ -132,7 +135,7 @@ def test_show_by_episode(client, db):
datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}")
db.upsert_track(t1)
db.set_show_tracks(show.id, [t1.id])
resp = client.get("/shows/by-episode/530")
resp = client.get("/shows/by-episode/530", headers=AUTH)
assert resp.status_code == 200
data = resp.json()
assert data["episode_number"] == 530
@@ -140,7 +143,7 @@ def test_show_by_episode(client, db):
def test_show_by_episode_not_found(client):
resp = client.get("/shows/by-episode/999")
resp = client.get("/shows/by-episode/999", headers=AUTH)
assert resp.status_code == 404
@@ -152,3 +155,112 @@ def test_no_dashboard_routes_without_config(client):
def test_no_login_route_without_config(client):
resp = client.get("/login")
assert resp.status_code == 404
# --- Auth-required (401) tests ---
def test_playlist_requires_auth(client, db):
_seed_show(db)
resp = client.get("/playlist")
assert resp.status_code == 401
def test_playlist_with_token(client, db):
_seed_show(db)
resp = client.get("/playlist", headers=AUTH)
assert resp.status_code == 200
def test_playlist_by_position_requires_auth(client, db):
_seed_show(db)
resp = client.get("/playlist/1")
assert resp.status_code == 401
def test_shows_requires_auth(client):
resp = client.get("/shows")
assert resp.status_code == 401
def test_shows_detail_requires_auth(client, db):
show = _seed_show(db)
resp = client.get(f"/shows/{show.id}")
assert resp.status_code == 401
def test_show_by_episode_requires_auth(client, db):
week_start = datetime(2026, 3, 12, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 19, 2, 0, 0, tzinfo=timezone.utc)
db.get_or_create_show(week_start, week_end, episode_number=530)
resp = client.get("/shows/by-episode/530")
assert resp.status_code == 401
# --- Public endpoint tests ---
def test_index_page(client):
resp = client.get("/")
assert resp.status_code == 200
assert "NtR Playlist" in resp.text
def test_public_playlist_censors_unannounced(client, db):
show = _seed_show(db)
db.set_track_announced(show.id, 1, True)
resp = client.get("/public/playlist")
assert resp.status_code == 200
data = resp.json()
assert len(data["tracks"]) == 2
revealed = data["tracks"][0]
assert revealed["announced"] == 1
assert revealed["title"] == "Song A"
assert revealed["artist"] == "Artist A"
assert "raw_json" not in revealed
assert "track_id" not in revealed
assert "show_id" not in revealed
hidden = data["tracks"][1]
assert hidden["announced"] == 0
assert hidden["position"] == 2
assert "title" not in hidden
assert "artist" not in hidden
def test_public_playlist_all_hidden(client, db):
_seed_show(db)
resp = client.get("/public/playlist")
data = resp.json()
for t in data["tracks"]:
assert t["announced"] == 0
assert "title" not in t
def test_public_shows_list(client, db):
_seed_show(db)
resp = client.get("/public/shows")
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert "id" in data[0]
assert "episode_number" in data[0]
def test_public_show_detail_fully_revealed(client, db):
show = _seed_show(db)
resp = client.get(f"/public/shows/{show.id}")
assert resp.status_code == 200
data = resp.json()
assert len(data["tracks"]) == 2
assert data["tracks"][0]["title"] == "Song A"
assert data["tracks"][1]["title"] == "Song B"
assert "raw_json" not in data["tracks"][0]
assert "track_id" not in data["tracks"][0]
def test_public_show_detail_not_found(client):
resp = client.get("/public/shows/999")
assert resp.status_code == 404

View File

@@ -2,7 +2,7 @@ from ntr_fetcher.config import Settings
def test_settings_defaults():
settings = Settings(admin_token="test-secret")
settings = Settings(admin_token="test-secret", _env_file=None)
assert settings.port == 8000
assert settings.host == "127.0.0.1"
assert settings.db_path == "./ntr_fetcher.db"
@@ -17,7 +17,7 @@ def test_settings_from_env(monkeypatch):
monkeypatch.setenv("NTR_HOST", "0.0.0.0")
monkeypatch.setenv("NTR_ADMIN_TOKEN", "my-secret")
monkeypatch.setenv("NTR_SOUNDCLOUD_USER", "someoneelse")
settings = Settings()
settings = Settings(_env_file=None)
assert settings.port == 9090
assert settings.host == "0.0.0.0"
assert settings.admin_token == "my-secret"
@@ -27,7 +27,7 @@ def test_settings_from_env(monkeypatch):
def test_settings_admin_token_required():
import pytest
with pytest.raises(Exception):
Settings()
Settings(_env_file=None)
def test_dashboard_config_absent(monkeypatch):
@@ -35,7 +35,7 @@ def test_dashboard_config_absent(monkeypatch):
monkeypatch.delenv("NTR_WEB_USER", raising=False)
monkeypatch.delenv("NTR_WEB_PASSWORD", raising=False)
monkeypatch.delenv("NTR_SECRET_KEY", raising=False)
s = Settings()
s = Settings(_env_file=None)
assert s.web_user is None
assert s.web_password is None
assert s.secret_key is None
@@ -47,7 +47,7 @@ def test_dashboard_config_present(monkeypatch):
monkeypatch.setenv("NTR_WEB_USER", "nick")
monkeypatch.setenv("NTR_WEB_PASSWORD", "secret")
monkeypatch.setenv("NTR_SECRET_KEY", "signme")
s = Settings()
s = Settings(_env_file=None)
assert s.web_user == "nick"
assert s.web_password == "secret"
assert s.secret_key == "signme"

View File

@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from ntr_fetcher.dashboard import create_dashboard_router
from ntr_fetcher.db import Database
from ntr_fetcher.models import Track
from ntr_fetcher.websocket import AnnounceManager
from ntr_fetcher.websocket import AnnounceManager, PublicManager
@pytest.fixture
@@ -23,11 +23,17 @@ def manager():
@pytest.fixture
def app(db, manager):
def public_manager():
return PublicManager()
@pytest.fixture
def app(db, manager, public_manager):
a = FastAPI()
router = create_dashboard_router(
db=db,
manager=manager,
public_manager=public_manager,
admin_token="test-token",
web_user="nick",
web_password="secret",
@@ -187,3 +193,122 @@ def test_ws_subscribe_with_invalid_token(app):
ws.send_json({"type": "subscribe", "token": "wrong"})
with pytest.raises(Exception):
ws.receive_json()
# --- Announced endpoint tests ---
def test_announce_sets_announced_flag(client, db):
show = _seed_show(db)
resp = client.post(
"/admin/announce",
json={"show_id": show.id, "position": 1},
headers={"Authorization": "Bearer test-token"},
)
assert resp.status_code == 200
tracks = db.get_show_tracks(show.id)
assert tracks[0]["announced"] == 1
def test_set_announced_with_bearer(client, db):
show = _seed_show(db)
resp = client.post(
"/admin/announced",
json={"show_id": show.id, "position": 1, "announced": True},
headers={"Authorization": "Bearer test-token"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
tracks = db.get_show_tracks(show.id)
assert tracks[0]["announced"] == 1
def test_set_announced_toggle_off(client, db):
show = _seed_show(db)
client.post(
"/admin/announced",
json={"show_id": show.id, "position": 1, "announced": True},
headers={"Authorization": "Bearer test-token"},
)
resp = client.post(
"/admin/announced",
json={"show_id": show.id, "position": 1, "announced": False},
headers={"Authorization": "Bearer test-token"},
)
assert resp.status_code == 200
tracks = db.get_show_tracks(show.id)
assert tracks[0]["announced"] == 0
def test_set_announced_without_auth(client, db):
_seed_show(db)
resp = client.post(
"/admin/announced",
json={"show_id": 1, "position": 1, "announced": True},
)
assert resp.status_code == 401
def test_set_announced_invalid_position(client, db):
_seed_show(db)
resp = client.post(
"/admin/announced",
json={"show_id": 1, "position": 99, "announced": True},
headers={"Authorization": "Bearer test-token"},
)
assert resp.status_code == 404
# --- Public WebSocket tests ---
def test_ws_public_connects_without_auth(app):
with TestClient(app) as c:
with c.websocket_connect("/ws/public") as ws:
pass
def test_ws_public_receives_reveal_on_announce(app, db, public_manager):
_seed_show(db)
with TestClient(app) as c:
with c.websocket_connect("/ws/public") as ws:
c.post(
"/admin/announce",
json={"show_id": 1, "position": 1},
headers={"Authorization": "Bearer test-token"},
)
msg = ws.receive_json()
assert msg["type"] == "reveal"
assert msg["position"] == 1
assert msg["track"]["title"] == "Song A"
assert msg["track"]["artist"] == "Artist A"
def test_ws_public_receives_reveal_on_announced_toggle(app, db, public_manager):
_seed_show(db)
with TestClient(app) as c:
with c.websocket_connect("/ws/public") as ws:
c.post(
"/admin/announced",
json={"show_id": 1, "position": 1, "announced": True},
headers={"Authorization": "Bearer test-token"},
)
msg = ws.receive_json()
assert msg["type"] == "reveal"
assert msg["position"] == 1
assert msg["track"]["title"] == "Song A"
def test_ws_public_receives_hide_on_unannounce(app, db, public_manager):
show = _seed_show(db)
db.set_track_announced(show.id, 1, True)
with TestClient(app) as c:
with c.websocket_connect("/ws/public") as ws:
c.post(
"/admin/announced",
json={"show_id": 1, "position": 1, "announced": False},
headers={"Authorization": "Bearer test-token"},
)
msg = ws.receive_json()
assert msg["type"] == "hide"
assert msg["position"] == 1
assert "track" not in msg

View File

@@ -295,3 +295,51 @@ def test_has_track_in_show(db):
db.set_show_tracks(show.id, [t1.id])
assert db.has_track_in_show(show.id, 1) is True
assert db.has_track_in_show(show.id, 999) is False
def test_announced_defaults_to_zero(db):
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
show = db.get_or_create_show(week_start, week_end)
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
db.upsert_track(t1)
db.set_show_tracks(show.id, [t1.id])
tracks = db.get_show_tracks(show.id)
assert tracks[0]["announced"] == 0
def test_set_track_announced(db):
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
show = db.get_or_create_show(week_start, week_end)
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
db.upsert_track(t1)
db.set_show_tracks(show.id, [t1.id])
db.set_track_announced(show.id, 1, True)
tracks = db.get_show_tracks(show.id)
assert tracks[0]["announced"] == 1
def test_set_track_announced_toggle_off(db):
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
show = db.get_or_create_show(week_start, week_end)
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
db.upsert_track(t1)
db.set_show_tracks(show.id, [t1.id])
db.set_track_announced(show.id, 1, True)
db.set_track_announced(show.id, 1, False)
tracks = db.get_show_tracks(show.id)
assert tracks[0]["announced"] == 0
def test_get_show_track_by_position_includes_announced(db):
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
show = db.get_or_create_show(week_start, week_end)
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
db.upsert_track(t1)
db.set_show_tracks(show.id, [t1.id])
db.set_track_announced(show.id, 1, True)
result = db.get_show_track_by_position(show.id, 1)
assert result["announced"] == 1

View File

@@ -1,6 +1,8 @@
import re
from datetime import datetime, timezone
from unittest.mock import patch
import httpx
import pytest
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),
)
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,5 +1,5 @@
import pytest
from ntr_fetcher.websocket import AnnounceManager
from ntr_fetcher.websocket import AnnounceManager, PublicManager
@pytest.fixture
@@ -98,3 +98,61 @@ async def test_status_broadcast_includes_clients(manager):
assert msg["subscribers"] == 1
assert len(msg["clients"]) == 1
assert msg["clients"][0]["client_id"] == "my-bot"
# --- PublicManager tests ---
@pytest.fixture
def public_manager():
return PublicManager()
def test_public_no_clients_initially(public_manager):
assert public_manager.client_count == 0
@pytest.mark.asyncio
async def test_public_add_remove_client(public_manager):
class FakeWS:
async def send_json(self, data):
pass
ws = FakeWS()
public_manager.add_client(ws)
assert public_manager.client_count == 1
public_manager.remove_client(ws)
assert public_manager.client_count == 0
@pytest.mark.asyncio
async def test_public_broadcast(public_manager):
received = []
class FakeWS:
async def send_json(self, data):
received.append(data)
ws1 = FakeWS()
ws2 = FakeWS()
public_manager.add_client(ws1)
public_manager.add_client(ws2)
await public_manager.broadcast({"type": "reveal", "position": 1})
assert len(received) == 2
assert all(m["type"] == "reveal" for m in received)
@pytest.mark.asyncio
async def test_public_broadcast_removes_dead_clients(public_manager):
class DeadWS:
async def send_json(self, data):
raise Exception("closed")
ws = DeadWS()
public_manager.add_client(ws)
assert public_manager.client_count == 1
await public_manager.broadcast({"type": "reveal", "position": 1})
assert public_manager.client_count == 0

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