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:
parent
a275d3bc77
commit
3b81faeb9d
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"] }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue