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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 17:29:13 +02:00
parent a275d3bc77
commit 3b81faeb9d
18 changed files with 1732 additions and 7 deletions

15
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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

View File

@ -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=<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.

View File

@ -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

View File

@ -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<Arc<ytdy::Client>>,
/// The fyyd podcast provider; `None` when disabled or its client failed
/// to build (non-fatal, architecture/fyyd-provider.md D1).
fyyd_client: Option<Arc<fyyd::Client>>,
}
/// 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)
}

View File

@ -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"),

View File

@ -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)

View File

@ -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

View File

@ -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`.

View File

@ -0,0 +1,78 @@
# fyyd — /fyyd
<!-- toc -->
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/<term> → matching podcasts
/fyyd/search/<term>/<podcast> → 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/<branch>/<podcast>` — 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.

17
fyyd/Cargo.toml Normal file
View File

@ -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"] }

42
fyyd/README.md Normal file
View File

@ -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/<branch>/<podcast>`** 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.

265
fyyd/src/api.rs Normal file
View File

@ -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<String>,
pub duration: Option<u32>,
}
/// The fyyd operations the provider needs. Behind `Box<dyn Fyyd>` 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<Vec<Podcast>, FetchError>;
/// The current featured ("hot") podcasts (at most `limit`).
async fn hot_podcasts(&self, limit: usize) -> Result<Vec<Podcast>, 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<Episode>), 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<Episode, FetchError>;
}
/// 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<Self, FetchError> {
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<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, FetchError> {
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<T> = 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<Vec<Podcast>, FetchError> {
let count = limit.to_string();
let dtos: Vec<PodcastDto> = 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<Vec<Podcast>, FetchError> {
let count = limit.to_string();
let dtos: Vec<PodcastDto> = 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<Episode>), 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<Episode, FetchError> {
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<String> {
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<T> {
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<Podcast> {
(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<EpisodeDto>,
}
#[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<String>) -> Option<Episode> {
(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),
})
}
}

832
fyyd/src/lib.rs Normal file
View File

@ -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<usize>,
/// Featured podcasts under `/fyyd/hot`. Default [`DEFAULT_HOT_COUNT`].
pub hot_count: Option<usize>,
/// Episodes listed per podcast. Default
/// [`DEFAULT_EPISODES_PER_PODCAST`].
pub episodes_per_podcast: Option<usize>,
/// Per-request timeout in seconds. Default
/// [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
/// API base URL override (a mirror, or a test server). Default
/// [`DEFAULT_BASE_URL`].
pub base_url: Option<String>,
}
/// 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<FyydPath<'_>, 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<dyn Fyyd>,
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<Vec<String>>,
}
impl Client {
/// A client over any [`Fyyd`] backend — the seam the tests use.
fn with_api(api: Box<dyn Fyyd>, 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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<String> {
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<Self, ProviderError> {
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<Vec<String>, 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<Track, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<String, Vec<Podcast>>,
hot: Vec<Podcast>,
/// podcast id → (title, episodes).
episodes: HashMap<String, (String, Vec<Episode>)>,
/// episode id → episode (as the single-episode endpoint returns it).
single: HashMap<String, Episode>,
}
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<u32>) -> 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<Vec<Podcast>, 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<Vec<Podcast>, api::FetchError> {
Ok(self.hot.iter().take(limit).cloned().collect())
}
async fn podcast_episodes(
&self,
podcast_id: &str,
limit: usize,
) -> Result<(String, Vec<Episode>), 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<Episode, api::FetchError> {
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());
}
}

39
plan/fyyd-provider.md Normal file
View File

@ -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<dyn Fyyd>` +
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`.

View File

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

71
quality/fyyd-provider.md Normal file
View File

@ -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/<term>` and `/fyyd/hot` list **podcasts** as children
(not tracks); `/fyyd/<branch>/<podcast>` lists **episodes** as tracks;
`/fyyd/<branch>/<podcast>/<episode>` 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.