diff --git a/Cargo.lock b/Cargo.lock index 32f0645..af15b06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -783,8 +783,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1173,6 +1175,7 @@ dependencies = [ "rand 0.10.2", "realfft", "reqwest 0.13.1", + "rssdy", "serde", "soundclouddy", "tempfile", @@ -1634,6 +1637,23 @@ dependencies = [ "getrandom 0.4.3", ] +[[package]] +name = "feed-rs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9" +dependencies = [ + "chrono", + "mediatype", + "quick-xml", + "regex", + "serde", + "serde_json", + "siphasher", + "url", + "uuid", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2851,6 +2871,9 @@ name = "mediatype" version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" +dependencies = [ + "serde", +] [[package]] name = "memchr" @@ -3698,6 +3721,16 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -4249,6 +4282,22 @@ dependencies = [ "cc", ] +[[package]] +name = "rssdy" +version = "0.1.0" +dependencies = [ + "async-trait", + "blake3", + "crabidy-core", + "feed-rs", + "reqwest 0.13.1", + "serde", + "thiserror 2.0.19", + "tokio", + "toml", + "tracing", +] + [[package]] name = "rstml" version = "0.12.1" @@ -5901,6 +5950,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 69c8eb9..3115219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "fsdy", "fyyd", "jamendody", + "rssdy", "soundclouddy", "tidaldy", "ytdy", @@ -44,6 +45,7 @@ http = "1" include_dir = "0.7" leptos = { version = "0.8", default-features = false, features = ["csr"] } notify-rust = "4" +feed-rs = "2" percent-encoding = "2" prost = "0.14" rand = "0.10" @@ -114,6 +116,7 @@ crabidy-server = { path = "crabidy-server", default-features = false } fsdy = { path = "fsdy" } fyyd = { path = "fyyd" } jamendody = { path = "jamendody" } +rssdy = { path = "rssdy" } soundclouddy = { path = "soundclouddy" } tidaldy = { path = "tidaldy" } ytdy = { path = "ytdy" } diff --git a/README.md b/README.md index b74d4b4..6d9ea0f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ each mounted as a subtree of one library: ├── fs a local music folder (fsdy/README.md) ├── fyyd podcast search (fyyd/README.md) ├── jamendo Creative-Commons music (jamendody/README.md) +├── rss podcast subscriptions (rssdy/README.md) ├── soundcloud SoundCloud (soundclouddy/README.md) ├── tidal Tidal streaming (tidaldy/README.md) ├── youtube YouTube search & playlists (ytdy/README.md) @@ -86,6 +87,7 @@ filled in. | `fsdy.toml` | local files | — | | `fyyd.toml` | podcasts | none needed | | `jamendo.toml` | Jamendo | none (key shipped) | +| `rss.toml` | podcast feeds | the feed URLs | | `soundcloud.toml` | SoundCloud | optional token | | `tidaly.toml` | Tidal | device login | | `ytdy.toml` | YouTube | optional cookies | @@ -154,7 +156,7 @@ On first start the server writes this file with every provider enabled: ```toml providers = [ - "tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", + "tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", "rss", "fs", "crabidy", "orphans", ] ``` diff --git a/cbd-tui/src/rpc.rs b/cbd-tui/src/rpc.rs index 3b73ddc..3fbb894 100644 --- a/cbd-tui/src/rpc.rs +++ b/cbd-tui/src/rpc.rs @@ -93,7 +93,7 @@ pub struct RpcClient { /// walks on the server; always refetch them. Remote provider nodes (tidal, /// youtube) keep the cache that makes back-navigation instant. fn is_cacheable(path: &str) -> bool { - const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"]; + const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"]; !MUTABLE_ROOTS.iter().any(|root| { path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/')) }) diff --git a/cbd-web/src/state.rs b/cbd-web/src/state.rs index 6e60d18..888b778 100644 --- a/cbd-web/src/state.rs +++ b/cbd-web/src/state.rs @@ -67,7 +67,7 @@ pub enum Dialog { /// store on every visit) all change server-side, so their listings are /// always refetched. pub fn is_cacheable(path: &str) -> bool { - const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"]; + const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"]; !MUTABLE_ROOTS.iter().any(|root| { path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/')) }) diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index a516c27..92eca57 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -23,6 +23,7 @@ all-providers = [ "abs", "soundcloud", "jamendo", + "rss", "fs", ] @@ -37,6 +38,7 @@ fyyd = ["dep:fyyd", "_any-provider"] abs = ["dep:absdy", "_any-provider"] soundcloud = ["dep:soundclouddy", "_any-provider"] jamendo = ["dep:jamendody", "_any-provider"] +rss = ["dep:rssdy", "_any-provider"] # Local files *and* persistent state (D5): the `/fs` mount, the content # store behind `/crabidy` and `/orphans`, bookmarks/captures, queue # persistence, and the `scan` command. Off means the server keeps its @@ -77,6 +79,7 @@ absdy = { workspace = true, optional = true } fsdy = { workspace = true, optional = true } fyyd = { workspace = true, optional = true } jamendody = { workspace = true, optional = true } +rssdy = { workspace = true, optional = true } futures.workspace = true rand.workspace = true reqwest = { workspace = true, optional = true } diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 09cab1e..5dac5eb 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -72,7 +72,8 @@ impl Mount { feature = "fyyd", feature = "abs", feature = "soundcloud", - feature = "jamendo" + feature = "jamendo", + feature = "rss" ))] async fn mount_from_config( config_dir: &std::path::Path, @@ -461,6 +462,8 @@ impl ProviderOrchestrator { // (architecture/soundcloud-provider.md D1). // - Jamendo: needs a registered `client_id`; without one it is // disabled (architecture/jamendo-provider.md D1). + // - RSS: subscriptions come from `rss.toml`; no feeds at all still + // mounts an empty, creatable `/rss` (architecture/rss-provider.md). #[cfg(feature = "youtube")] if enabled.youtube { mounts.extend( @@ -509,6 +512,18 @@ impl ProviderOrchestrator { .await, ); } + #[cfg(feature = "rss")] + if enabled.rss { + mounts.extend( + mount_from_config::( + &config_dir, + "rss.toml", + rssdy::PROVIDER_ROOT, + "rss", + ) + .await, + ); + } #[cfg(feature = "jamendo")] if enabled.jamendo { mounts.extend( diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index 0cd3910..a0802bb 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -23,13 +23,14 @@ pub const SETTINGS_FILE: &str = "crabidy-server.toml"; /// /// This is the *vocabulary*, not what this binary can mount — see /// [`BUILT_IN_PROVIDERS`]. -pub const ALL_PROVIDERS: [&str; 9] = [ +pub const ALL_PROVIDERS: [&str; 10] = [ "tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", + "rss", "fs", "crabidy", "orphans", @@ -53,6 +54,8 @@ pub const BUILT_IN_PROVIDERS: &[&str] = &[ "soundcloud", #[cfg(feature = "jamendo")] "jamendo", + #[cfg(feature = "rss")] + "rss", #[cfg(feature = "fs")] "fs", #[cfg(feature = "fs")] @@ -110,6 +113,7 @@ pub struct ProviderToggles { pub abs: bool, pub soundcloud: bool, pub jamendo: bool, + pub rss: bool, pub fs: bool, pub crabidy: bool, pub orphans: bool, @@ -128,6 +132,7 @@ impl ProviderToggles { abs: cfg!(feature = "abs"), soundcloud: cfg!(feature = "soundcloud"), jamendo: cfg!(feature = "jamendo"), + rss: cfg!(feature = "rss"), fs: cfg!(feature = "fs"), // Both live on the content store, which the `fs` feature brings // (D5). @@ -260,6 +265,7 @@ impl ServerSettings { abs: self.provider_enabled("abs"), soundcloud: self.provider_enabled("soundcloud"), jamendo: self.provider_enabled("jamendo"), + rss: self.provider_enabled("rss"), fs: self.provider_enabled("fs"), crabidy: self.provider_enabled("crabidy"), orphans: self.provider_enabled("orphans"), diff --git a/devenv.nix b/devenv.nix index 93dae8f..7fb202a 100644 --- a/devenv.nix +++ b/devenv.nix @@ -107,7 +107,7 @@ in test_it -p crabidy-server --no-default-features # Each provider on its own: nothing else may be needed to compile it. - for feature in tidal youtube fyyd abs soundcloud jamendo fs; do + for feature in tidal youtube fyyd abs soundcloud jamendo rss fs; do clippy -p crabidy-server --no-default-features --features "$feature" done diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index d6c2fad..48e8711 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -9,6 +9,7 @@ - [YouTube — /youtube](./providers/youtube.md) - [SoundCloud — /soundcloud](./providers/soundcloud.md) - [Jamendo — /jamendo](./providers/jamendo.md) + - [RSS — /rss](./providers/rss.md) - [audiobookshelf — /abs](./providers/abs.md) - [fyyd — /fyyd](./providers/fyyd.md) - [Search](./providers/search.md) diff --git a/docs/src/config.md b/docs/src/config.md index a88a128..f43a51a 100644 --- a/docs/src/config.md +++ b/docs/src/config.md @@ -21,6 +21,7 @@ which the server now also writes on first start. | `fsdy.toml` | local files | none | | `fyyd.toml` | podcasts | none | | `jamendo.toml` | Jamendo | none (a key is shipped) | +| `rss.toml` | podcast feeds | the feed URLs themselves | | `soundcloud.toml` | SoundCloud | none (token optional) | | `tidaly.toml` | Tidal | device login (interactive) | | `ytdy.toml` | YouTube | none (cookies optional) | @@ -57,6 +58,7 @@ providers = [ "abs", "soundcloud", "jamendo", + "rss", "fs", "crabidy", "orphans", diff --git a/docs/src/providers.md b/docs/src/providers.md index c81d9a6..530f7c8 100644 --- a/docs/src/providers.md +++ b/docs/src/providers.md @@ -21,6 +21,7 @@ abs: "/abs — audiobookshelf audiobooks" fs: "/fs — a local music folder" fyyd: "/fyyd — podcast search" jamendo: "/jamendo — Creative-Commons catalogue" +rss: "/rss — podcast subscriptions" soundcloud: "/soundcloud — SoundCloud" tidal: "/tidal — Tidal streaming" youtube: "/youtube — YouTube search & playlists" @@ -31,6 +32,7 @@ root -> abs: "route /abs/*" root -> fs: "route /fs/*" root -> fyyd: "route /fyyd/*" root -> jamendo: "route /jamendo/*" +root -> rss: "route /rss/*" root -> soundcloud: "route /soundcloud/*" root -> tidal: "route /tidal/*" root -> youtube: "route /youtube/*" @@ -90,6 +92,9 @@ Listed in the order the library root serves them. - **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of Creative-Commons music, by search or album. Works out of the box on a shipped app key; bring your own for your own rate limit (`jamendo.toml`). +- **[`/rss`](./providers/rss.md)** — podcast feeds you subscribe to by URL, + including premium per-subscriber feeds. Always shows the newest episodes; + nothing is cached (`rss.toml`). - **[`/soundcloud`](./providers/soundcloud.md)** — SoundCloud search, link resolving, and playback with no credentials at all; an optional token adds your likes and playlists (`soundcloud.toml`). diff --git a/docs/src/providers/rss.md b/docs/src/providers/rss.md new file mode 100644 index 0000000..b20c8c5 --- /dev/null +++ b/docs/src/providers/rss.md @@ -0,0 +1,113 @@ +# RSS — /rss + + + +Subscribe to podcast feeds by URL and play their episodes. Works with public +feeds and with **premium per-subscriber URLs**, and always shows the newest +episodes. + +`/fyyd` is the other podcast provider; the two do different jobs. `/fyyd` +*discovers* podcasts by searching a public directory. `/rss` plays the feeds +**you** name, including ones no directory indexes and ones only you can read. + +## Subscribing + +Press `%` on `/rss`, paste the feed URL, and that is it: the provider fetches +the feed once, names the subscription from the feed's own title, and writes it +into `rss.toml`. You can also list feeds in the config by hand (below). + +From then on, `e` renames a subscription — its path follows the new name — and +`d` unsubscribes, which removes the config entry and nothing else. Audio you +captured from it stays under [`/crabidy`](../store.md). + +## Premium feeds are credentials + +A paid podcast hands you a URL with a token in it: + +```text +https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-… +``` + +Whoever has that URL has the subscription, so crabidy treats it as a secret. +It is redacted from logs and debug output, and — the part that shapes the +tree — **it never appears in a library path**: + +```text +/rss/the-economist-podcasts/676f8bfa48c9cac3 + └ slug of the name └ hash of the episode id +``` + +Paths are displayed in clients, written into the saved queue, and persisted +into bookmark files. A URL in one would leak into all of those, so the path +carries a name slug and an episode hash instead. + +```admonish warning +`rss.toml` stores feed URLs in cleartext, like every other provider +credential. Keep `~/.config/crabidy/` private. +``` + +## Nothing is cached + +Every visit to a subscription **fetches the feed**, so an episode published a +minute ago is there when you look. This holds end to end: `/rss` is exempt +from the clients' library-listing cache as well, so re-entering a +subscription really does re-fetch rather than redraw what you saw before. + +One memo exists, and only to keep the obvious waste away: listing a feed and +then queueing its 40 episodes costs **one** fetch, not 41. It is written by +listings and read only when resolving a track, so it can never make a listing +stale. + +## The tree + +```text +/rss +├── one per feed; queueable and downloadable +│ └── a track, newest first +└── … +``` + +Episodes stream directly from the feed's enclosure URL — no sidecar and no +helper binary, the same as [`/fyyd`](./fyyd.md) episodes. `W` captures a whole +subscription (or one episode) into the content store. + +## Episode identity, and what it means for bookmarks + +An episode's path segment is a short hash of the publisher's `` (or of +the enclosure URL when a feed omits one), and the guid is also stored as the +track's provider id, so capturing the same episode twice de-duplicates in the +store. + +Two things follow, both inherent to RSS rather than to crabidy: + +- A **bookmark** (`w`) to an episode that has since aged out of the feed + cannot be resolved — there is nothing left to look up. **Capture** (`W`) + what you want to keep; that downloads the audio. +- A publisher that regenerates guids on every fetch invalidates bookmarks to + its episodes. + +## Configuration — `rss.toml` + +```toml +# One table per subscription. `name` is yours and decides the path slug; +# duplicate slugs get a numeric suffix. +[[feeds]] +name = "The Economist Podcasts" +url = "https://feeds.economist.com/v1/rss/…" + +[[feeds]] +name = "Cautionary Tales" +url = "https://feeds.example.org/cautionary-tales" + +# Optional, defaults shown. +episodes_per_feed = 200 # episodes listed per feed +call_timeout_secs = 30 # per-request timeout +max_feed_bytes = 8388608 # 8 MiB cap on a feed body +``` + +`%` appends to this file, so subscriptions made from a client persist. An +entry with no url is skipped with a warning, and no feeds at all is fine — +`/rss` mounts empty and creatable. + +Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed, and a malformed +episode is skipped rather than failing the listing. diff --git a/plan/rss-provider.md b/plan/rss-provider.md new file mode 100644 index 0000000..123110e --- /dev/null +++ b/plan/rss-provider.md @@ -0,0 +1,60 @@ +# Plan — RSS provider + +Executes `architecture/rss-provider.md` against `quality/rss-provider.md`. +Two commits: **(A)** the `rssdy` crate, **(B)** wiring (server feature, +clients, docs). + +## A — The `rssdy` crate + +- [x] **A1 — `api::Episode::key_for`** — `blake3(guid)` truncated to 16 hex + chars. *Verifies:* G7. +- [x] **A2 — `api::FeedFetcher`** — `reqwest` client with the timeout, + streaming the body while counting bytes so `max_feed_bytes` rejects + before buffering it whole; parse with `feed-rs`; map entries to + `Episode` (guid → key, title, author, published, `itunes:duration` where + present, first audio enclosure); skip entries with no enclosure; sort + newest first; truncate to `limit`. Errors carry no URL. *Verifies:* G1, + G15, G16, G17. +- [x] **A3 — `slugify` + de-duplication** at load and on rename/subscribe. + *Verifies:* G8. +- [x] **A4 — `parse_path`** for root/feed/episode, rejecting anything else. + *Verifies:* G10. +- [x] **A5 — `Client::with_api` / `init`** — resolve subscriptions with + slugs, drop entries without a url (warning), keep the settings for + write-back. No feeds still mounts. *Verifies:* G18. +- [x] **A6 — Listings** — `get_lib_root` lists subscriptions and is + creatable; `get_lib_node("/rss/")` fetches, replaces the memo, and + returns episodes as tracks (queueable, downloadable). *Verifies:* G3, G9. +- [x] **A7 — Memo** — bounded push/replace, listing-written, read by + `get_urls_for_track` / `get_metadata_for_track` / + `resolve_tracks_into`. *Verifies:* G4, G5. +- [x] **A8 — Subscription CRUD** — `create_lib_node` (URL → fetch → name + from feed title → add), `rename_lib_node`, `delete_lib_node`, and + `settings()` reserializing the live list. *Verifies:* G11–G14. +- [x] **A9 — `rssdy/src/tests.rs`** over a `FakeFeeds` fixture backend + covering every gate above. +- [x] **A10 — Commit A**: crate tests, clippy, fmt clean. + +## B — Wiring + +- [x] **B1 — Server feature `rss`** — optional `dep:rssdy`, `all-providers` + membership, `ALL_PROVIDERS` + `BUILT_IN_PROVIDERS` entries, + `ProviderToggles.rss`, `ProviderToggles::all`, and the mount + registration through `mount_from_config`. *Verifies:* G19. +- [x] **B2 — `check-features`** gains an `rss`-alone row. *Verifies:* G19. +- [x] **B3 — Clients** — `/rss` into `MUTABLE_ROOTS` in `cbd-tui/src/rpc.rs` + and `cbd-web/src/state.rs`, with their cacheability tests extended. + *Verifies:* G6. +- [x] **B4 — Docs** — `docs/src/providers/rss.md`, `rssdy/README.md`, + links from `docs/src/providers.md`, `SUMMARY.md`, `config.md`, and the + root README (tree + config table). Say plainly that a premium URL is a + credential, that listings are never cached, and that bookmarks depend on + publisher guids. *Verifies:* G20. +- [x] **B5 — `plan/summary.md`** entry, then commit B. + +## Deferred (recorded, not dropped) + +- OPML import/export, per-episode played state, background polling, and + artwork (`itunes:image`) — the library model has no field for it. +- Sharing the episode→`Track` mapping with `fyyd`; worth extracting only + if a third podcast source lands. diff --git a/plan/summary.md b/plan/summary.md index 84fabb8..09d72c0 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1342,3 +1342,54 @@ compiles for wasm, so native clippy alone proves nothing), the trunk bundle builds, and the book builds. Not exercised: live keypresses in a terminal and in a browser — the pure state machines are unit-tested and both render paths compile. + +## rss provider (2026-07-26) + +`/rss`: podcast feeds you subscribe to by URL, including premium +per-subscriber feeds, with listings that are never cached. New `rssdy` crate +plus wiring. Full dev-flow: `architecture/rss-provider.md`, +`quality/rss-provider.md` (G1–G21), `plan/rss-provider.md`. + +**Two findings drove the design.** + +*A premium feed URL is the credential.* Library paths are displayed, logged, +and persisted into saved queues and bookmark tomls — so a URL in a path would +leak into all of them. Hence subscriptions are `(name, url)` in `rss.toml` and +paths carry a name slug plus `blake3(guid)[..16]`: +`/rss/the-economist-podcasts/676f8bfa48c9cac3`. URLs are redacted from every +`Debug` impl and never reach an error message (reqwest errors go through +`without_url`). + +*"Not cached" has a client-side half.* Both clients cache library listings by +path, and only `/crabidy`, `/fs`, `/orphans` bypassed it — so without adding +`/rss` to `MUTABLE_ROOTS` in both, a re-visit would answer from the client's +cache and server freshness would be invisible. A listing always fetches; a +listing-written memo (read only when resolving a track, bounded to 8 feeds) +keeps queueing 40 episodes at one fetch instead of 41, with no TTL to guess +at. + +**Verified against the real feed, which caught a bug no unit test would +have.** `feed-rs` parses `` with an NPT parser, and NPT has +no `MM:SS` form: for `53:25` its fallback regex takes the leading number, so a +53-minute episode came back as **53 seconds** (`1:20:40` happens to parse +fine). The iTunes spec allows `S`, `MM:SS`, `HH:MM:SS`, so that one field is +now recovered from the raw body ourselves — a shallow scan keyed by guid and +enclosure URL, overriding feed-rs — and the live feed reports 3205 s / 2830 s +/ 1662 s, matching 53:25 / 47:10 / 27:42. + +Other decisions: `%` on `/rss` takes a pasted URL, fetches it once, names the +subscription from the feed's own title and persists it (`e` renames, `d` +unsubscribes and touches no audio); newest-first ordering enforced at the +provider boundary, not just in the parser, so any backend obeys it; a separate +crate from `fyyd` (discovery vs subscription differ in config, identity and +caching); behind a default-on `rss` cargo feature like every other provider. + +Bounded by design: per-request timeout, an 8 MiB body cap enforced *while* +reading chunks rather than after, and an episode cap — a malformed entry is +skipped, only an unfetchable feed errors, and it fails that node alone. + +Verified: 25 rssdy tests, 94 crabidy-server, 118 cbd-tui, 20 cbd-web, the +whole `check-features` matrix (now including `rss` alone) clippy-clean under +`-D warnings`, fmt clean, book builds, and a live fetch of the user's own +premium Economist feed. Not exercised: playing an episode through an audio +device. diff --git a/quality/rss-provider.md b/quality/rss-provider.md new file mode 100644 index 0000000..c443437 --- /dev/null +++ b/quality/rss-provider.md @@ -0,0 +1,100 @@ +# Quality gates — RSS provider + +Criteria an implementation of `architecture/rss-provider.md` must satisfy. +Automated coverage lives in `rssdy/src/tests.rs` (unit, over a fake +`Feeds`), plus the settings/toggle tests in `crabidy-server`. + +## Secrets (hard rule — highest priority) + +- [ ] **G1 — A feed URL never reaches a log, an error, or a path.** + `FeedEntry`, `Settings`, `Client`, and `FeedFetcher` `Debug` output + redact urls (manual impls, not derived). `FetchError` variants carry + status/parse context only. No `debug!`/`warn!`/`error!` call takes a + feed or enclosure URL. *(tests: `settings_debug_redacts_urls`, + `feed_entry_debug_redacts_the_url`.)* +- [ ] **G2 — No path contains a URL.** Subscription paths are + `/rss/`, episode paths `/rss//<16 hex>`. A tokened URL + therefore cannot leak through the UI, the queue, `current`, or a + bookmark toml. *(tests: `episode_paths_carry_only_slug_and_key`.)* + +## Freshness — the point of the feature + +- [ ] **G3 — Listing a subscription always fetches.** Two listings of the + same node cause two fetches, and the second reflects a feed that gained + an episode in between. *(test: `every_listing_refetches_the_feed`.)* +- [ ] **G4 — The memo is written by listings and read only by track + lookups.** After one listing, resolving every episode of that feed + costs **no** further fetches; a track lookup with a cold memo fetches + once. *(tests: `track_lookups_reuse_the_listing_fetch`, + `a_cold_memo_fetches_once`.)* +- [ ] **G5 — The memo is bounded** to `MEMO_CAPACITY` feeds, evicting the + oldest, so subscribing to 100 feeds cannot grow it without limit. + *(test: `the_memo_evicts_beyond_its_capacity`.)* +- [ ] **G6 — `/rss` is in `MUTABLE_ROOTS` in both clients** + (`cbd-tui/src/rpc.rs`, `cbd-web/src/state.rs`), or the client cache + defeats G3. *(tests: the existing cacheability tests, extended.)* + +## Identity and paths + +- [ ] **G7 — An episode key is stable** across restarts and builds: + `blake3(guid)[..16]`, and the guid falls back to the enclosure URL only + when the feed omits one. *(tests: `episode_keys_are_stable_and_short`, + `a_feed_without_guids_keys_on_the_enclosure_url`.)* +- [ ] **G8 — Slugs are derived, bounded, and de-duplicated.** Lowercased, + non-alphanumerics folded to single dashes, trimmed, length-capped; an + empty or all-punctuation name yields `feed`; two subscriptions that + slug alike get `-2`, `-3`. *(tests: `slugify_folds_and_bounds`, + `duplicate_slugs_get_a_suffix`.)* +- [ ] **G9 — `provider_item_id` is the guid**, so the content store + de-duplicates captures of the same episode across visits and feeds. + *(test: `tracks_carry_the_guid_as_provider_item_id`.)* +- [ ] **G10 — Foreign and malformed paths are typed errors**, never + panics: an unknown slug, a wrong-provider path, a bad episode key, and + a path with too many segments all yield `MalformedPath` (or + `NotSupported` for edits). *(test: `foreign_and_malformed_paths_reject`.)* + +## Subscriptions (`%`, `e`, `d`) + +- [ ] **G11 — `%` on `/rss` takes a URL, fetches it once, and names the + subscription from the feed's own title**, de-duplicating the slug. The + new node is returned. *(test: `subscribing_names_from_the_feed_title`.)* +- [ ] **G12 — `%` with a non-URL or an unfetchable URL fails with a typed + error and adds nothing.** *(test: `subscribing_rejects_bad_input`.)* +- [ ] **G13 — `e` renames and the path follows the new slug**; `d` + removes the subscription and no audio. Both return what a client should + display next. *(tests: `renaming_moves_the_slug`, + `unsubscribing_removes_only_the_entry`.)* +- [ ] **G14 — Every subscription change round-trips through `rss.toml`** + — `settings()` reserializes the live list so the server's write-back + persists it, and reloading yields the same subscriptions. *(test: + `subscription_changes_round_trip_through_toml`.)* + +## Robustness (hard rules) + +- [ ] **G15 — No panics on feed data.** A missing enclosure, empty title, + unparseable date, absent duration, or an entry with no guid is skipped + or degraded — never a panic and never a failed listing. Only an + unfetchable/unparseable *feed* errors, and it fails that node only. + *(tests: `malformed_entries_are_skipped`, `backend_failures_are_typed`.)* +- [ ] **G16 — Bounded network use.** Every request carries + `call_timeout_secs`; a body over `max_feed_bytes` is rejected without + being buffered whole; listings are capped at `episodes_per_feed`. +- [ ] **G17 — Newest first**, by publication date descending where dates + parse, feed order preserved otherwise (stable sort). *(test: + `episodes_are_newest_first`.)* +- [ ] **G18 — A provider failure never takes the server down.** A feed + that cannot be fetched fails its own node; `init` with no feeds still + mounts an empty, creatable `/rss`. *(test: `no_feeds_still_mounts`.)* + +## Wiring + +- [ ] **G19 — Behind the `rss` cargo feature**, default on: `dep:rssdy`, + a `BUILT_IN_PROVIDERS` entry, an `ALL_PROVIDERS` name, a + `ProviderToggles` field, a mount registration, and a `check-features` + row. The provider-less and each-provider-alone builds stay clean. +- [ ] **G20 — Documented.** A `docs/src/providers/rss.md` page (tree, + subscribing, premium feeds and what "not cached" means, every config + option), an `rssdy/README.md`, links from the providers index, + `SUMMARY.md`, `config.md`, and the root README's tree and config table. +- [ ] **G21 — Clippy clean under `-D warnings`** for the workspace, fmt + clean, and no `todo!()` from the stub stage left. diff --git a/rssdy/Cargo.toml b/rssdy/Cargo.toml new file mode 100644 index 0000000..fae8794 --- /dev/null +++ b/rssdy/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rssdy" +version.workspace = true +edition.workspace = true + +[dependencies] +async-trait.workspace = true +# Stable across builds, so an episode key stays valid in a saved bookmark +# (architecture/rss-provider.md D4 — `DefaultHasher` explicitly is not). +blake3.workspace = true +crabidy-core.workspace = true +# One parser for RSS 2.0/1.0/0.x, Atom and JSON Feed, with the iTunes +# extensions mapped (D-parser). Podcast feeds in the wild are not uniform. +feed-rs.workspace = true +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/rssdy/README.md b/rssdy/README.md new file mode 100644 index 0000000..eba8367 --- /dev/null +++ b/rssdy/README.md @@ -0,0 +1,91 @@ +# rssdy — the RSS podcast provider + +Mounts the podcast feeds you subscribe to at **`/rss`** in the crabidy +library. Works with plain public feeds and with **premium per-subscriber +URLs**, and always shows the newest episodes. + +## Logging in + +There is no login. A feed is either public or its URL *is* the credential — +a paid podcast gives you a URL with a token in it: + +```text +https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-… +``` + +Anyone holding that URL has your subscription, so the provider treats it as a +secret: it is redacted from `Debug`, never logged, and **never put in a +library path**. Paths carry a slug of the subscription name and a hash of the +episode id instead — paths are displayed, logged, and persisted into saved +queues and bookmarks, so a URL in one would leak everywhere. + +Keep `rss.toml` private; it holds the URLs in cleartext, like every other +provider credential. + +## Subscribing + +Either edit the config (below), or from a client: press `%` on `/rss`, paste +the feed URL, and the provider fetches it once, names the subscription from +the feed's own title, and writes it into `rss.toml`. `e` renames a +subscription (its path changes with the name), `d` unsubscribes — that only +removes the config entry, never audio you captured from it. + +## How it works + +```text +/rss +├── one node per feed, newest episodes first +│ └── a track; audio is the feed's enclosure URL +└── … +``` + +A subscription is queueable and downloadable, so you can queue or `W`-capture +a whole feed. Episodes stream directly from the enclosure URL — no sidecar, +no helper binary. + +**Nothing is cached.** Every visit to a subscription fetches the feed, so an +episode published a minute ago is there. One memo exists purely so that +listing a feed and then queueing its 40 episodes costs one fetch rather than +41: it is written by listings and read only when resolving a track, so it can +never make a listing stale. + +Episode paths are `blake3(guid)[..16]`, which is stable as long as the +publisher keeps its guids stable. Two consequences worth knowing: + +- A bookmark (`w`) to an episode that has since aged out of the feed cannot + resolve — there is nothing left to look up. Capture (`W`) what you want to + keep. +- A publisher that regenerates guids on every fetch invalidates bookmarks. + Nothing can be done about that from this side. + +## Configuration — `~/.config/crabidy/rss.toml` + +```toml +# One table per subscription. `name` is yours and decides the path slug; +# duplicates get a numeric suffix. +[[feeds]] +name = "The Economist Podcasts" +url = "https://feeds.economist.com/v1/rss/…" + +[[feeds]] +name = "Cautionary Tales" +url = "https://feeds.example.org/cautionary-tales" + +# Optional, defaults shown. +# episodes_per_feed = 200 # episodes listed per feed +# call_timeout_secs = 30 # per-request timeout +# max_feed_bytes = 8388608 # 8 MiB cap on a feed body +``` + +A feed entry with no url is skipped with a warning. No feeds at all is fine: +`/rss` mounts empty and you can `%` into it. + +## Notes + +- Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed via + [`feed-rs`](https://crates.io/crates/feed-rs). +- `itunes:duration` is parsed here rather than taken from `feed-rs`, which + reads it as NPT — a format with no `MM:SS` form, so `53:25` came back as 53 + *seconds*. `S`, `MM:SS` and `HH:MM:SS` all work now. +- Build the server without the `rss` cargo feature to leave this provider out + of the binary entirely. diff --git a/rssdy/src/api.rs b/rssdy/src/api.rs new file mode 100644 index 0000000..1cf0dd6 --- /dev/null +++ b/rssdy/src/api.rs @@ -0,0 +1,396 @@ +//! The feed HTTP seam. +//! +//! All network access goes through the [`Feeds`] trait so the provider's +//! subscription, slug, identity, and ordering logic is unit-tested with a fake +//! and no network (architecture/rss-provider.md). [`FeedFetcher`] is the +//! production `reqwest` + `feed-rs` implementation; tests supply their own +//! [`Feeds`]. +//! +//! **Every feed URL here is a credential.** A premium podcast URL embeds a +//! per-subscriber token, so URLs are redacted from `Debug`, never logged, and +//! never carried in an error message. Enclosure URLs get the same treatment — +//! they can be signed too. + +use std::collections::HashMap; +use std::fmt::{self, Debug}; +use std::time::Duration; + +use async_trait::async_trait; +use thiserror::Error; + +/// A typed feed failure. Carries only non-secret context — never a feed URL, +/// which would put a subscriber token in the logs. +#[derive(Debug, Error)] +pub enum FetchError { + /// Transport, timeout, or a non-success status. + #[error("feed request failed: {0}")] + Http(String), + /// The body was not a feed we could parse. + #[error("feed could not be parsed: {0}")] + Parse(String), + /// The body exceeded `max_feed_bytes` (D6). + #[error("feed is larger than the {limit} byte limit")] + TooLarge { limit: u64 }, +} + +/// One episode, normalized out of whatever feed dialect produced it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Episode { + /// The publisher's ``, or the enclosure URL when the feed omits one. + /// Keys [`Self::key`] and becomes `Track.provider_item_id` so the content + /// store de-duplicates captures of the same episode. + pub guid: String, + /// Stable short path segment: `blake3(guid)[..16]` (D4). + pub key: String, + pub title: String, + /// Show/author, used as the track artist. Empty when the feed omits it. + pub author: String, + /// Publication instant as a unix timestamp, when the feed has a parseable + /// date. `None` keeps the entry in feed order (D9). + pub published: Option, + pub duration_secs: Option, + /// The audio to play. **Secret-ish**: never logged. + pub enclosure_url: String, +} + +impl Episode { + /// The path segment for `guid` — `blake3` truncated to 16 hex chars, which + /// is short, readable, and stable across builds and restarts (D4). + pub fn key_for(guid: &str) -> String { + blake3::hash(guid.as_bytes()).to_hex()[..KEY_HEX_LEN].to_string() + } +} + +/// Hex characters of the guid hash kept in a path segment. 64 bits is far more +/// than enough to keep one feed's episodes apart, and stays readable. +const KEY_HEX_LEN: usize = 16; + +/// A parsed feed: the publisher's own title (used to name a new subscription, +/// D5) plus its episodes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Feed { + pub title: String, + pub episodes: Vec, +} + +/// Everything the provider does over the network. +#[async_trait] +pub trait Feeds: Debug + Send + Sync { + /// Fetches and parses `url`, returning at most `limit` episodes, newest + /// first. Bounded by a per-request timeout and a byte cap; a malformed + /// entry is skipped rather than failing the feed. + async fn fetch(&self, url: &str, limit: usize) -> Result; +} + +/// The production implementation: `reqwest` for transport, `feed-rs` for +/// parsing. +pub struct FeedFetcher { + http: reqwest::Client, + max_bytes: u64, +} + +impl Debug for FeedFetcher { + /// No URLs are held here, but the impl is explicit so it stays that way. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FeedFetcher") + .field("max_bytes", &self.max_bytes) + .finish_non_exhaustive() + } +} + +impl FeedFetcher { + /// `timeout` bounds every request; `max_bytes` caps a response body so a + /// broken or hostile feed cannot exhaust memory (D6). + pub fn new(timeout: Duration, max_bytes: u64) -> Result { + let http = reqwest::Client::builder() + .timeout(timeout) + .user_agent(USER_AGENT) + .build() + // `without_url` so a builder error cannot echo a URL. + .map_err(|err| FetchError::Http(err.without_url().to_string()))?; + Ok(Self { http, max_bytes }) + } + + /// Reads the body in chunks, refusing to buffer more than `max_bytes` — + /// the cap has to bite *before* the allocation, not after (D6). + async fn bounded_body(&self, mut response: reqwest::Response) -> Result, FetchError> { + let mut body: Vec = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|err| FetchError::Http(err.without_url().to_string()))? + { + if body.len() as u64 + chunk.len() as u64 > self.max_bytes { + return Err(FetchError::TooLarge { + limit: self.max_bytes, + }); + } + body.extend_from_slice(&chunk); + } + Ok(body) + } +} + +/// Sent on every feed request. Some publishers reject an empty agent. +const USER_AGENT: &str = concat!("crabidy/", env!("CARGO_PKG_VERSION")); + +#[async_trait] +impl Feeds for FeedFetcher { + async fn fetch(&self, url: &str, limit: usize) -> Result { + // `without_url` on every error: a premium feed URL is a credential and + // reqwest puts the URL in its Display output by default (G1). + let response = self + .http + .get(url) + .send() + .await + .map_err(|err| FetchError::Http(err.without_url().to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(FetchError::Http(status.to_string())); + } + let body = self.bounded_body(response).await?; + // Recovered before parsing, because feed-rs loses this field (see + // `itunes_durations`). + let durations = itunes_durations(&body); + let parsed = feed_rs::parser::parse(body.as_slice()) + .map_err(|err| FetchError::Parse(err.to_string()))?; + Ok(Feed { + title: parsed + .title + .map(|t| t.content) + .unwrap_or_default() + .trim() + .to_string(), + episodes: episodes_from(parsed.entries, limit, &durations), + }) + } +} + +/// Normalizes feed entries into episodes: newest first, capped, and skipping +/// anything unplayable. A single bad entry never fails the feed (G15). +fn episodes_from( + entries: Vec, + limit: usize, + durations: &HashMap, +) -> Vec { + let mut episodes: Vec = entries + .into_iter() + .filter_map(|entry| { + let enclosure_url = audio_url(&entry)?; + // RSS `` normalizes into `id`; a feed without one keys on + // the enclosure URL instead (D4). The URL stays out of the path — + // only its hash is used. + let guid = if entry.id.trim().is_empty() { + enclosure_url.clone() + } else { + entry.id.clone() + }; + let title = entry + .title + .as_ref() + .map(|t| t.content.trim().to_string()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| "(untitled)".to_string()); + let duration_secs = durations + .get(&guid) + .copied() + .or_else(|| duration_of(&entry)); + Some(Episode { + key: Episode::key_for(&guid), + guid, + title, + author: entry + .authors + .first() + .map(|p| p.name.trim().to_string()) + .unwrap_or_default(), + published: entry.published.or(entry.updated).map(|d| d.timestamp()), + // Our own `itunes:duration` wins over feed-rs's NPT parse. + duration_secs, + enclosure_url, + }) + }) + .collect(); + // Newest first where dates parse; a stable sort keeps dateless feeds in + // publication order (D9). + // Descending: `Reverse` keeps clippy's sort_by_key form while + // sorting newest first. + episodes.sort_by_key(|e| std::cmp::Reverse(e.published)); + episodes.truncate(limit); + episodes +} + +/// The first playable audio URL of an entry: a media enclosure, else a link +/// that advertises audio (feed dialects disagree about where it goes). +fn audio_url(entry: &feed_rs::model::Entry) -> Option { + let media = entry.media.iter().flat_map(|m| m.content.iter()); + // Prefer something explicitly typed as audio, then any media url at all. + let typed = media.clone().find(|c| { + c.url.is_some() + && c.content_type + .as_ref() + .is_some_and(|ct| ct.to_string().starts_with("audio")) + }); + if let Some(url) = typed.and_then(|c| c.url.as_ref()) { + return Some(url.to_string()); + } + if let Some(url) = media.filter_map(|c| c.url.as_ref()).next() { + return Some(url.to_string()); + } + entry + .links + .iter() + .find(|l| { + l.rel.as_deref() == Some("enclosure") + || l.media_type + .as_deref() + .is_some_and(|ct| ct.starts_with("audio")) + }) + .map(|l| l.href.clone()) +} + +/// `itunes:duration` (or a media duration) in whole seconds. +fn duration_of(entry: &feed_rs::model::Entry) -> Option { + entry + .media + .iter() + .find_map(|m| { + m.duration + .or_else(|| m.content.iter().find_map(|c| c.duration)) + }) + .map(|d| d.as_secs().min(u32::MAX as u64) as u32) +} + +/// `itunes:duration` values recovered from the raw feed, keyed by both the +/// item's guid and its enclosure URL (the two things an [`Episode`] can key +/// on). +/// +/// Why this exists: feed-rs parses `` with an **NPT** parser, +/// and NPT has no `MM:SS` form. For `53:25` +/// its fallback regex matches the leading number, so a 53-minute episode comes +/// back as 53 *seconds*. `1:20:40` happens to parse correctly. The iTunes spec +/// allows `S`, `MM:SS` and `HH:MM:SS`, so the field is recovered here and +/// overrides what feed-rs produced. +/// +/// This is a deliberately shallow scan, not a second feed parser: it walks +/// item chunks and pulls three optional strings. Anything it cannot find falls +/// back to feed-rs's value. +fn itunes_durations(body: &[u8]) -> HashMap { + let text = String::from_utf8_lossy(body); + let mut found = HashMap::new(); + // `text` in `chunk`, trimmed. +fn tag_text(chunk: &str, name: &str) -> Option { + let open = chunk.find(&format!("<{name}"))?; + let after_open = chunk[open..].find('>')? + open + 1; + let close = chunk[after_open..].find(&format!(""))? + after_open; + let inner = chunk[after_open..close] + .trim() + .trim_start_matches("") + .trim(); + (!inner.is_empty()).then(|| inner.to_string()) +} + +/// The `url="…"` of the chunk's ``, XML-unescaped for `&` — the +/// one entity that routinely appears in a query string. +fn enclosure_url_attr(chunk: &str) -> Option { + let at = chunk.find(" Option { + let mut parts: Vec = Vec::new(); + for field in text.split(':') { + // Tolerate "53:25.5" and stray whitespace. + let field = field.trim(); + let field = field.split('.').next().unwrap_or(field); + parts.push(field.parse::().ok()?); + } + let seconds = match parts.as_slice() { + [s] => *s, + [m, s] => m * 60 + s, + [h, m, s] => h * 3600 + m * 60 + s, + _ => return None, + }; + Some(seconds.min(u32::MAX as u64) as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The bug this module works around: `MM:SS` is not NPT, and a 53-minute + /// episode must not come back as 53 seconds. + #[test] + fn itunes_durations_accept_every_documented_form() { + assert_eq!(parse_itunes_duration("3205".to_string()), Some(3205)); + assert_eq!(parse_itunes_duration("53:25".to_string()), Some(3205)); + assert_eq!(parse_itunes_duration("1:20:40".to_string()), Some(4840)); + assert_eq!(parse_itunes_duration("53:25.5".to_string()), Some(3205)); + assert_eq!(parse_itunes_duration("".to_string()), None); + assert_eq!(parse_itunes_duration("about an hour".to_string()), None); + } + + /// Scanned straight out of a feed body shaped like the real one, keyed by + /// guid *and* enclosure url so either identity resolves it. + #[test] + fn durations_are_recovered_from_the_raw_body() { + let body = br#" + + 6a6479f2a51cbd54 + 53:25 + + + + second + 1:20:40 + + no-duration + "#; + let found = itunes_durations(body); + assert_eq!(found.get("6a6479f2a51cbd54"), Some(&3205)); + // The enclosure url is a key too, unescaped, for feeds without a guid. + assert_eq!( + found.get("https://cdn.example/a.mp3?tk=X&sig=Y"), + Some(&3205) + ); + assert_eq!(found.get("second"), Some(&4840)); + assert!(!found.contains_key("no-duration")); + } + + #[test] + fn tag_text_handles_cdata_and_attributes() { + assert_eq!( + tag_text("abc", "guid").as_deref(), + Some("abc") + ); + assert_eq!( + tag_text("<![CDATA[ Hello ]]>", "title").as_deref(), + Some("Hello") + ); + assert_eq!(tag_text("", "guid"), None); + assert_eq!(tag_text("nothing here", "guid"), None); + } +} diff --git a/rssdy/src/lib.rs b/rssdy/src/lib.rs new file mode 100644 index 0000000..2a89c3d --- /dev/null +++ b/rssdy/src/lib.rs @@ -0,0 +1,617 @@ +//! RSS provider: **subscribe to podcast feeds and play their episodes**. +//! Mounted at [`PROVIDER_ROOT`] (architecture/rss-provider.md). +//! +//! Unlike `/fyyd` (discovery through a public directory) this is a +//! *subscription* provider: the feeds you name in `rss.toml` are the tree, and +//! it works with private, per-subscriber URLs. +//! +//! Two properties shape everything here: +//! +//! 1. **A feed URL is a credential.** A premium URL embeds a subscriber +//! token, so it is redacted from `Debug`, never logged, and never put in a +//! library path — paths carry a slug of the subscription name and a hash +//! of the episode guid instead (D1/D4). Paths are displayed, logged, and +//! persisted into saved queues and bookmarks; URLs must not be. +//! 2. **Feed content is never cached.** A listing always fetches, so a newly +//! published episode appears on the next visit (D2). The one memo that +//! exists is written by listings and read only when resolving a track +//! (D3), so queueing 40 episodes costs one fetch, not 40. + +use std::fmt; +use std::sync::RwLock; +use std::time::Duration; + +use async_trait::async_trait; +use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; +use crabidy_core::{ProviderClient, ProviderError}; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +pub mod api; +use api::{Episode, Feeds}; + +/// First path segment owned by this provider. +pub const PROVIDER_ROOT: &str = "/rss"; + +/// Default cap on episodes listed per feed. +pub const DEFAULT_EPISODES_PER_FEED: usize = 200; +/// Default per-request timeout in seconds. +pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30; +/// Default cap on a feed body, in bytes (8 MiB). A feed larger than this is a +/// publishing bug, not something to load into memory (D6). +pub const DEFAULT_MAX_FEED_BYTES: u64 = 8 * 1024 * 1024; +/// How many feeds' episode lists the memo keeps (D3). +pub const MEMO_CAPACITY: usize = 8; + +/// One subscription. `name` is the user's; the path uses a slug of it. +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct FeedEntry { + /// Display name, and the source of the path slug. + pub name: String, + /// The feed URL. **Secret**: redacted from `Debug`, never logged, never in + /// a path (D1). + pub url: String, +} + +impl fmt::Debug for FeedEntry { + /// Redacts `url` (hard rule: credentials never reach logs or reports). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FeedEntry") + .field("name", &self.name) + .field("url", &"") + .finish() + } +} + +/// Provider settings, persisted as `rss.toml`. Everything is optional: with no +/// feeds the provider still mounts and shows an empty, creatable `/rss` you can +/// subscribe into with `%`. +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct Settings { + /// The subscriptions, in config order. + #[serde(default, rename = "feeds")] + pub feeds: Vec, + /// Episodes listed per feed. Default [`DEFAULT_EPISODES_PER_FEED`]. + pub episodes_per_feed: Option, + /// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`]. + pub call_timeout_secs: Option, + /// Cap on a feed body in bytes. Default [`DEFAULT_MAX_FEED_BYTES`]. + pub max_feed_bytes: Option, +} + +impl fmt::Debug for Settings { + /// The feed list is redacted wholesale — each entry hides its own url. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Settings") + .field("feeds", &self.feeds) + .field("episodes_per_feed", &self.episodes_per_feed) + .field("call_timeout_secs", &self.call_timeout_secs) + .field("max_feed_bytes", &self.max_feed_bytes) + .finish() + } +} + +/// A parsed `/rss/...` path. Subscriptions are addressed by slug, episodes by +/// the guid hash — never by URL (D1). +#[derive(Debug, PartialEq, Eq)] +enum RssPath<'a> { + Root, + /// A subscription: `/rss/`. + Feed { + slug: &'a str, + }, + /// One episode: `/rss//`. + Episode { + slug: &'a str, + key: &'a str, + }, +} + +/// Splits a `/rss/...` path into its recognized shape. Unknown shapes are +/// [`ProviderError::MalformedPath`]. +fn parse_path(path: &str) -> Result, ProviderError> { + if path == PROVIDER_ROOT { + return Ok(RssPath::Root); + } + let rest = path + .strip_prefix(PROVIDER_ROOT) + .and_then(|rest| rest.strip_prefix('/')) + .ok_or(ProviderError::MalformedPath)?; + let mut segments = rest.split('/'); + let slug = segments + .next() + .filter(|s| !s.is_empty()) + .ok_or(ProviderError::MalformedPath)?; + let key = segments.next(); + if segments.next().is_some() { + // Nothing lives below an episode. + return Err(ProviderError::MalformedPath); + } + match key { + None => Ok(RssPath::Feed { slug }), + Some(key) if !key.is_empty() => Ok(RssPath::Episode { slug, key }), + Some(_) => Err(ProviderError::MalformedPath), + } +} + +/// The path slug for a subscription name: lowercased, non-alphanumerics folded +/// to single dashes, trimmed, and bounded. Empty or all-punctuation names fall +/// back to `feed`; the caller de-duplicates collisions with a numeric suffix +/// (D1). +pub fn slugify(name: &str) -> String { + let mut slug = String::with_capacity(name.len()); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + } else if ch.is_alphanumeric() { + // Keep non-ASCII letters readable rather than dropping them. + slug.extend(ch.to_lowercase()); + } else if !slug.ends_with('-') { + slug.push('-'); + } + if slug.len() >= SLUG_MAX_LEN { + break; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + // A name of pure punctuation still needs an addressable path. + return "feed".to_string(); + } + slug +} + +/// Cap on a generated slug, so a pathological feed title cannot produce an +/// unwieldy path. +const SLUG_MAX_LEN: usize = 48; + +/// Resolves `name` to a slug not already in `taken`, suffixing `-2`, `-3`, … +fn unique_slug(name: &str, taken: &[String]) -> String { + let base = slugify(name); + if !taken.contains(&base) { + return base; + } + (2..) + .map(|n| format!("{base}-{n}")) + .find(|candidate| !taken.contains(candidate)) + .unwrap_or(base) +} + +/// The `/rss` provider. +/// +/// Holds the subscriptions (from config) and the episode memo (D3). No listing +/// cache: a visit fetches. +pub struct Client { + api: Box, + /// Subscriptions with their resolved slugs, in config order. + subscriptions: RwLock>, + /// `slug -> episodes`, written by listings, read by track lookups, capped + /// at [`MEMO_CAPACITY`] feeds (D3). + memo: RwLock)>>, + settings: RwLock, + episodes_per_feed: usize, +} + +/// A subscription with its path slug resolved (and de-duplicated). +#[derive(Clone, Debug)] +struct Subscription { + slug: String, + name: String, + /// **Secret**: never logged, never in a path. + url: String, +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("subscriptions", &self.subscriptions) + .field("episodes_per_feed", &self.episodes_per_feed) + .finish_non_exhaustive() + } +} + +impl Client { + /// Builds a client over an arbitrary [`Feeds`] backend — the seam the unit + /// tests use. + pub fn with_api(api: Box, settings: Settings) -> Self { + let episodes_per_feed = settings + .episodes_per_feed + .filter(|n| *n > 0) + .unwrap_or(DEFAULT_EPISODES_PER_FEED); + let mut slugs: Vec = Vec::new(); + let mut subscriptions = Vec::new(); + for entry in &settings.feeds { + if entry.url.trim().is_empty() { + // A feed we cannot fetch is not a subscription. Names only — + // never the url (G1). + warn!(name = %entry.name, "skipping rss feed with no url"); + continue; + } + let slug = unique_slug(&entry.name, &slugs); + slugs.push(slug.clone()); + subscriptions.push(Subscription { + slug, + name: entry.name.trim().to_string(), + url: entry.url.trim().to_string(), + }); + } + Self { + api, + subscriptions: RwLock::new(subscriptions), + memo: RwLock::new(Vec::new()), + settings: RwLock::new(settings), + episodes_per_feed, + } + } + + /// Read access to the subscriptions, tolerating a poisoned lock rather than + /// panicking (a provider must not take the server down). + fn subs(&self) -> Vec { + match self.subscriptions.read() { + Ok(subs) => subs.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } + + /// The subscription owning `slug`, or `MalformedPath`. + fn subscription(&self, slug: &str) -> Result { + self.subs() + .into_iter() + .find(|sub| sub.slug == slug) + .ok_or_else(|| { + warn!(slug, "no such rss subscription"); + ProviderError::MalformedPath + }) + } + + /// Fetches a feed and replaces its memo entry — the listing path (D3). + async fn fetch_and_memo(&self, sub: &Subscription) -> Result, ProviderError> { + let feed = self + .api + .fetch(&sub.url, self.episodes_per_feed) + .await + .map_err(|err| { + // The error carries no url by construction (G1). + warn!(slug = %sub.slug, "cannot fetch rss feed: {err}"); + ProviderError::FetchError + })?; + // The parser already sorts before truncating (it has to, or the cap + // would keep the wrong episodes), but the invariant belongs to the + // provider: any backend yields newest-first from here (G17). + let mut episodes = feed.episodes; + // Descending: `Reverse` keeps clippy's sort_by_key form while sorting + // newest first. + episodes.sort_by_key(|e| std::cmp::Reverse(e.published)); + self.remember(&sub.slug, episodes.clone()); + Ok(episodes) + } + + /// Replaces `slug`'s memo entry, evicting the oldest beyond + /// [`MEMO_CAPACITY`] (D3/G5). + fn remember(&self, slug: &str, episodes: Vec) { + let mut memo = match self.memo.write() { + Ok(memo) => memo, + Err(poisoned) => poisoned.into_inner(), + }; + memo.retain(|(known, _)| known != slug); + memo.push((slug.to_string(), episodes)); + while memo.len() > MEMO_CAPACITY { + memo.remove(0); + } + } + + /// Drops `slug`'s memo entry (it was renamed or unsubscribed). + fn forget(&self, slug: &str) { + let mut memo = match self.memo.write() { + Ok(memo) => memo, + Err(poisoned) => poisoned.into_inner(), + }; + memo.retain(|(known, _)| known != slug); + } + + /// The memoized episodes for `slug`, if a listing has fetched it. + fn recall(&self, slug: &str) -> Option> { + let memo = match self.memo.read() { + Ok(memo) => memo, + Err(poisoned) => poisoned.into_inner(), + }; + memo.iter() + .find(|(known, _)| known == slug) + .map(|(_, episodes)| episodes.clone()) + } + + /// The episodes for `slug` from the memo, fetching only on a miss — the + /// track-lookup path (D3). Never consulted to answer a listing. + async fn episodes_for(&self, slug: &str) -> Result, ProviderError> { + if let Some(episodes) = self.recall(slug) { + return Ok(episodes); + } + let sub = self.subscription(slug)?; + self.fetch_and_memo(&sub).await + } + + /// The episode `key` of `slug`, or `MalformedPath` when the feed no longer + /// lists it (an episode aged out, or a publisher changed its guid). + async fn episode(&self, slug: &str, key: &str) -> Result { + self.episodes_for(slug) + .await? + .into_iter() + .find(|episode| episode.key == key) + .ok_or_else(|| { + warn!(slug, key, "episode is not in the feed any more"); + ProviderError::MalformedPath + }) + } + + /// One episode as a track. `provider_item_id` is the guid so the content + /// store de-duplicates captures of it (D4/G9). + fn track(&self, sub: &Subscription, episode: &Episode) -> Track { + Track { + path: format!("{}/{}/{}", PROVIDER_ROOT, sub.slug, episode.key), + artist: if episode.author.is_empty() { + sub.name.clone() + } else { + episode.author.clone() + }, + title: episode.title.clone(), + duration: episode.duration_secs, + album: None, + is_skipped: false, + provider_item_id: episode.guid.clone(), + is_captured: false, + } + } + + /// A subscription node listing `episodes` as tracks. + fn feed_node(&self, sub: &Subscription, episodes: &[Episode]) -> LibraryNode { + LibraryNode { + path: format!("{}/{}", PROVIDER_ROOT, sub.slug), + title: sub.name.clone(), + parent: Some(PROVIDER_ROOT.to_string()), + children: Vec::new(), + tracks: episodes.iter().map(|e| self.track(sub, e)).collect(), + is_queable: true, + is_creatable: false, + is_downloadable: true, + tracks_deletable: false, + is_captured: false, + } + } + + /// Rewrites the persisted feed list from the live subscriptions, so the + /// server's settings write-back keeps them (D5/G14). + fn sync_settings(&self) { + let feeds = self + .subs() + .into_iter() + .map(|sub| FeedEntry { + name: sub.name, + url: sub.url, + }) + .collect(); + let mut settings = match self.settings.write() { + Ok(settings) => settings, + Err(poisoned) => poisoned.into_inner(), + }; + settings.feeds = feeds; + } + + /// Serializes the current settings, for the caller to persist after a + /// subscription changes (D5). + pub fn settings_toml(&self) -> String { + let settings = match self.settings.read() { + Ok(settings) => settings, + Err(poisoned) => poisoned.into_inner(), + }; + toml::to_string_pretty(&*settings).unwrap_or_default() + } +} + +#[async_trait] +impl ProviderClient for Client { + /// Loads `rss.toml`. A feed entry without a url is skipped with a warning; + /// no feeds at all is fine — `/rss` mounts empty and creatable, so `%` can + /// subscribe into it. + async fn init(raw_toml_settings: &str) -> Result { + let settings: Settings = toml::from_str(raw_toml_settings).map_err(|err| { + warn!("could not parse rss.toml: {err}"); + ProviderError::Config("rss.toml is not valid TOML".to_string()) + })?; + let timeout = Duration::from_secs( + settings + .call_timeout_secs + .unwrap_or(DEFAULT_CALL_TIMEOUT_SECS), + ); + let max_bytes = settings.max_feed_bytes.unwrap_or(DEFAULT_MAX_FEED_BYTES); + let api = api::FeedFetcher::new(timeout, max_bytes).map_err(|err| { + warn!("cannot build the rss client: {err}"); + ProviderError::Config(err.to_string()) + })?; + Ok(Self::with_api(Box::new(api), settings)) + } + + fn settings(&self) -> String { + self.settings_toml() + } + + fn is_track_path(&self, path: &str) -> bool { + matches!(parse_path(path), Ok(RssPath::Episode { .. })) + } + + /// The episode's enclosure URL. Never logged (G1). + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { + let RssPath::Episode { slug, key } = parse_path(track_path)? else { + return Err(ProviderError::MalformedPath); + }; + Ok(vec![self.episode(slug, key).await?.enclosure_url]) + } + + async fn get_metadata_for_track(&self, track_path: &str) -> Result { + let RssPath::Episode { slug, key } = parse_path(track_path)? else { + return Err(ProviderError::MalformedPath); + }; + let sub = self.subscription(slug)?; + let episode = self.episode(slug, key).await?; + Ok(self.track(&sub, &episode)) + } + + /// `/rss` itself: one child per subscription, creatable so `%` can + /// subscribe with a pasted URL (D5). + fn get_lib_root(&self) -> LibraryNode { + let children = self + .subs() + .into_iter() + .map(|sub| LibraryNodeChild { + is_editable: true, + is_deletable: true, + is_downloadable: true, + ..LibraryNodeChild::new(format!("{}/{}", PROVIDER_ROOT, sub.slug), sub.name, true) + }) + .collect(); + LibraryNode { + path: PROVIDER_ROOT.to_string(), + title: "rss".to_string(), + parent: Some(crabidy_core::ROOT_PATH.to_string()), + children, + tracks: Vec::new(), + is_queable: false, + is_creatable: true, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + /// A subscription listing **always fetches** — this is the freshness + /// guarantee (D2/G3). + async fn get_lib_node(&self, path: &str) -> Result { + match parse_path(path)? { + RssPath::Root => Ok(self.get_lib_root()), + RssPath::Feed { slug } => { + let sub = self.subscription(slug)?; + let episodes = self.fetch_and_memo(&sub).await?; + Ok(self.feed_node(&sub, &episodes)) + } + // An episode is a track, not a node. + RssPath::Episode { .. } => Err(ProviderError::MalformedPath), + } + } + + /// `%` on `/rss`: `title` is a pasted feed URL. Fetches it once to prove it + /// works and to learn the feed's own name, then subscribes (D5). + async fn create_lib_node( + &self, + parent_path: &str, + title: &str, + ) -> Result { + if parse_path(parent_path)? != RssPath::Root { + return Err(ProviderError::NotSupported); + } + let url = title.trim(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + warn!("rss subscribe needs an http(s) feed url"); + return Err(ProviderError::InvalidInput); + } + let feed = self + .api + .fetch(url, self.episodes_per_feed) + .await + .map_err(|err| { + warn!("cannot subscribe to that feed: {err}"); + ProviderError::FetchError + })?; + let name = if feed.title.is_empty() { + "feed".to_string() + } else { + feed.title.clone() + }; + let sub = { + let mut subs = match self.subscriptions.write() { + Ok(subs) => subs, + Err(poisoned) => poisoned.into_inner(), + }; + let taken: Vec = subs.iter().map(|s| s.slug.clone()).collect(); + let sub = Subscription { + slug: unique_slug(&name, &taken), + name, + url: url.to_string(), + }; + subs.push(sub.clone()); + sub + }; + self.sync_settings(); + self.remember(&sub.slug, feed.episodes.clone()); + Ok(self.feed_node(&sub, &feed.episodes)) + } + + /// `e` on a subscription: rename it. The slug — and so the path — changes + /// with the name (G13). + async fn rename_lib_node( + &self, + path: &str, + new_title: &str, + ) -> Result { + let RssPath::Feed { slug } = parse_path(path)? else { + return Err(ProviderError::NotSupported); + }; + let name = new_title.trim(); + if name.is_empty() { + return Err(ProviderError::InvalidInput); + } + let renamed = { + let mut subs = match self.subscriptions.write() { + Ok(subs) => subs, + Err(poisoned) => poisoned.into_inner(), + }; + let index = subs + .iter() + .position(|sub| sub.slug == slug) + .ok_or(ProviderError::MalformedPath)?; + let taken: Vec = subs + .iter() + .enumerate() + .filter(|(i, _)| *i != index) + .map(|(_, sub)| sub.slug.clone()) + .collect(); + subs[index].name = name.to_string(); + subs[index].slug = unique_slug(name, &taken); + subs[index].clone() + }; + self.sync_settings(); + // The old memo entry belongs to a slug that no longer exists. + if let Some(episodes) = self.recall(slug) { + self.forget(slug); + self.remember(&renamed.slug, episodes); + } + let episodes = self.episodes_for(&renamed.slug).await?; + Ok(self.feed_node(&renamed, &episodes)) + } + + /// `d` on a subscription: unsubscribe. Removes the config entry and touches + /// no audio — a capture made from it stays under `/crabidy` (G13). + async fn delete_lib_node(&self, path: &str) -> Result { + let RssPath::Feed { slug } = parse_path(path)? else { + return Err(ProviderError::NotSupported); + }; + { + let mut subs = match self.subscriptions.write() { + Ok(subs) => subs, + Err(poisoned) => poisoned.into_inner(), + }; + let before = subs.len(); + subs.retain(|sub| sub.slug != slug); + if subs.len() == before { + return Err(ProviderError::MalformedPath); + } + } + self.sync_settings(); + self.forget(slug); + // What the client should show next: the subscription list. + Ok(self.get_lib_root()) + } +} + +#[cfg(test)] +mod tests { + include!("tests.rs"); +} diff --git a/rssdy/src/tests.rs b/rssdy/src/tests.rs new file mode 100644 index 0000000..f1b0d24 --- /dev/null +++ b/rssdy/src/tests.rs @@ -0,0 +1,502 @@ +// Unit tests over a fixture-driven `FakeFeeds` backend (no network). They +// define the provider's contract (quality/rss-provider.md): secret handling, +// freshness, the memo, episode identity, slugs, and subscription CRUD. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use super::*; +use api::{Feed, FetchError}; + +/// A feed backend with canned responses and a fetch counter, so tests can +/// assert *how many times* a feed was fetched — the freshness gates are about +/// exactly that. +#[derive(Debug, Default)] +struct FakeFeeds { + feeds: HashMap, + fetches: AtomicUsize, + fail: bool, +} + +impl FakeFeeds { + fn with(url: &str, feed: Feed) -> Self { + let mut feeds = HashMap::new(); + feeds.insert(url.to_string(), feed); + Self { + feeds, + ..Self::default() + } + } + fn failing() -> Self { + Self { + fail: true, + ..Self::default() + } + } + fn count(&self) -> usize { + self.fetches.load(Ordering::Relaxed) + } +} + +/// Lets a test keep a handle on the fake (to read its fetch counter) while the +/// client owns it as a `Box`. +#[async_trait] +impl Feeds for Arc { + async fn fetch(&self, url: &str, limit: usize) -> Result { + self.as_ref().fetch(url, limit).await + } +} + +#[async_trait] +impl Feeds for FakeFeeds { + async fn fetch(&self, url: &str, limit: usize) -> Result { + self.fetches.fetch_add(1, Ordering::Relaxed); + if self.fail { + return Err(FetchError::Http("503 Service Unavailable".to_string())); + } + let mut feed = self + .feeds + .get(url) + .cloned() + .ok_or_else(|| FetchError::Http("404 Not Found".to_string()))?; + feed.episodes.truncate(limit); + Ok(feed) + } +} + +fn episode(guid: &str, title: &str, published: Option) -> Episode { + Episode { + key: Episode::key_for(guid), + guid: guid.to_string(), + title: title.to_string(), + author: String::new(), + published, + duration_secs: Some(120), + enclosure_url: format!("https://cdn.example.org/{guid}.mp3?token=SECRET"), + } +} + +fn feed(title: &str, episodes: Vec) -> Feed { + Feed { + title: title.to_string(), + episodes, + } +} + +/// A premium URL, the kind that must never escape into a path or a log. +const PREMIUM: &str = "https://feeds.economist.com/v1/rss/pods/f74365b0-TOKEN"; + +fn settings_with(name: &str, url: &str) -> Settings { + Settings { + feeds: vec![FeedEntry { + name: name.to_string(), + url: url.to_string(), + }], + ..Settings::default() + } +} + +fn client_with(api: FakeFeeds, settings: Settings) -> Client { + Client::with_api(Box::new(api), settings) +} + +/// A client plus a handle on its backend, for the fetch-count gates. +fn counted(api: FakeFeeds, settings: Settings) -> (Client, Arc) { + let api = Arc::new(api); + (Client::with_api(Box::new(Arc::clone(&api)), settings), api) +} + +// --- Secrets (G1, G2) ----------------------------------------------------- + +#[test] +fn settings_debug_redacts_urls() { + let dumped = format!("{:?}", settings_with("The Economist", PREMIUM)); + assert!(!dumped.contains("TOKEN"), "{dumped}"); + assert!(!dumped.contains("economist.com"), "{dumped}"); + assert!(dumped.contains(""), "{dumped}"); +} + +#[test] +fn feed_entry_debug_redacts_the_url() { + let entry = FeedEntry { + name: "x".to_string(), + url: PREMIUM.to_string(), + }; + let dumped = format!("{entry:?}"); + assert!(!dumped.contains("TOKEN"), "{dumped}"); +} + +#[tokio::test] +async fn episode_paths_carry_only_slug_and_key() { + let api = FakeFeeds::with( + PREMIUM, + feed("The Economist", vec![episode("g1", "Ep", None)]), + ); + let client = client_with(api, settings_with("The Economist", PREMIUM)); + let node = client + .get_lib_node("/rss/the-economist") + .await + .expect("listing"); + let path = &node.tracks[0].path; + assert_eq!( + path, + &format!("/rss/the-economist/{}", Episode::key_for("g1")) + ); + // Neither the feed URL nor the enclosure token may appear in a path: paths + // are displayed, logged, and persisted into bookmarks. + assert!(!path.contains("TOKEN") && !path.contains("http"), "{path}"); +} + +// --- Freshness and the memo (G3, G4, G5) ---------------------------------- + +#[tokio::test] +async fn every_listing_refetches_the_feed() { + let (client, api) = counted( + FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])), + settings_with("Econ", PREMIUM), + ); + for expected in 1..=3 { + client.get_lib_node("/rss/econ").await.expect("listing"); + // Listings never answer from the memo — a newly published episode has + // to show up on the next visit (G3). + assert_eq!(api.count(), expected); + } +} + +/// After one listing, playing or queueing its episodes costs no more fetches +/// (G4) — this is what keeps "always fresh" from meaning "fetch 40 times". +#[tokio::test] +async fn track_lookups_reuse_the_listing_fetch() { + let episodes = vec![episode("g1", "One", Some(2)), episode("g2", "Two", Some(1))]; + let (client, api) = counted( + FakeFeeds::with(PREMIUM, feed("Econ", episodes)), + settings_with("Econ", PREMIUM), + ); + let node = client.get_lib_node("/rss/econ").await.expect("listing"); + assert_eq!(api.count(), 1); + for track in &node.tracks { + client + .get_urls_for_track(&track.path) + .await + .expect("stream url"); + client + .get_metadata_for_track(&track.path) + .await + .expect("metadata"); + } + assert_eq!(api.count(), 1, "the memo served every track lookup"); +} + +/// A track lookup with a cold memo — a bookmark replayed after a restart — +/// fetches once and then serves the rest from the memo (G4). +#[tokio::test] +async fn a_cold_memo_fetches_once() { + let (client, api) = counted( + FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])), + settings_with("Econ", PREMIUM), + ); + let path = format!("/rss/econ/{}", Episode::key_for("g1")); + client.get_urls_for_track(&path).await.expect("stream url"); + assert_eq!(api.count(), 1); + client.get_urls_for_track(&path).await.expect("stream url"); + assert_eq!(api.count(), 1); +} + +/// An episode the feed no longer lists cannot be resolved — an aged-out +/// bookmark fails cleanly instead of panicking. +#[tokio::test] +async fn an_episode_missing_from_the_feed_is_a_typed_error() { + let client = client_with( + FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])), + settings_with("Econ", PREMIUM), + ); + let gone = format!("/rss/econ/{}", Episode::key_for("aged-out")); + assert_eq!( + client.get_urls_for_track(&gone).await.err(), + Some(ProviderError::MalformedPath) + ); +} + +/// The memo is bounded, so subscribing to many feeds cannot grow it without +/// limit (G5). +#[tokio::test] +async fn the_memo_evicts_beyond_its_capacity() { + let mut feeds = HashMap::new(); + let mut entries = Vec::new(); + let total = MEMO_CAPACITY + 2; + for i in 0..total { + let url = format!("https://feed{i}.example/rss"); + feeds.insert( + url.clone(), + feed( + &format!("Feed {i}"), + vec![episode(&format!("g{i}"), "Ep", None)], + ), + ); + entries.push(FeedEntry { + name: format!("Feed {i}"), + url, + }); + } + let api = Arc::new(FakeFeeds { + feeds, + ..FakeFeeds::default() + }); + let client = Client::with_api( + Box::new(Arc::clone(&api)), + Settings { + feeds: entries, + ..Settings::default() + }, + ); + for i in 0..total { + client + .get_lib_node(&format!("/rss/feed-{i}")) + .await + .expect("listing"); + } + assert_eq!(api.count(), total); + // The oldest two entries were evicted, so their track lookups refetch… + let first = format!("/rss/feed-0/{}", Episode::key_for("g0")); + client.get_urls_for_track(&first).await.expect("url"); + assert_eq!(api.count(), total + 1, "evicted feed had to be refetched"); + // …while the newest is still memoized. + let last = format!( + "/rss/feed-{}/{}", + total - 1, + Episode::key_for(&format!("g{}", total - 1)) + ); + client.get_urls_for_track(&last).await.expect("url"); + assert_eq!(api.count(), total + 1); +} + +// --- Identity and slugs (G7, G8, G9) -------------------------------------- + +#[test] +fn episode_keys_are_stable_and_short() { + let key = Episode::key_for("https://example.org/?p=42"); + assert_eq!(key.len(), 16); + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + // Stable across calls — a bookmark written today resolves tomorrow. + assert_eq!(key, Episode::key_for("https://example.org/?p=42")); + assert_ne!(key, Episode::key_for("https://example.org/?p=43")); +} + +#[test] +fn slugify_folds_and_bounds() { + assert_eq!(slugify("The Economist Podcasts"), "the-economist-podcasts"); + assert_eq!(slugify(" Hello, World! "), "hello-world"); + assert_eq!(slugify("!!!"), "feed"); + assert_eq!(slugify(""), "feed"); + assert!(slugify(&"x".repeat(200)).len() <= 48); +} + +#[test] +fn duplicate_slugs_get_a_suffix() { + let settings = Settings { + feeds: vec![ + FeedEntry { + name: "News".to_string(), + url: "https://a.example/feed".to_string(), + }, + FeedEntry { + name: "news".to_string(), + url: "https://b.example/feed".to_string(), + }, + FeedEntry { + name: "N E W S".to_string(), + url: "https://c.example/feed".to_string(), + }, + ], + ..Settings::default() + }; + let client = client_with(FakeFeeds::default(), settings); + let slugs: Vec = client + .get_lib_root() + .children + .into_iter() + .map(|c| c.path) + .collect(); + // "N E W S" folds each space to a dash, so it does not collide with + // "news" — the suffix only appears for a genuine collision. + assert_eq!(slugs, ["/rss/news", "/rss/news-2", "/rss/n-e-w-s"]); +} + +#[tokio::test] +async fn tracks_carry_the_guid_as_provider_item_id() { + let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("guid-42", "Ep", None)])); + let client = client_with(api, settings_with("Econ", PREMIUM)); + let node = client.get_lib_node("/rss/econ").await.expect("listing"); + assert_eq!(node.tracks[0].provider_item_id, "guid-42"); +} + +#[tokio::test] +async fn episodes_are_newest_first() { + let api = FakeFeeds::with( + PREMIUM, + feed( + "Econ", + vec![ + episode("old", "Old", Some(100)), + episode("new", "New", Some(300)), + episode("mid", "Mid", Some(200)), + ], + ), + ); + let client = client_with(api, settings_with("Econ", PREMIUM)); + let node = client.get_lib_node("/rss/econ").await.expect("listing"); + let titles: Vec<&str> = node.tracks.iter().map(|t| t.title.as_str()).collect(); + assert_eq!(titles, ["New", "Mid", "Old"]); +} + +// --- Errors (G10, G15, G18) ---------------------------------------------- + +#[tokio::test] +async fn foreign_and_malformed_paths_reject() { + let client = client_with(FakeFeeds::default(), settings_with("Econ", PREMIUM)); + for path in ["/tidal/x", "/rss/", "/rss/econ/key/extra", "/rssx"] { + assert!(client.get_lib_node(path).await.is_err(), "{path}"); + } + // An unknown subscription is not a panic. + assert_eq!( + client.get_lib_node("/rss/nope").await.err(), + Some(ProviderError::MalformedPath) + ); + assert!(!client.is_track_path("/rss/econ")); + assert!(client.is_track_path("/rss/econ/abcdef")); +} + +#[tokio::test] +async fn backend_failures_are_typed() { + let client = client_with(FakeFeeds::failing(), settings_with("Econ", PREMIUM)); + assert_eq!( + client.get_lib_node("/rss/econ").await.err(), + Some(ProviderError::FetchError) + ); +} + +#[tokio::test] +async fn no_feeds_still_mounts() { + let client = client_with(FakeFeeds::default(), Settings::default()); + let root = client.get_lib_root(); + assert!(root.children.is_empty()); + // Empty but creatable, so `%` can subscribe into it. + assert!(root.is_creatable); +} + +#[test] +fn a_feed_entry_without_a_url_is_skipped() { + let settings = Settings { + feeds: vec![ + FeedEntry { + name: "broken".to_string(), + url: " ".to_string(), + }, + FeedEntry { + name: "good".to_string(), + url: PREMIUM.to_string(), + }, + ], + ..Settings::default() + }; + let client = client_with(FakeFeeds::default(), settings); + assert_eq!(client.get_lib_root().children.len(), 1); +} + +// --- Subscription CRUD (G11–G14) ----------------------------------------- + +#[tokio::test] +async fn subscribing_names_from_the_feed_title() { + let api = FakeFeeds::with( + PREMIUM, + feed("The Economist Podcasts", vec![episode("g1", "Ep", None)]), + ); + let client = client_with(api, Settings::default()); + let node = client + .create_lib_node("/rss", PREMIUM) + .await + .expect("subscribe"); + assert_eq!(node.path, "/rss/the-economist-podcasts"); + assert_eq!(node.title, "The Economist Podcasts"); + assert_eq!(client.get_lib_root().children.len(), 1); +} + +#[tokio::test] +async fn subscribing_rejects_bad_input() { + let client = client_with(FakeFeeds::failing(), Settings::default()); + // Not a URL at all. + assert_eq!( + client.create_lib_node("/rss", "the economist").await.err(), + Some(ProviderError::InvalidInput) + ); + // A URL that cannot be fetched adds nothing. + assert_eq!( + client + .create_lib_node("/rss", "https://nope.example/feed") + .await + .err(), + Some(ProviderError::FetchError) + ); + assert!(client.get_lib_root().children.is_empty()); + // Only the root is creatable. + assert_eq!( + client.create_lib_node("/rss/econ", PREMIUM).await.err(), + Some(ProviderError::NotSupported) + ); +} + +#[tokio::test] +async fn renaming_moves_the_slug() { + let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "Ep", None)])); + let client = client_with(api, settings_with("Econ", PREMIUM)); + let node = client + .rename_lib_node("/rss/econ", "World News") + .await + .expect("rename"); + assert_eq!(node.path, "/rss/world-news"); + assert!(client.get_lib_node("/rss/econ").await.is_err()); + assert!(client.get_lib_node("/rss/world-news").await.is_ok()); + // An empty name is rejected. + assert_eq!( + client.rename_lib_node("/rss/world-news", " ").await.err(), + Some(ProviderError::InvalidInput) + ); +} + +#[tokio::test] +async fn unsubscribing_removes_only_the_entry() { + let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "Ep", None)])); + let client = client_with(api, settings_with("Econ", PREMIUM)); + let root = client.delete_lib_node("/rss/econ").await.expect("delete"); + assert!(root.children.is_empty()); + assert_eq!( + client.delete_lib_node("/rss/econ").await.err(), + Some(ProviderError::MalformedPath) + ); +} + +#[tokio::test] +async fn subscription_changes_round_trip_through_toml() { + let api = FakeFeeds::with( + PREMIUM, + feed("The Economist Podcasts", vec![episode("g1", "Ep", None)]), + ); + let client = client_with(api, Settings::default()); + client + .create_lib_node("/rss", PREMIUM) + .await + .expect("subscribe"); + let toml_text = client.settings(); + // The url must be persisted (it is the credential the user configured)… + assert!(toml_text.contains(PREMIUM), "{toml_text}"); + // …and reloading must yield the same subscription. + let reloaded: Settings = toml::from_str(&toml_text).expect("reparse"); + assert_eq!(reloaded.feeds.len(), 1); + assert_eq!(reloaded.feeds[0].url, PREMIUM); + let client = client_with(FakeFeeds::default(), reloaded); + assert_eq!( + client.get_lib_root().children[0].path, + "/rss/the-economist-podcasts" + ); +}