crabidy/quality/soundcloud-provider.md

115 lines
6.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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