Add the SoundCloud provider (`/soundcloud`) with HLS playback

New `soundclouddy` crate mounted at `/soundcloud`: search tracks and
playlists, resolve permalink URLs, and — with an optional OAuth token — the
user's likes and playlists (public browse/play needs only a client_id). Ran the
full dev-flow: architecture/soundcloud-provider.md, quality/soundcloud-provider.md,
plan/soundcloud.md, plan/summary.md.

- Provider logic over an `Sc` reqwest seam (faked in tests): creatable
  `search`/`resolve` parents, canonical `track/<id>` and `playlist/<id>`
  leaves, playlist hydration, download blessing — mirrors abs/fyyd.
- Auth: `client_id` from config or scraped from soundcloud.com (pure parsers,
  unit-tested), re-scraped once on 401; scraped id persisted via `settings()`.
- Playback: a new `HlsStream` SourceStream in audio-player streams the m3u8's
  mp3 segments in order as one continuous mp3; `open_source` routes `.m3u8` to
  it, non-seekable so symphonia never end-seeks a length-less stream.
- Wired into crabidy-server the standard way (settings toggle, sc_owns/
  sc_provider, non-fatal build block, root child, dispatch arms).

Verified offline: soundclouddy 19 tests, audio-player 14 (incl. HLS-parser),
crabidy-server 77+4 — all green; fmt/clippy/machete clean. The live client_id
scrape, real JSON shapes, and mp3-HLS play-to-EOS need real SoundCloud access
and are covered by tests/live.rs + #[ignore] gates (quality G7/G8/G14/G19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-24 02:27:10 +02:00
parent 9fba9708bf
commit 7091d37c32
17 changed files with 2846 additions and 28 deletions

15
Cargo.lock generated
View File

@ -1173,6 +1173,7 @@ dependencies = [
"realfft",
"reqwest 0.13.1",
"serde",
"soundclouddy",
"tempfile",
"thiserror 2.0.19",
"tidaldy",
@ -4771,6 +4772,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "soundclouddy"
version = "0.1.0"
dependencies = [
"async-trait",
"crabidy-core",
"reqwest 0.13.1",
"serde",
"thiserror 2.0.19",
"tokio",
"toml",
"tracing",
]
[[package]]
name = "spin"
version = "0.9.9"

View File

@ -11,6 +11,7 @@ members = [
"crabidy-server",
"fsdy",
"fyyd",
"soundclouddy",
"tidaldy",
"ytdy",
]
@ -108,5 +109,6 @@ crabidy-core = { path = "crabidy-core" }
crabidy-server = { path = "crabidy-server" }
fsdy = { path = "fsdy" }
fyyd = { path = "fyyd" }
soundclouddy = { path = "soundclouddy" }
tidaldy = { path = "tidaldy" }
ytdy = { path = "ytdy" }

View File

@ -0,0 +1,292 @@
# 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 `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<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**`likes` and `playlists`. 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 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
```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/<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.

318
audio-player/src/hls.rs Normal file
View File

@ -0,0 +1,318 @@
//! An HLS [`SourceStream`]: fetches an `.m3u8` media playlist and streams its
//! mp3 segments in order as one continuous byte stream.
//!
//! SoundCloud (and other HLS sources) serve audio as a playlist of short mp3
//! segments rather than one file. mp3 frames byte-concatenate into a valid
//! stream (the fact `ffmpeg -c copy` relies on), so streaming the segments in
//! order yields bytes rodio's symphonia mp3 decoder handles unchanged
//! (architecture/soundcloud-provider.md D4).
//!
//! Sibling to [`crate::windowed_http::WindowedHttpStream`]. This is **forward
//! only**: it reports the source as non-seekable and length-less, so symphonia
//! does not attempt an end-seek that would need a known total length (the
//! rodio-0.22 panic the opus work documented).
//!
//! Never logs the playlist or segment URLs — they are signed and ephemeral.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures::{Future, Stream};
use stream_download::source::{DecodeError, SourceStream};
use tracing::{debug, trace, warn};
use url::Url;
/// Parameters for [`HlsStream::create`].
#[derive(Clone, Debug)]
pub struct HlsParams {
/// The `.m3u8` media-playlist URL.
pub url: Url,
pub client: reqwest::Client,
}
impl HlsParams {
pub fn new(url: Url, client: reqwest::Client) -> Self {
Self { url, client }
}
}
/// Error creating the stream (playlist fetch/parse failed).
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct HlsError(String);
impl DecodeError for HlsError {}
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
type SegmentFuture = Pin<Box<dyn Future<Output = io::Result<BytesStream>> + Send + Sync>>;
/// A parsed playlist: either the media (segment) playlist we want, or a master
/// playlist pointing at variant playlists (we follow the first).
enum Playlist {
Media(Vec<Url>),
Master(Url),
}
enum State {
/// Ready to fetch `segments[cursor]`.
Idle,
/// Waiting for a segment response.
Requesting(SegmentFuture),
/// Draining a segment body.
Streaming(BytesStream),
Finished,
}
/// See the module docs. Streams `segments[cursor]` bytes, advancing at segment
/// boundaries; finishes after the last.
pub struct HlsStream {
client: reqwest::Client,
/// Ordered mp3 segment URLs parsed from the media playlist.
segments: Vec<Url>,
/// Index of the next segment to fetch.
cursor: usize,
state: State,
}
impl std::fmt::Debug for HlsStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HlsStream")
.field("segments", &self.segments.len())
.field("cursor", &self.cursor)
.finish_non_exhaustive()
}
}
impl HlsStream {
/// Schedules the fetch of the segment at `cursor`, or finishes when none
/// remain.
fn schedule_next(&mut self) {
let Some(url) = self.segments.get(self.cursor).cloned() else {
self.state = State::Finished;
return;
};
self.cursor += 1;
self.state = State::Requesting(Box::pin(fetch_segment(self.client.clone(), url)));
}
}
/// GETs one segment, returning its body byte-stream. A non-success status is an
/// error carrying the status only (never the signed URL).
async fn fetch_segment(client: reqwest::Client, url: Url) -> io::Result<BytesStream> {
trace!("fetching hls segment");
let resp = client.get(url).send().await.map_err(|err| {
io::Error::other(format!("segment request failed: {}", err.without_url()))
})?;
if !resp.status().is_success() {
return Err(io::Error::other(format!(
"segment request rejected: {}",
resp.status()
)));
}
Ok(Box::pin(resp.bytes_stream()))
}
impl Stream for HlsStream {
type Item = io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
loop {
match &mut this.state {
State::Finished => return Poll::Ready(None),
State::Idle => this.schedule_next(),
State::Requesting(future) => match future.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(stream)) => this.state = State::Streaming(stream),
Poll::Ready(Err(err)) => {
this.state = State::Finished;
return Poll::Ready(Some(Err(err)));
}
},
State::Streaming(stream) => match stream.as_mut().poll_next(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Some(Ok(bytes))) => return Poll::Ready(Some(Ok(bytes))),
Poll::Ready(Some(Err(err))) => {
return Poll::Ready(Some(Err(io::Error::other(format!(
"segment body failed: {}",
err.without_url()
)))));
}
// Segment drained: move to the next one.
Poll::Ready(None) => this.schedule_next(),
},
}
}
}
}
impl SourceStream for HlsStream {
type Params = HlsParams;
type StreamCreationError = HlsError;
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
let segments = load_segments(&params.client, params.url).await?;
debug!(segments = segments.len(), "hls stream open");
if segments.is_empty() {
return Err(HlsError("hls playlist has no segments".to_string()));
}
Ok(Self {
client: params.client,
segments,
cursor: 0,
state: State::Idle,
})
}
/// Unknown up front (segment sizes are not in the playlist), so `None` —
/// which, with `supports_seek() == false`, keeps symphonia off the
/// end-seek path.
fn content_length(&self) -> Option<u64> {
None
}
/// Forward-only. In-track seeking (open at a segment offset) is a later,
/// additive change (architecture/soundcloud-provider.md D7).
async fn seek_range(&mut self, _start: u64, _end: Option<u64>) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"HLS stream is forward-only",
))
}
/// Best-effort resume after a dropped connection: re-open the current
/// segment. `stream-download`'s temp storage already holds everything up to
/// `current_position`, and its `Read` side serves that; this only needs to
/// resume producing fresh bytes, so re-fetching the in-flight segment is
/// acceptable (a short overlap at worst).
async fn reconnect(&mut self, _current_position: u64) -> io::Result<()> {
let restart = self.cursor.saturating_sub(1);
warn!(
segment = restart,
"reconnecting hls stream at current segment"
);
self.cursor = restart;
self.state = State::Idle;
Ok(())
}
fn supports_seek(&self) -> bool {
false
}
}
/// Fetches the playlist at `url`, following one level of master playlist, and
/// returns the ordered media-segment URLs.
async fn load_segments(client: &reqwest::Client, url: Url) -> Result<Vec<Url>, HlsError> {
let body = fetch_text(client, url.clone()).await?;
match parse_playlist(&body, &url)? {
Playlist::Media(segments) => Ok(segments),
Playlist::Master(variant) => {
let body = fetch_text(client, variant.clone()).await?;
match parse_playlist(&body, &variant)? {
Playlist::Media(segments) => Ok(segments),
// A master pointing at another master is not something
// SoundCloud produces; refuse rather than recurse.
Playlist::Master(_) => Err(HlsError("nested master playlist".to_string())),
}
}
}
}
async fn fetch_text(client: &reqwest::Client, url: Url) -> Result<String, HlsError> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| HlsError(format!("playlist request failed: {}", e.without_url())))?;
if !resp.status().is_success() {
return Err(HlsError(format!("playlist rejected: {}", resp.status())));
}
resp.text()
.await
.map_err(|e| HlsError(format!("playlist read failed: {e}")))
}
/// Parses an m3u8 body. A master playlist (`#EXT-X-STREAM-INF`) yields the
/// first variant URI; otherwise every non-comment line is a media segment.
/// Relative URIs resolve against `base`.
fn parse_playlist(body: &str, base: &Url) -> Result<Playlist, HlsError> {
let is_master = body.contains("#EXT-X-STREAM-INF");
let mut uris = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let resolved = base
.join(line)
.map_err(|e| HlsError(format!("bad segment URI: {e}")))?;
uris.push(resolved);
if is_master {
// Only the first variant is needed.
break;
}
}
if is_master {
uris.into_iter()
.next()
.map(Playlist::Master)
.ok_or_else(|| HlsError("master playlist has no variant".to_string()))
} else {
Ok(Playlist::Media(uris))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Url {
Url::parse("https://cf-hls.sndcdn.com/media/0/playlist.m3u8?token=x").unwrap()
}
#[test]
fn parses_media_playlist_absolute_and_relative() {
let body = "#EXTM3U\n\
#EXT-X-VERSION:6\n\
#EXTINF:10.0,\n\
https://cdn.sndcdn.com/media/0/1.ts\n\
#EXTINF:9.9,\n\
2.ts\n\
#EXT-X-ENDLIST\n";
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
panic!("expected media playlist");
};
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].as_str(), "https://cdn.sndcdn.com/media/0/1.ts");
// Relative URI resolved against the playlist URL.
assert_eq!(segs[1].as_str(), "https://cf-hls.sndcdn.com/media/0/2.ts");
}
#[test]
fn follows_first_master_variant() {
let body = "#EXTM3U\n\
#EXT-X-STREAM-INF:BANDWIDTH=128000\n\
variant-128.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=64000\n\
variant-64.m3u8\n";
let Playlist::Master(url) = parse_playlist(body, &base()).unwrap() else {
panic!("expected master playlist");
};
assert_eq!(
url.as_str(),
"https://cf-hls.sndcdn.com/media/0/variant-128.m3u8"
);
}
#[test]
fn ignores_comments_and_blank_lines() {
let body = "#EXTM3U\n\n#EXTINF:1,\nonly.ts\n\n";
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
panic!("expected media playlist");
};
assert_eq!(segs.len(), 1);
}
}

View File

@ -1,3 +1,4 @@
mod hls;
mod opus_source;
mod player;
mod player_engine;

View File

@ -11,6 +11,7 @@ use rodio::{Decoder, Source};
use stream_download::storage::temp::TempStorageProvider;
use stream_download::{Settings, StreamDownload};
use crate::hls::{HlsParams, HlsStream};
use crate::opus_source::{is_ogg_opus, OpusSource};
use crate::spectrum_tap::{SpectrumTap, TappingSource};
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
@ -309,32 +310,57 @@ impl PlayerEngine {
host = url.host_str().unwrap_or("?"),
"opening network stream"
);
// Windowed fetching: some CDNs (googlevideo) reject plain
// and open-ended requests with 403 and only serve bounded
// ranges (see audio-player/src/windowed_http.rs).
let params = WindowedHttpParams::new(url.clone(), self.http.clone());
// HLS (`.m3u8`, e.g. SoundCloud) is a playlist of mp3 segments,
// streamed in order by HlsStream; every other http URL uses the
// windowed fetcher (some CDNs like googlevideo 403 plain and
// open-ended requests, serving only bounded ranges — see
// audio-player/src/windowed_http.rs).
let is_hls = Path::new(url.path())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("m3u8"));
let reader = self.runtime.block_on(async {
tokio::time::timeout(
STREAM_OPEN_TIMEOUT,
StreamDownload::new::<WindowedHttpStream>(
params,
TempStorageProvider::new(),
Settings::default(),
),
)
.await
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
.context("failed to open http stream")
// Each arm normalizes its distinct `StreamInitError<S>` to
// `anyhow` so the branches unify.
let open = async {
if is_hls {
StreamDownload::new::<HlsStream>(
HlsParams::new(url.clone(), self.http.clone()),
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| anyhow!("{err}"))
} else {
StreamDownload::new::<WindowedHttpStream>(
WindowedHttpParams::new(url.clone(), self.http.clone()),
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| anyhow!("{err}"))
}
};
tokio::time::timeout(STREAM_OPEN_TIMEOUT, open)
.await
.map_err(|_| {
anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}")
})?
.context("failed to open http stream")
})?;
let byte_len = reader.content_length();
let hint = Path::new(url.path())
.extension()
.and_then(|e| e.to_str())
.map(str::to_owned);
// HLS carries mp3 segments and no total length; hint mp3 and
// mark non-seekable so symphonia never end-seeks (which panics
// rodio 0.22 on a length-less stream).
let hint = if is_hls {
Some("mp3")
} else {
Path::new(url.path()).extension().and_then(|e| e.to_str())
};
self.build_source(
reader,
byte_len,
hint.as_deref(),
hint,
!is_hls,
"failed to decode http stream",
)
}
@ -352,6 +378,7 @@ impl PlayerEngine {
BufReader::new(file),
byte_len,
hint.as_deref(),
true,
"failed to decode file",
)
}
@ -369,6 +396,7 @@ impl PlayerEngine {
mut reader: R,
byte_len: Option<u64>,
hint: Option<&str>,
seekable: bool,
decode_err: &'static str,
) -> Result<(Box<dyn Source + Send>, Option<Duration>)>
where
@ -382,7 +410,7 @@ impl PlayerEngine {
if is_ogg_opus(&header[..n]) {
debug!("decoding Ogg-Opus via libopus");
let source = OpusSource::new(reader, byte_len, true)?;
let source = OpusSource::new(reader, byte_len, seekable)?;
let duration = source.total_duration();
let tapped: Box<dyn Source + Send> =
Box::new(TappingSource::new(source, self.spectrum.clone()));
@ -392,8 +420,9 @@ impl PlayerEngine {
// Symphonia probes the container length during init; without a known
// byte length it seeks from the end, which rodio 0.22 turns into an
// `unreachable!` panic on a streamed source. Handing it the content
// length up front avoids that seek entirely.
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
// length up front avoids that seek entirely; for length-less streams
// (HLS) `seekable` is false so symphonia never attempts that seek.
let mut builder = Decoder::builder().with_data(reader).with_seekable(seekable);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}

View File

@ -40,6 +40,7 @@ futures.workspace = true
rand.workspace = true
reqwest.workspace = true
serde.workspace = true
soundclouddy.workspace = true
thiserror.workspace = true
tidaldy.workspace = true
tokio = { workspace = true, features = ["full"] }

View File

@ -44,6 +44,10 @@ pub struct ProviderOrchestrator {
/// missing/incomplete — non-fatal, it only costs the `/abs` subtree
/// (architecture/audiobookshelf-provider.md D1).
abs_client: Option<Arc<absdy::Client>>,
/// The SoundCloud provider; `None` when disabled or no `client_id` could be
/// obtained — non-fatal, it only costs the `/soundcloud` subtree
/// (architecture/soundcloud-provider.md D1).
sc_client: Option<Arc<soundclouddy::Client>>,
}
/// Whether a path belongs to the filesystem provider.
@ -77,6 +81,11 @@ fn abs_owns(path: &str) -> bool {
path == absdy::PROVIDER_ROOT || path.starts_with("/abs/")
}
/// Whether a path belongs to the SoundCloud provider.
fn sc_owns(path: &str) -> bool {
path == soundclouddy::PROVIDER_ROOT || path.starts_with("/soundcloud/")
}
impl ProviderOrchestrator {
/// The tidal client, or `MalformedPath` (with a warning) when the
/// provider is disabled — a `/tidal` path then has no owner.
@ -145,6 +154,15 @@ impl ProviderOrchestrator {
ProviderError::MalformedPath
})
}
/// The SoundCloud client, or `MalformedPath` (with a warning) when the
/// provider is disabled — a `/soundcloud` path then has no owner.
fn sc_provider(&self) -> Result<&soundclouddy::Client, ProviderError> {
self.sc_client.as_deref().ok_or_else(|| {
warn!("soundcloud provider is disabled");
ProviderError::MalformedPath
})
}
pub fn run(self) {
tokio::spawn(async move {
// Behind an Arc so long-running resolves can be spawned onto
@ -459,6 +477,29 @@ impl ProviderOrchestrator {
} else {
None
};
// SoundCloud: non-fatal. Works with no config (scrapes a `client_id`);
// a total failure to obtain one disables the `/soundcloud` subtree
// only (architecture/soundcloud-provider.md D1). `init` may write back
// a freshly scraped `client_id` so it persists.
let sc_client = if enabled.soundcloud {
let sc_config_file = config_dir.join("soundcloud.toml");
debug!(config_file = %sc_config_file.display(), "loading soundcloud config");
let raw_sc_settings = fs::read_to_string(&sc_config_file).unwrap_or_default();
match soundclouddy::Client::init(&raw_sc_settings).await {
Ok(client) => {
if let Err(err) = tokio::fs::write(&sc_config_file, client.settings()).await {
error!("failed to write soundcloud config file: {err}");
}
Some(Arc::new(client))
}
Err(err) => {
warn!("soundcloud provider disabled: {err}");
None
}
}
} else {
None
};
let (provider_tx, provider_rx) = flume::bounded(100);
Ok(Self {
provider_rx,
@ -471,6 +512,7 @@ impl ProviderOrchestrator {
youtube_client,
fyyd_client,
abs_client,
sc_client,
})
}
}
@ -531,6 +573,12 @@ impl ProviderClient for ProviderOrchestrator {
.as_ref()
.is_some_and(|abs| abs.is_track_path(path));
}
if sc_owns(path) {
return self
.sc_client
.as_ref()
.is_some_and(|sc| sc.is_track_path(path));
}
false
}
@ -566,6 +614,9 @@ impl ProviderClient for ProviderOrchestrator {
if abs_owns(track_path) {
return self.abs_provider()?.get_urls_for_track(track_path).await;
}
if sc_owns(track_path) {
return self.sc_provider()?.get_urls_for_track(track_path).await;
}
warn!(path = track_path, "no provider owns this track path");
Err(ProviderError::MalformedPath)
}
@ -611,6 +662,9 @@ impl ProviderClient for ProviderOrchestrator {
.get_metadata_for_track(track_path)
.await;
}
if sc_owns(track_path) {
return self.sc_provider()?.get_metadata_for_track(track_path).await;
}
warn!(path = track_path, "no provider owns this track path");
Err(ProviderError::MalformedPath)
}
@ -658,6 +712,14 @@ impl ProviderClient for ProviderOrchestrator {
LibraryNodeChild::new(absdy::PROVIDER_ROOT.to_owned(), "abs".to_owned(), false);
root_node.children.push(child);
}
if self.sc_client.is_some() {
let child = LibraryNodeChild::new(
soundclouddy::PROVIDER_ROOT.to_owned(),
"soundcloud".to_owned(),
false,
);
root_node.children.push(child);
}
root_node
}
@ -681,6 +743,8 @@ impl ProviderClient for ProviderOrchestrator {
self.fyyd_provider()?.get_lib_node(path).await?
} else if abs_owns(path) {
self.abs_provider()?.get_lib_node(path).await?
} else if sc_owns(path) {
self.sc_provider()?.get_lib_node(path).await?
} else {
warn!(path, "no provider owns this path");
return Err(ProviderError::MalformedPath);
@ -743,6 +807,12 @@ impl ProviderClient for ProviderOrchestrator {
.create_lib_node(parent_path, title)
.await;
}
if sc_owns(parent_path) {
return self
.sc_provider()?
.create_lib_node(parent_path, title)
.await;
}
warn!(parent_path, "no provider supports creating nodes here");
Err(ProviderError::NotSupported)
}
@ -788,6 +858,9 @@ impl ProviderClient for ProviderOrchestrator {
if abs_owns(path) {
return self.abs_provider()?.rename_lib_node(path, new_title).await;
}
if sc_owns(path) {
return self.sc_provider()?.rename_lib_node(path, new_title).await;
}
warn!(path, "no provider supports renaming this node");
Err(ProviderError::NotSupported)
}
@ -842,6 +915,12 @@ impl ProviderClient for ProviderOrchestrator {
.resolve_tracks_into(path, chunk_tx)
.await;
}
if sc_owns(path) {
return self
.sc_provider()?
.resolve_tracks_into(path, chunk_tx)
.await;
}
warn!(path, "no provider owns this path");
Err(ProviderError::MalformedPath)
}
@ -871,6 +950,9 @@ impl ProviderClient for ProviderOrchestrator {
if abs_owns(path) {
return self.abs_provider()?.delete_lib_node(path).await;
}
if sc_owns(path) {
return self.sc_provider()?.delete_lib_node(path).await;
}
warn!(path, "no provider supports deleting this node");
Err(ProviderError::NotSupported)
}

View File

@ -16,11 +16,18 @@ use serde::{Deserialize, Serialize};
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
/// Every built-in provider, in the order the default config lists them. Each
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, `/fs`,
/// `/crabidy`, `/orphans`). `orphans` is a view over the store, so it needs
/// `crabidy`.
pub const ALL_PROVIDERS: [&str; 7] = [
"tidal", "youtube", "fyyd", "abs", "fs", "crabidy", "orphans",
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`,
/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the
/// store, so it needs `crabidy`.
pub const ALL_PROVIDERS: [&str; 8] = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"fs",
"crabidy",
"orphans",
];
/// Contents of `crabidy-server.toml`.
@ -70,6 +77,7 @@ pub struct ProviderToggles {
pub youtube: bool,
pub fyyd: bool,
pub abs: bool,
pub soundcloud: bool,
pub fs: bool,
pub crabidy: bool,
pub orphans: bool,
@ -84,6 +92,7 @@ impl ProviderToggles {
youtube: true,
fyyd: true,
abs: true,
soundcloud: true,
fs: true,
crabidy: true,
orphans: true,
@ -191,6 +200,7 @@ impl ServerSettings {
youtube: self.provider_enabled("youtube"),
fyyd: self.provider_enabled("fyyd"),
abs: self.provider_enabled("abs"),
soundcloud: self.provider_enabled("soundcloud"),
fs: self.provider_enabled("fs"),
crabidy: self.provider_enabled("crabidy"),
orphans: self.provider_enabled("orphans"),

111
plan/soundcloud.md Normal file
View File

@ -0,0 +1,111 @@
# Task plan — soundcloud provider
**Status (2026-07-24): all phases AE implemented.** Everything offline is
verified (unit tests, parsers, server build, fmt/clippy/machete). The live-only
gates — the real `client_id` scrape (B2/B3), live JSON shapes (B4/B5), and
mp3-HLS play-to-EOS (C4), plus browsing `/soundcloud` (D4) and `tests/live.rs`
(E1) — need real SoundCloud access and are left to run on a machine with creds
and audio. See `plan/summary.md`.
Executes architecture/soundcloud-provider.md against the stubs in
`soundclouddy/` and `audio-player/src/hls.rs`, satisfying the gates in
`quality/soundcloud-provider.md`. Ordered by dependency. Verification column
names the test(s) / gate(s) each task must satisfy.
## Phase A — Provider logic (no network; unit-testable now)
- [ ] **A1 — `Settings` + accessors.** Bounds accessors (`search_results`,
`playlist_tracks`, `call_timeout`, `hls_deadline`) with the `DEFAULT_*`
consts; redacting `Debug` already present. *(G1; `settings_debug_redacts_secrets`.)*
- [ ] **A2 — `get_lib_root` builds root children directly** (sync, no network):
`search` + `resolve`, plus `likes` + `playlists` iff `logged_in`. Serve the
Root node from here (decision #1) and have `get_lib_node(Root)` delegate to it.
*(G10; `root_without_login_has_no_personal_nodes`, `root_with_login_shows_personal_nodes`.)*
- [ ] **A3 — `sc_track` mapper** ScTrack→proto `Track` at `/soundcloud/track/<id>`,
`provider_item_id = "track:<id>"`, ms→`Option<u32>`. *(G12.)*
- [ ] **A4 — `search_node` + term store** (create/rename/delete, dedup, implicit
recreation). *(G17; `create_rename_delete_search_term`.)*
- [ ] **A5 — `search_term_node`** lists tracks (into `node.tracks`, canonical
paths, queueable node) **and** playlists (into `node.children`, canonical
container paths, queueable). *(G13, G12, G16;
`search_term_lists_tracks_and_playlists_with_canonical_paths`.)*
- [ ] **A6 — `resolve_node` + URL store** (create/rename/delete), and
**`resolve_entry_node`**: track→`node.tracks=[t]`; playlist→child container.
*(G14, G17; `resolve_entry_shapes_track_and_playlist`, `create_resolve_url_entry`.)*
- [ ] **A7 — `playlist_node`** fetch detail, hydrate stub ids in ≤50 batches up
to `playlist_tracks`, `log` truncation. *(G15; `playlist_node_hydrates_stub_tracks`.)*
- [ ] **A8 — `likes_node` / `playlists_node`** (login only). *(G10.)*
- [ ] **A9 — `get_urls_for_track` / `get_metadata_for_track`** via
`resolve_stream_url` / `track_detail`; non-track path → `MalformedPath`.
*(G18; `get_urls_for_track_returns_the_m3u8`.)*
- [ ] **A10 — Download blessing** in `get_lib_node` (node + children), and typed
`fetch_err` mapping (no panics). *(G4, G16; `backend_failures_are_typed`.)*
- [ ] **A11 — Gate:** `cargo test -p soundclouddy` — all 15 tests pass. *(G11, G22.)*
## Phase B — HTTP seam `ScApi` (needs live SoundCloud to fully verify)
- [ ] **B1 — `ScApi::new`** builds the `reqwest` client with per-call timeout +
user-agent; holds `client_id`/`app_version` behind `RwLock`, optional OAuth.
*(G3, G6.)*
- [ ] **B2 — `scrape_client_id` free fn** — fetch soundcloud.com, find script
bundles, regex `client_id` + `app_version`. Unit-test the **parser** against a
captured HTML/JS fixture (no network). *(G7; new `scrape_parses_fixture` test.)*
- [ ] **B3 — Signed GET helper**: append `client_id`/`app_version`, OAuth header
when present; map status→`FetchError` (404/401/403); **re-scrape once on
401/403** then retry. *(G8; `reauth_rescrapes_once_then_gives_up` over a
mock/loopback.)*
- [ ] **B4 — Defensive wire DTOs** (`#[serde(default)]`, drop id-less entries)
and `into_*` converters for track/playlist/resolve/search/transcoding.
*(G5.)*
- [ ] **B5 — `resolve_stream_url`**: pick `hls`+`audio/mpeg` transcoding, GET its
url, return the `.m3u8`; `NotStreamable` when absent. *(G18.)*
- [ ] **B6 — `hydrate_tracks`** batched `/tracks?ids=` (≤50). *(G15.)*
- [ ] **B7 — `settings()`** folds cached `client_id`/`app_version` back in.
*(G9; `settings_round_trip_persists_scraped_id`.)*
- [ ] **B8 — `init`** parses TOML, builds `ScApi`, ensures a `client_id`
(scrape if none), non-fatal on total failure. *(G7.)*
## Phase C — HLS playback in `audio-player`
- [ ] **C1 — `parse_media_playlist`** hand-written line parser (`#EXTINF` + URI,
resolve relative against base, one-level master fallback). Unit test with a
fixture playlist string. *(G19; new `hls::tests::parses_media_playlist`.)*
- [ ] **C2 — `HlsStream` state machine** (`Stream` + `SourceStream`): fetch
segments in order, advance at boundaries, finish after last; `reconnect`
re-opens the current segment; forward-only, `content_length None`,
`supports_seek false`; total-deadline bound. *(G6, G19, G20.)*
- [ ] **C3 — Route `.m3u8` in `open_source`** to `HlsStream` (via `StreamDownload`)
before the windowed-HTTP path; opus/other sniffing downstream unchanged. Wire
`mod hls;` (already declared). *(G21.)*
- [ ] **C4 — Gate (integration):** an ffmpeg-built mp3-HLS fixture (or a live
track) plays to EOS with no panic; add a `#[ignore]` play-to-EOS test.
*(G19, G20.)*
## Phase D — Server wiring
- [ ] **D1 — Workspace dep** `soundclouddy = { path = "soundclouddy" }` in root
`Cargo.toml` `[workspace.dependencies]`; add to `crabidy-server` deps.
- [ ] **D2 — `settings.rs`**: `"soundcloud"` in `ALL_PROVIDERS` (→8),
`ProviderToggles.sc`, `all()`, `provider_toggles()`.
- [ ] **D3 — `provider.rs`**: `sc_client` field, `sc_owns`, `sc_provider`,
`build()` block reading `soundcloud.toml` (non-fatal, round-trip
`settings()`), `get_lib_root` child gated on `is_some()`, and a routing arm in
each dispatch method. *(mirrors abs.)*
- [ ] **D4 — Gate:** `cargo build -p crabidy-server`; browse `/soundcloud` in the
running app (manual/`/run`).
## Phase E — Validation & docs
- [ ] **E1 — `tests/live.rs`** (`#[ignore]`): reads `SOUNDCLOUD_OAUTH`/optional
`SOUNDCLOUD_CLIENT_ID` from env, exercises scrape→search→resolve→stream_url
end-to-end; confirms DTOs match live JSON. *(G7 live gate.)*
- [ ] **E2 — Quality sweep:** `cargo fmt`, `clippy`, `machete`; tick
`quality/soundcloud-provider.md`. *(G23.)*
- [ ] **E3 — `plan/summary.md`** records deviations from this plan.
## Notes
- **Live-verification boundary:** Phases A and C1 are fully verifiable offline.
B3/B5/C4/D4/E1 need live SoundCloud access (no creds in CI/sandbox) — deliver
as code + fixture/mock tests, run the live gate on a real machine.
- Do **not** commit secrets; `soundcloud.toml` stays out of git.

View File

@ -1055,3 +1055,70 @@ smoke test on the running server with a configured `abs.toml`.
Verification: `absdy` 14 tests + `crabidy-server` 12 (+ existing) +
`crabidy-core` 77 green; clippy and rustfmt clean on `absdy` and
`crabidy-server`.
## opus playback (2026-07-24)
Fixed "can't play opus files from the abs server". Root cause: rodio decodes
via symphonia 0.5, which ships **no Opus decoder**, and the abs provider serves
raw `.opus` files — so `Decoder::build()` failed on them (every other format
worked). Client-side fix, so it covers opus from **any** provider:
- **`audio-player/src/opus_source.rs`** (new): `OpusSource`, a rodio `Source`
that demuxes Ogg with symphonia's own Ogg reader and decodes Opus via
**`symphonia-adapter-libopus`** (libopus, registered into an explicit codec
registry — rodio's `Decoder` won't take a custom registry, so opus is driven
directly). Mirrors rodio 0.22's `SymphoniaDecoder` loop (channels/rate/
duration/seek). First packet decoded up front so the struct always holds a
real spec (symphonia's `SampleBuffer::new` divides by channel count).
- **`player_engine.rs`**: `open_source` refactored into a shared `build_source`
that sniffs the first 64 bytes (`OggS`+`OpusHead`), rewinds, and routes opus
to `OpusSource`, everything else to rodio. Shared across the http + file
paths. Content-sniffing (not the extension hint) is essential — the abs
stream URL has no extension (the ino is a number).
- **`devenv.nix`**: `cmake`+`ninja` (bundled libopus builds with CMake).
**Verified end-to-end** with ffmpeg-encoded fixtures: mono opus → 48 kHz, 2.02 s
duration, correct sample count; stereo opus → 2ch/48 kHz, and **seek to 1 s of a
3 s file** left ~2.02 s. fmt/clippy clean, 11 audio-player tests pass,
`crabidy-server` builds (public API unchanged).
## soundcloud provider — dev-flow S1S4 + implement Phase A (2026-07-24)
Ran the dev-flow for a `/soundcloud` provider (streamrip referenced for the
API). Decisions confirmed with the user: (1) root children served sync from
`get_lib_root`; (2) hand-written m3u8 parser (no dep); (3) `scrape_client_id`
free fn; (4) search lists tracks **and** playlists; playback via a new
`HlsStream`; client_id config-or-scrape with re-scrape-on-401; **login
optional** (OAuth token unlocks likes/playlists, else public-only).
- **S1 architecture** `architecture/soundcloud-provider.md` (D1D7, 2 diagrams).
- **S2 api-design** `soundclouddy` crate stubs (`Sc` seam, `Client`,
`ScPath`+real `parse_path`) + `audio-player/src/hls.rs` (`HlsStream` skeleton).
- **S3 quality-gates** `quality/soundcloud-provider.md` (G1G23) + acceptance
tests.
- **S4 task-plan** `plan/soundcloud.md` (phases AE, each mapped to a gate/test).
- **S5 implement — Phases AE built**:
- **A** provider tree/path logic (`lib.rs`) over the `Sc` seam.
- **B** `ScApi`: reqwest client with per-call timeout + browser UA, signed GET
with **re-scrape-on-401** (once), pure scrape parsers
(`extract_app_version`/`extract_script_urls`/`extract_client_id`, unit-tested
against fixtures), defensive wire DTOs, `resolve_stream_url` (picks
hls+audio/mpeg → media m3u8), batched `hydrate_tracks`, and `init` that
scrapes if needed and persists the id.
- **C** `audio-player/src/hls.rs` `HlsStream` (`SourceStream`): fetches the
m3u8, follows one master level, streams mp3 segments in order; forward-only,
length-less, non-seekable. `parse_playlist` unit-tested. `open_source` routes
`.m3u8` → HlsStream (non-seekable so symphonia never end-seeks); `build_source`
gained a `seekable` flag.
- **D** server wiring: `soundclouddy` dep, `settings.rs` (`ALL_PROVIDERS`→8,
`ProviderToggles.soundcloud`), `provider.rs` (`sc_client`, `sc_owns`,
`sc_provider`, non-fatal `build` block, root child, all 8 dispatch arms).
- **E** `soundclouddy/tests/live.rs` (`#[ignore]`, env-gated); fmt/clippy/machete
clean; dropped unused `serde_json`.
**Verified offline:** soundclouddy 19 tests + 4 api-parser tests, audio-player 14
(incl. 3 HLS-parser), crabidy-server 77+4 — all green; `cargo build -p
crabidy-server` clean. **Needs live SoundCloud to confirm (no creds/audio in
sandbox):** the actual `client_id` scrape, live API JSON shapes, and mp3-HLS
play-to-EOS — all exercised by `tests/live.rs` and the `#[ignore]` gates
(quality G7/G8/G14/G19). Run those on a real machine.

View File

@ -0,0 +1,114 @@
# Quality gates — soundcloud provider
Criteria an implementation of `soundclouddy`
(architecture/soundcloud-provider.md) must satisfy. Each is pass/fail by
reading/reasoning; automated coverage lives in `soundclouddy/src/tests.rs`
(unit, no network, over a `FakeSc`) and `soundclouddy/tests/live.rs` (ignored,
real SoundCloud).
**Status (2026-07-24):** G1, G3G6, G10G13, G15G18, G20G23 are
implemented and verified offline (unit tests + fmt/clippy/machete). G7/G8/G9
(scrape + re-auth + persist) are implemented; their **live** behaviour and
G14/G19 (real resolve/playlist + mp3-HLS play-to-EOS) are confirmed only by
`tests/live.rs` and a manual play-through on a machine with creds/audio. G6's
per-call timeouts are enforced; a whole-HLS-fetch deadline is deferred (the
reqwest client's per-segment timeout bounds each fetch).
## Secrets (hard rule — highest priority)
- [ ] **G1 — `client_id`/`oauth_token` never in `Debug`.** `Settings`, `Client`,
and `ScApi` `Debug` output redact both. *(test:
`settings_debug_redacts_secrets`.)*
- [ ] **G2 — The signed media URL never reaches a log or error.** The `.m3u8`
URL from `Sc::resolve_stream_url` (and the segment URLs in `HlsStream`) are
never passed to `debug!`/`warn!`/`error!`. Segment fetch errors carry status
only (use `reqwest::Error::without_url()` like `windowed_http.rs`).
- [ ] **G3 — `ScApi::Debug` redacts `client_id` and `oauth_token`** (manual
impl, not derived).
## Errors and robustness (hard rule: no panics on input/network)
- [ ] **G4 — No panics on bad input or network failures.** Backend failures map
to a typed `ProviderError` (`FetchError` for fetches, `MalformedPath` for bad
paths, `InvalidInput` for empty titles/URLs). No `unwrap`/`expect`/`panic!` on
request, path, or response data. *(tests: `backend_failures_are_typed`,
`foreign_and_malformed_paths_reject`.)*
- [ ] **G5 — Defensive decoding.** Wire DTOs use `#[serde(default)]`; missing
fields degrade (empty string / `None` / dropped entry), never error the whole
call. Tracks/playlists without an id are dropped, not panicked on.
- [ ] **G6 — Timeouts on every external call.** `ScApi` sets a per-call
`reqwest` timeout from `call_timeout_secs` (default 30); `HlsStream` bounds
the whole fetch by `hls_deadline_secs` (default 300).
## Auth: client_id self-heal, optional login (D5)
- [ ] **G7 — Works with no config.** With an empty `soundcloud.toml`, init
scrapes a `client_id` and the provider serves public browse/play; a total
scrape failure disables the provider **non-fatally** (never crashes startup).
- [ ] **G8 — Re-scrape on 401.** A `401/403` triggers exactly **one** re-scrape
and retry; a second failure surfaces as `FetchError::Unauthorized` (no
infinite loop). *(test: `reauth_rescrapes_once_then_gives_up`.)*
- [ ] **G9 — Scraped `client_id` is persisted.** `settings()` folds the cached
`client_id`/`app_version` back into the TOML so the next start does not
re-scrape. *(test: `settings_round_trip_persists_scraped_id`.)*
- [ ] **G10 — Login is optional and gates only personal nodes.** No
`oauth_token``get_lib_root` children are `search` + `resolve` only; a token
`likes` + `playlists` also appear. Public browse/play is identical either
way. *(tests: `root_without_login_has_no_personal_nodes`,
`root_with_login_shows_personal_nodes`.)*
## Tree and path contract
- [ ] **G11 — Path parsing is total and tag-safe.** Every `/soundcloud/...`
shape maps to a variant or `MalformedPath`; the literals
`search`/`resolve`/`likes`/`playlists`/`track`/`playlist` never collide with a
numeric id. Empty segments (`/soundcloud//x`) are `MalformedPath`. *(tests:
the `*_parse` / `*_reject` cases.)*
- [ ] **G12 — Canonical leaves.** Every track child points at
`/soundcloud/track/<id>` and every playlist child at
`/soundcloud/playlist/<id>`, so a track id alone resolves a stream (no browse
context needed). *(test: `search_children_use_canonical_paths`.)*
- [ ] **G13 — Search lists tracks *and* playlists.** `/soundcloud/search/<term>`
children include matching tracks (queueable leaves) and matching playlists
(containers). *(test: `search_term_lists_tracks_and_playlists`.)*
- [ ] **G14 — Resolve handles both kinds.** `/soundcloud/resolve/<url>` yields a
single track leaf or a playlist container per the resolved `kind`. *(test:
`resolve_entry_shapes_track_and_playlist`.)*
- [ ] **G15 — Playlists hydrate within bounds.** A playlist node hydrates stub
ids in ≤50-id batches up to `playlist_tracks` (default 500); a truncated
playlist is `log`-ged, not silently cut. *(test:
`playlist_node_hydrates_stub_tracks`.)*
- [ ] **G16 — Download blessing.** Every node serving tracks and each queueable
child raises `is_downloadable`, matching abs/fyyd. *(test:
`queueable_nodes_are_downloadable`.)*
- [ ] **G17 — Search terms / resolve URLs are creatable/editable/deletable.**
`/soundcloud/search` and `/soundcloud/resolve` are `is_creatable`; their term
children are `is_editable` + `is_deletable`, with implicit recreation on stale
paths. *(tests: `create_rename_delete_search_term`,
`create_resolve_url_entry`.)*
## Playback: HLS (D4)
- [ ] **G18 — `get_urls_for_track` returns one m3u8.** For a `track/<id>` path it
returns exactly `[m3u8_url]` via `resolve_stream_url`; a non-track path is
`MalformedPath`; a non-streamable track surfaces a typed error (skipped, never
a crash). *(test: `get_urls_for_track_returns_m3u8`.)*
- [ ] **G19 — `HlsStream` streams segments in order as continuous mp3.** The
media playlist is parsed (hand-written; relative URIs resolved against the
playlist URL; one level of master-playlist fallback), segments are fetched in
order, and the concatenated bytes decode as mp3. *(gate: live/integration —
a real or fixture m3u8 plays to EOS without panic.)*
- [ ] **G20 — `HlsStream` is forward-only and non-seekable.** `supports_seek()`
is `false` and `content_length()` is `None`, so symphonia never attempts the
end-seek that panics rodio 0.22 on a length-less stream. *(gate: verified in
the play-to-EOS integration check.)*
- [ ] **G21 — HLS routing.** `audio-player`'s `open_source` routes a `.m3u8`
URL to `HlsStream` and every other http URL to the windowed-HTTP path;
opus/other content-sniffing downstream is unchanged.
## Docs and hygiene
- [ ] **G22 — Public items documented.** Every `pub` item has a doc comment
stating intent and error/edge behavior.
- [ ] **G23 — `cargo fmt`/`clippy` clean** for `soundclouddy` and `audio-player`
(workspace lint level), and `cargo machete` reports no unused deps.

17
soundclouddy/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "soundclouddy"
version.workspace = true
edition.workspace = true
[dependencies]
async-trait.workspace = true
crabidy-core = { path = "../crabidy-core" }
reqwest.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time", "sync"] }
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

750
soundclouddy/src/api.rs Normal file
View File

@ -0,0 +1,750 @@
//! The SoundCloud HTTP seam.
//!
//! All network access goes through the [`Sc`] trait so the provider's
//! tree/path logic is unit-tested with a fake and no network
//! (architecture/soundcloud-provider.md D2). [`ScApi`] is the production
//! `reqwest` implementation; tests supply their own [`Sc`].
//!
//! Two secrets live here and must never leak: the `client_id` (scraped from
//! soundcloud.com and rotated) and the optional `oauth_token`. The resolved
//! HLS media URL is signed and ephemeral — also never logged. `ScApi` owns
//! `client_id` acquisition and the **re-scrape-on-401** recovery (D5), so the
//! provider layer never sees a raw credential. The scrape *parsers* are pure
//! functions (unit-tested against fixtures); only the fetch glue needs the
//! network.
use std::fmt::{self, Debug};
use std::time::Duration;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use thiserror::Error;
use tracing::{debug, warn};
/// The api-v2 base. Every request also carries `client_id` (+ `app_version`).
const API_BASE: &str = "https://api-v2.soundcloud.com";
/// The web app, scraped for a `client_id`/`app_version`.
const WEB_BASE: &str = "https://soundcloud.com";
/// Max ids per `/tracks?ids=` hydration batch (SoundCloud's cap).
const MAX_BATCH: usize = 50;
/// A typed SoundCloud request failure. Carries only non-secret context — never
/// the `client_id`, the OAuth token, or a signed media URL.
#[derive(Debug, Error)]
pub enum FetchError {
/// The HTTP call failed (transport, timeout, or non-success status).
#[error("soundcloud request failed: {0}")]
Http(String),
/// The response body did not decode into the expected shape.
#[error("soundcloud returned malformed data: {0}")]
Decode(String),
/// The resource does not exist (HTTP 404).
#[error("soundcloud resource not found")]
NotFound,
/// Auth was rejected (HTTP 401/403) even after a `client_id` re-scrape,
/// or an OAuth-only call without a token.
#[error("soundcloud authentication failed")]
Unauthorized,
/// No usable `client_id` could be obtained (scrape failed and none was
/// configured).
#[error("soundcloud client_id unavailable")]
NoClientId,
/// The track exposes no HLS/mp3 transcoding we can play.
#[error("soundcloud track is not streamable")]
NotStreamable,
}
/// A SoundCloud track. `id` is numeric (URL-safe). `duration_ms` is the track
/// length in milliseconds. Transcoding selection is hidden inside the seam, so
/// this domain type never carries stream URLs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScTrack {
pub id: String,
pub title: String,
/// The uploader's display name (`user.username`).
pub artist: String,
pub duration_ms: Option<u32>,
/// Album/release title from `publisher_metadata`, when present.
pub album: Option<String>,
}
/// A SoundCloud playlist/set. `tracks` may be **partially hydrated**: playlist
/// detail can return track stubs (ids only), which the provider fills via
/// [`Sc::hydrate_tracks`] in bounded batches.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScPlaylist {
pub id: String,
pub title: String,
pub artist: String,
/// Track ids in playlist order (authoritative even when `tracks` is
/// partial).
pub track_ids: Vec<String>,
/// Hydrated tracks known so far (a subset of `track_ids`, in order).
pub tracks: Vec<ScTrack>,
}
/// The result of resolving a permalink URL — a single track or a playlist.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolved {
Track(ScTrack),
Playlist(ScPlaylist),
}
/// The SoundCloud operations the provider needs. Behind `Box<dyn Sc>` so tests
/// fake it (architecture/soundcloud-provider.md D2).
///
/// Every method may transparently (re-)acquire a `client_id`; callers never
/// pass one. Personal methods (`my_*`) require a configured OAuth token and
/// return [`FetchError::Unauthorized`] without one.
#[async_trait]
pub trait Sc: Debug + Send + Sync {
/// Resolves a permalink URL to a track or playlist.
async fn resolve(&self, url: &str) -> Result<Resolved, FetchError>;
/// Tracks matching a free-text query (at most `limit`).
async fn search_tracks(&self, query: &str, limit: usize) -> Result<Vec<ScTrack>, FetchError>;
/// Playlists matching a free-text query (at most `limit`).
async fn search_playlists(
&self,
query: &str,
limit: usize,
) -> Result<Vec<ScPlaylist>, FetchError>;
/// One track's detail by id.
async fn track_detail(&self, id: &str) -> Result<ScTrack, FetchError>;
/// One playlist's detail by id (tracks may be stubs — see [`ScPlaylist`]).
async fn playlist_detail(&self, id: &str) -> Result<ScPlaylist, FetchError>;
/// Hydrates stub track ids into full tracks in ≤50-id batches; order
/// follows `ids`.
async fn hydrate_tracks(&self, ids: &[String]) -> Result<Vec<ScTrack>, FetchError>;
/// Resolves a track id to a **playable HLS `.m3u8` media URL**: picks the
/// `hls + audio/mpeg` transcoding and exchanges its API URL for the signed
/// media URL. The returned URL is ephemeral and secret — never logged.
/// [`FetchError::NotStreamable`] when no such transcoding exists.
async fn resolve_stream_url(&self, track_id: &str) -> Result<String, FetchError>;
/// The signed-in user's liked tracks (at most `limit`). Requires OAuth.
async fn my_likes(&self, limit: usize) -> Result<Vec<ScTrack>, FetchError>;
/// The signed-in user's playlists (at most `limit`). Requires OAuth.
async fn my_playlists(&self, limit: usize) -> Result<Vec<ScPlaylist>, FetchError>;
}
/// Production `reqwest` client for `api-v2.soundcloud.com`. `Debug` redacts the
/// `client_id` and OAuth token (hard rule: redact secrets from logs/reports).
///
/// The `client_id`/`app_version` are cached behind a lock so a 401 can swap in
/// a freshly scraped id at runtime without rebuilding the client.
pub struct ScApi {
http: reqwest::Client,
creds: tokio::sync::RwLock<Creds>,
/// Optional OAuth bearer for personal calls. Secret — redacted.
oauth_token: Option<String>,
}
/// The mutable scraped credentials.
#[derive(Default, Clone)]
struct Creds {
client_id: Option<String>,
app_version: Option<String>,
}
impl Debug for ScApi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ScApi")
.field("client_id", &"<redacted>")
.field(
"oauth_token",
&self.oauth_token.as_ref().map(|_| "<redacted>"),
)
.finish_non_exhaustive()
}
}
impl ScApi {
/// Builds the client. `client_id` may be `None` (it is scraped on first
/// use); `oauth_token` enables personal endpoints. `timeout` bounds every
/// call (hard rule: timeouts on external calls).
pub fn new(
client_id: Option<String>,
app_version: Option<String>,
oauth_token: Option<String>,
timeout: Duration,
) -> Result<Self, FetchError> {
let http = reqwest::Client::builder()
.timeout(timeout)
// A browser-ish UA: the web endpoints 403 obviously-bot clients.
.user_agent(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
)
.build()
.map_err(|err| FetchError::Http(err.to_string()))?;
Ok(Self {
http,
creds: tokio::sync::RwLock::new(Creds {
client_id: client_id.filter(|s| !s.trim().is_empty()),
app_version: app_version.filter(|s| !s.trim().is_empty()),
}),
oauth_token: oauth_token.filter(|s| !s.trim().is_empty()),
})
}
/// The currently cached `client_id`, if any (for `settings()` round-trip so
/// a scraped id is persisted and not re-scraped every start, D5).
pub async fn cached_client_id(&self) -> Option<String> {
self.creds.read().await.client_id.clone()
}
/// The currently cached `app_version`, if any (persisted like the id).
pub async fn cached_app_version(&self) -> Option<String> {
self.creds.read().await.app_version.clone()
}
/// Ensures a `client_id` is available, scraping once if not, and returns a
/// snapshot of the credentials. `init` calls this so a first run is ready
/// (and so the scraped id can be persisted).
pub async fn ensure_ready(&self) -> Result<(), FetchError> {
let have = self.creds.read().await.client_id.is_some();
if !have {
self.rescrape().await?;
}
Ok(())
}
/// Scrapes a fresh `client_id`/`app_version` and stores it under the write
/// lock (never held across the network fetch: fetch first, then swap).
async fn rescrape(&self) -> Result<(), FetchError> {
let (client_id, app_version) = scrape_client_id(&self.http).await?;
let mut creds = self.creds.write().await;
creds.client_id = Some(client_id);
creds.app_version = Some(app_version);
debug!("soundcloud client_id refreshed");
Ok(())
}
/// A `client_id` snapshot, scraping if absent.
async fn client_id(&self) -> Result<String, FetchError> {
if let Some(id) = self.creds.read().await.client_id.clone() {
return Ok(id);
}
self.rescrape().await?;
self.creds
.read()
.await
.client_id
.clone()
.ok_or(FetchError::NoClientId)
}
/// Signed GET of `url` with the given query pairs, decoding JSON. On a
/// 401/403 it re-scrapes the `client_id` **once** and retries; a second
/// rejection is [`FetchError::Unauthorized`] (no loop, G8).
async fn get_json<T: DeserializeOwned>(
&self,
url: &str,
query: &[(&str, String)],
) -> Result<T, FetchError> {
match self.get_once(url, query).await {
Err(FetchError::Unauthorized) => {
warn!("soundcloud auth rejected; re-scraping client_id once");
self.rescrape().await?;
self.get_once(url, query).await
}
other => other,
}
}
async fn get_once<T: DeserializeOwned>(
&self,
url: &str,
query: &[(&str, String)],
) -> Result<T, FetchError> {
let client_id = self.client_id().await?;
let app_version = self.creds.read().await.app_version.clone();
let mut req = self
.http
.get(url)
.query(&[("client_id", client_id.as_str())]);
if let Some(ver) = app_version.as_deref() {
req = req.query(&[("app_version", ver)]);
}
if !query.is_empty() {
req = req.query(query);
}
if let Some(token) = self.oauth_token.as_deref() {
req = req.header(reqwest::header::AUTHORIZATION, format!("OAuth {token}"));
}
let resp = req
.send()
.await
.map_err(|err| FetchError::Http(err.without_url().to_string()))?;
match resp.status() {
s if s.is_success() => resp
.json::<T>()
.await
.map_err(|err| FetchError::Decode(err.to_string())),
reqwest::StatusCode::NOT_FOUND => Err(FetchError::NotFound),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
Err(FetchError::Unauthorized)
}
status => Err(FetchError::Http(format!("status {status}"))),
}
}
fn require_oauth(&self) -> Result<(), FetchError> {
if self.oauth_token.is_some() {
Ok(())
} else {
Err(FetchError::Unauthorized)
}
}
}
#[async_trait]
impl Sc for ScApi {
async fn resolve(&self, url: &str) -> Result<Resolved, FetchError> {
let dto: ResolveDto = self
.get_json(&format!("{API_BASE}/resolve"), &[("url", url.to_string())])
.await?;
dto.into_resolved().ok_or(FetchError::NotFound)
}
async fn search_tracks(&self, query: &str, limit: usize) -> Result<Vec<ScTrack>, FetchError> {
let dto: Collection<TrackDto> = self
.get_json(
&format!("{API_BASE}/search/tracks"),
&[
("q", query.to_string()),
("limit", limit.to_string()),
("linked_partitioning", "1".to_string()),
],
)
.await?;
Ok(dto
.collection
.into_iter()
.filter_map(TrackDto::into_track)
.collect())
}
async fn search_playlists(
&self,
query: &str,
limit: usize,
) -> Result<Vec<ScPlaylist>, FetchError> {
let dto: Collection<PlaylistDto> = self
.get_json(
&format!("{API_BASE}/search/playlists"),
&[
("q", query.to_string()),
("limit", limit.to_string()),
("linked_partitioning", "1".to_string()),
],
)
.await?;
Ok(dto
.collection
.into_iter()
.filter_map(PlaylistDto::into_playlist)
.collect())
}
async fn track_detail(&self, id: &str) -> Result<ScTrack, FetchError> {
let dto: TrackDto = self
.get_json(&format!("{API_BASE}/tracks/{id}"), &[])
.await?;
dto.into_track().ok_or(FetchError::NotFound)
}
async fn playlist_detail(&self, id: &str) -> Result<ScPlaylist, FetchError> {
let dto: PlaylistDto = self
.get_json(&format!("{API_BASE}/playlists/{id}"), &[])
.await?;
dto.into_playlist().ok_or(FetchError::NotFound)
}
async fn hydrate_tracks(&self, ids: &[String]) -> Result<Vec<ScTrack>, FetchError> {
let mut out = Vec::with_capacity(ids.len());
for batch in ids.chunks(MAX_BATCH) {
let dto: Vec<TrackDto> = self
.get_json(&format!("{API_BASE}/tracks"), &[("ids", batch.join(","))])
.await?;
out.extend(dto.into_iter().filter_map(TrackDto::into_track));
}
Ok(out)
}
async fn resolve_stream_url(&self, track_id: &str) -> Result<String, FetchError> {
let dto: TrackDto = self
.get_json(&format!("{API_BASE}/tracks/{track_id}"), &[])
.await?;
let transcoding_url =
pick_hls_mp3(&dto.media.transcodings).ok_or(FetchError::NotStreamable)?;
let media: MediaUrl = self.get_json(&transcoding_url, &[]).await?;
if media.url.is_empty() {
return Err(FetchError::NotStreamable);
}
Ok(media.url)
}
async fn my_likes(&self, limit: usize) -> Result<Vec<ScTrack>, FetchError> {
self.require_oauth()?;
let dto: Collection<LikeDto> = self
.get_json(
&format!("{API_BASE}/me/likes/tracks"),
&[
("limit", limit.to_string()),
("linked_partitioning", "1".to_string()),
],
)
.await?;
Ok(dto
.collection
.into_iter()
.filter_map(|l| l.track.and_then(TrackDto::into_track))
.collect())
}
async fn my_playlists(&self, limit: usize) -> Result<Vec<ScPlaylist>, FetchError> {
self.require_oauth()?;
let dto: Collection<PlaylistDto> = self
.get_json(
&format!("{API_BASE}/me/playlists"),
&[
("limit", limit.to_string()),
("linked_partitioning", "1".to_string()),
],
)
.await?;
Ok(dto
.collection
.into_iter()
.filter_map(PlaylistDto::into_playlist)
.collect())
}
}
/// Picks the `hls + audio/mpeg` transcoding's API url (mp3-HLS, D4).
fn pick_hls_mp3(transcodings: &[TranscodingDto]) -> Option<String> {
transcodings
.iter()
.find(|t| t.format.protocol == "hls" && t.format.mime_type.starts_with("audio/mpeg"))
.map(|t| t.url.clone())
}
// --- client_id scraping: pure parsers + network glue -----------------------
/// Scrapes a fresh `(client_id, app_version)` from soundcloud.com. Fetches the
/// homepage, then the referenced JS bundles (newest first — the `client_id`
/// tends to live in the last one), returning the first match. Used at init
/// when none is configured, and on a 401 to survive rotation (D5).
pub async fn scrape_client_id(http: &reqwest::Client) -> Result<(String, String), FetchError> {
let html = http
.get(WEB_BASE)
.send()
.await
.map_err(|e| FetchError::Http(e.without_url().to_string()))?
.text()
.await
.map_err(|e| FetchError::Decode(e.to_string()))?;
let app_version = extract_app_version(&html).unwrap_or_default();
// Try the bundles in reverse: the client_id is usually in the last script.
for url in extract_script_urls(&html).into_iter().rev() {
let Ok(resp) = http.get(&url).send().await else {
continue;
};
let Ok(js) = resp.text().await else { continue };
if let Some(id) = extract_client_id(&js) {
return Ok((id, app_version));
}
}
Err(FetchError::NoClientId)
}
/// Extracts `window.__sc_version="<digits>"`.
fn extract_app_version(html: &str) -> Option<String> {
let after = html.split_once("__sc_version=\"")?.1;
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
(!digits.is_empty()).then_some(digits)
}
/// Extracts the `sndcdn.com/assets/*.js` bundle URLs referenced by the page, in
/// document order.
fn extract_script_urls(html: &str) -> Vec<String> {
let mut urls = Vec::new();
for part in html.split("src=\"").skip(1) {
if let Some((url, _)) = part.split_once('"') {
if url.contains("sndcdn.com") && url.ends_with(".js") {
urls.push(url.to_string());
}
}
}
urls
}
/// Extracts `client_id:"<token>"` (or `client_id="..."`) from a JS bundle.
fn extract_client_id(js: &str) -> Option<String> {
for sep in ["client_id:\"", "client_id=\"", "\"client_id\":\""] {
if let Some(after) = js.split_once(sep).map(|(_, r)| r) {
let id: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect();
if id.len() >= 16 {
return Some(id);
}
}
}
None
}
// --- Wire DTOs: decode defensively, missing fields degrade, never panic. ---
#[derive(Deserialize)]
struct Collection<T> {
#[serde(default = "Vec::new")]
collection: Vec<T>,
}
#[derive(Default, Deserialize)]
struct UserDto {
#[serde(default)]
username: String,
}
#[derive(Default, Deserialize)]
struct PublisherDto {
#[serde(default)]
album_title: Option<String>,
}
#[derive(Default, Deserialize)]
struct FormatDto {
#[serde(default)]
protocol: String,
#[serde(default)]
mime_type: String,
}
#[derive(Default, Deserialize)]
struct TranscodingDto {
#[serde(default)]
url: String,
#[serde(default)]
format: FormatDto,
}
#[derive(Default, Deserialize)]
struct MediaDto {
#[serde(default)]
transcodings: Vec<TranscodingDto>,
}
#[derive(Deserialize)]
struct MediaUrl {
#[serde(default)]
url: String,
}
#[derive(Default, Deserialize)]
struct TrackDto {
#[serde(default)]
id: Option<i64>,
#[serde(default)]
title: String,
#[serde(default)]
user: UserDto,
#[serde(default)]
duration: Option<u32>,
#[serde(default)]
publisher_metadata: Option<PublisherDto>,
#[serde(default)]
media: MediaDto,
}
impl TrackDto {
fn into_track(self) -> Option<ScTrack> {
let id = self.id?.to_string();
Some(ScTrack {
id,
title: self.title,
artist: self.user.username,
duration_ms: self.duration.filter(|d| *d > 0),
album: self
.publisher_metadata
.and_then(|p| p.album_title)
.filter(|s| !s.is_empty()),
})
}
}
#[derive(Deserialize)]
struct LikeDto {
#[serde(default)]
track: Option<TrackDto>,
}
/// A playlist track can arrive as a full object or a `{id}` stub.
#[derive(Default, Deserialize)]
struct PlaylistTrackDto {
#[serde(default)]
id: Option<i64>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
user: Option<UserDto>,
#[serde(default)]
duration: Option<u32>,
}
#[derive(Default, Deserialize)]
struct PlaylistDto {
#[serde(default)]
id: Option<i64>,
#[serde(default)]
title: String,
#[serde(default)]
user: UserDto,
#[serde(default)]
tracks: Vec<PlaylistTrackDto>,
}
impl PlaylistDto {
fn into_playlist(self) -> Option<ScPlaylist> {
let id = self.id?.to_string();
let mut track_ids = Vec::new();
let mut tracks = Vec::new();
for t in self.tracks {
let Some(tid) = t.id else { continue };
let tid = tid.to_string();
// A track with a title is already hydrated; otherwise it is a stub.
if let Some(title) = t.title {
tracks.push(ScTrack {
id: tid.clone(),
title,
artist: t.user.map(|u| u.username).unwrap_or_default(),
duration_ms: t.duration.filter(|d| *d > 0),
album: None,
});
}
track_ids.push(tid);
}
Some(ScPlaylist {
id,
title: self.title,
artist: self.user.username,
track_ids,
tracks,
})
}
}
/// `/resolve` returns a track or a playlist, discriminated by `kind`.
#[derive(Deserialize)]
struct ResolveDto {
#[serde(default)]
kind: String,
#[serde(flatten)]
track: TrackDto,
#[serde(default)]
tracks: Vec<PlaylistTrackDto>,
#[serde(default)]
id: Option<i64>,
#[serde(default)]
title: String,
#[serde(default)]
user: UserDto,
}
impl ResolveDto {
fn into_resolved(self) -> Option<Resolved> {
match self.kind.as_str() {
"playlist" => PlaylistDto {
id: self.id,
title: self.title,
user: self.user,
tracks: self.tracks,
}
.into_playlist()
.map(Resolved::Playlist),
_ => self.track.into_track().map(Resolved::Track),
}
}
}
#[cfg(test)]
mod api_tests {
use super::*;
#[test]
fn extracts_app_version() {
let html = r#"<script>window.__sc_version="1700000000";</script>"#;
assert_eq!(extract_app_version(html), Some("1700000000".to_string()));
assert_eq!(extract_app_version("nope"), None);
}
#[test]
fn extracts_script_bundles_in_order() {
let html = r#"
<script crossorigin src="https://a-v2.sndcdn.com/assets/0-abc.js"></script>
<script src="https://example.com/other.js"></script>
<script crossorigin src="https://a-v2.sndcdn.com/assets/9-zzz.js"></script>
"#;
assert_eq!(
extract_script_urls(html),
vec![
"https://a-v2.sndcdn.com/assets/0-abc.js".to_string(),
"https://a-v2.sndcdn.com/assets/9-zzz.js".to_string(),
]
);
}
#[test]
fn extracts_client_id_from_js() {
assert_eq!(
extract_client_id(r#"...,client_id:"aBcDeF0123456789xyz",..."#),
Some("aBcDeF0123456789xyz".to_string())
);
// Too-short tokens are ignored (avoids false positives).
assert_eq!(extract_client_id(r#"client_id:"short""#), None);
assert_eq!(extract_client_id("no id here"), None);
}
#[test]
fn picks_hls_mp3_transcoding() {
let transcodings = vec![
TranscodingDto {
url: "https://api/opus".into(),
format: FormatDto {
protocol: "hls".into(),
mime_type: "audio/ogg; codecs=\"opus\"".into(),
},
},
TranscodingDto {
url: "https://api/mp3".into(),
format: FormatDto {
protocol: "hls".into(),
mime_type: "audio/mpeg".into(),
},
},
TranscodingDto {
url: "https://api/prog".into(),
format: FormatDto {
protocol: "progressive".into(),
mime_type: "audio/mpeg".into(),
},
},
];
assert_eq!(
pick_hls_mp3(&transcodings),
Some("https://api/mp3".to_string())
);
assert_eq!(pick_hls_mp3(&[]), None);
}
}

651
soundclouddy/src/lib.rs Normal file
View File

@ -0,0 +1,651 @@
//! SoundCloud provider: **search, resolve links, and play** SoundCloud tracks
//! and playlists, plus — when an OAuth token is configured — the user's own
//! likes and playlists. Mounted at [`PROVIDER_ROOT`].
//!
//! Shaped on the abs/fyyd providers (remote, search-driven), with two
//! SoundCloud-specific twists (architecture/soundcloud-provider.md):
//! 1. **Login is optional** — public browse/play needs only a scraped
//! `client_id`; a token merely adds the `likes`/`playlists` nodes.
//! 2. **Playback is HLS** — `get_urls_for_track` returns an `.m3u8` media URL
//! that the audio-player's new `HlsStream` streams as continuous mp3.
//!
//! The `client_id`/OAuth token and the signed media URL are secrets, redacted
//! from `Debug` and never logged.
//!
//! Provider tree/path logic (Phase A) is implemented and unit-tested over a
//! fake; the `reqwest`-backed [`api::ScApi`] and its `init`/scrape flow are
//! Phase B (see plan/soundcloud.md).
use std::fmt;
use std::sync::RwLock;
use async_trait::async_trait;
use crabidy_core::proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track};
use crabidy_core::{ProviderClient, ProviderError};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use std::time::Duration;
pub mod api;
use api::{Resolved, Sc, ScApi, ScPlaylist, ScTrack};
/// First path segment owned by this provider.
pub const PROVIDER_ROOT: &str = "/soundcloud";
/// Reserved second-level literals (numeric ids never collide with these).
const SEARCH_SEGMENT: &str = "search";
const RESOLVE_SEGMENT: &str = "resolve";
const LIKES_SEGMENT: &str = "likes";
const PLAYLISTS_SEGMENT: &str = "playlists";
/// Canonical type tags disambiguating numeric track vs playlist ids.
const TRACK_SEGMENT: &str = "track";
const PLAYLIST_SEGMENT: &str = "playlist";
/// Default (and cap on) results per search term.
pub const DEFAULT_SEARCH_RESULTS: usize = 50;
/// Default (and cap on) tracks hydrated per playlist.
pub const DEFAULT_PLAYLIST_TRACKS: usize = 500;
/// Default per-request timeout in seconds.
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
/// Default total deadline for fetching a whole HLS stream, in seconds.
pub const DEFAULT_HLS_DEADLINE_SECS: u64 = 300;
/// Provider settings, persisted as `soundcloud.toml`. All fields are optional:
/// with none set, the provider still works (scrapes a `client_id`, no login).
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Settings {
/// SoundCloud `client_id`. If unset it is scraped and then **written back
/// here** by `init` so it persists. **Secret**: redacted from `Debug`.
pub client_id: Option<String>,
/// Web-app version paired with `client_id`; scraped and persisted like it.
pub app_version: Option<String>,
/// Optional OAuth token. Present → the `likes`/`playlists` nodes appear.
/// **Secret**: redacted from `Debug`.
pub oauth_token: Option<String>,
/// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`].
pub search_results: Option<usize>,
/// Tracks hydrated per playlist. Default [`DEFAULT_PLAYLIST_TRACKS`].
pub playlist_tracks: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
/// Total HLS-fetch deadline in seconds. Default [`DEFAULT_HLS_DEADLINE_SECS`].
pub hls_deadline_secs: Option<u64>,
}
impl fmt::Debug for Settings {
/// Redacts `client_id` and `oauth_token` (hard rule: secrets never logged).
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Settings")
.field("client_id", &self.client_id.as_ref().map(|_| "<redacted>"))
.field("app_version", &self.app_version)
.field(
"oauth_token",
&self.oauth_token.as_ref().map(|_| "<redacted>"),
)
.field("search_results", &self.search_results)
.field("playlist_tracks", &self.playlist_tracks)
.field("call_timeout_secs", &self.call_timeout_secs)
.field("hls_deadline_secs", &self.hls_deadline_secs)
.finish()
}
}
/// A parsed `/soundcloud/...` path. Browse nodes (search/resolve/likes/
/// playlists) point their children at the **canonical** `track/<id>` and
/// `playlist/<id>` shapes, so a leaf carries the id alone — no browse context.
#[derive(Debug, PartialEq, Eq)]
enum ScPath<'a> {
Root,
/// The search-terms parent (creatable).
Search,
/// A single search term (percent-encoded segment).
SearchTerm {
term: &'a str,
},
/// The resolve-URLs parent (creatable; a "term" is a pasted permalink).
Resolve,
/// One resolved permalink (percent-encoded URL segment).
ResolveEntry {
url: &'a str,
},
/// The signed-in user's liked tracks (login only).
Likes,
/// The signed-in user's playlists (login only).
Playlists,
/// A playlist container by id (canonical).
Playlist {
id: &'a str,
},
/// A track leaf by id (canonical).
TrackLeaf {
id: &'a str,
},
}
/// Splits a `/soundcloud/...` path into its recognized shape. Unknown shapes
/// are [`ProviderError::MalformedPath`].
fn parse_path(path: &str) -> Result<ScPath<'_>, ProviderError> {
if path == PROVIDER_ROOT {
return Ok(ScPath::Root);
}
let rest = path
.strip_prefix("/soundcloud/")
.ok_or(ProviderError::MalformedPath)?;
let segments: Vec<&str> = rest.split('/').collect();
if segments.iter().any(|segment| segment.is_empty()) {
return Err(ProviderError::MalformedPath);
}
match segments.as_slice() {
[s] if *s == SEARCH_SEGMENT => Ok(ScPath::Search),
[s, term] if *s == SEARCH_SEGMENT => Ok(ScPath::SearchTerm { term }),
[s] if *s == RESOLVE_SEGMENT => Ok(ScPath::Resolve),
[s, url] if *s == RESOLVE_SEGMENT => Ok(ScPath::ResolveEntry { url }),
[s] if *s == LIKES_SEGMENT => Ok(ScPath::Likes),
[s] if *s == PLAYLISTS_SEGMENT => Ok(ScPath::Playlists),
[s, id] if *s == PLAYLIST_SEGMENT => Ok(ScPath::Playlist { id }),
[s, id] if *s == TRACK_SEGMENT => Ok(ScPath::TrackLeaf { id }),
_ => Err(ProviderError::MalformedPath),
}
}
/// Maps a fetch failure to the trait-level error, logging the typed cause.
fn fetch_err(context: &str, err: api::FetchError) -> ProviderError {
warn!(context, "soundcloud fetch failed: {err}");
ProviderError::FetchError
}
/// Snapshot of an in-memory term/URL store (never held across awaits).
fn store_snapshot(store: &RwLock<Vec<String>>) -> Vec<String> {
store.read().map(|v| v.clone()).unwrap_or_default()
}
/// Adds an item to a store if absent (dedup, creation order preserved).
fn store_add(store: &RwLock<Vec<String>>, item: &str) {
if let Ok(mut list) = store.write() {
if !list.iter().any(|existing| existing == item) {
list.push(item.to_string());
}
}
}
/// Removes an item; `true` when it existed.
fn store_remove(store: &RwLock<Vec<String>>, item: &str) -> bool {
match store.write() {
Ok(mut list) => {
let before = list.len();
list.retain(|existing| existing != item);
list.len() != before
}
Err(_) => false,
}
}
/// The SoundCloud provider client.
pub struct Client {
api: Box<dyn Sc>,
settings: Settings,
/// Whether an OAuth token is configured (gates the personal nodes).
logged_in: bool,
/// Search terms created under `/soundcloud/search`, in creation order,
/// deduplicated. In-memory only, never held across awaits.
search_terms: RwLock<Vec<String>>,
/// Resolved permalink URLs created under `/soundcloud/resolve`.
resolve_urls: RwLock<Vec<String>>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("api", &self.api)
.field("settings", &self.settings)
.field("logged_in", &self.logged_in)
.finish_non_exhaustive()
}
}
impl Client {
/// A client over any [`Sc`] backend — the seam the tests use.
fn with_api(api: Box<dyn Sc>, settings: Settings) -> Self {
let logged_in = settings
.oauth_token
.as_deref()
.is_some_and(|t| !t.trim().is_empty());
Self {
api,
settings,
logged_in,
search_terms: RwLock::new(Vec::new()),
resolve_urls: RwLock::new(Vec::new()),
}
}
fn search_limit(&self) -> usize {
self.settings
.search_results
.unwrap_or(DEFAULT_SEARCH_RESULTS)
}
fn playlist_limit(&self) -> usize {
self.settings
.playlist_tracks
.unwrap_or(DEFAULT_PLAYLIST_TRACKS)
}
// --- Node builders ---
/// The `/soundcloud` root: `search` + `resolve`, plus `likes`/`playlists`
/// when [`Self::logged_in`]. Sync — the children are fixed (decision #1).
fn root_node(&self) -> LibraryNode {
let mut children = vec![
creatable_child(join(PROVIDER_ROOT, SEARCH_SEGMENT), SEARCH_SEGMENT),
creatable_child(join(PROVIDER_ROOT, RESOLVE_SEGMENT), RESOLVE_SEGMENT),
];
if self.logged_in {
// `likes` holds tracks directly (queueable); `playlists` holds
// playlist containers (not itself queueable).
children.push(LibraryNodeChild::new(
join(PROVIDER_ROOT, LIKES_SEGMENT),
LIKES_SEGMENT.to_string(),
true,
));
children.push(LibraryNodeChild::new(
join(PROVIDER_ROOT, PLAYLISTS_SEGMENT),
PLAYLISTS_SEGMENT.to_string(),
false,
));
}
LibraryNode {
path: PROVIDER_ROOT.to_string(),
title: "soundcloud".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
children,
is_queable: false,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// The `/soundcloud/search` (or `/resolve`) node listing a store's entries,
/// each editable + deletable. `title` names the node; entries link to
/// `parent/<encoded>`.
fn store_node(&self, path: &str, title: &str, store: &RwLock<Vec<String>>) -> LibraryNode {
let children = store_snapshot(store)
.iter()
.map(|entry| LibraryNodeChild {
is_editable: true,
is_deletable: true,
..LibraryNodeChild::new(
join(path, &crabidy_core::encode_segment(entry)),
entry.clone(),
false,
)
})
.collect();
LibraryNode {
path: path.to_string(),
title: title.to_string(),
parent: Some(parent_of(path)),
tracks: Vec::new(),
children,
is_queable: false,
is_creatable: true,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// A `/soundcloud/search/<term>` node: matching tracks as directly-playable
/// rows (canonical paths) and matching playlists as queueable container
/// children (G13).
async fn search_term_node(&self, path: &str, term: &str) -> Result<LibraryNode, ProviderError> {
let tracks = self
.api
.search_tracks(term, self.search_limit())
.await
.map_err(|err| fetch_err("search tracks", err))?;
let playlists = self
.api
.search_playlists(term, self.search_limit())
.await
.map_err(|err| fetch_err("search playlists", err))?;
Ok(self.list_node(path, term.to_string(), &tracks, &playlists))
}
/// A `/soundcloud/resolve/<url>` node: the resolved track (single row) or
/// playlist (a queueable container child).
async fn resolve_entry_node(
&self,
path: &str,
url: &str,
) -> Result<LibraryNode, ProviderError> {
match self
.api
.resolve(url)
.await
.map_err(|err| fetch_err("resolve", err))?
{
Resolved::Track(track) => Ok(self.list_node(path, url.to_string(), &[track], &[])),
Resolved::Playlist(playlist) => {
Ok(self.list_node(path, url.to_string(), &[], &[playlist]))
}
}
}
/// The `/soundcloud/likes` node: the user's liked tracks (login only).
async fn likes_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let tracks = self
.api
.my_likes(self.search_limit())
.await
.map_err(|err| fetch_err("likes", err))?;
Ok(self.list_node(path, LIKES_SEGMENT.to_string(), &tracks, &[]))
}
/// The `/soundcloud/playlists` node: the user's playlists (login only).
async fn playlists_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let playlists = self
.api
.my_playlists(self.search_limit())
.await
.map_err(|err| fetch_err("my playlists", err))?;
Ok(self.list_node(path, PLAYLISTS_SEGMENT.to_string(), &[], &playlists))
}
/// A `/soundcloud/playlist/<id>` node: the playlist's tracks, hydrating
/// stub ids in bounded batches (G15).
async fn playlist_node(&self, path: &str, id: &str) -> Result<LibraryNode, ProviderError> {
let detail = self
.api
.playlist_detail(id)
.await
.map_err(|err| fetch_err("playlist detail", err))?;
let limit = self.playlist_limit();
if detail.track_ids.len() > limit {
debug!(id, limit, "playlist truncated to the configured cap");
}
// Prefer tracks already inlined; hydrate the rest by id, in order.
let tracks = if detail.tracks.len() >= detail.track_ids.len().min(limit) {
detail.tracks
} else {
let ids: Vec<String> = detail.track_ids.iter().take(limit).cloned().collect();
self.api
.hydrate_tracks(&ids)
.await
.map_err(|err| fetch_err("hydrate tracks", err))?
};
Ok(self.list_node(path, detail.title, &tracks, &[]))
}
/// Builds a node holding `tracks` as directly-playable rows (canonical
/// paths) and `playlists` as queueable container children (canonical
/// paths). Queueable when it carries either.
fn list_node(
&self,
path: &str,
title: String,
tracks: &[ScTrack],
playlists: &[ScPlaylist],
) -> LibraryNode {
let track_rows: Vec<Track> = tracks.iter().map(sc_track).collect();
let children: Vec<LibraryNodeChild> = playlists
.iter()
.map(|p| {
LibraryNodeChild::new(
join(PROVIDER_ROOT, &format!("{PLAYLIST_SEGMENT}/{}", p.id)),
p.title.clone(),
true,
)
})
.collect();
let is_queable = !track_rows.is_empty() || children.iter().any(|c| c.is_queable);
LibraryNode {
path: path.to_string(),
title,
parent: Some(parent_of(path)),
tracks: track_rows,
children,
is_queable,
is_creatable: false,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// The track id addressed by a canonical track path, or `MalformedPath`.
fn track_id<'a>(&self, path: &'a str) -> Result<&'a str, ProviderError> {
match parse_path(path)? {
ScPath::TrackLeaf { id } => Ok(id),
_ => Err(ProviderError::MalformedPath),
}
}
}
/// Builds the wire [`Track`] for a SoundCloud track at its canonical path.
/// Duration is milliseconds→seconds; `provider_item_id` is `"track:<id>"`.
fn sc_track(track: &ScTrack) -> Track {
Track {
path: join(PROVIDER_ROOT, &format!("{TRACK_SEGMENT}/{}", track.id)),
artist: track.artist.clone(),
title: track.title.clone(),
duration: track.duration_ms.map(|ms| ms / 1000),
album: track.album.as_ref().map(|title| Album {
title: title.clone(),
release_date: None,
}),
is_skipped: false,
provider_item_id: format!("track:{}", track.id),
is_captured: false,
}
}
/// A creatable browse child (search/resolve parents).
fn creatable_child(path: String, title: &str) -> LibraryNodeChild {
LibraryNodeChild {
is_creatable: true,
..LibraryNodeChild::new(path, title.to_string(), false)
}
}
fn join(base: &str, segment: &str) -> String {
crabidy_core::join_path(base, segment)
}
fn parent_of(path: &str) -> String {
crabidy_core::parent_path(path)
.unwrap_or(crabidy_core::ROOT_PATH)
.to_string()
}
#[async_trait]
impl ProviderClient for Client {
/// Parses `soundcloud.toml`, builds the `reqwest`-backed [`api::ScApi`]
/// (scraping a `client_id` if none configured), and enables the personal
/// nodes when an OAuth token is present. Phase B (see plan/soundcloud.md).
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
let mut settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
warn!("could not parse soundcloud.toml, using defaults");
Settings::default()
});
let timeout = Duration::from_secs(
settings
.call_timeout_secs
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
);
let api = ScApi::new(
settings.client_id.clone(),
settings.app_version.clone(),
settings.oauth_token.clone(),
timeout,
)
.map_err(|err| {
warn!("cannot build the soundcloud client: {err}");
ProviderError::Config(err.to_string())
})?;
// Ensure a client_id (scrape if none). A total failure disables the
// provider non-fatally (D5/G7).
api.ensure_ready().await.map_err(|err| {
warn!("soundcloud provider disabled: no client_id ({err})");
ProviderError::Config("soundcloud client_id unavailable".to_string())
})?;
// Persist the (possibly freshly scraped) credentials so the next start
// does not re-scrape (D5/G9).
settings.client_id = api.cached_client_id().await;
settings.app_version = api.cached_app_version().await;
debug!("soundcloud provider ready");
Ok(Self::with_api(Box::new(api), settings))
}
/// Serializes settings back to TOML. `init` folds a freshly scraped
/// `client_id`/`app_version` into `self.settings`, so this persists them.
fn settings(&self) -> String {
toml::to_string_pretty(&self.settings).unwrap_or_default()
}
fn is_track_path(&self, path: &str) -> bool {
matches!(parse_path(path), Ok(ScPath::TrackLeaf { .. }))
}
/// Resolves the track id to a signed HLS `.m3u8` media URL (one API round
/// trip). The URL is ephemeral and secret — never logged (D4).
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
let id = self.track_id(track_path)?;
let url = self
.api
.resolve_stream_url(id)
.await
.map_err(|err| fetch_err("stream url", err))?;
Ok(vec![url])
}
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
let id = self.track_id(track_path)?;
let track = self
.api
.track_detail(id)
.await
.map_err(|err| fetch_err("track metadata", err))?;
let mut wire = sc_track(&track);
wire.path = track_path.to_string();
Ok(wire)
}
/// The root is served synchronously (its children are fixed).
fn get_lib_root(&self) -> LibraryNode {
self.root_node()
}
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let mut node = match parse_path(path)? {
ScPath::Root => self.root_node(),
ScPath::Search => self.store_node(path, SEARCH_SEGMENT, &self.search_terms),
ScPath::SearchTerm { term } => {
let decoded = crabidy_core::decode_segment(term);
// Unknown terms (stale client cache) are recreated implicitly.
store_add(&self.search_terms, &decoded);
self.search_term_node(path, &decoded).await?
}
ScPath::Resolve => self.store_node(path, RESOLVE_SEGMENT, &self.resolve_urls),
ScPath::ResolveEntry { url } => {
let decoded = crabidy_core::decode_segment(url);
store_add(&self.resolve_urls, &decoded);
self.resolve_entry_node(path, &decoded).await?
}
ScPath::Likes if self.logged_in => self.likes_node(path).await?,
ScPath::Playlists if self.logged_in => self.playlists_node(path).await?,
ScPath::Likes | ScPath::Playlists => {
warn!(path, "personal node requested without login");
return Err(ProviderError::MalformedPath);
}
ScPath::Playlist { id } => self.playlist_node(path, id).await?,
ScPath::TrackLeaf { .. } => {
warn!(path, "get_lib_node called with a track path");
return Err(ProviderError::MalformedPath);
}
};
// Download blessing (same rule as abs/fyyd): a node serving tracks and
// each queueable child allows `W`.
node.is_downloadable = node.is_queable || !node.tracks.is_empty();
for child in &mut node.children {
child.is_downloadable = child.is_queable;
}
Ok(node)
}
/// `/soundcloud/search` and `/soundcloud/resolve` are creatable: register a
/// term / permalink and return its node (implicit recreation on stale
/// paths, like the other providers).
async fn create_lib_node(
&self,
parent_path: &str,
title: &str,
) -> Result<LibraryNode, ProviderError> {
let entry = title.trim();
if entry.is_empty() {
return Err(ProviderError::InvalidInput);
}
let (store, base) = match parse_path(parent_path)? {
ScPath::Search => (&self.search_terms, SEARCH_SEGMENT),
ScPath::Resolve => (&self.resolve_urls, RESOLVE_SEGMENT),
_ => {
warn!(parent_path, "node creation not supported here");
return Err(ProviderError::NotSupported);
}
};
store_add(store, entry);
let entry_path = join(
&join(PROVIDER_ROOT, base),
&crabidy_core::encode_segment(entry),
);
self.get_lib_node(&entry_path).await
}
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<LibraryNode, ProviderError> {
let (store, base, old_encoded) = match parse_path(path)? {
ScPath::SearchTerm { term } => (&self.search_terms, SEARCH_SEGMENT, term),
ScPath::ResolveEntry { url } => (&self.resolve_urls, RESOLVE_SEGMENT, url),
_ => {
warn!(path, "only search terms / resolve urls are renamable");
return Err(ProviderError::NotSupported);
}
};
let new_entry = new_title.trim();
if new_entry.is_empty() {
return Err(ProviderError::InvalidInput);
}
store_remove(store, &crabidy_core::decode_segment(old_encoded));
store_add(store, new_entry);
let new_path = join(
&join(PROVIDER_ROOT, base),
&crabidy_core::encode_segment(new_entry),
);
self.get_lib_node(&new_path).await
}
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let (store, base, encoded) = match parse_path(path)? {
ScPath::SearchTerm { term } => (&self.search_terms, SEARCH_SEGMENT, term),
ScPath::ResolveEntry { url } => (&self.resolve_urls, RESOLVE_SEGMENT, url),
_ => {
warn!(path, "only search terms / resolve urls are deletable");
return Err(ProviderError::NotSupported);
}
};
store_remove(store, &crabidy_core::decode_segment(encoded));
self.get_lib_node(&join(PROVIDER_ROOT, base)).await
}
}
#[cfg(test)]
mod tests {
include!("tests.rs");
}

283
soundclouddy/src/tests.rs Normal file
View File

@ -0,0 +1,283 @@
// Unit tests over a fixture-driven `FakeSc` backend (no network). Path parsing
// and secret redaction are real now; the behavioural tests define the Stage-5
// target and fail (on `todo!()`) until the provider logic is implemented.
use std::collections::HashMap;
use super::*;
use api::{Resolved, ScPlaylist, ScTrack};
// --- Pure logic (passes today) -------------------------------------------
#[test]
fn root_path_parses() {
assert_eq!(parse_path("/soundcloud"), Ok(ScPath::Root));
}
#[test]
fn canonical_leaves_parse() {
assert_eq!(parse_path("/soundcloud/track/12345"), Ok(ScPath::TrackLeaf { id: "12345" }));
assert_eq!(parse_path("/soundcloud/playlist/999"), Ok(ScPath::Playlist { id: "999" }));
}
#[test]
fn browse_parents_parse() {
assert_eq!(parse_path("/soundcloud/search"), Ok(ScPath::Search));
assert_eq!(parse_path("/soundcloud/resolve"), Ok(ScPath::Resolve));
assert_eq!(parse_path("/soundcloud/likes"), Ok(ScPath::Likes));
assert_eq!(parse_path("/soundcloud/playlists"), Ok(ScPath::Playlists));
}
#[test]
fn term_and_url_segments_parse() {
assert_eq!(
parse_path("/soundcloud/search/boards%20of%20canada"),
Ok(ScPath::SearchTerm { term: "boards%20of%20canada" })
);
assert!(matches!(
parse_path("/soundcloud/resolve/https%3A%2F%2Fsoundcloud.com%2Fx"),
Ok(ScPath::ResolveEntry { .. })
));
}
#[test]
fn foreign_and_malformed_paths_reject() {
assert_eq!(parse_path("/tidal/x"), Err(ProviderError::MalformedPath));
assert_eq!(parse_path("/soundcloud/"), Err(ProviderError::MalformedPath));
assert_eq!(parse_path("/soundcloud/track"), Err(ProviderError::MalformedPath));
assert_eq!(parse_path("/soundcloud/track/1/2"), Err(ProviderError::MalformedPath));
assert_eq!(parse_path("/soundcloud//x"), Err(ProviderError::MalformedPath));
}
#[test]
fn is_track_path_only_for_canonical_track() {
let client = Client::with_api(Box::new(FakeSc::default()), Settings::default());
assert!(client.is_track_path("/soundcloud/track/1"));
assert!(!client.is_track_path("/soundcloud/playlist/1"));
assert!(!client.is_track_path("/soundcloud/search/x"));
}
#[test]
fn settings_debug_redacts_secrets() {
let s = Settings {
client_id: Some("SECRET_ID".into()),
oauth_token: Some("SECRET_TOKEN".into()),
..Settings::default()
};
let dumped = format!("{s:?}");
assert!(!dumped.contains("SECRET_ID"));
assert!(!dumped.contains("SECRET_TOKEN"));
assert!(dumped.contains("<redacted>"));
}
// --- Behavioural (Stage-5 target; fails on `todo!()` until implemented) ---
#[tokio::test]
async fn root_without_login_has_no_personal_nodes() {
let client = Client::with_api(Box::new(FakeSc::default()), Settings::default());
let root = client.get_lib_root();
let paths: Vec<&str> = root.children.iter().map(|c| c.path.as_str()).collect();
assert!(paths.contains(&"/soundcloud/search"));
assert!(paths.contains(&"/soundcloud/resolve"));
assert!(!paths.iter().any(|p| p.ends_with("/likes") || p.ends_with("/playlists")));
}
#[tokio::test]
async fn root_with_login_shows_personal_nodes() {
let settings = Settings { oauth_token: Some("tok".into()), ..Settings::default() };
let client = Client::with_api(Box::new(FakeSc::default()), settings);
let paths: Vec<String> = client.get_lib_root().children.iter().map(|c| c.path.clone()).collect();
assert!(paths.iter().any(|p| p == "/soundcloud/likes"));
assert!(paths.iter().any(|p| p == "/soundcloud/playlists"));
}
#[tokio::test]
async fn search_term_lists_tracks_and_playlists_with_canonical_paths() {
let fake = FakeSc {
search_tracks: vec![track("10", "Kaini Industries")],
search_playlists: vec![playlist("77", "BoC set", &["10"])],
..FakeSc::default()
};
let client = Client::with_api(Box::new(fake), Settings::default());
let node = client.get_lib_node("/soundcloud/search/boc").await.unwrap();
// Tracks are directly-playable rows on the node, at canonical paths.
assert!(node.tracks.iter().any(|t| t.path == "/soundcloud/track/10"));
// Playlists are queueable container children, at canonical paths.
let child = node.children.iter().find(|c| c.path == "/soundcloud/playlist/77");
assert!(child.is_some(), "playlist child missing");
assert!(child.unwrap().is_queable);
assert!(node.is_queable);
assert!(node.is_downloadable, "download blessing");
}
#[tokio::test]
async fn get_urls_for_track_returns_the_m3u8() {
let fake = FakeSc {
streams: HashMap::from([("10".to_string(), "https://cf-hls.sndcdn.com/x.m3u8".to_string())]),
..FakeSc::default()
};
let client = Client::with_api(Box::new(fake), Settings::default());
let urls = client.get_urls_for_track("/soundcloud/track/10").await.unwrap();
assert_eq!(urls, vec!["https://cf-hls.sndcdn.com/x.m3u8".to_string()]);
// A non-track path is rejected, not streamed.
assert_eq!(
client.get_urls_for_track("/soundcloud/playlist/77").await,
Err(ProviderError::MalformedPath)
);
}
#[tokio::test]
async fn resolve_entry_shapes_track_and_playlist() {
let fake = FakeSc {
resolve: HashMap::from([
("https://soundcloud.com/a/t".to_string(), Resolved::Track(track("10", "T"))),
("https://soundcloud.com/a/set".to_string(), Resolved::Playlist(playlist("77", "S", &["10"]))),
]),
..FakeSc::default()
};
let client = Client::with_api(Box::new(fake), Settings::default());
client.create_lib_node("/soundcloud/resolve", "https://soundcloud.com/a/t").await.unwrap();
let enc = crabidy_core::encode_segment("https://soundcloud.com/a/t");
let node = client.get_lib_node(&format!("/soundcloud/resolve/{enc}")).await.unwrap();
assert!(node.tracks.iter().any(|t| t.path == "/soundcloud/track/10"));
}
#[tokio::test]
async fn playlist_node_hydrates_stub_tracks() {
let fake = FakeSc {
playlists: HashMap::from([("77".to_string(), playlist("77", "S", &["10", "11"]))]),
tracks: HashMap::from([
("10".to_string(), track("10", "A")),
("11".to_string(), track("11", "B")),
]),
..FakeSc::default()
};
let client = Client::with_api(Box::new(fake), Settings::default());
let node = client.get_lib_node("/soundcloud/playlist/77").await.unwrap();
assert_eq!(node.tracks.len(), 2);
assert!(node.is_queable && node.is_downloadable);
}
#[tokio::test]
async fn create_rename_delete_search_term() {
let client = Client::with_api(Box::new(FakeSc::default()), Settings::default());
client.create_lib_node("/soundcloud/search", "aphex").await.unwrap();
let search = client.get_lib_node("/soundcloud/search").await.unwrap();
assert!(search.children.iter().any(|c| c.title == "aphex" && c.is_deletable && c.is_editable));
// Empty title is rejected.
assert_eq!(
client.create_lib_node("/soundcloud/search", " ").await,
Err(ProviderError::InvalidInput)
);
}
#[tokio::test]
async fn backend_failures_are_typed() {
// A backend that always fails must surface FetchError, never panic.
let client = Client::with_api(Box::new(FailingSc), Settings::default());
assert_eq!(
client.get_lib_node("/soundcloud/search/x").await,
Err(ProviderError::FetchError)
);
}
// --- Fakes and fixtures ---------------------------------------------------
fn track(id: &str, title: &str) -> ScTrack {
ScTrack {
id: id.into(),
title: title.into(),
artist: "Boards of Canada".into(),
duration_ms: Some(180_000),
album: None,
}
}
fn playlist(id: &str, title: &str, track_ids: &[&str]) -> ScPlaylist {
ScPlaylist {
id: id.into(),
title: title.into(),
artist: "Boards of Canada".into(),
track_ids: track_ids.iter().map(|s| s.to_string()).collect(),
tracks: Vec::new(),
}
}
/// A fixture-driven fake: every method reads a field, defaulting to empty.
#[derive(Debug, Default)]
struct FakeSc {
tracks: HashMap<String, ScTrack>,
playlists: HashMap<String, ScPlaylist>,
search_tracks: Vec<ScTrack>,
search_playlists: Vec<ScPlaylist>,
resolve: HashMap<String, Resolved>,
likes: Vec<ScTrack>,
my_playlists: Vec<ScPlaylist>,
streams: HashMap<String, String>,
}
#[async_trait]
impl api::Sc for FakeSc {
async fn resolve(&self, url: &str) -> Result<Resolved, api::FetchError> {
self.resolve.get(url).cloned().ok_or(api::FetchError::NotFound)
}
async fn search_tracks(&self, _q: &str, _l: usize) -> Result<Vec<ScTrack>, api::FetchError> {
Ok(self.search_tracks.clone())
}
async fn search_playlists(&self, _q: &str, _l: usize) -> Result<Vec<ScPlaylist>, api::FetchError> {
Ok(self.search_playlists.clone())
}
async fn track_detail(&self, id: &str) -> Result<ScTrack, api::FetchError> {
self.tracks.get(id).cloned().ok_or(api::FetchError::NotFound)
}
async fn playlist_detail(&self, id: &str) -> Result<ScPlaylist, api::FetchError> {
self.playlists.get(id).cloned().ok_or(api::FetchError::NotFound)
}
async fn hydrate_tracks(&self, ids: &[String]) -> Result<Vec<ScTrack>, api::FetchError> {
Ok(ids.iter().filter_map(|id| self.tracks.get(id).cloned()).collect())
}
async fn resolve_stream_url(&self, id: &str) -> Result<String, api::FetchError> {
self.streams.get(id).cloned().ok_or(api::FetchError::NotStreamable)
}
async fn my_likes(&self, _l: usize) -> Result<Vec<ScTrack>, api::FetchError> {
Ok(self.likes.clone())
}
async fn my_playlists(&self, _l: usize) -> Result<Vec<ScPlaylist>, api::FetchError> {
Ok(self.my_playlists.clone())
}
}
/// A backend whose every call fails — for the no-panic / typed-error gate.
#[derive(Debug)]
struct FailingSc;
#[async_trait]
impl api::Sc for FailingSc {
async fn resolve(&self, _u: &str) -> Result<Resolved, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn search_tracks(&self, _q: &str, _l: usize) -> Result<Vec<ScTrack>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn search_playlists(&self, _q: &str, _l: usize) -> Result<Vec<ScPlaylist>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn track_detail(&self, _id: &str) -> Result<ScTrack, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn playlist_detail(&self, _id: &str) -> Result<ScPlaylist, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn hydrate_tracks(&self, _ids: &[String]) -> Result<Vec<ScTrack>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn resolve_stream_url(&self, _id: &str) -> Result<String, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn my_likes(&self, _l: usize) -> Result<Vec<ScTrack>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn my_playlists(&self, _l: usize) -> Result<Vec<ScPlaylist>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
}

View File

@ -0,0 +1,75 @@
//! Live validation against the real SoundCloud API. `#[ignore]`d so it never
//! runs in CI or hits the network by default; run it deliberately with
//!
//! ```sh
//! cargo test -p soundclouddy --test live -- --ignored --nocapture
//! # optional: SOUNDCLOUD_CLIENT_ID=<id> to skip the scrape,
//! # SOUNDCLOUD_OAUTH=<token> to also exercise the personal calls.
//! ```
//!
//! It exercises the real endpoints end-to-end (scrape client_id → search →
//! resolve stream URL) so the `ScApi` DTOs and the `client_id` scrape are
//! confirmed against the live shapes — the drift risk called out in
//! architecture/soundcloud-provider.md.
use std::time::Duration;
use soundclouddy::api::{Sc, ScApi};
fn api() -> ScApi {
ScApi::new(
std::env::var("SOUNDCLOUD_CLIENT_ID").ok(),
None,
std::env::var("SOUNDCLOUD_OAUTH").ok(),
Duration::from_secs(30),
)
.expect("client builds")
}
#[tokio::test]
#[ignore = "hits the real SoundCloud API (scrapes a client_id)"]
async fn live_scrape_search_and_stream() {
let api = api();
// Scrapes a client_id if none was provided; a failure here means the
// scrape parsers need updating against the current soundcloud.com.
api.ensure_ready().await.expect("obtain a client_id");
println!(
"client_id acquired: {}",
api.cached_client_id().await.is_some()
);
let tracks = api
.search_tracks("boards of canada", 5)
.await
.expect("search decodes");
assert!(!tracks.is_empty(), "search returns tracks");
let first = &tracks[0];
println!(
"first track: {} — {} ({})",
first.artist, first.title, first.id
);
// Resolve a playable HLS media URL for it.
let url = api
.resolve_stream_url(&first.id)
.await
.expect("stream url resolves");
assert!(
url.contains(".m3u8"),
"stream URL is an HLS playlist: {url}"
);
println!("stream url resolves to an m3u8 (len {})", url.len());
}
#[tokio::test]
#[ignore = "hits the real SoundCloud API; requires SOUNDCLOUD_OAUTH"]
async fn live_personal_likes() {
if std::env::var("SOUNDCLOUD_OAUTH").is_err() {
eprintln!("SOUNDCLOUD_OAUTH unset — skipping personal test");
return;
}
let api = api();
api.ensure_ready().await.expect("client_id");
let likes = api.my_likes(5).await.expect("likes decode");
println!("liked tracks: {}", likes.len());
}