Compare commits

..

No commits in common. "378066470ea0c0990ee4b3185b89b7085695fbd1" and "e2c1b44cdbdd80c89a468c5096ceaea06e261c2d" have entirely different histories.

43 changed files with 253 additions and 4641 deletions

50
Cargo.lock generated
View File

@ -783,10 +783,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@ -1175,7 +1173,6 @@ dependencies = [
"rand 0.10.2",
"realfft",
"reqwest 0.13.1",
"rssdy",
"serde",
"soundclouddy",
"tempfile",
@ -1637,23 +1634,6 @@ dependencies = [
"getrandom 0.4.3",
]
[[package]]
name = "feed-rs"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9"
dependencies = [
"chrono",
"mediatype",
"quick-xml",
"regex",
"serde",
"serde_json",
"siphasher",
"url",
"uuid",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@ -2871,9 +2851,6 @@ name = "mediatype"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
dependencies = [
"serde",
]
[[package]]
name = "memchr"
@ -3721,16 +3698,6 @@ dependencies = [
"pulldown-cmark",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"encoding_rs",
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
@ -4282,22 +4249,6 @@ dependencies = [
"cc",
]
[[package]]
name = "rssdy"
version = "0.1.0"
dependencies = [
"async-trait",
"blake3",
"crabidy-core",
"feed-rs",
"reqwest 0.13.1",
"serde",
"thiserror 2.0.19",
"tokio",
"toml",
"tracing",
]
[[package]]
name = "rstml"
version = "0.12.1"
@ -5950,7 +5901,6 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]

View File

@ -12,7 +12,6 @@ members = [
"fsdy",
"fyyd",
"jamendody",
"rssdy",
"soundclouddy",
"tidaldy",
"ytdy",
@ -45,7 +44,6 @@ http = "1"
include_dir = "0.7"
leptos = { version = "0.8", default-features = false, features = ["csr"] }
notify-rust = "4"
feed-rs = "2"
percent-encoding = "2"
prost = "0.14"
rand = "0.10"
@ -116,7 +114,6 @@ crabidy-server = { path = "crabidy-server", default-features = false }
fsdy = { path = "fsdy" }
fyyd = { path = "fyyd" }
jamendody = { path = "jamendody" }
rssdy = { path = "rssdy" }
soundclouddy = { path = "soundclouddy" }
tidaldy = { path = "tidaldy" }
ytdy = { path = "ytdy" }

View File

@ -13,7 +13,6 @@ each mounted as a subtree of one library:
├── fs a local music folder (fsdy/README.md)
├── fyyd podcast search (fyyd/README.md)
├── jamendo Creative-Commons music (jamendody/README.md)
├── rss podcast subscriptions (rssdy/README.md)
├── soundcloud SoundCloud (soundclouddy/README.md)
├── tidal Tidal streaming (tidaldy/README.md)
├── youtube YouTube search & playlists (ytdy/README.md)
@ -87,7 +86,6 @@ filled in.
| `fsdy.toml` | local files | — |
| `fyyd.toml` | podcasts | none needed |
| `jamendo.toml` | Jamendo | none (key shipped) |
| `rss.toml` | podcast feeds | the feed URLs |
| `soundcloud.toml` | SoundCloud | optional token |
| `tidaly.toml` | Tidal | device login |
| `ytdy.toml` | YouTube | optional cookies |
@ -156,7 +154,7 @@ On first start the server writes this file with every provider enabled:
```toml
providers = [
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", "rss",
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo",
"fs", "crabidy", "orphans",
]
```
@ -318,17 +316,7 @@ live as you type — `Enter` keeps the filter, `Esc` clears it.
`s` marks the selected row; `v` (or `V`) enters **visual mode**, where
movement marks or unmarks everything you sweep over, vim-style. The sweep
is anchored where you entered it, so moving back reverses it. `Esc` (or
any non-movement key) leaves visual mode. Both panes have marks and visual
mode.
In the queue this feeds a vim-style **register**: `y` yanks the selection
into it, `d` deletes the marked rows *into* it, and `c`/`C` fill it with
whatever they clear — so an accidental clear is recoverable. `p` pastes it
after the cursor and `P` before it, which makes `d` then `P` an exact undo
and `d``p` a move. The register is per client, in memory, one slot, and
holds paths — so a paste re-resolves (a yanked album expands to its
tracks). Note `p` no longer inserts the library selection: that flow is now
`y` on the left, then `p` on the right.
any non-movement key) leaves visual mode.
- `w` saves the selection (a library subtree, or in the queue pane the
queue) as a new folder under `/crabidy/<name>` of **link** files —

View File

@ -1,265 +0,0 @@
# Queue selection, visual mode, and the register
Marks and visual mode in the queue pane, paired with a vim-style
**register**: deleting or yanking queue entries puts them in the register,
pasting brings them back. One model, implemented in both clients.
## Context and problem statement
The library pane has marks (`s`) and visual mode (`v`/`V`); the queue pane
has neither — `d` removes exactly the row under the cursor, one at a time,
and `queue.rs` carries the standing note *"FIXME: mark multiple tracks on
queue and remove them"*. Clearing 200 tracks with `c`/`C`, or deleting the
wrong row, is unrecoverable: there is no undo anywhere in the system.
Meanwhile `p` in the queue means "insert the **library** selection after
this track" — a cross-pane action with no name for what it is inserting.
The ask is to close both gaps with one concept borrowed from vim: a client
register that deletes and yanks write to, and paste reads from. That turns
multi-delete into a safe operation (it is recoverable), gives "move these
three tracks down" a natural spelling (`d` then `P`), and gives `p` a single
meaning.
## Assumptions (confirmed)
- **Only explicit commands write the register**: `y` (both panes), and `d`,
`c`, `C` (queue). Marking or moving the cursor never writes it. This was
the one point of disagreement — an implicit fill from the library
selection would have kept `p` backward-compatible, but it means `s` in
the library silently clobbers a clipboard you are about to paste.
- **Both `p` (after cursor) and `P` (before cursor)**, so `d` then `P`
restores exactly what you deleted, and `d``p` is a move.
- **`c`/`C` fill the register too** — the destructive ops most worth
undoing.
- **One unnamed register**, but the type is shaped so named registers
(`"a`) are additive rather than a rewrite.
- **Both clients, now.** `cbd-web` gets queue marks, queue visual mode, and
the register — *plus* the library visual mode that was deferred when
`v`/`V` landed in the TUI, so the two clients do not drift further.
- The register is **per-client, in-memory, one level deep**. It is not a
server-side undo log; another client cannot undo your delete, and a
restart forgets it. Same expectations as vim.
## What the protocol already gives us
No `.proto` change, no server change. The two RPCs this feature needs are
already shaped for it:
- `Remove { positions: repeated uint32 }` — already takes **many**
positions, so multi-delete is a client-side gather.
- `Insert { position, paths: repeated string }` — takes a **path list**, so
a register of paths pastes with the existing call.
That is what makes this a client-only feature.
## Options considered
### What the register holds
1. **Resolved tracks** (`Track` messages, as deleted). Exact: what you
deleted is what comes back, and the UI can show real titles. But it
cannot express "the album node I yanked in the library", and pasting
still has to send paths, so the extra fidelity buys nothing at the wire.
2. **Paths, with titles alongside for display** (chosen). It is what both
RPCs speak; a yanked library *node* expands at paste time, which is a
feature (`y` an album, `p` it into the queue); and the register is one
`Vec<String>` plus labels.
The cost is honest and worth stating: paste **re-resolves**. A yanked
child of a search term whose term has since been deleted may not come
back, and a node's track count is unknown until paste, so the UI can
only say "3 entries", not "12 tracks".
### Keeping marks alive in a moving queue
This is the only genuinely new problem. The library never had it: a listing
is a snapshot you re-fetch deliberately. The queue is server-pushed and
rebuilt on **every** change — another client appending, playback advancing,
and each chunk of a streaming resolve.
1. **Remap marks by index.** Cheapest, and silently wrong: if playback
advanced and the queue shifted by one, `d` deletes the wrong tracks.
Rejected — a data-losing failure mode.
2. **Clear marks on every snapshot.** Never wrong, but a resolve streaming
in or another client's append wipes a selection mid-flow, which makes
the feature feel broken exactly when the queue is busy.
3. **Greedy in-order match on track path** (chosen). Walk the old and new
path lists together and carry a mark to the new position of the same
path; drop marks whose track is gone. Survives appends, removals, and
playback advancing. Duplicate paths (the same track queued twice) are
ambiguous by nature — in-order matching degrades sanely, keeping the
*n*-th occurrence marked. If the lists diverge past recognition (no
common prefix worth speaking of), clear rather than guess.
The safety rule that makes any of this sound: **positions handed to
`Remove` are always read off the newest snapshot**, never a remembered
index.
### Where marks live
The two clients are shaped differently and this is not worth papering over:
- `cbd-tui` owns a `Vec<UiItem>` per pane and already has `marked` on it
(the queue builds every row `marked: false` today).
- `cbd-web` owns **no** queue list at all — just `QueueCursor { selected }`,
with rows rendered straight from the server's `Queue` signal.
So the shared thing is the *rule*, not the struct: marks are a set of
positions plus the path list they were taken against, reconciled on each
snapshot. The TUI keeps them in its `UiItem`s (it rebuilds that list
anyway); the web client keeps a position set beside the signal. Both
implement the same reconciliation, and both unit-test it against the same
cases.
## Decisions
**D1 — `Register` holds paths plus display labels.** One unnamed slot:
```rust
struct Register {
/// Library paths, in the order they were yanked or deleted. Empty means
/// nothing to paste.
paths: Vec<String>,
/// Row labels for the status line only — never sent anywhere.
labels: Vec<String>,
}
```
Named registers stay additive: the owner becomes a small map keyed by a
register name, with `None` meaning the unnamed one. Not built now.
**D2 — Only `y`, `d`, `c`, `C` write the register.** Marks, visual mode,
and cursor movement never do. A write **overwrites**: there is no history
and no numbered registers.
**D3 — `p` pastes after the cursor, `P` before it.** Both are queue-only
(there is nothing to paste into a library listing). Paste sends
`Insert { position, paths }` and leaves the register intact, so you can
paste twice. `d` then `P` is an exact restore; `d``p` is a move.
**D4 — `y` works in both panes.** In the library it yanks the marked items,
or the cursor item, under the same `is_queable` gate that `a`/`Enter` use —
so yanking cannot put an unqueueable folder in the register. In the queue it
yanks the marked rows, or the cursor row. `y` clears the marks it consumed,
exactly as queueing does today.
**D5 — Queue marks reconcile per snapshot** by the greedy in-order path
match above, clearing on divergence. `Remove`/`Insert` positions always come
from the newest snapshot.
**D6 — `d` in the queue deletes every marked row** (or the cursor row when
nothing is marked), in one `Remove` call, and writes them to the register
first. `c`/`C` write the tracks they are about to drop.
**D7 — Queue visual mode mirrors the library's** anchored paint: `v` (and
`V`) enters, movement toggles the marks of the swept range against the
anchor so moving back reverses, `Esc` or any non-movement action leaves it.
The mark+visual logic is extracted so both panes share one implementation
per client, rather than a second copy in the queue.
One concrete wrinkle: queue rows are built `is_queable: false,
is_deletable: false`, so the library's mark gate would refuse all of them.
The extracted code takes the gate as a parameter — the queue's is "always
allowed".
**D8 — `cbd-web` reaches parity in the same change**, including the library
visual mode it does not have yet. Its queue marks live beside the server
signal (D-above); its keymap and help overlay gain the same rows as the
TUI's.
**D9 — Keys.** The queue gains `s`, `v`, `V`, `y`, `P`; the library gains
`y`. No chord collides with an existing one in either scope.
**D10 — `p` changes meaning, and that is a breaking change** to muscle
memory: it pastes the register instead of the library selection. The
browse→queue flows that do not go through `p` (`a` append, `L` queue-next,
`Enter` replace) are untouched, so the cost is confined to "insert what I
picked on the left at this exact position", which becomes `y` then `p`.
Documented in the key tables and in the book.
## Structure
```d2
direction: right
lib: Library pane {
libsel: "marked rows,\nelse cursor row\n(is_queable gate)"
}
q: Queue pane {
qsel: "marked rows,\nelse cursor row"
}
reg: "Register (per client, one slot)\npaths + labels" {
shape: cylinder
}
rpc: Server (unchanged) {
ins: "Insert { position, paths }"
rem: "Remove { positions }"
}
lib.libsel -> reg: "y"
q.qsel -> reg: "y · d · c · C"
q.qsel -> rpc.rem: "d · c · C\n(positions from the\nnewest snapshot)"
reg -> rpc.ins: "p (after cursor)\nP (before cursor)"
rpc -> q: "Queue snapshot\n(marks reconciled)"
```
Mark reconciliation, on every queue snapshot:
```d2
direction: down
snap: "Queue snapshot arrives"
cmp: "Walk old paths and new paths\nin order"
carry: "Carry each mark to the new\nposition of the same path"
drop: "Drop marks whose track is gone"
clear: "Clear all marks"
act: "d / y / c / C read positions\nfrom this snapshot only"
snap -> cmp
cmp -> carry: recognizable
cmp -> clear: "diverged past\nrecognition"
carry -> drop
drop -> act
clear -> act
```
## Boundaries and interfaces
- **Register** — owned by each client's app state, written only by the four
commands, read only by paste. No I/O, no server involvement; a pure value
that is trivially unit-testable.
- **Mark reconciliation** — one function per client, `(old_paths,
new_paths, marks) -> marks`, tested against: append, remove-before,
remove-marked, playback advance, streaming resolve, duplicate paths, and
wholesale replacement.
- **Pane selection** — both panes expose "the rows this action applies to"
the way the library's `get_selected` already does; `y`/`d` consume it.
- **Server** — untouched. This whole feature is two clients.
## Risks
- **Mark drift** deleting the wrong tracks. The reconciliation rule and the
newest-snapshot rule exist for this; it needs the strongest tests in the
feature.
- **`p`'s changed meaning** surprising existing users. Mitigated by leaving
`a`/`L`/`Enter` alone and documenting the change; not avoidable if `p` is
to have one meaning.
- **Silent paste shortfall** when a path no longer resolves — you paste 5
and get 4. The server already skips unresolvable paths in a resolve; the
clients should say what they pasted rather than claim success blindly.
- **Two implementations drifting** (the very thing D8 is fixing for visual
mode). Same rule, same test cases, both landed together.
- **Duplicate-path ambiguity** in reconciliation is inherent, not solvable
without a per-entry queue id in the proto. In-order matching is the
honest approximation; a queue id is the escape hatch if it ever bites.
## Open questions
None blocking. Deferred by choice: named registers (D1 leaves room), a
numbered/history register stack, `y` in the queue putting entries somewhere
persistent (that is what `w` and saved queues are for), and a per-entry
queue id in the proto to make reconciliation exact.

View File

@ -1,263 +0,0 @@
# RSS provider — /rss
Subscribe to podcast feeds by URL and play their episodes. Mounted at
`/rss` by a new `rssdy` crate. Listings are **never cached**: every visit
to a subscription fetches the feed, so a new episode shows up the moment
it is published.
## Context and problem statement
`/fyyd` already plays podcasts, but it is a *discovery* provider: it
searches fyyd's public directory and gets episode metadata from fyyd's
API. It cannot play a feed fyyd does not index, and it cannot play a
**private** feed at all.
Plenty of podcasts are distributed as a plain RSS URL, and paid ones as a
per-subscriber URL with a token in it:
```text
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
```
That URL *is* the credential. Anyone holding it has the subscription.
So the ask is: a list of feeds I subscribe to, each browsable as a folder
of episodes, always showing the newest ones, and safe to use with
tokened URLs.
## Assumptions (decided)
- **Subscriptions are configuration, not discovery.** There is no feed
search; you name the feeds you want. `%` on `/rss` adds one from a
pasted URL and persists it (D5), which is how you "subscribe to
several" without hand-editing TOML.
- **A feed URL is a secret**, because a premium URL is a bearer
credential. It is redacted from `Debug` and never logged, and — the
decision that shapes the whole tree — **it never appears in a library
path** (D1).
- **Nothing about feed content is cached** (D2). This is the explicit
requirement, and it has a client-side half people forget: both clients
cache library listings by path, so the server being fresh is not
enough.
- Feeds are read-only. No OPML import, no per-episode read state, no
"unplayed" tracking — this is a player, not a podcatcher.
## Options considered
### The parser
1. **`feed-rs` 2.4** (chosen) — one crate for RSS 2.0, RSS 1.0, RSS
0.x, Atom and JSON Feed, with the iTunes extensions mapped into its
model. Podcast feeds in the wild are not uniformly RSS 2.0, and this
is the only option that does not make us care.
2. `rss` + `atom_syndication` — two crates, two models, and we write the
normalisation. Rejected: more code for less coverage.
3. `quick-xml` by hand — full control, and a long tail of real-world
feed quirks to discover the hard way. Rejected.
### Episode identity — the interesting problem
A track path has to survive being **stored**: it goes into the queue, is
persisted in `current`, and a bookmark (`w`) writes it into a toml that
is replayed days later. It is also *displayed*. So the path may not
contain the feed URL, and it should not contain an arbitrary
publisher-chosen id verbatim (guids are unbounded and can be URLs
themselves).
1. **Enclosure URL in the path** (base64 or percent-encoded). Resolves
with no feed fetch, and a bookmark keeps working forever. Rejected on
two counts: a premium enclosure URL leaks its token into the UI, the
logs, and every saved queue; and paths become unreadable.
2. **Index in the feed** (`/rss/economist/3`). Stable only until the next
episode is published, which is the one thing guaranteed to happen.
Rejected — a bookmark would silently point at a different episode.
3. **A short hash of the episode's guid** (chosen), falling back to the
enclosure URL when a feed omits the guid: `/rss/<slug>/<16 hex>`.
Short, readable, token-free, and stable for as long as the publisher
keeps the guid stable (which is what guids are for). Resolving one
means fetching the feed and matching the hash — which we are doing
anyway (D2), and the memo (D3) keeps it to one fetch per action.
`blake3` is already a workspace dependency, and using it here keeps
the hash stable across builds (`DefaultHasher` explicitly is not).
### Freshness versus hammering the feed
"Always fetch" taken literally means listing a 40-episode feed, queueing
it, and playing a track costs 40+ feed fetches. Options:
1. A TTL cache. Rejected: it turns the requirement into a timing
question ("is 30 seconds fresh?") and gets it wrong twice.
2. **A listing-driven memo** (chosen, D3). A *listing* always fetches and
replaces the memo entry for that feed. Track lookups
(`get_urls_for_track`, `get_metadata_for_track`,
`resolve_tracks_into`) read the memo and only fetch when they miss.
Freshness is exactly "what a visit shows", with no clock involved, and
an episode you can see is an episode you can play.
## Decisions
**D1 — Subscriptions are `(name, url)` pairs in `rss.toml`; the path
carries a slug of the name.**
```toml
# Per-subscription. `name` is yours; the path uses a slug of it.
[[feeds]]
name = "The Economist Podcasts"
url = "https://feeds.economist.com/v1/rss/…/f74365b0-…"
[[feeds]]
name = "Cautionary Tales"
url = "https://feeds.example.org/cautionary-tales"
```
`/rss/the-economist-podcasts` is the node. The URL stays in the config
file and never reaches a path, a log line, or a saved queue. Duplicate
slugs get a numeric suffix; a feed whose entry has no usable url is
skipped with a warning at load.
**D2 — No caching of feed content, at either end.**
- The provider holds no listing cache: `get_lib_node("/rss/<slug>")`
fetches.
- **`/rss` joins `MUTABLE_ROOTS` in both clients** (`cbd-tui/src/rpc.rs`,
`cbd-web/src/state.rs`), which is what actually makes a re-visit
re-fetch. Without this the client answers from its own cache and the
server's freshness is invisible.
**D3 — One memo, written by listings, read by track lookups.** A bounded
map (last 8 feeds) of `slug -> episodes`, replaced on every listing of
that feed. Track lookups consult it and fetch on a miss. It is never
consulted to answer a listing.
**D4 — Episode key is `blake3(guid)[..16]`**, or `blake3(enclosure_url)`
when the feed omits a guid. `Track.provider_item_id` is set to the guid,
so the content store de-duplicates captures of the same episode across
visits and across feeds.
**D5 — `%`, `e`, `d` manage subscriptions.** `%` on `/rss` takes a pasted
feed URL, fetches it once, names the subscription from the feed's own
title (slug-deduped), appends it to `rss.toml` and writes the file back —
the same shape as SoundCloud's `resolve` node taking a URL as its
"title", and the same write-back the other providers already do for their
settings. `e` renames a subscription (the slug, and so the path, changes
with it). `d` unsubscribes: it removes the config entry and touches no
audio. A capture made from it stays under `/crabidy`.
**D6 — Bounded and defensive.** Per-request timeout
(`call_timeout_secs`, default 30), a response-size cap (`max_feed_bytes`,
default 8 MiB) so a hostile or broken feed cannot exhaust memory, and
`episodes_per_feed` (default 200). A malformed entry — no enclosure, no
title, unparseable date — is skipped with a warning; only a feed that
cannot be fetched or parsed at all is an error, and it fails just that
node, never the server.
**D7 — Behind the `rss` cargo feature**, default on, exactly like the
other providers: `dep:rssdy`, a `BUILT_IN_PROVIDERS` entry, a
`ProviderToggles` field, a name in `ALL_PROVIDERS`, and a row in
`check-features`.
**D8 — A separate crate, not part of `fyyd`.** They share only "an
episode is a track whose audio is an enclosure URL". Their config,
identity model, and caching differ completely; folding them together
would mean one crate with two personalities. If a third podcast source
ever lands, the shared piece to extract is the episode→`Track` mapping,
not the provider.
**D9 — Newest first.** Episodes are sorted by publication date
descending where dates parse, keeping feed order for the rest (a stable
sort, so a dateless feed lists exactly as published). Queueing a
subscription therefore plays newest first.
**D10 — Episodes stream directly, like fyyd's.** The enclosure URL is a
plain media file; the existing player streams it with no sidecar and no
new player component. Captures (`W`) download it into the content store.
## Structure
```d2
direction: right
config: "rss.toml\n[[feeds]] name + url\n(url is a credential)" {
shape: document
}
rssdy: "rssdy (/rss)" {
subs: "subscriptions\nname -> url"
memo: "episode memo\nslug -> episodes\n(last 8, listing-written)"
parse: "feed-rs\nRSS/Atom/JSON Feed"
}
feeds: "publisher feeds\n(https, tokened URLs)" { shape: cloud }
player: "audio player\n(enclosure URL)"
clients: "cbd-tui · cbd-web\n/rss is never cached client-side"
config -> rssdy.subs: loaded at init
clients -> rssdy: "GetLibraryNode(/rss/<slug>)"
rssdy.subs -> feeds: "fetch on every listing"
feeds -> rssdy.parse
rssdy.parse -> rssdy.memo: replaces the entry
rssdy.memo -> player: "enclosure URL for a track key"
```
Listing versus playing — the same feed, two paths through it:
```d2
direction: down
visit: "visit /rss/<slug>"
fetch: "fetch + parse the feed"
memo_w: "replace memo[slug]"
list: "list episodes, newest first\npath = /rss/<slug>/<hash(guid)>"
play: "play or queue an episode"
memo_r: "memo[slug] hit?"
enclosure: "enclosure URL -> player"
visit -> fetch
fetch -> memo_w
memo_w -> list
play -> memo_r
memo_r -> enclosure: hit
memo_r -> fetch: "miss (restart, or a\nbookmark replayed later)"
```
## Boundaries and interfaces
- **`rssdy::Client`** implements `ProviderClient` like every other
provider: `PROVIDER_ROOT = "/rss"`, `init(&str) -> Settings` with
write-back, and the node/track methods. Nothing new at the boundary.
- **`rssdy::api::Feeds` trait** wraps all network access (one method:
fetch and parse a feed URL), so the tree, slug, identity, and ordering
logic is unit-tested over a fake with no network — the pattern
`fyyd`/`absdy`/`soundclouddy` already use.
- **Server** — one more optional dependency and one more mount
registration. The mount registry means no dispatch code changes.
- **Clients** — one entry in each `MUTABLE_ROOTS`. No UI work: a
subscription is a queueable node like any other.
## Risks
- **A token leaking through a path or a log** is the failure that
actually matters. D1 and D4 keep URLs out of paths structurally, and
the redaction rule matches what SoundCloud and Jamendo already do for
signed media URLs.
- **Guid instability.** A publisher that regenerates guids per fetch
breaks bookmarks (the episode key changes). Nothing can fix that from
our side; `W` captures the audio, which survives it.
- **Episodes ageing out of a feed.** A bookmark to an episode the feed no
longer lists cannot resolve. Inherent to RSS; documented, and again the
reason to capture rather than bookmark things you want to keep.
- **`itunes:duration` coverage.** Durations come from whatever the feed
provides through `feed-rs`; absent duration is `None`, which the UI
already handles. To be confirmed against a real feed at implementation
time rather than assumed.
- **Feed size.** D6's byte cap is the guard; a 40 MiB feed is a bug in
someone else's publishing, not something to load into memory.
## Open questions
None blocking. Deferred by choice: OPML import/export, per-episode
played state, feed refresh in the background (there is no server-side
polling — a listing is the refresh), and `itunes:image`/artwork, which
the library model has no field for.

View File

@ -76,8 +76,7 @@ pub enum QueueCmd {
Show,
/// Append tracks/subtrees (by library path) to the end of the queue.
Append { paths: Vec<String> },
/// Insert tracks/subtrees at a position, pushing what was there down
/// (0 = front, past the end = append).
/// Insert tracks/subtrees at a position.
Insert { position: u32, paths: Vec<String> },
/// Replace the whole queue with the given tracks/subtrees.
Replace { paths: Vec<String> },

View File

@ -58,7 +58,6 @@ pub enum Action {
LibraryQueueAppend,
LibraryQueueReplace,
LibraryToggleMark,
LibraryYank,
/// Enter (or leave) visual mode: while active, movement toggles the mark
/// of every row it sweeps over, so a run of items is marked by `v` then
/// moving (architecture/visual-mode.md). Entering toggles the current row;
@ -98,11 +97,7 @@ pub enum Action {
/// library and queue scopes). A no-op when no filter is applied.
ClearSearch,
// Queue pane
QueueToggleMark,
QueueVisualMode,
QueueYank,
QueuePaste,
QueuePasteBefore,
QueueInsertHere,
QueueFirst,
QueueLast,
QueueNext,
@ -314,13 +309,6 @@ pub const BINDINGS: &[Binding] = &[
action: Action::LibraryVisualMode,
description: "Visual mode (same as v)",
},
Binding {
scope: Scope::Library,
mods: KeyModifiers::NONE,
code: KeyCode::Char('y'),
action: Action::LibraryYank,
description: "Yank selection into the register (paste with p in the queue)",
},
Binding {
scope: Scope::Library,
mods: KeyModifiers::NONE,
@ -448,47 +436,12 @@ pub const BINDINGS: &[Binding] = &[
action: Action::QueuePlaySelected,
description: "Play selected track",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
code: KeyCode::Char('s'),
action: Action::QueueToggleMark,
description: "Mark/unmark selection",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
code: KeyCode::Char('v'),
action: Action::QueueVisualMode,
description: "Visual mode: movement toggles marks (v/V; Esc to leave)",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('V'),
action: Action::QueueVisualMode,
description: "Visual mode (same as v)",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
code: KeyCode::Char('y'),
action: Action::QueueYank,
description: "Yank selection into the register",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::NONE,
code: KeyCode::Char('p'),
action: Action::QueuePaste,
description: "Paste the register after this track",
},
Binding {
scope: Scope::Queue,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('P'),
action: Action::QueuePasteBefore,
description: "Paste the register before this track",
action: Action::QueueInsertHere,
description: "Insert library selection after this track",
},
Binding {
scope: Scope::Queue,
@ -879,9 +832,9 @@ mod tests {
}
#[test]
fn v_and_v_enter_visual_in_both_panes() {
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual
// mode, in the library and — since the register landed — the queue.
fn v_and_v_enter_visual_in_library_only() {
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual mode
// in the library; neither is bound in the queue (no marks there).
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
assert_eq!(
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
@ -893,11 +846,11 @@ mod tests {
);
assert_eq!(
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
Some(Action::QueueVisualMode)
None
);
assert_eq!(
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
Some(Action::QueueVisualMode)
None
);
}
}

View File

@ -12,13 +12,9 @@ use ratatui::{
use super::bindings::{key_label, Scope, BINDINGS};
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
/// The modal's content: a usage blurb, two binding columns, and a close-keys
/// footer derived from the `Scope::Help` bindings.
///
/// The columns are balanced by row count, not by scope order: Global + Queue
/// on the left, Library on the right. With marks and the register the Queue
/// group is nearly as long as the Library group, and pairing the two of them
/// in one column overflowed any normal terminal.
/// The modal's content: a usage blurb, two binding columns (Global left,
/// Library + Queue right, so the whole table fits a typical frame), and a
/// close-keys footer derived from the `Scope::Help` bindings.
struct HelpContent {
usage: Vec<Line<'static>>,
left: Vec<Line<'static>>,
@ -34,11 +30,11 @@ impl HelpContent {
Line::from(""),
];
let mut left = group(Scope::Global, "Global");
left.push(Line::from(""));
left.extend(group(Scope::Queue, "Queue"));
let left = group(Scope::Global, "Global");
let right = group(Scope::Library, "Library");
let mut right = group(Scope::Library, "Library");
right.push(Line::from(""));
right.extend(group(Scope::Queue, "Queue"));
// All Help-scope chords close the modal; derive their labels instead
// of hardcoding key names.
@ -194,29 +190,14 @@ mod tests {
#[test]
fn help_lists_bindings_from_the_table() {
// Tall enough for the whole table: the two columns now need ~43 rows
// (see `a_short_frame_truncates_rather_than_panicking`).
let text = buffer_text(&render_to_buffer(100, 50));
let text = buffer_text(&render_to_buffer(100, 40));
// Spot-check one entry per scope, by description from BINDINGS.
assert!(text.contains("Quit"));
assert!(text.contains("Enter selected folder"));
assert!(text.contains("Remove selected track"));
assert!(text.contains("Paste the register after this track"));
assert!(text.contains("Close help"));
}
/// The modal clamps to the frame and truncates; it does not scroll and
/// does not panic. Pins the known limitation rather than hiding it: the
/// full table needs more rows than a short terminal has.
#[test]
fn a_short_frame_truncates_rather_than_panicking() {
let text = buffer_text(&render_to_buffer(100, 20));
// The head of the left column is still there…
assert!(text.contains("Quit"));
// …and the tail of the longest column is not.
assert!(!text.contains("Paste the register after this track"));
}
#[test]
fn help_explains_basic_usage() {
let text = buffer_text(&render_to_buffer(100, 40));

View File

@ -12,8 +12,8 @@ use ratatui::{
use crabidy_core::proto::crabidy::LibraryNode;
use super::{
Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
};
pub struct Library {
@ -110,11 +110,24 @@ impl Library {
let item = self.resolved()?;
(item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
}
/// The paths a queue action ships — the marked rows, or the gated cursor
/// row. Thin wrapper over [`MarkedPane::selection`], which both panes
/// share.
pub fn get_selected(&self) -> Option<Vec<String>> {
self.selection().map(|(paths, _labels)| paths)
// Marks live on the full list; a hidden marked item still counts
// (the filter narrows what you *see*, not what you already chose).
if self.list.iter().any(|i| i.marked) {
return Some(
self.list
.iter()
.filter(|i| i.marked)
.map(|i| i.path.to_string())
.collect(),
);
}
// Marks are gated on is_queable when set; the bare selection must be
// gated here too, or Enter on a plain folder ships a path the server
// can only resolve to nothing (silently ignored, like % / e / d on
// items without the capability).
let item = self.resolved()?;
item.is_queable.then(|| vec![item.path.to_string()])
}
pub fn ascend(&mut self) {
if let Some(parent) = self.parent.as_ref() {
@ -154,9 +167,41 @@ impl Library {
}
}
}
pub fn queue_insert(&mut self, pos: usize) {
if let Some(items) = self.get_selected() {
match self.tx.send(MessageFromUi::InsertTracks(items, pos)) {
Ok(_) => self.remove_marks(),
Err(_) => { /* FIXME: warn */ }
}
}
}
pub fn prev_selected(&self) -> usize {
*self.positions.get(&self.path).unwrap_or(&0)
}
pub fn toggle_mark(&mut self) {
if let Some(view) = self.list_state.selected() {
self.toggle_mark_view(view);
}
}
/// Toggle the mark of the row at a **view** index (mapped through the `/`
/// filter to its real row), honoring the `is_queable` gate. Shared by
/// `toggle_mark` and visual-mode painting.
fn toggle_mark_view(&mut self, view: usize) {
if let Some(real) = self.filter.to_real(view) {
let item = &mut self.list[real];
if !item.is_queable {
return;
}
item.marked = !item.marked;
}
}
/// Whether visual (paint-select) mode is active.
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// Titles of the currently marked rows, in list order (inspection helper).
pub fn marked_titles(&self) -> Vec<String> {
self.list
@ -166,6 +211,56 @@ impl Library {
.collect()
}
/// Enter or leave visual mode. Entering anchors at the current row and
/// toggles its mark (vim includes the row you start on, D2); leaving keeps
/// the marks.
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.list_state.selected().unwrap_or(0));
self.toggle_mark();
}
}
/// Leave visual mode (marks kept). Idempotent.
pub fn exit_visual(&mut self) {
self.visual = None;
}
/// The current cursor position as a view index.
pub fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Repaint after a visual-mode move from `from_view` to `to_view`. The
/// selection is the contiguous range `[anchor, cursor]`; a move that grows
/// or shrinks it toggles exactly the rows whose range membership changed
/// (relative to the anchor), so moving back reverses a move cleanly and the
/// row you turn around on is never stranded. No-op unless visual is active.
pub fn paint_between(&mut self, from_view: usize, to_view: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&view);
let in_new = (new_lo..=new_hi).contains(&view);
if in_old != in_new {
self.toggle_mark_view(view);
}
}
}
pub fn remove_marks(&mut self) {
if self.list.iter().any(|i| i.marked) {
self.list
.iter_mut()
.filter(|i| i.marked)
.for_each(|i| i.marked = false);
}
}
pub fn update(&mut self, node: LibraryNode) {
// Creatable nodes (e.g. an empty search node) must be enterable even
// with nothing in them — the user goes there to create children.
@ -346,32 +441,6 @@ impl StatefulList for Library {
}
}
impl MarkedPane for Library {
fn items(&self) -> &[UiItem] {
&self.list
}
fn items_mut(&mut self) -> &mut [UiItem] {
&mut self.list
}
fn filter(&self) -> &Filter {
&self.filter
}
fn visual(&self) -> Option<usize> {
self.visual
}
fn set_visual(&mut self, anchor: Option<usize>) {
self.visual = anchor;
}
fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Only queueable rows may be marked: a marked plain folder would ship a
/// path the server can resolve to nothing.
fn markable(&self, item: &UiItem) -> bool {
item.is_queable
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -163,256 +163,3 @@ pub trait StatefulList {
}
}
}
/// Marks and visual mode for a pane that owns a `Vec<UiItem>` and a `/`
/// filter — shared by the library and the queue
/// (architecture/queue-register.md D7).
///
/// The required methods are the pane's own state; everything else is
/// behaviour both panes must agree on. Marks live on the **full** list, so a
/// marked-but-filtered-out row still counts; the view indices these methods
/// take are mapped through the filter.
pub(crate) trait MarkedPane {
/// Every row, in real (unfiltered) order.
fn items(&self) -> &[super::UiItem];
fn items_mut(&mut self) -> &mut [super::UiItem];
fn filter(&self) -> &Filter;
/// The visual-mode anchor (a **view** index), or `None` when off.
fn visual(&self) -> Option<usize>;
fn set_visual(&mut self, anchor: Option<usize>);
/// The cursor's **view** index.
fn selected_view(&self) -> Option<usize>;
/// Whether a row may be marked at all. The library gates on
/// `is_queable`; the queue allows every row.
fn markable(&self, item: &super::UiItem) -> bool;
fn is_visual(&self) -> bool {
self.visual().is_some()
}
/// Enter or leave visual mode. Entering anchors at the cursor and toggles
/// its mark (vim includes the row you start on); leaving keeps the marks.
fn toggle_visual(&mut self) {
if self.is_visual() {
self.set_visual(None);
} else {
self.set_visual(Some(self.selected_view().unwrap_or(0)));
self.toggle_mark();
}
}
fn exit_visual(&mut self) {
self.set_visual(None);
}
/// Toggle the mark of the cursor row.
fn toggle_mark(&mut self) {
if let Some(view) = self.selected_view() {
self.toggle_mark_view(view);
}
}
/// Toggle the mark of one **view** row, honoring [`Self::markable`].
fn toggle_mark_view(&mut self, view: usize) {
let Some(real) = self.filter().to_real(view) else {
return;
};
let Some(item) = self.items().get(real) else {
return;
};
if !self.markable(item) {
return;
}
if let Some(item) = self.items_mut().get_mut(real) {
item.marked = !item.marked;
}
}
/// Paint a visual-mode sweep. The selection is the contiguous range
/// `[anchor, cursor]`; a move that grows or shrinks it toggles exactly the
/// rows whose range membership changed, so moving back reverses a move
/// cleanly and the row you turn around on is never stranded. No-op unless
/// visual mode is active.
fn paint_between(&mut self, from_view: usize, to_view: usize) {
let Some(anchor) = self.visual() else {
return;
};
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&view);
let in_new = (new_lo..=new_hi).contains(&view);
if in_old != in_new {
self.toggle_mark_view(view);
}
}
}
fn has_marks(&self) -> bool {
self.items().iter().any(|i| i.marked)
}
fn remove_marks(&mut self) {
for item in self.items_mut() {
item.marked = false;
}
}
/// The rows an action applies to: every marked row, or the cursor row
/// when nothing is marked. Returns `(paths, labels)` — labels are for
/// display only. `None` when nothing applies (an unmarkable cursor row).
///
/// Marks live on the full list, so a marked-but-filtered-out row still
/// counts: the filter narrows what you *see*, not what you already chose.
fn selection(&self) -> Option<(Vec<String>, Vec<String>)> {
if self.has_marks() {
let marked = self.items().iter().filter(|i| i.marked);
return Some((
marked.clone().map(|i| i.path.clone()).collect(),
marked.map(|i| i.title.clone()).collect(),
));
}
// The bare cursor row must pass the same gate the marks do, or a
// plain folder would ship a path the server resolves to nothing.
let real = self.filter().to_real(self.selected_view()?)?;
let item = self.items().get(real)?;
self.markable(item)
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
}
}
/// Carry queue marks across a server snapshot by a greedy in-order match on
/// track path (architecture/queue-register.md D5).
///
/// Returns the mark flags for `new_paths`. A mark follows its track through
/// appends, removals, and playback advancing; a mark whose track is gone is
/// dropped. Duplicate paths are inherently ambiguous — the *n*-th occurrence
/// keeps the *n*-th occurrence's mark. When the two lists share no marked
/// track at all, the result is all-unmarked rather than a guess.
pub(crate) fn carry_marks(
old_paths: &[String],
old_marked: &[bool],
new_paths: &[String],
) -> Vec<bool> {
let mut carried = vec![false; new_paths.len()];
// Walk both lists forward, pairing equal paths. Insertions in `new` and
// removals from `old` are skipped over, so a mark follows its own track
// rather than its old index; duplicates pair up in order.
let mut old_idx = 0;
for (new_idx, path) in new_paths.iter().enumerate() {
while old_idx < old_paths.len() && &old_paths[old_idx] != path {
old_idx += 1;
}
if old_idx < old_paths.len() {
// A short `old_marked` (caller bookkeeping drift) reads as
// unmarked rather than panicking.
carried[new_idx] = old_marked.get(old_idx).copied().unwrap_or(false);
old_idx += 1;
}
}
carried
}
#[cfg(test)]
mod carry_marks_tests {
use super::carry_marks;
fn paths(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
/// The plain case: nothing moved, marks stay put.
#[test]
fn an_unchanged_queue_keeps_its_marks() {
let old = paths(&["/a", "/b", "/c"]);
assert_eq!(
carry_marks(&old, &[false, true, false], &old),
vec![false, true, false]
);
}
/// Another client appended: marks must not slide.
#[test]
fn an_append_leaves_earlier_marks_alone() {
let old = paths(&["/a", "/b"]);
let new = paths(&["/a", "/b", "/c"]);
assert_eq!(
carry_marks(&old, &[false, true], &new),
vec![false, true, false]
);
}
/// Playback advanced and dropped the head: the mark follows its track to
/// its new index instead of staying on a number.
#[test]
fn a_removal_before_a_mark_shifts_it_down() {
let old = paths(&["/a", "/b", "/c"]);
let new = paths(&["/b", "/c"]);
assert_eq!(
carry_marks(&old, &[false, false, true], &new),
vec![false, true]
);
}
/// The marked track itself is gone (we just deleted it).
#[test]
fn a_removed_marked_track_drops_its_mark() {
let old = paths(&["/a", "/b", "/c"]);
let new = paths(&["/a", "/c"]);
assert_eq!(
carry_marks(&old, &[false, true, false], &new),
vec![false, false]
);
}
/// A resolve streaming in grows the tail; a selection made meanwhile
/// survives.
#[test]
fn a_streaming_resolve_keeps_the_selection() {
let old = paths(&["/a", "/b"]);
let new = paths(&["/a", "/b", "/c", "/d", "/e"]);
assert_eq!(
carry_marks(&old, &[true, true], &new),
vec![true, true, false, false, false]
);
}
/// The same track queued twice: the n-th occurrence keeps the n-th mark.
#[test]
fn duplicate_paths_match_in_order() {
let old = paths(&["/a", "/a", "/a"]);
let new = paths(&["/a", "/a", "/a"]);
assert_eq!(
carry_marks(&old, &[false, true, false], &new),
vec![false, true, false]
);
}
/// A wholesale replacement shares nothing: clear rather than guess.
#[test]
fn a_replaced_queue_clears_marks() {
let old = paths(&["/a", "/b", "/c"]);
let new = paths(&["/x", "/y"]);
assert_eq!(carry_marks(&old, &[true, true, true], &new), vec![false; 2]);
}
#[test]
fn an_emptied_queue_has_no_marks() {
let old = paths(&["/a", "/b"]);
assert!(carry_marks(&old, &[true, true], &[]).is_empty());
}
/// A first snapshot has no history to carry.
#[test]
fn no_previous_marks_yields_none() {
let new = paths(&["/a", "/b"]);
assert_eq!(carry_marks(&[], &[], &new), vec![false, false]);
}
/// Length mismatches in the caller's bookkeeping must not panic.
#[test]
fn a_short_mark_vector_is_tolerated() {
let old = paths(&["/a", "/b", "/c"]);
assert_eq!(carry_marks(&old, &[true], &old), vec![true, false, false]);
}
}

View File

@ -4,7 +4,6 @@ mod library;
mod list;
mod now_playing;
mod queue;
mod register;
use flume::Sender;
use ratatui::{
@ -19,14 +18,12 @@ use crabidy_core::proto::crabidy::{
InitResponse as InitialData, LibraryNode,
};
pub(crate) use list::{carry_marks, MarkedPane};
pub use list::{Filter, StatefulList};
use bindings::Action;
use library::Library;
use now_playing::NowPlaying;
use queue::Queue;
pub use register::Register;
#[derive(Clone, Copy)]
pub enum UiFocus {
@ -40,7 +37,7 @@ enum UiItemKind {
Track,
}
pub(crate) struct UiItem {
struct UiItem {
path: String,
title: String,
kind: UiItemKind,
@ -280,9 +277,6 @@ pub struct App {
pub library: Library,
pub now_playing: NowPlaying,
pub queue: Queue,
/// What `y`/`d`/`c`/`C` set aside and `p`/`P` paste back
/// (architecture/queue-register.md). Per client, one slot, in memory.
pub register: Register,
tx: Sender<MessageFromUi>,
}
@ -300,7 +294,6 @@ impl App {
library,
now_playing,
queue,
register: Register::default(),
tx,
}
}
@ -423,50 +416,12 @@ impl App {
}
}
/// Run a queue cursor move, painting the swept `(old, new]` range when
/// visual mode is active — the queue mirror of [`Self::library_move`].
fn queue_move(&mut self, mv: impl FnOnce(&mut Queue)) {
if self.queue.is_visual() {
let old = self.queue.selected_view();
mv(&mut self.queue);
let new = self.queue.selected_view();
if let (Some(old), Some(new)) = (old, new) {
self.queue.paint_between(old, new);
}
} else {
mv(&mut self.queue);
}
}
/// `p`/`P`: insert the register at the cursor. Nothing to paste is a
/// no-op, not an empty round trip; the register survives so the same
/// yank can be pasted again (architecture/queue-register.md D3).
fn paste(&mut self, before: bool) {
if self.register.is_empty() {
return;
}
let pos = self.queue.paste_position(before);
let _ = self.tx.send(MessageFromUi::InsertTracks(
self.register.paths().to_vec(),
pos,
));
}
/// Put `entries` in the register, unless a command produced nothing.
fn set_register(&mut self, entries: (Vec<String>, Vec<String>)) {
if !entries.0.is_empty() {
self.register.set(entries.0, entries.1);
}
}
pub fn dispatch(&mut self, action: Action) -> DispatchResult {
// Visual mode (library paint-select): the six movements paint (handled
// in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other
// action leaves visual mode before running (architecture/visual-mode.md).
// `Esc` leaves visual mode instead of clearing the `/` filter, so the
// arms below need to know a pane was painting before the guards run.
let was_visual = self.library.is_visual() || self.queue.is_visual();
if self.library.is_visual()
let was_visual = self.library.is_visual();
if was_visual
&& !matches!(
action,
Action::LibraryFirst
@ -481,22 +436,6 @@ impl App {
{
self.library.exit_visual();
}
if self.queue.is_visual()
&& !matches!(
action,
Action::QueueFirst
| Action::QueueLast
| Action::QueueNext
| Action::QueuePrev
| Action::QueueJumpDown
| Action::QueueJumpUp
| Action::QueueSelectCurrent
| Action::QueueVisualMode
| Action::ClearSearch
)
{
self.queue.exit_visual();
}
match action {
Action::Quit => return DispatchResult::Quit,
Action::OpenHelp => self.show_help = true,
@ -615,7 +554,6 @@ impl App {
// In visual mode `Esc` leaves the mode (marks kept) and
// does not also clear the search filter.
self.library.exit_visual();
self.queue.exit_visual();
} else {
// `Esc` in normal navigation clears the focused pane's
// filter, restoring the full listing. A no-op when nothing
@ -626,40 +564,24 @@ impl App {
}
}
}
Action::QueueFirst => self.queue_move(|q| q.first()),
Action::QueueLast => self.queue_move(|q| q.last()),
Action::QueueNext => self.queue_move(|q| q.next()),
Action::QueuePrev => self.queue_move(|q| q.prev()),
Action::QueueJumpDown => self.queue_move(|q| q.down()),
Action::QueueJumpUp => self.queue_move(|q| q.up()),
Action::QueueSelectCurrent => self.queue_move(|q| q.select_current()),
Action::QueueInsertHere => {
if let Some(selected) = self.queue.selected() {
self.library.queue_insert(selected);
}
}
Action::QueueFirst => self.queue.first(),
Action::QueueLast => self.queue.last(),
Action::QueueNext => self.queue.next(),
Action::QueuePrev => self.queue.prev(),
Action::QueueJumpDown => self.queue.down(),
Action::QueueJumpUp => self.queue.up(),
Action::QueueSelectCurrent => self.queue.select_current(),
Action::QueuePlaySelected => self.queue.play_selected(),
Action::QueueRemoveTrack => {
let removed = self.queue.remove_track();
self.set_register(removed);
}
Action::QueueToggleMark => self.queue.toggle_mark(),
Action::QueueVisualMode => self.queue.toggle_visual(),
Action::QueueYank => {
let yanked = self.queue.yank();
self.set_register(yanked);
}
Action::QueuePaste => self.paste(false),
Action::QueuePasteBefore => self.paste(true),
Action::LibraryYank => {
if let Some((paths, labels)) = self.library.selection() {
self.register.set(paths, labels);
self.library.remove_marks();
}
}
Action::QueueRemoveTrack => self.queue.remove_track(),
Action::QueueClearKeepCurrent => {
let dropped = self.queue.all_entries(true);
self.set_register(dropped);
let _ = self.tx.send(MessageFromUi::ClearQueue(true));
}
Action::QueueClearAll => {
let dropped = self.queue.all_entries(false);
self.set_register(dropped);
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
}
Action::QueueSaveAs => {
@ -720,8 +642,7 @@ impl App {
.constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref())
.split(main[1]);
self.queue
.render(f, right_side[0], queue_focused, self.register.len());
self.queue.render(f, right_side[0], queue_focused);
self.now_playing.render(f, right_side[1]);
// The node-creation/rename input: one line inside the bottom of the
@ -1900,226 +1821,4 @@ mod tests {
Ok(MessageFromUi::ClearQueue(false))
));
}
/// A three-track queue for the register/mark tests.
fn queue_of(paths: &[&str]) -> crabidy_core::proto::crabidy::Queue {
use crabidy_core::proto::crabidy::{Queue as ProtoQueue, Track};
ProtoQueue {
timestamp: 0,
current_position: 0,
tracks: paths
.iter()
.enumerate()
.map(|(i, path)| Track {
path: path.to_string(),
artist: "artist".to_string(),
title: format!("t{i}"),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
})
.collect(),
resolving: false,
}
}
fn filled_queue_app() -> (App, Receiver<MessageFromUi>) {
let (mut app, rx) = app();
app.queue.update_queue(queue_of(&["/a", "/b", "/c"]));
app.focus = UiFocus::Queue;
app.queue.select(Some(0));
(app, rx)
}
/// `d` removes every marked row in one call, with positions read off the
/// current list (quality/queue-register.md G1).
#[test]
fn deleting_marked_rows_sends_their_current_positions() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueToggleMark); // row 0
let _ = app.dispatch(Action::QueueNext);
let _ = app.dispatch(Action::QueueNext);
let _ = app.dispatch(Action::QueueToggleMark); // row 2
let _ = app.dispatch(Action::QueueRemoveTrack);
match rx.try_recv() {
Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![0, 2]),
_ => panic!("expected RemoveTracks"),
}
// The rows it acted on are handed to the register, and the marks are
// consumed.
assert_eq!(app.register.paths(), ["/a", "/c"]);
assert!(!app.queue.has_marks());
}
/// With nothing marked, `d` still removes just the cursor row (G15).
#[test]
fn deleting_without_marks_removes_the_cursor_row() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueNext);
let _ = app.dispatch(Action::QueueRemoveTrack);
match rx.try_recv() {
Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![1]),
_ => panic!("expected RemoveTracks"),
}
assert_eq!(app.register.paths(), ["/b"]);
}
/// A marked row hidden by the `/` filter still counts (G13).
#[test]
fn a_filtered_out_marked_row_is_still_deleted() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueToggleMark); // mark "t0"
app.queue.set_filter(Some("t2".to_string()));
let _ = app.dispatch(Action::QueueRemoveTrack);
match rx.try_recv() {
Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![0]),
_ => panic!("expected RemoveTracks"),
}
}
/// `y` fills the register without removing anything (G5, G14).
#[test]
fn yanking_fills_the_register_and_sends_nothing() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueToggleMark);
let _ = app.dispatch(Action::QueueYank);
assert_eq!(app.register.paths(), ["/a"]);
assert_eq!(app.register.labels(), ["artist - t0"]);
assert!(!app.queue.has_marks());
assert!(rx.try_recv().is_err(), "yank must not talk to the server");
}
/// `c`/`C` hand what they drop to the register first (G4).
#[test]
fn clear_fills_the_register_with_what_it_dropped() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueClearAll);
assert_eq!(app.register.paths(), ["/a", "/b", "/c"]);
assert!(matches!(
rx.try_recv(),
Ok(MessageFromUi::ClearQueue(false))
));
// `c` keeps the current track, so it is not in the register either.
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueClearKeepCurrent);
assert_eq!(app.register.paths(), ["/b", "/c"]);
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ClearQueue(true))));
}
/// Nothing but `y`/`d`/`c`/`C` writes the register (G5).
#[test]
fn marking_and_queueing_do_not_touch_the_register() {
let (mut app, _rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueYank);
let before = app.register.clone();
for action in [
Action::QueueToggleMark,
Action::QueueVisualMode,
Action::QueueNext,
Action::QueuePrev,
Action::TogglePlay,
Action::QueueSelectCurrent,
] {
let _ = app.dispatch(action);
}
assert_eq!(app.register, before);
}
/// `p` inserts after the cursor, `P` before it (G8).
#[test]
fn paste_after_and_before_use_the_right_position() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueNext); // cursor on position 1
let _ = app.dispatch(Action::QueueYank);
let _ = app.dispatch(Action::QueuePaste);
match rx.try_recv() {
Ok(MessageFromUi::InsertTracks(paths, pos)) => {
assert_eq!(paths, vec!["/b".to_string()]);
assert_eq!(pos, 2, "p pastes after the cursor");
}
_ => panic!("expected InsertTracks"),
}
let _ = app.dispatch(Action::QueuePasteBefore);
match rx.try_recv() {
Ok(MessageFromUi::InsertTracks(_, pos)) => {
assert_eq!(pos, 1, "P pastes before the cursor");
}
_ => panic!("expected InsertTracks"),
}
}
/// The register survives a paste, so the same yank pastes twice (G7).
#[test]
fn pasting_twice_inserts_twice() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueYank);
let _ = app.dispatch(Action::QueuePaste);
let _ = app.dispatch(Action::QueuePaste);
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::InsertTracks(..))));
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::InsertTracks(..))));
assert!(!app.register.is_empty());
}
/// Deleting then pasting before restores the rows where they were (G8).
#[test]
fn delete_then_paste_before_restores_the_positions() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueNext); // cursor on position 1
let _ = app.dispatch(Action::QueueRemoveTrack);
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RemoveTracks(_))));
// The server echoes the shortened queue; the cursor stays on 1, which
// is where the deleted row belongs.
app.queue.update_queue(queue_of(&["/a", "/c"]));
let _ = app.dispatch(Action::QueuePasteBefore);
match rx.try_recv() {
Ok(MessageFromUi::InsertTracks(paths, pos)) => {
assert_eq!(paths, vec!["/b".to_string()]);
assert_eq!(pos, 1);
}
_ => panic!("expected InsertTracks"),
}
}
/// An empty register pastes nothing at all (G9).
#[test]
fn pasting_an_empty_register_sends_nothing() {
let (mut app, rx) = filled_queue_app();
let _ = app.dispatch(Action::QueuePaste);
let _ = app.dispatch(Action::QueuePasteBefore);
assert!(rx.try_recv().is_err());
}
/// `y` in the library fills the register under the queueing gate (G5).
#[test]
fn library_yank_fills_the_register() {
let (mut app, rx) = app();
app.library.update(children_listing(&["alpha", "beta"]));
app.library.select(Some(0));
let _ = app.dispatch(Action::LibraryYank);
assert!(!app.register.is_empty());
assert!(rx.try_recv().is_err(), "yank must not talk to the server");
}
/// Queue visual mode paints the swept range, and `Esc` leaves it keeping
/// the marks (G11).
#[test]
fn queue_visual_mode_paints_and_esc_leaves_it() {
let (mut app, _rx) = filled_queue_app();
let _ = app.dispatch(Action::QueueVisualMode);
assert!(app.queue.is_visual());
let _ = app.dispatch(Action::QueueNext);
let _ = app.dispatch(Action::QueueNext);
assert_eq!(app.queue.marked_positions(), vec![0, 1, 2]);
// Sweeping back reverses.
let _ = app.dispatch(Action::QueuePrev);
assert_eq!(app.queue.marked_positions(), vec![0, 1]);
let _ = app.dispatch(Action::ClearSearch);
assert!(!app.queue.is_visual());
assert_eq!(app.queue.marked_positions(), vec![0, 1]);
}
}

View File

@ -10,8 +10,8 @@ use ratatui::{
use crabidy_core::proto::crabidy::Queue as QueueData;
use super::{
carry_marks, Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
COLOR_RED, COLOR_SECONDARY,
};
pub struct Queue {
@ -28,9 +28,6 @@ pub struct Queue {
/// render time — it never enters `list`, so selection and removal
/// cannot reach it.
resolving: bool,
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
/// as in the library (architecture/queue-register.md D7).
visual: Option<usize>,
tx: Sender<MessageFromUi>,
}
@ -42,7 +39,6 @@ impl Queue {
list_state: ListState::default(),
filter: Filter::default(),
resolving: false,
visual: None,
tx,
}
}
@ -88,88 +84,10 @@ impl Queue {
self.select(Some(view));
}
}
/// The real queue positions an action applies to: every marked row, or
/// the cursor row when nothing is marked. Positions are read off the
/// **current** list, so they always match the newest snapshot
/// (architecture/queue-register.md D5).
fn action_positions(&self) -> Vec<usize> {
if self.has_marks() {
return self
.list
.iter()
.enumerate()
.filter(|(_, item)| item.marked)
.map(|(pos, _)| pos)
.collect();
}
self.selected_position().into_iter().collect()
}
/// `d`: remove the marked rows (or the cursor row), handing them to the
/// register first so `p`/`P` can bring them back.
pub fn remove_track(&mut self) -> (Vec<String>, Vec<String>) {
let positions = self.action_positions();
if positions.is_empty() {
return (Vec::new(), Vec::new());
}
let yanked = self.entries_at(&positions);
if self.tx.send(MessageFromUi::RemoveTracks(positions)).is_ok() {
self.remove_marks();
}
yanked
}
/// `y`: put the marked rows (or the cursor row) in the register without
/// removing anything. Consumes the marks, like queueing does.
pub fn yank(&mut self) -> (Vec<String>, Vec<String>) {
let yanked = self.entries_at(&self.action_positions());
if !yanked.0.is_empty() {
self.remove_marks();
}
yanked
}
/// Real positions of the marked rows, in queue order (inspection helper,
/// mirroring the library's `marked_titles`).
pub fn marked_positions(&self) -> Vec<usize> {
self.list
.iter()
.enumerate()
.filter(|(_, item)| item.marked)
.map(|(pos, _)| pos)
.collect()
}
/// Paths and labels of the given real positions, in queue order.
fn entries_at(&self, positions: &[usize]) -> (Vec<String>, Vec<String>) {
let mut paths = Vec::with_capacity(positions.len());
let mut labels = Vec::with_capacity(positions.len());
for pos in positions {
if let Some(item) = self.list.get(*pos) {
paths.push(item.path.clone());
labels.push(item.title.clone());
}
}
(paths, labels)
}
/// Every queue entry, for `c`/`C` to hand to the register before the
/// server drops them. `keep_current` mirrors the RPC's flag.
pub fn all_entries(&self, keep_current: bool) -> (Vec<String>, Vec<String>) {
let positions: Vec<usize> = (0..self.list.len())
.filter(|pos| !(keep_current && *pos == self.current_position))
.collect();
self.entries_at(&positions)
}
/// The insert position for a paste: after the cursor for `p`, at the
/// cursor for `P` (which restores what `d` just removed). An empty queue
/// pastes at the front.
pub fn paste_position(&self, before: bool) -> usize {
match self.selected_position() {
Some(pos) if before => pos,
Some(pos) => pos + 1,
None => 0,
pub fn remove_track(&mut self) {
if let Some(pos) = self.selected_position() {
// FIXME: mark multiple tracks on queue and remove them
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
}
}
pub fn update_position(&mut self, pos: usize) {
@ -178,23 +96,14 @@ impl Queue {
pub fn update_queue(&mut self, queue: QueueData) {
self.current_position = queue.current_position as usize;
self.resolving = queue.resolving;
// The queue is server-pushed and rebuilt on every change, so marks
// are carried across by matching track paths rather than indices —
// otherwise a mark would silently retarget when playback advances
// (architecture/queue-register.md D5).
let old_paths: Vec<String> = self.list.iter().map(|i| i.path.clone()).collect();
let old_marked: Vec<bool> = self.list.iter().map(|i| i.marked).collect();
let new_paths: Vec<String> = queue.tracks.iter().map(|t| t.path.clone()).collect();
let carried = carry_marks(&old_paths, &old_marked, &new_paths);
self.list = queue
.tracks
.iter()
.enumerate()
.map(|(idx, t)| UiItem {
.map(|t| UiItem {
path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: carried.get(idx).copied().unwrap_or(false),
marked: false,
is_queable: false,
is_creatable: false,
is_editable: false,
@ -212,10 +121,7 @@ impl Queue {
self.update_selection();
}
/// Draws the pane. `register_len` is the number of entries `p`/`P` would
/// paste; a non-zero count is shown in the title so a paste is never
/// blind.
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool, register_len: usize) {
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
let selected = self.list_state.selected();
// Render only the visible rows; `view` is the rendered index the
// selection bar keys off, `real` the queue position (which drives
@ -229,24 +135,13 @@ impl Queue {
.map(|(idx, (real, item))| {
let active = real == self.current_position;
// Markers, in the library's vocabulary: `>` is the playing
// track, `*` is a mark (`s`, or painted in visual mode). A
// row can be both.
let mut title = String::new();
if active {
title.push_str("> ");
}
if item.marked {
title.push_str("* ");
}
title.push_str(&item.title);
let title = if active {
format!("> {}", item.title)
} else {
item.title.to_string()
};
let mut style = if active {
Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD)
} else if item.marked {
// Same green as a marked library row.
Style::default()
.fg(COLOR_GREEN)
.add_modifier(Modifier::BOLD)
} else if item.is_skipped {
// No playable audio: rendered red (not bold — the
// playing marker keeps precedence), skipped by
@ -290,15 +185,9 @@ impl Queue {
} else {
COLOR_PRIMARY_DARK
}))
.title(if self.visual.is_some() {
// Visual (paint-select) mode: movement toggles marks.
"Queue — VISUAL".to_string()
} else {
match (self.filter.query(), register_len) {
(Some(query), _) => format!("Queue — /{query}"),
(None, 0) => "Queue".to_string(),
(None, n) => format!("Queue — register: {n}"),
}
.title(match self.filter.query() {
Some(query) => format!("Queue — /{query}"),
None => "Queue".to_string(),
}),
)
.highlight_style(Style::default().bg(if focused {
@ -326,33 +215,6 @@ impl StatefulList for Queue {
}
}
impl MarkedPane for Queue {
fn items(&self) -> &[UiItem] {
&self.list
}
fn items_mut(&mut self) -> &mut [UiItem] {
&mut self.list
}
fn filter(&self) -> &Filter {
&self.filter
}
fn visual(&self) -> Option<usize> {
self.visual
}
fn set_visual(&mut self, anchor: Option<usize>) {
self.visual = anchor;
}
fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Every queue row is a track, so every row may be marked — the
/// library's `is_queable` gate does not apply here (queue rows carry
/// `is_queable: false`).
fn markable(&self, _item: &UiItem) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -384,7 +246,7 @@ mod tests {
let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| queue.render(f, f.area(), true, 0))
.draw(|f| queue.render(f, f.area(), true))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
@ -466,34 +328,11 @@ mod tests {
/// Renders and returns the buffer plus the y of the row containing
/// `needle` and the x of its first character.
#[test]
fn marked_rows_render_a_star_like_the_library() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
queue.select(Some(1));
queue.toggle_mark();
let rows = rendered_rows(&mut queue);
// The playing row keeps `>`; the marked row gets `*`.
assert!(
rows.iter().any(|row| row.contains("* artist - two")),
"marked row needs a star: {rows:?}"
);
// And the title says so while visual mode is on.
queue.toggle_visual();
assert!(
rendered_rows(&mut queue)
.iter()
.any(|row| row.contains("VISUAL")),
"visual mode needs a title indicator"
);
}
fn render_and_find(queue: &mut Queue, needle: &str) -> (ratatui::buffer::Buffer, u16, u16) {
let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| queue.render(f, f.area(), true, 0))
.draw(|f| queue.render(f, f.area(), true))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
for y in 0..buffer.area.height {

View File

@ -1,85 +0,0 @@
//! The client-side register: what `y`, `d`, `c`, and `C` put aside and `p`/`P`
//! paste back (architecture/queue-register.md D1D3).
//!
//! One unnamed slot, in memory, overwritten by each write — vim's unnamed
//! register, not a history. It holds library **paths**, so pasting
//! re-resolves them: a yanked node expands to its tracks at paste time, and a
//! path that no longer resolves simply does not come back.
/// Paths set aside by the last yank or delete, with labels for display.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Register {
paths: Vec<String>,
labels: Vec<String>,
}
impl Register {
/// Overwrite the register. Empty `paths` clears it.
pub fn set(&mut self, paths: Vec<String>, labels: Vec<String>) {
self.paths = paths;
self.labels = labels;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
/// What a paste sends, in yank order.
pub fn paths(&self) -> &[String] {
&self.paths
}
/// Row labels, for the status line only — never sent to the server.
pub fn labels(&self) -> &[String] {
&self.labels
}
}
#[cfg(test)]
mod tests {
use super::Register;
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_fresh_register_is_empty() {
let reg = Register::default();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
assert!(reg.paths().is_empty());
}
#[test]
fn set_stores_paths_in_order_with_labels() {
let mut reg = Register::default();
reg.set(v(&["/fs/a", "/fs/b"]), v(&["A", "B"]));
assert!(!reg.is_empty());
assert_eq!(reg.len(), 2);
assert_eq!(reg.paths(), ["/fs/a", "/fs/b"]);
assert_eq!(reg.labels(), ["A", "B"]);
}
/// A write overwrites: one slot, no history (D2).
#[test]
fn a_second_write_replaces_the_first() {
let mut reg = Register::default();
reg.set(v(&["/fs/a"]), v(&["A"]));
reg.set(v(&["/tidal/x", "/tidal/y"]), v(&["X", "Y"]));
assert_eq!(reg.paths(), ["/tidal/x", "/tidal/y"]);
assert_eq!(reg.labels(), ["X", "Y"]);
}
#[test]
fn setting_nothing_clears_it() {
let mut reg = Register::default();
reg.set(v(&["/fs/a"]), v(&["A"]));
reg.set(Vec::new(), Vec::new());
assert!(reg.is_empty());
}
}

View File

@ -93,7 +93,7 @@ pub struct RpcClient {
/// walks on the server; always refetch them. Remote provider nodes (tidal,
/// youtube) keep the cache that makes back-navigation instant.
fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
!MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
})

View File

@ -16,7 +16,7 @@ use crate::keymap::{self, Action};
use crate::rpc::Rpc;
use crate::state::{
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
NamePurpose, QueueCursor, Register, UiItemKind,
NamePurpose, QueueCursor, UiItemKind,
};
const VOLUME_STEP: f32 = 0.1;
@ -119,9 +119,6 @@ struct Store {
queue_cursor: RwSignal<QueueCursor>,
focus: RwSignal<Focus>,
dialog: RwSignal<Option<Dialog>>,
/// What `y`/`d`/`c`/`C` set aside and `p`/`P` paste back
/// (architecture/queue-register.md). Per client, one slot, in memory.
register: RwSignal<Register>,
toast: RwSignal<Option<String>>,
/// The transport; local because the wasm client is not `Send`.
rpc: StoredValue<Option<Rpc>, LocalStorage>,
@ -153,7 +150,6 @@ impl Store {
queue_cursor: RwSignal::new(QueueCursor::default()),
focus: RwSignal::new(Focus::Library),
dialog: RwSignal::new(None),
register: RwSignal::new(Register::default()),
toast: RwSignal::new(None),
rpc: StoredValue::new_local(None),
cache: StoredValue::new_local(HashMap::new()),
@ -195,11 +191,9 @@ impl Store {
StreamUpdate::Queue(queue) => {
self.queue_pos.set(queue.current_position);
self.resolving.set(queue.resolving);
// Marks follow their own track across the snapshot rather
// than their index (architecture/queue-register.md D5).
let paths: Vec<String> = queue.tracks.iter().map(|t| t.path.clone()).collect();
self.queue.set(queue.tracks);
self.queue_cursor.update(|c| c.reconcile(paths));
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor.update(|c| c.clamp(len));
}
StreamUpdate::Mods(mods) => self.mods.set(mods),
StreamUpdate::QueueTrack(queue_track) => {
@ -292,98 +286,7 @@ impl Store {
}
/// Executes one keymap action — the web twin of the TUI dispatch.
/// Move the library cursor, painting the swept range when visual mode is
/// active — the web mirror of the TUI's `library_move`.
fn library_move(&self, mv: impl FnOnce(&mut LibraryPane)) {
self.library.update(|pane| {
let old = pane.selected;
mv(pane);
pane.paint_between(old, pane.selected);
});
}
/// Move the queue cursor, painting the swept range when visual mode is
/// active — the web mirror of the TUI's `queue_move`.
fn queue_move(&self, mv: impl FnOnce(&mut QueueCursor, usize)) {
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor.update(|cursor| {
let old = cursor.selected;
mv(cursor, len);
let new = cursor.selected;
cursor.paint_between(old, new);
});
}
/// Put the tracks at `positions` in the register. Returns whether
/// anything was stored, so callers can decide about clearing marks.
fn yank_positions(&self, positions: &[usize]) -> bool {
let (paths, labels) = self.queue.with_untracked(|queue| {
let mut paths = Vec::new();
let mut labels = Vec::new();
for pos in positions {
if let Some(track) = queue.get(*pos) {
paths.push(track.path.clone());
labels.push(track_label(track));
}
}
(paths, labels)
});
if paths.is_empty() {
return false;
}
self.register.update(|r| r.set(paths, labels));
true
}
/// `p`/`P`: insert the register at the cursor. An empty register is a
/// no-op, and the register survives so it can be pasted again.
fn paste(&self, before: bool) {
let paths = self.register.with_untracked(|r| r.paths().to_vec());
if paths.is_empty() {
return;
}
let position = self
.queue_cursor
.with_untracked(|c| c.paste_position(before));
self.queue_op(async move |mut rpc: Rpc| rpc.insert_tracks(position, paths).await);
}
/// Whether `action` keeps a pane in visual mode. Everything else leaves
/// it before running, exactly as in the TUI.
fn keeps_visual(action: Action) -> bool {
matches!(
action,
Action::LibraryFirst
| Action::LibraryLast
| Action::LibraryNext
| Action::LibraryPrev
| Action::LibraryJumpDown
| Action::LibraryJumpUp
| Action::LibraryVisualMode
| Action::QueueFirst
| Action::QueueLast
| Action::QueueNext
| Action::QueuePrev
| Action::QueueJumpDown
| Action::QueueJumpUp
| Action::QueueSelectCurrent
| Action::QueueVisualMode
)
}
fn dispatch(&self, action: Action) {
if !Self::keeps_visual(action) {
if self.library.with_untracked(LibraryPane::is_visual) {
self.library.update(LibraryPane::exit_visual);
}
if self.queue_cursor.with_untracked(QueueCursor::is_visual) {
self.queue_cursor.update(QueueCursor::exit_visual);
}
}
self.dispatch_inner(action)
}
fn dispatch_inner(&self, action: Action) {
match action {
Action::OpenHelp => self.dialog.set(Some(Dialog::Help)),
Action::CloseHelp => self.dialog.set(None),
@ -406,12 +309,12 @@ impl Store {
Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await),
Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().await),
Action::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await),
Action::LibraryNext => self.library_move(|p| p.select_by(1)),
Action::LibraryPrev => self.library_move(|p| p.select_by(-1)),
Action::LibraryFirst => self.library_move(LibraryPane::select_first),
Action::LibraryLast => self.library_move(LibraryPane::select_last),
Action::LibraryJumpDown => self.library_move(|p| p.select_by(JUMP)),
Action::LibraryJumpUp => self.library_move(|p| p.select_by(-JUMP)),
Action::LibraryNext => self.library.update(|p| p.select_by(1)),
Action::LibraryPrev => self.library.update(|p| p.select_by(-1)),
Action::LibraryFirst => self.library.update(LibraryPane::select_first),
Action::LibraryLast => self.library.update(LibraryPane::select_last),
Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)),
Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)),
Action::LibraryAscend => {
if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) {
self.open_library_node(parent);
@ -452,6 +355,17 @@ impl Store {
self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await);
}
}
Action::QueueInsertHere => {
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
if let Some(paths) = self
.library
.with_untracked(LibraryPane::queueable_selection)
{
self.queue_op(async move |mut rpc: Rpc| {
rpc.insert_tracks(position, paths).await
});
}
}
Action::LibraryCreateNode => {
let creatable = self
.library
@ -511,17 +425,33 @@ impl Store {
}));
}
}
Action::QueueNext => self.queue_move(|c, len| c.select_by(1, len)),
Action::QueuePrev => self.queue_move(|c, len| c.select_by(-1, len)),
Action::QueueFirst => self.queue_move(|c, _| c.selected = 0),
Action::QueueLast => {
self.queue_move(|c, len| c.selected = len.saturating_sub(1));
Action::QueueNext => {
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor.update(|c| c.select_by(1, len));
}
Action::QueuePrev => {
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor.update(|c| c.select_by(-1, len));
}
Action::QueueFirst => self.queue_cursor.update(|c| c.selected = 0),
Action::QueueLast => {
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor
.update(|c| c.selected = len.saturating_sub(1));
}
Action::QueueJumpDown => {
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor.update(|c| c.select_by(JUMP, len));
}
Action::QueueJumpUp => {
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor.update(|c| c.select_by(-JUMP, len));
}
Action::QueueJumpDown => self.queue_move(|c, len| c.select_by(JUMP, len)),
Action::QueueJumpUp => self.queue_move(|c, len| c.select_by(-JUMP, len)),
Action::QueueSelectCurrent => {
let current = self.queue_pos.get_untracked() as usize;
self.queue_move(|c, len| c.selected = current.min(len.saturating_sub(1)));
let len = self.queue.with_untracked(Vec::len);
self.queue_cursor
.update(|c| c.selected = current.min(len.saturating_sub(1)));
}
Action::QueuePlaySelected => {
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
@ -530,52 +460,15 @@ impl Store {
}
}
Action::QueueRemoveTrack => {
let positions = self
.queue_cursor
.with_untracked(QueueCursor::action_positions);
if positions.is_empty() {
return;
}
// Hand them to the register first, so `p`/`P` brings them back.
self.yank_positions(&positions);
self.queue_cursor.update(QueueCursor::remove_marks);
let wire: Vec<u32> = positions.iter().map(|p| *p as u32).collect();
self.call(async move |mut rpc: Rpc| rpc.remove_tracks(wire).await);
}
Action::QueueToggleMark => self.queue_cursor.update(QueueCursor::toggle_mark),
Action::QueueVisualMode => self.queue_cursor.update(QueueCursor::toggle_visual),
Action::QueueYank => {
let positions = self
.queue_cursor
.with_untracked(QueueCursor::action_positions);
if self.yank_positions(&positions) {
self.queue_cursor.update(QueueCursor::remove_marks);
}
}
Action::QueuePaste => self.paste(false),
Action::QueuePasteBefore => self.paste(true),
Action::LibraryVisualMode => self.library.update(LibraryPane::toggle_visual),
Action::LibraryYank => {
if let Some((paths, labels)) =
self.library.with_untracked(LibraryPane::yank_selection)
{
self.register.update(|r| r.set(paths, labels));
self.library.update(LibraryPane::remove_marks);
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
if self.queue.with_untracked(|q| !q.is_empty()) {
self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await);
}
}
Action::QueueClearKeepCurrent => {
let current = self.queue_pos.get_untracked() as usize;
let keep: Vec<usize> = (0..self.queue.with_untracked(Vec::len))
.filter(|pos| *pos != current)
.collect();
self.yank_positions(&keep);
self.call(async |mut rpc: Rpc| rpc.clear_queue(true).await)
}
Action::QueueClearAll => {
let all: Vec<usize> = (0..self.queue.with_untracked(Vec::len)).collect();
self.yank_positions(&all);
self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await)
}
Action::QueueClearAll => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await),
Action::QueueSaveAs => {
if self.queue.with_untracked(|q| !q.is_empty()) {
self.dialog.set(Some(Dialog::Name {
@ -884,9 +777,6 @@ fn LibraryView(store: Store) -> impl IntoView {
""
</button>
<span class="path" title=pane.path.clone()>{pane.title.clone()}</span>
<Show when=move || library.with(|p| p.is_visual())>
<span class="mode">"VISUAL"</span>
</Show>
<span class="spacer"></span>
<Show when=move || library.with(|p| p.is_creatable)>
<button
@ -1045,22 +935,14 @@ fn QueueView(store: Store) -> impl IntoView {
<span class="path">
"queue"
{move || store.resolving.get().then_some(" (loading…)")}
{move || {
// A paste is blind unless the register is visible.
let reg = store.register.get();
(!reg.is_empty()).then(|| format!(" — register: {}", reg.len()))
}}
</span>
<Show when=move || store.queue_cursor.with(|c| c.is_visual())>
<span class="mode">"VISUAL"</span>
</Show>
<span class="spacer"></span>
<button
class="ghost"
title="paste the register after selected (p)"
on:click=move |_| store.dispatch(Action::QueuePaste)
title="insert library selection after selected (p)"
on:click=move |_| store.dispatch(Action::QueueInsertHere)
>
"paste"
"insert"
</button>
<button
class="ghost"
@ -1087,9 +969,7 @@ fn QueueView(store: Store) -> impl IntoView {
<ul class="list">
{move || {
let current = store.queue_pos.get() as usize;
let queue_state = store.queue_cursor.get();
let cursor = queue_state.selected;
let marks = queue_state.marks.clone();
let cursor = store.queue_cursor.get().selected;
store
.queue
.get()
@ -1097,11 +977,9 @@ fn QueueView(store: Store) -> impl IntoView {
.enumerate()
.map(|(index, track)| {
let label = track_label(track);
let marked = marks.get(index).copied().unwrap_or(false);
view! {
<li
class:selected=index == cursor
class:marked=marked
class:current=index == current
class:skipped=track.is_skipped
on:click=move |_| {

View File

@ -30,8 +30,6 @@ pub enum Action {
LibraryAscend,
LibraryDive,
LibraryToggleMark,
LibraryVisualMode,
LibraryYank,
LibraryCaptureNode,
LibraryDownloadNode,
LibraryCreateNode,
@ -48,11 +46,7 @@ pub enum Action {
QueueJumpUp,
QueueSelectCurrent,
QueuePlaySelected,
QueueToggleMark,
QueueVisualMode,
QueueYank,
QueuePaste,
QueuePasteBefore,
QueueInsertHere,
QueueRemoveTrack,
QueueClearKeepCurrent,
QueueClearAll,
@ -154,16 +148,6 @@ pub const HELP: &[HelpEntry] = &[
key: "s",
description: "Mark/unmark selection",
},
HelpEntry {
scope: "Library",
key: "v / V",
description: "Visual mode: movement toggles marks",
},
HelpEntry {
scope: "Library",
key: "y",
description: "Yank selection into the register",
},
HelpEntry {
scope: "Library",
key: "w",
@ -231,23 +215,8 @@ pub const HELP: &[HelpEntry] = &[
},
HelpEntry {
scope: "Queue",
key: "s",
description: "Mark/unmark selection",
},
HelpEntry {
scope: "Queue",
key: "v / V",
description: "Visual mode: movement toggles marks",
},
HelpEntry {
scope: "Queue",
key: "y",
description: "Yank selection into the register",
},
HelpEntry {
scope: "Queue",
key: "p / P",
description: "Paste the register after / before this track",
key: "p",
description: "Insert library selection after this track",
},
HelpEntry {
scope: "Queue",
@ -324,8 +293,6 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"h" | "ArrowLeft" => Some(Action::LibraryAscend),
"l" | "ArrowRight" => Some(Action::LibraryDive),
"s" => Some(Action::LibraryToggleMark),
"v" | "V" => Some(Action::LibraryVisualMode),
"y" => Some(Action::LibraryYank),
"w" => Some(Action::LibraryCaptureNode),
"W" => Some(Action::LibraryDownloadNode),
"%" => Some(Action::LibraryCreateNode),
@ -343,11 +310,7 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"G" => Some(Action::QueueLast),
"o" => Some(Action::QueueSelectCurrent),
"Enter" => Some(Action::QueuePlaySelected),
"s" => Some(Action::QueueToggleMark),
"v" | "V" => Some(Action::QueueVisualMode),
"y" => Some(Action::QueueYank),
"p" => Some(Action::QueuePaste),
"P" => Some(Action::QueuePasteBefore),
"p" => Some(Action::QueueInsertHere),
"d" => Some(Action::QueueRemoveTrack),
"c" => Some(Action::QueueClearKeepCurrent),
"C" => Some(Action::QueueClearAll),

View File

@ -67,7 +67,7 @@ pub enum Dialog {
/// store on every visit) all change server-side, so their listings are
/// always refetched.
pub fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
!MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
})
@ -104,10 +104,6 @@ pub struct LibraryPane {
pub is_creatable: bool,
pub items: Vec<UiItem>,
pub selected: usize,
/// Visual (paint-select) mode: `Some(anchor)` while active. Movement
/// toggles the marks of the range between the anchor and the cursor, so
/// moving back reverses — the TUI's rule, same code shape.
pub visual: Option<usize>,
positions: HashMap<String, usize>,
}
@ -197,66 +193,6 @@ impl LibraryPane {
}
}
/// Toggle the mark of one row, honoring the queueable gate.
fn toggle_mark_at(&mut self, index: usize) {
if let Some(item) = self.items.get_mut(index) {
if item.is_queable {
item.marked = !item.marked;
}
}
}
/// Enter or leave visual mode. Entering anchors at the cursor and marks
/// it; leaving keeps the marks.
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.selected);
self.toggle_mark();
}
}
pub fn exit_visual(&mut self) {
self.visual = None;
}
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// Paint a visual-mode sweep: toggle every row whose membership in the
/// anchored range changed, so moving back over a row reverses it.
pub fn paint_between(&mut self, from: usize, to: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&index);
let in_new = (new_lo..=new_hi).contains(&index);
if in_old != in_new {
self.toggle_mark_at(index);
}
}
}
/// What `y` puts in the register: the marked rows, or the queueable
/// cursor row — paths with labels for display.
pub fn yank_selection(&self) -> Option<(Vec<String>, Vec<String>)> {
if self.items.iter().any(|i| i.marked) {
let marked = self.items.iter().filter(|i| i.marked);
return Some((
marked.clone().map(|i| i.path.clone()).collect(),
marked.map(|i| i.title.clone()).collect(),
));
}
let item = self.selected_item()?;
item.is_queable
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
}
pub fn remove_marks(&mut self) {
for item in &mut self.items {
item.marked = false;
@ -306,20 +242,9 @@ impl LibraryPane {
/// The queue pane cursor. The queue itself (tracks, current position,
/// play state) lives in signals fed by the update stream; this only
/// tracks the selection.
// No longer `Copy`: it owns the mark flags and the paths they were taken
// against (architecture/queue-register.md).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct QueueCursor {
pub selected: usize,
/// Marks, one flag per queue position. The queue is server-pushed and has
/// no client-owned list, so these sit beside the snapshot and are carried
/// across updates by [`carry_marks`].
pub marks: Vec<bool>,
/// The track paths `marks` was taken against, so the next snapshot can be
/// matched against it.
paths: Vec<String>,
/// Visual (paint-select) mode anchor, as in the library.
pub visual: Option<usize>,
}
impl QueueCursor {
@ -333,156 +258,6 @@ impl QueueCursor {
pub fn clamp(&mut self, len: usize) {
self.selected = self.selected.min(len.saturating_sub(1));
}
/// Apply a fresh queue snapshot: marks follow their own track rather than
/// their old index, so a mark cannot silently retarget when playback
/// advances or another client edits the queue.
pub fn reconcile(&mut self, new_paths: Vec<String>) {
self.marks = carry_marks(&self.paths, &self.marks, &new_paths);
self.paths = new_paths;
self.clamp(self.marks.len());
}
pub fn has_marks(&self) -> bool {
self.marks.iter().any(|m| *m)
}
/// The positions an action applies to: every marked row, or the cursor
/// row when nothing is marked.
pub fn action_positions(&self) -> Vec<usize> {
if self.has_marks() {
return self
.marks
.iter()
.enumerate()
.filter(|(_, marked)| **marked)
.map(|(pos, _)| pos)
.collect();
}
if self.marks.is_empty() {
return Vec::new();
}
vec![self.selected]
}
fn toggle_mark_at(&mut self, index: usize) {
if let Some(mark) = self.marks.get_mut(index) {
*mark = !*mark;
}
}
/// Every queue row may be marked — unlike the library there is no
/// queueable gate to apply.
pub fn toggle_mark(&mut self) {
self.toggle_mark_at(self.selected);
}
pub fn remove_marks(&mut self) {
for mark in &mut self.marks {
*mark = false;
}
}
pub fn toggle_visual(&mut self) {
if self.visual.is_some() {
self.visual = None;
} else {
self.visual = Some(self.selected);
self.toggle_mark();
}
}
pub fn exit_visual(&mut self) {
self.visual = None;
}
pub fn is_visual(&self) -> bool {
self.visual.is_some()
}
/// The library's paint rule, over positions instead of view rows.
pub fn paint_between(&mut self, from: usize, to: usize) {
let Some(anchor) = self.visual else {
return;
};
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
let in_old = (old_lo..=old_hi).contains(&index);
let in_new = (new_lo..=new_hi).contains(&index);
if in_old != in_new {
self.toggle_mark_at(index);
}
}
}
/// The insert position for a paste: after the cursor for `p`, at the
/// cursor for `P`. An empty queue pastes at the front.
pub fn paste_position(&self, before: bool) -> u32 {
if self.marks.is_empty() {
return 0;
}
let pos = if before {
self.selected
} else {
self.selected + 1
};
pos as u32
}
}
/// What `y`, `d`, `c`, and `C` set aside and `p`/`P` paste back — one unnamed
/// in-memory slot of library paths, with labels for display only. Overwritten
/// by each write; paste re-resolves the paths, so a yanked node expands at
/// paste time.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Register {
paths: Vec<String>,
labels: Vec<String>,
}
impl Register {
pub fn set(&mut self, paths: Vec<String>, labels: Vec<String>) {
self.paths = paths;
self.labels = labels;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
pub fn paths(&self) -> &[String] {
&self.paths
}
pub fn labels(&self) -> &[String] {
&self.labels
}
}
/// Carry queue marks across a server snapshot by a greedy in-order match on
/// track path — the same rule the TUI applies (`cbd-tui/src/app/list.rs`).
///
/// A mark follows its track through appends, removals, and playback
/// advancing; a mark whose track is gone is dropped; duplicate paths pair up
/// in order. Nothing in common means no marks rather than a guess.
pub fn carry_marks(old_paths: &[String], old_marked: &[bool], new_paths: &[String]) -> Vec<bool> {
let mut carried = vec![false; new_paths.len()];
let mut old_idx = 0;
for (new_idx, path) in new_paths.iter().enumerate() {
while old_idx < old_paths.len() && &old_paths[old_idx] != path {
old_idx += 1;
}
if old_idx < old_paths.len() {
carried[new_idx] = old_marked.get(old_idx).copied().unwrap_or(false);
old_idx += 1;
}
}
carried
}
/// How long a finished capture's line lingers, in milliseconds —
@ -751,150 +526,4 @@ mod tests {
assert_eq!(format_seconds(3600), "1:00:00");
assert_eq!(format_seconds(3661), "1:01:01");
}
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
/// The same reconciliation cases the TUI pins, so the two clients cannot
/// drift (quality/queue-register.md G18).
#[test]
fn marks_follow_their_track_across_a_snapshot() {
// Unchanged.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a", "/b"])),
vec![false, true]
);
// Append leaves earlier marks alone.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a", "/b", "/c"])),
vec![false, true, false]
);
// A removal before the mark shifts it down.
assert_eq!(
carry_marks(
&v(&["/a", "/b", "/c"]),
&[false, false, true],
&v(&["/b", "/c"])
),
vec![false, true]
);
// The marked track itself is gone.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[false, true], &v(&["/a"])),
vec![false]
);
// Nothing in common: no marks rather than a guess.
assert_eq!(
carry_marks(&v(&["/a"]), &[true], &v(&["/x", "/y"])),
vec![false, false]
);
// Duplicates pair up in order.
assert_eq!(
carry_marks(&v(&["/a", "/a"]), &[false, true], &v(&["/a", "/a"])),
vec![false, true]
);
// A short mark vector must not panic.
assert_eq!(
carry_marks(&v(&["/a", "/b"]), &[true], &v(&["/a", "/b"])),
vec![true, false]
);
}
#[test]
fn reconcile_carries_marks_and_clamps_the_cursor() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.selected = 2;
cursor.toggle_mark();
assert_eq!(cursor.action_positions(), vec![2]);
// Playback drops the head: the mark follows /c to position 1.
cursor.reconcile(v(&["/b", "/c"]));
assert_eq!(cursor.action_positions(), vec![1]);
assert!(cursor.selected < 2, "cursor clamped into the shorter queue");
}
/// Every queue row is markable — no queueable gate here (G12).
#[test]
fn the_cursor_row_is_the_default_target() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b"]));
cursor.selected = 1;
assert_eq!(cursor.action_positions(), vec![1]);
assert!(!cursor.has_marks());
}
#[test]
fn an_empty_queue_has_nothing_to_act_on() {
let cursor = QueueCursor::default();
assert!(cursor.action_positions().is_empty());
assert_eq!(cursor.paste_position(false), 0);
assert_eq!(cursor.paste_position(true), 0);
}
/// `p` after the cursor, `P` at it (G8).
#[test]
fn paste_positions_straddle_the_cursor() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.selected = 1;
assert_eq!(cursor.paste_position(false), 2);
assert_eq!(cursor.paste_position(true), 1);
}
#[test]
fn queue_visual_mode_paints_and_reverses() {
let mut cursor = QueueCursor::default();
cursor.reconcile(v(&["/a", "/b", "/c"]));
cursor.toggle_visual();
assert!(cursor.is_visual());
// Sweep down to 2.
for to in 1..=2 {
let from = cursor.selected;
cursor.selected = to;
cursor.paint_between(from, to);
}
assert_eq!(cursor.action_positions(), vec![0, 1, 2]);
// Sweep back up: the row turned around on is released.
let from = cursor.selected;
cursor.selected = 1;
cursor.paint_between(from, 1);
assert_eq!(cursor.action_positions(), vec![0, 1]);
cursor.exit_visual();
assert!(!cursor.is_visual());
assert_eq!(cursor.action_positions(), vec![0, 1], "marks survive");
}
#[test]
fn library_visual_mode_paints_under_the_queueable_gate() {
let mut pane = LibraryPane::default();
pane.update(&node("/a", 2, 0));
pane.toggle_visual();
assert!(pane.is_visual());
let from = pane.selected;
pane.selected = 1;
pane.paint_between(from, 1);
let marked: Vec<&str> = pane
.items
.iter()
.filter(|i| i.marked)
.map(|i| i.path.as_str())
.collect();
assert_eq!(marked.len(), 2, "both queueable rows painted: {marked:?}");
}
#[test]
fn a_register_write_overwrites_and_survives_reads() {
let mut reg = Register::default();
assert!(reg.is_empty());
reg.set(v(&["/a"]), v(&["A"]));
reg.set(v(&["/b", "/c"]), v(&["B", "C"]));
assert_eq!(reg.len(), 2);
assert_eq!(reg.paths(), ["/b", "/c"]);
assert_eq!(reg.labels(), ["B", "C"]);
// Reading does not consume.
assert_eq!(reg.paths(), ["/b", "/c"]);
reg.set(Vec::new(), Vec::new());
assert!(reg.is_empty());
}
}

View File

@ -392,18 +392,6 @@ input {
}
}
/* Visual (paint-select) mode badge in a pane toolbar the web counterpart of
the TUI's "— VISUAL" pane title. */
.mode {
padding: 0.05rem 0.4rem;
border: 1px solid var(--accent);
border-radius: 0.6rem;
color: var(--accent);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.help {
min-inline-size: min(52rem, 94vw);

View File

@ -148,9 +148,6 @@ message RemoveRequest {
message RemoveResponse {}
message InsertRequest {
// Index to insert **at**: the entry currently there, and everything after
// it, shifts down. 0 inserts at the front, a position at or past the end
// appends. "After entry N" is therefore N + 1.
uint32 position = 1;
repeated string paths = 2;
}

View File

@ -23,7 +23,6 @@ all-providers = [
"abs",
"soundcloud",
"jamendo",
"rss",
"fs",
]
@ -38,7 +37,6 @@ fyyd = ["dep:fyyd", "_any-provider"]
abs = ["dep:absdy", "_any-provider"]
soundcloud = ["dep:soundclouddy", "_any-provider"]
jamendo = ["dep:jamendody", "_any-provider"]
rss = ["dep:rssdy", "_any-provider"]
# Local files *and* persistent state (D5): the `/fs` mount, the content
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
# persistence, and the `scan` command. Off means the server keeps its
@ -79,7 +77,6 @@ absdy = { workspace = true, optional = true }
fsdy = { workspace = true, optional = true }
fyyd = { workspace = true, optional = true }
jamendody = { workspace = true, optional = true }
rssdy = { workspace = true, optional = true }
futures.workspace = true
rand.workspace = true
reqwest = { workspace = true, optional = true }

View File

@ -255,12 +255,10 @@ pub enum ResolveKind {
Replace,
/// Every chunk appends at the end.
Append,
/// Chunks insert **at** the given index, each advancing it so the resolved
/// collection stays contiguous and in order. The index is the one
/// `QueueManager::insert_tracks` takes: whatever sits there shifts down,
/// `0` is the front. `Queue` (play-after-current) is an `InsertAt` of the
/// current position **+ 1**.
InsertAt(u32),
/// Chunks insert after the given position, each advancing the cursor so
/// the resolved collection stays contiguous and in order. `Queue`
/// (play-after-current) is an `InsertAfter` at the current position.
InsertAfter(u32),
}
/// The playback loop's bookkeeping for one in-flight resolve operation.
@ -342,12 +340,12 @@ impl PendingResolve {
queue.replace_with_tracks(tracks)
}
ResolveKind::Append => queue.append_tracks(tracks),
ResolveKind::InsertAt(index) => {
// Advance so this op's next chunk lands right behind this one,
// keeping the collection contiguous. `insert_tracks` clamps
// past the end.
self.kind = ResolveKind::InsertAt(index + tracks.len() as u32);
queue.insert_tracks(index, tracks)
ResolveKind::InsertAfter(position) => {
// Advance the cursor so this op's next chunk lands right
// behind this one, keeping the collection contiguous.
// `insert_tracks` clamps positions past the end.
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
queue.insert_tracks(position, tracks)
}
};
if started.is_some() {
@ -594,23 +592,19 @@ impl QueueManager {
}
}
/// Inserts `tracks` **at** `position`: whatever sits there, and everything
/// after it, shifts down. `0` inserts at the front and a position at or
/// past the end appends, so every insertion point is reachable — which is
/// what a client needs to paste *before* the first row. An empty queue is
/// a replace, which starts playback.
///
/// Callers wanting "after track N" (play-next) pass `N + 1`.
pub fn insert_tracks(&mut self, position: u32, tracks: &[Track]) -> Option<Track> {
let len = self.tracks.len();
if len == 0 {
return self.replace_with_tracks(tracks);
}
let inserted = tracks.len();
let at = (position as usize).min(len);
let position = (position as usize).min(len - 1);
let order_additions: Vec<usize> = (len..len + inserted).collect();
self.play_order.extend(order_additions);
let tail: Vec<Track> = self.tracks.splice(at.., tracks.to_vec()).collect();
let tail: Vec<Track> = self
.tracks
.splice(position + 1.., tracks.to_vec())
.collect();
self.tracks.extend(tail);
let mut changed: Vec<usize> = Vec::new();
// In shuffle mode we may already have played positions that are
@ -620,7 +614,7 @@ impl QueueManager {
.play_order
.iter_mut()
.take(self.current_offset)
.filter(|i| at <= **i)
.filter(|i| position < **i)
{
*i += inserted;
changed.push(*i);
@ -642,10 +636,9 @@ impl QueueManager {
None
}
/// Play-next: insert right after the current track.
pub fn queue_tracks(&mut self, tracks: &[Track]) -> Option<Track> {
let pos = self.current_position();
self.insert_tracks(pos as u32 + 1, tracks)
self.insert_tracks(pos as u32, tracks)
}
pub fn clear(&mut self, exclude_current: bool) -> bool {
@ -757,35 +750,6 @@ mod tests {
assert_eq!(q.next_track().unwrap().title, "track 0");
}
/// Position 0 puts tracks at the very front — the insertion point a
/// client needs to paste *before* the first row (and so to undo deleting
/// it). Everything at or after the index shifts down.
#[test]
fn insert_at_zero_lands_at_the_front() {
let mut q = queue_with(2);
q.insert_tracks(0, &[track(9)]);
assert_eq!(titles(&q), vec!["track 9", "track 0", "track 1"]);
assert_eq!(q.play_order.len(), 3);
}
/// An interior insert pushes the row that was there down, rather than
/// landing after it.
#[test]
fn insert_at_a_position_pushes_that_row_down() {
let mut q = queue_with(3);
q.insert_tracks(1, &[track(9)]);
assert_eq!(titles(&q), vec!["track 0", "track 9", "track 1", "track 2"]);
}
/// Play-next stays "after the current track" even though the primitive
/// now inserts *at* an index.
#[test]
fn queue_tracks_lands_right_after_the_current_track() {
let mut q = queue_with(3); // playing track 0
q.queue_tracks(&[track(9)]);
assert_eq!(titles(&q), vec!["track 0", "track 9", "track 1", "track 2"]);
}
#[test]
fn insert_past_end_appends() {
let mut q = queue_with(2);
@ -815,10 +779,9 @@ mod tests {
}
#[test]
fn insert_op_keeps_chunks_contiguous_and_in_order() {
fn insert_after_op_keeps_chunks_contiguous_and_in_order() {
let mut q = queue_with(3); // playing track 0
// "after track 0" is "at index 1".
let mut op = PendingResolve::new(ResolveKind::InsertAt(1));
let mut op = PendingResolve::new(ResolveKind::InsertAfter(0));
assert!(op.apply_chunk(&mut q, &[track(10), track(11)]).is_none());
assert!(op.apply_chunk(&mut q, &[track(12)]).is_none());
// Both chunks sit as one contiguous run right after the current
@ -830,9 +793,9 @@ mod tests {
}
#[test]
fn insert_op_clamps_past_the_end() {
fn insert_after_op_clamps_past_the_end() {
let mut q = queue_with(1);
let mut op = PendingResolve::new(ResolveKind::InsertAt(99));
let mut op = PendingResolve::new(ResolveKind::InsertAfter(99));
assert!(op.apply_chunk(&mut q, &[track(10)]).is_none());
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
assert_eq!(titles(&q), vec!["track 0", "track 10", "track 11"]);

View File

@ -192,8 +192,7 @@ impl Playback {
};
queue.current_position() as u32
};
// Play-next means "right after the current track".
self.start_resolve(ResolveKind::InsertAt(position + 1), paths);
self.start_resolve(ResolveKind::InsertAfter(position), paths);
}
PlaybackCommand::Append { paths } => {
@ -239,9 +238,7 @@ impl Playback {
}
PlaybackCommand::Insert { position, paths } => {
// The RPC's `position` is the index to insert *at*: whatever
// sits there shifts down, 0 is the front. No offset here.
self.start_resolve(ResolveKind::InsertAt(position), paths);
self.start_resolve(ResolveKind::InsertAfter(position), paths);
}
PlaybackCommand::Clear { exclude_current } => {
@ -907,12 +904,7 @@ mod tests {
)
}
/// Queue titles in queue order, for order assertions.
fn queue_titles(playback: &Playback) -> Vec<String> {
let proto: ProtoQueue = playback.queue.lock().expect("queue lock").clone().into();
proto.tracks.iter().map(|t| t.title.clone()).collect()
}
#[cfg(feature = "fs")]
fn fill_queue(playback: &Playback, n: usize) {
let tracks: Vec<Track> = (0..n).map(track).collect();
let mut queue = playback.queue.lock().expect("queue lock");
@ -1063,91 +1055,4 @@ mod tests {
let snapshot = rx.borrow().clone().expect("snapshot sent");
assert_eq!(snapshot.tracks.len(), 1);
}
/// The gap that let the paste bug through: every previous test drove
/// either `insert_tracks` or `PendingResolve` directly, so none of them
/// pinned what the **`Insert` command** does with its position. It is an
/// index — the row there shifts down — and 0 is the front.
#[tokio::test]
async fn insert_command_places_tracks_at_the_given_index() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
fill_queue(&playback, 3);
// The first op registered gets id 0; the resolve itself goes nowhere
// in tests (no provider), so the chunk is applied by hand.
playback
.handle_command(PlaybackCommand::Insert {
position: 1,
paths: vec!["/x".to_string()],
})
.await;
playback
.handle_command(PlaybackCommand::ApplyResolvedChunk {
op_id: 0,
tracks: vec![track(9)],
})
.await;
assert_eq!(
queue_titles(&playback),
vec!["track 0", "track 9", "track 1", "track 2"],
"position 1 must push the row that was there down"
);
}
/// Play-next (`L`) must still land right *after* the current track, not
/// on top of it — the same command-level check for the other caller of
/// the insert op.
#[tokio::test]
async fn queue_command_places_tracks_after_the_current_track() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
fill_queue(&playback, 3); // playing track 0
playback
.handle_command(PlaybackCommand::Queue {
paths: vec!["/x".to_string()],
})
.await;
playback
.handle_command(PlaybackCommand::ApplyResolvedChunk {
op_id: 0,
tracks: vec![track(9)],
})
.await;
assert_eq!(
queue_titles(&playback),
vec!["track 0", "track 9", "track 1", "track 2"]
);
}
/// And position 0 reaches the very front — what `P` on the first row needs.
#[tokio::test]
async fn insert_command_at_zero_reaches_the_front() {
let playback = playback_with(
#[cfg(feature = "fs")]
None,
);
fill_queue(&playback, 2);
playback
.handle_command(PlaybackCommand::Insert {
position: 0,
paths: vec!["/x".to_string()],
})
.await;
playback
.handle_command(PlaybackCommand::ApplyResolvedChunk {
op_id: 0,
tracks: vec![track(9)],
})
.await;
assert_eq!(
queue_titles(&playback),
vec!["track 9", "track 0", "track 1"]
);
}
}

View File

@ -72,8 +72,7 @@ impl Mount {
feature = "fyyd",
feature = "abs",
feature = "soundcloud",
feature = "jamendo",
feature = "rss"
feature = "jamendo"
))]
async fn mount_from_config<C>(
config_dir: &std::path::Path,
@ -462,8 +461,6 @@ impl ProviderOrchestrator {
// (architecture/soundcloud-provider.md D1).
// - Jamendo: needs a registered `client_id`; without one it is
// disabled (architecture/jamendo-provider.md D1).
// - RSS: subscriptions come from `rss.toml`; no feeds at all still
// mounts an empty, creatable `/rss` (architecture/rss-provider.md).
#[cfg(feature = "youtube")]
if enabled.youtube {
mounts.extend(
@ -512,18 +509,6 @@ impl ProviderOrchestrator {
.await,
);
}
#[cfg(feature = "rss")]
if enabled.rss {
mounts.extend(
mount_from_config::<rssdy::Client>(
&config_dir,
"rss.toml",
rssdy::PROVIDER_ROOT,
"rss",
)
.await,
);
}
#[cfg(feature = "jamendo")]
if enabled.jamendo {
mounts.extend(

View File

@ -23,14 +23,13 @@ pub const SETTINGS_FILE: &str = "crabidy-server.toml";
///
/// This is the *vocabulary*, not what this binary can mount — see
/// [`BUILT_IN_PROVIDERS`].
pub const ALL_PROVIDERS: [&str; 10] = [
pub const ALL_PROVIDERS: [&str; 9] = [
"tidal",
"youtube",
"fyyd",
"abs",
"soundcloud",
"jamendo",
"rss",
"fs",
"crabidy",
"orphans",
@ -54,8 +53,6 @@ pub const BUILT_IN_PROVIDERS: &[&str] = &[
"soundcloud",
#[cfg(feature = "jamendo")]
"jamendo",
#[cfg(feature = "rss")]
"rss",
#[cfg(feature = "fs")]
"fs",
#[cfg(feature = "fs")]
@ -113,7 +110,6 @@ pub struct ProviderToggles {
pub abs: bool,
pub soundcloud: bool,
pub jamendo: bool,
pub rss: bool,
pub fs: bool,
pub crabidy: bool,
pub orphans: bool,
@ -132,7 +128,6 @@ impl ProviderToggles {
abs: cfg!(feature = "abs"),
soundcloud: cfg!(feature = "soundcloud"),
jamendo: cfg!(feature = "jamendo"),
rss: cfg!(feature = "rss"),
fs: cfg!(feature = "fs"),
// Both live on the content store, which the `fs` feature brings
// (D5).
@ -265,7 +260,6 @@ impl ServerSettings {
abs: self.provider_enabled("abs"),
soundcloud: self.provider_enabled("soundcloud"),
jamendo: self.provider_enabled("jamendo"),
rss: self.provider_enabled("rss"),
fs: self.provider_enabled("fs"),
crabidy: self.provider_enabled("crabidy"),
orphans: self.provider_enabled("orphans"),

View File

@ -107,7 +107,7 @@ in
test_it -p crabidy-server --no-default-features
# Each provider on its own: nothing else may be needed to compile it.
for feature in tidal youtube fyyd abs soundcloud jamendo rss fs; do
for feature in tidal youtube fyyd abs soundcloud jamendo fs; do
clippy -p crabidy-server --no-default-features --features "$feature"
done

View File

@ -9,7 +9,6 @@
- [YouTube — /youtube](./providers/youtube.md)
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
- [Jamendo — /jamendo](./providers/jamendo.md)
- [RSS — /rss](./providers/rss.md)
- [audiobookshelf — /abs](./providers/abs.md)
- [fyyd — /fyyd](./providers/fyyd.md)
- [Search](./providers/search.md)

View File

@ -41,8 +41,6 @@ cbd global volume -- -0.1 # lower the volume
marked. `library create`/`rename`/`delete`, and `library save`/
`capture` (the `w`/`W` equivalents into `/crabidy` — see [The crabidy
store](../store.md)) mutate it.
- `queue insert <POS> <PATH>…` inserts **at** `POS`, pushing the row that
was there down; `0` puts tracks at the front.
- `queue show` prints the [queue](../queue.md); `queue append`/`insert`/
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture

View File

@ -48,12 +48,12 @@ it, and while it is open every other key is inert.
- `s` toggles a **mark** on the selected item. Marks live on the full
item list (they survive filtering) so a marked-but-hidden row still
counts when you queue or save.
- `v` (or `V`) enters **visual mode**: movement then marks or unmarks the
rows you sweep over, like vim's visual selection. The selection is
anchored where you entered it, so moving back over a row reverses it.
`Esc` leaves visual mode (keeping the marks and any `/` filter), and so
does any non-movement action. The pane title shows `— VISUAL` while it is
on. Both panes have it.
- `v` (or `V`) enters **visual mode** in the library: movement then marks
or unmarks the rows you sweep over, like vim's visual selection. The
selection is anchored where you entered it, so moving back over a row
reverses it. `Esc` leaves visual mode (keeping the marks and any `/`
filter), and so does any non-movement action. The pane title shows
`— VISUAL` while it is on.
- `w` **saves** the selection — a library subtree, or in the queue pane
the whole queue — as a new folder of link files under
`/crabidy/<name>`; playback replays it from the source provider.
@ -74,39 +74,6 @@ confirmation — it removes only the metadata toml, never the shared
store audio, which other saves may reference.
```
## The register: `y`, `d`, and `p`/`P`
The queue has marks and visual mode too, and they feed a **register** — one
in-memory slot, like vim's unnamed register:
- `y` **yanks** the selection into the register without changing anything.
It works in both panes: in the library it yanks the paths you have marked
(or the row under the cursor), in the queue the marked tracks.
- `d` in the queue **deletes** every marked row (or the cursor row) in one
go — and puts them in the register first.
- `c` and `C` also fill the register with what they clear, so emptying the
queue by accident is recoverable.
- `p` **pastes** the register after the cursor, `P` before it. The register
survives, so you can paste again.
That gives you the two vim moves: `d` then `P` puts the tracks back exactly
where they were, and `d``p` moves them somewhere else. The queue title
shows `register: n` while something is in it, so a paste is never blind.
```admonish note
`p` used to insert the **library** selection at the cursor. It now pastes
the register, so that flow is `y` in the library, then `p` in the queue.
`a`, `L`, and `Enter` still queue the library selection directly.
```
```admonish warning
The register lives in **your client**, in memory, one slot deep: another
client cannot undo your delete, a restart forgets it, and each write
overwrites the last. It holds *paths*, so a paste re-resolves them — a
yanked album node expands to its tracks at paste time, and a path that no
longer resolves (a search term you deleted meanwhile) does not come back.
```
## The `/` live filter
Pressing `/` opens a search input that filters the focused pane's items
@ -172,7 +139,6 @@ pane).
| Library | `l` | Enter selected folder |
| Library | `s` | Mark / unmark selection |
| Library | `v` / `V` | Visual mode: movement toggles marks |
| Library | `y` | Yank selection into the register |
| Library | `Enter` | Replace queue with selection |
| Library | `a` | Append selection to queue |
| Library | `L` | Queue selection after current track |
@ -186,15 +152,12 @@ pane).
| Queue | `g` / `G` | Select first / last track |
| Queue | `Ctrl-d` | Jump 15 tracks down |
| Queue | `Ctrl-u` | Jump 15 tracks up |
| Queue | `s` | Mark / unmark selection |
| Queue | `v` / `V` | Visual mode: movement toggles marks |
| Queue | `y` | Yank selection into the register |
| Queue | `o` | Select the playing track |
| Queue | `Enter` | Play selected track |
| Queue | `p` / `P` | Paste the register after / before |
| Queue | `d` | Remove selection (into the register) |
| Queue | `c` | Clear queue except current (to register) |
| Queue | `C` | Clear entire queue (to register) |
| Queue | `p` | Insert library selection after this track |
| Queue | `d` | Remove selected track |
| Queue | `c` | Clear queue except current track |
| Queue | `C` | Clear entire queue |
| Queue | `w` | Save queue under a name |
| Queue | `W` | Capture the queue into /crabidy (audio) |
| Queue | `/` | Filter this view |

View File

@ -9,13 +9,8 @@ terminal required, so phones, tablets, and guests can drive the server.
Every TUI binding has a clickable equivalent, and the familiar keyboard
bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and
queue keys, `?` for help) also work on desktop browsers. That includes
marks (`s`), visual mode (`v`/`V`) in both panes, and the register — `y`
yanks, `d`/`c`/`C` fill it as they remove, and `p`/`P` paste it after or
before the cursor (see [the TUI's register
section](./tui.md#the-register-y-d-and-pp), which behaves identically here;
the queue toolbar shows how many entries are waiting). The `/` live filter
is TUI-only for now.
queue keys, `?` for help) also work on desktop browsers. The `/` live
filter is TUI-only for now.
## How it is served

View File

@ -21,7 +21,6 @@ which the server now also writes on first start.
| `fsdy.toml` | local files | none |
| `fyyd.toml` | podcasts | none |
| `jamendo.toml` | Jamendo | none (a key is shipped) |
| `rss.toml` | podcast feeds | the feed URLs themselves |
| `soundcloud.toml` | SoundCloud | none (token optional) |
| `tidaly.toml` | Tidal | device login (interactive) |
| `ytdy.toml` | YouTube | none (cookies optional) |
@ -58,7 +57,6 @@ providers = [
"abs",
"soundcloud",
"jamendo",
"rss",
"fs",
"crabidy",
"orphans",

View File

@ -21,7 +21,6 @@ abs: "/abs — audiobookshelf audiobooks"
fs: "/fs — a local music folder"
fyyd: "/fyyd — podcast search"
jamendo: "/jamendo — Creative-Commons catalogue"
rss: "/rss — podcast subscriptions"
soundcloud: "/soundcloud — SoundCloud"
tidal: "/tidal — Tidal streaming"
youtube: "/youtube — YouTube search & playlists"
@ -32,7 +31,6 @@ root -> abs: "route /abs/*"
root -> fs: "route /fs/*"
root -> fyyd: "route /fyyd/*"
root -> jamendo: "route /jamendo/*"
root -> rss: "route /rss/*"
root -> soundcloud: "route /soundcloud/*"
root -> tidal: "route /tidal/*"
root -> youtube: "route /youtube/*"
@ -92,9 +90,6 @@ Listed in the order the library root serves them.
- **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of
Creative-Commons music, by search or album. Works out of the box on a
shipped app key; bring your own for your own rate limit (`jamendo.toml`).
- **[`/rss`](./providers/rss.md)** — podcast feeds you subscribe to by URL,
including premium per-subscriber feeds. Always shows the newest episodes;
nothing is cached (`rss.toml`).
- **[`/soundcloud`](./providers/soundcloud.md)** — SoundCloud search, link
resolving, and playback with no credentials at all; an optional token adds
your likes and playlists (`soundcloud.toml`).

View File

@ -1,113 +0,0 @@
# RSS — /rss
<!-- toc -->
Subscribe to podcast feeds by URL and play their episodes. Works with public
feeds and with **premium per-subscriber URLs**, and always shows the newest
episodes.
`/fyyd` is the other podcast provider; the two do different jobs. `/fyyd`
*discovers* podcasts by searching a public directory. `/rss` plays the feeds
**you** name, including ones no directory indexes and ones only you can read.
## Subscribing
Press `%` on `/rss`, paste the feed URL, and that is it: the provider fetches
the feed once, names the subscription from the feed's own title, and writes it
into `rss.toml`. You can also list feeds in the config by hand (below).
From then on, `e` renames a subscription — its path follows the new name — and
`d` unsubscribes, which removes the config entry and nothing else. Audio you
captured from it stays under [`/crabidy`](../store.md).
## Premium feeds are credentials
A paid podcast hands you a URL with a token in it:
```text
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
```
Whoever has that URL has the subscription, so crabidy treats it as a secret.
It is redacted from logs and debug output, and — the part that shapes the
tree — **it never appears in a library path**:
```text
/rss/the-economist-podcasts/676f8bfa48c9cac3
└ slug of the name └ hash of the episode id
```
Paths are displayed in clients, written into the saved queue, and persisted
into bookmark files. A URL in one would leak into all of those, so the path
carries a name slug and an episode hash instead.
```admonish warning
`rss.toml` stores feed URLs in cleartext, like every other provider
credential. Keep `~/.config/crabidy/` private.
```
## Nothing is cached
Every visit to a subscription **fetches the feed**, so an episode published a
minute ago is there when you look. This holds end to end: `/rss` is exempt
from the clients' library-listing cache as well, so re-entering a
subscription really does re-fetch rather than redraw what you saw before.
One memo exists, and only to keep the obvious waste away: listing a feed and
then queueing its 40 episodes costs **one** fetch, not 41. It is written by
listings and read only when resolving a track, so it can never make a listing
stale.
## The tree
```text
/rss
├── <subscription> one per feed; queueable and downloadable
│ └── <episode> a track, newest first
└── …
```
Episodes stream directly from the feed's enclosure URL — no sidecar and no
helper binary, the same as [`/fyyd`](./fyyd.md) episodes. `W` captures a whole
subscription (or one episode) into the content store.
## Episode identity, and what it means for bookmarks
An episode's path segment is a short hash of the publisher's `<guid>` (or of
the enclosure URL when a feed omits one), and the guid is also stored as the
track's provider id, so capturing the same episode twice de-duplicates in the
store.
Two things follow, both inherent to RSS rather than to crabidy:
- A **bookmark** (`w`) to an episode that has since aged out of the feed
cannot be resolved — there is nothing left to look up. **Capture** (`W`)
what you want to keep; that downloads the audio.
- A publisher that regenerates guids on every fetch invalidates bookmarks to
its episodes.
## Configuration — `rss.toml`
```toml
# One table per subscription. `name` is yours and decides the path slug;
# duplicate slugs get a numeric suffix.
[[feeds]]
name = "The Economist Podcasts"
url = "https://feeds.economist.com/v1/rss/…"
[[feeds]]
name = "Cautionary Tales"
url = "https://feeds.example.org/cautionary-tales"
# Optional, defaults shown.
episodes_per_feed = 200 # episodes listed per feed
call_timeout_secs = 30 # per-request timeout
max_feed_bytes = 8388608 # 8 MiB cap on a feed body
```
`%` appends to this file, so subscriptions made from a client persist. An
entry with no url is skipped with a warning, and no feeds at all is fine —
`/rss` mounts empty and creatable.
Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed, and a malformed
episode is skipped rather than failing the listing.

View File

@ -132,32 +132,6 @@ pass at most. Without that bound, an all-skipped queue with repeat on would
cycle forever and hammer the provider; instead the search gives up after one
pass and the player stops.
## Editing the queue from a client
Queue edits are ordinary RPCs — `Insert`, `Remove`, `Clear`, `SetCurrent`
and both clients drive them the same way: mark rows with `s`, sweep them
with visual mode (`v`/`V`), then `d` to remove or `y` to yank. Removed and
yanked tracks land in a client-side **register** that `p`/`P` paste back,
which is the closest thing to an undo the queue has. It is per client and
in memory; the server keeps no edit history. See [the terminal
client](./clients/tui.md#the-register-y-d-and-pp) for the keys and the
limits.
Two consequences worth knowing:
- `Insert` takes the index to insert **at**: the row that was there, and
everything after it, shifts down. `0` is the front and a position at or
past the end appends, so every insertion point is reachable — which is what
pasting *before* the first row needs. "Play after track N" is `N + 1`.
- `Remove` takes **positions**, so a client must send positions from the
queue state it is currently showing. Both clients carry their marks across
each pushed queue snapshot by matching track paths, so a mark follows its
own track when playback advances or another client edits the queue —
rather than silently pointing at whatever now sits at that index.
- `Insert` takes **paths**, so a paste re-resolves them. Pasting a yanked
node expands it to tracks at paste time, and a path that no longer
resolves simply does not come back.
## Persistence
The live queue survives a server restart. It is mirrored to the reserved

View File

@ -1,102 +0,0 @@
# Plan — queue selection, visual mode, and the register
Executes `architecture/queue-register.md` against
`quality/queue-register.md`. Ordered by dependency. Three commits: **(A)**
the shared mark/visual trait with the library moved onto it, **(B)** queue
marks + the register in `cbd-tui`, **(C)** `cbd-web` parity + docs.
No server work: `Remove` already takes many positions and `Insert` takes a
path list (G25).
## A — Shared mark and visual behaviour (`cbd-tui`)
- [x] **A1 — Fill in `MarkedPane`** in `app/list.rs`: `toggle_visual`,
`toggle_mark`, `toggle_mark_view`, `paint_between`, `selection` — moved
verbatim in behaviour from `library.rs`, with the `markable` gate taken
from the implementor. *Verifies:* G11, G12, G16.
- [x] **A2 — `impl MarkedPane for Library`**, deleting the now-duplicated
inherent methods and keeping `get_selected` as a thin wrapper over
`selection` so existing call sites are untouched. `markable` =
`is_queable`. *Verifies:* the existing library visual-mode suite passes
unchanged (G11, G12).
- [x] **A3 — `carry_marks`** in `app/list.rs`: greedy in-order path match,
drop marks whose track is gone, all-false when nothing matches, tolerant
of a short `old_marked`. *Verifies:* the ten `carry_marks_tests` (G2, G3).
- [x] **A4 — Commit A.** *Merged into commit B:* group A alone leaves
`carry_marks` and `Register` unwired, and committing dead code for the next
commit to use is worse than one larger commit. The extraction is still
proven behaviour-preserving — the library's whole visual-mode suite passes
untouched.
## B — Queue marks and the register (`cbd-tui`)
- [x] **B1 — `impl MarkedPane for Queue`** with `markable` = always, and a
`visual: Option<usize>` field on `Queue`. *Verifies:* G12.
- [x] **B2 — `update_queue` carries marks** through `carry_marks` instead of
rebuilding them as `false`: keep the previous path list and mark flags,
reconcile, apply. *Verifies:* G1, G2.
- [x] **B3 — `Register::set`** (replace the `todo!()`) and an `App.register`
field. *Verifies:* the register suite (G6, G10).
- [x] **B4 — `y` in both panes.** New `Action::LibraryYank` and
`Action::QueueYank`; each takes `selection()`, writes the register, and
clears the marks it consumed. *Verifies:* G5, G14, G15.
- [x] **B5 — `d` in the queue deletes every marked row** (cursor row when
none), writing them to the register first, in one `RemoveTracks` call with
positions from the current list. Replaces the `FIXME`. *Verifies:* G1,
G13, G14, G15.
- [x] **B6 — `c`/`C` fill the register** with the tracks they drop (`C`
everything, `c` everything but the current track). *Verifies:* G4.
- [x] **B7 — `p`/`P` paste the register.** `Action::QueuePaste` /
`QueuePasteBefore` send `InsertTracks(register.paths(), pos)` with `pos` =
cursor + 1 for `p`, cursor for `P`; a no-op on an empty register; the
register survives. `QueueInsertHere`'s old cross-pane behaviour is gone.
*Verifies:* G7, G8, G9.
- [x] **B8 — Queue visual mode in dispatch.** A `queue_move` wrapper
mirroring `library_move`, and the visual auto-leave guard generalized to
whichever pane is in visual mode with its own movement whitelist.
*Verifies:* G11.
- [x] **B9 — Bindings + help.** Queue scope gains `s`, `v`, `V`, `y`, `P`;
library gains `y`; `p`'s description changes. The help modal derives from
`BINDINGS`, so it follows. *Verifies:* G19, G20.
- [x] **B10 — Status feedback.** The pane title shows `— VISUAL` in the
queue as in the library, and a non-empty register is visible somewhere
(title or now-playing line) so paste is not blind. *Verifies:* G22 in
spirit; keeps G9's no-op explicable.
- [x] **B11 — Dispatch tests** for the gates named above. *Verifies:* G1,
G4, G5, G7G9, G13G15.
- [x] **B12 — Commit B** with the whole `cbd-tui` suite, clippy `-D
warnings`, and fmt clean (G24, G26).
## C — `cbd-web` parity and docs
- [x] **C1 — `Register` + `carry_marks` in `cbd-web/src/state.rs`**, same
shape and same test cases as the TUI's. *Verifies:* G17, G18.
- [x] **C2 — Library visual mode** (the piece deferred when `v`/`V` landed
in the TUI): `visual: Option<usize>` on `LibraryPane`, the same anchored
paint, `Action::LibraryVisualMode` on `v`/`V`, auto-leave on non-movement.
*Verifies:* G17.
- [x] **C3 — Queue marks beside the signal.** The web queue owns no list, so
hold `marks: Vec<bool>` plus the path list they were taken against, and
reconcile on each `Queue` update. *Verifies:* G18.
- [x] **C4 — Queue actions:** `QueueToggleMark`, `QueueVisualMode`,
`QueueYank`, `QueuePaste`, `QueuePasteBefore`, plus `d`/`c`/`C` writing the
register — same semantics as the TUI. *Verifies:* G17.
- [x] **C5 — Keymap + `HELP` table** rows for all of the above, keeping the
lockstep test green; clickable equivalents where the web client has them.
*Verifies:* G19.
- [x] **C6 — Verify the web half properly:** clippy for native **and**
`wasm32-unknown-unknown`, `cargo test -p cbd-web`, and a trunk bundle
build. *Verifies:* G26.
- [x] **C7 — Docs.** `docs/src/clients/tui.md` (key table + a register
section), `docs/src/clients/web.md`, `docs/src/queue.md` (what the register
is and is not), and the README usage section. State plainly that `p`
changed meaning. *Verifies:* G21, G22, G23.
- [x] **C8 — `plan/summary.md`** entry, then commit C.
## Deferred (recorded, not dropped)
- Named registers (`"a`) — `Register` is shaped for it; not built.
- A numbered/history register stack, and any cross-client or persistent
register.
- A per-entry queue id in the proto, which would make mark reconciliation
exact instead of a greedy path match.

View File

@ -1,60 +0,0 @@
# Plan — RSS provider
Executes `architecture/rss-provider.md` against `quality/rss-provider.md`.
Two commits: **(A)** the `rssdy` crate, **(B)** wiring (server feature,
clients, docs).
## A — The `rssdy` crate
- [x] **A1 — `api::Episode::key_for`**`blake3(guid)` truncated to 16 hex
chars. *Verifies:* G7.
- [x] **A2 — `api::FeedFetcher`**`reqwest` client with the timeout,
streaming the body while counting bytes so `max_feed_bytes` rejects
before buffering it whole; parse with `feed-rs`; map entries to
`Episode` (guid → key, title, author, published, `itunes:duration` where
present, first audio enclosure); skip entries with no enclosure; sort
newest first; truncate to `limit`. Errors carry no URL. *Verifies:* G1,
G15, G16, G17.
- [x] **A3 — `slugify` + de-duplication** at load and on rename/subscribe.
*Verifies:* G8.
- [x] **A4 — `parse_path`** for root/feed/episode, rejecting anything else.
*Verifies:* G10.
- [x] **A5 — `Client::with_api` / `init`** — resolve subscriptions with
slugs, drop entries without a url (warning), keep the settings for
write-back. No feeds still mounts. *Verifies:* G18.
- [x] **A6 — Listings**`get_lib_root` lists subscriptions and is
creatable; `get_lib_node("/rss/<slug>")` fetches, replaces the memo, and
returns episodes as tracks (queueable, downloadable). *Verifies:* G3, G9.
- [x] **A7 — Memo** — bounded push/replace, listing-written, read by
`get_urls_for_track` / `get_metadata_for_track` /
`resolve_tracks_into`. *Verifies:* G4, G5.
- [x] **A8 — Subscription CRUD**`create_lib_node` (URL → fetch → name
from feed title → add), `rename_lib_node`, `delete_lib_node`, and
`settings()` reserializing the live list. *Verifies:* G11G14.
- [x] **A9 — `rssdy/src/tests.rs`** over a `FakeFeeds` fixture backend
covering every gate above.
- [x] **A10 — Commit A**: crate tests, clippy, fmt clean.
## B — Wiring
- [x] **B1 — Server feature `rss`** — optional `dep:rssdy`, `all-providers`
membership, `ALL_PROVIDERS` + `BUILT_IN_PROVIDERS` entries,
`ProviderToggles.rss`, `ProviderToggles::all`, and the mount
registration through `mount_from_config`. *Verifies:* G19.
- [x] **B2 — `check-features`** gains an `rss`-alone row. *Verifies:* G19.
- [x] **B3 — Clients**`/rss` into `MUTABLE_ROOTS` in `cbd-tui/src/rpc.rs`
and `cbd-web/src/state.rs`, with their cacheability tests extended.
*Verifies:* G6.
- [x] **B4 — Docs**`docs/src/providers/rss.md`, `rssdy/README.md`,
links from `docs/src/providers.md`, `SUMMARY.md`, `config.md`, and the
root README (tree + config table). Say plainly that a premium URL is a
credential, that listings are never cached, and that bookmarks depend on
publisher guids. *Verifies:* G20.
- [x] **B5 — `plan/summary.md`** entry, then commit B.
## Deferred (recorded, not dropped)
- OPML import/export, per-episode played state, background polling, and
artwork (`itunes:image`) — the library model has no field for it.
- Sharing the episode→`Track` mapping with `fyyd`; worth extracting only
if a third podcast source lands.

View File

@ -1279,117 +1279,3 @@ provider-less and the `fs,opus` binaries under isolated XDG dirs: startup
logs the feature list, writes a default config listing only built-ins, and
warns once per unavailable name. Not exercised: `nix build` (not run here)
and playback of an Opus file in an `opus`-less build (no audio device).
## queue register (2026-07-26)
Marks and visual mode for the queue pane, paired with a vim-style register:
`y` yanks, `d`/`c`/`C` fill it as they remove, `p`/`P` paste it back after
or before the cursor. Both clients, in one change. Ran the full dev-flow:
`architecture/queue-register.md` (D1D10), `quality/queue-register.md`
(G1G26), `plan/queue-register.md`.
**No server work at all** — that was the feasibility finding that made this
cheap: `Remove` already accepted many positions and `Insert` already took a
path list, so the register reduces to a `Vec<String>` and the whole feature
is two clients.
**Design decisions taken in discussion with the user** (all four of my
recommendations were accepted):
- **Only `y`/`d`/`c`/`C` write the register.** The user's first proposal had
a library selection fill it implicitly, which keeps `p` backward
compatible — I argued against it because `s` in the library would then
silently clobber a clipboard you were about to paste, and the one thing
that makes vim registers safe is that only explicit yank/delete write
them. The cost lands on one flow only (browse→insert-here is now `y` then
`p`); `a`/`L`/`Enter` are untouched.
- **`p` after the cursor, `P` before**, because after a delete the cursor
sits on the successor, so paste-after lands one slot late — `P` is the
exact restore.
- **`c`/`C` fill the register**, the destructive ops most worth undoing.
- **One unnamed slot**, with `Register` shaped so named registers stay
additive.
**The one hard problem** was that queue marks are positional while the queue
is server-pushed and rebuilt on every change (append, playback advancing,
each streaming-resolve chunk). Naive index remapping silently retargets a
mark, so `d` would delete the wrong tracks. `carry_marks()` carries marks
across a snapshot by a greedy in-order match on track path; irreconcilable
snapshots clear rather than guess, and positions handed to `Remove` are
always read off the newest list. Ten test cases pin it in the TUI and the
same cases again in the web client, so the two cannot drift.
**Structure:** the library's mark/visual code moved into a `MarkedPane`
trait in `cbd-tui/src/app/list.rs` (beside the existing `StatefulList`),
which both panes implement — the library keeps `is_queable` as its mark
gate, the queue allows every row. The web client has no owned queue list
(just a cursor), so its marks live on `QueueCursor` beside the server
signal; same rule, different home, as the architecture doc says.
**Deviations from the plan:** commits A and B were merged — group A leaves
`carry_marks`/`Register` unwired, and committing dead code so the next
commit can use it is worse than one larger commit. Also removed
`Library::queue_insert` (orphaned once `p` stopped pulling from the library)
and rebalanced the help modal's columns: it was already overflowing at 46
rows in a single column and the seven new bindings made it worse. It still
truncates below ~43 rows, now pinned by a test rather than hidden —
scrolling remains the real fix and is still an open question in the
help-modal design.
Verified: cbd-tui 117 tests (was 91), cbd-web 20 (was 12), clippy clean for
cbd-tui and for cbd-web on **both** native and wasm32 (`mod app` only
compiles for wasm, so native clippy alone proves nothing), the trunk bundle
builds, and the book builds. Not exercised: live keypresses in a terminal
and in a browser — the pure state machines are unit-tested and both render
paths compile.
## rss provider (2026-07-26)
`/rss`: podcast feeds you subscribe to by URL, including premium
per-subscriber feeds, with listings that are never cached. New `rssdy` crate
plus wiring. Full dev-flow: `architecture/rss-provider.md`,
`quality/rss-provider.md` (G1G21), `plan/rss-provider.md`.
**Two findings drove the design.**
*A premium feed URL is the credential.* Library paths are displayed, logged,
and persisted into saved queues and bookmark tomls — so a URL in a path would
leak into all of them. Hence subscriptions are `(name, url)` in `rss.toml` and
paths carry a name slug plus `blake3(guid)[..16]`:
`/rss/the-economist-podcasts/676f8bfa48c9cac3`. URLs are redacted from every
`Debug` impl and never reach an error message (reqwest errors go through
`without_url`).
*"Not cached" has a client-side half.* Both clients cache library listings by
path, and only `/crabidy`, `/fs`, `/orphans` bypassed it — so without adding
`/rss` to `MUTABLE_ROOTS` in both, a re-visit would answer from the client's
cache and server freshness would be invisible. A listing always fetches; a
listing-written memo (read only when resolving a track, bounded to 8 feeds)
keeps queueing 40 episodes at one fetch instead of 41, with no TTL to guess
at.
**Verified against the real feed, which caught a bug no unit test would
have.** `feed-rs` parses `<itunes:duration>` with an NPT parser, and NPT has
no `MM:SS` form: for `53:25` its fallback regex takes the leading number, so a
53-minute episode came back as **53 seconds** (`1:20:40` happens to parse
fine). The iTunes spec allows `S`, `MM:SS`, `HH:MM:SS`, so that one field is
now recovered from the raw body ourselves — a shallow scan keyed by guid and
enclosure URL, overriding feed-rs — and the live feed reports 3205 s / 2830 s
/ 1662 s, matching 53:25 / 47:10 / 27:42.
Other decisions: `%` on `/rss` takes a pasted URL, fetches it once, names the
subscription from the feed's own title and persists it (`e` renames, `d`
unsubscribes and touches no audio); newest-first ordering enforced at the
provider boundary, not just in the parser, so any backend obeys it; a separate
crate from `fyyd` (discovery vs subscription differ in config, identity and
caching); behind a default-on `rss` cargo feature like every other provider.
Bounded by design: per-request timeout, an 8 MiB body cap enforced *while*
reading chunks rather than after, and an episode cap — a malformed entry is
skipped, only an unfetchable feed errors, and it fails that node alone.
Verified: 25 rssdy tests, 94 crabidy-server, 118 cbd-tui, 20 cbd-web, the
whole `check-features` matrix (now including `rss` alone) clippy-clean under
`-D warnings`, fmt clean, book builds, and a live fetch of the user's own
premium Economist feed. Not exercised: playing an episode through an audio
device.

View File

@ -1,100 +0,0 @@
# Quality gates — queue selection, visual mode, and the register
Criteria an implementation of `architecture/queue-register.md` must satisfy.
Automated coverage lives in `cbd-tui/src/app/list.rs`
(`carry_marks_tests`), `cbd-tui/src/app/register.rs`,
`cbd-tui/src/app/mod.rs` (dispatch), and the mirrors of those in
`cbd-web/src/state.rs`.
## Data safety (highest priority)
- [ ] **G1 — A delete never removes an unmarked track.** The positions sent
to `Remove` are computed from the newest queue snapshot, never from an
index remembered across an update. *(tests: the `carry_marks` suite plus
`deleting_marked_rows_sends_their_current_positions`.)*
- [ ] **G2 — Marks follow their track across a snapshot.** Append,
removal before a mark, removal of the marked track, streaming resolve,
duplicate paths, wholesale replacement, empty queue, and a first snapshot
all behave as the `carry_marks` tests state. Divergence clears rather than
guesses.
- [ ] **G3 — No panic on any mark bookkeeping.** Mismatched lengths, an
empty queue, a cursor past the end, and marks on a filtered-out row are
all handled without indexing panics. *(test:
`a_short_mark_vector_is_tolerated`, plus the empty-queue dispatch tests.)*
- [ ] **G4 — `c`/`C` write the register before clearing.** Clearing 200
tracks is recoverable with one `p`. *(tests:
`clear_fills_the_register_with_what_it_dropped`.)*
## Register semantics
- [ ] **G5 — Only `y`, `d`, `c`, `C` write the register.** Marking (`s`),
visual mode, cursor movement, `a`/`L`/`Enter`, `w`/`W`, and every playback
action leave it untouched. *(test:
`marking_and_queueing_do_not_touch_the_register`.)*
- [ ] **G6 — A write overwrites.** One slot, no history, no numbered
registers. An empty write clears it.
- [ ] **G7 — Paste leaves the register intact**, so the same yank can be
pasted repeatedly. *(test: `pasting_twice_inserts_twice`.)*
- [ ] **G8 — `p` inserts after the cursor, `P` before it**, and `d`
followed by `P` restores the deleted rows to their original positions.
*(tests: `paste_after_and_before_use_the_right_position`,
`delete_then_paste_before_restores_the_positions`.)*
- [ ] **G9 — Paste on an empty register is a no-op**, not an empty `Insert`
round trip. *(test: `pasting_an_empty_register_sends_nothing`.)*
- [ ] **G10 — The register holds paths, and only labels for display.** No
label ever reaches the server; `Insert` carries paths in yank order.
## Selection and visual mode
- [ ] **G11 — The queue's `s`, `v`, `V` behave exactly as the library's**:
`v` and `V` are the same action, entering anchors at the cursor and marks
it, movement paints the anchored range so moving back reverses, `Esc` and
any non-movement action leave visual mode while keeping the marks.
*(tests: the queue mirrors of the library's visual-mode suite.)*
- [ ] **G12 — Every queue row is markable**; the library's `is_queable`
gate does not leak into the queue (queue rows carry `is_queable: false`).
Conversely the library still refuses to mark an unqueueable row.
- [ ] **G13 — Marks live on the full list.** A marked row hidden by the `/`
filter still counts for `y` and `d`. *(test:
`a_filtered_out_marked_row_is_still_deleted`.)*
- [ ] **G14 — `y` and `d` consume the marks they acted on** (the pane comes
back unmarked), as queueing already does.
- [ ] **G15 — With nothing marked, `y`/`d` act on the cursor row only**
today's `d` behaviour is preserved.
- [ ] **G16 — Mark and visual logic exists once per client.** `Library` and
`Queue` both go through `MarkedPane`; no second copy of the paint rule.
## Both clients
- [ ] **G17 — `cbd-web` reaches parity in the same change**: library visual
mode (which it lacks today), queue marks, queue visual mode, `y`, `p`,
`P`, and `c`/`C` filling the register.
- [ ] **G18 — The web client's queue marks reconcile by the same rule.**
It owns no queue list, so its marks sit beside the server signal; the
reconciliation cases from G2 are tested there too.
- [ ] **G19 — Both key tables and both help overlays list the new keys**,
and `cbd-web`'s keymap tests keep their lockstep with its `HELP` table.
- [ ] **G20 — The TUI's binding table stays the single source of truth**
for its help modal (no hand-maintained duplicate list).
## Documentation
- [ ] **G21 — `p`'s changed meaning is documented as a change**, in the
book's TUI page, the web page, and the README walkthrough: it pastes the
register, and the browse→insert-here flow is now `y` then `p`.
- [ ] **G22 — The register's limits are stated**: per client, in memory,
one slot, and paste re-resolves so a stale path may not come back.
- [ ] **G23 — Key tables match the code.** Every new binding appears in
`docs/src/clients/tui.md`, `docs/src/clients/web.md` where it lists keys,
and the README's usage section.
## Hard rules
- [ ] **G24 — No panics** on user input or empty state anywhere in the new
paths; `todo!()`/`unimplemented!()` from the stub stage are all gone.
- [ ] **G25 — No new server surface.** No `.proto` change, no new RPC, no
change to `crabidy-server`. The feature is two clients and the two
existing calls (`Insert`, `Remove`).
- [ ] **G26 — Clippy clean under `-D warnings`** for `cbd-tui` and for
`cbd-web` on **both** the native and `wasm32-unknown-unknown` targets
(`mod app` only compiles for wasm), and the trunk bundle still builds.

View File

@ -1,100 +0,0 @@
# Quality gates — RSS provider
Criteria an implementation of `architecture/rss-provider.md` must satisfy.
Automated coverage lives in `rssdy/src/tests.rs` (unit, over a fake
`Feeds`), plus the settings/toggle tests in `crabidy-server`.
## Secrets (hard rule — highest priority)
- [ ] **G1 — A feed URL never reaches a log, an error, or a path.**
`FeedEntry`, `Settings`, `Client`, and `FeedFetcher` `Debug` output
redact urls (manual impls, not derived). `FetchError` variants carry
status/parse context only. No `debug!`/`warn!`/`error!` call takes a
feed or enclosure URL. *(tests: `settings_debug_redacts_urls`,
`feed_entry_debug_redacts_the_url`.)*
- [ ] **G2 — No path contains a URL.** Subscription paths are
`/rss/<slug>`, episode paths `/rss/<slug>/<16 hex>`. A tokened URL
therefore cannot leak through the UI, the queue, `current`, or a
bookmark toml. *(tests: `episode_paths_carry_only_slug_and_key`.)*
## Freshness — the point of the feature
- [ ] **G3 — Listing a subscription always fetches.** Two listings of the
same node cause two fetches, and the second reflects a feed that gained
an episode in between. *(test: `every_listing_refetches_the_feed`.)*
- [ ] **G4 — The memo is written by listings and read only by track
lookups.** After one listing, resolving every episode of that feed
costs **no** further fetches; a track lookup with a cold memo fetches
once. *(tests: `track_lookups_reuse_the_listing_fetch`,
`a_cold_memo_fetches_once`.)*
- [ ] **G5 — The memo is bounded** to `MEMO_CAPACITY` feeds, evicting the
oldest, so subscribing to 100 feeds cannot grow it without limit.
*(test: `the_memo_evicts_beyond_its_capacity`.)*
- [ ] **G6 — `/rss` is in `MUTABLE_ROOTS` in both clients**
(`cbd-tui/src/rpc.rs`, `cbd-web/src/state.rs`), or the client cache
defeats G3. *(tests: the existing cacheability tests, extended.)*
## Identity and paths
- [ ] **G7 — An episode key is stable** across restarts and builds:
`blake3(guid)[..16]`, and the guid falls back to the enclosure URL only
when the feed omits one. *(tests: `episode_keys_are_stable_and_short`,
`a_feed_without_guids_keys_on_the_enclosure_url`.)*
- [ ] **G8 — Slugs are derived, bounded, and de-duplicated.** Lowercased,
non-alphanumerics folded to single dashes, trimmed, length-capped; an
empty or all-punctuation name yields `feed`; two subscriptions that
slug alike get `-2`, `-3`. *(tests: `slugify_folds_and_bounds`,
`duplicate_slugs_get_a_suffix`.)*
- [ ] **G9 — `provider_item_id` is the guid**, so the content store
de-duplicates captures of the same episode across visits and feeds.
*(test: `tracks_carry_the_guid_as_provider_item_id`.)*
- [ ] **G10 — Foreign and malformed paths are typed errors**, never
panics: an unknown slug, a wrong-provider path, a bad episode key, and
a path with too many segments all yield `MalformedPath` (or
`NotSupported` for edits). *(test: `foreign_and_malformed_paths_reject`.)*
## Subscriptions (`%`, `e`, `d`)
- [ ] **G11 — `%` on `/rss` takes a URL, fetches it once, and names the
subscription from the feed's own title**, de-duplicating the slug. The
new node is returned. *(test: `subscribing_names_from_the_feed_title`.)*
- [ ] **G12 — `%` with a non-URL or an unfetchable URL fails with a typed
error and adds nothing.** *(test: `subscribing_rejects_bad_input`.)*
- [ ] **G13 — `e` renames and the path follows the new slug**; `d`
removes the subscription and no audio. Both return what a client should
display next. *(tests: `renaming_moves_the_slug`,
`unsubscribing_removes_only_the_entry`.)*
- [ ] **G14 — Every subscription change round-trips through `rss.toml`**
`settings()` reserializes the live list so the server's write-back
persists it, and reloading yields the same subscriptions. *(test:
`subscription_changes_round_trip_through_toml`.)*
## Robustness (hard rules)
- [ ] **G15 — No panics on feed data.** A missing enclosure, empty title,
unparseable date, absent duration, or an entry with no guid is skipped
or degraded — never a panic and never a failed listing. Only an
unfetchable/unparseable *feed* errors, and it fails that node only.
*(tests: `malformed_entries_are_skipped`, `backend_failures_are_typed`.)*
- [ ] **G16 — Bounded network use.** Every request carries
`call_timeout_secs`; a body over `max_feed_bytes` is rejected without
being buffered whole; listings are capped at `episodes_per_feed`.
- [ ] **G17 — Newest first**, by publication date descending where dates
parse, feed order preserved otherwise (stable sort). *(test:
`episodes_are_newest_first`.)*
- [ ] **G18 — A provider failure never takes the server down.** A feed
that cannot be fetched fails its own node; `init` with no feeds still
mounts an empty, creatable `/rss`. *(test: `no_feeds_still_mounts`.)*
## Wiring
- [ ] **G19 — Behind the `rss` cargo feature**, default on: `dep:rssdy`,
a `BUILT_IN_PROVIDERS` entry, an `ALL_PROVIDERS` name, a
`ProviderToggles` field, a mount registration, and a `check-features`
row. The provider-less and each-provider-alone builds stay clean.
- [ ] **G20 — Documented.** A `docs/src/providers/rss.md` page (tree,
subscribing, premium feeds and what "not cached" means, every config
option), an `rssdy/README.md`, links from the providers index,
`SUMMARY.md`, `config.md`, and the root README's tree and config table.
- [ ] **G21 — Clippy clean under `-D warnings`** for the workspace, fmt
clean, and no `todo!()` from the stub stage left.

View File

@ -1,23 +0,0 @@
[package]
name = "rssdy"
version.workspace = true
edition.workspace = true
[dependencies]
async-trait.workspace = true
# Stable across builds, so an episode key stays valid in a saved bookmark
# (architecture/rss-provider.md D4 — `DefaultHasher` explicitly is not).
blake3.workspace = true
crabidy-core.workspace = true
# One parser for RSS 2.0/1.0/0.x, Atom and JSON Feed, with the iTunes
# extensions mapped (D-parser). Podcast feeds in the wild are not uniform.
feed-rs.workspace = true
reqwest.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time", "sync"] }
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@ -1,91 +0,0 @@
# rssdy — the RSS podcast provider
Mounts the podcast feeds you subscribe to at **`/rss`** in the crabidy
library. Works with plain public feeds and with **premium per-subscriber
URLs**, and always shows the newest episodes.
## Logging in
There is no login. A feed is either public or its URL *is* the credential —
a paid podcast gives you a URL with a token in it:
```text
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
```
Anyone holding that URL has your subscription, so the provider treats it as a
secret: it is redacted from `Debug`, never logged, and **never put in a
library path**. Paths carry a slug of the subscription name and a hash of the
episode id instead — paths are displayed, logged, and persisted into saved
queues and bookmarks, so a URL in one would leak everywhere.
Keep `rss.toml` private; it holds the URLs in cleartext, like every other
provider credential.
## Subscribing
Either edit the config (below), or from a client: press `%` on `/rss`, paste
the feed URL, and the provider fetches it once, names the subscription from
the feed's own title, and writes it into `rss.toml`. `e` renames a
subscription (its path changes with the name), `d` unsubscribes — that only
removes the config entry, never audio you captured from it.
## How it works
```text
/rss
├── <subscription> one node per feed, newest episodes first
│ └── <episode> a track; audio is the feed's enclosure URL
└── …
```
A subscription is queueable and downloadable, so you can queue or `W`-capture
a whole feed. Episodes stream directly from the enclosure URL — no sidecar,
no helper binary.
**Nothing is cached.** Every visit to a subscription fetches the feed, so an
episode published a minute ago is there. One memo exists purely so that
listing a feed and then queueing its 40 episodes costs one fetch rather than
41: it is written by listings and read only when resolving a track, so it can
never make a listing stale.
Episode paths are `blake3(guid)[..16]`, which is stable as long as the
publisher keeps its guids stable. Two consequences worth knowing:
- A bookmark (`w`) to an episode that has since aged out of the feed cannot
resolve — there is nothing left to look up. Capture (`W`) what you want to
keep.
- A publisher that regenerates guids on every fetch invalidates bookmarks.
Nothing can be done about that from this side.
## Configuration — `~/.config/crabidy/rss.toml`
```toml
# One table per subscription. `name` is yours and decides the path slug;
# duplicates get a numeric suffix.
[[feeds]]
name = "The Economist Podcasts"
url = "https://feeds.economist.com/v1/rss/…"
[[feeds]]
name = "Cautionary Tales"
url = "https://feeds.example.org/cautionary-tales"
# Optional, defaults shown.
# episodes_per_feed = 200 # episodes listed per feed
# call_timeout_secs = 30 # per-request timeout
# max_feed_bytes = 8388608 # 8 MiB cap on a feed body
```
A feed entry with no url is skipped with a warning. No feeds at all is fine:
`/rss` mounts empty and you can `%` into it.
## Notes
- Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed via
[`feed-rs`](https://crates.io/crates/feed-rs).
- `itunes:duration` is parsed here rather than taken from `feed-rs`, which
reads it as NPT — a format with no `MM:SS` form, so `53:25` came back as 53
*seconds*. `S`, `MM:SS` and `HH:MM:SS` all work now.
- Build the server without the `rss` cargo feature to leave this provider out
of the binary entirely.

View File

@ -1,396 +0,0 @@
//! The feed HTTP seam.
//!
//! All network access goes through the [`Feeds`] trait so the provider's
//! subscription, slug, identity, and ordering logic is unit-tested with a fake
//! and no network (architecture/rss-provider.md). [`FeedFetcher`] is the
//! production `reqwest` + `feed-rs` implementation; tests supply their own
//! [`Feeds`].
//!
//! **Every feed URL here is a credential.** A premium podcast URL embeds a
//! per-subscriber token, so URLs are redacted from `Debug`, never logged, and
//! never carried in an error message. Enclosure URLs get the same treatment —
//! they can be signed too.
use std::collections::HashMap;
use std::fmt::{self, Debug};
use std::time::Duration;
use async_trait::async_trait;
use thiserror::Error;
/// A typed feed failure. Carries only non-secret context — never a feed URL,
/// which would put a subscriber token in the logs.
#[derive(Debug, Error)]
pub enum FetchError {
/// Transport, timeout, or a non-success status.
#[error("feed request failed: {0}")]
Http(String),
/// The body was not a feed we could parse.
#[error("feed could not be parsed: {0}")]
Parse(String),
/// The body exceeded `max_feed_bytes` (D6).
#[error("feed is larger than the {limit} byte limit")]
TooLarge { limit: u64 },
}
/// One episode, normalized out of whatever feed dialect produced it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Episode {
/// The publisher's `<guid>`, or the enclosure URL when the feed omits one.
/// Keys [`Self::key`] and becomes `Track.provider_item_id` so the content
/// store de-duplicates captures of the same episode.
pub guid: String,
/// Stable short path segment: `blake3(guid)[..16]` (D4).
pub key: String,
pub title: String,
/// Show/author, used as the track artist. Empty when the feed omits it.
pub author: String,
/// Publication instant as a unix timestamp, when the feed has a parseable
/// date. `None` keeps the entry in feed order (D9).
pub published: Option<i64>,
pub duration_secs: Option<u32>,
/// The audio to play. **Secret-ish**: never logged.
pub enclosure_url: String,
}
impl Episode {
/// The path segment for `guid` — `blake3` truncated to 16 hex chars, which
/// is short, readable, and stable across builds and restarts (D4).
pub fn key_for(guid: &str) -> String {
blake3::hash(guid.as_bytes()).to_hex()[..KEY_HEX_LEN].to_string()
}
}
/// Hex characters of the guid hash kept in a path segment. 64 bits is far more
/// than enough to keep one feed's episodes apart, and stays readable.
const KEY_HEX_LEN: usize = 16;
/// A parsed feed: the publisher's own title (used to name a new subscription,
/// D5) plus its episodes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Feed {
pub title: String,
pub episodes: Vec<Episode>,
}
/// Everything the provider does over the network.
#[async_trait]
pub trait Feeds: Debug + Send + Sync {
/// Fetches and parses `url`, returning at most `limit` episodes, newest
/// first. Bounded by a per-request timeout and a byte cap; a malformed
/// entry is skipped rather than failing the feed.
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError>;
}
/// The production implementation: `reqwest` for transport, `feed-rs` for
/// parsing.
pub struct FeedFetcher {
http: reqwest::Client,
max_bytes: u64,
}
impl Debug for FeedFetcher {
/// No URLs are held here, but the impl is explicit so it stays that way.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FeedFetcher")
.field("max_bytes", &self.max_bytes)
.finish_non_exhaustive()
}
}
impl FeedFetcher {
/// `timeout` bounds every request; `max_bytes` caps a response body so a
/// broken or hostile feed cannot exhaust memory (D6).
pub fn new(timeout: Duration, max_bytes: u64) -> Result<Self, FetchError> {
let http = reqwest::Client::builder()
.timeout(timeout)
.user_agent(USER_AGENT)
.build()
// `without_url` so a builder error cannot echo a URL.
.map_err(|err| FetchError::Http(err.without_url().to_string()))?;
Ok(Self { http, max_bytes })
}
/// Reads the body in chunks, refusing to buffer more than `max_bytes` —
/// the cap has to bite *before* the allocation, not after (D6).
async fn bounded_body(&self, mut response: reqwest::Response) -> Result<Vec<u8>, FetchError> {
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|err| FetchError::Http(err.without_url().to_string()))?
{
if body.len() as u64 + chunk.len() as u64 > self.max_bytes {
return Err(FetchError::TooLarge {
limit: self.max_bytes,
});
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
}
/// Sent on every feed request. Some publishers reject an empty agent.
const USER_AGENT: &str = concat!("crabidy/", env!("CARGO_PKG_VERSION"));
#[async_trait]
impl Feeds for FeedFetcher {
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
// `without_url` on every error: a premium feed URL is a credential and
// reqwest puts the URL in its Display output by default (G1).
let response = self
.http
.get(url)
.send()
.await
.map_err(|err| FetchError::Http(err.without_url().to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(FetchError::Http(status.to_string()));
}
let body = self.bounded_body(response).await?;
// Recovered before parsing, because feed-rs loses this field (see
// `itunes_durations`).
let durations = itunes_durations(&body);
let parsed = feed_rs::parser::parse(body.as_slice())
.map_err(|err| FetchError::Parse(err.to_string()))?;
Ok(Feed {
title: parsed
.title
.map(|t| t.content)
.unwrap_or_default()
.trim()
.to_string(),
episodes: episodes_from(parsed.entries, limit, &durations),
})
}
}
/// Normalizes feed entries into episodes: newest first, capped, and skipping
/// anything unplayable. A single bad entry never fails the feed (G15).
fn episodes_from(
entries: Vec<feed_rs::model::Entry>,
limit: usize,
durations: &HashMap<String, u32>,
) -> Vec<Episode> {
let mut episodes: Vec<Episode> = entries
.into_iter()
.filter_map(|entry| {
let enclosure_url = audio_url(&entry)?;
// RSS `<guid>` normalizes into `id`; a feed without one keys on
// the enclosure URL instead (D4). The URL stays out of the path —
// only its hash is used.
let guid = if entry.id.trim().is_empty() {
enclosure_url.clone()
} else {
entry.id.clone()
};
let title = entry
.title
.as_ref()
.map(|t| t.content.trim().to_string())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| "(untitled)".to_string());
let duration_secs = durations
.get(&guid)
.copied()
.or_else(|| duration_of(&entry));
Some(Episode {
key: Episode::key_for(&guid),
guid,
title,
author: entry
.authors
.first()
.map(|p| p.name.trim().to_string())
.unwrap_or_default(),
published: entry.published.or(entry.updated).map(|d| d.timestamp()),
// Our own `itunes:duration` wins over feed-rs's NPT parse.
duration_secs,
enclosure_url,
})
})
.collect();
// Newest first where dates parse; a stable sort keeps dateless feeds in
// publication order (D9).
// Descending: `Reverse` keeps clippy's sort_by_key form while
// sorting newest first.
episodes.sort_by_key(|e| std::cmp::Reverse(e.published));
episodes.truncate(limit);
episodes
}
/// The first playable audio URL of an entry: a media enclosure, else a link
/// that advertises audio (feed dialects disagree about where it goes).
fn audio_url(entry: &feed_rs::model::Entry) -> Option<String> {
let media = entry.media.iter().flat_map(|m| m.content.iter());
// Prefer something explicitly typed as audio, then any media url at all.
let typed = media.clone().find(|c| {
c.url.is_some()
&& c.content_type
.as_ref()
.is_some_and(|ct| ct.to_string().starts_with("audio"))
});
if let Some(url) = typed.and_then(|c| c.url.as_ref()) {
return Some(url.to_string());
}
if let Some(url) = media.filter_map(|c| c.url.as_ref()).next() {
return Some(url.to_string());
}
entry
.links
.iter()
.find(|l| {
l.rel.as_deref() == Some("enclosure")
|| l.media_type
.as_deref()
.is_some_and(|ct| ct.starts_with("audio"))
})
.map(|l| l.href.clone())
}
/// `itunes:duration` (or a media duration) in whole seconds.
fn duration_of(entry: &feed_rs::model::Entry) -> Option<u32> {
entry
.media
.iter()
.find_map(|m| {
m.duration
.or_else(|| m.content.iter().find_map(|c| c.duration))
})
.map(|d| d.as_secs().min(u32::MAX as u64) as u32)
}
/// `itunes:duration` values recovered from the raw feed, keyed by both the
/// item's guid and its enclosure URL (the two things an [`Episode`] can key
/// on).
///
/// Why this exists: feed-rs parses `<itunes:duration>` with an **NPT** parser,
/// and NPT has no `MM:SS` form. For `<itunes:duration>53:25</itunes:duration>`
/// its fallback regex matches the leading number, so a 53-minute episode comes
/// back as 53 *seconds*. `1:20:40` happens to parse correctly. The iTunes spec
/// allows `S`, `MM:SS` and `HH:MM:SS`, so the field is recovered here and
/// overrides what feed-rs produced.
///
/// This is a deliberately shallow scan, not a second feed parser: it walks
/// item chunks and pulls three optional strings. Anything it cannot find falls
/// back to feed-rs's value.
fn itunes_durations(body: &[u8]) -> HashMap<String, u32> {
let text = String::from_utf8_lossy(body);
let mut found = HashMap::new();
// `<item` for RSS, `<entry` for Atom; the first chunk is the feed header.
for chunk in text.split("<item").flat_map(|c| c.split("<entry")).skip(1) {
let Some(duration) = tag_text(chunk, "itunes:duration").and_then(parse_itunes_duration)
else {
continue;
};
if let Some(guid) = tag_text(chunk, "guid") {
found.insert(guid, duration);
}
if let Some(url) = enclosure_url_attr(chunk) {
found.insert(url, duration);
}
}
found
}
/// The text of the first `<name …>text</name>` in `chunk`, trimmed.
fn tag_text(chunk: &str, name: &str) -> Option<String> {
let open = chunk.find(&format!("<{name}"))?;
let after_open = chunk[open..].find('>')? + open + 1;
let close = chunk[after_open..].find(&format!("</{name}>"))? + after_open;
let inner = chunk[after_open..close]
.trim()
.trim_start_matches("<![CDATA[")
.trim_end_matches("]]>")
.trim();
(!inner.is_empty()).then(|| inner.to_string())
}
/// The `url="…"` of the chunk's `<enclosure>`, XML-unescaped for `&amp;` — the
/// one entity that routinely appears in a query string.
fn enclosure_url_attr(chunk: &str) -> Option<String> {
let at = chunk.find("<enclosure")?;
let rest = &chunk[at..];
let start = rest.find("url=\"")? + 5;
let end = rest[start..].find('"')? + start;
Some(rest[start..end].replace("&amp;", "&"))
}
/// `itunes:duration` in whole seconds: `S`, `MM:SS`, or `HH:MM:SS`. Fractional
/// seconds are truncated; anything unparseable is `None` rather than a guess.
fn parse_itunes_duration(text: String) -> Option<u32> {
let mut parts: Vec<u64> = Vec::new();
for field in text.split(':') {
// Tolerate "53:25.5" and stray whitespace.
let field = field.trim();
let field = field.split('.').next().unwrap_or(field);
parts.push(field.parse::<u64>().ok()?);
}
let seconds = match parts.as_slice() {
[s] => *s,
[m, s] => m * 60 + s,
[h, m, s] => h * 3600 + m * 60 + s,
_ => return None,
};
Some(seconds.min(u32::MAX as u64) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
/// The bug this module works around: `MM:SS` is not NPT, and a 53-minute
/// episode must not come back as 53 seconds.
#[test]
fn itunes_durations_accept_every_documented_form() {
assert_eq!(parse_itunes_duration("3205".to_string()), Some(3205));
assert_eq!(parse_itunes_duration("53:25".to_string()), Some(3205));
assert_eq!(parse_itunes_duration("1:20:40".to_string()), Some(4840));
assert_eq!(parse_itunes_duration("53:25.5".to_string()), Some(3205));
assert_eq!(parse_itunes_duration("".to_string()), None);
assert_eq!(parse_itunes_duration("about an hour".to_string()), None);
}
/// Scanned straight out of a feed body shaped like the real one, keyed by
/// guid *and* enclosure url so either identity resolves it.
#[test]
fn durations_are_recovered_from_the_raw_body() {
let body = br#"<rss><channel>
<item>
<guid isPermaLink="false">6a6479f2a51cbd54</guid>
<itunes:duration>53:25</itunes:duration>
<enclosure url="https://cdn.example/a.mp3?tk=X&amp;sig=Y" type="audio/mpeg"/>
</item>
<item>
<guid>second</guid>
<itunes:duration>1:20:40</itunes:duration>
</item>
<item><guid>no-duration</guid></item>
</channel></rss>"#;
let found = itunes_durations(body);
assert_eq!(found.get("6a6479f2a51cbd54"), Some(&3205));
// The enclosure url is a key too, unescaped, for feeds without a guid.
assert_eq!(
found.get("https://cdn.example/a.mp3?tk=X&sig=Y"),
Some(&3205)
);
assert_eq!(found.get("second"), Some(&4840));
assert!(!found.contains_key("no-duration"));
}
#[test]
fn tag_text_handles_cdata_and_attributes() {
assert_eq!(
tag_text("<guid isPermaLink=\"false\">abc</guid>", "guid").as_deref(),
Some("abc")
);
assert_eq!(
tag_text("<title><![CDATA[ Hello ]]></title>", "title").as_deref(),
Some("Hello")
);
assert_eq!(tag_text("<guid></guid>", "guid"), None);
assert_eq!(tag_text("nothing here", "guid"), None);
}
}

View File

@ -1,617 +0,0 @@
//! RSS provider: **subscribe to podcast feeds and play their episodes**.
//! Mounted at [`PROVIDER_ROOT`] (architecture/rss-provider.md).
//!
//! Unlike `/fyyd` (discovery through a public directory) this is a
//! *subscription* provider: the feeds you name in `rss.toml` are the tree, and
//! it works with private, per-subscriber URLs.
//!
//! Two properties shape everything here:
//!
//! 1. **A feed URL is a credential.** A premium URL embeds a subscriber
//! token, so it is redacted from `Debug`, never logged, and never put in a
//! library path — paths carry a slug of the subscription name and a hash
//! of the episode guid instead (D1/D4). Paths are displayed, logged, and
//! persisted into saved queues and bookmarks; URLs must not be.
//! 2. **Feed content is never cached.** A listing always fetches, so a newly
//! published episode appears on the next visit (D2). The one memo that
//! exists is written by listings and read only when resolving a track
//! (D3), so queueing 40 episodes costs one fetch, not 40.
use std::fmt;
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::warn;
pub mod api;
use api::{Episode, Feeds};
/// First path segment owned by this provider.
pub const PROVIDER_ROOT: &str = "/rss";
/// Default cap on episodes listed per feed.
pub const DEFAULT_EPISODES_PER_FEED: usize = 200;
/// Default per-request timeout in seconds.
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
/// Default cap on a feed body, in bytes (8 MiB). A feed larger than this is a
/// publishing bug, not something to load into memory (D6).
pub const DEFAULT_MAX_FEED_BYTES: u64 = 8 * 1024 * 1024;
/// How many feeds' episode lists the memo keeps (D3).
pub const MEMO_CAPACITY: usize = 8;
/// One subscription. `name` is the user's; the path uses a slug of it.
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct FeedEntry {
/// Display name, and the source of the path slug.
pub name: String,
/// The feed URL. **Secret**: redacted from `Debug`, never logged, never in
/// a path (D1).
pub url: String,
}
impl fmt::Debug for FeedEntry {
/// Redacts `url` (hard rule: credentials never reach logs or reports).
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FeedEntry")
.field("name", &self.name)
.field("url", &"<redacted>")
.finish()
}
}
/// Provider settings, persisted as `rss.toml`. Everything is optional: with no
/// feeds the provider still mounts and shows an empty, creatable `/rss` you can
/// subscribe into with `%`.
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Settings {
/// The subscriptions, in config order.
#[serde(default, rename = "feeds")]
pub feeds: Vec<FeedEntry>,
/// Episodes listed per feed. Default [`DEFAULT_EPISODES_PER_FEED`].
pub episodes_per_feed: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
/// Cap on a feed body in bytes. Default [`DEFAULT_MAX_FEED_BYTES`].
pub max_feed_bytes: Option<u64>,
}
impl fmt::Debug for Settings {
/// The feed list is redacted wholesale — each entry hides its own url.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Settings")
.field("feeds", &self.feeds)
.field("episodes_per_feed", &self.episodes_per_feed)
.field("call_timeout_secs", &self.call_timeout_secs)
.field("max_feed_bytes", &self.max_feed_bytes)
.finish()
}
}
/// A parsed `/rss/...` path. Subscriptions are addressed by slug, episodes by
/// the guid hash — never by URL (D1).
#[derive(Debug, PartialEq, Eq)]
enum RssPath<'a> {
Root,
/// A subscription: `/rss/<slug>`.
Feed {
slug: &'a str,
},
/// One episode: `/rss/<slug>/<key>`.
Episode {
slug: &'a str,
key: &'a str,
},
}
/// Splits a `/rss/...` path into its recognized shape. Unknown shapes are
/// [`ProviderError::MalformedPath`].
fn parse_path(path: &str) -> Result<RssPath<'_>, ProviderError> {
if path == PROVIDER_ROOT {
return Ok(RssPath::Root);
}
let rest = path
.strip_prefix(PROVIDER_ROOT)
.and_then(|rest| rest.strip_prefix('/'))
.ok_or(ProviderError::MalformedPath)?;
let mut segments = rest.split('/');
let slug = segments
.next()
.filter(|s| !s.is_empty())
.ok_or(ProviderError::MalformedPath)?;
let key = segments.next();
if segments.next().is_some() {
// Nothing lives below an episode.
return Err(ProviderError::MalformedPath);
}
match key {
None => Ok(RssPath::Feed { slug }),
Some(key) if !key.is_empty() => Ok(RssPath::Episode { slug, key }),
Some(_) => Err(ProviderError::MalformedPath),
}
}
/// The path slug for a subscription name: lowercased, non-alphanumerics folded
/// to single dashes, trimmed, and bounded. Empty or all-punctuation names fall
/// back to `feed`; the caller de-duplicates collisions with a numeric suffix
/// (D1).
pub fn slugify(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
} else if ch.is_alphanumeric() {
// Keep non-ASCII letters readable rather than dropping them.
slug.extend(ch.to_lowercase());
} else if !slug.ends_with('-') {
slug.push('-');
}
if slug.len() >= SLUG_MAX_LEN {
break;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
// A name of pure punctuation still needs an addressable path.
return "feed".to_string();
}
slug
}
/// Cap on a generated slug, so a pathological feed title cannot produce an
/// unwieldy path.
const SLUG_MAX_LEN: usize = 48;
/// Resolves `name` to a slug not already in `taken`, suffixing `-2`, `-3`, …
fn unique_slug(name: &str, taken: &[String]) -> String {
let base = slugify(name);
if !taken.contains(&base) {
return base;
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|candidate| !taken.contains(candidate))
.unwrap_or(base)
}
/// The `/rss` provider.
///
/// Holds the subscriptions (from config) and the episode memo (D3). No listing
/// cache: a visit fetches.
pub struct Client {
api: Box<dyn Feeds>,
/// Subscriptions with their resolved slugs, in config order.
subscriptions: RwLock<Vec<Subscription>>,
/// `slug -> episodes`, written by listings, read by track lookups, capped
/// at [`MEMO_CAPACITY`] feeds (D3).
memo: RwLock<Vec<(String, Vec<Episode>)>>,
settings: RwLock<Settings>,
episodes_per_feed: usize,
}
/// A subscription with its path slug resolved (and de-duplicated).
#[derive(Clone, Debug)]
struct Subscription {
slug: String,
name: String,
/// **Secret**: never logged, never in a path.
url: String,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("subscriptions", &self.subscriptions)
.field("episodes_per_feed", &self.episodes_per_feed)
.finish_non_exhaustive()
}
}
impl Client {
/// Builds a client over an arbitrary [`Feeds`] backend — the seam the unit
/// tests use.
pub fn with_api(api: Box<dyn Feeds>, settings: Settings) -> Self {
let episodes_per_feed = settings
.episodes_per_feed
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_EPISODES_PER_FEED);
let mut slugs: Vec<String> = Vec::new();
let mut subscriptions = Vec::new();
for entry in &settings.feeds {
if entry.url.trim().is_empty() {
// A feed we cannot fetch is not a subscription. Names only —
// never the url (G1).
warn!(name = %entry.name, "skipping rss feed with no url");
continue;
}
let slug = unique_slug(&entry.name, &slugs);
slugs.push(slug.clone());
subscriptions.push(Subscription {
slug,
name: entry.name.trim().to_string(),
url: entry.url.trim().to_string(),
});
}
Self {
api,
subscriptions: RwLock::new(subscriptions),
memo: RwLock::new(Vec::new()),
settings: RwLock::new(settings),
episodes_per_feed,
}
}
/// Read access to the subscriptions, tolerating a poisoned lock rather than
/// panicking (a provider must not take the server down).
fn subs(&self) -> Vec<Subscription> {
match self.subscriptions.read() {
Ok(subs) => subs.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
/// The subscription owning `slug`, or `MalformedPath`.
fn subscription(&self, slug: &str) -> Result<Subscription, ProviderError> {
self.subs()
.into_iter()
.find(|sub| sub.slug == slug)
.ok_or_else(|| {
warn!(slug, "no such rss subscription");
ProviderError::MalformedPath
})
}
/// Fetches a feed and replaces its memo entry — the listing path (D3).
async fn fetch_and_memo(&self, sub: &Subscription) -> Result<Vec<Episode>, ProviderError> {
let feed = self
.api
.fetch(&sub.url, self.episodes_per_feed)
.await
.map_err(|err| {
// The error carries no url by construction (G1).
warn!(slug = %sub.slug, "cannot fetch rss feed: {err}");
ProviderError::FetchError
})?;
// The parser already sorts before truncating (it has to, or the cap
// would keep the wrong episodes), but the invariant belongs to the
// provider: any backend yields newest-first from here (G17).
let mut episodes = feed.episodes;
// Descending: `Reverse` keeps clippy's sort_by_key form while sorting
// newest first.
episodes.sort_by_key(|e| std::cmp::Reverse(e.published));
self.remember(&sub.slug, episodes.clone());
Ok(episodes)
}
/// Replaces `slug`'s memo entry, evicting the oldest beyond
/// [`MEMO_CAPACITY`] (D3/G5).
fn remember(&self, slug: &str, episodes: Vec<Episode>) {
let mut memo = match self.memo.write() {
Ok(memo) => memo,
Err(poisoned) => poisoned.into_inner(),
};
memo.retain(|(known, _)| known != slug);
memo.push((slug.to_string(), episodes));
while memo.len() > MEMO_CAPACITY {
memo.remove(0);
}
}
/// Drops `slug`'s memo entry (it was renamed or unsubscribed).
fn forget(&self, slug: &str) {
let mut memo = match self.memo.write() {
Ok(memo) => memo,
Err(poisoned) => poisoned.into_inner(),
};
memo.retain(|(known, _)| known != slug);
}
/// The memoized episodes for `slug`, if a listing has fetched it.
fn recall(&self, slug: &str) -> Option<Vec<Episode>> {
let memo = match self.memo.read() {
Ok(memo) => memo,
Err(poisoned) => poisoned.into_inner(),
};
memo.iter()
.find(|(known, _)| known == slug)
.map(|(_, episodes)| episodes.clone())
}
/// The episodes for `slug` from the memo, fetching only on a miss — the
/// track-lookup path (D3). Never consulted to answer a listing.
async fn episodes_for(&self, slug: &str) -> Result<Vec<Episode>, ProviderError> {
if let Some(episodes) = self.recall(slug) {
return Ok(episodes);
}
let sub = self.subscription(slug)?;
self.fetch_and_memo(&sub).await
}
/// The episode `key` of `slug`, or `MalformedPath` when the feed no longer
/// lists it (an episode aged out, or a publisher changed its guid).
async fn episode(&self, slug: &str, key: &str) -> Result<Episode, ProviderError> {
self.episodes_for(slug)
.await?
.into_iter()
.find(|episode| episode.key == key)
.ok_or_else(|| {
warn!(slug, key, "episode is not in the feed any more");
ProviderError::MalformedPath
})
}
/// One episode as a track. `provider_item_id` is the guid so the content
/// store de-duplicates captures of it (D4/G9).
fn track(&self, sub: &Subscription, episode: &Episode) -> Track {
Track {
path: format!("{}/{}/{}", PROVIDER_ROOT, sub.slug, episode.key),
artist: if episode.author.is_empty() {
sub.name.clone()
} else {
episode.author.clone()
},
title: episode.title.clone(),
duration: episode.duration_secs,
album: None,
is_skipped: false,
provider_item_id: episode.guid.clone(),
is_captured: false,
}
}
/// A subscription node listing `episodes` as tracks.
fn feed_node(&self, sub: &Subscription, episodes: &[Episode]) -> LibraryNode {
LibraryNode {
path: format!("{}/{}", PROVIDER_ROOT, sub.slug),
title: sub.name.clone(),
parent: Some(PROVIDER_ROOT.to_string()),
children: Vec::new(),
tracks: episodes.iter().map(|e| self.track(sub, e)).collect(),
is_queable: true,
is_creatable: false,
is_downloadable: true,
tracks_deletable: false,
is_captured: false,
}
}
/// Rewrites the persisted feed list from the live subscriptions, so the
/// server's settings write-back keeps them (D5/G14).
fn sync_settings(&self) {
let feeds = self
.subs()
.into_iter()
.map(|sub| FeedEntry {
name: sub.name,
url: sub.url,
})
.collect();
let mut settings = match self.settings.write() {
Ok(settings) => settings,
Err(poisoned) => poisoned.into_inner(),
};
settings.feeds = feeds;
}
/// Serializes the current settings, for the caller to persist after a
/// subscription changes (D5).
pub fn settings_toml(&self) -> String {
let settings = match self.settings.read() {
Ok(settings) => settings,
Err(poisoned) => poisoned.into_inner(),
};
toml::to_string_pretty(&*settings).unwrap_or_default()
}
}
#[async_trait]
impl ProviderClient for Client {
/// Loads `rss.toml`. A feed entry without a url is skipped with a warning;
/// no feeds at all is fine — `/rss` mounts empty and creatable, so `%` can
/// subscribe into it.
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
let settings: Settings = toml::from_str(raw_toml_settings).map_err(|err| {
warn!("could not parse rss.toml: {err}");
ProviderError::Config("rss.toml is not valid TOML".to_string())
})?;
let timeout = Duration::from_secs(
settings
.call_timeout_secs
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
);
let max_bytes = settings.max_feed_bytes.unwrap_or(DEFAULT_MAX_FEED_BYTES);
let api = api::FeedFetcher::new(timeout, max_bytes).map_err(|err| {
warn!("cannot build the rss client: {err}");
ProviderError::Config(err.to_string())
})?;
Ok(Self::with_api(Box::new(api), settings))
}
fn settings(&self) -> String {
self.settings_toml()
}
fn is_track_path(&self, path: &str) -> bool {
matches!(parse_path(path), Ok(RssPath::Episode { .. }))
}
/// The episode's enclosure URL. Never logged (G1).
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
let RssPath::Episode { slug, key } = parse_path(track_path)? else {
return Err(ProviderError::MalformedPath);
};
Ok(vec![self.episode(slug, key).await?.enclosure_url])
}
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
let RssPath::Episode { slug, key } = parse_path(track_path)? else {
return Err(ProviderError::MalformedPath);
};
let sub = self.subscription(slug)?;
let episode = self.episode(slug, key).await?;
Ok(self.track(&sub, &episode))
}
/// `/rss` itself: one child per subscription, creatable so `%` can
/// subscribe with a pasted URL (D5).
fn get_lib_root(&self) -> LibraryNode {
let children = self
.subs()
.into_iter()
.map(|sub| LibraryNodeChild {
is_editable: true,
is_deletable: true,
is_downloadable: true,
..LibraryNodeChild::new(format!("{}/{}", PROVIDER_ROOT, sub.slug), sub.name, true)
})
.collect();
LibraryNode {
path: PROVIDER_ROOT.to_string(),
title: "rss".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
children,
tracks: Vec::new(),
is_queable: false,
is_creatable: true,
is_downloadable: false,
tracks_deletable: false,
is_captured: false,
}
}
/// A subscription listing **always fetches** — this is the freshness
/// guarantee (D2/G3).
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
match parse_path(path)? {
RssPath::Root => Ok(self.get_lib_root()),
RssPath::Feed { slug } => {
let sub = self.subscription(slug)?;
let episodes = self.fetch_and_memo(&sub).await?;
Ok(self.feed_node(&sub, &episodes))
}
// An episode is a track, not a node.
RssPath::Episode { .. } => Err(ProviderError::MalformedPath),
}
}
/// `%` on `/rss`: `title` is a pasted feed URL. Fetches it once to prove it
/// works and to learn the feed's own name, then subscribes (D5).
async fn create_lib_node(
&self,
parent_path: &str,
title: &str,
) -> Result<LibraryNode, ProviderError> {
if parse_path(parent_path)? != RssPath::Root {
return Err(ProviderError::NotSupported);
}
let url = title.trim();
if !(url.starts_with("http://") || url.starts_with("https://")) {
warn!("rss subscribe needs an http(s) feed url");
return Err(ProviderError::InvalidInput);
}
let feed = self
.api
.fetch(url, self.episodes_per_feed)
.await
.map_err(|err| {
warn!("cannot subscribe to that feed: {err}");
ProviderError::FetchError
})?;
let name = if feed.title.is_empty() {
"feed".to_string()
} else {
feed.title.clone()
};
let sub = {
let mut subs = match self.subscriptions.write() {
Ok(subs) => subs,
Err(poisoned) => poisoned.into_inner(),
};
let taken: Vec<String> = subs.iter().map(|s| s.slug.clone()).collect();
let sub = Subscription {
slug: unique_slug(&name, &taken),
name,
url: url.to_string(),
};
subs.push(sub.clone());
sub
};
self.sync_settings();
self.remember(&sub.slug, feed.episodes.clone());
Ok(self.feed_node(&sub, &feed.episodes))
}
/// `e` on a subscription: rename it. The slug — and so the path — changes
/// with the name (G13).
async fn rename_lib_node(
&self,
path: &str,
new_title: &str,
) -> Result<LibraryNode, ProviderError> {
let RssPath::Feed { slug } = parse_path(path)? else {
return Err(ProviderError::NotSupported);
};
let name = new_title.trim();
if name.is_empty() {
return Err(ProviderError::InvalidInput);
}
let renamed = {
let mut subs = match self.subscriptions.write() {
Ok(subs) => subs,
Err(poisoned) => poisoned.into_inner(),
};
let index = subs
.iter()
.position(|sub| sub.slug == slug)
.ok_or(ProviderError::MalformedPath)?;
let taken: Vec<String> = subs
.iter()
.enumerate()
.filter(|(i, _)| *i != index)
.map(|(_, sub)| sub.slug.clone())
.collect();
subs[index].name = name.to_string();
subs[index].slug = unique_slug(name, &taken);
subs[index].clone()
};
self.sync_settings();
// The old memo entry belongs to a slug that no longer exists.
if let Some(episodes) = self.recall(slug) {
self.forget(slug);
self.remember(&renamed.slug, episodes);
}
let episodes = self.episodes_for(&renamed.slug).await?;
Ok(self.feed_node(&renamed, &episodes))
}
/// `d` on a subscription: unsubscribe. Removes the config entry and touches
/// no audio — a capture made from it stays under `/crabidy` (G13).
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
let RssPath::Feed { slug } = parse_path(path)? else {
return Err(ProviderError::NotSupported);
};
{
let mut subs = match self.subscriptions.write() {
Ok(subs) => subs,
Err(poisoned) => poisoned.into_inner(),
};
let before = subs.len();
subs.retain(|sub| sub.slug != slug);
if subs.len() == before {
return Err(ProviderError::MalformedPath);
}
}
self.sync_settings();
self.forget(slug);
// What the client should show next: the subscription list.
Ok(self.get_lib_root())
}
}
#[cfg(test)]
mod tests {
include!("tests.rs");
}

View File

@ -1,502 +0,0 @@
// Unit tests over a fixture-driven `FakeFeeds` backend (no network). They
// define the provider's contract (quality/rss-provider.md): secret handling,
// freshness, the memo, episode identity, slugs, and subscription CRUD.
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use super::*;
use api::{Feed, FetchError};
/// A feed backend with canned responses and a fetch counter, so tests can
/// assert *how many times* a feed was fetched — the freshness gates are about
/// exactly that.
#[derive(Debug, Default)]
struct FakeFeeds {
feeds: HashMap<String, Feed>,
fetches: AtomicUsize,
fail: bool,
}
impl FakeFeeds {
fn with(url: &str, feed: Feed) -> Self {
let mut feeds = HashMap::new();
feeds.insert(url.to_string(), feed);
Self {
feeds,
..Self::default()
}
}
fn failing() -> Self {
Self {
fail: true,
..Self::default()
}
}
fn count(&self) -> usize {
self.fetches.load(Ordering::Relaxed)
}
}
/// Lets a test keep a handle on the fake (to read its fetch counter) while the
/// client owns it as a `Box<dyn Feeds>`.
#[async_trait]
impl Feeds for Arc<FakeFeeds> {
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
self.as_ref().fetch(url, limit).await
}
}
#[async_trait]
impl Feeds for FakeFeeds {
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
self.fetches.fetch_add(1, Ordering::Relaxed);
if self.fail {
return Err(FetchError::Http("503 Service Unavailable".to_string()));
}
let mut feed = self
.feeds
.get(url)
.cloned()
.ok_or_else(|| FetchError::Http("404 Not Found".to_string()))?;
feed.episodes.truncate(limit);
Ok(feed)
}
}
fn episode(guid: &str, title: &str, published: Option<i64>) -> Episode {
Episode {
key: Episode::key_for(guid),
guid: guid.to_string(),
title: title.to_string(),
author: String::new(),
published,
duration_secs: Some(120),
enclosure_url: format!("https://cdn.example.org/{guid}.mp3?token=SECRET"),
}
}
fn feed(title: &str, episodes: Vec<Episode>) -> Feed {
Feed {
title: title.to_string(),
episodes,
}
}
/// A premium URL, the kind that must never escape into a path or a log.
const PREMIUM: &str = "https://feeds.economist.com/v1/rss/pods/f74365b0-TOKEN";
fn settings_with(name: &str, url: &str) -> Settings {
Settings {
feeds: vec![FeedEntry {
name: name.to_string(),
url: url.to_string(),
}],
..Settings::default()
}
}
fn client_with(api: FakeFeeds, settings: Settings) -> Client {
Client::with_api(Box::new(api), settings)
}
/// A client plus a handle on its backend, for the fetch-count gates.
fn counted(api: FakeFeeds, settings: Settings) -> (Client, Arc<FakeFeeds>) {
let api = Arc::new(api);
(Client::with_api(Box::new(Arc::clone(&api)), settings), api)
}
// --- Secrets (G1, G2) -----------------------------------------------------
#[test]
fn settings_debug_redacts_urls() {
let dumped = format!("{:?}", settings_with("The Economist", PREMIUM));
assert!(!dumped.contains("TOKEN"), "{dumped}");
assert!(!dumped.contains("economist.com"), "{dumped}");
assert!(dumped.contains("<redacted>"), "{dumped}");
}
#[test]
fn feed_entry_debug_redacts_the_url() {
let entry = FeedEntry {
name: "x".to_string(),
url: PREMIUM.to_string(),
};
let dumped = format!("{entry:?}");
assert!(!dumped.contains("TOKEN"), "{dumped}");
}
#[tokio::test]
async fn episode_paths_carry_only_slug_and_key() {
let api = FakeFeeds::with(
PREMIUM,
feed("The Economist", vec![episode("g1", "Ep", None)]),
);
let client = client_with(api, settings_with("The Economist", PREMIUM));
let node = client
.get_lib_node("/rss/the-economist")
.await
.expect("listing");
let path = &node.tracks[0].path;
assert_eq!(
path,
&format!("/rss/the-economist/{}", Episode::key_for("g1"))
);
// Neither the feed URL nor the enclosure token may appear in a path: paths
// are displayed, logged, and persisted into bookmarks.
assert!(!path.contains("TOKEN") && !path.contains("http"), "{path}");
}
// --- Freshness and the memo (G3, G4, G5) ----------------------------------
#[tokio::test]
async fn every_listing_refetches_the_feed() {
let (client, api) = counted(
FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])),
settings_with("Econ", PREMIUM),
);
for expected in 1..=3 {
client.get_lib_node("/rss/econ").await.expect("listing");
// Listings never answer from the memo — a newly published episode has
// to show up on the next visit (G3).
assert_eq!(api.count(), expected);
}
}
/// After one listing, playing or queueing its episodes costs no more fetches
/// (G4) — this is what keeps "always fresh" from meaning "fetch 40 times".
#[tokio::test]
async fn track_lookups_reuse_the_listing_fetch() {
let episodes = vec![episode("g1", "One", Some(2)), episode("g2", "Two", Some(1))];
let (client, api) = counted(
FakeFeeds::with(PREMIUM, feed("Econ", episodes)),
settings_with("Econ", PREMIUM),
);
let node = client.get_lib_node("/rss/econ").await.expect("listing");
assert_eq!(api.count(), 1);
for track in &node.tracks {
client
.get_urls_for_track(&track.path)
.await
.expect("stream url");
client
.get_metadata_for_track(&track.path)
.await
.expect("metadata");
}
assert_eq!(api.count(), 1, "the memo served every track lookup");
}
/// A track lookup with a cold memo — a bookmark replayed after a restart —
/// fetches once and then serves the rest from the memo (G4).
#[tokio::test]
async fn a_cold_memo_fetches_once() {
let (client, api) = counted(
FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])),
settings_with("Econ", PREMIUM),
);
let path = format!("/rss/econ/{}", Episode::key_for("g1"));
client.get_urls_for_track(&path).await.expect("stream url");
assert_eq!(api.count(), 1);
client.get_urls_for_track(&path).await.expect("stream url");
assert_eq!(api.count(), 1);
}
/// An episode the feed no longer lists cannot be resolved — an aged-out
/// bookmark fails cleanly instead of panicking.
#[tokio::test]
async fn an_episode_missing_from_the_feed_is_a_typed_error() {
let client = client_with(
FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])),
settings_with("Econ", PREMIUM),
);
let gone = format!("/rss/econ/{}", Episode::key_for("aged-out"));
assert_eq!(
client.get_urls_for_track(&gone).await.err(),
Some(ProviderError::MalformedPath)
);
}
/// The memo is bounded, so subscribing to many feeds cannot grow it without
/// limit (G5).
#[tokio::test]
async fn the_memo_evicts_beyond_its_capacity() {
let mut feeds = HashMap::new();
let mut entries = Vec::new();
let total = MEMO_CAPACITY + 2;
for i in 0..total {
let url = format!("https://feed{i}.example/rss");
feeds.insert(
url.clone(),
feed(
&format!("Feed {i}"),
vec![episode(&format!("g{i}"), "Ep", None)],
),
);
entries.push(FeedEntry {
name: format!("Feed {i}"),
url,
});
}
let api = Arc::new(FakeFeeds {
feeds,
..FakeFeeds::default()
});
let client = Client::with_api(
Box::new(Arc::clone(&api)),
Settings {
feeds: entries,
..Settings::default()
},
);
for i in 0..total {
client
.get_lib_node(&format!("/rss/feed-{i}"))
.await
.expect("listing");
}
assert_eq!(api.count(), total);
// The oldest two entries were evicted, so their track lookups refetch…
let first = format!("/rss/feed-0/{}", Episode::key_for("g0"));
client.get_urls_for_track(&first).await.expect("url");
assert_eq!(api.count(), total + 1, "evicted feed had to be refetched");
// …while the newest is still memoized.
let last = format!(
"/rss/feed-{}/{}",
total - 1,
Episode::key_for(&format!("g{}", total - 1))
);
client.get_urls_for_track(&last).await.expect("url");
assert_eq!(api.count(), total + 1);
}
// --- Identity and slugs (G7, G8, G9) --------------------------------------
#[test]
fn episode_keys_are_stable_and_short() {
let key = Episode::key_for("https://example.org/?p=42");
assert_eq!(key.len(), 16);
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
// Stable across calls — a bookmark written today resolves tomorrow.
assert_eq!(key, Episode::key_for("https://example.org/?p=42"));
assert_ne!(key, Episode::key_for("https://example.org/?p=43"));
}
#[test]
fn slugify_folds_and_bounds() {
assert_eq!(slugify("The Economist Podcasts"), "the-economist-podcasts");
assert_eq!(slugify(" Hello, World! "), "hello-world");
assert_eq!(slugify("!!!"), "feed");
assert_eq!(slugify(""), "feed");
assert!(slugify(&"x".repeat(200)).len() <= 48);
}
#[test]
fn duplicate_slugs_get_a_suffix() {
let settings = Settings {
feeds: vec![
FeedEntry {
name: "News".to_string(),
url: "https://a.example/feed".to_string(),
},
FeedEntry {
name: "news".to_string(),
url: "https://b.example/feed".to_string(),
},
FeedEntry {
name: "N E W S".to_string(),
url: "https://c.example/feed".to_string(),
},
],
..Settings::default()
};
let client = client_with(FakeFeeds::default(), settings);
let slugs: Vec<String> = client
.get_lib_root()
.children
.into_iter()
.map(|c| c.path)
.collect();
// "N E W S" folds each space to a dash, so it does not collide with
// "news" — the suffix only appears for a genuine collision.
assert_eq!(slugs, ["/rss/news", "/rss/news-2", "/rss/n-e-w-s"]);
}
#[tokio::test]
async fn tracks_carry_the_guid_as_provider_item_id() {
let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("guid-42", "Ep", None)]));
let client = client_with(api, settings_with("Econ", PREMIUM));
let node = client.get_lib_node("/rss/econ").await.expect("listing");
assert_eq!(node.tracks[0].provider_item_id, "guid-42");
}
#[tokio::test]
async fn episodes_are_newest_first() {
let api = FakeFeeds::with(
PREMIUM,
feed(
"Econ",
vec![
episode("old", "Old", Some(100)),
episode("new", "New", Some(300)),
episode("mid", "Mid", Some(200)),
],
),
);
let client = client_with(api, settings_with("Econ", PREMIUM));
let node = client.get_lib_node("/rss/econ").await.expect("listing");
let titles: Vec<&str> = node.tracks.iter().map(|t| t.title.as_str()).collect();
assert_eq!(titles, ["New", "Mid", "Old"]);
}
// --- Errors (G10, G15, G18) ----------------------------------------------
#[tokio::test]
async fn foreign_and_malformed_paths_reject() {
let client = client_with(FakeFeeds::default(), settings_with("Econ", PREMIUM));
for path in ["/tidal/x", "/rss/", "/rss/econ/key/extra", "/rssx"] {
assert!(client.get_lib_node(path).await.is_err(), "{path}");
}
// An unknown subscription is not a panic.
assert_eq!(
client.get_lib_node("/rss/nope").await.err(),
Some(ProviderError::MalformedPath)
);
assert!(!client.is_track_path("/rss/econ"));
assert!(client.is_track_path("/rss/econ/abcdef"));
}
#[tokio::test]
async fn backend_failures_are_typed() {
let client = client_with(FakeFeeds::failing(), settings_with("Econ", PREMIUM));
assert_eq!(
client.get_lib_node("/rss/econ").await.err(),
Some(ProviderError::FetchError)
);
}
#[tokio::test]
async fn no_feeds_still_mounts() {
let client = client_with(FakeFeeds::default(), Settings::default());
let root = client.get_lib_root();
assert!(root.children.is_empty());
// Empty but creatable, so `%` can subscribe into it.
assert!(root.is_creatable);
}
#[test]
fn a_feed_entry_without_a_url_is_skipped() {
let settings = Settings {
feeds: vec![
FeedEntry {
name: "broken".to_string(),
url: " ".to_string(),
},
FeedEntry {
name: "good".to_string(),
url: PREMIUM.to_string(),
},
],
..Settings::default()
};
let client = client_with(FakeFeeds::default(), settings);
assert_eq!(client.get_lib_root().children.len(), 1);
}
// --- Subscription CRUD (G11G14) -----------------------------------------
#[tokio::test]
async fn subscribing_names_from_the_feed_title() {
let api = FakeFeeds::with(
PREMIUM,
feed("The Economist Podcasts", vec![episode("g1", "Ep", None)]),
);
let client = client_with(api, Settings::default());
let node = client
.create_lib_node("/rss", PREMIUM)
.await
.expect("subscribe");
assert_eq!(node.path, "/rss/the-economist-podcasts");
assert_eq!(node.title, "The Economist Podcasts");
assert_eq!(client.get_lib_root().children.len(), 1);
}
#[tokio::test]
async fn subscribing_rejects_bad_input() {
let client = client_with(FakeFeeds::failing(), Settings::default());
// Not a URL at all.
assert_eq!(
client.create_lib_node("/rss", "the economist").await.err(),
Some(ProviderError::InvalidInput)
);
// A URL that cannot be fetched adds nothing.
assert_eq!(
client
.create_lib_node("/rss", "https://nope.example/feed")
.await
.err(),
Some(ProviderError::FetchError)
);
assert!(client.get_lib_root().children.is_empty());
// Only the root is creatable.
assert_eq!(
client.create_lib_node("/rss/econ", PREMIUM).await.err(),
Some(ProviderError::NotSupported)
);
}
#[tokio::test]
async fn renaming_moves_the_slug() {
let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "Ep", None)]));
let client = client_with(api, settings_with("Econ", PREMIUM));
let node = client
.rename_lib_node("/rss/econ", "World News")
.await
.expect("rename");
assert_eq!(node.path, "/rss/world-news");
assert!(client.get_lib_node("/rss/econ").await.is_err());
assert!(client.get_lib_node("/rss/world-news").await.is_ok());
// An empty name is rejected.
assert_eq!(
client.rename_lib_node("/rss/world-news", " ").await.err(),
Some(ProviderError::InvalidInput)
);
}
#[tokio::test]
async fn unsubscribing_removes_only_the_entry() {
let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "Ep", None)]));
let client = client_with(api, settings_with("Econ", PREMIUM));
let root = client.delete_lib_node("/rss/econ").await.expect("delete");
assert!(root.children.is_empty());
assert_eq!(
client.delete_lib_node("/rss/econ").await.err(),
Some(ProviderError::MalformedPath)
);
}
#[tokio::test]
async fn subscription_changes_round_trip_through_toml() {
let api = FakeFeeds::with(
PREMIUM,
feed("The Economist Podcasts", vec![episode("g1", "Ep", None)]),
);
let client = client_with(api, Settings::default());
client
.create_lib_node("/rss", PREMIUM)
.await
.expect("subscribe");
let toml_text = client.settings();
// The url must be persisted (it is the credential the user configured)…
assert!(toml_text.contains(PREMIUM), "{toml_text}");
// …and reloading must yield the same subscription.
let reloaded: Settings = toml::from_str(&toml_text).expect("reparse");
assert_eq!(reloaded.feeds.len(), 1);
assert_eq!(reloaded.feeds[0].url, PREMIUM);
let client = client_with(FakeFeeds::default(), reloaded);
assert_eq!(
client.get_lib_root().children[0].path,
"/rss/the-economist-podcasts"
);
}