Base URL: http://localhost:8000 (dev) · your deployed origin (prod).
Interactive Swagger UI: /api/docs · OpenAPI JSON: /api/openapi.json.
All request/response bodies are JSON unless noted. Durations are seconds (float). Track IDs are 11-character YouTube video IDs unless the endpoint documents a local file ID.
Rheoson uses Clerk session JWTs.
- Send
Authorization: Bearer <jwt>on every request. - Tokens are short-lived (~60 s); the frontend fetches a fresh one from Clerk per request burst via
getToken(). - The backend verifies the RS256 signature against Clerk's JWKS (
https://api.clerk.com/v1/jwks), checksexp/nbf, and requires anissunderclerk.accounts.dev/clerk.dev/clerk.com. - The canonical user key in every handler is the
subclaim (a Clerk user ID likeuser_2abc…).
Local dev mode: with ENV=development and Clerk unconfigured, the API issues a synthetic dev-user-local identity so you can develop without Clerk. The moment CLERK_SECRET_KEY is set (or ENV=production), this fallback disappears and unauthenticated calls fail closed with 401.
| Status | Meaning |
|---|---|
401 |
Missing/invalid/expired token, or auth not configured |
403 |
Webhook signature invalid |
404 |
Resource not found (or not visible to this user) |
422 |
Request body/query validation failure (Pydantic) |
429 |
Rate limited — includes Retry-After header |
502 |
Upstream (YouTube/Spotify/lyrics provider) failure |
503 |
MongoDB unavailable, or webhook secret unset |
Error body shape: { "detail": "human-readable message" }.
Per IP, sliding 60-second window, applied by path prefix. Defaults (tune via RATE_LIMIT_* env):
| Prefix | Limit/min |
|---|---|
/api/search |
30 |
/api/downloads |
10 |
/api/auth |
20 |
/api/stream |
300 (audio seeking issues many range requests) |
/api/lyrics |
60 |
Exceeding a limit returns 429 with Retry-After seconds.
Deliberately public, validated by resource shape:
GET /api/health,GET /health/live,GET /health/ready,GET /api/versionGET /api/share/{track_id}/card— share image cardGET/HEAD /api/stream/{id}/audioand the artwork routes —<audio>/<img>elements cannot attach headers; these are read-only byte streams for validated resource IDsPOST /api/webhooks/clerk— verified by Svix HMAC signature instead
Cheap full snapshot backed by a 60 s background probe. No auth.
{
"status": "ok",
"version": "2.16.4",
"env": "production",
"uptime": "03:12:44",
"uptimeS": 11564,
"python": "3.13.1",
"memory": { "rss_mb": 184.2 },
"services": { "mongodb": true, "clerk": true, "redis": false, "spotify": true },
"downloads": { "total": 4, "by_status": { "done": 4 } },
"keep_alive": { "last_ping": "…", "last_status": 200, "last_latency_ms": 187, "total_pings": 41, "total_failures": 0 },
"cron_jobs": [ { "id": "keep_alive", "next_run": "…" } ]
}Deep diagnostics on demand: fresh DB ping with latency, yt-dlp/ffmpeg versions, config validation, recent 5xx ring and latency percentiles.
Kubernetes/Render-style liveness (zero I/O) and readiness (fast storage probe). GET /api/version returns { "version", "name", "releaseDate" }.
Credentials are handled entirely by Clerk's hosted components. There is no
server-side login or registration route: Clerk's Backend API can mint a session
for a user id without verifying a password, so such a proxy would make an email
address sufficient to obtain a session for that account. Account creation is
observed through POST /api/webhooks/clerk (user.created), which is also
what increments the visitor counter.
Returns the synced user record: { "id", "email", "name", "image_url", "created_at", "preferences", "stats" }.
Updates profile fields (name, image_url) and preference keys (theme, audio_format, audio_quality, …).
Revokes the Clerk session server-side. {"ok": true}.
Public: the landing page renders this counter for guests. Counts distinct
accounts, incremented once per user.created delivery.
Unified search. Plain text goes to ytmusicapi; URLs are detected and routed (Spotify → metadata + match; YouTube → lookup; anything else → yt-dlp). Queries are sanitized (control characters stripped, 200-char cap) and filter is pattern-validated.
Response (SearchResultsSchema):
{
"query": "daft punk",
"type": "search",
"tracks": [ { TrackSchema } ],
"albums": [ … ],
"artists": [ … ],
"playlists": [ … ]
}Track shape (used everywhere in the API):
{
"id": "dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"artist": { "id": "UCuAX…", "name": "Rick Astley", "imageUrl": "", "genres": [] },
"album": { "id": "", "title": "", "artworkUrl": "", "releaseYear": 0, "trackCount": 0, "artist": { … } },
"artworkUrl": "https://i.ytimg.com/…",
"duration": 212.0,
"streamUrl": "/api/stream/dQw4w9WgXcQ/audio",
"isDownloaded": false,
"isLiked": false,
"youtubeId": "dQw4w9WgXcQ"
}isDownloaded/isLiked are hydrated server-side from the local library and the caller's per-user store.
Instant autocomplete strings (~80 ms). Returns [] under 2 characters.
{ "url": "https://open.spotify.com/track/…" }Resolves any supported media URL into a full search result. Enforced by netguard:
http/httpsonly, no embedded credentials, no exotic ports, ≤ 2048 chars- Host on the media allowlist (youtube, youtu.be, googlevideo, spotify, scdn, soundcloud, sndcdn, bandcamp, bcbits, deezer, tidal, music.apple, itunes, mzstatic, vimeo, twitch, jtvnw, mixcloud, audiomack, reverbnation, apple, ytimg)
- Hostname must resolve only to public IPs — loopback/private/link-local/metadata addresses are rejected (SSRF defense)
400 with a reason for violations. 502 when the upstream extractor fails.
All routes below require auth. Likes and history are per user — every query/filter includes the caller's sub, so one user's data is invisible to another (enforced by tests).
| Endpoint | Description |
|---|---|
GET /api/tracks |
Full local library index (file-backed). Each track hydrated with isDownloaded, artwork, metadata |
GET /api/tracks/liked |
Caller's liked tracks |
GET /api/tracks/liked/count |
{ "count": n } |
GET /api/tracks/recently-played |
Caller's play history, newest first |
GET /api/tracks/trending |
Popular tracks (global fallback when no history) |
GET /api/tracks/{id} |
Single track by YouTube ID or local file ID |
POST /api/tracks/{id}/like |
Like (idempotent) |
DELETE /api/tracks/{id}/like |
Unlike (idempotent) |
POST /api/tracks/{id}/dislike |
Negative signal — feeds recommendations and radio filtering |
DELETE /api/tracks/{id}/dislike |
Remove dislike |
POST /api/tracks/{id}/play |
Record a play event (history + recommendation signals) |
DELETE /api/tracks/history |
Clear caller's history |
POST /api/tracks/signals |
Explicit signal ingest: { "signal": "play|like|dislike|skip", "track_id": "…" } |
GET /api/tracks/stats/{id} |
Per-track stats for the caller |
DELETE /api/tracks/history |
Clear history |
The core playback route. Also answers HEAD (durations/probes). Public by design (see Authentication).
Resolution order:
- Local library — track ID exists in the file index → served from disk with full HTTP byte-range support (
206 Partial Content,Accept-Ranges: bytes), correct MIME per container (mp3/flac/m4a/ogg/opus/wav) - Durable remote cache — a previous yt-dlp fill completed within the 30-minute TTL → served from the cached buffer file
- Direct-URL relay — a still-valid cached CDN URL, else the relay resolver: public Piped/Invidious instances cache YouTube extractions across users, so their pre-extracted audio URL arrives in ~1 s vs a ~12 s cold local extraction. Best-effort: a dead fleet is skipped silently and a circuit breaker steps aside for 10 minutes after a fruitless sweep, so relay downtime never slows playback. When a relay URL is in hand, bytes are relayed to the client (Range preserved) and teed into the warm cache as before
- Live session — one background yt-dlp fill per track (max 6 concurrent); all clients stream from the growing buffer. Seek (
Range) requests during an in-progress fill wait up to 120 s for completion, then serve the exact range 502with a user-safe message on failure; repeated failures are cached for 60 s so retry storms don't spawn processes. Downloads get the relay as a last rung too: if the whole client ladder fails, a relay URL is downloaded through the same ffmpeg conversion instead of failing the job
Malformed IDs (length ≠ 11) return 404 before any process spawn.
Idempotent background warm-up: starts the shared fill without streaming. Returns {"ok": true, "state": "local|cached|warming|failed|busy"}.
Embedded artwork from a locally downloaded file. 204 when downloaded but artless, 404 when not downloaded.
Artwork proxy. In-memory cache (500 entries, LRU) + 24 h client cache. Allowlist-enforced (i.ytimg.com, yt3.ggpht.com, googleusercontent, scdn.co, spotifycdn…) — any other host returns 400. Only https.
POST /api/stream/cache/clear (file index) · POST /api/stream/remote-cache/clear (yt-dlp buffers) · POST /api/stream/artwork/cache/clear (proxy cache).
All routes require auth. Jobs are in-memory + JSON-persisted; the queue trims to the most recent 100 every 6 hours.
{
"trackId": "dQw4w9WgXcQ", // or "url": "https://…"
"format": "mp3", // mp3|flac|opus|m4a|wav
"quality": "320", // 128|192|256|320|best
"embedArtwork": true,
"embedLyrics": true,
"embedMetadata": true,
"fileNaming": "artist-title", // artist-title|title-artist|id
"customPath": "/abs/path", // optional; MUST resolve inside a configured music dir
"retries": 3,
"speedLimit": 0, // KB/s, 0 = unlimited
"concurrency": 3
}202 Accepted → DownloadJobSchema:
{
"id": "uuid", "trackId": "…", "title": "…", "artist": "…", "artworkUrl": "…",
"status": "downloading", "progress": 12.5, "format": "mp3", "quality": "320",
"error": null, "filePath": null, "createdAt": "2026-09-10T12:00:00+00:00"
}Status flow: queued → downloading → tagging → done, or cancelled/failed. Progress events are pushed over Socket.IO (download:progress, download:done, download:error).
Validation: trackId ≤ 20 chars; URLs pass netguard; customPath must resolve inside a configured music directory (defense against arbitrary file writes — checked in both router and service).
Same options, {"track_ids": [...]} (max 20). Returns a list of jobs; invalid IDs are skipped.
GET /api/downloads (list, newest first) · GET /api/downloads/{id} · POST /api/downloads/{id}/cancel (SIGTERM → SIGKILL the process group; partial files removed) · POST /api/downloads/{id}/retry (replays the original options) · DELETE /api/downloads/{id} (drops the record).
Job IDs are UUID-format-validated (400 otherwise).
Returns synced (LRC) and/or plain lyrics from the provider chain (syncedlyrics → ytmusicapi), with local metadata fallback:
{ "synced": true, "lines": [ { "time": 12.4, "text": "…" } ], "plain": "…" }404 when no provider has lyrics. Provider failures degrade to the next source; 502 only when everything failed and the failure was upstream.
All routes require auth; playlists are per user. Playlist IDs are server-generated; foreign IDs 404 (ownership is checked on every access, not just listing — covered by tests).
| Endpoint | Description |
|---|---|
GET /api/playlists |
Caller's playlists |
POST /api/playlists |
Create: { "title", "description?", "artworkUrl?" } → 201 |
GET /api/playlists/{id} |
Playlist with hydrated tracks |
PATCH /api/playlists/{id} |
Rename / re-artwork |
DELETE /api/playlists/{id} |
204; idempotent (deleting another user's playlist is still 204 but deletes nothing) |
POST /api/playlists/{id}/tracks |
{ "trackId" } or full track object; deduplicates |
DELETE /api/playlists/{id}/tracks/{track_id} |
Remove |
PUT /api/playlists/{id}/tracks/reorder |
{ "from": 0, "to": 2 } or full order array |
POST /api/playlists/import |
{ "url": "<spotify playlist url>" } → creates a playlist from matched tracks |
POST /api/playlists/{id}/import |
Append imported tracks to an existing playlist |
GET /api/playlists/{id}/export |
JSON export |
| Endpoint | Description |
|---|---|
GET /api/library/featured?limit= |
Pinned playlists for the home rail |
GET /api/library/albums |
Album aggregates from the local index |
GET /api/library/albums/{album_id}?name= |
Remote (YouTube Music browse ID) or local aggregate by md5/slug/name |
GET /api/library/artists |
Artist aggregates |
GET /api/artists/{artist_id}?name= |
Remote artist profile + top tracks, or local aggregate |
Local aggregates work fully offline from the file index.
All require auth. Signals are stored in MongoDB (user_signals, taste_profiles, user_recommendations) — these endpoints 503 without a database.
| Endpoint | Description |
|---|---|
GET /api/recommendations/home |
Personalized home rails (mixes, discovery, because-you-liked) |
GET /api/recommendations/autoplay |
Next-track suggestion seeded by the current track |
GET /api/recommendations/discover |
Discovery loop items with dislike/hide feedback |
GET /api/recommendations/taste |
Taste profile + cold_start flag (drives onboarding) |
GET /api/recommendations/mixes |
Daily Mixes |
GET /api/recommendations/radio?track_id= |
One-tap radio seeded from a track |
POST /api/recommendations/onboard |
{ "artists": ["A", "B", "C"] } — seeds taste (max 3 enforced) |
POST /api/recommendations/refresh |
Recompute the profile from accumulated signals |
| Endpoint | Description |
|---|---|
GET /api/analytics/stats |
Totals: plays, minutes, unique tracks, streaks |
GET /api/analytics/wrapped |
Year-in-review payload (top artists/tracks, listening clock) |
GET /api/analytics/top-artists?limit= |
Aggregated by plays |
GET /api/analytics/top-tracks?limit= |
Aggregated by plays |
GET /api/analytics/listening-by-hour |
24-bucket histogram |
GET /api/analytics/listening-by-day |
Day-of-week histogram |
GET /api/smart-playlists/most-played|recently-added|discover|time-capsule |
Virtual playlists, computed per request |
GET /api/equalizer/presets — the 10-band preset catalog (Flat, Bass Boost, Vocal, …). GET /api/equalizer/presets/{id} — one preset.
GET /api/share/{track_id}/link (auth) — canonical share URL. GET /api/share/{track_id}/card?title=&artist= — public OG-image card (SVG) for link previews.
| Endpoint | Description |
|---|---|
GET /api/settings/directories |
Configured + discovered music dirs |
POST /api/settings/directories |
Add a directory (path-validated; restricted to ADMIN_SUBS when set) |
GET /api/settings/directories/browse?path= |
Sandbox-bound directory browser |
POST /api/settings/rescan |
Force library + stream cache invalidation |
GET /api/settings/spotify/status |
Whether Spotify metadata is configured |
Clerk user lifecycle sync (created/updated/deleted, session.created). Security, in order:
503ifCLERK_WEBHOOK_SECRETis unset — never accepts unverified events- Svix headers required (
svix-id,svix-timestamp,svix-signature) - Timestamp freshness: ±5 minutes
- HMAC-SHA256 over
"{id}.{timestamp}.{body}"with the base64-decoded secret, constant-time compared against everyv1,…entry
Handled events upsert/soft-delete the users document keyed by the Clerk ID. Always returns 200 after verification (failures inside handlers are logged, not retried by Clerk).
Endpoint: same origin as the API (/socket.io), CORS-restricted to the same allowlist as HTTP.
| Event (server → client) | Payload | When |
|---|---|---|
download:progress |
{ "job_id", "progress", "status" } |
Every meaningful chunk |
download:done |
{ "job_id", "file_path" } |
Download + tagging finished |
download:error |
{ "job_id", "error" } |
Failure or cancellation |
player:state |
Player snapshot | Cross-device sync (broadcast to the account's other sessions) |
pong |
{ "sid" } |
Heartbeat reply |
The client auto-reconnects with backoff; the frontend re-fetches download state on reconnect so no event is structurally required to succeed.