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"
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1173,6 +1175,7 @@ dependencies = [
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"realfft",
|
"realfft",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
|
"rssdy",
|
||||||
"serde",
|
"serde",
|
||||||
"soundclouddy",
|
"soundclouddy",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -1634,6 +1637,23 @@ dependencies = [
|
||||||
"getrandom 0.4.3",
|
"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]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
@ -2851,6 +2871,9 @@ name = "mediatype"
|
||||||
version = "0.21.0"
|
version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
|
checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
|
|
@ -3698,6 +3721,16 @@ dependencies = [
|
||||||
"pulldown-cmark",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.11"
|
version = "0.11.11"
|
||||||
|
|
@ -4249,6 +4282,22 @@ dependencies = [
|
||||||
"cc",
|
"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]]
|
[[package]]
|
||||||
name = "rstml"
|
name = "rstml"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -5901,6 +5950,7 @@ dependencies = [
|
||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ members = [
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"fyyd",
|
"fyyd",
|
||||||
"jamendody",
|
"jamendody",
|
||||||
|
"rssdy",
|
||||||
"soundclouddy",
|
"soundclouddy",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
"ytdy",
|
"ytdy",
|
||||||
|
|
@ -44,6 +45,7 @@ http = "1"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
leptos = { version = "0.8", default-features = false, features = ["csr"] }
|
leptos = { version = "0.8", default-features = false, features = ["csr"] }
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
|
feed-rs = "2"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
|
@ -114,6 +116,7 @@ crabidy-server = { path = "crabidy-server", default-features = false }
|
||||||
fsdy = { path = "fsdy" }
|
fsdy = { path = "fsdy" }
|
||||||
fyyd = { path = "fyyd" }
|
fyyd = { path = "fyyd" }
|
||||||
jamendody = { path = "jamendody" }
|
jamendody = { path = "jamendody" }
|
||||||
|
rssdy = { path = "rssdy" }
|
||||||
soundclouddy = { path = "soundclouddy" }
|
soundclouddy = { path = "soundclouddy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
ytdy = { path = "ytdy" }
|
ytdy = { path = "ytdy" }
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ each mounted as a subtree of one library:
|
||||||
├── fs a local music folder (fsdy/README.md)
|
├── fs a local music folder (fsdy/README.md)
|
||||||
├── fyyd podcast search (fyyd/README.md)
|
├── fyyd podcast search (fyyd/README.md)
|
||||||
├── jamendo Creative-Commons music (jamendody/README.md)
|
├── jamendo Creative-Commons music (jamendody/README.md)
|
||||||
|
├── rss podcast subscriptions (rssdy/README.md)
|
||||||
├── soundcloud SoundCloud (soundclouddy/README.md)
|
├── soundcloud SoundCloud (soundclouddy/README.md)
|
||||||
├── tidal Tidal streaming (tidaldy/README.md)
|
├── tidal Tidal streaming (tidaldy/README.md)
|
||||||
├── youtube YouTube search & playlists (ytdy/README.md)
|
├── youtube YouTube search & playlists (ytdy/README.md)
|
||||||
|
|
@ -86,6 +87,7 @@ filled in.
|
||||||
| `fsdy.toml` | local files | — |
|
| `fsdy.toml` | local files | — |
|
||||||
| `fyyd.toml` | podcasts | none needed |
|
| `fyyd.toml` | podcasts | none needed |
|
||||||
| `jamendo.toml` | Jamendo | none (key shipped) |
|
| `jamendo.toml` | Jamendo | none (key shipped) |
|
||||||
|
| `rss.toml` | podcast feeds | the feed URLs |
|
||||||
| `soundcloud.toml` | SoundCloud | optional token |
|
| `soundcloud.toml` | SoundCloud | optional token |
|
||||||
| `tidaly.toml` | Tidal | device login |
|
| `tidaly.toml` | Tidal | device login |
|
||||||
| `ytdy.toml` | YouTube | optional cookies |
|
| `ytdy.toml` | YouTube | optional cookies |
|
||||||
|
|
@ -154,7 +156,7 @@ On first start the server writes this file with every provider enabled:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
providers = [
|
providers = [
|
||||||
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo",
|
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", "rss",
|
||||||
"fs", "crabidy", "orphans",
|
"fs", "crabidy", "orphans",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ pub struct RpcClient {
|
||||||
/// walks on the server; always refetch them. Remote provider nodes (tidal,
|
/// walks on the server; always refetch them. Remote provider nodes (tidal,
|
||||||
/// youtube) keep the cache that makes back-navigation instant.
|
/// youtube) keep the cache that makes back-navigation instant.
|
||||||
fn is_cacheable(path: &str) -> bool {
|
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| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
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
|
/// store on every visit) all change server-side, so their listings are
|
||||||
/// always refetched.
|
/// always refetched.
|
||||||
pub fn is_cacheable(path: &str) -> bool {
|
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| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ all-providers = [
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
"jamendo",
|
"jamendo",
|
||||||
|
"rss",
|
||||||
"fs",
|
"fs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ fyyd = ["dep:fyyd", "_any-provider"]
|
||||||
abs = ["dep:absdy", "_any-provider"]
|
abs = ["dep:absdy", "_any-provider"]
|
||||||
soundcloud = ["dep:soundclouddy", "_any-provider"]
|
soundcloud = ["dep:soundclouddy", "_any-provider"]
|
||||||
jamendo = ["dep:jamendody", "_any-provider"]
|
jamendo = ["dep:jamendody", "_any-provider"]
|
||||||
|
rss = ["dep:rssdy", "_any-provider"]
|
||||||
# Local files *and* persistent state (D5): the `/fs` mount, the content
|
# Local files *and* persistent state (D5): the `/fs` mount, the content
|
||||||
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
|
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
|
||||||
# persistence, and the `scan` command. Off means the server keeps its
|
# 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 }
|
fsdy = { workspace = true, optional = true }
|
||||||
fyyd = { workspace = true, optional = true }
|
fyyd = { workspace = true, optional = true }
|
||||||
jamendody = { workspace = true, optional = true }
|
jamendody = { workspace = true, optional = true }
|
||||||
|
rssdy = { workspace = true, optional = true }
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
reqwest = { workspace = true, optional = true }
|
reqwest = { workspace = true, optional = true }
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,8 @@ impl Mount {
|
||||||
feature = "fyyd",
|
feature = "fyyd",
|
||||||
feature = "abs",
|
feature = "abs",
|
||||||
feature = "soundcloud",
|
feature = "soundcloud",
|
||||||
feature = "jamendo"
|
feature = "jamendo",
|
||||||
|
feature = "rss"
|
||||||
))]
|
))]
|
||||||
async fn mount_from_config<C>(
|
async fn mount_from_config<C>(
|
||||||
config_dir: &std::path::Path,
|
config_dir: &std::path::Path,
|
||||||
|
|
@ -461,6 +462,8 @@ impl ProviderOrchestrator {
|
||||||
// (architecture/soundcloud-provider.md D1).
|
// (architecture/soundcloud-provider.md D1).
|
||||||
// - Jamendo: needs a registered `client_id`; without one it is
|
// - Jamendo: needs a registered `client_id`; without one it is
|
||||||
// disabled (architecture/jamendo-provider.md D1).
|
// 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")]
|
#[cfg(feature = "youtube")]
|
||||||
if enabled.youtube {
|
if enabled.youtube {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
|
|
@ -509,6 +512,18 @@ impl ProviderOrchestrator {
|
||||||
.await,
|
.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")]
|
#[cfg(feature = "jamendo")]
|
||||||
if enabled.jamendo {
|
if enabled.jamendo {
|
||||||
mounts.extend(
|
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
|
/// This is the *vocabulary*, not what this binary can mount — see
|
||||||
/// [`BUILT_IN_PROVIDERS`].
|
/// [`BUILT_IN_PROVIDERS`].
|
||||||
pub const ALL_PROVIDERS: [&str; 9] = [
|
pub const ALL_PROVIDERS: [&str; 10] = [
|
||||||
"tidal",
|
"tidal",
|
||||||
"youtube",
|
"youtube",
|
||||||
"fyyd",
|
"fyyd",
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
"jamendo",
|
"jamendo",
|
||||||
|
"rss",
|
||||||
"fs",
|
"fs",
|
||||||
"crabidy",
|
"crabidy",
|
||||||
"orphans",
|
"orphans",
|
||||||
|
|
@ -53,6 +54,8 @@ pub const BUILT_IN_PROVIDERS: &[&str] = &[
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
#[cfg(feature = "jamendo")]
|
#[cfg(feature = "jamendo")]
|
||||||
"jamendo",
|
"jamendo",
|
||||||
|
#[cfg(feature = "rss")]
|
||||||
|
"rss",
|
||||||
#[cfg(feature = "fs")]
|
#[cfg(feature = "fs")]
|
||||||
"fs",
|
"fs",
|
||||||
#[cfg(feature = "fs")]
|
#[cfg(feature = "fs")]
|
||||||
|
|
@ -110,6 +113,7 @@ pub struct ProviderToggles {
|
||||||
pub abs: bool,
|
pub abs: bool,
|
||||||
pub soundcloud: bool,
|
pub soundcloud: bool,
|
||||||
pub jamendo: bool,
|
pub jamendo: bool,
|
||||||
|
pub rss: bool,
|
||||||
pub fs: bool,
|
pub fs: bool,
|
||||||
pub crabidy: bool,
|
pub crabidy: bool,
|
||||||
pub orphans: bool,
|
pub orphans: bool,
|
||||||
|
|
@ -128,6 +132,7 @@ impl ProviderToggles {
|
||||||
abs: cfg!(feature = "abs"),
|
abs: cfg!(feature = "abs"),
|
||||||
soundcloud: cfg!(feature = "soundcloud"),
|
soundcloud: cfg!(feature = "soundcloud"),
|
||||||
jamendo: cfg!(feature = "jamendo"),
|
jamendo: cfg!(feature = "jamendo"),
|
||||||
|
rss: cfg!(feature = "rss"),
|
||||||
fs: cfg!(feature = "fs"),
|
fs: cfg!(feature = "fs"),
|
||||||
// Both live on the content store, which the `fs` feature brings
|
// Both live on the content store, which the `fs` feature brings
|
||||||
// (D5).
|
// (D5).
|
||||||
|
|
@ -260,6 +265,7 @@ impl ServerSettings {
|
||||||
abs: self.provider_enabled("abs"),
|
abs: self.provider_enabled("abs"),
|
||||||
soundcloud: self.provider_enabled("soundcloud"),
|
soundcloud: self.provider_enabled("soundcloud"),
|
||||||
jamendo: self.provider_enabled("jamendo"),
|
jamendo: self.provider_enabled("jamendo"),
|
||||||
|
rss: self.provider_enabled("rss"),
|
||||||
fs: self.provider_enabled("fs"),
|
fs: self.provider_enabled("fs"),
|
||||||
crabidy: self.provider_enabled("crabidy"),
|
crabidy: self.provider_enabled("crabidy"),
|
||||||
orphans: self.provider_enabled("orphans"),
|
orphans: self.provider_enabled("orphans"),
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ in
|
||||||
test_it -p crabidy-server --no-default-features
|
test_it -p crabidy-server --no-default-features
|
||||||
|
|
||||||
# Each provider on its own: nothing else may be needed to compile it.
|
# 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"
|
clippy -p crabidy-server --no-default-features --features "$feature"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
- [YouTube — /youtube](./providers/youtube.md)
|
- [YouTube — /youtube](./providers/youtube.md)
|
||||||
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
|
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
|
||||||
- [Jamendo — /jamendo](./providers/jamendo.md)
|
- [Jamendo — /jamendo](./providers/jamendo.md)
|
||||||
|
- [RSS — /rss](./providers/rss.md)
|
||||||
- [audiobookshelf — /abs](./providers/abs.md)
|
- [audiobookshelf — /abs](./providers/abs.md)
|
||||||
- [fyyd — /fyyd](./providers/fyyd.md)
|
- [fyyd — /fyyd](./providers/fyyd.md)
|
||||||
- [Search](./providers/search.md)
|
- [Search](./providers/search.md)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ which the server now also writes on first start.
|
||||||
| `fsdy.toml` | local files | none |
|
| `fsdy.toml` | local files | none |
|
||||||
| `fyyd.toml` | podcasts | none |
|
| `fyyd.toml` | podcasts | none |
|
||||||
| `jamendo.toml` | Jamendo | none (a key is shipped) |
|
| `jamendo.toml` | Jamendo | none (a key is shipped) |
|
||||||
|
| `rss.toml` | podcast feeds | the feed URLs themselves |
|
||||||
| `soundcloud.toml` | SoundCloud | none (token optional) |
|
| `soundcloud.toml` | SoundCloud | none (token optional) |
|
||||||
| `tidaly.toml` | Tidal | device login (interactive) |
|
| `tidaly.toml` | Tidal | device login (interactive) |
|
||||||
| `ytdy.toml` | YouTube | none (cookies optional) |
|
| `ytdy.toml` | YouTube | none (cookies optional) |
|
||||||
|
|
@ -57,6 +58,7 @@ providers = [
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
"jamendo",
|
"jamendo",
|
||||||
|
"rss",
|
||||||
"fs",
|
"fs",
|
||||||
"crabidy",
|
"crabidy",
|
||||||
"orphans",
|
"orphans",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ abs: "/abs — audiobookshelf audiobooks"
|
||||||
fs: "/fs — a local music folder"
|
fs: "/fs — a local music folder"
|
||||||
fyyd: "/fyyd — podcast search"
|
fyyd: "/fyyd — podcast search"
|
||||||
jamendo: "/jamendo — Creative-Commons catalogue"
|
jamendo: "/jamendo — Creative-Commons catalogue"
|
||||||
|
rss: "/rss — podcast subscriptions"
|
||||||
soundcloud: "/soundcloud — SoundCloud"
|
soundcloud: "/soundcloud — SoundCloud"
|
||||||
tidal: "/tidal — Tidal streaming"
|
tidal: "/tidal — Tidal streaming"
|
||||||
youtube: "/youtube — YouTube search & playlists"
|
youtube: "/youtube — YouTube search & playlists"
|
||||||
|
|
@ -31,6 +32,7 @@ root -> abs: "route /abs/*"
|
||||||
root -> fs: "route /fs/*"
|
root -> fs: "route /fs/*"
|
||||||
root -> fyyd: "route /fyyd/*"
|
root -> fyyd: "route /fyyd/*"
|
||||||
root -> jamendo: "route /jamendo/*"
|
root -> jamendo: "route /jamendo/*"
|
||||||
|
root -> rss: "route /rss/*"
|
||||||
root -> soundcloud: "route /soundcloud/*"
|
root -> soundcloud: "route /soundcloud/*"
|
||||||
root -> tidal: "route /tidal/*"
|
root -> tidal: "route /tidal/*"
|
||||||
root -> youtube: "route /youtube/*"
|
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
|
- **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of
|
||||||
Creative-Commons music, by search or album. Works out of the box on a
|
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`).
|
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
|
- **[`/soundcloud`](./providers/soundcloud.md)** — SoundCloud search, link
|
||||||
resolving, and playback with no credentials at all; an optional token adds
|
resolving, and playback with no credentials at all; an optional token adds
|
||||||
your likes and playlists (`soundcloud.toml`).
|
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
|
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
|
and in a browser — the pure state machines are unit-tested and both render
|
||||||
paths compile.
|
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