From 7091d37c32fe3f22a3d12f158bdd3767c5a557b6 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 24 Jul 2026 02:27:10 +0200 Subject: [PATCH] Add the SoundCloud provider (`/soundcloud`) with HLS playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` and `playlist/` 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) --- Cargo.lock | 15 + Cargo.toml | 2 + architecture/soundcloud-provider.md | 292 +++++++++++ audio-player/src/hls.rs | 318 ++++++++++++ audio-player/src/lib.rs | 1 + audio-player/src/player_engine.rs | 75 ++- crabidy-server/Cargo.toml | 1 + crabidy-server/src/provider.rs | 82 +++ crabidy-server/src/settings.rs | 20 +- plan/soundcloud.md | 111 ++++ plan/summary.md | 67 +++ quality/soundcloud-provider.md | 114 +++++ soundclouddy/Cargo.toml | 17 + soundclouddy/src/api.rs | 750 ++++++++++++++++++++++++++++ soundclouddy/src/lib.rs | 651 ++++++++++++++++++++++++ soundclouddy/src/tests.rs | 283 +++++++++++ soundclouddy/tests/live.rs | 75 +++ 17 files changed, 2846 insertions(+), 28 deletions(-) create mode 100644 architecture/soundcloud-provider.md create mode 100644 audio-player/src/hls.rs create mode 100644 plan/soundcloud.md create mode 100644 quality/soundcloud-provider.md create mode 100644 soundclouddy/Cargo.toml create mode 100644 soundclouddy/src/api.rs create mode 100644 soundclouddy/src/lib.rs create mode 100644 soundclouddy/src/tests.rs create mode 100644 soundclouddy/tests/live.rs diff --git a/Cargo.lock b/Cargo.lock index 55403f6..8cc5034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 6963d4e..1bba1af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/architecture/soundcloud-provider.md b/architecture/soundcloud-provider.md new file mode 100644 index 0000000..c997181 --- /dev/null +++ b/architecture/soundcloud-provider.md @@ -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 `. `search/*` add +`&limit=&offset=&linked_partitioning=1`. Endpoints we use: + +| Purpose | Endpoint | +| --- | --- | +| Resolve a permalink URL | `GET /resolve?url=` | +| Search tracks | `GET /search/tracks?q=` | +| Search playlists | `GET /search/playlists?q=` | +| Track detail | `GET /tracks/` | +| Playlist detail | `GET /playlists/` | +| Transcoding → media URL | `GET ` → `{"url": ""}` | +| (login) My likes | `GET /me/likes/tracks` (OAuth) | +| (login) My playlists | `GET /me/playlists` (OAuth) | + +Objects (fields we read): + +- **track**: `id` (numeric), `title`, `user.username` (→ artist), `duration` + (ms), `permalink_url`, `media.transcodings[]`, `policy`/`streamable`, + `publisher_metadata` (optional album/release). +- **transcoding**: `url` (a second API URL, not the CDN), `preset`, + `format.{protocol, mime_type}`, `quality`. We select + `protocol == "hls" && mime_type == "audio/mpeg"` (mp3-HLS), which SoundCloud + offers for essentially every playable track. +- **playlist**: `id`, `title`, `user.username`, `tracks[]` — often returned as + **stubs** (`{id}` only); missing tracks are hydrated in batches of ≤50 via + `GET /tracks?ids=&client_id=…`. +- **resolve**: returns a track or a playlist object (discriminated by `kind`). + +`client_id` acquisition (no login): `GET https://soundcloud.com`, find the +referenced JS bundles, fetch them, regex `client_id:"(\w+)"`; `app_version` +from `window.__sc_version="(\d+)"`. This is exactly the streamrip approach. + +## Assumptions (decided here) + +- The captures/creatable/editable/deletable TUI flows are provider-agnostic + (confirmed by `/youtube`, `/fyyd`, `/abs`): search-term + resolve semantics + cost no TUI or wire change. No proto change, no new `ProviderCommand`. +- A missing/rotated/invalid `client_id` must never crash startup or a browse. + The provider **self-heals** by scraping and by re-scraping on `401/403`; only + if scraping itself fails does the `/soundcloud` subtree degrade (typed + errors, skipped tracks), never the app. +- **Login is optional.** With no `oauth_token`, personal nodes (`likes`, + `playlists`) are simply **not shown**; public search/resolve/play still work. + A token unlocks the personal nodes and is refreshed/persisted like tidal's. +- HLS media/segment URLs and the `client_id`/`oauth_token` are **secrets or + ephemeral signed URLs**: redact from `Debug`/config dumps, never log the built + stream/segment URLs (hard rule: redact secrets from logs and error reports). +- SoundCloud `id`s are numeric (URL-safe); only user-typed **search terms** and + **pasted URLs** are percent-encoded into a path segment. + +## Decisions + +### D1 — Crate `soundclouddy`, mounted at `/soundcloud`, non-fatal init + +New workspace crate `soundclouddy` implementing `ProviderClient`, shaped on +`fyyd`/`absdy` (remote, search-driven, plain leaf tracks). Wired into +`ProviderOrchestrator` with a `sc_client: Option>` +field, `sc_owns()`/`sc_provider()` helpers, a `build()` block that reads +`soundcloud.toml` (non-fatal), a `get_lib_root` child gated on +`self.sc_client.is_some()`, and one routing arm in each dispatch method. +`crabidy-server` settings gain `soundcloud` in `ALL_PROVIDERS` (now 8), in +`ProviderToggles`, in `all()`, and in `provider_toggles()`. No `cli.rs`/ +`main.rs` change (providers are pure path-prefix subtrees). + +### D2 — HTTP behind a trait, faked in tests; client_id lifecycle inside the seam + +All network access goes through one seam — an `Sc` trait (`resolve`, +`search_tracks`, `search_playlists`, `track_detail`, `playlist_detail`, +`hydrate_tracks`, `resolve_stream_url`, and, when logged in, `my_likes`, +`my_playlists`) behind `Box` — with a `reqwest`-based `ScApi` for +production and a `FakeApi` in tests (as `absdy` hides `reqwest` behind `Abs`). +The **client_id acquisition, caching, and re-scrape-on-401** live entirely +inside `ScApi` so provider logic (tree shaping, path parsing, term store) is +unit-tested with zero network. Errors map to `ProviderError::FetchError` at the +boundary; malformed paths → `MalformedPath`; empty create/rename → `InvalidInput`. + +### D3 — Tree shape (search, resolve, optional personal) + +Track ids and playlist ids are both numeric, so leaf/container paths use a +**type-tagged** canonical segment to disambiguate: `track/` and +`playlist/`. Browse nodes point their children at these canonical paths. + +- `/soundcloud` — children: `search` (creatable), `resolve` (creatable), and — + **only if logged in** — `likes` and `playlists`. Not itself queueable. +- `/soundcloud/search` / `/soundcloud/resolve` — `is_creatable`; children are + the in-memory terms/URLs (`RwLock>`, dedup), each editable and + deletable, like tidal/youtube/fyyd/abs search terms. +- `/soundcloud/search/` — matching **tracks** as queueable leaves (and, + optionally, matching playlists as containers). +- `/soundcloud/resolve/` — the resolved permalink: a single track leaf, or + a playlist container. +- `/soundcloud/likes` (login) — the user's liked **tracks**. +- `/soundcloud/playlists` (login) — the user's playlists as containers. +- `/soundcloud/playlist/` — a playlist's tracks (queueable, downloadable); + the canonical container path, reached from search/resolve/likes/playlists. +- `/soundcloud/track/` — the canonical **track leaf**. A track id alone is + sufficient to resolve a stream, so every branch's track children point here + and playback needs no browse context. + +Terms/URLs are percent-encoded into one segment (`encode_segment`/ +`decode_segment`); numeric ids are already URL-safe. + +### D4 — Playback via a new HLS source in `audio-player` + +- `get_urls_for_track(/soundcloud/track/)`: `GET /tracks/` (or use a + cached transcoding), pick the `hls + audio/mpeg` transcoding, `GET + ?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`, + `provider_item_id` = `"track:"` (keys the capture store). + +### D5 — Auth: client_id self-heal, optional OAuth login + +- `Settings`: `client_id: Option`, `app_version: Option` + (both **cached** after first scrape and round-tripped via `settings()` so we + don't re-scrape every start), `oauth_token: Option` (optional), + and bounds (D6). Hand-written `Debug` redacts `client_id`/`oauth_token`. +- **No login (baseline)**: if `client_id` is unset, `ScApi` scrapes it from + `soundcloud.com` at init; on any `401/403` it re-scrapes **once** and retries + (rotation recovery). The freshly scraped id is persisted. +- **Optional login**: if `oauth_token` is present, `get_lib_root` adds `likes` + and `playlists`, and personal calls send `Authorization: OAuth `. If a + refresh-token flow is configured later it mirrors tidal's persist-on-refresh; + v1 accepts a static token and, on `401`, drops the personal subtree with a + typed error (never a crash) — public browse is unaffected. + +### D6 — Bounds and freshness + +- `search_results` (default 50), `playlist_tracks_limit` (default 500, hydrated + in ≤50-id batches), `call_timeout_secs` (default 30) bound each HTTP call, and + `hls_total_deadline_secs` (default 300) bounds a whole HLS fetch (segments are + retried with jitter under this deadline). Caps are `log`-ged so truncation is + visible, not silent (hard rule: no silent caps). +- Listings are fetched fresh per call (no cross-call cache), like the other + remote providers; only search terms / resolve URLs are stored in memory. The + chosen transcoding may be briefly cached per track id to save the extra + round-trip on replay. + +### D7 — Out of scope (explicitly) + +- **opus-HLS** (`audio/ogg; codecs=opus`) and **progressive** transcodings: v1 + targets mp3-HLS uniformly (offered for ~all tracks). opus-HLS is a phase-2 + add that reuses the new `OpusSource` per segment; the `HlsStream` seam makes + it additive. +- **HLS seeking**: v1 HLS is forward-only and reports the source as + non-seekable, so symphonia does not attempt an end-seek (which would need a + known byte length). In-track seek is a later add (open at a segment offset). +- Go+ / high-quality / lossless streams (need a premium account), uploads, + comments, reposts feed, waveforms, social graph, and playback-progress sync. +- Pagination past the configured caps (one page per listing). + +## Structure + +```d2 +direction: right + +server: crabidy-server { + orch: ProviderOrchestrator +} + +sc: "soundclouddy (crate)" { + client: "Client\n(ProviderClient)" + terms: "search terms + resolve URLs\n(in-memory)" + api: "ScApi\n(reqwest seam: Sc trait,\nclient_id self-heal, opt OAuth)" + client -> terms + client -> api +} + +player: "audio-player" { + hls: "HlsStream\n(new SourceStream)" + dec: "rodio / symphonia\n(mp3, opus)" + hls -> dec: "concatenated mp3 bytes" +} + +scapi: "SoundCloud\napi-v2 + HLS CDN" { shape: cloud } +web: "soundcloud.com\n(HTML + JS)" { shape: cloud } + +server.orch -> sc.client: "/soundcloud/..." +sc.api -> scapi: "resolve / search / tracks / transcoding (JSON, timeout)" +sc.api -> web: "scrape client_id (init, on 401)" +server.orch -> player.hls: ".m3u8 media URL" +player.hls -> scapi: "GET m3u8 + mp3 segments" +``` + +## Key flow: search and play a track + +```d2 +shape: sequence_diagram +tui: TUI +orch: Orchestrator +s: soundclouddy +api: "SoundCloud api-v2" +hls: "HlsStream (audio-player)" +cdn: "HLS CDN" + +tui -> orch: "open /soundcloud/search" +tui -> orch: "create term \"boards of canada\"" +orch -> s: "create_lib_node(search, term)" +s -> tui: "term stored" +tui -> orch: "open /soundcloud/search/" +orch -> s: "get_lib_node" +s -> api: "GET /search/tracks?q=…&client_id=" +api -> s: "tracks (id, title, user, duration)" +s -> tui: "tracks as queueable leaves (/soundcloud/track/)" +tui -> orch: "queue + play a track" +orch -> s: "get_urls_for_track(/soundcloud/track/)" +s -> api: "GET /tracks/ → pick hls+audio/mpeg transcoding" +s -> api: "GET ?client_id= → { url: m3u8 }" +s -> orch: "urls = [ m3u8 ]" +orch -> hls: "player.play(m3u8)" +hls -> cdn: "GET m3u8 (segments)" +hls -> cdn: "GET segment 1..N (mp3, in order)" +hls -> orch: "continuous mp3 → symphonia decodes" +``` + +## Risks and open questions + +- **client_id scraping fragility.** The scrape regexes depend on + soundcloud.com's HTML/JS shape and can break on a redesign. Mitigation: a + config override (`client_id` in `soundcloud.toml`) always wins, and failures + are typed (subtree degrades, app survives). The scrape is the one piece with + no test-double coverage of the *live* format — flagged as a **live-test gate**. +- **Ephemeral media URLs.** The resolved `.m3u8` and its segments are signed and + short-lived; playback must start promptly after resolution (like `/youtube`). + A stale URL surfaces as a skipped track, never a crash. Never logged. +- **HLS without a known length.** The concatenated stream has no total byte + length; reported non-seekable so symphonia won't end-seek (the rodio-0.22 + panic the opus work documented). **Gate:** verify an end-to-end mp3-HLS play + does not panic and reaches EOS cleanly. +- **Master vs media playlist.** SoundCloud returns a media (segment) playlist + for the chosen transcoding; `HlsStream` follows one level of master playlist + defensively and picks the first variant. +- **Playlist stubs.** Playlist detail may return track stubs; hydration in + ≤50-id batches is bounded by `playlist_tracks_limit`. A hydration miss drops + that track (skipped), never an error. +- **OAuth token lifetime.** v1 accepts a static token; expiry drops the personal + subtree with a typed `401` (public browse unaffected). A device-flow/refresh + upgrade mirrors tidal and is additive. +- **Field / envelope drift.** DTOs decode defensively (`#[serde(default)]`); + a renamed field is a local fix in `ScApi`. Live validation is a task-plan gate. diff --git a/audio-player/src/hls.rs b/audio-player/src/hls.rs new file mode 100644 index 0000000..41e9b04 --- /dev/null +++ b/audio-player/src/hls.rs @@ -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> + Send + Sync>>; +type SegmentFuture = Pin> + 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), + 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, + /// 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 { + 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; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + 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 { + let segments = load_segments(¶ms.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 { + 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) -> 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, 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 { + 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 { + 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); + } +} diff --git a/audio-player/src/lib.rs b/audio-player/src/lib.rs index 61cc659..79ea684 100644 --- a/audio-player/src/lib.rs +++ b/audio-player/src/lib.rs @@ -1,3 +1,4 @@ +mod hls; mod opus_source; mod player; mod player_engine; diff --git a/audio-player/src/player_engine.rs b/audio-player/src/player_engine.rs index 92a7838..ae8abe4 100644 --- a/audio-player/src/player_engine.rs +++ b/audio-player/src/player_engine.rs @@ -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::( - 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` to + // `anyhow` so the branches unify. + let open = async { + if is_hls { + StreamDownload::new::( + HlsParams::new(url.clone(), self.http.clone()), + TempStorageProvider::new(), + Settings::default(), + ) + .await + .map_err(|err| anyhow!("{err}")) + } else { + StreamDownload::new::( + 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, hint: Option<&str>, + seekable: bool, decode_err: &'static str, ) -> Result<(Box, Option)> 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 = 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); } diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index 31f80ea..497f2da 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -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"] } diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 7f4f4ae..968ff8d 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -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>, + /// 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>, } /// 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) } diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index e476551..6d13d1f 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -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"), diff --git a/plan/soundcloud.md b/plan/soundcloud.md new file mode 100644 index 0000000..7c5d2db --- /dev/null +++ b/plan/soundcloud.md @@ -0,0 +1,111 @@ +# Task plan — soundcloud provider + +**Status (2026-07-24): all phases A–E 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/`, + `provider_item_id = "track:"`, ms→`Option`. *(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. diff --git a/plan/summary.md b/plan/summary.md index 2bad772..e37e43b 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -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 S1–S4 + 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` (D1–D7, 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` (G1–G23) + acceptance + tests. +- **S4 task-plan** `plan/soundcloud.md` (phases A–E, each mapped to a gate/test). +- **S5 implement — Phases A–E 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. diff --git a/quality/soundcloud-provider.md b/quality/soundcloud-provider.md new file mode 100644 index 0000000..451605e --- /dev/null +++ b/quality/soundcloud-provider.md @@ -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, G3–G6, G10–G13, G15–G18, G20–G23 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/` and every playlist child at + `/soundcloud/playlist/`, 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/` + children include matching tracks (queueable leaves) and matching playlists + (containers). *(test: `search_term_lists_tracks_and_playlists`.)* +- [ ] **G14 — Resolve handles both kinds.** `/soundcloud/resolve/` 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/` 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. diff --git a/soundclouddy/Cargo.toml b/soundclouddy/Cargo.toml new file mode 100644 index 0000000..71b1770 --- /dev/null +++ b/soundclouddy/Cargo.toml @@ -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"] } diff --git a/soundclouddy/src/api.rs b/soundclouddy/src/api.rs new file mode 100644 index 0000000..228687e --- /dev/null +++ b/soundclouddy/src/api.rs @@ -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, + /// Album/release title from `publisher_metadata`, when present. + pub album: Option, +} + +/// 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, + /// Hydrated tracks known so far (a subset of `track_ids`, in order). + pub tracks: Vec, +} + +/// 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` 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; + + /// Tracks matching a free-text query (at most `limit`). + async fn search_tracks(&self, query: &str, limit: usize) -> Result, FetchError>; + + /// Playlists matching a free-text query (at most `limit`). + async fn search_playlists( + &self, + query: &str, + limit: usize, + ) -> Result, FetchError>; + + /// One track's detail by id. + async fn track_detail(&self, id: &str) -> Result; + + /// One playlist's detail by id (tracks may be stubs — see [`ScPlaylist`]). + async fn playlist_detail(&self, id: &str) -> Result; + + /// Hydrates stub track ids into full tracks in ≤50-id batches; order + /// follows `ids`. + async fn hydrate_tracks(&self, ids: &[String]) -> Result, 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; + + /// The signed-in user's liked tracks (at most `limit`). Requires OAuth. + async fn my_likes(&self, limit: usize) -> Result, FetchError>; + + /// The signed-in user's playlists (at most `limit`). Requires OAuth. + async fn my_playlists(&self, limit: usize) -> Result, 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, + /// Optional OAuth bearer for personal calls. Secret — redacted. + oauth_token: Option, +} + +/// The mutable scraped credentials. +#[derive(Default, Clone)] +struct Creds { + client_id: Option, + app_version: Option, +} + +impl Debug for ScApi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ScApi") + .field("client_id", &"") + .field( + "oauth_token", + &self.oauth_token.as_ref().map(|_| ""), + ) + .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, + app_version: Option, + oauth_token: Option, + timeout: Duration, + ) -> Result { + 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 { + 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 { + 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 { + 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( + &self, + url: &str, + query: &[(&str, String)], + ) -> Result { + 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( + &self, + url: &str, + query: &[(&str, String)], + ) -> Result { + 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::() + .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 { + 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, FetchError> { + let dto: Collection = 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, FetchError> { + let dto: Collection = 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 { + 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 { + 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, FetchError> { + let mut out = Vec::with_capacity(ids.len()); + for batch in ids.chunks(MAX_BATCH) { + let dto: Vec = 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 { + 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, FetchError> { + self.require_oauth()?; + let dto: Collection = 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, FetchError> { + self.require_oauth()?; + let dto: Collection = 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 { + 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=""`. +fn extract_app_version(html: &str) -> Option { + 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 { + 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:""` (or `client_id="..."`) from a JS bundle. +fn extract_client_id(js: &str) -> Option { + 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 { + #[serde(default = "Vec::new")] + collection: Vec, +} + +#[derive(Default, Deserialize)] +struct UserDto { + #[serde(default)] + username: String, +} + +#[derive(Default, Deserialize)] +struct PublisherDto { + #[serde(default)] + album_title: Option, +} + +#[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, +} + +#[derive(Deserialize)] +struct MediaUrl { + #[serde(default)] + url: String, +} + +#[derive(Default, Deserialize)] +struct TrackDto { + #[serde(default)] + id: Option, + #[serde(default)] + title: String, + #[serde(default)] + user: UserDto, + #[serde(default)] + duration: Option, + #[serde(default)] + publisher_metadata: Option, + #[serde(default)] + media: MediaDto, +} + +impl TrackDto { + fn into_track(self) -> Option { + 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, +} + +/// A playlist track can arrive as a full object or a `{id}` stub. +#[derive(Default, Deserialize)] +struct PlaylistTrackDto { + #[serde(default)] + id: Option, + #[serde(default)] + title: Option, + #[serde(default)] + user: Option, + #[serde(default)] + duration: Option, +} + +#[derive(Default, Deserialize)] +struct PlaylistDto { + #[serde(default)] + id: Option, + #[serde(default)] + title: String, + #[serde(default)] + user: UserDto, + #[serde(default)] + tracks: Vec, +} + +impl PlaylistDto { + fn into_playlist(self) -> Option { + 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, + #[serde(default)] + id: Option, + #[serde(default)] + title: String, + #[serde(default)] + user: UserDto, +} + +impl ResolveDto { + fn into_resolved(self) -> Option { + 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#""#; + 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#" + + + + "#; + 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); + } +} diff --git a/soundclouddy/src/lib.rs b/soundclouddy/src/lib.rs new file mode 100644 index 0000000..79ab017 --- /dev/null +++ b/soundclouddy/src/lib.rs @@ -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, + /// Web-app version paired with `client_id`; scraped and persisted like it. + pub app_version: Option, + /// Optional OAuth token. Present → the `likes`/`playlists` nodes appear. + /// **Secret**: redacted from `Debug`. + pub oauth_token: Option, + /// Results per search term. Default [`DEFAULT_SEARCH_RESULTS`]. + pub search_results: Option, + /// Tracks hydrated per playlist. Default [`DEFAULT_PLAYLIST_TRACKS`]. + pub playlist_tracks: Option, + /// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`]. + pub call_timeout_secs: Option, + /// Total HLS-fetch deadline in seconds. Default [`DEFAULT_HLS_DEADLINE_SECS`]. + pub hls_deadline_secs: Option, +} + +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(|_| "")) + .field("app_version", &self.app_version) + .field( + "oauth_token", + &self.oauth_token.as_ref().map(|_| ""), + ) + .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/` and +/// `playlist/` 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, 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 { + 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>, 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>, 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, + 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>, + /// Resolved permalink URLs created under `/soundcloud/resolve`. + resolve_urls: RwLock>, +} + +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, 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/`. + fn store_node(&self, path: &str, title: &str, store: &RwLock>) -> 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/` 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 { + 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/` node: the resolved track (single row) or + /// playlist (a queueable container child). + async fn resolve_entry_node( + &self, + path: &str, + url: &str, + ) -> Result { + 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 { + 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 { + 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/` node: the playlist's tracks, hydrating + /// stub ids in bounded batches (G15). + async fn playlist_node(&self, path: &str, id: &str) -> Result { + 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 = 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 = tracks.iter().map(sc_track).collect(); + let children: Vec = 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:"`. +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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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"); +} diff --git a/soundclouddy/src/tests.rs b/soundclouddy/src/tests.rs new file mode 100644 index 0000000..d292fc5 --- /dev/null +++ b/soundclouddy/src/tests.rs @@ -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("")); +} + +// --- 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 = 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, + playlists: HashMap, + search_tracks: Vec, + search_playlists: Vec, + resolve: HashMap, + likes: Vec, + my_playlists: Vec, + streams: HashMap, +} + +#[async_trait] +impl api::Sc for FakeSc { + async fn resolve(&self, url: &str) -> Result { + self.resolve.get(url).cloned().ok_or(api::FetchError::NotFound) + } + async fn search_tracks(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Ok(self.search_tracks.clone()) + } + async fn search_playlists(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Ok(self.search_playlists.clone()) + } + async fn track_detail(&self, id: &str) -> Result { + self.tracks.get(id).cloned().ok_or(api::FetchError::NotFound) + } + async fn playlist_detail(&self, id: &str) -> Result { + self.playlists.get(id).cloned().ok_or(api::FetchError::NotFound) + } + async fn hydrate_tracks(&self, ids: &[String]) -> Result, api::FetchError> { + Ok(ids.iter().filter_map(|id| self.tracks.get(id).cloned()).collect()) + } + async fn resolve_stream_url(&self, id: &str) -> Result { + self.streams.get(id).cloned().ok_or(api::FetchError::NotStreamable) + } + async fn my_likes(&self, _l: usize) -> Result, api::FetchError> { + Ok(self.likes.clone()) + } + async fn my_playlists(&self, _l: usize) -> Result, 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 { + Err(api::FetchError::Http("boom".into())) + } + async fn search_tracks(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } + async fn search_playlists(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } + async fn track_detail(&self, _id: &str) -> Result { + Err(api::FetchError::Http("boom".into())) + } + async fn playlist_detail(&self, _id: &str) -> Result { + Err(api::FetchError::Http("boom".into())) + } + async fn hydrate_tracks(&self, _ids: &[String]) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } + async fn resolve_stream_url(&self, _id: &str) -> Result { + Err(api::FetchError::Http("boom".into())) + } + async fn my_likes(&self, _l: usize) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } + async fn my_playlists(&self, _l: usize) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } +} diff --git a/soundclouddy/tests/live.rs b/soundclouddy/tests/live.rs new file mode 100644 index 0000000..956e689 --- /dev/null +++ b/soundclouddy/tests/live.rs @@ -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= to skip the scrape, +//! # SOUNDCLOUD_OAUTH= 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()); +}