# 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.