Compare commits
6 Commits
e2c1b44cdb
...
378066470e
| Author | SHA1 | Date |
|---|---|---|
|
|
378066470e | |
|
|
bdc961501d | |
|
|
1b2f01a2b6 | |
|
|
7fe4326923 | |
|
|
95700bf31f | |
|
|
ad19b6352e |
|
|
@ -783,8 +783,10 @@ 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",
|
||||
]
|
||||
|
||||
|
|
@ -1173,6 +1175,7 @@ dependencies = [
|
|||
"rand 0.10.2",
|
||||
"realfft",
|
||||
"reqwest 0.13.1",
|
||||
"rssdy",
|
||||
"serde",
|
||||
"soundclouddy",
|
||||
"tempfile",
|
||||
|
|
@ -1634,6 +1637,23 @@ 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"
|
||||
|
|
@ -2851,6 +2871,9 @@ name = "mediatype"
|
|||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
|
|
@ -3698,6 +3721,16 @@ 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"
|
||||
|
|
@ -4249,6 +4282,22 @@ 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"
|
||||
|
|
@ -5901,6 +5950,7 @@ dependencies = [
|
|||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ members = [
|
|||
"fsdy",
|
||||
"fyyd",
|
||||
"jamendody",
|
||||
"rssdy",
|
||||
"soundclouddy",
|
||||
"tidaldy",
|
||||
"ytdy",
|
||||
|
|
@ -44,6 +45,7 @@ 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"
|
||||
|
|
@ -114,6 +116,7 @@ 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" }
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -13,6 +13,7 @@ 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)
|
||||
|
|
@ -86,6 +87,7 @@ 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 |
|
||||
|
|
@ -154,7 +156,7 @@ On first start the server writes this file with every provider enabled:
|
|||
|
||||
```toml
|
||||
providers = [
|
||||
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo",
|
||||
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", "rss",
|
||||
"fs", "crabidy", "orphans",
|
||||
]
|
||||
```
|
||||
|
|
@ -316,7 +318,17 @@ 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.
|
||||
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.
|
||||
|
||||
- `w` saves the selection (a library subtree, or in the queue pane the
|
||||
queue) as a new folder under `/crabidy/<name>` of **link** files —
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
# 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.
|
||||
|
|
@ -76,7 +76,8 @@ 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.
|
||||
/// Insert tracks/subtrees at a position, pushing what was there down
|
||||
/// (0 = front, past the end = append).
|
||||
Insert { position: u32, paths: Vec<String> },
|
||||
/// Replace the whole queue with the given tracks/subtrees.
|
||||
Replace { paths: Vec<String> },
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ 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;
|
||||
|
|
@ -97,7 +98,11 @@ pub enum Action {
|
|||
/// library and queue scopes). A no-op when no filter is applied.
|
||||
ClearSearch,
|
||||
// Queue pane
|
||||
QueueInsertHere,
|
||||
QueueToggleMark,
|
||||
QueueVisualMode,
|
||||
QueueYank,
|
||||
QueuePaste,
|
||||
QueuePasteBefore,
|
||||
QueueFirst,
|
||||
QueueLast,
|
||||
QueueNext,
|
||||
|
|
@ -309,6 +314,13 @@ 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,
|
||||
|
|
@ -436,12 +448,47 @@ 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::QueueInsertHere,
|
||||
description: "Insert library selection after this track",
|
||||
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",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Queue,
|
||||
|
|
@ -832,9 +879,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
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).
|
||||
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.
|
||||
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
|
||||
|
|
@ -846,11 +893,11 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
|
||||
None
|
||||
Some(Action::QueueVisualMode)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
|
||||
None
|
||||
Some(Action::QueueVisualMode)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ 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 (Global left,
|
||||
/// Library + Queue right, so the whole table fits a typical frame), and a
|
||||
/// close-keys footer derived from the `Scope::Help` bindings.
|
||||
/// 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.
|
||||
struct HelpContent {
|
||||
usage: Vec<Line<'static>>,
|
||||
left: Vec<Line<'static>>,
|
||||
|
|
@ -30,11 +34,11 @@ impl HelpContent {
|
|||
Line::from(""),
|
||||
];
|
||||
|
||||
let left = group(Scope::Global, "Global");
|
||||
let mut left = group(Scope::Global, "Global");
|
||||
left.push(Line::from(""));
|
||||
left.extend(group(Scope::Queue, "Queue"));
|
||||
|
||||
let mut right = group(Scope::Library, "Library");
|
||||
right.push(Line::from(""));
|
||||
right.extend(group(Scope::Queue, "Queue"));
|
||||
let right = group(Scope::Library, "Library");
|
||||
|
||||
// All Help-scope chords close the modal; derive their labels instead
|
||||
// of hardcoding key names.
|
||||
|
|
@ -190,14 +194,29 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn help_lists_bindings_from_the_table() {
|
||||
let text = buffer_text(&render_to_buffer(100, 40));
|
||||
// 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));
|
||||
// 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));
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use ratatui::{
|
|||
use crabidy_core::proto::crabidy::LibraryNode;
|
||||
|
||||
use super::{
|
||||
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
|
||||
COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||
Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
|
||||
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||
};
|
||||
|
||||
pub struct Library {
|
||||
|
|
@ -110,24 +110,11 @@ 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>> {
|
||||
// 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()])
|
||||
self.selection().map(|(paths, _labels)| paths)
|
||||
}
|
||||
pub fn ascend(&mut self) {
|
||||
if let Some(parent) = self.parent.as_ref() {
|
||||
|
|
@ -167,41 +154,9 @@ 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
|
||||
|
|
@ -211,56 +166,6 @@ 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.
|
||||
|
|
@ -441,6 +346,32 @@ 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::*;
|
||||
|
|
|
|||
|
|
@ -163,3 +163,256 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ mod library;
|
|||
mod list;
|
||||
mod now_playing;
|
||||
mod queue;
|
||||
mod register;
|
||||
|
||||
use flume::Sender;
|
||||
use ratatui::{
|
||||
|
|
@ -18,12 +19,14 @@ 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 {
|
||||
|
|
@ -37,7 +40,7 @@ enum UiItemKind {
|
|||
Track,
|
||||
}
|
||||
|
||||
struct UiItem {
|
||||
pub(crate) struct UiItem {
|
||||
path: String,
|
||||
title: String,
|
||||
kind: UiItemKind,
|
||||
|
|
@ -277,6 +280,9 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +300,7 @@ impl App {
|
|||
library,
|
||||
now_playing,
|
||||
queue,
|
||||
register: Register::default(),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
|
@ -416,12 +423,50 @@ 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).
|
||||
let was_visual = self.library.is_visual();
|
||||
if was_visual
|
||||
// `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()
|
||||
&& !matches!(
|
||||
action,
|
||||
Action::LibraryFirst
|
||||
|
|
@ -436,6 +481,22 @@ 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,
|
||||
|
|
@ -554,6 +615,7 @@ 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
|
||||
|
|
@ -564,24 +626,40 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
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::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::QueuePlaySelected => self.queue.play_selected(),
|
||||
Action::QueueRemoveTrack => self.queue.remove_track(),
|
||||
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::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 => {
|
||||
|
|
@ -642,7 +720,8 @@ impl App {
|
|||
.constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref())
|
||||
.split(main[1]);
|
||||
|
||||
self.queue.render(f, right_side[0], queue_focused);
|
||||
self.queue
|
||||
.render(f, right_side[0], queue_focused, self.register.len());
|
||||
self.now_playing.render(f, right_side[1]);
|
||||
|
||||
// The node-creation/rename input: one line inside the bottom of the
|
||||
|
|
@ -1821,4 +1900,226 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use ratatui::{
|
|||
use crabidy_core::proto::crabidy::Queue as QueueData;
|
||||
|
||||
use super::{
|
||||
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
||||
COLOR_RED, COLOR_SECONDARY,
|
||||
carry_marks, Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
|
||||
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||
};
|
||||
|
||||
pub struct Queue {
|
||||
|
|
@ -28,6 +28,9 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +42,7 @@ impl Queue {
|
|||
list_state: ListState::default(),
|
||||
filter: Filter::default(),
|
||||
resolving: false,
|
||||
visual: None,
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
|
@ -84,10 +88,88 @@ impl Queue {
|
|||
self.select(Some(view));
|
||||
}
|
||||
}
|
||||
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]));
|
||||
/// 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 update_position(&mut self, pos: usize) {
|
||||
|
|
@ -96,14 +178,23 @@ 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()
|
||||
.map(|t| UiItem {
|
||||
.enumerate()
|
||||
.map(|(idx, t)| UiItem {
|
||||
path: t.path.clone(),
|
||||
title: format!("{} - {}", t.artist, t.title),
|
||||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
marked: carried.get(idx).copied().unwrap_or(false),
|
||||
is_queable: false,
|
||||
is_creatable: false,
|
||||
is_editable: false,
|
||||
|
|
@ -121,7 +212,10 @@ impl Queue {
|
|||
self.update_selection();
|
||||
}
|
||||
|
||||
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
/// 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) {
|
||||
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
|
||||
|
|
@ -135,13 +229,24 @@ impl Queue {
|
|||
.map(|(idx, (real, item))| {
|
||||
let active = real == self.current_position;
|
||||
|
||||
let title = if active {
|
||||
format!("> {}", item.title)
|
||||
} else {
|
||||
item.title.to_string()
|
||||
};
|
||||
// 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 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
|
||||
|
|
@ -185,9 +290,15 @@ impl Queue {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title(match self.filter.query() {
|
||||
Some(query) => format!("Queue — /{query}▏"),
|
||||
None => "Queue".to_string(),
|
||||
.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}"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.highlight_style(Style::default().bg(if focused {
|
||||
|
|
@ -215,6 +326,33 @@ 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::*;
|
||||
|
|
@ -246,7 +384,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))
|
||||
.draw(|f| queue.render(f, f.area(), true, 0))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..buffer.area.height)
|
||||
|
|
@ -328,11 +466,34 @@ 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))
|
||||
.draw(|f| queue.render(f, f.area(), true, 0))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
for y in 0..buffer.area.height {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
//! The client-side register: what `y`, `d`, `c`, and `C` put aside and `p`/`P`
|
||||
//! paste back (architecture/queue-register.md D1–D3).
|
||||
//!
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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; 3] = ["/crabidy", "/fs", "/orphans"];
|
||||
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
|
||||
!MUTABLE_ROOTS.iter().any(|root| {
|
||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, UiItemKind,
|
||||
NamePurpose, QueueCursor, Register, UiItemKind,
|
||||
};
|
||||
|
||||
const VOLUME_STEP: f32 = 0.1;
|
||||
|
|
@ -119,6 +119,9 @@ 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>,
|
||||
|
|
@ -150,6 +153,7 @@ 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()),
|
||||
|
|
@ -191,9 +195,11 @@ 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);
|
||||
let len = self.queue.with_untracked(Vec::len);
|
||||
self.queue_cursor.update(|c| c.clamp(len));
|
||||
self.queue_cursor.update(|c| c.reconcile(paths));
|
||||
}
|
||||
StreamUpdate::Mods(mods) => self.mods.set(mods),
|
||||
StreamUpdate::QueueTrack(queue_track) => {
|
||||
|
|
@ -286,7 +292,98 @@ 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),
|
||||
|
|
@ -309,12 +406,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.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::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::LibraryAscend => {
|
||||
if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) {
|
||||
self.open_library_node(parent);
|
||||
|
|
@ -355,17 +452,6 @@ 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
|
||||
|
|
@ -425,33 +511,17 @@ impl Store {
|
|||
}));
|
||||
}
|
||||
}
|
||||
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::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 => {
|
||||
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));
|
||||
self.queue_move(|c, len| c.selected = len.saturating_sub(1));
|
||||
}
|
||||
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;
|
||||
let len = self.queue.with_untracked(Vec::len);
|
||||
self.queue_cursor
|
||||
.update(|c| c.selected = current.min(len.saturating_sub(1)));
|
||||
self.queue_move(|c, len| c.selected = current.min(len.saturating_sub(1)));
|
||||
}
|
||||
Action::QueuePlaySelected => {
|
||||
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
|
||||
|
|
@ -460,15 +530,52 @@ impl Store {
|
|||
}
|
||||
}
|
||||
Action::QueueRemoveTrack => {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).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::QueueSaveAs => {
|
||||
if self.queue.with_untracked(|q| !q.is_empty()) {
|
||||
self.dialog.set(Some(Dialog::Name {
|
||||
|
|
@ -777,6 +884,9 @@ 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
|
||||
|
|
@ -935,14 +1045,22 @@ 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="insert library selection after selected (p)"
|
||||
on:click=move |_| store.dispatch(Action::QueueInsertHere)
|
||||
title="paste the register after selected (p)"
|
||||
on:click=move |_| store.dispatch(Action::QueuePaste)
|
||||
>
|
||||
"insert"
|
||||
"paste"
|
||||
</button>
|
||||
<button
|
||||
class="ghost"
|
||||
|
|
@ -969,7 +1087,9 @@ fn QueueView(store: Store) -> impl IntoView {
|
|||
<ul class="list">
|
||||
{move || {
|
||||
let current = store.queue_pos.get() as usize;
|
||||
let cursor = store.queue_cursor.get().selected;
|
||||
let queue_state = store.queue_cursor.get();
|
||||
let cursor = queue_state.selected;
|
||||
let marks = queue_state.marks.clone();
|
||||
store
|
||||
.queue
|
||||
.get()
|
||||
|
|
@ -977,9 +1097,11 @@ 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 |_| {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub enum Action {
|
|||
LibraryAscend,
|
||||
LibraryDive,
|
||||
LibraryToggleMark,
|
||||
LibraryVisualMode,
|
||||
LibraryYank,
|
||||
LibraryCaptureNode,
|
||||
LibraryDownloadNode,
|
||||
LibraryCreateNode,
|
||||
|
|
@ -46,7 +48,11 @@ pub enum Action {
|
|||
QueueJumpUp,
|
||||
QueueSelectCurrent,
|
||||
QueuePlaySelected,
|
||||
QueueInsertHere,
|
||||
QueueToggleMark,
|
||||
QueueVisualMode,
|
||||
QueueYank,
|
||||
QueuePaste,
|
||||
QueuePasteBefore,
|
||||
QueueRemoveTrack,
|
||||
QueueClearKeepCurrent,
|
||||
QueueClearAll,
|
||||
|
|
@ -148,6 +154,16 @@ 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",
|
||||
|
|
@ -215,8 +231,23 @@ pub const HELP: &[HelpEntry] = &[
|
|||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
key: "p",
|
||||
description: "Insert library selection after this track",
|
||||
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",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Queue",
|
||||
|
|
@ -293,6 +324,8 @@ 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),
|
||||
|
|
@ -310,7 +343,11 @@ 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),
|
||||
"p" => Some(Action::QueueInsertHere),
|
||||
"s" => Some(Action::QueueToggleMark),
|
||||
"v" | "V" => Some(Action::QueueVisualMode),
|
||||
"y" => Some(Action::QueueYank),
|
||||
"p" => Some(Action::QueuePaste),
|
||||
"P" => Some(Action::QueuePasteBefore),
|
||||
"d" => Some(Action::QueueRemoveTrack),
|
||||
"c" => Some(Action::QueueClearKeepCurrent),
|
||||
"C" => Some(Action::QueueClearAll),
|
||||
|
|
|
|||
|
|
@ -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; 3] = ["/crabidy", "/fs", "/orphans"];
|
||||
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
|
||||
!MUTABLE_ROOTS.iter().any(|root| {
|
||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||
})
|
||||
|
|
@ -104,6 +104,10 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +197,66 @@ 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;
|
||||
|
|
@ -242,9 +306,20 @@ 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.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
// 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)]
|
||||
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 {
|
||||
|
|
@ -258,6 +333,156 @@ 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 —
|
||||
|
|
@ -526,4 +751,150 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -392,6 +392,18 @@ 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ all-providers = [
|
|||
"abs",
|
||||
"soundcloud",
|
||||
"jamendo",
|
||||
"rss",
|
||||
"fs",
|
||||
]
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ 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
|
||||
|
|
@ -77,6 +79,7 @@ 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 }
|
||||
|
|
|
|||
|
|
@ -255,10 +255,12 @@ pub enum ResolveKind {
|
|||
Replace,
|
||||
/// Every chunk appends at the end.
|
||||
Append,
|
||||
/// 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),
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// The playback loop's bookkeeping for one in-flight resolve operation.
|
||||
|
|
@ -340,12 +342,12 @@ impl PendingResolve {
|
|||
queue.replace_with_tracks(tracks)
|
||||
}
|
||||
ResolveKind::Append => queue.append_tracks(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)
|
||||
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)
|
||||
}
|
||||
};
|
||||
if started.is_some() {
|
||||
|
|
@ -592,19 +594,23 @@ 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 position = (position as usize).min(len - 1);
|
||||
let at = (position as usize).min(len);
|
||||
let order_additions: Vec<usize> = (len..len + inserted).collect();
|
||||
self.play_order.extend(order_additions);
|
||||
let tail: Vec<Track> = self
|
||||
.tracks
|
||||
.splice(position + 1.., tracks.to_vec())
|
||||
.collect();
|
||||
let tail: Vec<Track> = self.tracks.splice(at.., 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
|
||||
|
|
@ -614,7 +620,7 @@ impl QueueManager {
|
|||
.play_order
|
||||
.iter_mut()
|
||||
.take(self.current_offset)
|
||||
.filter(|i| position < **i)
|
||||
.filter(|i| at <= **i)
|
||||
{
|
||||
*i += inserted;
|
||||
changed.push(*i);
|
||||
|
|
@ -636,9 +642,10 @@ 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, tracks)
|
||||
self.insert_tracks(pos as u32 + 1, tracks)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, exclude_current: bool) -> bool {
|
||||
|
|
@ -750,6 +757,35 @@ 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);
|
||||
|
|
@ -779,9 +815,10 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn insert_after_op_keeps_chunks_contiguous_and_in_order() {
|
||||
fn insert_op_keeps_chunks_contiguous_and_in_order() {
|
||||
let mut q = queue_with(3); // playing track 0
|
||||
let mut op = PendingResolve::new(ResolveKind::InsertAfter(0));
|
||||
// "after track 0" is "at index 1".
|
||||
let mut op = PendingResolve::new(ResolveKind::InsertAt(1));
|
||||
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
|
||||
|
|
@ -793,9 +830,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn insert_after_op_clamps_past_the_end() {
|
||||
fn insert_op_clamps_past_the_end() {
|
||||
let mut q = queue_with(1);
|
||||
let mut op = PendingResolve::new(ResolveKind::InsertAfter(99));
|
||||
let mut op = PendingResolve::new(ResolveKind::InsertAt(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"]);
|
||||
|
|
|
|||
|
|
@ -192,7 +192,8 @@ impl Playback {
|
|||
};
|
||||
queue.current_position() as u32
|
||||
};
|
||||
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||
// Play-next means "right after the current track".
|
||||
self.start_resolve(ResolveKind::InsertAt(position + 1), paths);
|
||||
}
|
||||
|
||||
PlaybackCommand::Append { paths } => {
|
||||
|
|
@ -238,7 +239,9 @@ impl Playback {
|
|||
}
|
||||
|
||||
PlaybackCommand::Insert { position, paths } => {
|
||||
self.start_resolve(ResolveKind::InsertAfter(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);
|
||||
}
|
||||
|
||||
PlaybackCommand::Clear { exclude_current } => {
|
||||
|
|
@ -904,7 +907,12 @@ mod tests {
|
|||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs")]
|
||||
/// 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()
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
@ -1055,4 +1063,91 @@ 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ impl Mount {
|
|||
feature = "fyyd",
|
||||
feature = "abs",
|
||||
feature = "soundcloud",
|
||||
feature = "jamendo"
|
||||
feature = "jamendo",
|
||||
feature = "rss"
|
||||
))]
|
||||
async fn mount_from_config<C>(
|
||||
config_dir: &std::path::Path,
|
||||
|
|
@ -461,6 +462,8 @@ 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(
|
||||
|
|
@ -509,6 +512,18 @@ 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(
|
||||
|
|
|
|||
|
|
@ -23,13 +23,14 @@ 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; 9] = [
|
||||
pub const ALL_PROVIDERS: [&str; 10] = [
|
||||
"tidal",
|
||||
"youtube",
|
||||
"fyyd",
|
||||
"abs",
|
||||
"soundcloud",
|
||||
"jamendo",
|
||||
"rss",
|
||||
"fs",
|
||||
"crabidy",
|
||||
"orphans",
|
||||
|
|
@ -53,6 +54,8 @@ pub const BUILT_IN_PROVIDERS: &[&str] = &[
|
|||
"soundcloud",
|
||||
#[cfg(feature = "jamendo")]
|
||||
"jamendo",
|
||||
#[cfg(feature = "rss")]
|
||||
"rss",
|
||||
#[cfg(feature = "fs")]
|
||||
"fs",
|
||||
#[cfg(feature = "fs")]
|
||||
|
|
@ -110,6 +113,7 @@ pub struct ProviderToggles {
|
|||
pub abs: bool,
|
||||
pub soundcloud: bool,
|
||||
pub jamendo: bool,
|
||||
pub rss: bool,
|
||||
pub fs: bool,
|
||||
pub crabidy: bool,
|
||||
pub orphans: bool,
|
||||
|
|
@ -128,6 +132,7 @@ 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).
|
||||
|
|
@ -260,6 +265,7 @@ 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"),
|
||||
|
|
|
|||
|
|
@ -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 fs; do
|
||||
for feature in tidal youtube fyyd abs soundcloud jamendo rss fs; do
|
||||
clippy -p crabidy-server --no-default-features --features "$feature"
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
- [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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -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** 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.
|
||||
- `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.
|
||||
- `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,6 +74,39 @@ 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
|
||||
|
|
@ -139,6 +172,7 @@ 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 |
|
||||
|
|
@ -152,12 +186,15 @@ 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` | Insert library selection after this track |
|
||||
| Queue | `d` | Remove selected track |
|
||||
| Queue | `c` | Clear queue except current track |
|
||||
| Queue | `C` | Clear entire queue |
|
||||
| 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 | `w` | Save queue under a name |
|
||||
| Queue | `W` | Capture the queue into /crabidy (audio) |
|
||||
| Queue | `/` | Filter this view |
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ 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. The `/` live
|
||||
filter is TUI-only for now.
|
||||
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.
|
||||
|
||||
## How it is served
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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) |
|
||||
|
|
@ -57,6 +58,7 @@ providers = [
|
|||
"abs",
|
||||
"soundcloud",
|
||||
"jamendo",
|
||||
"rss",
|
||||
"fs",
|
||||
"crabidy",
|
||||
"orphans",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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"
|
||||
|
|
@ -31,6 +32,7 @@ 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/*"
|
||||
|
|
@ -90,6 +92,9 @@ 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`).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
# 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.
|
||||
|
|
@ -132,6 +132,32 @@ 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
# 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, G7–G9, G13–G15.
|
||||
- [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.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# 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:* G11–G14.
|
||||
- [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.
|
||||
114
plan/summary.md
114
plan/summary.md
|
|
@ -1279,3 +1279,117 @@ 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` (D1–D10), `quality/queue-register.md`
|
||||
(G1–G26), `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` (G1–G21), `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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
[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"] }
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
//! 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 `&` — 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("&", "&"))
|
||||
}
|
||||
|
||||
/// `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&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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
//! 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");
|
||||
}
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
// 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 (G11–G14) -----------------------------------------
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue