crabidy/architecture/soundcloud-provider.md

15 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.com requires a client_id that SoundCloud embeds in its web app and rotates periodically, and personal-account access needs an OAuth token. So the provider must (a) obtain a client_id on its own and survive rotation, and (b) treat login as optional — public browse + play works with only a client_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 .m3u8 playlist of short mp3 segments. crabidy's player streams a single byte source, so this requires a new HLS source in audio-player that fetches the playlist and streams the mp3 segments in order as one continuous mp3 (mp3 frames byte-concatenate into a valid stream — the same fact ffmpeg -c copy relies on). rodio's existing symphonia mp3 path then decodes it, unchanged.
  • Captures (W, download) come for free once nodes serve tracks and raise is_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 select protocol == "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 via GET /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 new ProviderCommand.
  • A missing/rotated/invalid client_id must never crash startup or a browse. The provider self-heals by scraping and by re-scraping on 401/403; only if scraping itself fails does the /soundcloud subtree 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_token are secrets or ephemeral signed URLs: redact from Debug/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 inlikes and playlists. Not itself queueable.
  • /soundcloud/search / /soundcloud/resolveis_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 via a new HLS source in audio-player

  • get_urls_for_track(/soundcloud/track/<id>): GET /tracks/<id> (or use a cached transcoding), pick the hls + audio/mpeg transcoding, GET <transcoding.url>?client_id=… → the .m3u8 media URL, and return that URL as urls[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.
  • New audio-player component HlsStream — a stream-download SourceStream, sibling to WindowedHttpStream: 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_source selects HlsStream when 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. #EXTM3U content-sniff is a hardening fallback if needed.
  • Duration comes from the track metadata (duration ms → 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, album from publisher_metadata when present else None, 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 via settings() so we don't re-scrape every start), oauth_token: Option<String> (optional), and bounds (D6). Hand-written Debug redacts client_id/oauth_token.
  • No login (baseline): if client_id is unset, ScApi scrapes it from soundcloud.com at init; on any 401/403 it re-scrapes once and retries (rotation recovery). The freshly scraped id is persisted.
  • Optional login: if oauth_token is present, get_lib_root adds likes and playlists, and personal calls send Authorization: OAuth <token>. If a refresh-token flow is configured later it mirrors tidal's persist-on-refresh; v1 accepts a static token and, on 401, 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, and hls_total_deadline_secs (default 300) bounds a whole HLS fetch (segments are retried with jitter under this deadline). Caps are log-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 new OpusSource per segment; the HlsStream seam 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_id in soundcloud.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 .m3u8 and 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; HlsStream follows 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 in ScApi. Live validation is a task-plan gate.