Compare commits
3 Commits
b353f606e5
...
a5f77187b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f77187b3
|
||
|
|
82049ab47f
|
||
|
|
a328684af0
|
34
.env.example
Normal file
34
.env.example
Normal 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
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
19
docs/api.md
19
docs/api.md
@@ -399,6 +399,25 @@ All timestamps in API responses are UTC. The boundary shifts by 1 hour across DS
|
|||||||
| EST (Nov -- Mar) | Wed 22:00 | Thu 03:00 |
|
| 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"uvicorn[standard]",
|
"uvicorn[standard]",
|
||||||
"httpx",
|
"httpx",
|
||||||
"pydantic-settings",
|
"pydantic-settings",
|
||||||
|
"python-dotenv",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", [])
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user