217 lines
9.7 KiB
Markdown
217 lines
9.7 KiB
Markdown
# 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=<t>&count=<n>` |
|
|
| Podcast episodes | `GET /podcast/episodes?podcast_id=<id>&count=<n>` |
|
|
| Hot (featured) podcasts | `GET /feature/podcast/hot?count=<n>` |
|
|
| Single episode | `GET /episode?episode_id=<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<Arc<fyyd::Client>>`
|
|
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<dyn Fyyd>` — 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<Vec<String>>`, dedup, recreated implicitly on stale paths),
|
|
each `is_editable` + `is_deletable`, like tidal/youtube.
|
|
- `/fyyd/search/<term>` — lists the top-N matching **podcasts** as
|
|
children (containers, not tracks). Not directly queueable itself; each
|
|
podcast child *is* queueable.
|
|
- `/fyyd/search/<term>/<podcast-id>` — 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/<term>/<podcast-id>/<episode-id>` — 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/<podcast-id>` and `/fyyd/hot/<podcast-id>/<episode-id>` —
|
|
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<u32>`),
|
|
`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<String>` 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/<pid>)"
|
|
f -> api: "GET /podcast/episodes?podcast_id=<pid>"
|
|
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(.../<eid>)"
|
|
f -> api: "GET /episode?episode_id=<eid>"
|
|
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.
|