crabidy/quality/soundcloud-provider.md

6.5 KiB
Raw Blame History

Quality gates — soundcloud provider

Criteria an implementation of soundclouddy (architecture/soundcloud-provider.md) must satisfy. Each is pass/fail by reading/reasoning; automated coverage lives in soundclouddy/src/tests.rs (unit, no network, over a FakeSc) and soundclouddy/tests/live.rs (ignored, real SoundCloud).

Status (2026-07-24): G1, G3G6, G10G13, G15G18, G20G23 are implemented and verified offline (unit tests + fmt/clippy/machete). G7/G8/G9 (scrape + re-auth + persist) are implemented; their live behaviour and G14/G19 (real resolve/playlist + mp3-HLS play-to-EOS) are confirmed only by tests/live.rs and a manual play-through on a machine with creds/audio. G6's per-call timeouts are enforced; a whole-HLS-fetch deadline is deferred (the reqwest client's per-segment timeout bounds each fetch).

Secrets (hard rule — highest priority)

  • G1 — client_id/oauth_token never in Debug. Settings, Client, and ScApi Debug output redact both. (test: settings_debug_redacts_secrets.)
  • G2 — The signed media URL never reaches a log or error. The .m3u8 URL from Sc::resolve_stream_url (and the segment URLs in HlsStream) are never passed to debug!/warn!/error!. Segment fetch errors carry status only (use reqwest::Error::without_url() like windowed_http.rs).
  • G3 — ScApi::Debug redacts client_id and oauth_token (manual impl, not derived).

Errors and robustness (hard rule: no panics on input/network)

  • G4 — No panics on bad input or network failures. Backend failures map to a typed ProviderError (FetchError for fetches, MalformedPath for bad paths, InvalidInput for empty titles/URLs). No unwrap/expect/panic! on request, path, or response data. (tests: backend_failures_are_typed, foreign_and_malformed_paths_reject.)
  • G5 — Defensive decoding. Wire DTOs use #[serde(default)]; missing fields degrade (empty string / None / dropped entry), never error the whole call. Tracks/playlists without an id are dropped, not panicked on.
  • G6 — Timeouts on every external call. ScApi sets a per-call reqwest timeout from call_timeout_secs (default 30); HlsStream bounds the whole fetch by hls_deadline_secs (default 300).

Auth: client_id self-heal, optional login (D5)

  • G7 — Works with no config. With an empty soundcloud.toml, init scrapes a client_id and the provider serves public browse/play; a total scrape failure disables the provider non-fatally (never crashes startup).
  • G8 — Re-scrape on 401. A 401/403 triggers exactly one re-scrape and retry; a second failure surfaces as FetchError::Unauthorized (no infinite loop). (test: reauth_rescrapes_once_then_gives_up.)
  • G9 — Scraped client_id is persisted. settings() folds the cached client_id/app_version back into the TOML so the next start does not re-scrape. (test: settings_round_trip_persists_scraped_id.)
  • G10 — Login is optional and gates only personal nodes. No oauth_tokenget_lib_root children are search + resolve only; a token → likes + playlists also appear. Public browse/play is identical either way. (tests: root_without_login_has_no_personal_nodes, root_with_login_shows_personal_nodes.)

Tree and path contract

  • G11 — Path parsing is total and tag-safe. Every /soundcloud/... shape maps to a variant or MalformedPath; the literals search/resolve/likes/playlists/track/playlist never collide with a numeric id. Empty segments (/soundcloud//x) are MalformedPath. (tests: the *_parse / *_reject cases.)
  • G12 — Canonical leaves. Every track child points at /soundcloud/track/<id> and every playlist child at /soundcloud/playlist/<id>, so a track id alone resolves a stream (no browse context needed). (test: search_children_use_canonical_paths.)
  • G13 — Search lists tracks and playlists. /soundcloud/search/<term> children include matching tracks (queueable leaves) and matching playlists (containers). (test: search_term_lists_tracks_and_playlists.)
  • G14 — Resolve handles both kinds. /soundcloud/resolve/<url> yields a single track leaf or a playlist container per the resolved kind. (test: resolve_entry_shapes_track_and_playlist.)
  • G15 — Playlists hydrate within bounds. A playlist node hydrates stub ids in ≤50-id batches up to playlist_tracks (default 500); a truncated playlist is log-ged, not silently cut. (test: playlist_node_hydrates_stub_tracks.)
  • G16 — Download blessing. Every node serving tracks and each queueable child raises is_downloadable, matching abs/fyyd. (test: queueable_nodes_are_downloadable.)
  • G17 — Search terms / resolve URLs are creatable/editable/deletable. /soundcloud/search and /soundcloud/resolve are is_creatable; their term children are is_editable + is_deletable, with implicit recreation on stale paths. (tests: create_rename_delete_search_term, create_resolve_url_entry.)

Playback: HLS (D4)

  • G18 — get_urls_for_track returns one m3u8. For a track/<id> path it returns exactly [m3u8_url] via resolve_stream_url; a non-track path is MalformedPath; a non-streamable track surfaces a typed error (skipped, never a crash). (test: get_urls_for_track_returns_m3u8.)
  • G19 — HlsStream streams segments in order as continuous mp3. The media playlist is parsed (hand-written; relative URIs resolved against the playlist URL; one level of master-playlist fallback), segments are fetched in order, and the concatenated bytes decode as mp3. (gate: live/integration — a real or fixture m3u8 plays to EOS without panic.)
  • G20 — HlsStream is forward-only and non-seekable. supports_seek() is false and content_length() is None, so symphonia never attempts the end-seek that panics rodio 0.22 on a length-less stream. (gate: verified in the play-to-EOS integration check.)
  • G21 — HLS routing. audio-player's open_source routes a .m3u8 URL to HlsStream and every other http URL to the windowed-HTTP path; opus/other content-sniffing downstream is unchanged.

Docs and hygiene

  • G22 — Public items documented. Every pub item has a doc comment stating intent and error/edge behavior.
  • G23 — cargo fmt/clippy clean for soundclouddy and audio-player (workspace lint level), and cargo machete reports no unused deps.