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:
parent
bdc961501d
commit
378066470e
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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'/'))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'/'))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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:* 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.
|
||||
|
|
@ -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 `<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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"] }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 `&` — 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("&", "&"))
|
||||
}
|
||||
|
||||
/// `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&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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 (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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue