# 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 `. `search/*` add `&limit=&offset=&linked_partitioning=1`. Endpoints we use: | Purpose | Endpoint | | --- | --- | | Resolve a permalink URL | `GET /resolve?url=` | | Search tracks | `GET /search/tracks?q=` | | Search playlists | `GET /search/playlists?q=` | | Track detail | `GET /tracks/` | | Playlist detail | `GET /playlists/` | | Transcoding → media URL | `GET ` → `{"url": ""}` | | (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=&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 `id`s 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>` 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` — 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/` and `playlist/`. Browse nodes point their children at these canonical paths. - `/soundcloud` — children: `search` (creatable), `resolve` (creatable), and — **only if logged in** — `likes` and `playlists`. Not itself queueable. - `/soundcloud/search` / `/soundcloud/resolve` — `is_creatable`; children are the in-memory terms/URLs (`RwLock>`, dedup), each editable and deletable, like tidal/youtube/fyyd/abs search terms. - `/soundcloud/search/` — matching **tracks** as queueable leaves (and, optionally, matching playlists as containers). - `/soundcloud/resolve/` — 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/` — a playlist's tracks (queueable, downloadable); the canonical container path, reached from search/resolve/likes/playlists. - `/soundcloud/track/` — 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/)`: `GET /tracks/`, pick the best mp3 transcoding (`progressive` first, then `hls`), `GET ?client_id=…` → the media URL, and **return it 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. A track whose exchange 404s (Go+/label preview, geo-blocked) surfaces `NotStreamable`/`NotFound` and is skipped, never a crash. - **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`, `provider_item_id` = `"track:"` (keys the capture store). ### D5 — Auth: client_id self-heal, optional OAuth login - `Settings`: `client_id: Option`, `app_version: Option` (both **cached** after first scrape and round-tripped via `settings()` so we don't re-scrape every start), `oauth_token: Option` (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 `. 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 ```d2 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 ```d2 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/" 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/)" tui -> orch: "queue + play a track" orch -> s: "get_urls_for_track(/soundcloud/track/)" s -> api: "GET /tracks/ → pick hls+audio/mpeg transcoding" s -> api: "GET ?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.