From 3b81faeb9db107843554a814767b0a51855d3c1c Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 17:29:13 +0200 Subject: [PATCH] Add the fyyd podcast provider (/fyyd) A new library provider for finding and playing podcasts via fyyd's keyless public API (api.fyyd.de), mounted at /fyyd and modelled on ytdy. A podcast search returns podcasts, each a container of episodes, so the tree carries one extra level: search-term -> podcast -> episodes-as-tracks, plus a fixed /fyyd/hot featured browse. An episode is a track whose enclosure URL the audio player streams directly -- no sidecar, no proto change, no new ProviderCommand. Search terms are creatable/renamable/ deletable in memory like tidal and youtube; podcasts and their episode lists are queueable and downloadable (W captures work out of the box). All network access goes through a Fyyd trait (fyyd/src/api.rs), faked in tests, so the provider logic runs with no network. Init is non-fatal and needs no credentials; every call is timeout-bounded and every listing capped. Wired into ProviderOrchestrator and the crabidy-server provider toggles alongside the other providers. Dev-flow artifacts: architecture/, quality/, and plan/fyyd-provider.md, plus a plan/summary.md entry. Docs updated across docs/src and the README. Deferred: live validation of the api.fyyd.de field shapes (offline unit suite cannot cover it) -- left as an open gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 15 + Cargo.toml | 2 + README.md | 4 +- architecture/fyyd-provider.md | 216 +++++++++ crabidy-server/Cargo.toml | 1 + crabidy-server/src/provider.rs | 80 ++++ crabidy-server/src/settings.rs | 7 +- docs/src/SUMMARY.md | 1 + docs/src/config.md | 9 +- docs/src/providers.md | 5 + docs/src/providers/fyyd.md | 78 ++++ fyyd/Cargo.toml | 17 + fyyd/README.md | 42 ++ fyyd/src/api.rs | 265 +++++++++++ fyyd/src/lib.rs | 832 +++++++++++++++++++++++++++++++++ plan/fyyd-provider.md | 39 ++ plan/summary.md | 55 +++ quality/fyyd-provider.md | 71 +++ 18 files changed, 1732 insertions(+), 7 deletions(-) create mode 100644 architecture/fyyd-provider.md create mode 100644 docs/src/providers/fyyd.md create mode 100644 fyyd/Cargo.toml create mode 100644 fyyd/README.md create mode 100644 fyyd/src/api.rs create mode 100644 fyyd/src/lib.rs create mode 100644 plan/fyyd-provider.md create mode 100644 quality/fyyd-provider.md diff --git a/Cargo.lock b/Cargo.lock index 23aab45..b73f66f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1149,6 +1149,7 @@ dependencies = [ "flume", "fsdy", "futures", + "fyyd", "http", "include_dir", "rand 0.10.2", @@ -1822,6 +1823,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fyyd" +version = "0.1.0" +dependencies = [ + "async-trait", + "crabidy-core", + "reqwest 0.13.1", + "serde", + "thiserror 2.0.19", + "tokio", + "toml", + "tracing", +] + [[package]] name = "generic-array" version = "0.14.7" diff --git a/Cargo.toml b/Cargo.toml index 2b17e62..c787c36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crabidy-core", "crabidy-server", "fsdy", + "fyyd", "tidaldy", "ytdy", ] @@ -99,5 +100,6 @@ cbd-tui = { path = "cbd-tui" } crabidy-core = { path = "crabidy-core" } crabidy-server = { path = "crabidy-server" } fsdy = { path = "fsdy" } +fyyd = { path = "fyyd" } tidaldy = { path = "tidaldy" } ytdy = { path = "ytdy" } diff --git a/README.md b/README.md index b98b5ba..6f6f0b5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ each mounted as a subtree of one library: / ├── tidal Tidal streaming (see tidaldy/README.md) ├── youtube YouTube search & playlists (see ytdy/README.md) +├── fyyd podcast search (see fyyd/README.md) ├── fs a local music folder (see fsdy/README.md) ├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`), │ managed by the server (see architecture/crabidy-store.md) @@ -54,6 +55,7 @@ filled in. | -------------------- | ---------- | -------------------------------------- | | `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) | | `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) | +| `fyyd.toml` | podcasts | [fyyd/README.md](fyyd/README.md) | | `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | | `cbd-tui.toml` | `cbd-tui` | below | | `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) | @@ -110,7 +112,7 @@ cbd-tui auth queue-owner 'pw' --address http://pi:50051 On first start the server writes this file with every provider enabled: ```toml -providers = ["tidal", "youtube", "fs", "crabidy", "orphans"] +providers = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"] ``` **Remove a name to disable that provider** — it no longer mounts and does diff --git a/architecture/fyyd-provider.md b/architecture/fyyd-provider.md new file mode 100644 index 0000000..4bbd385 --- /dev/null +++ b/architecture/fyyd-provider.md @@ -0,0 +1,216 @@ +# fyyd provider (podcasts) + +## Context and problem statement + +A new library provider mounted at `/fyyd` that lets a user **find and play +podcasts**, backed by [fyyd](https://fyyd.de)'s public search engine. + +- **Search** works with no account — creatable search-term nodes exactly + like `/tidal/search` and `/youtube/search` (`%` creates a term, results + appear underneath). +- Unlike YouTube, a podcast search does **not** return tracks directly: it + returns *podcasts*, each of which is a container of *episodes*. So the + tree carries **one extra level** — `search-term → podcast → episodes` — + where YouTube is `search-term → tracks`. +- **Playing** an episode means streaming its `enclosure` URL (the plain + HTTP(S) audio file from the podcast's RSS feed). That is exactly the + URL-returning shape the audio player already handles; no sidecar, no + cipher solving, no byte-proxying (contrast `/youtube`). +- **Captures** (`W`, download) come for free: any node that serves tracks + and raises `is_downloadable` gets `w`/`W` with no wire or TUI work. + +## The fyyd API (grounding) + +Base `https://api.fyyd.de/0.2/`, no key or auth for search and browse. +Responses are wrapped in a JSON envelope `{ "status", "msg", "data", … }`; +list endpoints add `meta.paging`. The endpoints we use: + +| Purpose | Endpoint | +| --- | --- | +| Search podcasts | `GET /search/podcast?term=&count=` | +| Podcast episodes | `GET /podcast/episodes?podcast_id=&count=` | +| Hot (featured) podcasts | `GET /feature/podcast/hot?count=` | +| Single episode | `GET /episode?episode_id=` | + +Objects (fields we read): + +- **podcast**: `id` (int), `title`, `xmlURL`, `imgURL`, `description`, + `language`. +- **episode**: `id` (int), `title`, `guid`, **`enclosure`** (audio URL), + `podcast_id`, `duration` (seconds), `pubdate`. + +The episode's `enclosure` is the only field playback needs. The single +episode endpoint does not carry the podcast's title, so an episode fetched +on its own has no "artist" until we look the podcast up (see D4). + +## Assumptions (decided here) + +- The captures/creatable/editable/deletable TUI flows are provider-agnostic + (confirmed by `/youtube`): mirroring tidal's search-term semantics costs + no TUI or wire change. No proto change, no new `ProviderCommand`. +- fyyd's public API needs no credentials, so — unlike tidal — a missing or + empty `fyyd.toml` is the normal case, and init never needs a login. The + provider is non-fatal at startup like the local providers: a build/parse + failure only costs the `/fyyd` subtree. +- The audio player streams plain https `enclosure` URLs directly + (`audio-player` windowed-HTTP path). Podcast enclosures are ordinary + media files, so no `/youtube`-style URL-lifetime or ~1 MiB-cap problem. +- Episodes are addressed by fyyd's stable numeric `id` (as a string + segment). We do not use the RSS `guid` as the path key — the fyyd id is + shorter, already URL-safe, and is what the episode endpoint takes. + +## Decisions + +### D1 — Crate `fyyd`, mounted at `/fyyd`, non-fatal init + +New workspace crate `fyyd` implementing `ProviderClient`, shaped on `ytdy` +(the closest analog: remote, search-driven, in-memory terms). Wired into +`ProviderOrchestrator` with a `fyyd_client: Option>` +field, `fyyd_owns()`/`fyyd_provider()` helpers, a `build()` block that +reads `fyyd.toml` (non-fatal), a `get_lib_root` child gated on +`self.fyyd_client.is_some()`, and one routing arm in each dispatch method. +`crabidy-server`'s settings gain `fyyd` in `ALL_PROVIDERS`, in +`ProviderToggles`, in `all()`, and in `provider_toggles()`. + +### D2 — HTTP behind a trait, faked in tests + +All network access goes through one seam — a `Fyyd` trait (`search_podcasts`, +`hot_podcasts`, `podcast_episodes`, `episode`) behind `Box` — with +a `reqwest`-based `FyydApi` for production and a `FakeApi` in tests, exactly +as `ytdy` hides `rustypipe` behind `Extract`. Provider logic (tree shaping, +path parsing, term store) is then unit-tested with zero network. The trait's +error type maps to `ProviderError::FetchError` at the boundary; malformed +paths are `MalformedPath`; empty create/rename input is `InvalidInput`. + +### D3 — Tree shape (the extra level) + +- `/fyyd` — children: `search` (always, `is_creatable`), `hot` (always, a + fixed featured-podcasts browse so the provider is useful with zero + typing). +- `/fyyd/search` — `is_creatable`; children are the in-memory search terms + (`RwLock>`, dedup, recreated implicitly on stale paths), + each `is_editable` + `is_deletable`, like tidal/youtube. +- `/fyyd/search/` — lists the top-N matching **podcasts** as + children (containers, not tracks). Not directly queueable itself; each + podcast child *is* queueable. +- `/fyyd/search//` — lists that podcast's episodes as + **tracks**; queueable and downloadable (homogeneous tracks). Queueing the + podcast node enqueues its listed episodes (bounded, see D5). +- `/fyyd/search///` — the track leaf. +- `/fyyd/hot` — lists featured podcasts as children (same podcast shape as + a search-term node, but the list comes from `/feature/podcast/hot` + instead of a term). +- `/fyyd/hot/` and `/fyyd/hot//` — + identical podcast/episode shapes as under `search`; the two branches + share the podcast-node and episode-leaf builders. + +Terms are percent-encoded into one segment (`encode_segment`/ +`decode_segment`); podcast and episode ids are already URL-safe integers. + +### D4 — Streams, metadata, artist + +- `get_urls_for_track`: parse the episode id from the path → + `episode(id)` → `vec![enclosure]`. An empty/missing enclosure is + `FetchError`, not a panic; the queue skips it. +- Track fields: `title` = episode title, `artist` = the **podcast** title, + `duration` = episode duration (seconds → `Option`), + `provider_item_id` = fyyd episode id (keys the content store for + captures), `album` = `None`. +- **Artist source.** When episodes are listed under a podcast we already + hold the podcast title, so listed tracks get the right artist for free. + A *directly* fetched episode (`get_metadata_for_track` on a bare episode + path, or a stream resolve) does not carry the podcast title from the + episode endpoint; the `FyydApi` fills it with one extra `/podcast` + lookup, and the `Episode` model carries `artist: Option` so the + fake can supply it in tests. A missing artist degrades to an empty + string, never an error. + +### D5 — Bounds and freshness + +- `search_results` (podcasts per term, default 20), `hot_count` (default + 20), and `episodes_per_podcast` (default 100) are configurable in + `fyyd.toml`; every listing is capped so a huge podcast cannot stall the + library or queue resolution. A `call_timeout_secs` (default 30) bounds + each HTTP call (hard rule: timeouts on external calls). +- Listings are fetched fresh per call (no cross-call cache), like tidal's + and youtube's fresh remote calls. Only the search *terms* are stored, in + memory. + +### D6 — Out of scope (explicitly) + +- fyyd user accounts, OAuth, subscriptions, personal collections, or the + "hot languages" / category browse — search + hot cover the "find and + play" ask. +- Episode chapters, transcripts, or per-episode images in the queue. +- Pagination past the configured caps (one page is fetched per listing). +- Caching or persisting episodes to disk beyond the existing `W` captures. + +## Structure + +```d2 +direction: right + +server: crabidy-server { + orch: ProviderOrchestrator +} + +fyyd: "fyyd (crate)" { + client: "Client\n(ProviderClient)" + terms: "search terms\n(in-memory, like tidal)" + api: "FyydApi\n(reqwest seam: Fyyd trait)" + client -> terms + client -> api +} + +svc: "api.fyyd.de\n(public, keyless)" { shape: cloud } +cdn: "podcast enclosure\n(RSS media host)" { shape: cloud } +player: audio-player { shape: hexagon } + +server.orch -> fyyd.client: "/fyyd/..." +fyyd.api -> svc: "search / episodes / hot (JSON, timeout)" +server.orch -> player: "enclosure URL" +player -> cdn: "windowed HTTP stream" +``` + +## Key flow: search a podcast, play an episode + +```d2 +shape: sequence_diagram +tui: TUI +orch: Orchestrator +f: fyyd +api: api.fyyd.de + +tui -> orch: "% on /fyyd/search: 'history'" +orch -> f: "create_lib_node" +f -> api: "GET /search/podcast?term=history" +api -> f: "podcasts (id, title)" +f -> tui: "term node: podcasts as children" +tui -> orch: "open a podcast" +orch -> f: "get_lib_node(/fyyd/search/history/)" +f -> api: "GET /podcast/episodes?podcast_id=" +api -> f: "episodes (id, title, enclosure, duration)" +f -> tui: "podcast node: episodes as tracks (queueable)" +tui -> orch: "queue + play an episode" +orch -> f: "get_urls_for_track(.../)" +f -> api: "GET /episode?episode_id=" +api -> f: "enclosure URL" +orch -> orch: "player streams the enclosure" +``` + +## Risks and open questions + +- **fyyd envelope / field drift.** The `data` envelope and field names + (`enclosure`, `xmlURL`, numeric `id`) are read from the current public + docs but were not live-validated during design. The `FyydApi` decodes + defensively (serde with `#[serde(default)]`, missing fields degrade, no + panic) and every failure is a typed `FetchError`; if a field name is + wrong the fix is local to `FyydApi`'s DTOs. **Live validation is a + task-plan gate.** +- **Episode enclosure availability / expiry.** Some feeds proxy or expire + enclosures; a dead URL surfaces as a skipped track, never a crash. +- **Artist double-fetch.** Filling a directly-fetched episode's artist + costs one extra `/podcast` call; acceptable because direct metadata + fetches are rare (listing is the common path and already has the title). +- **Rate limits.** fyyd does not document limits; the per-call timeout and + the absence of background polling keep request volume to user actions. diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index aceb7b8..9858844 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -34,6 +34,7 @@ crabidy-core.workspace = true dirs.workspace = true flume.workspace = true fsdy.workspace = true +fyyd.workspace = true futures.workspace = true rand.workspace = true reqwest.workspace = true diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index a56e7ab..b8aa1c1 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -37,6 +37,9 @@ pub struct ProviderOrchestrator { /// The YouTube provider (yt-dlp backed); `None` when the binary /// probe failed at init (architecture/youtube-provider.md D2). youtube_client: Option>, + /// The fyyd podcast provider; `None` when disabled or its client failed + /// to build (non-fatal, architecture/fyyd-provider.md D1). + fyyd_client: Option>, } /// Whether a path belongs to the filesystem provider. @@ -60,6 +63,11 @@ fn orphans_owns(path: &str) -> bool { path == ORPHANS_PROVIDER_ROOT || path.starts_with("/orphans/") } +/// Whether a path belongs to the fyyd podcast provider. +fn fyyd_owns(path: &str) -> bool { + path == fyyd::PROVIDER_ROOT || path.starts_with("/fyyd/") +} + impl ProviderOrchestrator { /// The tidal client, or `MalformedPath` (with a warning) when the /// provider is disabled — a `/tidal` path then has no owner. @@ -110,6 +118,15 @@ impl ProviderOrchestrator { ProviderError::MalformedPath }) } + + /// The fyyd client, or `MalformedPath` (with a warning) when the + /// provider is disabled — a `/fyyd` path then has no owner. + fn fyyd_provider(&self) -> Result<&fyyd::Client, ProviderError> { + self.fyyd_client.as_deref().ok_or_else(|| { + warn!("fyyd provider is disabled"); + ProviderError::MalformedPath + }) + } pub fn run(self) { tokio::spawn(async move { // Behind an Arc so long-running resolves can be spawned onto @@ -379,6 +396,28 @@ impl ProviderOrchestrator { } else { None }; + // fyyd: non-fatal like the other remote providers — a client that + // cannot be built only costs the `/fyyd` subtree. Needs no + // credentials, so a missing `fyyd.toml` is the normal case. + let fyyd_client = if enabled.fyyd { + let fyyd_config_file = config_dir.join("fyyd.toml"); + debug!(config_file = %fyyd_config_file.display(), "loading fyyd config"); + let raw_fyyd_settings = fs::read_to_string(&fyyd_config_file).unwrap_or_default(); + match fyyd::Client::init(&raw_fyyd_settings).await { + Ok(client) => { + if let Err(err) = tokio::fs::write(&fyyd_config_file, client.settings()).await { + error!("failed to write fyyd config file: {err}"); + } + Some(Arc::new(client)) + } + Err(err) => { + warn!("fyyd provider disabled: {err}"); + None + } + } + } else { + None + }; let (provider_tx, provider_rx) = flume::bounded(100); Ok(Self { provider_rx, @@ -389,6 +428,7 @@ impl ProviderOrchestrator { crabidy_store, orphans_client, youtube_client, + fyyd_client, }) } } @@ -437,6 +477,12 @@ impl ProviderClient for ProviderOrchestrator { // Orphans are addressed as nodes; there are no track paths. return false; } + if fyyd_owns(path) { + return self + .fyyd_client + .as_ref() + .is_some_and(|fyyd| fyyd.is_track_path(path)); + } false } @@ -466,6 +512,9 @@ impl ProviderClient for ProviderOrchestrator { .get_urls_for_track(track_path) .await; } + if fyyd_owns(track_path) { + return self.fyyd_provider()?.get_urls_for_track(track_path).await; + } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } @@ -499,6 +548,12 @@ impl ProviderClient for ProviderOrchestrator { .get_metadata_for_track(track_path) .await; } + if fyyd_owns(track_path) { + return self + .fyyd_provider()? + .get_metadata_for_track(track_path) + .await; + } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } @@ -536,6 +591,11 @@ impl ProviderClient for ProviderOrchestrator { ); root_node.children.push(child); } + if self.fyyd_client.is_some() { + let child = + LibraryNodeChild::new(fyyd::PROVIDER_ROOT.to_owned(), "fyyd".to_owned(), false); + root_node.children.push(child); + } root_node } @@ -555,6 +615,8 @@ impl ProviderClient for ProviderOrchestrator { self.youtube_provider()?.get_lib_node(path).await? } else if orphans_owns(path) { self.orphans_provider()?.get_lib_node(path).await? + } else if fyyd_owns(path) { + self.fyyd_provider()?.get_lib_node(path).await? } else { warn!(path, "no provider owns this path"); return Err(ProviderError::MalformedPath); @@ -605,6 +667,12 @@ impl ProviderClient for ProviderOrchestrator { .create_lib_node(parent_path, title) .await; } + if fyyd_owns(parent_path) { + return self + .fyyd_provider()? + .create_lib_node(parent_path, title) + .await; + } warn!(parent_path, "no provider supports creating nodes here"); Err(ProviderError::NotSupported) } @@ -644,6 +712,9 @@ impl ProviderClient for ProviderOrchestrator { .rename_lib_node(path, new_title) .await; } + if fyyd_owns(path) { + return self.fyyd_provider()?.rename_lib_node(path, new_title).await; + } warn!(path, "no provider supports renaming this node"); Err(ProviderError::NotSupported) } @@ -686,6 +757,12 @@ impl ProviderClient for ProviderOrchestrator { .resolve_tracks_into(path, chunk_tx) .await; } + if fyyd_owns(path) { + return self + .fyyd_provider()? + .resolve_tracks_into(path, chunk_tx) + .await; + } warn!(path, "no provider owns this path"); Err(ProviderError::MalformedPath) } @@ -709,6 +786,9 @@ impl ProviderClient for ProviderOrchestrator { if orphans_owns(path) { return self.orphans_provider()?.delete_lib_node(path).await; } + if fyyd_owns(path) { + return self.fyyd_provider()?.delete_lib_node(path).await; + } warn!(path, "no provider supports deleting this node"); Err(ProviderError::NotSupported) } diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index 76eb974..b3bfa54 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -16,9 +16,9 @@ use serde::{Deserialize, Serialize}; pub const SETTINGS_FILE: &str = "crabidy-server.toml"; /// Every built-in provider, in the order the default config lists them. Each -/// name is a library root (`/tidal`, `/youtube`, `/fs`, `/crabidy`, +/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/fs`, `/crabidy`, /// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`. -pub const ALL_PROVIDERS: [&str; 5] = ["tidal", "youtube", "fs", "crabidy", "orphans"]; +pub const ALL_PROVIDERS: [&str; 6] = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"]; /// Contents of `crabidy-server.toml`. #[derive(Debug, Default, Deserialize, Serialize)] @@ -41,6 +41,7 @@ pub struct ServerSettings { pub struct ProviderToggles { pub tidal: bool, pub youtube: bool, + pub fyyd: bool, pub fs: bool, pub crabidy: bool, pub orphans: bool, @@ -53,6 +54,7 @@ impl ProviderToggles { Self { tidal: true, youtube: true, + fyyd: true, fs: true, crabidy: true, orphans: true, @@ -158,6 +160,7 @@ impl ServerSettings { ProviderToggles { tidal: self.provider_enabled("tidal"), youtube: self.provider_enabled("youtube"), + fyyd: self.provider_enabled("fyyd"), fs: self.provider_enabled("fs"), crabidy: self.provider_enabled("crabidy"), orphans: self.provider_enabled("orphans"), diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 5a0f370..549e331 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,6 +7,7 @@ - [Filesystem — /fs](./providers/fs.md) - [Tidal — /tidal](./providers/tidal.md) - [YouTube — /youtube](./providers/youtube.md) + - [fyyd — /fyyd](./providers/fyyd.md) - [Search](./providers/search.md) - [The crabidy store](./store.md) - [Queue and playback](./queue.md) diff --git a/docs/src/config.md b/docs/src/config.md index 43490a7..0b0894b 100644 --- a/docs/src/config.md +++ b/docs/src/config.md @@ -19,14 +19,15 @@ which the server now also writes on first start. | --------------------- | ---------- | ------------ | | `tidaly.toml` | Tidal | yes | | `ytdy.toml` | YouTube | yes | +| `fyyd.toml` | podcasts | yes | | `fsdy.toml` | local fs | yes | | `cbd-tui.toml` | `cbd-tui` | yes | | `cbd.toml` | `cbd` | yes | | `crabidy-server.toml` | server | yes | -- `tidaly.toml`, `ytdy.toml`, and `fsdy.toml` configure the three media - providers — Tidal, YouTube, and a local music folder (its filesystem - root). See [Providers](./providers.md). +- `tidaly.toml`, `ytdy.toml`, `fyyd.toml`, and `fsdy.toml` configure the + media providers — Tidal, YouTube, fyyd podcasts, and a local music + folder (its filesystem root). See [Providers](./providers.md). - `cbd-tui.toml` and `cbd.toml` are client configs (below). - `crabidy-server.toml` holds the enabled-providers list (below) and server auth (see [Roles and authorization](./auth.md)). @@ -37,7 +38,7 @@ On first start the server writes `crabidy-server.toml` with every provider enabled: ```toml -providers = ["tidal", "youtube", "fs", "crabidy", "orphans"] +providers = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"] ``` **Remove a name to disable that provider** — it no longer mounts and drops diff --git a/docs/src/providers.md b/docs/src/providers.md index 8e947bc..ab59c32 100644 --- a/docs/src/providers.md +++ b/docs/src/providers.md @@ -18,11 +18,13 @@ root: "/ (orchestrator)" { tidal: "/tidal — Tidal streaming" youtube: "/youtube — YouTube search & playlists" +fyyd: "/fyyd — podcast search" fs: "/fs — a local music folder" crabidy: "/crabidy — your saves & captures" root -> tidal: "route /tidal/*" root -> youtube: "route /youtube/*" +root -> fyyd: "route /fyyd/*" root -> fs: "route /fs/*" root -> crabidy: "route /crabidy/*" ``` @@ -59,6 +61,9 @@ present because the server owns it. - **[`/youtube`](./providers/youtube.md)** — YouTube search and the logged-in account's playlists via the `ytdy` crate. Metadata is extracted in-process; config lives in `ytdy.toml`. +- **[`/fyyd`](./providers/fyyd.md)** — podcast search via the `fyyd` + crate and fyyd's keyless public API: find a podcast, drill into its + episodes, and play them. Config (all optional) lives in `fyyd.toml`. - **[`/fs`](./providers/fs.md)** — serves a local music folder. Folders become nodes and `*.cbd-track.toml` files become tracks. Config lives in `fsdy.toml`. diff --git a/docs/src/providers/fyyd.md b/docs/src/providers/fyyd.md new file mode 100644 index 0000000..1493a21 --- /dev/null +++ b/docs/src/providers/fyyd.md @@ -0,0 +1,78 @@ +# fyyd — /fyyd + + + +The fyyd provider (crate `fyyd`) mounts podcasts under `/fyyd`, backed by +[fyyd](https://fyyd.de)'s public podcast search engine +(`api.fyyd.de`). It needs no account or API key: you search for a +podcast, drill into its episodes, and play them. + +## How it differs from the music providers + +A music search returns tracks. A *podcast* search returns **podcasts**, +and each podcast is a container of **episodes**. So `/fyyd` carries one +level more than `/youtube`: + +```text +/fyyd/search/ → matching podcasts +/fyyd/search// → that podcast's episodes (tracks) +``` + +An episode is an ordinary track whose audio is the `enclosure` URL from +the podcast's feed — a plain media file the built-in player streams +directly. There is no sidecar, cipher solving, or byte-proxying. + +## The tree + +- `/fyyd/search` — always present. Press `%` to create a search term + (see [Search](./search.md)); the term node lists matching podcasts. + Terms are renamable (`e`) and deletable (`d`), live in memory, and are + recreated implicitly if you navigate to an old term path after a + restart. +- `/fyyd/hot` — always present. A fixed browse of fyyd's currently + featured ("hot") podcasts, so the provider is useful with no typing. +- `/fyyd//` — one podcast's episodes as tracks; + queueable and downloadable, so you can queue or capture (`W`) a whole + podcast at once (bounded, see below). + +Track paths end in the fyyd episode id, which is also the wire track's +`provider_item_id` — a stable identity independent of the path, so the +same episode de-duplicates in the crabidy store (see [The crabidy +store](../store.md)). + +## Configuration — `~/.config/crabidy/fyyd.toml` + +fyyd's API needs no credentials, so the file is optional and every +option has a default: + +```toml +# Podcasts listed per search term. Default: 20. +# search_results = 20 + +# Featured podcasts under /fyyd/hot. Default: 20. +# hot_count = 20 + +# Episodes listed per podcast (also a hard cap so a huge back catalogue +# cannot stall the library). Default: 100. +# episodes_per_podcast = 100 + +# Per-request timeout in seconds. Default: 30. +# call_timeout_secs = 30 + +# API base URL override (a mirror, or a test server). Default: +# "https://api.fyyd.de/0.2". +# base_url = "https://api.fyyd.de/0.2" +``` + +Every listing is fetched fresh per request; only your search *terms* are +kept, in memory. A client that fails to build disables only `/fyyd` with +a warning — never the server. + +## Captures + +Podcasts and their episode lists are downloadable: `W` captures a +podcast's episodes into `/crabidy`, the same way it works for any other +provider. + +For the tree shape and design decisions, see +`architecture/fyyd-provider.md` in the source tree. diff --git a/fyyd/Cargo.toml b/fyyd/Cargo.toml new file mode 100644 index 0000000..a6e8110 --- /dev/null +++ b/fyyd/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fyyd" +version.workspace = true +edition.workspace = true + +[dependencies] +async-trait.workspace = true +crabidy-core.workspace = true +reqwest.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["time"] } +toml.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } diff --git a/fyyd/README.md b/fyyd/README.md new file mode 100644 index 0000000..251836c --- /dev/null +++ b/fyyd/README.md @@ -0,0 +1,42 @@ +# fyyd — the podcast provider + +Mounts podcasts at **`/fyyd`** in the crabidy library, backed by +[fyyd](https://fyyd.de)'s public podcast search engine (`api.fyyd.de`). +No account or API key is required. + +## How it works + +A podcast search returns *podcasts*, and each podcast is a container of +*episodes*, so `/fyyd` carries one level more than the music providers: + +- **`/fyyd/search`** works with no login. Press `%` to create a search + term; the term becomes a persistent child node listing the matching + podcasts. Terms are renamable (`e`, re-runs the search) and deletable + (`d`). Terms live in memory — a server restart forgets them, but + navigating to an old term path recreates it. +- **`/fyyd/hot`** is a fixed browse of fyyd's currently featured ("hot") + podcasts, so the provider is useful with no typing. +- **`/fyyd//`** lists one podcast's episodes as tracks. + It is queueable and downloadable, so you can queue or `W`-capture a + whole podcast at once. + +An episode is an ordinary track whose audio is the `enclosure` URL from +the podcast's feed — a plain media file the built-in player streams +directly. No sidecar, cipher solving, or byte-proxying is involved. + +## Configuration — `fyyd.toml` + +All options are optional (fyyd needs no credentials): + +```toml +# search_results = 20 # podcasts per search term +# hot_count = 20 # featured podcasts under /fyyd/hot +# episodes_per_podcast = 100 # episodes per podcast (also a hard cap) +# call_timeout_secs = 30 # per-request timeout +# base_url = "https://api.fyyd.de/0.2" # API base override +``` + +All network access goes through the `Fyyd` trait +(`fyyd/src/api.rs`), so the provider's tree/path logic is unit-tested +with a fake and no network. See `architecture/fyyd-provider.md` for the +tree shape and design decisions. diff --git a/fyyd/src/api.rs b/fyyd/src/api.rs new file mode 100644 index 0000000..afa8684 --- /dev/null +++ b/fyyd/src/api.rs @@ -0,0 +1,265 @@ +//! The fyyd HTTP seam. +//! +//! All network access to `api.fyyd.de` goes through the [`Fyyd`] trait so +//! the provider's tree/path logic is unit-tested with a fake and no network +//! (architecture/fyyd-provider.md D2). [`FyydApi`] is the production +//! `reqwest` implementation; tests supply their own `Fyyd`. +//! +//! The public fyyd API needs no key or auth for search and browse. Its +//! JSON responses are wrapped in an envelope `{ "status", "msg", "data" }`; +//! we decode defensively (missing fields degrade, never panic) and map +//! every failure to a typed [`FetchError`]. + +use std::fmt::Debug; +use std::time::Duration; + +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use thiserror::Error; +use tracing::debug; + +/// Public fyyd API base (no trailing slash). +pub const DEFAULT_BASE_URL: &str = "https://api.fyyd.de/0.2"; + +/// A typed fyyd request failure. Carries only public request context (URLs, +/// ids, decode messages) — fyyd needs no credentials, so nothing here is a +/// secret. +#[derive(Debug, Error)] +pub enum FetchError { + /// The HTTP call failed (transport, timeout, or non-success status). + #[error("fyyd request failed: {0}")] + Http(String), + /// The response body did not decode into the expected shape. + #[error("fyyd returned malformed data: {0}")] + Decode(String), + /// The resource does not exist (HTTP 404). + #[error("fyyd resource not found")] + NotFound, +} + +/// A podcast: a container of episodes. `id` is fyyd's stable numeric id as a +/// string (already URL-safe, used as a path segment). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Podcast { + pub id: String, + pub title: String, +} + +/// One podcast episode — a playable track. `enclosure` is the direct audio +/// URL the player streams. `artist` is the owning podcast's title when +/// known (see architecture/fyyd-provider.md D4); `duration` is in seconds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Episode { + pub id: String, + pub title: String, + pub enclosure: String, + pub artist: Option, + pub duration: Option, +} + +/// The fyyd operations the provider needs. Behind `Box` so tests +/// fake it (architecture/fyyd-provider.md D2). +#[async_trait] +pub trait Fyyd: Debug + Send + Sync { + /// Podcasts matching a free-text term (at most `limit`). + async fn search_podcasts(&self, term: &str, limit: usize) -> Result, FetchError>; + + /// The current featured ("hot") podcasts (at most `limit`). + async fn hot_podcasts(&self, limit: usize) -> Result, FetchError>; + + /// A podcast's episodes (at most `limit`), with the podcast's title — + /// returned so listed episodes get the right `artist` for free. + async fn podcast_episodes( + &self, + podcast_id: &str, + limit: usize, + ) -> Result<(String, Vec), FetchError>; + + /// One episode by id. The implementation fills `artist` with the owning + /// podcast's title when it can; a failure to do so leaves it `None` + /// rather than failing the call. + async fn episode(&self, episode_id: &str) -> Result; +} + +/// Production `reqwest` client for `api.fyyd.de`. +#[derive(Debug)] +pub struct FyydApi { + http: reqwest::Client, + /// Base URL without a trailing slash. + base_url: String, +} + +impl FyydApi { + /// Builds the client with a per-call `timeout` (hard rule: timeouts on + /// external calls). `base_url` is normally [`DEFAULT_BASE_URL`]. + pub fn new(base_url: String, timeout: Duration) -> Result { + let http = reqwest::Client::builder() + .timeout(timeout) + .user_agent(concat!("crabidy-fyyd/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|err| FetchError::Http(err.to_string()))?; + Ok(Self { + http, + base_url: base_url.trim_end_matches('/').to_string(), + }) + } + + /// GETs `path` with `query`, unwrapping the fyyd `data` envelope. + async fn get( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result { + let url = format!("{}{}", self.base_url, path); + debug!(url, "fyyd GET"); + let resp = self + .http + .get(&url) + .query(query) + .send() + .await + .map_err(|err| FetchError::Http(err.to_string()))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(FetchError::NotFound); + } + let resp = resp + .error_for_status() + .map_err(|err| FetchError::Http(err.to_string()))?; + let envelope: Envelope = resp + .json() + .await + .map_err(|err| FetchError::Decode(err.to_string()))?; + Ok(envelope.data) + } +} + +#[async_trait] +impl Fyyd for FyydApi { + async fn search_podcasts(&self, term: &str, limit: usize) -> Result, FetchError> { + let count = limit.to_string(); + let dtos: Vec = self + .get("/search/podcast", &[("term", term), ("count", &count)]) + .await?; + Ok(dtos + .into_iter() + .filter_map(PodcastDto::into_podcast) + .collect()) + } + + async fn hot_podcasts(&self, limit: usize) -> Result, FetchError> { + let count = limit.to_string(); + let dtos: Vec = self + .get("/feature/podcast/hot", &[("count", &count)]) + .await?; + Ok(dtos + .into_iter() + .filter_map(PodcastDto::into_podcast) + .collect()) + } + + async fn podcast_episodes( + &self, + podcast_id: &str, + limit: usize, + ) -> Result<(String, Vec), FetchError> { + let count = limit.to_string(); + let dto: PodcastEpisodesDto = self + .get( + "/podcast/episodes", + &[("podcast_id", podcast_id), ("count", &count)], + ) + .await?; + let title = dto.title.clone(); + let episodes = dto + .episodes + .into_iter() + .filter_map(|e| e.into_episode(Some(title.clone()))) + .collect(); + Ok((dto.title, episodes)) + } + + async fn episode(&self, episode_id: &str) -> Result { + let dto: EpisodeDto = self.get("/episode", &[("episode_id", episode_id)]).await?; + // The episode endpoint carries no podcast title; look it up so the + // track gets an artist. A failed lookup is not fatal — artist stays + // None (architecture/fyyd-provider.md D4). + let artist = if dto.podcast_id > 0 { + self.podcast_title(dto.podcast_id).await + } else { + None + }; + dto.into_episode(artist) + .ok_or_else(|| FetchError::Decode("episode has no id".to_string())) + } +} + +impl FyydApi { + /// Best-effort podcast title for `artist`; `None` on any failure. + async fn podcast_title(&self, podcast_id: i64) -> Option { + let id = podcast_id.to_string(); + let dto: PodcastDto = self.get("/podcast", &[("podcast_id", &id)]).await.ok()?; + let title = dto.title.trim(); + (!title.is_empty()).then(|| title.to_string()) + } +} + +/// The `{ "data": … }` envelope every fyyd response is wrapped in. +#[derive(Debug, Deserialize)] +struct Envelope { + data: T, +} + +#[derive(Debug, Default, Deserialize)] +struct PodcastDto { + #[serde(default)] + id: i64, + #[serde(default)] + title: String, +} + +impl PodcastDto { + /// Drops entries without a usable id (fyyd ids are positive). + fn into_podcast(self) -> Option { + (self.id > 0).then(|| Podcast { + id: self.id.to_string(), + title: self.title, + }) + } +} + +#[derive(Debug, Default, Deserialize)] +struct PodcastEpisodesDto { + #[serde(default)] + title: String, + #[serde(default)] + episodes: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct EpisodeDto { + #[serde(default)] + id: i64, + #[serde(default)] + title: String, + #[serde(default)] + enclosure: String, + #[serde(default)] + duration: i64, + #[serde(default)] + podcast_id: i64, +} + +impl EpisodeDto { + /// Builds the domain episode, dropping entries without a usable id. + /// `duration <= 0` degrades to `None`. + fn into_episode(self, artist: Option) -> Option { + (self.id > 0).then(|| Episode { + id: self.id.to_string(), + title: self.title, + enclosure: self.enclosure, + artist, + duration: u32::try_from(self.duration).ok().filter(|&d| d > 0), + }) + } +} diff --git a/fyyd/src/lib.rs b/fyyd/src/lib.rs new file mode 100644 index 0000000..194ad39 --- /dev/null +++ b/fyyd/src/lib.rs @@ -0,0 +1,832 @@ +//! fyyd podcast provider: **find and play podcasts** through fyyd's public +//! search engine (`api.fyyd.de`, keyless). Mounted at [`PROVIDER_ROOT`]. +//! +//! Search works like `/tidal/search` and `/youtube/search`: creatable +//! in-memory search-term nodes (renamable/deletable). Unlike YouTube a +//! podcast search returns *podcasts*, each a container of *episodes*, so the +//! tree carries one extra level — `search-term → podcast → episodes` — plus +//! a fixed `/fyyd/hot` featured-podcast browse. An episode is a track whose +//! `enclosure` URL the audio player streams directly; every node that serves +//! episodes is downloadable, so `W` captures work out of the box. +//! +//! See architecture/fyyd-provider.md for the tree shape and decisions. + +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::{debug, warn}; + +pub mod api; +use api::{Episode, Fyyd, FyydApi, Podcast, DEFAULT_BASE_URL}; + +/// First path segment owned by this provider. +pub const PROVIDER_ROOT: &str = "/fyyd"; + +/// Default number of podcasts listed per search term. +pub const DEFAULT_SEARCH_RESULTS: usize = 20; + +/// Default number of featured podcasts under `/fyyd/hot`. +pub const DEFAULT_HOT_COUNT: usize = 20; + +/// Default (and cap on) episodes listed under a podcast — a huge podcast +/// must not stall the library or queue resolution. +pub const DEFAULT_EPISODES_PER_PODCAST: usize = 100; + +/// Default per-request timeout in seconds. +pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30; + +/// Provider settings, persisted as `fyyd.toml`. fyyd's public API needs no +/// credentials, so every field is optional and an empty file is normal. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Settings { + /// Podcasts per search term. Default [`DEFAULT_SEARCH_RESULTS`]. + pub search_results: Option, + /// Featured podcasts under `/fyyd/hot`. Default [`DEFAULT_HOT_COUNT`]. + pub hot_count: Option, + /// Episodes listed per podcast. Default + /// [`DEFAULT_EPISODES_PER_PODCAST`]. + pub episodes_per_podcast: Option, + /// Per-request timeout in seconds. Default + /// [`DEFAULT_CALL_TIMEOUT_SECS`]. + pub call_timeout_secs: Option, + /// API base URL override (a mirror, or a test server). Default + /// [`DEFAULT_BASE_URL`]. + pub base_url: Option, +} + +/// A parsed `/fyyd/...` path. The `search` and `hot` branches share the same +/// podcast/episode shapes below their first segment. +#[derive(Debug, PartialEq, Eq)] +enum FyydPath<'a> { + Root, + Search, + /// Percent-encoded search-term segment. + SearchTerm(&'a str), + SearchPodcast { + term: &'a str, + podcast: &'a str, + }, + SearchEpisode { + term: &'a str, + podcast: &'a str, + episode: &'a str, + }, + Hot, + HotPodcast(&'a str), + HotEpisode { + podcast: &'a str, + episode: &'a str, + }, +} + +/// Splits a `/fyyd/...` path into its recognized shape. Unknown shapes are +/// [`ProviderError::MalformedPath`]. +fn parse_path(path: &str) -> Result, ProviderError> { + if path == PROVIDER_ROOT { + return Ok(FyydPath::Root); + } + let rest = path + .strip_prefix("/fyyd/") + .ok_or(ProviderError::MalformedPath)?; + let segments: Vec<&str> = rest.split('/').collect(); + if segments.iter().any(|segment| segment.is_empty()) { + return Err(ProviderError::MalformedPath); + } + match segments.as_slice() { + ["search"] => Ok(FyydPath::Search), + ["search", term] => Ok(FyydPath::SearchTerm(term)), + ["search", term, podcast] => Ok(FyydPath::SearchPodcast { term, podcast }), + ["search", term, podcast, episode] => Ok(FyydPath::SearchEpisode { + term, + podcast, + episode, + }), + ["hot"] => Ok(FyydPath::Hot), + ["hot", podcast] => Ok(FyydPath::HotPodcast(podcast)), + ["hot", podcast, episode] => Ok(FyydPath::HotEpisode { podcast, episode }), + _ => Err(ProviderError::MalformedPath), + } +} + +/// Maps a fetch failure to the trait-level error, logging the typed cause. +fn fetch_err(context: &str, err: api::FetchError) -> ProviderError { + warn!(context, "fyyd fetch failed: {err}"); + ProviderError::FetchError +} + +/// Builds the wire track for one episode under `node_path`. Missing metadata +/// degrades to empty fields; a missing artist falls back to the podcast +/// title. Never an error. +fn episode_to_track(episode: &Episode, node_path: &str, podcast_title: &str) -> Track { + Track { + path: crabidy_core::join_path(node_path, &episode.id), + artist: episode + .artist + .clone() + .unwrap_or_else(|| podcast_title.to_string()), + title: episode.title.clone(), + duration: episode.duration, + album: None, + is_skipped: false, + provider_item_id: episode.id.clone(), + is_captured: false, + } +} + +/// The fyyd podcast provider client. +#[derive(Debug)] +pub struct Client { + api: Box, + settings: Settings, + /// Search terms created under `/fyyd/search`, in creation order, + /// deduplicated. In-memory only, like tidal's. Never held across awaits. + search_terms: RwLock>, +} + +impl Client { + /// A client over any [`Fyyd`] backend — the seam the tests use. + fn with_api(api: Box, settings: Settings) -> Self { + Self { + api, + settings, + search_terms: RwLock::new(Vec::new()), + } + } + + fn search_results_limit(&self) -> usize { + self.settings + .search_results + .unwrap_or(DEFAULT_SEARCH_RESULTS) + } + + fn hot_limit(&self) -> usize { + self.settings.hot_count.unwrap_or(DEFAULT_HOT_COUNT) + } + + fn episodes_limit(&self) -> usize { + self.settings + .episodes_per_podcast + .unwrap_or(DEFAULT_EPISODES_PER_PODCAST) + } + + /// A node listing `podcasts` as queueable, downloadable children. Used + /// for both a search-term node and the `hot` node — they differ only in + /// where the podcast list comes from. + fn podcast_listing_node( + &self, + path: &str, + title: String, + parent: String, + podcasts: &[Podcast], + ) -> LibraryNode { + LibraryNode { + path: path.to_string(), + title, + parent: Some(parent), + tracks: Vec::new(), + children: podcasts + .iter() + .map(|podcast| { + LibraryNodeChild::new( + crabidy_core::join_path(path, &podcast.id), + podcast.title.clone(), + true, + ) + }) + .collect(), + is_queable: false, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + /// One podcast's episodes as tracks (bounded by [`Self::episodes_limit`]). + /// Queueable and downloadable — homogeneous tracks. + async fn episodes_node( + &self, + path: &str, + podcast_id: &str, + parent: String, + ) -> Result { + let (podcast_title, episodes) = self + .api + .podcast_episodes(podcast_id, self.episodes_limit()) + .await + .map_err(|err| fetch_err("podcast episodes", err))?; + Ok(LibraryNode { + path: path.to_string(), + title: podcast_title.clone(), + parent: Some(parent), + tracks: episodes + .iter() + .map(|episode| episode_to_track(episode, path, &podcast_title)) + .collect(), + children: Vec::new(), + is_queable: true, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + }) + } + + /// The search-term node: matching podcasts as children. + async fn search_term_node( + &self, + path: &str, + term: &str, + parent: String, + ) -> Result { + let podcasts = self + .api + .search_podcasts(term, self.search_results_limit()) + .await + .map_err(|err| fetch_err("search", err))?; + Ok(self.podcast_listing_node(path, term.to_string(), parent, &podcasts)) + } + + /// The `/fyyd/hot` node: featured podcasts as children. + async fn hot_node(&self, path: &str, parent: String) -> Result { + let podcasts = self + .api + .hot_podcasts(self.hot_limit()) + .await + .map_err(|err| fetch_err("hot", err))?; + Ok(self.podcast_listing_node(path, "hot".to_string(), parent, &podcasts)) + } + + fn search_terms_snapshot(&self) -> Vec { + self.search_terms + .read() + .map(|terms| terms.clone()) + .unwrap_or_default() + } + + fn register_search_term(&self, term: &str) { + if let Ok(mut terms) = self.search_terms.write() { + if !terms.iter().any(|existing| existing == term) { + terms.push(term.to_string()); + } + } + } + + /// Removes a term; `true` when it existed. + fn remove_search_term(&self, term: &str) -> bool { + match self.search_terms.write() { + Ok(mut terms) => { + let before = terms.len(); + terms.retain(|existing| existing != term); + terms.len() != before + } + Err(_) => false, + } + } + + /// The episode id addressed by a track path, or `MalformedPath`. + fn track_episode<'a>(&self, path: &'a str) -> Result<&'a str, ProviderError> { + match parse_path(path)? { + FyydPath::SearchEpisode { episode, .. } | FyydPath::HotEpisode { episode, .. } => { + Ok(episode) + } + _ => Err(ProviderError::MalformedPath), + } + } +} + +#[async_trait] +impl ProviderClient for Client { + /// Builds the `reqwest`-backed fyyd client. A client that cannot be built + /// fails init (the orchestrator then disables the provider non-fatally). + async fn init(raw_toml_settings: &str) -> Result { + let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| { + warn!("could not parse fyyd.toml, using defaults"); + Settings::default() + }); + let timeout = Duration::from_secs( + settings + .call_timeout_secs + .unwrap_or(DEFAULT_CALL_TIMEOUT_SECS), + ); + let base_url = settings + .base_url + .clone() + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + let api = FyydApi::new(base_url, timeout).map_err(|err| { + warn!("cannot build the fyyd client: {err}"); + ProviderError::Config(err.to_string()) + })?; + debug!("fyyd provider ready"); + Ok(Self::with_api(Box::new(api), settings)) + } + + fn settings(&self) -> String { + toml::to_string_pretty(&self.settings).unwrap_or_default() + } + + fn is_track_path(&self, path: &str) -> bool { + matches!( + parse_path(path), + Ok(FyydPath::SearchEpisode { .. } | FyydPath::HotEpisode { .. }) + ) + } + + /// The episode's `enclosure` (audio) URL — a plain https media file the + /// player streams directly. + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { + let episode_id = self.track_episode(track_path)?; + let episode = self + .api + .episode(episode_id) + .await + .map_err(|err| fetch_err("stream url", err))?; + if episode.enclosure.trim().is_empty() { + warn!(track_path, "fyyd episode has no enclosure url"); + return Err(ProviderError::FetchError); + } + Ok(vec![episode.enclosure]) + } + + /// Single-episode metadata. + async fn get_metadata_for_track(&self, track_path: &str) -> Result { + let episode_id = self.track_episode(track_path)?; + let episode = self + .api + .episode(episode_id) + .await + .map_err(|err| fetch_err("episode metadata", err))?; + let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT); + let mut track = episode_to_track(&episode, parent, ""); + // The caller's path is canonical (the episode id names the episode). + track.path = track_path.to_string(); + Ok(track) + } + + /// `search` (creatable) and `hot`, both always present. + fn get_lib_root(&self) -> LibraryNode { + let children = vec![ + LibraryNodeChild { + is_creatable: true, + ..LibraryNodeChild::new( + format!("{PROVIDER_ROOT}/search"), + "search".to_string(), + false, + ) + }, + LibraryNodeChild::new(format!("{PROVIDER_ROOT}/hot"), "hot".to_string(), false), + ]; + LibraryNode { + path: PROVIDER_ROOT.to_string(), + title: "fyyd".to_string(), + parent: Some(crabidy_core::ROOT_PATH.to_string()), + tracks: Vec::new(), + children, + is_queable: false, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + async fn get_lib_node(&self, path: &str) -> Result { + let parent = crabidy_core::parent_path(path) + .unwrap_or(crabidy_core::ROOT_PATH) + .to_string(); + let node = match parse_path(path)? { + FyydPath::Root => self.get_lib_root(), + FyydPath::Search => LibraryNode { + path: path.to_string(), + title: "search".to_string(), + parent: Some(parent), + tracks: Vec::new(), + children: self + .search_terms_snapshot() + .iter() + .map(|term| { + // Term nodes are the modifiable nodes: renamable + // (`e`) and deletable (`d`), like tidal's. + LibraryNodeChild { + is_editable: true, + is_deletable: true, + ..LibraryNodeChild::new( + crabidy_core::join_path(path, &crabidy_core::encode_segment(term)), + term.clone(), + false, + ) + } + }) + .collect(), + is_queable: false, + is_creatable: true, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + }, + FyydPath::SearchTerm(encoded) => { + let term = crabidy_core::decode_segment(encoded); + // Unknown terms (stale client cache, server restart) are + // recreated implicitly instead of erroring. + self.register_search_term(&term); + self.search_term_node(path, &term, parent).await? + } + FyydPath::SearchPodcast { podcast, .. } | FyydPath::HotPodcast(podcast) => { + self.episodes_node(path, podcast, parent).await? + } + FyydPath::Hot => self.hot_node(path, parent).await?, + FyydPath::SearchEpisode { .. } | FyydPath::HotEpisode { .. } => { + warn!(path, "get_lib_node called with a track path"); + return Err(ProviderError::MalformedPath); + } + }; + // The central download blessing (architecture/fyyd-provider.md D3, + // same rule as youtube/tidal): every node serving playable content + // allows `W`; children mirror their queueability (a podcast child is + // queueable, so a whole podcast is capturable). + let mut node = node; + node.is_downloadable = node.is_queable || !node.tracks.is_empty(); + for child in &mut node.children { + child.is_downloadable = child.is_queable; + } + Ok(node) + } + + /// Only `/fyyd/search` is creatable: registers the term and returns its + /// node (implicit recreation on stale paths, like tidal/youtube). + async fn create_lib_node( + &self, + parent_path: &str, + title: &str, + ) -> Result { + let term = title.trim(); + if term.is_empty() { + return Err(ProviderError::InvalidInput); + } + if parse_path(parent_path)? != FyydPath::Search { + warn!(parent_path, "node creation not supported here"); + return Err(ProviderError::NotSupported); + } + self.register_search_term(term); + let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term)); + self.get_lib_node(&term_path).await + } + + /// Renaming a search term re-runs the search under the new term. + async fn rename_lib_node( + &self, + path: &str, + new_title: &str, + ) -> Result { + let FyydPath::SearchTerm(encoded) = parse_path(path)? else { + warn!(path, "only search terms are renamable"); + return Err(ProviderError::NotSupported); + }; + let new_term = new_title.trim(); + if new_term.is_empty() { + return Err(ProviderError::InvalidInput); + } + let old_term = crabidy_core::decode_segment(encoded); + // Replace in place; renaming onto an existing term merges (the + // duplicate disappears), like tidal's terms. + self.remove_search_term(&old_term); + self.register_search_term(new_term); + let new_path = crabidy_core::join_path( + &format!("{PROVIDER_ROOT}/search"), + &crabidy_core::encode_segment(new_term), + ); + self.get_lib_node(&new_path).await + } + + /// Deleting a search term is idempotent and returns the refreshed search + /// node. + async fn delete_lib_node(&self, path: &str) -> Result { + let FyydPath::SearchTerm(encoded) = parse_path(path)? else { + warn!(path, "only search terms are deletable"); + return Err(ProviderError::NotSupported); + }; + let term = crabidy_core::decode_segment(encoded); + self.remove_search_term(&term); + self.get_lib_node(&format!("{PROVIDER_ROOT}/search")).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// A programmable fyyd backend: provider logic is tested without network + /// (architecture/fyyd-provider.md D2). + #[derive(Debug, Default)] + struct FakeApi { + /// term → podcasts (limit applied like the real one). + searches: HashMap>, + hot: Vec, + /// podcast id → (title, episodes). + episodes: HashMap)>, + /// episode id → episode (as the single-episode endpoint returns it). + single: HashMap, + } + + fn podcast(id: &str, title: &str) -> Podcast { + Podcast { + id: id.to_string(), + title: title.to_string(), + } + } + + fn episode(id: &str, title: &str, enclosure: &str, duration: Option) -> Episode { + Episode { + id: id.to_string(), + title: title.to_string(), + enclosure: enclosure.to_string(), + artist: None, + duration, + } + } + + #[async_trait] + impl Fyyd for FakeApi { + async fn search_podcasts( + &self, + term: &str, + limit: usize, + ) -> Result, api::FetchError> { + self.searches + .get(term) + .map(|list| list.iter().take(limit).cloned().collect()) + .ok_or_else(|| api::FetchError::Http(format!("no search fixture for {term:?}"))) + } + async fn hot_podcasts(&self, limit: usize) -> Result, api::FetchError> { + Ok(self.hot.iter().take(limit).cloned().collect()) + } + async fn podcast_episodes( + &self, + podcast_id: &str, + limit: usize, + ) -> Result<(String, Vec), api::FetchError> { + self.episodes + .get(podcast_id) + .map(|(title, eps)| { + let eps = eps + .iter() + .take(limit) + .cloned() + .map(|mut e| { + e.artist = Some(title.clone()); + e + }) + .collect(); + (title.clone(), eps) + }) + .ok_or_else(|| api::FetchError::NotFound) + } + async fn episode(&self, episode_id: &str) -> Result { + self.single + .get(episode_id) + .cloned() + .ok_or(api::FetchError::NotFound) + } + } + + /// The standard fixture: one search term matching two podcasts, one hot + /// podcast, one podcast with two episodes, one directly-resolvable + /// episode. + fn fake() -> FakeApi { + FakeApi { + searches: HashMap::from([( + "history".to_string(), + vec![ + podcast("10", "Hardcore History"), + podcast("20", "Revolutions"), + ], + )]), + hot: vec![podcast("99", "Hot Cast")], + episodes: HashMap::from([( + "10".to_string(), + ( + "Hardcore History".to_string(), + vec![ + episode("101", "Ep One", "https://cdn.test/1.mp3", Some(3600)), + episode("102", "Ep Two", "https://cdn.test/2.mp3", None), + ], + ), + )]), + single: HashMap::from([( + "101".to_string(), + Episode { + artist: Some("Hardcore History".to_string()), + ..episode("101", "Ep One", "https://cdn.test/1.mp3", Some(3600)) + }, + )]), + } + } + + fn client_with(api: FakeApi) -> Client { + Client::with_api(Box::new(api), Settings::default()) + } + + fn client() -> Client { + client_with(fake()) + } + + #[test] + fn root_lists_search_and_hot() { + let root = client().get_lib_root(); + let titles: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect(); + assert_eq!(titles, vec!["search", "hot"]); + assert!(root.children[0].is_creatable, "search is creatable"); + assert!(!root.children[1].is_creatable, "hot is a fixed browse"); + } + + #[tokio::test] + async fn search_terms_list_podcasts_as_children() { + let client = client(); + let node = client + .create_lib_node("/fyyd/search", "history") + .await + .expect("create term"); + assert_eq!(node.path, "/fyyd/search/history"); + assert!( + !node.is_queable, + "a podcast listing is not itself queueable" + ); + assert!( + node.tracks.is_empty(), + "search results are podcasts, not tracks" + ); + assert_eq!(node.children.len(), 2); + let first = &node.children[0]; + assert_eq!(first.path, "/fyyd/search/history/10"); + assert_eq!(first.title, "Hardcore History"); + assert!( + first.is_queable && first.is_downloadable, + "podcasts queue/capture whole" + ); + + // The search node lists the term as an editable/deletable child. + let search = client.get_lib_node("/fyyd/search").await.expect("search"); + assert!(search.is_creatable); + assert_eq!(search.children.len(), 1); + let child = &search.children[0]; + assert_eq!(child.title, "history"); + assert!(child.is_editable && child.is_deletable); + assert!(!child.is_queable, "a search term is not directly queueable"); + } + + #[tokio::test] + async fn a_podcast_lists_its_episodes_as_tracks() { + let client = client(); + let node = client + .get_lib_node("/fyyd/search/history/10") + .await + .expect("podcast"); + assert_eq!(node.title, "Hardcore History"); + assert!(node.is_queable && node.is_downloadable); + assert_eq!(node.tracks.len(), 2); + let one = &node.tracks[0]; + assert_eq!(one.path, "/fyyd/search/history/10/101"); + assert_eq!(one.title, "Ep One"); + assert_eq!( + one.artist, "Hardcore History", + "artist is the podcast title" + ); + assert_eq!(one.duration, Some(3600)); + assert_eq!(one.provider_item_id, "101"); + // Missing duration degrades to None, never an error. + assert_eq!(node.tracks[1].duration, None); + } + + #[tokio::test] + async fn hot_lists_featured_podcasts() { + let client = client(); + let node = client.get_lib_node("/fyyd/hot").await.expect("hot"); + assert_eq!(node.children.len(), 1); + let child = &node.children[0]; + assert_eq!(child.path, "/fyyd/hot/99"); + assert_eq!(child.title, "Hot Cast"); + assert!(child.is_queable && child.is_downloadable); + } + + #[tokio::test] + async fn search_terms_rename_and_delete() { + let client = client(); + client + .create_lib_node("/fyyd/search", "history") + .await + .expect("create term"); + // Rename onto a term with its own fixture re-runs the search. + let renamed = client + .rename_lib_node("/fyyd/search/history", "history") + .await + .expect("rename re-searches"); + assert_eq!(renamed.path, "/fyyd/search/history"); + assert_eq!(client.search_terms_snapshot(), vec!["history".to_string()]); + + let search = client + .delete_lib_node("/fyyd/search/history") + .await + .expect("delete"); + assert!(search.children.is_empty()); + // Idempotent. + let again = client + .delete_lib_node("/fyyd/search/history") + .await + .expect("idempotent delete"); + assert!(again.children.is_empty()); + // Only /fyyd/search children are mutable. + assert!(client.rename_lib_node("/fyyd", "nope").await.is_err()); + assert!(client.delete_lib_node("/fyyd/hot").await.is_err()); + } + + #[tokio::test] + async fn tracks_resolve_streams_and_metadata() { + let client = client(); + assert!(client.is_track_path("/fyyd/search/history/10/101")); + assert!(client.is_track_path("/fyyd/hot/99/101")); + assert!(!client.is_track_path("/fyyd/search/history/10")); + assert!(!client.is_track_path("/fyyd/search/history")); + + let urls = client + .get_urls_for_track("/fyyd/search/history/10/101") + .await + .expect("enclosure url"); + assert_eq!(urls, vec!["https://cdn.test/1.mp3".to_string()]); + + let track = client + .get_metadata_for_track("/fyyd/hot/99/101") + .await + .expect("metadata"); + assert_eq!(track.title, "Ep One"); + assert_eq!(track.artist, "Hardcore History"); + assert_eq!(track.duration, Some(3600)); + assert_eq!(track.path, "/fyyd/hot/99/101"); + } + + #[tokio::test] + async fn a_missing_enclosure_is_an_error_not_a_panic() { + let mut api = fake(); + api.single + .insert("500".to_string(), episode("500", "No Audio", "", Some(10))); + let client = client_with(api); + assert!(client + .get_urls_for_track("/fyyd/search/history/10/500") + .await + .is_err()); + } + + #[tokio::test] + async fn backend_failures_are_typed_never_panics() { + // An empty fake answers nothing: every lookup is a typed error. + let client = client_with(FakeApi::default()); + client.register_search_term("history"); + assert!(client.get_lib_node("/fyyd/search/history").await.is_err()); + assert!(client + .get_lib_node("/fyyd/search/history/10") + .await + .is_err()); + assert!(client + .get_urls_for_track("/fyyd/search/history/10/101") + .await + .is_err()); + assert!(client + .get_metadata_for_track("/fyyd/hot/99/101") + .await + .is_err()); + } + + #[tokio::test] + async fn foreign_and_malformed_paths_are_rejected() { + let client = client(); + for path in [ + "/tidal/artists", + "/fyyd/nope", + "/fyyd/search/a/b/c/d", + "/fyyd//10", + ] { + assert!(client.get_lib_node(path).await.is_err(), "{path}"); + } + assert!(client.create_lib_node("/fyyd", "term").await.is_err()); + assert!(client.create_lib_node("/fyyd/search", " ").await.is_err()); + } + + #[test] + fn settings_round_trip() { + let settings: Settings = toml::from_str( + "search_results = 5\nhot_count = 3\nepisodes_per_podcast = 50\ncall_timeout_secs = 10\n", + ) + .expect("parses"); + assert_eq!(settings.search_results, Some(5)); + assert_eq!(settings.hot_count, Some(3)); + assert_eq!(settings.episodes_per_podcast, Some(50)); + assert_eq!(settings.call_timeout_secs, Some(10)); + // Empty file is valid — fyyd needs no config. + let empty: Settings = toml::from_str("").expect("empty parses"); + assert!(empty.base_url.is_none()); + } +} diff --git a/plan/fyyd-provider.md b/plan/fyyd-provider.md new file mode 100644 index 0000000..e80f202 --- /dev/null +++ b/plan/fyyd-provider.md @@ -0,0 +1,39 @@ +# Plan — fyyd podcast provider + +From `architecture/fyyd-provider.md` and `quality/fyyd-provider.md`. Ordered +by dependency; each task names how it is verified. + +- [x] **HTTP seam** (`fyyd/src/api.rs`): `Fyyd` trait + (`search_podcasts`, `hot_podcasts`, `podcast_episodes`, `episode`), + domain `Podcast`/`Episode`, typed `FetchError`, and the `reqwest` + `FyydApi` (envelope unwrap, defensive DTOs, per-call timeout, artist + backfill via a `/podcast` lookup). Verify: it compiles; used by the + provider tests via a fake. +- [x] **Crate skeleton** (`fyyd/Cargo.toml`, workspace `members` + dep): + new `fyyd` crate depending on `crabidy-core`, `reqwest`, `serde`, + `thiserror`, `tokio`, `toml`, `tracing`, `async-trait`. Verify: + `cargo build -p fyyd`. +- [x] **Path model** (`fyyd/src/lib.rs`): `FyydPath` enum + `parse_path` + covering root/search/term/podcast/episode and hot/podcast/episode; + empty segments rejected. Verify: `foreign_and_malformed_paths_are_rejected`. +- [x] **Provider core** (`fyyd/src/lib.rs`): `Client` (`Box` + + settings + in-memory search terms), `Settings` (`fyyd.toml`), the + `ProviderClient` impl (root, node dispatch, podcast/episode builders, + stream + metadata resolution, create/rename/delete of terms, central + download blessing). Verify: the 10 unit tests in `fyyd/src/lib.rs`. +- [x] **Settings registration** (`crabidy-server/src/settings.rs`): add + `fyyd` to `ALL_PROVIDERS`, `ProviderToggles`, `all()`, and + `provider_toggles()`. Verify: existing settings tests (they iterate + `ALL_PROVIDERS`); `cargo test -p crabidy-server`. +- [x] **Orchestrator wiring** (`crabidy-server/src/provider.rs`): + `fyyd_client` field, `fyyd_owns`/`fyyd_provider`, non-fatal `build()` + block reading `fyyd.toml`, `get_lib_root` child, and a routing arm in + each of the eight `ProviderClient` methods. Verify: `cargo build`, + full server suite green. +- [x] **Gates**: `cargo test -p fyyd -p crabidy-server`, `cargo clippy`, + `cargo fmt --check` all clean; check off `quality/fyyd-provider.md`. +- [x] **Docs**: architecture + quality + plan committed; user docs + (`docs/src/`, README provider list) updated. +- [ ] **Live validation** (deferred, needs network): confirm the real + `api.fyyd.de` field shapes against `fyyd/src/api.rs` DTOs — see the + open gate in `quality/fyyd-provider.md`. diff --git a/plan/summary.md b/plan/summary.md index 26fd6e3..aa90410 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -941,3 +941,58 @@ command surface via the `cbd-cli` crate. and `scan`/`ingest_file` behaviour (`crabidy_server::cli`, `crabidy_store`). The unreachable-server error path was verified manually (concise message, exit 1). + +## fyyd podcast provider (2026-07-23) + +New `/fyyd` provider (crate `fyyd`) for finding and playing podcasts via +fyyd's keyless public API. Ran the full dev-flow pipeline; artifacts: +`architecture/fyyd-provider.md`, `quality/fyyd-provider.md`, +`plan/fyyd-provider.md`. Modelled on `ytdy` (remote, search-driven, +in-memory terms), wired into `ProviderOrchestrator` with the same +five-place pattern as every other provider: settings toggle, an +owns-check plus `fyyd_provider()`, a `build()` block, a root child, and +eight dispatch arms. No proto change, no new `ProviderCommand`, and no +audio-player change: +episode `enclosure` URLs feed straight into the existing HTTP-streaming +path. + +Where the implementation shaped decisions beyond the plan: + +- **Extra tree level (the defining difference from `/youtube`).** A + podcast search returns *podcasts*, each a container of *episodes*, so + the tree is `search-term → podcast → episodes-as-tracks` (one level + deeper than YouTube). A podcast node maps to ytdy's *playlist* (a + queueable/downloadable container of tracks); a search-term node maps to + ytdy's *playlists* listing (containers as children). The `search` and + `hot` branches share the podcast-node and episode-leaf builders. + +- **`/fyyd/hot` added.** Not in the original one-line request, but fyyd's + `/feature/podcast/hot` gives a zero-typing browse for free, so the root + exposes `search` (creatable) **and** `hot` (fixed). Documented as D3. + +- **HTTP behind a `Fyyd` trait** (`fyyd/src/api.rs`), faked in tests, so + all 10 provider unit tests run with no network — same seam pattern as + ytdy's `Extract`. Production `FyydApi` uses the workspace `reqwest` + (json/query/rustls), unwraps fyyd's `data` envelope, and decodes DTOs + defensively (`#[serde(default)]`, drop id-less entries, non-positive + durations → `None`). + +- **Artist backfill.** A track's `artist` is the podcast title. Listing + episodes under a podcast already yields the title, so listed tracks get + it free; a *directly* fetched episode (`/episode`) carries no podcast + title, so `FyydApi::episode` does one extra best-effort `/podcast` + lookup (failure → `None`, never fatal). D4. + +- **Non-fatal init, no credentials.** Unlike tidal, a missing `fyyd.toml` + is normal and a build failure only drops `/fyyd`. Every call is + timeout-bounded and every listing capped (`search_results`, + `hot_count`, `episodes_per_podcast`). + +Deferred: **live validation** against the real `api.fyyd.de` (field +names/envelope were taken from the current public docs, not exercised on +the network). Left as an open gate in `quality/fyyd-provider.md` and a +`- [ ]` in `plan/fyyd-provider.md`; if a field differs the fix is +confined to the `FyydApi` DTOs. + +Verification: `fyyd` 10 tests + `crabidy-server` 74 lib + 4 integration +green; clippy and rustfmt clean on both crates. diff --git a/quality/fyyd-provider.md b/quality/fyyd-provider.md new file mode 100644 index 0000000..ed76243 --- /dev/null +++ b/quality/fyyd-provider.md @@ -0,0 +1,71 @@ +# Quality gates — fyyd provider + +LLM-verified gates for `architecture/fyyd-provider.md`. Automatic tests live +in `fyyd/src/lib.rs` (provider logic against a fake `Fyyd`) and +`crabidy-server/src/settings.rs` (the provider toggle). + +## Correctness / semantics + +- [x] Tree shape matches D3: `/fyyd` → `search` (creatable) + `hot`; + `/fyyd/search/` and `/fyyd/hot` list **podcasts** as children + (not tracks); `/fyyd//` lists **episodes** as tracks; + `/fyyd///` is the track leaf. Verified by + `root_lists_search_and_hot`, `search_terms_list_podcasts_as_children`, + `a_podcast_lists_its_episodes_as_tracks`, `hot_lists_featured_podcasts`. +- [x] A podcast listing node is not itself queueable; each podcast child is + queueable and downloadable (queue/capture a whole podcast). An episode + list node is queueable and downloadable. `is_track_path` is true only + for episode leaves. Verified by the tests above and + `tracks_resolve_streams_and_metadata`. +- [x] Search terms are creatable/renamable/deletable exactly like + tidal/youtube (in-memory, dedup, implicit recreation on stale paths), + and only `/fyyd/search` is creatable. Verified by + `search_terms_list_podcasts_as_children`, + `search_terms_rename_and_delete`, `foreign_and_malformed_paths_are_rejected`. +- [x] Playback resolves an episode to its `enclosure` URL; a track's + `artist` is the podcast title and `provider_item_id` is the fyyd + episode id. Verified by `tracks_resolve_streams_and_metadata`. +- [x] The download blessing is applied centrally (node downloadable when + queueable or track-bearing; children mirror queueability), so `W` + captures work with no capture-side change. + +## Robustness (hard rules) + +- [x] No panic on any input: malformed/foreign paths, empty create/rename + input, a missing enclosure, and every backend failure return a typed + `ProviderError`, never a panic. Verified by + `foreign_and_malformed_paths_are_rejected`, + `a_missing_enclosure_is_an_error_not_a_panic`, + `backend_failures_are_typed_never_panics`. +- [x] Every external call is bounded by a timeout (`call_timeout_secs`, + default 30) set on the `reqwest` client; every listing is capped + (`search_results`, `hot_count`, `episodes_per_podcast`). D5. +- [x] fyyd responses decode defensively: the `data` envelope is unwrapped, + DTO fields use `#[serde(default)]`, entries without a usable id are + dropped, and non-positive durations degrade to `None`. Failures are + typed `FetchError` → `ProviderError::FetchError`. +- [x] No secrets: fyyd's public API uses no credentials, so nothing secret + is logged; only public URLs/ids appear in traces. + +## Integration / operability + +- [x] `fyyd` is a registered provider: in `ALL_PROVIDERS`, `ProviderToggles` + (`all()` + `provider_toggles()`), and the default `crabidy-server.toml`. + Disabling it in the `providers` list drops the `/fyyd` subtree. Covered + by the existing settings tests (which iterate `ALL_PROVIDERS`). +- [x] Init is non-fatal: a client that fails to build disables `/fyyd` with + a warning and leaves every other provider and the server running (D1). + A missing `fyyd.toml` is normal (no credentials needed). +- [x] The orchestrator routes every `ProviderClient` method for `/fyyd` + paths to the fyyd client (owns-check + `fyyd_provider()`), and + `get_lib_root` lists `fyyd` only when the client is mounted. No proto + change and no new `ProviderCommand` were needed. + +## Live validation (deferred, needs network) + +- [ ] Against the real `api.fyyd.de`: a search returns podcasts, a podcast + lists episodes with non-empty `enclosure` URLs, and an enclosure plays + through the audio player. If a field name differs from the documented + shape, the fix is confined to `fyyd/src/api.rs` DTOs (design risk noted + in the architecture doc). This gate cannot run in the offline unit + suite.