16 KiB
soundcloud provider (streaming music)
Context and problem statement
A new library provider mounted at /soundcloud that lets a user search,
resolve share links, and play SoundCloud tracks and playlists — and, when
they opt in with a token, browse their own likes and playlists.
- Like fyyd/tidal/youtube, SoundCloud is a remote, search-driven service.
Unlike them, SoundCloud offers no official public API: the modern
api-v2.soundcloud.comrequires aclient_idthat SoundCloud embeds in its web app and rotates periodically, and personal-account access needs an OAuth token. So the provider must (a) obtain aclient_idon its own and survive rotation, and (b) treat login as optional — public browse + play works with only aclient_id; a token merely adds personal nodes. - Content is organized as tracks and playlists (a playlist is a
container of tracks). There is no per-track container level like abs books —
the tree is
search-term → tracks,playlist → tracks, and (logged in)likes → tracks/playlists → playlist → tracks, plus a resolve entry that turns a pasted permalink URL into a track or playlist. - Playing a track is the biggest divergence from every existing provider.
SoundCloud does not serve a plain file URL: each track carries a set of
media.transcodings, and the playable ones are HLS — an.m3u8playlist of short mp3 segments. crabidy's player streams a single byte source, so this requires a new HLS source inaudio-playerthat fetches the playlist and streams the mp3 segments in order as one continuous mp3 (mp3 frames byte-concatenate into a valid stream — the same factffmpeg -c copyrelies on). rodio's existing symphonia mp3 path then decodes it, unchanged. - Captures (
W, download) come for free once nodes serve tracks and raiseis_downloadable, exactly as for abs/fyyd — no wire or TUI work.
The SoundCloud API (grounding)
Base https://api-v2.soundcloud.com. Every request carries ?client_id
(plus app_version, app_locale=en), omitted from the cells below; personal
calls also send Authorization: OAuth <token>. search/* add
&limit=<n>&offset=<o>&linked_partitioning=1. Endpoints we use:
| Purpose | Endpoint |
|---|---|
| Resolve a permalink URL | GET /resolve?url=<permalink> |
| Search tracks | GET /search/tracks?q=<t> |
| Search playlists | GET /search/playlists?q=<t> |
| Track detail | GET /tracks/<id> |
| Playlist detail | GET /playlists/<id> |
| Transcoding → media URL | GET <transcoding.url> → {"url": "<m3u8>"} |
| (login) My likes | GET /me/likes/tracks (OAuth) |
| (login) My playlists | GET /me/playlists (OAuth) |
Objects (fields we read):
- track:
id(numeric),title,user.username(→ artist),duration(ms),permalink_url,media.transcodings[],policy/streamable,publisher_metadata(optional album/release). - transcoding:
url(a second API URL, not the CDN),preset,format.{protocol, mime_type},quality. We selectprotocol == "hls" && mime_type == "audio/mpeg"(mp3-HLS), which SoundCloud offers for essentially every playable track. - playlist:
id,title,user.username,tracks[]— often returned as stubs ({id}only); missing tracks are hydrated in batches of ≤50 viaGET /tracks?ids=<csv>&client_id=…. - resolve: returns a track or a playlist object (discriminated by
kind).
client_id acquisition (no login): GET https://soundcloud.com, find the
referenced JS bundles, fetch them, regex client_id:"(\w+)"; app_version
from window.__sc_version="(\d+)". This is exactly the streamrip approach.
Assumptions (decided here)
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
(confirmed by
/youtube,/fyyd,/abs): search-term + resolve semantics cost no TUI or wire change. No proto change, no newProviderCommand. - A missing/rotated/invalid
client_idmust never crash startup or a browse. The provider self-heals by scraping and by re-scraping on401/403; only if scraping itself fails does the/soundcloudsubtree degrade (typed errors, skipped tracks), never the app. - Login is optional. With no
oauth_token, personal nodes (likes,playlists) are simply not shown; public search/resolve/play still work. A token unlocks the personal nodes and is refreshed/persisted like tidal's. - HLS media/segment URLs and the
client_id/oauth_tokenare secrets or ephemeral signed URLs: redact fromDebug/config dumps, never log the built stream/segment URLs (hard rule: redact secrets from logs and error reports). - SoundCloud
ids are numeric (URL-safe); only user-typed search terms and pasted URLs are percent-encoded into a path segment.
Decisions
D1 — Crate soundclouddy, mounted at /soundcloud, non-fatal init
New workspace crate soundclouddy implementing ProviderClient, shaped on
fyyd/absdy (remote, search-driven, plain leaf tracks). Wired into
ProviderOrchestrator with a sc_client: Option<Arc<soundclouddy::Client>>
field, sc_owns()/sc_provider() helpers, a build() block that reads
soundcloud.toml (non-fatal), a get_lib_root child gated on
self.sc_client.is_some(), and one routing arm in each dispatch method.
crabidy-server settings gain soundcloud in ALL_PROVIDERS (now 8), in
ProviderToggles, in all(), and in provider_toggles(). No cli.rs/
main.rs change (providers are pure path-prefix subtrees).
D2 — HTTP behind a trait, faked in tests; client_id lifecycle inside the seam
All network access goes through one seam — an Sc trait (resolve,
search_tracks, search_playlists, track_detail, playlist_detail,
hydrate_tracks, resolve_stream_url, and, when logged in, my_likes,
my_playlists) behind Box<dyn Sc> — with a reqwest-based ScApi for
production and a FakeApi in tests (as absdy hides reqwest behind Abs).
The client_id acquisition, caching, and re-scrape-on-401 live entirely
inside ScApi so provider logic (tree shaping, path parsing, term store) is
unit-tested with zero network. Errors map to ProviderError::FetchError at the
boundary; malformed paths → MalformedPath; empty create/rename → InvalidInput.
D3 — Tree shape (search, resolve, optional personal)
Track ids and playlist ids are both numeric, so leaf/container paths use a
type-tagged canonical segment to disambiguate: track/<id> and
playlist/<id>. Browse nodes point their children at these canonical paths.
/soundcloud— children:search(creatable),resolve(creatable), and — only if logged in —likesandplaylists. Not itself queueable./soundcloud/search//soundcloud/resolve—is_creatable; children are the in-memory terms/URLs (RwLock<Vec<String>>, dedup), each editable and deletable, like tidal/youtube/fyyd/abs search terms./soundcloud/search/<term>— matching tracks as queueable leaves (and, optionally, matching playlists as containers)./soundcloud/resolve/<url>— the resolved permalink: a single track leaf, or a playlist container./soundcloud/likes(login) — the user's liked tracks./soundcloud/playlists(login) — the user's playlists as containers./soundcloud/playlist/<id>— a playlist's tracks (queueable, downloadable); the canonical container path, reached from search/resolve/likes/playlists./soundcloud/track/<id>— the canonical track leaf. A track id alone is sufficient to resolve a stream, so every branch's track children point here and playback needs no browse context.
Terms/URLs are percent-encoded into one segment (encode_segment/
decode_segment); numeric ids are already URL-safe.
D4 — Playback: progressive mp3 (HLS source retained as fallback)
Revised after live testing (2026-07-24). The original plan chose HLS mp3,
but live probing showed SoundCloud's plain hls + audio/mpeg transcoding
exchange 404s for anonymous clients (every track, streamable or not), while
the progressive + audio/mpeg transcoding returns 200 with a direct,
range-streamable mp3 URL (cf-media.sndcdn.com, 206, audio/mpeg). So the
provider now prefers progressive, which the player streams on its normal
windowed-HTTP path — no HLS needed for the common case. The HlsStream built
for the original plan is kept as a fallback for any track that offers only HLS.
get_urls_for_track(/soundcloud/track/<id>):GET /tracks/<id>, pick the best mp3 transcoding (progressivefirst, thenhls),GET <transcoding.url>?client_id=…→ the media URL, and return it asurls[0](the player consumes only the first). One API round-trip — unlike abs's pure string-building — because the media URL is signed and ephemeral. A track whose exchange 404s (Go+/label preview, geo-blocked) surfacesNotStreamable/NotFoundand is skipped, never a crash.- New
audio-playercomponentHlsStream— astream-downloadSourceStream, sibling toWindowedHttpStream: on create it fetches the.m3u8(a media playlist), parses#EXTINF/segment URIs (resolving relative URIs against the playlist URL, and following one level if handed a master playlist); on poll it streams each mp3 segment's bytes in order, advancing at segment boundaries, finishing after the last. The concatenated bytes are a valid mp3 → rodio's symphonia mp3 decoder handles them unchanged. - Routing:
open_sourceselectsHlsStreamwhen the URL path ends in.m3u8(SoundCloud's media URLs carry it); all other http URLs keep the windowed-HTTP path, and content-sniffing (opus vs the rest) is unchanged downstream.#EXTM3Ucontent-sniff is a hardening fallback if needed. - Duration comes from the track metadata (
durationms →Track.duration), not from the stream, so the seek bar is correct even though the concatenated HLS stream carries no container duration. - Track fields:
title= track title,artist=user.username,albumfrompublisher_metadatawhen present elseNone,duration= ms→Option<u32>,provider_item_id="track:<id>"(keys the capture store).
D5 — Auth: client_id self-heal, optional OAuth login
Settings:client_id: Option<String>,app_version: Option<String>(both cached after first scrape and round-tripped viasettings()so we don't re-scrape every start),oauth_token: Option<String>(optional), and bounds (D6). Hand-writtenDebugredactsclient_id/oauth_token.- No login (baseline): if
client_idis unset,ScApiscrapes it fromsoundcloud.comat init; on any401/403it re-scrapes once and retries (rotation recovery). The freshly scraped id is persisted. - Optional login: if
oauth_tokenis present,get_lib_rootaddslikesandplaylists, and personal calls sendAuthorization: OAuth <token>. If a refresh-token flow is configured later it mirrors tidal's persist-on-refresh; v1 accepts a static token and, on401, drops the personal subtree with a typed error (never a crash) — public browse is unaffected.
D6 — Bounds and freshness
search_results(default 50),playlist_tracks_limit(default 500, hydrated in ≤50-id batches),call_timeout_secs(default 30) bound each HTTP call, andhls_total_deadline_secs(default 300) bounds a whole HLS fetch (segments are retried with jitter under this deadline). Caps arelog-ged so truncation is visible, not silent (hard rule: no silent caps).- Listings are fetched fresh per call (no cross-call cache), like the other remote providers; only search terms / resolve URLs are stored in memory. The chosen transcoding may be briefly cached per track id to save the extra round-trip on replay.
D7 — Out of scope (explicitly)
- opus-HLS (
audio/ogg; codecs=opus) and progressive transcodings: v1 targets mp3-HLS uniformly (offered for ~all tracks). opus-HLS is a phase-2 add that reuses the newOpusSourceper segment; theHlsStreamseam makes it additive. - HLS seeking: v1 HLS is forward-only and reports the source as non-seekable, so symphonia does not attempt an end-seek (which would need a known byte length). In-track seek is a later add (open at a segment offset).
- Go+ / high-quality / lossless streams (need a premium account), uploads, comments, reposts feed, waveforms, social graph, and playback-progress sync.
- Pagination past the configured caps (one page per listing).
Structure
direction: right
server: crabidy-server {
orch: ProviderOrchestrator
}
sc: "soundclouddy (crate)" {
client: "Client\n(ProviderClient)"
terms: "search terms + resolve URLs\n(in-memory)"
api: "ScApi\n(reqwest seam: Sc trait,\nclient_id self-heal, opt OAuth)"
client -> terms
client -> api
}
player: "audio-player" {
hls: "HlsStream\n(new SourceStream)"
dec: "rodio / symphonia\n(mp3, opus)"
hls -> dec: "concatenated mp3 bytes"
}
scapi: "SoundCloud\napi-v2 + HLS CDN" { shape: cloud }
web: "soundcloud.com\n(HTML + JS)" { shape: cloud }
server.orch -> sc.client: "/soundcloud/..."
sc.api -> scapi: "resolve / search / tracks / transcoding (JSON, timeout)"
sc.api -> web: "scrape client_id (init, on 401)"
server.orch -> player.hls: ".m3u8 media URL"
player.hls -> scapi: "GET m3u8 + mp3 segments"
Key flow: search and play a track
shape: sequence_diagram
tui: TUI
orch: Orchestrator
s: soundclouddy
api: "SoundCloud api-v2"
hls: "HlsStream (audio-player)"
cdn: "HLS CDN"
tui -> orch: "open /soundcloud/search"
tui -> orch: "create term \"boards of canada\""
orch -> s: "create_lib_node(search, term)"
s -> tui: "term stored"
tui -> orch: "open /soundcloud/search/<term>"
orch -> s: "get_lib_node"
s -> api: "GET /search/tracks?q=…&client_id="
api -> s: "tracks (id, title, user, duration)"
s -> tui: "tracks as queueable leaves (/soundcloud/track/<id>)"
tui -> orch: "queue + play a track"
orch -> s: "get_urls_for_track(/soundcloud/track/<id>)"
s -> api: "GET /tracks/<id> → pick hls+audio/mpeg transcoding"
s -> api: "GET <transcoding.url>?client_id= → { url: m3u8 }"
s -> orch: "urls = [ m3u8 ]"
orch -> hls: "player.play(m3u8)"
hls -> cdn: "GET m3u8 (segments)"
hls -> cdn: "GET segment 1..N (mp3, in order)"
hls -> orch: "continuous mp3 → symphonia decodes"
Risks and open questions
- client_id scraping fragility. The scrape regexes depend on
soundcloud.com's HTML/JS shape and can break on a redesign. Mitigation: a
config override (
client_idinsoundcloud.toml) always wins, and failures are typed (subtree degrades, app survives). The scrape is the one piece with no test-double coverage of the live format — flagged as a live-test gate. - Ephemeral media URLs. The resolved
.m3u8and its segments are signed and short-lived; playback must start promptly after resolution (like/youtube). A stale URL surfaces as a skipped track, never a crash. Never logged. - HLS without a known length. The concatenated stream has no total byte length; reported non-seekable so symphonia won't end-seek (the rodio-0.22 panic the opus work documented). Gate: verify an end-to-end mp3-HLS play does not panic and reaches EOS cleanly.
- Master vs media playlist. SoundCloud returns a media (segment) playlist
for the chosen transcoding;
HlsStreamfollows one level of master playlist defensively and picks the first variant. - Playlist stubs. Playlist detail may return track stubs; hydration in
≤50-id batches is bounded by
playlist_tracks_limit. A hydration miss drops that track (skipped), never an error. - OAuth token lifetime. v1 accepts a static token; expiry drops the personal
subtree with a typed
401(public browse unaffected). A device-flow/refresh upgrade mirrors tidal and is additive. - Field / envelope drift. DTOs decode defensively (
#[serde(default)]); a renamed field is a local fix inScApi. Live validation is a task-plan gate.