rss: subscribe to podcast feeds at /rss, including premium ones

A new rssdy crate mounted at /rss. Subscriptions are (name, url) pairs in
rss.toml; `%` on /rss takes a pasted feed URL, fetches it once, names the
subscription from the feed's own title and persists it, `e` renames, `d`
unsubscribes without touching captured audio. Feeds are read as RSS
2.0/1.0/0.x, Atom or JSON Feed through feed-rs.

**A premium feed URL is the credential.** Library paths are displayed,
logged, and persisted into saved queues and bookmark tomls, so a URL in one
leaks into all of them. Paths therefore carry a slug of the subscription name
plus blake3(guid)[..16] — /rss/the-economist-podcasts/676f8bfa48c9cac3 — and
URLs are redacted from every Debug impl and kept out of errors (reqwest goes
through without_url).

**Nothing is cached, at either end.** A listing always fetches. The half that
is easy to miss is client-side: both clients cache listings by path and only
/crabidy, /fs and /orphans bypassed it, so /rss joins MUTABLE_ROOTS in both —
otherwise a re-visit answers from the client and the server's freshness is
invisible. One memo, written by listings and read only when resolving a track
(bounded to 8 feeds), keeps queueing 40 episodes at one fetch instead of 41
without a TTL to guess at.

Verifying against the user's real Economist feed caught a bug no unit test
would have: feed-rs parses <itunes:duration> as NPT, which has no MM:SS form,
so "53:25" fell through to its leading-number regex and a 53-minute episode
reported 53 *seconds* ("1:20:40" happens to parse fine). That field is now
recovered from the raw body — a shallow scan keyed by guid and enclosure URL —
and the live feed reports 3205/2830/1662 s, matching 53:25/47:10/27:42.

Bounded by design: per-request timeout, an 8 MiB body cap enforced while
reading chunks rather than after the fact, an episode cap, newest-first
enforced at the provider boundary so any backend obeys it. A malformed entry
is skipped; only an unfetchable feed errors, and it fails that node alone.

Behind a default-on `rss` cargo feature like every other provider, with a row
in check-features. Documented in docs/src/providers/rss.md and
rssdy/README.md, both stating plainly that the URL is a credential, that
listings are never cached, and that bookmarks depend on publisher guids —
capture what you want to keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-26 12:36:30 +02:00
parent bdc961501d
commit 378066470e
21 changed files with 2046 additions and 6 deletions

50
Cargo.lock generated
View File

@ -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]]

View File

@ -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" }

View File

@ -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",
]
```

View File

@ -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'/'))
})

View File

@ -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'/'))
})

View File

@ -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 }

View File

@ -72,7 +72,8 @@ impl Mount {
feature = "fyyd",
feature = "abs",
feature = "soundcloud",
feature = "jamendo"
feature = "jamendo",
feature = "rss"
))]
async fn mount_from_config<C>(
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::<rssdy::Client>(
&config_dir,
"rss.toml",
rssdy::PROVIDER_ROOT,
"rss",
)
.await,
);
}
#[cfg(feature = "jamendo")]
if enabled.jamendo {
mounts.extend(

View File

@ -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"),

View File

@ -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

View File

@ -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)

View File

@ -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",

View File

@ -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`).

113
docs/src/providers/rss.md Normal file
View File

@ -0,0 +1,113 @@
# RSS — /rss
<!-- toc -->
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
├── <subscription> one per feed; queueable and downloadable
│ └── <episode> 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 `<guid>` (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.

60
plan/rss-provider.md Normal file
View File

@ -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/<slug>")` 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:* G11G14.
- [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.

View File

@ -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` (G1G21), `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 `<itunes:duration>` 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.

100
quality/rss-provider.md Normal file
View File

@ -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/<slug>`, episode paths `/rss/<slug>/<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.

23
rssdy/Cargo.toml Normal file
View File

@ -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"] }

91
rssdy/README.md Normal file
View File

@ -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
├── <subscription> one node per feed, newest episodes first
│ └── <episode> 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.

396
rssdy/src/api.rs Normal file
View File

@ -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 `<guid>`, 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<i64>,
pub duration_secs: Option<u32>,
/// 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<Episode>,
}
/// 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<Feed, FetchError>;
}
/// 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<Self, FetchError> {
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<Vec<u8>, FetchError> {
let mut body: Vec<u8> = 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<Feed, FetchError> {
// `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<feed_rs::model::Entry>,
limit: usize,
durations: &HashMap<String, u32>,
) -> Vec<Episode> {
let mut episodes: Vec<Episode> = entries
.into_iter()
.filter_map(|entry| {
let enclosure_url = audio_url(&entry)?;
// RSS `<guid>` 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<String> {
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<u32> {
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 `<itunes:duration>` with an **NPT** parser,
/// and NPT has no `MM:SS` form. For `<itunes:duration>53:25</itunes:duration>`
/// 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<String, u32> {
let text = String::from_utf8_lossy(body);
let mut found = HashMap::new();
// `<item` for RSS, `<entry` for Atom; the first chunk is the feed header.
for chunk in text.split("<item").flat_map(|c| c.split("<entry")).skip(1) {
let Some(duration) = tag_text(chunk, "itunes:duration").and_then(parse_itunes_duration)
else {
continue;
};
if let Some(guid) = tag_text(chunk, "guid") {
found.insert(guid, duration);
}
if let Some(url) = enclosure_url_attr(chunk) {
found.insert(url, duration);
}
}
found
}
/// The text of the first `<name …>text</name>` in `chunk`, trimmed.
fn tag_text(chunk: &str, name: &str) -> Option<String> {
let open = chunk.find(&format!("<{name}"))?;
let after_open = chunk[open..].find('>')? + open + 1;
let close = chunk[after_open..].find(&format!("</{name}>"))? + after_open;
let inner = chunk[after_open..close]
.trim()
.trim_start_matches("<![CDATA[")
.trim_end_matches("]]>")
.trim();
(!inner.is_empty()).then(|| inner.to_string())
}
/// The `url="…"` of the chunk's `<enclosure>`, XML-unescaped for `&amp;` — the
/// one entity that routinely appears in a query string.
fn enclosure_url_attr(chunk: &str) -> Option<String> {
let at = chunk.find("<enclosure")?;
let rest = &chunk[at..];
let start = rest.find("url=\"")? + 5;
let end = rest[start..].find('"')? + start;
Some(rest[start..end].replace("&amp;", "&"))
}
/// `itunes:duration` in whole seconds: `S`, `MM:SS`, or `HH:MM:SS`. Fractional
/// seconds are truncated; anything unparseable is `None` rather than a guess.
fn parse_itunes_duration(text: String) -> Option<u32> {
let mut parts: Vec<u64> = 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::<u64>().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#"<rss><channel>
<item>
<guid isPermaLink="false">6a6479f2a51cbd54</guid>
<itunes:duration>53:25</itunes:duration>
<enclosure url="https://cdn.example/a.mp3?tk=X&amp;sig=Y" type="audio/mpeg"/>
</item>
<item>
<guid>second</guid>
<itunes:duration>1:20:40</itunes:duration>
</item>
<item><guid>no-duration</guid></item>
</channel></rss>"#;
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("<guid isPermaLink=\"false\">abc</guid>", "guid").as_deref(),
Some("abc")
);
assert_eq!(
tag_text("<title><![CDATA[ Hello ]]></title>", "title").as_deref(),
Some("Hello")
);
assert_eq!(tag_text("<guid></guid>", "guid"), None);
assert_eq!(tag_text("nothing here", "guid"), None);
}
}

617
rssdy/src/lib.rs Normal file
View File

@ -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", &"<redacted>")
.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<FeedEntry>,
/// Episodes listed per feed. Default [`DEFAULT_EPISODES_PER_FEED`].
pub episodes_per_feed: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
/// Cap on a feed body in bytes. Default [`DEFAULT_MAX_FEED_BYTES`].
pub max_feed_bytes: Option<u64>,
}
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/<slug>`.
Feed {
slug: &'a str,
},
/// One episode: `/rss/<slug>/<key>`.
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<RssPath<'_>, 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<dyn Feeds>,
/// Subscriptions with their resolved slugs, in config order.
subscriptions: RwLock<Vec<Subscription>>,
/// `slug -> episodes`, written by listings, read by track lookups, capped
/// at [`MEMO_CAPACITY`] feeds (D3).
memo: RwLock<Vec<(String, Vec<Episode>)>>,
settings: RwLock<Settings>,
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<dyn Feeds>, 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<String> = 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<Subscription> {
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<Subscription, ProviderError> {
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<Vec<Episode>, 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<Episode>) {
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<Vec<Episode>> {
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<Vec<Episode>, 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<Episode, ProviderError> {
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<Self, ProviderError> {
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<Vec<String>, 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<Track, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<String> = 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<LibraryNode, ProviderError> {
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<String> = 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<LibraryNode, ProviderError> {
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");
}

502
rssdy/src/tests.rs Normal file
View File

@ -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<String, Feed>,
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<dyn Feeds>`.
#[async_trait]
impl Feeds for Arc<FakeFeeds> {
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
self.as_ref().fetch(url, limit).await
}
}
#[async_trait]
impl Feeds for FakeFeeds {
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
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<i64>) -> 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<Episode>) -> 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<FakeFeeds>) {
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("<redacted>"), "{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<String> = 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 (G11G14) -----------------------------------------
#[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"
);
}