Compare commits
No commits in common. "378066470ea0c0990ee4b3185b89b7085695fbd1" and "e2c1b44cdbdd80c89a468c5096ceaea06e261c2d" have entirely different histories.
378066470e
...
e2c1b44cdb
|
|
@ -783,10 +783,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"wasm-bindgen",
|
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1175,7 +1173,6 @@ dependencies = [
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"realfft",
|
"realfft",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"rssdy",
|
|
||||||
"serde",
|
"serde",
|
||||||
"soundclouddy",
|
"soundclouddy",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -1637,23 +1634,6 @@ dependencies = [
|
||||||
"getrandom 0.4.3",
|
"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]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
@ -2871,9 +2851,6 @@ name = "mediatype"
|
||||||
version = "0.21.0"
|
version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
|
checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
|
|
@ -3721,16 +3698,6 @@ dependencies = [
|
||||||
"pulldown-cmark",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.11"
|
version = "0.11.11"
|
||||||
|
|
@ -4282,22 +4249,6 @@ dependencies = [
|
||||||
"cc",
|
"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]]
|
[[package]]
|
||||||
name = "rstml"
|
name = "rstml"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -5950,7 +5901,6 @@ dependencies = [
|
||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ members = [
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"fyyd",
|
"fyyd",
|
||||||
"jamendody",
|
"jamendody",
|
||||||
"rssdy",
|
|
||||||
"soundclouddy",
|
"soundclouddy",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
"ytdy",
|
"ytdy",
|
||||||
|
|
@ -45,7 +44,6 @@ http = "1"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
leptos = { version = "0.8", default-features = false, features = ["csr"] }
|
leptos = { version = "0.8", default-features = false, features = ["csr"] }
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
feed-rs = "2"
|
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
|
@ -116,7 +114,6 @@ crabidy-server = { path = "crabidy-server", default-features = false }
|
||||||
fsdy = { path = "fsdy" }
|
fsdy = { path = "fsdy" }
|
||||||
fyyd = { path = "fyyd" }
|
fyyd = { path = "fyyd" }
|
||||||
jamendody = { path = "jamendody" }
|
jamendody = { path = "jamendody" }
|
||||||
rssdy = { path = "rssdy" }
|
|
||||||
soundclouddy = { path = "soundclouddy" }
|
soundclouddy = { path = "soundclouddy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
ytdy = { path = "ytdy" }
|
ytdy = { path = "ytdy" }
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -13,7 +13,6 @@ each mounted as a subtree of one library:
|
||||||
├── fs a local music folder (fsdy/README.md)
|
├── fs a local music folder (fsdy/README.md)
|
||||||
├── fyyd podcast search (fyyd/README.md)
|
├── fyyd podcast search (fyyd/README.md)
|
||||||
├── jamendo Creative-Commons music (jamendody/README.md)
|
├── jamendo Creative-Commons music (jamendody/README.md)
|
||||||
├── rss podcast subscriptions (rssdy/README.md)
|
|
||||||
├── soundcloud SoundCloud (soundclouddy/README.md)
|
├── soundcloud SoundCloud (soundclouddy/README.md)
|
||||||
├── tidal Tidal streaming (tidaldy/README.md)
|
├── tidal Tidal streaming (tidaldy/README.md)
|
||||||
├── youtube YouTube search & playlists (ytdy/README.md)
|
├── youtube YouTube search & playlists (ytdy/README.md)
|
||||||
|
|
@ -87,7 +86,6 @@ filled in.
|
||||||
| `fsdy.toml` | local files | — |
|
| `fsdy.toml` | local files | — |
|
||||||
| `fyyd.toml` | podcasts | none needed |
|
| `fyyd.toml` | podcasts | none needed |
|
||||||
| `jamendo.toml` | Jamendo | none (key shipped) |
|
| `jamendo.toml` | Jamendo | none (key shipped) |
|
||||||
| `rss.toml` | podcast feeds | the feed URLs |
|
|
||||||
| `soundcloud.toml` | SoundCloud | optional token |
|
| `soundcloud.toml` | SoundCloud | optional token |
|
||||||
| `tidaly.toml` | Tidal | device login |
|
| `tidaly.toml` | Tidal | device login |
|
||||||
| `ytdy.toml` | YouTube | optional cookies |
|
| `ytdy.toml` | YouTube | optional cookies |
|
||||||
|
|
@ -156,7 +154,7 @@ On first start the server writes this file with every provider enabled:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
providers = [
|
providers = [
|
||||||
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo", "rss",
|
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo",
|
||||||
"fs", "crabidy", "orphans",
|
"fs", "crabidy", "orphans",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
@ -318,17 +316,7 @@ live as you type — `Enter` keeps the filter, `Esc` clears it.
|
||||||
`s` marks the selected row; `v` (or `V`) enters **visual mode**, where
|
`s` marks the selected row; `v` (or `V`) enters **visual mode**, where
|
||||||
movement marks or unmarks everything you sweep over, vim-style. The sweep
|
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
|
is anchored where you entered it, so moving back reverses it. `Esc` (or
|
||||||
any non-movement key) leaves visual mode. Both panes have marks and visual
|
any non-movement key) leaves visual mode.
|
||||||
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
|
- `w` saves the selection (a library subtree, or in the queue pane the
|
||||||
queue) as a new folder under `/crabidy/<name>` of **link** files —
|
queue) as a new folder under `/crabidy/<name>` of **link** files —
|
||||||
|
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
# Queue selection, visual mode, and the register
|
|
||||||
|
|
||||||
Marks and visual mode in the queue pane, paired with a vim-style
|
|
||||||
**register**: deleting or yanking queue entries puts them in the register,
|
|
||||||
pasting brings them back. One model, implemented in both clients.
|
|
||||||
|
|
||||||
## Context and problem statement
|
|
||||||
|
|
||||||
The library pane has marks (`s`) and visual mode (`v`/`V`); the queue pane
|
|
||||||
has neither — `d` removes exactly the row under the cursor, one at a time,
|
|
||||||
and `queue.rs` carries the standing note *"FIXME: mark multiple tracks on
|
|
||||||
queue and remove them"*. Clearing 200 tracks with `c`/`C`, or deleting the
|
|
||||||
wrong row, is unrecoverable: there is no undo anywhere in the system.
|
|
||||||
|
|
||||||
Meanwhile `p` in the queue means "insert the **library** selection after
|
|
||||||
this track" — a cross-pane action with no name for what it is inserting.
|
|
||||||
|
|
||||||
The ask is to close both gaps with one concept borrowed from vim: a client
|
|
||||||
register that deletes and yanks write to, and paste reads from. That turns
|
|
||||||
multi-delete into a safe operation (it is recoverable), gives "move these
|
|
||||||
three tracks down" a natural spelling (`d` then `P`), and gives `p` a single
|
|
||||||
meaning.
|
|
||||||
|
|
||||||
## Assumptions (confirmed)
|
|
||||||
|
|
||||||
- **Only explicit commands write the register**: `y` (both panes), and `d`,
|
|
||||||
`c`, `C` (queue). Marking or moving the cursor never writes it. This was
|
|
||||||
the one point of disagreement — an implicit fill from the library
|
|
||||||
selection would have kept `p` backward-compatible, but it means `s` in
|
|
||||||
the library silently clobbers a clipboard you are about to paste.
|
|
||||||
- **Both `p` (after cursor) and `P` (before cursor)**, so `d` then `P`
|
|
||||||
restores exactly what you deleted, and `d` … `p` is a move.
|
|
||||||
- **`c`/`C` fill the register too** — the destructive ops most worth
|
|
||||||
undoing.
|
|
||||||
- **One unnamed register**, but the type is shaped so named registers
|
|
||||||
(`"a`) are additive rather than a rewrite.
|
|
||||||
- **Both clients, now.** `cbd-web` gets queue marks, queue visual mode, and
|
|
||||||
the register — *plus* the library visual mode that was deferred when
|
|
||||||
`v`/`V` landed in the TUI, so the two clients do not drift further.
|
|
||||||
- The register is **per-client, in-memory, one level deep**. It is not a
|
|
||||||
server-side undo log; another client cannot undo your delete, and a
|
|
||||||
restart forgets it. Same expectations as vim.
|
|
||||||
|
|
||||||
## What the protocol already gives us
|
|
||||||
|
|
||||||
No `.proto` change, no server change. The two RPCs this feature needs are
|
|
||||||
already shaped for it:
|
|
||||||
|
|
||||||
- `Remove { positions: repeated uint32 }` — already takes **many**
|
|
||||||
positions, so multi-delete is a client-side gather.
|
|
||||||
- `Insert { position, paths: repeated string }` — takes a **path list**, so
|
|
||||||
a register of paths pastes with the existing call.
|
|
||||||
|
|
||||||
That is what makes this a client-only feature.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### What the register holds
|
|
||||||
|
|
||||||
1. **Resolved tracks** (`Track` messages, as deleted). Exact: what you
|
|
||||||
deleted is what comes back, and the UI can show real titles. But it
|
|
||||||
cannot express "the album node I yanked in the library", and pasting
|
|
||||||
still has to send paths, so the extra fidelity buys nothing at the wire.
|
|
||||||
2. **Paths, with titles alongside for display** (chosen). It is what both
|
|
||||||
RPCs speak; a yanked library *node* expands at paste time, which is a
|
|
||||||
feature (`y` an album, `p` it into the queue); and the register is one
|
|
||||||
`Vec<String>` plus labels.
|
|
||||||
|
|
||||||
The cost is honest and worth stating: paste **re-resolves**. A yanked
|
|
||||||
child of a search term whose term has since been deleted may not come
|
|
||||||
back, and a node's track count is unknown until paste, so the UI can
|
|
||||||
only say "3 entries", not "12 tracks".
|
|
||||||
|
|
||||||
### Keeping marks alive in a moving queue
|
|
||||||
|
|
||||||
This is the only genuinely new problem. The library never had it: a listing
|
|
||||||
is a snapshot you re-fetch deliberately. The queue is server-pushed and
|
|
||||||
rebuilt on **every** change — another client appending, playback advancing,
|
|
||||||
and each chunk of a streaming resolve.
|
|
||||||
|
|
||||||
1. **Remap marks by index.** Cheapest, and silently wrong: if playback
|
|
||||||
advanced and the queue shifted by one, `d` deletes the wrong tracks.
|
|
||||||
Rejected — a data-losing failure mode.
|
|
||||||
2. **Clear marks on every snapshot.** Never wrong, but a resolve streaming
|
|
||||||
in or another client's append wipes a selection mid-flow, which makes
|
|
||||||
the feature feel broken exactly when the queue is busy.
|
|
||||||
3. **Greedy in-order match on track path** (chosen). Walk the old and new
|
|
||||||
path lists together and carry a mark to the new position of the same
|
|
||||||
path; drop marks whose track is gone. Survives appends, removals, and
|
|
||||||
playback advancing. Duplicate paths (the same track queued twice) are
|
|
||||||
ambiguous by nature — in-order matching degrades sanely, keeping the
|
|
||||||
*n*-th occurrence marked. If the lists diverge past recognition (no
|
|
||||||
common prefix worth speaking of), clear rather than guess.
|
|
||||||
|
|
||||||
The safety rule that makes any of this sound: **positions handed to
|
|
||||||
`Remove` are always read off the newest snapshot**, never a remembered
|
|
||||||
index.
|
|
||||||
|
|
||||||
### Where marks live
|
|
||||||
|
|
||||||
The two clients are shaped differently and this is not worth papering over:
|
|
||||||
|
|
||||||
- `cbd-tui` owns a `Vec<UiItem>` per pane and already has `marked` on it
|
|
||||||
(the queue builds every row `marked: false` today).
|
|
||||||
- `cbd-web` owns **no** queue list at all — just `QueueCursor { selected }`,
|
|
||||||
with rows rendered straight from the server's `Queue` signal.
|
|
||||||
|
|
||||||
So the shared thing is the *rule*, not the struct: marks are a set of
|
|
||||||
positions plus the path list they were taken against, reconciled on each
|
|
||||||
snapshot. The TUI keeps them in its `UiItem`s (it rebuilds that list
|
|
||||||
anyway); the web client keeps a position set beside the signal. Both
|
|
||||||
implement the same reconciliation, and both unit-test it against the same
|
|
||||||
cases.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
**D1 — `Register` holds paths plus display labels.** One unnamed slot:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
struct Register {
|
|
||||||
/// Library paths, in the order they were yanked or deleted. Empty means
|
|
||||||
/// nothing to paste.
|
|
||||||
paths: Vec<String>,
|
|
||||||
/// Row labels for the status line only — never sent anywhere.
|
|
||||||
labels: Vec<String>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Named registers stay additive: the owner becomes a small map keyed by a
|
|
||||||
register name, with `None` meaning the unnamed one. Not built now.
|
|
||||||
|
|
||||||
**D2 — Only `y`, `d`, `c`, `C` write the register.** Marks, visual mode,
|
|
||||||
and cursor movement never do. A write **overwrites**: there is no history
|
|
||||||
and no numbered registers.
|
|
||||||
|
|
||||||
**D3 — `p` pastes after the cursor, `P` before it.** Both are queue-only
|
|
||||||
(there is nothing to paste into a library listing). Paste sends
|
|
||||||
`Insert { position, paths }` and leaves the register intact, so you can
|
|
||||||
paste twice. `d` then `P` is an exact restore; `d` … `p` is a move.
|
|
||||||
|
|
||||||
**D4 — `y` works in both panes.** In the library it yanks the marked items,
|
|
||||||
or the cursor item, under the same `is_queable` gate that `a`/`Enter` use —
|
|
||||||
so yanking cannot put an unqueueable folder in the register. In the queue it
|
|
||||||
yanks the marked rows, or the cursor row. `y` clears the marks it consumed,
|
|
||||||
exactly as queueing does today.
|
|
||||||
|
|
||||||
**D5 — Queue marks reconcile per snapshot** by the greedy in-order path
|
|
||||||
match above, clearing on divergence. `Remove`/`Insert` positions always come
|
|
||||||
from the newest snapshot.
|
|
||||||
|
|
||||||
**D6 — `d` in the queue deletes every marked row** (or the cursor row when
|
|
||||||
nothing is marked), in one `Remove` call, and writes them to the register
|
|
||||||
first. `c`/`C` write the tracks they are about to drop.
|
|
||||||
|
|
||||||
**D7 — Queue visual mode mirrors the library's** anchored paint: `v` (and
|
|
||||||
`V`) enters, movement toggles the marks of the swept range against the
|
|
||||||
anchor so moving back reverses, `Esc` or any non-movement action leaves it.
|
|
||||||
The mark+visual logic is extracted so both panes share one implementation
|
|
||||||
per client, rather than a second copy in the queue.
|
|
||||||
|
|
||||||
One concrete wrinkle: queue rows are built `is_queable: false,
|
|
||||||
is_deletable: false`, so the library's mark gate would refuse all of them.
|
|
||||||
The extracted code takes the gate as a parameter — the queue's is "always
|
|
||||||
allowed".
|
|
||||||
|
|
||||||
**D8 — `cbd-web` reaches parity in the same change**, including the library
|
|
||||||
visual mode it does not have yet. Its queue marks live beside the server
|
|
||||||
signal (D-above); its keymap and help overlay gain the same rows as the
|
|
||||||
TUI's.
|
|
||||||
|
|
||||||
**D9 — Keys.** The queue gains `s`, `v`, `V`, `y`, `P`; the library gains
|
|
||||||
`y`. No chord collides with an existing one in either scope.
|
|
||||||
|
|
||||||
**D10 — `p` changes meaning, and that is a breaking change** to muscle
|
|
||||||
memory: it pastes the register instead of the library selection. The
|
|
||||||
browse→queue flows that do not go through `p` (`a` append, `L` queue-next,
|
|
||||||
`Enter` replace) are untouched, so the cost is confined to "insert what I
|
|
||||||
picked on the left at this exact position", which becomes `y` then `p`.
|
|
||||||
Documented in the key tables and in the book.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```d2
|
|
||||||
direction: right
|
|
||||||
|
|
||||||
lib: Library pane {
|
|
||||||
libsel: "marked rows,\nelse cursor row\n(is_queable gate)"
|
|
||||||
}
|
|
||||||
|
|
||||||
q: Queue pane {
|
|
||||||
qsel: "marked rows,\nelse cursor row"
|
|
||||||
}
|
|
||||||
|
|
||||||
reg: "Register (per client, one slot)\npaths + labels" {
|
|
||||||
shape: cylinder
|
|
||||||
}
|
|
||||||
|
|
||||||
rpc: Server (unchanged) {
|
|
||||||
ins: "Insert { position, paths }"
|
|
||||||
rem: "Remove { positions }"
|
|
||||||
}
|
|
||||||
|
|
||||||
lib.libsel -> reg: "y"
|
|
||||||
q.qsel -> reg: "y · d · c · C"
|
|
||||||
q.qsel -> rpc.rem: "d · c · C\n(positions from the\nnewest snapshot)"
|
|
||||||
reg -> rpc.ins: "p (after cursor)\nP (before cursor)"
|
|
||||||
rpc -> q: "Queue snapshot\n(marks reconciled)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Mark reconciliation, on every queue snapshot:
|
|
||||||
|
|
||||||
```d2
|
|
||||||
direction: down
|
|
||||||
|
|
||||||
snap: "Queue snapshot arrives"
|
|
||||||
cmp: "Walk old paths and new paths\nin order"
|
|
||||||
carry: "Carry each mark to the new\nposition of the same path"
|
|
||||||
drop: "Drop marks whose track is gone"
|
|
||||||
clear: "Clear all marks"
|
|
||||||
act: "d / y / c / C read positions\nfrom this snapshot only"
|
|
||||||
|
|
||||||
snap -> cmp
|
|
||||||
cmp -> carry: recognizable
|
|
||||||
cmp -> clear: "diverged past\nrecognition"
|
|
||||||
carry -> drop
|
|
||||||
drop -> act
|
|
||||||
clear -> act
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boundaries and interfaces
|
|
||||||
|
|
||||||
- **Register** — owned by each client's app state, written only by the four
|
|
||||||
commands, read only by paste. No I/O, no server involvement; a pure value
|
|
||||||
that is trivially unit-testable.
|
|
||||||
- **Mark reconciliation** — one function per client, `(old_paths,
|
|
||||||
new_paths, marks) -> marks`, tested against: append, remove-before,
|
|
||||||
remove-marked, playback advance, streaming resolve, duplicate paths, and
|
|
||||||
wholesale replacement.
|
|
||||||
- **Pane selection** — both panes expose "the rows this action applies to"
|
|
||||||
the way the library's `get_selected` already does; `y`/`d` consume it.
|
|
||||||
- **Server** — untouched. This whole feature is two clients.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- **Mark drift** deleting the wrong tracks. The reconciliation rule and the
|
|
||||||
newest-snapshot rule exist for this; it needs the strongest tests in the
|
|
||||||
feature.
|
|
||||||
- **`p`'s changed meaning** surprising existing users. Mitigated by leaving
|
|
||||||
`a`/`L`/`Enter` alone and documenting the change; not avoidable if `p` is
|
|
||||||
to have one meaning.
|
|
||||||
- **Silent paste shortfall** when a path no longer resolves — you paste 5
|
|
||||||
and get 4. The server already skips unresolvable paths in a resolve; the
|
|
||||||
clients should say what they pasted rather than claim success blindly.
|
|
||||||
- **Two implementations drifting** (the very thing D8 is fixing for visual
|
|
||||||
mode). Same rule, same test cases, both landed together.
|
|
||||||
- **Duplicate-path ambiguity** in reconciliation is inherent, not solvable
|
|
||||||
without a per-entry queue id in the proto. In-order matching is the
|
|
||||||
honest approximation; a queue id is the escape hatch if it ever bites.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
None blocking. Deferred by choice: named registers (D1 leaves room), a
|
|
||||||
numbered/history register stack, `y` in the queue putting entries somewhere
|
|
||||||
persistent (that is what `w` and saved queues are for), and a per-entry
|
|
||||||
queue id in the proto to make reconciliation exact.
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
# RSS provider — /rss
|
|
||||||
|
|
||||||
Subscribe to podcast feeds by URL and play their episodes. Mounted at
|
|
||||||
`/rss` by a new `rssdy` crate. Listings are **never cached**: every visit
|
|
||||||
to a subscription fetches the feed, so a new episode shows up the moment
|
|
||||||
it is published.
|
|
||||||
|
|
||||||
## Context and problem statement
|
|
||||||
|
|
||||||
`/fyyd` already plays podcasts, but it is a *discovery* provider: it
|
|
||||||
searches fyyd's public directory and gets episode metadata from fyyd's
|
|
||||||
API. It cannot play a feed fyyd does not index, and it cannot play a
|
|
||||||
**private** feed at all.
|
|
||||||
|
|
||||||
Plenty of podcasts are distributed as a plain RSS URL, and paid ones as a
|
|
||||||
per-subscriber URL with a token in it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
|
|
||||||
```
|
|
||||||
|
|
||||||
That URL *is* the credential. Anyone holding it has the subscription.
|
|
||||||
|
|
||||||
So the ask is: a list of feeds I subscribe to, each browsable as a folder
|
|
||||||
of episodes, always showing the newest ones, and safe to use with
|
|
||||||
tokened URLs.
|
|
||||||
|
|
||||||
## Assumptions (decided)
|
|
||||||
|
|
||||||
- **Subscriptions are configuration, not discovery.** There is no feed
|
|
||||||
search; you name the feeds you want. `%` on `/rss` adds one from a
|
|
||||||
pasted URL and persists it (D5), which is how you "subscribe to
|
|
||||||
several" without hand-editing TOML.
|
|
||||||
- **A feed URL is a secret**, because a premium URL is a bearer
|
|
||||||
credential. It is redacted from `Debug` and never logged, and — the
|
|
||||||
decision that shapes the whole tree — **it never appears in a library
|
|
||||||
path** (D1).
|
|
||||||
- **Nothing about feed content is cached** (D2). This is the explicit
|
|
||||||
requirement, and it has a client-side half people forget: both clients
|
|
||||||
cache library listings by path, so the server being fresh is not
|
|
||||||
enough.
|
|
||||||
- Feeds are read-only. No OPML import, no per-episode read state, no
|
|
||||||
"unplayed" tracking — this is a player, not a podcatcher.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### The parser
|
|
||||||
|
|
||||||
1. **`feed-rs` 2.4** (chosen) — one crate for RSS 2.0, RSS 1.0, RSS
|
|
||||||
0.x, Atom and JSON Feed, with the iTunes extensions mapped into its
|
|
||||||
model. Podcast feeds in the wild are not uniformly RSS 2.0, and this
|
|
||||||
is the only option that does not make us care.
|
|
||||||
2. `rss` + `atom_syndication` — two crates, two models, and we write the
|
|
||||||
normalisation. Rejected: more code for less coverage.
|
|
||||||
3. `quick-xml` by hand — full control, and a long tail of real-world
|
|
||||||
feed quirks to discover the hard way. Rejected.
|
|
||||||
|
|
||||||
### Episode identity — the interesting problem
|
|
||||||
|
|
||||||
A track path has to survive being **stored**: it goes into the queue, is
|
|
||||||
persisted in `current`, and a bookmark (`w`) writes it into a toml that
|
|
||||||
is replayed days later. It is also *displayed*. So the path may not
|
|
||||||
contain the feed URL, and it should not contain an arbitrary
|
|
||||||
publisher-chosen id verbatim (guids are unbounded and can be URLs
|
|
||||||
themselves).
|
|
||||||
|
|
||||||
1. **Enclosure URL in the path** (base64 or percent-encoded). Resolves
|
|
||||||
with no feed fetch, and a bookmark keeps working forever. Rejected on
|
|
||||||
two counts: a premium enclosure URL leaks its token into the UI, the
|
|
||||||
logs, and every saved queue; and paths become unreadable.
|
|
||||||
2. **Index in the feed** (`/rss/economist/3`). Stable only until the next
|
|
||||||
episode is published, which is the one thing guaranteed to happen.
|
|
||||||
Rejected — a bookmark would silently point at a different episode.
|
|
||||||
3. **A short hash of the episode's guid** (chosen), falling back to the
|
|
||||||
enclosure URL when a feed omits the guid: `/rss/<slug>/<16 hex>`.
|
|
||||||
Short, readable, token-free, and stable for as long as the publisher
|
|
||||||
keeps the guid stable (which is what guids are for). Resolving one
|
|
||||||
means fetching the feed and matching the hash — which we are doing
|
|
||||||
anyway (D2), and the memo (D3) keeps it to one fetch per action.
|
|
||||||
`blake3` is already a workspace dependency, and using it here keeps
|
|
||||||
the hash stable across builds (`DefaultHasher` explicitly is not).
|
|
||||||
|
|
||||||
### Freshness versus hammering the feed
|
|
||||||
|
|
||||||
"Always fetch" taken literally means listing a 40-episode feed, queueing
|
|
||||||
it, and playing a track costs 40+ feed fetches. Options:
|
|
||||||
|
|
||||||
1. A TTL cache. Rejected: it turns the requirement into a timing
|
|
||||||
question ("is 30 seconds fresh?") and gets it wrong twice.
|
|
||||||
2. **A listing-driven memo** (chosen, D3). A *listing* always fetches and
|
|
||||||
replaces the memo entry for that feed. Track lookups
|
|
||||||
(`get_urls_for_track`, `get_metadata_for_track`,
|
|
||||||
`resolve_tracks_into`) read the memo and only fetch when they miss.
|
|
||||||
Freshness is exactly "what a visit shows", with no clock involved, and
|
|
||||||
an episode you can see is an episode you can play.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
**D1 — Subscriptions are `(name, url)` pairs in `rss.toml`; the path
|
|
||||||
carries a slug of the name.**
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# Per-subscription. `name` is yours; the path uses a slug of it.
|
|
||||||
[[feeds]]
|
|
||||||
name = "The Economist Podcasts"
|
|
||||||
url = "https://feeds.economist.com/v1/rss/…/f74365b0-…"
|
|
||||||
|
|
||||||
[[feeds]]
|
|
||||||
name = "Cautionary Tales"
|
|
||||||
url = "https://feeds.example.org/cautionary-tales"
|
|
||||||
```
|
|
||||||
|
|
||||||
`/rss/the-economist-podcasts` is the node. The URL stays in the config
|
|
||||||
file and never reaches a path, a log line, or a saved queue. Duplicate
|
|
||||||
slugs get a numeric suffix; a feed whose entry has no usable url is
|
|
||||||
skipped with a warning at load.
|
|
||||||
|
|
||||||
**D2 — No caching of feed content, at either end.**
|
|
||||||
|
|
||||||
- The provider holds no listing cache: `get_lib_node("/rss/<slug>")`
|
|
||||||
fetches.
|
|
||||||
- **`/rss` joins `MUTABLE_ROOTS` in both clients** (`cbd-tui/src/rpc.rs`,
|
|
||||||
`cbd-web/src/state.rs`), which is what actually makes a re-visit
|
|
||||||
re-fetch. Without this the client answers from its own cache and the
|
|
||||||
server's freshness is invisible.
|
|
||||||
|
|
||||||
**D3 — One memo, written by listings, read by track lookups.** A bounded
|
|
||||||
map (last 8 feeds) of `slug -> episodes`, replaced on every listing of
|
|
||||||
that feed. Track lookups consult it and fetch on a miss. It is never
|
|
||||||
consulted to answer a listing.
|
|
||||||
|
|
||||||
**D4 — Episode key is `blake3(guid)[..16]`**, or `blake3(enclosure_url)`
|
|
||||||
when the feed omits a guid. `Track.provider_item_id` is set to the guid,
|
|
||||||
so the content store de-duplicates captures of the same episode across
|
|
||||||
visits and across feeds.
|
|
||||||
|
|
||||||
**D5 — `%`, `e`, `d` manage subscriptions.** `%` on `/rss` takes a pasted
|
|
||||||
feed URL, fetches it once, names the subscription from the feed's own
|
|
||||||
title (slug-deduped), appends it to `rss.toml` and writes the file back —
|
|
||||||
the same shape as SoundCloud's `resolve` node taking a URL as its
|
|
||||||
"title", and the same write-back the other providers already do for their
|
|
||||||
settings. `e` renames a subscription (the slug, and so the path, changes
|
|
||||||
with it). `d` unsubscribes: it removes the config entry and touches no
|
|
||||||
audio. A capture made from it stays under `/crabidy`.
|
|
||||||
|
|
||||||
**D6 — Bounded and defensive.** Per-request timeout
|
|
||||||
(`call_timeout_secs`, default 30), a response-size cap (`max_feed_bytes`,
|
|
||||||
default 8 MiB) so a hostile or broken feed cannot exhaust memory, and
|
|
||||||
`episodes_per_feed` (default 200). A malformed entry — no enclosure, no
|
|
||||||
title, unparseable date — is skipped with a warning; only a feed that
|
|
||||||
cannot be fetched or parsed at all is an error, and it fails just that
|
|
||||||
node, never the server.
|
|
||||||
|
|
||||||
**D7 — Behind the `rss` cargo feature**, default on, exactly like the
|
|
||||||
other providers: `dep:rssdy`, a `BUILT_IN_PROVIDERS` entry, a
|
|
||||||
`ProviderToggles` field, a name in `ALL_PROVIDERS`, and a row in
|
|
||||||
`check-features`.
|
|
||||||
|
|
||||||
**D8 — A separate crate, not part of `fyyd`.** They share only "an
|
|
||||||
episode is a track whose audio is an enclosure URL". Their config,
|
|
||||||
identity model, and caching differ completely; folding them together
|
|
||||||
would mean one crate with two personalities. If a third podcast source
|
|
||||||
ever lands, the shared piece to extract is the episode→`Track` mapping,
|
|
||||||
not the provider.
|
|
||||||
|
|
||||||
**D9 — Newest first.** Episodes are sorted by publication date
|
|
||||||
descending where dates parse, keeping feed order for the rest (a stable
|
|
||||||
sort, so a dateless feed lists exactly as published). Queueing a
|
|
||||||
subscription therefore plays newest first.
|
|
||||||
|
|
||||||
**D10 — Episodes stream directly, like fyyd's.** The enclosure URL is a
|
|
||||||
plain media file; the existing player streams it with no sidecar and no
|
|
||||||
new player component. Captures (`W`) download it into the content store.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```d2
|
|
||||||
direction: right
|
|
||||||
|
|
||||||
config: "rss.toml\n[[feeds]] name + url\n(url is a credential)" {
|
|
||||||
shape: document
|
|
||||||
}
|
|
||||||
|
|
||||||
rssdy: "rssdy (/rss)" {
|
|
||||||
subs: "subscriptions\nname -> url"
|
|
||||||
memo: "episode memo\nslug -> episodes\n(last 8, listing-written)"
|
|
||||||
parse: "feed-rs\nRSS/Atom/JSON Feed"
|
|
||||||
}
|
|
||||||
|
|
||||||
feeds: "publisher feeds\n(https, tokened URLs)" { shape: cloud }
|
|
||||||
player: "audio player\n(enclosure URL)"
|
|
||||||
clients: "cbd-tui · cbd-web\n/rss is never cached client-side"
|
|
||||||
|
|
||||||
config -> rssdy.subs: loaded at init
|
|
||||||
clients -> rssdy: "GetLibraryNode(/rss/<slug>)"
|
|
||||||
rssdy.subs -> feeds: "fetch on every listing"
|
|
||||||
feeds -> rssdy.parse
|
|
||||||
rssdy.parse -> rssdy.memo: replaces the entry
|
|
||||||
rssdy.memo -> player: "enclosure URL for a track key"
|
|
||||||
```
|
|
||||||
|
|
||||||
Listing versus playing — the same feed, two paths through it:
|
|
||||||
|
|
||||||
```d2
|
|
||||||
direction: down
|
|
||||||
|
|
||||||
visit: "visit /rss/<slug>"
|
|
||||||
fetch: "fetch + parse the feed"
|
|
||||||
memo_w: "replace memo[slug]"
|
|
||||||
list: "list episodes, newest first\npath = /rss/<slug>/<hash(guid)>"
|
|
||||||
|
|
||||||
play: "play or queue an episode"
|
|
||||||
memo_r: "memo[slug] hit?"
|
|
||||||
enclosure: "enclosure URL -> player"
|
|
||||||
|
|
||||||
visit -> fetch
|
|
||||||
fetch -> memo_w
|
|
||||||
memo_w -> list
|
|
||||||
|
|
||||||
play -> memo_r
|
|
||||||
memo_r -> enclosure: hit
|
|
||||||
memo_r -> fetch: "miss (restart, or a\nbookmark replayed later)"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boundaries and interfaces
|
|
||||||
|
|
||||||
- **`rssdy::Client`** implements `ProviderClient` like every other
|
|
||||||
provider: `PROVIDER_ROOT = "/rss"`, `init(&str) -> Settings` with
|
|
||||||
write-back, and the node/track methods. Nothing new at the boundary.
|
|
||||||
- **`rssdy::api::Feeds` trait** wraps all network access (one method:
|
|
||||||
fetch and parse a feed URL), so the tree, slug, identity, and ordering
|
|
||||||
logic is unit-tested over a fake with no network — the pattern
|
|
||||||
`fyyd`/`absdy`/`soundclouddy` already use.
|
|
||||||
- **Server** — one more optional dependency and one more mount
|
|
||||||
registration. The mount registry means no dispatch code changes.
|
|
||||||
- **Clients** — one entry in each `MUTABLE_ROOTS`. No UI work: a
|
|
||||||
subscription is a queueable node like any other.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- **A token leaking through a path or a log** is the failure that
|
|
||||||
actually matters. D1 and D4 keep URLs out of paths structurally, and
|
|
||||||
the redaction rule matches what SoundCloud and Jamendo already do for
|
|
||||||
signed media URLs.
|
|
||||||
- **Guid instability.** A publisher that regenerates guids per fetch
|
|
||||||
breaks bookmarks (the episode key changes). Nothing can fix that from
|
|
||||||
our side; `W` captures the audio, which survives it.
|
|
||||||
- **Episodes ageing out of a feed.** A bookmark to an episode the feed no
|
|
||||||
longer lists cannot resolve. Inherent to RSS; documented, and again the
|
|
||||||
reason to capture rather than bookmark things you want to keep.
|
|
||||||
- **`itunes:duration` coverage.** Durations come from whatever the feed
|
|
||||||
provides through `feed-rs`; absent duration is `None`, which the UI
|
|
||||||
already handles. To be confirmed against a real feed at implementation
|
|
||||||
time rather than assumed.
|
|
||||||
- **Feed size.** D6's byte cap is the guard; a 40 MiB feed is a bug in
|
|
||||||
someone else's publishing, not something to load into memory.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
None blocking. Deferred by choice: OPML import/export, per-episode
|
|
||||||
played state, feed refresh in the background (there is no server-side
|
|
||||||
polling — a listing is the refresh), and `itunes:image`/artwork, which
|
|
||||||
the library model has no field for.
|
|
||||||
|
|
@ -76,8 +76,7 @@ pub enum QueueCmd {
|
||||||
Show,
|
Show,
|
||||||
/// Append tracks/subtrees (by library path) to the end of the queue.
|
/// Append tracks/subtrees (by library path) to the end of the queue.
|
||||||
Append { paths: Vec<String> },
|
Append { paths: Vec<String> },
|
||||||
/// Insert tracks/subtrees at a position, pushing what was there down
|
/// Insert tracks/subtrees at a position.
|
||||||
/// (0 = front, past the end = append).
|
|
||||||
Insert { position: u32, paths: Vec<String> },
|
Insert { position: u32, paths: Vec<String> },
|
||||||
/// Replace the whole queue with the given tracks/subtrees.
|
/// Replace the whole queue with the given tracks/subtrees.
|
||||||
Replace { paths: Vec<String> },
|
Replace { paths: Vec<String> },
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ pub enum Action {
|
||||||
LibraryQueueAppend,
|
LibraryQueueAppend,
|
||||||
LibraryQueueReplace,
|
LibraryQueueReplace,
|
||||||
LibraryToggleMark,
|
LibraryToggleMark,
|
||||||
LibraryYank,
|
|
||||||
/// Enter (or leave) visual mode: while active, movement toggles the mark
|
/// 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
|
/// 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;
|
/// moving (architecture/visual-mode.md). Entering toggles the current row;
|
||||||
|
|
@ -98,11 +97,7 @@ pub enum Action {
|
||||||
/// library and queue scopes). A no-op when no filter is applied.
|
/// library and queue scopes). A no-op when no filter is applied.
|
||||||
ClearSearch,
|
ClearSearch,
|
||||||
// Queue pane
|
// Queue pane
|
||||||
QueueToggleMark,
|
QueueInsertHere,
|
||||||
QueueVisualMode,
|
|
||||||
QueueYank,
|
|
||||||
QueuePaste,
|
|
||||||
QueuePasteBefore,
|
|
||||||
QueueFirst,
|
QueueFirst,
|
||||||
QueueLast,
|
QueueLast,
|
||||||
QueueNext,
|
QueueNext,
|
||||||
|
|
@ -314,13 +309,6 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::LibraryVisualMode,
|
action: Action::LibraryVisualMode,
|
||||||
description: "Visual mode (same as v)",
|
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 {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
|
|
@ -448,47 +436,12 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::QueuePlaySelected,
|
action: Action::QueuePlaySelected,
|
||||||
description: "Play selected track",
|
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 {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
code: KeyCode::Char('p'),
|
code: KeyCode::Char('p'),
|
||||||
action: Action::QueuePaste,
|
action: Action::QueueInsertHere,
|
||||||
description: "Paste the register after this track",
|
description: "Insert library selection 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 {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
|
|
@ -879,9 +832,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v_and_v_enter_visual_in_both_panes() {
|
fn v_and_v_enter_visual_in_library_only() {
|
||||||
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual
|
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual mode
|
||||||
// mode, in the library and — since the register landed — the queue.
|
// in the library; neither is bound in the queue (no marks there).
|
||||||
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
|
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
|
||||||
|
|
@ -893,11 +846,11 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
|
||||||
Some(Action::QueueVisualMode)
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
|
||||||
Some(Action::QueueVisualMode)
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,9 @@ use ratatui::{
|
||||||
use super::bindings::{key_label, Scope, BINDINGS};
|
use super::bindings::{key_label, Scope, BINDINGS};
|
||||||
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
|
use super::{COLOR_PRIMARY, COLOR_SECONDARY};
|
||||||
|
|
||||||
/// The modal's content: a usage blurb, two binding columns, and a close-keys
|
/// The modal's content: a usage blurb, two binding columns (Global left,
|
||||||
/// footer derived from the `Scope::Help` bindings.
|
/// Library + Queue right, so the whole table fits a typical frame), 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 {
|
struct HelpContent {
|
||||||
usage: Vec<Line<'static>>,
|
usage: Vec<Line<'static>>,
|
||||||
left: Vec<Line<'static>>,
|
left: Vec<Line<'static>>,
|
||||||
|
|
@ -34,11 +30,11 @@ impl HelpContent {
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut left = group(Scope::Global, "Global");
|
let left = group(Scope::Global, "Global");
|
||||||
left.push(Line::from(""));
|
|
||||||
left.extend(group(Scope::Queue, "Queue"));
|
|
||||||
|
|
||||||
let right = group(Scope::Library, "Library");
|
let mut right = group(Scope::Library, "Library");
|
||||||
|
right.push(Line::from(""));
|
||||||
|
right.extend(group(Scope::Queue, "Queue"));
|
||||||
|
|
||||||
// All Help-scope chords close the modal; derive their labels instead
|
// All Help-scope chords close the modal; derive their labels instead
|
||||||
// of hardcoding key names.
|
// of hardcoding key names.
|
||||||
|
|
@ -194,29 +190,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_lists_bindings_from_the_table() {
|
fn help_lists_bindings_from_the_table() {
|
||||||
// Tall enough for the whole table: the two columns now need ~43 rows
|
let text = buffer_text(&render_to_buffer(100, 40));
|
||||||
// (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.
|
// Spot-check one entry per scope, by description from BINDINGS.
|
||||||
assert!(text.contains("Quit"));
|
assert!(text.contains("Quit"));
|
||||||
assert!(text.contains("Enter selected folder"));
|
assert!(text.contains("Enter selected folder"));
|
||||||
assert!(text.contains("Remove selected track"));
|
assert!(text.contains("Remove selected track"));
|
||||||
assert!(text.contains("Paste the register after this track"));
|
|
||||||
assert!(text.contains("Close help"));
|
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]
|
#[test]
|
||||||
fn help_explains_basic_usage() {
|
fn help_explains_basic_usage() {
|
||||||
let text = buffer_text(&render_to_buffer(100, 40));
|
let text = buffer_text(&render_to_buffer(100, 40));
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ use ratatui::{
|
||||||
use crabidy_core::proto::crabidy::LibraryNode;
|
use crabidy_core::proto::crabidy::LibraryNode;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
|
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
|
||||||
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
|
|
@ -110,11 +110,24 @@ impl Library {
|
||||||
let item = self.resolved()?;
|
let item = self.resolved()?;
|
||||||
(item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
|
(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>> {
|
pub fn get_selected(&self) -> Option<Vec<String>> {
|
||||||
self.selection().map(|(paths, _labels)| paths)
|
// Marks live on the full list; a hidden marked item still counts
|
||||||
|
// (the filter narrows what you *see*, not what you already chose).
|
||||||
|
if self.list.iter().any(|i| i.marked) {
|
||||||
|
return Some(
|
||||||
|
self.list
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.marked)
|
||||||
|
.map(|i| i.path.to_string())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Marks are gated on is_queable when set; the bare selection must be
|
||||||
|
// gated here too, or Enter on a plain folder ships a path the server
|
||||||
|
// can only resolve to nothing (silently ignored, like % / e / d on
|
||||||
|
// items without the capability).
|
||||||
|
let item = self.resolved()?;
|
||||||
|
item.is_queable.then(|| vec![item.path.to_string()])
|
||||||
}
|
}
|
||||||
pub fn ascend(&mut self) {
|
pub fn ascend(&mut self) {
|
||||||
if let Some(parent) = self.parent.as_ref() {
|
if let Some(parent) = self.parent.as_ref() {
|
||||||
|
|
@ -154,9 +167,41 @@ impl Library {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn queue_insert(&mut self, pos: usize) {
|
||||||
|
if let Some(items) = self.get_selected() {
|
||||||
|
match self.tx.send(MessageFromUi::InsertTracks(items, pos)) {
|
||||||
|
Ok(_) => self.remove_marks(),
|
||||||
|
Err(_) => { /* FIXME: warn */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn prev_selected(&self) -> usize {
|
pub fn prev_selected(&self) -> usize {
|
||||||
*self.positions.get(&self.path).unwrap_or(&0)
|
*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).
|
/// Titles of the currently marked rows, in list order (inspection helper).
|
||||||
pub fn marked_titles(&self) -> Vec<String> {
|
pub fn marked_titles(&self) -> Vec<String> {
|
||||||
self.list
|
self.list
|
||||||
|
|
@ -166,6 +211,56 @@ impl Library {
|
||||||
.collect()
|
.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) {
|
pub fn update(&mut self, node: LibraryNode) {
|
||||||
// Creatable nodes (e.g. an empty search node) must be enterable even
|
// Creatable nodes (e.g. an empty search node) must be enterable even
|
||||||
// with nothing in them — the user goes there to create children.
|
// with nothing in them — the user goes there to create children.
|
||||||
|
|
@ -346,32 +441,6 @@ impl StatefulList for Library {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarkedPane for Library {
|
|
||||||
fn items(&self) -> &[UiItem] {
|
|
||||||
&self.list
|
|
||||||
}
|
|
||||||
fn items_mut(&mut self) -> &mut [UiItem] {
|
|
||||||
&mut self.list
|
|
||||||
}
|
|
||||||
fn filter(&self) -> &Filter {
|
|
||||||
&self.filter
|
|
||||||
}
|
|
||||||
fn visual(&self) -> Option<usize> {
|
|
||||||
self.visual
|
|
||||||
}
|
|
||||||
fn set_visual(&mut self, anchor: Option<usize>) {
|
|
||||||
self.visual = anchor;
|
|
||||||
}
|
|
||||||
fn selected_view(&self) -> Option<usize> {
|
|
||||||
self.list_state.selected()
|
|
||||||
}
|
|
||||||
/// Only queueable rows may be marked: a marked plain folder would ship a
|
|
||||||
/// path the server can resolve to nothing.
|
|
||||||
fn markable(&self, item: &UiItem) -> bool {
|
|
||||||
item.is_queable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -163,256 +163,3 @@ pub trait StatefulList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks and visual mode for a pane that owns a `Vec<UiItem>` and a `/`
|
|
||||||
/// filter — shared by the library and the queue
|
|
||||||
/// (architecture/queue-register.md D7).
|
|
||||||
///
|
|
||||||
/// The required methods are the pane's own state; everything else is
|
|
||||||
/// behaviour both panes must agree on. Marks live on the **full** list, so a
|
|
||||||
/// marked-but-filtered-out row still counts; the view indices these methods
|
|
||||||
/// take are mapped through the filter.
|
|
||||||
pub(crate) trait MarkedPane {
|
|
||||||
/// Every row, in real (unfiltered) order.
|
|
||||||
fn items(&self) -> &[super::UiItem];
|
|
||||||
fn items_mut(&mut self) -> &mut [super::UiItem];
|
|
||||||
fn filter(&self) -> &Filter;
|
|
||||||
/// The visual-mode anchor (a **view** index), or `None` when off.
|
|
||||||
fn visual(&self) -> Option<usize>;
|
|
||||||
fn set_visual(&mut self, anchor: Option<usize>);
|
|
||||||
/// The cursor's **view** index.
|
|
||||||
fn selected_view(&self) -> Option<usize>;
|
|
||||||
/// Whether a row may be marked at all. The library gates on
|
|
||||||
/// `is_queable`; the queue allows every row.
|
|
||||||
fn markable(&self, item: &super::UiItem) -> bool;
|
|
||||||
|
|
||||||
fn is_visual(&self) -> bool {
|
|
||||||
self.visual().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter or leave visual mode. Entering anchors at the cursor and toggles
|
|
||||||
/// its mark (vim includes the row you start on); leaving keeps the marks.
|
|
||||||
fn toggle_visual(&mut self) {
|
|
||||||
if self.is_visual() {
|
|
||||||
self.set_visual(None);
|
|
||||||
} else {
|
|
||||||
self.set_visual(Some(self.selected_view().unwrap_or(0)));
|
|
||||||
self.toggle_mark();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exit_visual(&mut self) {
|
|
||||||
self.set_visual(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle the mark of the cursor row.
|
|
||||||
fn toggle_mark(&mut self) {
|
|
||||||
if let Some(view) = self.selected_view() {
|
|
||||||
self.toggle_mark_view(view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle the mark of one **view** row, honoring [`Self::markable`].
|
|
||||||
fn toggle_mark_view(&mut self, view: usize) {
|
|
||||||
let Some(real) = self.filter().to_real(view) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(item) = self.items().get(real) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !self.markable(item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(item) = self.items_mut().get_mut(real) {
|
|
||||||
item.marked = !item.marked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paint a visual-mode sweep. The selection is the contiguous range
|
|
||||||
/// `[anchor, cursor]`; a move that grows or shrinks it toggles exactly the
|
|
||||||
/// rows whose range membership changed, so moving back reverses a move
|
|
||||||
/// cleanly and the row you turn around on is never stranded. No-op unless
|
|
||||||
/// visual mode is active.
|
|
||||||
fn paint_between(&mut self, from_view: usize, to_view: usize) {
|
|
||||||
let Some(anchor) = self.visual() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
|
|
||||||
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
|
|
||||||
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
|
|
||||||
let in_old = (old_lo..=old_hi).contains(&view);
|
|
||||||
let in_new = (new_lo..=new_hi).contains(&view);
|
|
||||||
if in_old != in_new {
|
|
||||||
self.toggle_mark_view(view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_marks(&self) -> bool {
|
|
||||||
self.items().iter().any(|i| i.marked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_marks(&mut self) {
|
|
||||||
for item in self.items_mut() {
|
|
||||||
item.marked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The rows an action applies to: every marked row, or the cursor row
|
|
||||||
/// when nothing is marked. Returns `(paths, labels)` — labels are for
|
|
||||||
/// display only. `None` when nothing applies (an unmarkable cursor row).
|
|
||||||
///
|
|
||||||
/// Marks live on the full list, so a marked-but-filtered-out row still
|
|
||||||
/// counts: the filter narrows what you *see*, not what you already chose.
|
|
||||||
fn selection(&self) -> Option<(Vec<String>, Vec<String>)> {
|
|
||||||
if self.has_marks() {
|
|
||||||
let marked = self.items().iter().filter(|i| i.marked);
|
|
||||||
return Some((
|
|
||||||
marked.clone().map(|i| i.path.clone()).collect(),
|
|
||||||
marked.map(|i| i.title.clone()).collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// The bare cursor row must pass the same gate the marks do, or a
|
|
||||||
// plain folder would ship a path the server resolves to nothing.
|
|
||||||
let real = self.filter().to_real(self.selected_view()?)?;
|
|
||||||
let item = self.items().get(real)?;
|
|
||||||
self.markable(item)
|
|
||||||
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Carry queue marks across a server snapshot by a greedy in-order match on
|
|
||||||
/// track path (architecture/queue-register.md D5).
|
|
||||||
///
|
|
||||||
/// Returns the mark flags for `new_paths`. A mark follows its track through
|
|
||||||
/// appends, removals, and playback advancing; a mark whose track is gone is
|
|
||||||
/// dropped. Duplicate paths are inherently ambiguous — the *n*-th occurrence
|
|
||||||
/// keeps the *n*-th occurrence's mark. When the two lists share no marked
|
|
||||||
/// track at all, the result is all-unmarked rather than a guess.
|
|
||||||
pub(crate) fn carry_marks(
|
|
||||||
old_paths: &[String],
|
|
||||||
old_marked: &[bool],
|
|
||||||
new_paths: &[String],
|
|
||||||
) -> Vec<bool> {
|
|
||||||
let mut carried = vec![false; new_paths.len()];
|
|
||||||
// Walk both lists forward, pairing equal paths. Insertions in `new` and
|
|
||||||
// removals from `old` are skipped over, so a mark follows its own track
|
|
||||||
// rather than its old index; duplicates pair up in order.
|
|
||||||
let mut old_idx = 0;
|
|
||||||
for (new_idx, path) in new_paths.iter().enumerate() {
|
|
||||||
while old_idx < old_paths.len() && &old_paths[old_idx] != path {
|
|
||||||
old_idx += 1;
|
|
||||||
}
|
|
||||||
if old_idx < old_paths.len() {
|
|
||||||
// A short `old_marked` (caller bookkeeping drift) reads as
|
|
||||||
// unmarked rather than panicking.
|
|
||||||
carried[new_idx] = old_marked.get(old_idx).copied().unwrap_or(false);
|
|
||||||
old_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
carried
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod carry_marks_tests {
|
|
||||||
use super::carry_marks;
|
|
||||||
|
|
||||||
fn paths(v: &[&str]) -> Vec<String> {
|
|
||||||
v.iter().map(|s| s.to_string()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The plain case: nothing moved, marks stay put.
|
|
||||||
#[test]
|
|
||||||
fn an_unchanged_queue_keeps_its_marks() {
|
|
||||||
let old = paths(&["/a", "/b", "/c"]);
|
|
||||||
assert_eq!(
|
|
||||||
carry_marks(&old, &[false, true, false], &old),
|
|
||||||
vec![false, true, false]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Another client appended: marks must not slide.
|
|
||||||
#[test]
|
|
||||||
fn an_append_leaves_earlier_marks_alone() {
|
|
||||||
let old = paths(&["/a", "/b"]);
|
|
||||||
let new = paths(&["/a", "/b", "/c"]);
|
|
||||||
assert_eq!(
|
|
||||||
carry_marks(&old, &[false, true], &new),
|
|
||||||
vec![false, true, false]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Playback advanced and dropped the head: the mark follows its track to
|
|
||||||
/// its new index instead of staying on a number.
|
|
||||||
#[test]
|
|
||||||
fn a_removal_before_a_mark_shifts_it_down() {
|
|
||||||
let old = paths(&["/a", "/b", "/c"]);
|
|
||||||
let new = paths(&["/b", "/c"]);
|
|
||||||
assert_eq!(
|
|
||||||
carry_marks(&old, &[false, false, true], &new),
|
|
||||||
vec![false, true]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The marked track itself is gone (we just deleted it).
|
|
||||||
#[test]
|
|
||||||
fn a_removed_marked_track_drops_its_mark() {
|
|
||||||
let old = paths(&["/a", "/b", "/c"]);
|
|
||||||
let new = paths(&["/a", "/c"]);
|
|
||||||
assert_eq!(
|
|
||||||
carry_marks(&old, &[false, true, false], &new),
|
|
||||||
vec![false, false]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A resolve streaming in grows the tail; a selection made meanwhile
|
|
||||||
/// survives.
|
|
||||||
#[test]
|
|
||||||
fn a_streaming_resolve_keeps_the_selection() {
|
|
||||||
let old = paths(&["/a", "/b"]);
|
|
||||||
let new = paths(&["/a", "/b", "/c", "/d", "/e"]);
|
|
||||||
assert_eq!(
|
|
||||||
carry_marks(&old, &[true, true], &new),
|
|
||||||
vec![true, true, false, false, false]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same track queued twice: the n-th occurrence keeps the n-th mark.
|
|
||||||
#[test]
|
|
||||||
fn duplicate_paths_match_in_order() {
|
|
||||||
let old = paths(&["/a", "/a", "/a"]);
|
|
||||||
let new = paths(&["/a", "/a", "/a"]);
|
|
||||||
assert_eq!(
|
|
||||||
carry_marks(&old, &[false, true, false], &new),
|
|
||||||
vec![false, true, false]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A wholesale replacement shares nothing: clear rather than guess.
|
|
||||||
#[test]
|
|
||||||
fn a_replaced_queue_clears_marks() {
|
|
||||||
let old = paths(&["/a", "/b", "/c"]);
|
|
||||||
let new = paths(&["/x", "/y"]);
|
|
||||||
assert_eq!(carry_marks(&old, &[true, true, true], &new), vec![false; 2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_emptied_queue_has_no_marks() {
|
|
||||||
let old = paths(&["/a", "/b"]);
|
|
||||||
assert!(carry_marks(&old, &[true, true], &[]).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A first snapshot has no history to carry.
|
|
||||||
#[test]
|
|
||||||
fn no_previous_marks_yields_none() {
|
|
||||||
let new = paths(&["/a", "/b"]);
|
|
||||||
assert_eq!(carry_marks(&[], &[], &new), vec![false, false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Length mismatches in the caller's bookkeeping must not panic.
|
|
||||||
#[test]
|
|
||||||
fn a_short_mark_vector_is_tolerated() {
|
|
||||||
let old = paths(&["/a", "/b", "/c"]);
|
|
||||||
assert_eq!(carry_marks(&old, &[true], &old), vec![true, false, false]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ mod library;
|
||||||
mod list;
|
mod list;
|
||||||
mod now_playing;
|
mod now_playing;
|
||||||
mod queue;
|
mod queue;
|
||||||
mod register;
|
|
||||||
|
|
||||||
use flume::Sender;
|
use flume::Sender;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
|
@ -19,14 +18,12 @@ use crabidy_core::proto::crabidy::{
|
||||||
InitResponse as InitialData, LibraryNode,
|
InitResponse as InitialData, LibraryNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) use list::{carry_marks, MarkedPane};
|
|
||||||
pub use list::{Filter, StatefulList};
|
pub use list::{Filter, StatefulList};
|
||||||
|
|
||||||
use bindings::Action;
|
use bindings::Action;
|
||||||
use library::Library;
|
use library::Library;
|
||||||
use now_playing::NowPlaying;
|
use now_playing::NowPlaying;
|
||||||
use queue::Queue;
|
use queue::Queue;
|
||||||
pub use register::Register;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub enum UiFocus {
|
pub enum UiFocus {
|
||||||
|
|
@ -40,7 +37,7 @@ enum UiItemKind {
|
||||||
Track,
|
Track,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct UiItem {
|
struct UiItem {
|
||||||
path: String,
|
path: String,
|
||||||
title: String,
|
title: String,
|
||||||
kind: UiItemKind,
|
kind: UiItemKind,
|
||||||
|
|
@ -280,9 +277,6 @@ pub struct App {
|
||||||
pub library: Library,
|
pub library: Library,
|
||||||
pub now_playing: NowPlaying,
|
pub now_playing: NowPlaying,
|
||||||
pub queue: Queue,
|
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>,
|
tx: Sender<MessageFromUi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +294,6 @@ impl App {
|
||||||
library,
|
library,
|
||||||
now_playing,
|
now_playing,
|
||||||
queue,
|
queue,
|
||||||
register: Register::default(),
|
|
||||||
tx,
|
tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -423,50 +416,12 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a queue cursor move, painting the swept `(old, new]` range when
|
|
||||||
/// visual mode is active — the queue mirror of [`Self::library_move`].
|
|
||||||
fn queue_move(&mut self, mv: impl FnOnce(&mut Queue)) {
|
|
||||||
if self.queue.is_visual() {
|
|
||||||
let old = self.queue.selected_view();
|
|
||||||
mv(&mut self.queue);
|
|
||||||
let new = self.queue.selected_view();
|
|
||||||
if let (Some(old), Some(new)) = (old, new) {
|
|
||||||
self.queue.paint_between(old, new);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mv(&mut self.queue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `p`/`P`: insert the register at the cursor. Nothing to paste is a
|
|
||||||
/// no-op, not an empty round trip; the register survives so the same
|
|
||||||
/// yank can be pasted again (architecture/queue-register.md D3).
|
|
||||||
fn paste(&mut self, before: bool) {
|
|
||||||
if self.register.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let pos = self.queue.paste_position(before);
|
|
||||||
let _ = self.tx.send(MessageFromUi::InsertTracks(
|
|
||||||
self.register.paths().to_vec(),
|
|
||||||
pos,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Put `entries` in the register, unless a command produced nothing.
|
|
||||||
fn set_register(&mut self, entries: (Vec<String>, Vec<String>)) {
|
|
||||||
if !entries.0.is_empty() {
|
|
||||||
self.register.set(entries.0, entries.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dispatch(&mut self, action: Action) -> DispatchResult {
|
pub fn dispatch(&mut self, action: Action) -> DispatchResult {
|
||||||
// Visual mode (library paint-select): the six movements paint (handled
|
// Visual mode (library paint-select): the six movements paint (handled
|
||||||
// in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other
|
// in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other
|
||||||
// action leaves visual mode before running (architecture/visual-mode.md).
|
// action leaves visual mode before running (architecture/visual-mode.md).
|
||||||
// `Esc` leaves visual mode instead of clearing the `/` filter, so the
|
let was_visual = self.library.is_visual();
|
||||||
// arms below need to know a pane was painting before the guards run.
|
if was_visual
|
||||||
let was_visual = self.library.is_visual() || self.queue.is_visual();
|
|
||||||
if self.library.is_visual()
|
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
action,
|
action,
|
||||||
Action::LibraryFirst
|
Action::LibraryFirst
|
||||||
|
|
@ -481,22 +436,6 @@ impl App {
|
||||||
{
|
{
|
||||||
self.library.exit_visual();
|
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 {
|
match action {
|
||||||
Action::Quit => return DispatchResult::Quit,
|
Action::Quit => return DispatchResult::Quit,
|
||||||
Action::OpenHelp => self.show_help = true,
|
Action::OpenHelp => self.show_help = true,
|
||||||
|
|
@ -615,7 +554,6 @@ impl App {
|
||||||
// In visual mode `Esc` leaves the mode (marks kept) and
|
// In visual mode `Esc` leaves the mode (marks kept) and
|
||||||
// does not also clear the search filter.
|
// does not also clear the search filter.
|
||||||
self.library.exit_visual();
|
self.library.exit_visual();
|
||||||
self.queue.exit_visual();
|
|
||||||
} else {
|
} else {
|
||||||
// `Esc` in normal navigation clears the focused pane's
|
// `Esc` in normal navigation clears the focused pane's
|
||||||
// filter, restoring the full listing. A no-op when nothing
|
// filter, restoring the full listing. A no-op when nothing
|
||||||
|
|
@ -626,40 +564,24 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::QueueFirst => self.queue_move(|q| q.first()),
|
Action::QueueInsertHere => {
|
||||||
Action::QueueLast => self.queue_move(|q| q.last()),
|
if let Some(selected) = self.queue.selected() {
|
||||||
Action::QueueNext => self.queue_move(|q| q.next()),
|
self.library.queue_insert(selected);
|
||||||
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::QueueFirst => self.queue.first(),
|
||||||
Action::QueueSelectCurrent => self.queue_move(|q| q.select_current()),
|
Action::QueueLast => self.queue.last(),
|
||||||
|
Action::QueueNext => self.queue.next(),
|
||||||
|
Action::QueuePrev => self.queue.prev(),
|
||||||
|
Action::QueueJumpDown => self.queue.down(),
|
||||||
|
Action::QueueJumpUp => self.queue.up(),
|
||||||
|
Action::QueueSelectCurrent => self.queue.select_current(),
|
||||||
Action::QueuePlaySelected => self.queue.play_selected(),
|
Action::QueuePlaySelected => self.queue.play_selected(),
|
||||||
Action::QueueRemoveTrack => {
|
Action::QueueRemoveTrack => self.queue.remove_track(),
|
||||||
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 => {
|
Action::QueueClearKeepCurrent => {
|
||||||
let dropped = self.queue.all_entries(true);
|
|
||||||
self.set_register(dropped);
|
|
||||||
let _ = self.tx.send(MessageFromUi::ClearQueue(true));
|
let _ = self.tx.send(MessageFromUi::ClearQueue(true));
|
||||||
}
|
}
|
||||||
Action::QueueClearAll => {
|
Action::QueueClearAll => {
|
||||||
let dropped = self.queue.all_entries(false);
|
|
||||||
self.set_register(dropped);
|
|
||||||
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
|
let _ = self.tx.send(MessageFromUi::ClearQueue(false));
|
||||||
}
|
}
|
||||||
Action::QueueSaveAs => {
|
Action::QueueSaveAs => {
|
||||||
|
|
@ -720,8 +642,7 @@ impl App {
|
||||||
.constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref())
|
.constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref())
|
||||||
.split(main[1]);
|
.split(main[1]);
|
||||||
|
|
||||||
self.queue
|
self.queue.render(f, right_side[0], queue_focused);
|
||||||
.render(f, right_side[0], queue_focused, self.register.len());
|
|
||||||
self.now_playing.render(f, right_side[1]);
|
self.now_playing.render(f, right_side[1]);
|
||||||
|
|
||||||
// The node-creation/rename input: one line inside the bottom of the
|
// The node-creation/rename input: one line inside the bottom of the
|
||||||
|
|
@ -1900,226 +1821,4 @@ mod tests {
|
||||||
Ok(MessageFromUi::ClearQueue(false))
|
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 crabidy_core::proto::crabidy::Queue as QueueData;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
carry_marks, Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
|
Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
||||||
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
|
COLOR_RED, COLOR_SECONDARY,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Queue {
|
pub struct Queue {
|
||||||
|
|
@ -28,9 +28,6 @@ pub struct Queue {
|
||||||
/// render time — it never enters `list`, so selection and removal
|
/// render time — it never enters `list`, so selection and removal
|
||||||
/// cannot reach it.
|
/// cannot reach it.
|
||||||
resolving: bool,
|
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>,
|
tx: Sender<MessageFromUi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +39,6 @@ impl Queue {
|
||||||
list_state: ListState::default(),
|
list_state: ListState::default(),
|
||||||
filter: Filter::default(),
|
filter: Filter::default(),
|
||||||
resolving: false,
|
resolving: false,
|
||||||
visual: None,
|
|
||||||
tx,
|
tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -88,88 +84,10 @@ impl Queue {
|
||||||
self.select(Some(view));
|
self.select(Some(view));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// The real queue positions an action applies to: every marked row, or
|
pub fn remove_track(&mut self) {
|
||||||
/// the cursor row when nothing is marked. Positions are read off the
|
if let Some(pos) = self.selected_position() {
|
||||||
/// **current** list, so they always match the newest snapshot
|
// FIXME: mark multiple tracks on queue and remove them
|
||||||
/// (architecture/queue-register.md D5).
|
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
|
||||||
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) {
|
pub fn update_position(&mut self, pos: usize) {
|
||||||
|
|
@ -178,23 +96,14 @@ impl Queue {
|
||||||
pub fn update_queue(&mut self, queue: QueueData) {
|
pub fn update_queue(&mut self, queue: QueueData) {
|
||||||
self.current_position = queue.current_position as usize;
|
self.current_position = queue.current_position as usize;
|
||||||
self.resolving = queue.resolving;
|
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
|
self.list = queue
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.map(|t| UiItem {
|
||||||
.map(|(idx, t)| UiItem {
|
|
||||||
path: t.path.clone(),
|
path: t.path.clone(),
|
||||||
title: format!("{} - {}", t.artist, t.title),
|
title: format!("{} - {}", t.artist, t.title),
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: carried.get(idx).copied().unwrap_or(false),
|
marked: false,
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
is_creatable: false,
|
is_creatable: false,
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
|
|
@ -212,10 +121,7 @@ impl Queue {
|
||||||
self.update_selection();
|
self.update_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws the pane. `register_len` is the number of entries `p`/`P` would
|
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||||
/// 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();
|
let selected = self.list_state.selected();
|
||||||
// Render only the visible rows; `view` is the rendered index the
|
// Render only the visible rows; `view` is the rendered index the
|
||||||
// selection bar keys off, `real` the queue position (which drives
|
// selection bar keys off, `real` the queue position (which drives
|
||||||
|
|
@ -229,24 +135,13 @@ impl Queue {
|
||||||
.map(|(idx, (real, item))| {
|
.map(|(idx, (real, item))| {
|
||||||
let active = real == self.current_position;
|
let active = real == self.current_position;
|
||||||
|
|
||||||
// Markers, in the library's vocabulary: `>` is the playing
|
let title = if active {
|
||||||
// track, `*` is a mark (`s`, or painted in visual mode). A
|
format!("> {}", item.title)
|
||||||
// row can be both.
|
} else {
|
||||||
let mut title = String::new();
|
item.title.to_string()
|
||||||
if active {
|
};
|
||||||
title.push_str("> ");
|
|
||||||
}
|
|
||||||
if item.marked {
|
|
||||||
title.push_str("* ");
|
|
||||||
}
|
|
||||||
title.push_str(&item.title);
|
|
||||||
let mut style = if active {
|
let mut style = if active {
|
||||||
Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD)
|
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 {
|
} else if item.is_skipped {
|
||||||
// No playable audio: rendered red (not bold — the
|
// No playable audio: rendered red (not bold — the
|
||||||
// playing marker keeps precedence), skipped by
|
// playing marker keeps precedence), skipped by
|
||||||
|
|
@ -290,15 +185,9 @@ impl Queue {
|
||||||
} else {
|
} else {
|
||||||
COLOR_PRIMARY_DARK
|
COLOR_PRIMARY_DARK
|
||||||
}))
|
}))
|
||||||
.title(if self.visual.is_some() {
|
.title(match self.filter.query() {
|
||||||
// Visual (paint-select) mode: movement toggles marks.
|
Some(query) => format!("Queue — /{query}▏"),
|
||||||
"Queue — VISUAL".to_string()
|
None => "Queue".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 {
|
.highlight_style(Style::default().bg(if focused {
|
||||||
|
|
@ -326,33 +215,6 @@ impl StatefulList for Queue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarkedPane for Queue {
|
|
||||||
fn items(&self) -> &[UiItem] {
|
|
||||||
&self.list
|
|
||||||
}
|
|
||||||
fn items_mut(&mut self) -> &mut [UiItem] {
|
|
||||||
&mut self.list
|
|
||||||
}
|
|
||||||
fn filter(&self) -> &Filter {
|
|
||||||
&self.filter
|
|
||||||
}
|
|
||||||
fn visual(&self) -> Option<usize> {
|
|
||||||
self.visual
|
|
||||||
}
|
|
||||||
fn set_visual(&mut self, anchor: Option<usize>) {
|
|
||||||
self.visual = anchor;
|
|
||||||
}
|
|
||||||
fn selected_view(&self) -> Option<usize> {
|
|
||||||
self.list_state.selected()
|
|
||||||
}
|
|
||||||
/// Every queue row is a track, so every row may be marked — the
|
|
||||||
/// library's `is_queable` gate does not apply here (queue rows carry
|
|
||||||
/// `is_queable: false`).
|
|
||||||
fn markable(&self, _item: &UiItem) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -384,7 +246,7 @@ mod tests {
|
||||||
let backend = TestBackend::new(40, 8);
|
let backend = TestBackend::new(40, 8);
|
||||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| queue.render(f, f.area(), true, 0))
|
.draw(|f| queue.render(f, f.area(), true))
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
let buffer = terminal.backend().buffer().clone();
|
let buffer = terminal.backend().buffer().clone();
|
||||||
(0..buffer.area.height)
|
(0..buffer.area.height)
|
||||||
|
|
@ -466,34 +328,11 @@ mod tests {
|
||||||
|
|
||||||
/// Renders and returns the buffer plus the y of the row containing
|
/// Renders and returns the buffer plus the y of the row containing
|
||||||
/// `needle` and the x of its first character.
|
/// `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) {
|
fn render_and_find(queue: &mut Queue, needle: &str) -> (ratatui::buffer::Buffer, u16, u16) {
|
||||||
let backend = TestBackend::new(40, 8);
|
let backend = TestBackend::new(40, 8);
|
||||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| queue.render(f, f.area(), true, 0))
|
.draw(|f| queue.render(f, f.area(), true))
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
let buffer = terminal.backend().buffer().clone();
|
let buffer = terminal.backend().buffer().clone();
|
||||||
for y in 0..buffer.area.height {
|
for y in 0..buffer.area.height {
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
//! The client-side register: what `y`, `d`, `c`, and `C` put aside and `p`/`P`
|
|
||||||
//! paste back (architecture/queue-register.md 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,
|
/// walks on the server; always refetch them. Remote provider nodes (tidal,
|
||||||
/// youtube) keep the cache that makes back-navigation instant.
|
/// youtube) keep the cache that makes back-navigation instant.
|
||||||
fn is_cacheable(path: &str) -> bool {
|
fn is_cacheable(path: &str) -> bool {
|
||||||
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
|
const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
|
||||||
!MUTABLE_ROOTS.iter().any(|root| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
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::rpc::Rpc;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
|
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
|
||||||
NamePurpose, QueueCursor, Register, UiItemKind,
|
NamePurpose, QueueCursor, UiItemKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VOLUME_STEP: f32 = 0.1;
|
const VOLUME_STEP: f32 = 0.1;
|
||||||
|
|
@ -119,9 +119,6 @@ struct Store {
|
||||||
queue_cursor: RwSignal<QueueCursor>,
|
queue_cursor: RwSignal<QueueCursor>,
|
||||||
focus: RwSignal<Focus>,
|
focus: RwSignal<Focus>,
|
||||||
dialog: RwSignal<Option<Dialog>>,
|
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>>,
|
toast: RwSignal<Option<String>>,
|
||||||
/// The transport; local because the wasm client is not `Send`.
|
/// The transport; local because the wasm client is not `Send`.
|
||||||
rpc: StoredValue<Option<Rpc>, LocalStorage>,
|
rpc: StoredValue<Option<Rpc>, LocalStorage>,
|
||||||
|
|
@ -153,7 +150,6 @@ impl Store {
|
||||||
queue_cursor: RwSignal::new(QueueCursor::default()),
|
queue_cursor: RwSignal::new(QueueCursor::default()),
|
||||||
focus: RwSignal::new(Focus::Library),
|
focus: RwSignal::new(Focus::Library),
|
||||||
dialog: RwSignal::new(None),
|
dialog: RwSignal::new(None),
|
||||||
register: RwSignal::new(Register::default()),
|
|
||||||
toast: RwSignal::new(None),
|
toast: RwSignal::new(None),
|
||||||
rpc: StoredValue::new_local(None),
|
rpc: StoredValue::new_local(None),
|
||||||
cache: StoredValue::new_local(HashMap::new()),
|
cache: StoredValue::new_local(HashMap::new()),
|
||||||
|
|
@ -195,11 +191,9 @@ impl Store {
|
||||||
StreamUpdate::Queue(queue) => {
|
StreamUpdate::Queue(queue) => {
|
||||||
self.queue_pos.set(queue.current_position);
|
self.queue_pos.set(queue.current_position);
|
||||||
self.resolving.set(queue.resolving);
|
self.resolving.set(queue.resolving);
|
||||||
// Marks follow their own track across the snapshot rather
|
|
||||||
// than their index (architecture/queue-register.md D5).
|
|
||||||
let paths: Vec<String> = queue.tracks.iter().map(|t| t.path.clone()).collect();
|
|
||||||
self.queue.set(queue.tracks);
|
self.queue.set(queue.tracks);
|
||||||
self.queue_cursor.update(|c| c.reconcile(paths));
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
|
self.queue_cursor.update(|c| c.clamp(len));
|
||||||
}
|
}
|
||||||
StreamUpdate::Mods(mods) => self.mods.set(mods),
|
StreamUpdate::Mods(mods) => self.mods.set(mods),
|
||||||
StreamUpdate::QueueTrack(queue_track) => {
|
StreamUpdate::QueueTrack(queue_track) => {
|
||||||
|
|
@ -292,98 +286,7 @@ impl Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes one keymap action — the web twin of the TUI dispatch.
|
/// 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) {
|
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 {
|
match action {
|
||||||
Action::OpenHelp => self.dialog.set(Some(Dialog::Help)),
|
Action::OpenHelp => self.dialog.set(Some(Dialog::Help)),
|
||||||
Action::CloseHelp => self.dialog.set(None),
|
Action::CloseHelp => self.dialog.set(None),
|
||||||
|
|
@ -406,12 +309,12 @@ impl Store {
|
||||||
Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await),
|
Action::ToggleMute => self.call(async |mut rpc: Rpc| rpc.toggle_mute().await),
|
||||||
Action::ToggleShuffle => self.call(async |mut rpc: Rpc| rpc.toggle_shuffle().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::ToggleRepeat => self.call(async |mut rpc: Rpc| rpc.toggle_repeat().await),
|
||||||
Action::LibraryNext => self.library_move(|p| p.select_by(1)),
|
Action::LibraryNext => self.library.update(|p| p.select_by(1)),
|
||||||
Action::LibraryPrev => self.library_move(|p| p.select_by(-1)),
|
Action::LibraryPrev => self.library.update(|p| p.select_by(-1)),
|
||||||
Action::LibraryFirst => self.library_move(LibraryPane::select_first),
|
Action::LibraryFirst => self.library.update(LibraryPane::select_first),
|
||||||
Action::LibraryLast => self.library_move(LibraryPane::select_last),
|
Action::LibraryLast => self.library.update(LibraryPane::select_last),
|
||||||
Action::LibraryJumpDown => self.library_move(|p| p.select_by(JUMP)),
|
Action::LibraryJumpDown => self.library.update(|p| p.select_by(JUMP)),
|
||||||
Action::LibraryJumpUp => self.library_move(|p| p.select_by(-JUMP)),
|
Action::LibraryJumpUp => self.library.update(|p| p.select_by(-JUMP)),
|
||||||
Action::LibraryAscend => {
|
Action::LibraryAscend => {
|
||||||
if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) {
|
if let Some(parent) = self.library.with_untracked(|p| p.parent.clone()) {
|
||||||
self.open_library_node(parent);
|
self.open_library_node(parent);
|
||||||
|
|
@ -452,6 +355,17 @@ impl Store {
|
||||||
self.queue_op(async |mut rpc: Rpc| rpc.queue_tracks(paths).await);
|
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 => {
|
Action::LibraryCreateNode => {
|
||||||
let creatable = self
|
let creatable = self
|
||||||
.library
|
.library
|
||||||
|
|
@ -511,17 +425,33 @@ impl Store {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::QueueNext => self.queue_move(|c, len| c.select_by(1, len)),
|
Action::QueueNext => {
|
||||||
Action::QueuePrev => self.queue_move(|c, len| c.select_by(-1, len)),
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
Action::QueueFirst => self.queue_move(|c, _| c.selected = 0),
|
self.queue_cursor.update(|c| c.select_by(1, len));
|
||||||
Action::QueueLast => {
|
}
|
||||||
self.queue_move(|c, len| c.selected = len.saturating_sub(1));
|
Action::QueuePrev => {
|
||||||
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
|
self.queue_cursor.update(|c| c.select_by(-1, len));
|
||||||
|
}
|
||||||
|
Action::QueueFirst => self.queue_cursor.update(|c| c.selected = 0),
|
||||||
|
Action::QueueLast => {
|
||||||
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
|
self.queue_cursor
|
||||||
|
.update(|c| c.selected = len.saturating_sub(1));
|
||||||
|
}
|
||||||
|
Action::QueueJumpDown => {
|
||||||
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
|
self.queue_cursor.update(|c| c.select_by(JUMP, len));
|
||||||
|
}
|
||||||
|
Action::QueueJumpUp => {
|
||||||
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
|
self.queue_cursor.update(|c| c.select_by(-JUMP, len));
|
||||||
}
|
}
|
||||||
Action::QueueJumpDown => self.queue_move(|c, len| c.select_by(JUMP, len)),
|
|
||||||
Action::QueueJumpUp => self.queue_move(|c, len| c.select_by(-JUMP, len)),
|
|
||||||
Action::QueueSelectCurrent => {
|
Action::QueueSelectCurrent => {
|
||||||
let current = self.queue_pos.get_untracked() as usize;
|
let current = self.queue_pos.get_untracked() as usize;
|
||||||
self.queue_move(|c, len| c.selected = current.min(len.saturating_sub(1)));
|
let len = self.queue.with_untracked(Vec::len);
|
||||||
|
self.queue_cursor
|
||||||
|
.update(|c| c.selected = current.min(len.saturating_sub(1)));
|
||||||
}
|
}
|
||||||
Action::QueuePlaySelected => {
|
Action::QueuePlaySelected => {
|
||||||
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
|
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
|
||||||
|
|
@ -530,52 +460,15 @@ impl Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::QueueRemoveTrack => {
|
Action::QueueRemoveTrack => {
|
||||||
let positions = self
|
let position = self.queue_cursor.with_untracked(|c| c.selected as u32);
|
||||||
.queue_cursor
|
if self.queue.with_untracked(|q| !q.is_empty()) {
|
||||||
.with_untracked(QueueCursor::action_positions);
|
self.call(async move |mut rpc: Rpc| rpc.remove_tracks(vec![position]).await);
|
||||||
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 => {
|
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)
|
self.call(async |mut rpc: Rpc| rpc.clear_queue(true).await)
|
||||||
}
|
}
|
||||||
Action::QueueClearAll => {
|
Action::QueueClearAll => self.call(async |mut rpc: Rpc| rpc.clear_queue(false).await),
|
||||||
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 => {
|
Action::QueueSaveAs => {
|
||||||
if self.queue.with_untracked(|q| !q.is_empty()) {
|
if self.queue.with_untracked(|q| !q.is_empty()) {
|
||||||
self.dialog.set(Some(Dialog::Name {
|
self.dialog.set(Some(Dialog::Name {
|
||||||
|
|
@ -884,9 +777,6 @@ fn LibraryView(store: Store) -> impl IntoView {
|
||||||
"‹"
|
"‹"
|
||||||
</button>
|
</button>
|
||||||
<span class="path" title=pane.path.clone()>{pane.title.clone()}</span>
|
<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>
|
<span class="spacer"></span>
|
||||||
<Show when=move || library.with(|p| p.is_creatable)>
|
<Show when=move || library.with(|p| p.is_creatable)>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1045,22 +935,14 @@ fn QueueView(store: Store) -> impl IntoView {
|
||||||
<span class="path">
|
<span class="path">
|
||||||
"queue"
|
"queue"
|
||||||
{move || store.resolving.get().then_some(" (loading…)")}
|
{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>
|
</span>
|
||||||
<Show when=move || store.queue_cursor.with(|c| c.is_visual())>
|
|
||||||
<span class="mode">"VISUAL"</span>
|
|
||||||
</Show>
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button
|
<button
|
||||||
class="ghost"
|
class="ghost"
|
||||||
title="paste the register after selected (p)"
|
title="insert library selection after selected (p)"
|
||||||
on:click=move |_| store.dispatch(Action::QueuePaste)
|
on:click=move |_| store.dispatch(Action::QueueInsertHere)
|
||||||
>
|
>
|
||||||
"paste"
|
"insert"
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ghost"
|
class="ghost"
|
||||||
|
|
@ -1087,9 +969,7 @@ fn QueueView(store: Store) -> impl IntoView {
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
{move || {
|
{move || {
|
||||||
let current = store.queue_pos.get() as usize;
|
let current = store.queue_pos.get() as usize;
|
||||||
let queue_state = store.queue_cursor.get();
|
let cursor = store.queue_cursor.get().selected;
|
||||||
let cursor = queue_state.selected;
|
|
||||||
let marks = queue_state.marks.clone();
|
|
||||||
store
|
store
|
||||||
.queue
|
.queue
|
||||||
.get()
|
.get()
|
||||||
|
|
@ -1097,11 +977,9 @@ fn QueueView(store: Store) -> impl IntoView {
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, track)| {
|
.map(|(index, track)| {
|
||||||
let label = track_label(track);
|
let label = track_label(track);
|
||||||
let marked = marks.get(index).copied().unwrap_or(false);
|
|
||||||
view! {
|
view! {
|
||||||
<li
|
<li
|
||||||
class:selected=index == cursor
|
class:selected=index == cursor
|
||||||
class:marked=marked
|
|
||||||
class:current=index == current
|
class:current=index == current
|
||||||
class:skipped=track.is_skipped
|
class:skipped=track.is_skipped
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ pub enum Action {
|
||||||
LibraryAscend,
|
LibraryAscend,
|
||||||
LibraryDive,
|
LibraryDive,
|
||||||
LibraryToggleMark,
|
LibraryToggleMark,
|
||||||
LibraryVisualMode,
|
|
||||||
LibraryYank,
|
|
||||||
LibraryCaptureNode,
|
LibraryCaptureNode,
|
||||||
LibraryDownloadNode,
|
LibraryDownloadNode,
|
||||||
LibraryCreateNode,
|
LibraryCreateNode,
|
||||||
|
|
@ -48,11 +46,7 @@ pub enum Action {
|
||||||
QueueJumpUp,
|
QueueJumpUp,
|
||||||
QueueSelectCurrent,
|
QueueSelectCurrent,
|
||||||
QueuePlaySelected,
|
QueuePlaySelected,
|
||||||
QueueToggleMark,
|
QueueInsertHere,
|
||||||
QueueVisualMode,
|
|
||||||
QueueYank,
|
|
||||||
QueuePaste,
|
|
||||||
QueuePasteBefore,
|
|
||||||
QueueRemoveTrack,
|
QueueRemoveTrack,
|
||||||
QueueClearKeepCurrent,
|
QueueClearKeepCurrent,
|
||||||
QueueClearAll,
|
QueueClearAll,
|
||||||
|
|
@ -154,16 +148,6 @@ pub const HELP: &[HelpEntry] = &[
|
||||||
key: "s",
|
key: "s",
|
||||||
description: "Mark/unmark selection",
|
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 {
|
HelpEntry {
|
||||||
scope: "Library",
|
scope: "Library",
|
||||||
key: "w",
|
key: "w",
|
||||||
|
|
@ -231,23 +215,8 @@ pub const HELP: &[HelpEntry] = &[
|
||||||
},
|
},
|
||||||
HelpEntry {
|
HelpEntry {
|
||||||
scope: "Queue",
|
scope: "Queue",
|
||||||
key: "s",
|
key: "p",
|
||||||
description: "Mark/unmark selection",
|
description: "Insert library selection after this track",
|
||||||
},
|
|
||||||
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 {
|
HelpEntry {
|
||||||
scope: "Queue",
|
scope: "Queue",
|
||||||
|
|
@ -324,8 +293,6 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
|
||||||
"h" | "ArrowLeft" => Some(Action::LibraryAscend),
|
"h" | "ArrowLeft" => Some(Action::LibraryAscend),
|
||||||
"l" | "ArrowRight" => Some(Action::LibraryDive),
|
"l" | "ArrowRight" => Some(Action::LibraryDive),
|
||||||
"s" => Some(Action::LibraryToggleMark),
|
"s" => Some(Action::LibraryToggleMark),
|
||||||
"v" | "V" => Some(Action::LibraryVisualMode),
|
|
||||||
"y" => Some(Action::LibraryYank),
|
|
||||||
"w" => Some(Action::LibraryCaptureNode),
|
"w" => Some(Action::LibraryCaptureNode),
|
||||||
"W" => Some(Action::LibraryDownloadNode),
|
"W" => Some(Action::LibraryDownloadNode),
|
||||||
"%" => Some(Action::LibraryCreateNode),
|
"%" => Some(Action::LibraryCreateNode),
|
||||||
|
|
@ -343,11 +310,7 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
|
||||||
"G" => Some(Action::QueueLast),
|
"G" => Some(Action::QueueLast),
|
||||||
"o" => Some(Action::QueueSelectCurrent),
|
"o" => Some(Action::QueueSelectCurrent),
|
||||||
"Enter" => Some(Action::QueuePlaySelected),
|
"Enter" => Some(Action::QueuePlaySelected),
|
||||||
"s" => Some(Action::QueueToggleMark),
|
"p" => Some(Action::QueueInsertHere),
|
||||||
"v" | "V" => Some(Action::QueueVisualMode),
|
|
||||||
"y" => Some(Action::QueueYank),
|
|
||||||
"p" => Some(Action::QueuePaste),
|
|
||||||
"P" => Some(Action::QueuePasteBefore),
|
|
||||||
"d" => Some(Action::QueueRemoveTrack),
|
"d" => Some(Action::QueueRemoveTrack),
|
||||||
"c" => Some(Action::QueueClearKeepCurrent),
|
"c" => Some(Action::QueueClearKeepCurrent),
|
||||||
"C" => Some(Action::QueueClearAll),
|
"C" => Some(Action::QueueClearAll),
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ pub enum Dialog {
|
||||||
/// store on every visit) all change server-side, so their listings are
|
/// store on every visit) all change server-side, so their listings are
|
||||||
/// always refetched.
|
/// always refetched.
|
||||||
pub fn is_cacheable(path: &str) -> bool {
|
pub fn is_cacheable(path: &str) -> bool {
|
||||||
const MUTABLE_ROOTS: [&str; 4] = ["/crabidy", "/fs", "/orphans", "/rss"];
|
const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
|
||||||
!MUTABLE_ROOTS.iter().any(|root| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||||
})
|
})
|
||||||
|
|
@ -104,10 +104,6 @@ pub struct LibraryPane {
|
||||||
pub is_creatable: bool,
|
pub is_creatable: bool,
|
||||||
pub items: Vec<UiItem>,
|
pub items: Vec<UiItem>,
|
||||||
pub selected: usize,
|
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>,
|
positions: HashMap<String, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,66 +193,6 @@ impl LibraryPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle the mark of one row, honoring the queueable gate.
|
|
||||||
fn toggle_mark_at(&mut self, index: usize) {
|
|
||||||
if let Some(item) = self.items.get_mut(index) {
|
|
||||||
if item.is_queable {
|
|
||||||
item.marked = !item.marked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter or leave visual mode. Entering anchors at the cursor and marks
|
|
||||||
/// it; leaving keeps the marks.
|
|
||||||
pub fn toggle_visual(&mut self) {
|
|
||||||
if self.visual.is_some() {
|
|
||||||
self.visual = None;
|
|
||||||
} else {
|
|
||||||
self.visual = Some(self.selected);
|
|
||||||
self.toggle_mark();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exit_visual(&mut self) {
|
|
||||||
self.visual = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_visual(&self) -> bool {
|
|
||||||
self.visual.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paint a visual-mode sweep: toggle every row whose membership in the
|
|
||||||
/// anchored range changed, so moving back over a row reverses it.
|
|
||||||
pub fn paint_between(&mut self, from: usize, to: usize) {
|
|
||||||
let Some(anchor) = self.visual else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (old_lo, old_hi) = (anchor.min(from), anchor.max(from));
|
|
||||||
let (new_lo, new_hi) = (anchor.min(to), anchor.max(to));
|
|
||||||
for index in old_lo.min(new_lo)..=old_hi.max(new_hi) {
|
|
||||||
let in_old = (old_lo..=old_hi).contains(&index);
|
|
||||||
let in_new = (new_lo..=new_hi).contains(&index);
|
|
||||||
if in_old != in_new {
|
|
||||||
self.toggle_mark_at(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `y` puts in the register: the marked rows, or the queueable
|
|
||||||
/// cursor row — paths with labels for display.
|
|
||||||
pub fn yank_selection(&self) -> Option<(Vec<String>, Vec<String>)> {
|
|
||||||
if self.items.iter().any(|i| i.marked) {
|
|
||||||
let marked = self.items.iter().filter(|i| i.marked);
|
|
||||||
return Some((
|
|
||||||
marked.clone().map(|i| i.path.clone()).collect(),
|
|
||||||
marked.map(|i| i.title.clone()).collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let item = self.selected_item()?;
|
|
||||||
item.is_queable
|
|
||||||
.then(|| (vec![item.path.clone()], vec![item.title.clone()]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_marks(&mut self) {
|
pub fn remove_marks(&mut self) {
|
||||||
for item in &mut self.items {
|
for item in &mut self.items {
|
||||||
item.marked = false;
|
item.marked = false;
|
||||||
|
|
@ -306,20 +242,9 @@ impl LibraryPane {
|
||||||
/// The queue pane cursor. The queue itself (tracks, current position,
|
/// The queue pane cursor. The queue itself (tracks, current position,
|
||||||
/// play state) lives in signals fed by the update stream; this only
|
/// play state) lives in signals fed by the update stream; this only
|
||||||
/// tracks the selection.
|
/// tracks the selection.
|
||||||
// No longer `Copy`: it owns the mark flags and the paths they were taken
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
// against (architecture/queue-register.md).
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct QueueCursor {
|
pub struct QueueCursor {
|
||||||
pub selected: usize,
|
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 {
|
impl QueueCursor {
|
||||||
|
|
@ -333,156 +258,6 @@ impl QueueCursor {
|
||||||
pub fn clamp(&mut self, len: usize) {
|
pub fn clamp(&mut self, len: usize) {
|
||||||
self.selected = self.selected.min(len.saturating_sub(1));
|
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 —
|
/// How long a finished capture's line lingers, in milliseconds —
|
||||||
|
|
@ -751,150 +526,4 @@ mod tests {
|
||||||
assert_eq!(format_seconds(3600), "1:00:00");
|
assert_eq!(format_seconds(3600), "1:00:00");
|
||||||
assert_eq!(format_seconds(3661), "1:01:01");
|
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,18 +392,6 @@ input {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Visual (paint-select) mode badge in a pane toolbar — the web counterpart of
|
|
||||||
the TUI's "— VISUAL" pane title. */
|
|
||||||
.mode {
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help {
|
.help {
|
||||||
min-inline-size: min(52rem, 94vw);
|
min-inline-size: min(52rem, 94vw);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,6 @@ message RemoveRequest {
|
||||||
message RemoveResponse {}
|
message RemoveResponse {}
|
||||||
|
|
||||||
message InsertRequest {
|
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;
|
uint32 position = 1;
|
||||||
repeated string paths = 2;
|
repeated string paths = 2;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ all-providers = [
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
"jamendo",
|
"jamendo",
|
||||||
"rss",
|
|
||||||
"fs",
|
"fs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -38,7 +37,6 @@ fyyd = ["dep:fyyd", "_any-provider"]
|
||||||
abs = ["dep:absdy", "_any-provider"]
|
abs = ["dep:absdy", "_any-provider"]
|
||||||
soundcloud = ["dep:soundclouddy", "_any-provider"]
|
soundcloud = ["dep:soundclouddy", "_any-provider"]
|
||||||
jamendo = ["dep:jamendody", "_any-provider"]
|
jamendo = ["dep:jamendody", "_any-provider"]
|
||||||
rss = ["dep:rssdy", "_any-provider"]
|
|
||||||
# Local files *and* persistent state (D5): the `/fs` mount, the content
|
# Local files *and* persistent state (D5): the `/fs` mount, the content
|
||||||
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
|
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
|
||||||
# persistence, and the `scan` command. Off means the server keeps its
|
# persistence, and the `scan` command. Off means the server keeps its
|
||||||
|
|
@ -79,7 +77,6 @@ absdy = { workspace = true, optional = true }
|
||||||
fsdy = { workspace = true, optional = true }
|
fsdy = { workspace = true, optional = true }
|
||||||
fyyd = { workspace = true, optional = true }
|
fyyd = { workspace = true, optional = true }
|
||||||
jamendody = { workspace = true, optional = true }
|
jamendody = { workspace = true, optional = true }
|
||||||
rssdy = { workspace = true, optional = true }
|
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
reqwest = { workspace = true, optional = true }
|
reqwest = { workspace = true, optional = true }
|
||||||
|
|
|
||||||
|
|
@ -255,12 +255,10 @@ pub enum ResolveKind {
|
||||||
Replace,
|
Replace,
|
||||||
/// Every chunk appends at the end.
|
/// Every chunk appends at the end.
|
||||||
Append,
|
Append,
|
||||||
/// Chunks insert **at** the given index, each advancing it so the resolved
|
/// Chunks insert after the given position, each advancing the cursor so
|
||||||
/// collection stays contiguous and in order. The index is the one
|
/// the resolved collection stays contiguous and in order. `Queue`
|
||||||
/// `QueueManager::insert_tracks` takes: whatever sits there shifts down,
|
/// (play-after-current) is an `InsertAfter` at the current position.
|
||||||
/// `0` is the front. `Queue` (play-after-current) is an `InsertAt` of the
|
InsertAfter(u32),
|
||||||
/// current position **+ 1**.
|
|
||||||
InsertAt(u32),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The playback loop's bookkeeping for one in-flight resolve operation.
|
/// The playback loop's bookkeeping for one in-flight resolve operation.
|
||||||
|
|
@ -342,12 +340,12 @@ impl PendingResolve {
|
||||||
queue.replace_with_tracks(tracks)
|
queue.replace_with_tracks(tracks)
|
||||||
}
|
}
|
||||||
ResolveKind::Append => queue.append_tracks(tracks),
|
ResolveKind::Append => queue.append_tracks(tracks),
|
||||||
ResolveKind::InsertAt(index) => {
|
ResolveKind::InsertAfter(position) => {
|
||||||
// Advance so this op's next chunk lands right behind this one,
|
// Advance the cursor so this op's next chunk lands right
|
||||||
// keeping the collection contiguous. `insert_tracks` clamps
|
// behind this one, keeping the collection contiguous.
|
||||||
// past the end.
|
// `insert_tracks` clamps positions past the end.
|
||||||
self.kind = ResolveKind::InsertAt(index + tracks.len() as u32);
|
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
|
||||||
queue.insert_tracks(index, tracks)
|
queue.insert_tracks(position, tracks)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if started.is_some() {
|
if started.is_some() {
|
||||||
|
|
@ -594,23 +592,19 @@ impl QueueManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts `tracks` **at** `position`: whatever sits there, and everything
|
|
||||||
/// after it, shifts down. `0` inserts at the front and a position at or
|
|
||||||
/// past the end appends, so every insertion point is reachable — which is
|
|
||||||
/// what a client needs to paste *before* the first row. An empty queue is
|
|
||||||
/// a replace, which starts playback.
|
|
||||||
///
|
|
||||||
/// Callers wanting "after track N" (play-next) pass `N + 1`.
|
|
||||||
pub fn insert_tracks(&mut self, position: u32, tracks: &[Track]) -> Option<Track> {
|
pub fn insert_tracks(&mut self, position: u32, tracks: &[Track]) -> Option<Track> {
|
||||||
let len = self.tracks.len();
|
let len = self.tracks.len();
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return self.replace_with_tracks(tracks);
|
return self.replace_with_tracks(tracks);
|
||||||
}
|
}
|
||||||
let inserted = tracks.len();
|
let inserted = tracks.len();
|
||||||
let at = (position as usize).min(len);
|
let position = (position as usize).min(len - 1);
|
||||||
let order_additions: Vec<usize> = (len..len + inserted).collect();
|
let order_additions: Vec<usize> = (len..len + inserted).collect();
|
||||||
self.play_order.extend(order_additions);
|
self.play_order.extend(order_additions);
|
||||||
let tail: Vec<Track> = self.tracks.splice(at.., tracks.to_vec()).collect();
|
let tail: Vec<Track> = self
|
||||||
|
.tracks
|
||||||
|
.splice(position + 1.., tracks.to_vec())
|
||||||
|
.collect();
|
||||||
self.tracks.extend(tail);
|
self.tracks.extend(tail);
|
||||||
let mut changed: Vec<usize> = Vec::new();
|
let mut changed: Vec<usize> = Vec::new();
|
||||||
// In shuffle mode we may already have played positions that are
|
// In shuffle mode we may already have played positions that are
|
||||||
|
|
@ -620,7 +614,7 @@ impl QueueManager {
|
||||||
.play_order
|
.play_order
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.take(self.current_offset)
|
.take(self.current_offset)
|
||||||
.filter(|i| at <= **i)
|
.filter(|i| position < **i)
|
||||||
{
|
{
|
||||||
*i += inserted;
|
*i += inserted;
|
||||||
changed.push(*i);
|
changed.push(*i);
|
||||||
|
|
@ -642,10 +636,9 @@ impl QueueManager {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play-next: insert right after the current track.
|
|
||||||
pub fn queue_tracks(&mut self, tracks: &[Track]) -> Option<Track> {
|
pub fn queue_tracks(&mut self, tracks: &[Track]) -> Option<Track> {
|
||||||
let pos = self.current_position();
|
let pos = self.current_position();
|
||||||
self.insert_tracks(pos as u32 + 1, tracks)
|
self.insert_tracks(pos as u32, tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self, exclude_current: bool) -> bool {
|
pub fn clear(&mut self, exclude_current: bool) -> bool {
|
||||||
|
|
@ -757,35 +750,6 @@ mod tests {
|
||||||
assert_eq!(q.next_track().unwrap().title, "track 0");
|
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]
|
#[test]
|
||||||
fn insert_past_end_appends() {
|
fn insert_past_end_appends() {
|
||||||
let mut q = queue_with(2);
|
let mut q = queue_with(2);
|
||||||
|
|
@ -815,10 +779,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_op_keeps_chunks_contiguous_and_in_order() {
|
fn insert_after_op_keeps_chunks_contiguous_and_in_order() {
|
||||||
let mut q = queue_with(3); // playing track 0
|
let mut q = queue_with(3); // playing track 0
|
||||||
// "after track 0" is "at index 1".
|
let mut op = PendingResolve::new(ResolveKind::InsertAfter(0));
|
||||||
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(10), track(11)]).is_none());
|
||||||
assert!(op.apply_chunk(&mut q, &[track(12)]).is_none());
|
assert!(op.apply_chunk(&mut q, &[track(12)]).is_none());
|
||||||
// Both chunks sit as one contiguous run right after the current
|
// Both chunks sit as one contiguous run right after the current
|
||||||
|
|
@ -830,9 +793,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_op_clamps_past_the_end() {
|
fn insert_after_op_clamps_past_the_end() {
|
||||||
let mut q = queue_with(1);
|
let mut q = queue_with(1);
|
||||||
let mut op = PendingResolve::new(ResolveKind::InsertAt(99));
|
let mut op = PendingResolve::new(ResolveKind::InsertAfter(99));
|
||||||
assert!(op.apply_chunk(&mut q, &[track(10)]).is_none());
|
assert!(op.apply_chunk(&mut q, &[track(10)]).is_none());
|
||||||
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
|
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
|
||||||
assert_eq!(titles(&q), vec!["track 0", "track 10", "track 11"]);
|
assert_eq!(titles(&q), vec!["track 0", "track 10", "track 11"]);
|
||||||
|
|
|
||||||
|
|
@ -192,8 +192,7 @@ impl Playback {
|
||||||
};
|
};
|
||||||
queue.current_position() as u32
|
queue.current_position() as u32
|
||||||
};
|
};
|
||||||
// Play-next means "right after the current track".
|
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||||
self.start_resolve(ResolveKind::InsertAt(position + 1), paths);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Append { paths } => {
|
PlaybackCommand::Append { paths } => {
|
||||||
|
|
@ -239,9 +238,7 @@ impl Playback {
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Insert { position, paths } => {
|
PlaybackCommand::Insert { position, paths } => {
|
||||||
// The RPC's `position` is the index to insert *at*: whatever
|
self.start_resolve(ResolveKind::InsertAfter(position), paths);
|
||||||
// sits there shifts down, 0 is the front. No offset here.
|
|
||||||
self.start_resolve(ResolveKind::InsertAt(position), paths);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::Clear { exclude_current } => {
|
PlaybackCommand::Clear { exclude_current } => {
|
||||||
|
|
@ -907,12 +904,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue titles in queue order, for order assertions.
|
#[cfg(feature = "fs")]
|
||||||
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) {
|
fn fill_queue(playback: &Playback, n: usize) {
|
||||||
let tracks: Vec<Track> = (0..n).map(track).collect();
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
let mut queue = playback.queue.lock().expect("queue lock");
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
|
|
@ -1063,91 +1055,4 @@ mod tests {
|
||||||
let snapshot = rx.borrow().clone().expect("snapshot sent");
|
let snapshot = rx.borrow().clone().expect("snapshot sent");
|
||||||
assert_eq!(snapshot.tracks.len(), 1);
|
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,8 +72,7 @@ impl Mount {
|
||||||
feature = "fyyd",
|
feature = "fyyd",
|
||||||
feature = "abs",
|
feature = "abs",
|
||||||
feature = "soundcloud",
|
feature = "soundcloud",
|
||||||
feature = "jamendo",
|
feature = "jamendo"
|
||||||
feature = "rss"
|
|
||||||
))]
|
))]
|
||||||
async fn mount_from_config<C>(
|
async fn mount_from_config<C>(
|
||||||
config_dir: &std::path::Path,
|
config_dir: &std::path::Path,
|
||||||
|
|
@ -462,8 +461,6 @@ impl ProviderOrchestrator {
|
||||||
// (architecture/soundcloud-provider.md D1).
|
// (architecture/soundcloud-provider.md D1).
|
||||||
// - Jamendo: needs a registered `client_id`; without one it is
|
// - Jamendo: needs a registered `client_id`; without one it is
|
||||||
// disabled (architecture/jamendo-provider.md D1).
|
// 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")]
|
#[cfg(feature = "youtube")]
|
||||||
if enabled.youtube {
|
if enabled.youtube {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
|
|
@ -512,18 +509,6 @@ impl ProviderOrchestrator {
|
||||||
.await,
|
.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")]
|
#[cfg(feature = "jamendo")]
|
||||||
if enabled.jamendo {
|
if enabled.jamendo {
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,13 @@ pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||||
///
|
///
|
||||||
/// This is the *vocabulary*, not what this binary can mount — see
|
/// This is the *vocabulary*, not what this binary can mount — see
|
||||||
/// [`BUILT_IN_PROVIDERS`].
|
/// [`BUILT_IN_PROVIDERS`].
|
||||||
pub const ALL_PROVIDERS: [&str; 10] = [
|
pub const ALL_PROVIDERS: [&str; 9] = [
|
||||||
"tidal",
|
"tidal",
|
||||||
"youtube",
|
"youtube",
|
||||||
"fyyd",
|
"fyyd",
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
"jamendo",
|
"jamendo",
|
||||||
"rss",
|
|
||||||
"fs",
|
"fs",
|
||||||
"crabidy",
|
"crabidy",
|
||||||
"orphans",
|
"orphans",
|
||||||
|
|
@ -54,8 +53,6 @@ pub const BUILT_IN_PROVIDERS: &[&str] = &[
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
#[cfg(feature = "jamendo")]
|
#[cfg(feature = "jamendo")]
|
||||||
"jamendo",
|
"jamendo",
|
||||||
#[cfg(feature = "rss")]
|
|
||||||
"rss",
|
|
||||||
#[cfg(feature = "fs")]
|
#[cfg(feature = "fs")]
|
||||||
"fs",
|
"fs",
|
||||||
#[cfg(feature = "fs")]
|
#[cfg(feature = "fs")]
|
||||||
|
|
@ -113,7 +110,6 @@ pub struct ProviderToggles {
|
||||||
pub abs: bool,
|
pub abs: bool,
|
||||||
pub soundcloud: bool,
|
pub soundcloud: bool,
|
||||||
pub jamendo: bool,
|
pub jamendo: bool,
|
||||||
pub rss: bool,
|
|
||||||
pub fs: bool,
|
pub fs: bool,
|
||||||
pub crabidy: bool,
|
pub crabidy: bool,
|
||||||
pub orphans: bool,
|
pub orphans: bool,
|
||||||
|
|
@ -132,7 +128,6 @@ impl ProviderToggles {
|
||||||
abs: cfg!(feature = "abs"),
|
abs: cfg!(feature = "abs"),
|
||||||
soundcloud: cfg!(feature = "soundcloud"),
|
soundcloud: cfg!(feature = "soundcloud"),
|
||||||
jamendo: cfg!(feature = "jamendo"),
|
jamendo: cfg!(feature = "jamendo"),
|
||||||
rss: cfg!(feature = "rss"),
|
|
||||||
fs: cfg!(feature = "fs"),
|
fs: cfg!(feature = "fs"),
|
||||||
// Both live on the content store, which the `fs` feature brings
|
// Both live on the content store, which the `fs` feature brings
|
||||||
// (D5).
|
// (D5).
|
||||||
|
|
@ -265,7 +260,6 @@ impl ServerSettings {
|
||||||
abs: self.provider_enabled("abs"),
|
abs: self.provider_enabled("abs"),
|
||||||
soundcloud: self.provider_enabled("soundcloud"),
|
soundcloud: self.provider_enabled("soundcloud"),
|
||||||
jamendo: self.provider_enabled("jamendo"),
|
jamendo: self.provider_enabled("jamendo"),
|
||||||
rss: self.provider_enabled("rss"),
|
|
||||||
fs: self.provider_enabled("fs"),
|
fs: self.provider_enabled("fs"),
|
||||||
crabidy: self.provider_enabled("crabidy"),
|
crabidy: self.provider_enabled("crabidy"),
|
||||||
orphans: self.provider_enabled("orphans"),
|
orphans: self.provider_enabled("orphans"),
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ in
|
||||||
test_it -p crabidy-server --no-default-features
|
test_it -p crabidy-server --no-default-features
|
||||||
|
|
||||||
# Each provider on its own: nothing else may be needed to compile it.
|
# Each provider on its own: nothing else may be needed to compile it.
|
||||||
for feature in tidal youtube fyyd abs soundcloud jamendo rss fs; do
|
for feature in tidal youtube fyyd abs soundcloud jamendo fs; do
|
||||||
clippy -p crabidy-server --no-default-features --features "$feature"
|
clippy -p crabidy-server --no-default-features --features "$feature"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
- [YouTube — /youtube](./providers/youtube.md)
|
- [YouTube — /youtube](./providers/youtube.md)
|
||||||
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
|
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
|
||||||
- [Jamendo — /jamendo](./providers/jamendo.md)
|
- [Jamendo — /jamendo](./providers/jamendo.md)
|
||||||
- [RSS — /rss](./providers/rss.md)
|
|
||||||
- [audiobookshelf — /abs](./providers/abs.md)
|
- [audiobookshelf — /abs](./providers/abs.md)
|
||||||
- [fyyd — /fyyd](./providers/fyyd.md)
|
- [fyyd — /fyyd](./providers/fyyd.md)
|
||||||
- [Search](./providers/search.md)
|
- [Search](./providers/search.md)
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ cbd global volume -- -0.1 # lower the volume
|
||||||
marked. `library create`/`rename`/`delete`, and `library save`/
|
marked. `library create`/`rename`/`delete`, and `library save`/
|
||||||
`capture` (the `w`/`W` equivalents into `/crabidy` — see [The crabidy
|
`capture` (the `w`/`W` equivalents into `/crabidy` — see [The crabidy
|
||||||
store](../store.md)) mutate it.
|
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`/
|
- `queue show` prints the [queue](../queue.md); `queue append`/`insert`/
|
||||||
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
||||||
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
|
[--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
|
- `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
|
item list (they survive filtering) so a marked-but-hidden row still
|
||||||
counts when you queue or save.
|
counts when you queue or save.
|
||||||
- `v` (or `V`) enters **visual mode**: movement then marks or unmarks the
|
- `v` (or `V`) enters **visual mode** in the library: movement then marks
|
||||||
rows you sweep over, like vim's visual selection. The selection is
|
or unmarks the rows you sweep over, like vim's visual selection. The
|
||||||
anchored where you entered it, so moving back over a row reverses it.
|
selection is anchored where you entered it, so moving back over a row
|
||||||
`Esc` leaves visual mode (keeping the marks and any `/` filter), and so
|
reverses it. `Esc` leaves visual mode (keeping the marks and any `/`
|
||||||
does any non-movement action. The pane title shows `— VISUAL` while it is
|
filter), and so does any non-movement action. The pane title shows
|
||||||
on. Both panes have it.
|
`— VISUAL` while it is on.
|
||||||
- `w` **saves** the selection — a library subtree, or in the queue pane
|
- `w` **saves** the selection — a library subtree, or in the queue pane
|
||||||
the whole queue — as a new folder of link files under
|
the whole queue — as a new folder of link files under
|
||||||
`/crabidy/<name>`; playback replays it from the source provider.
|
`/crabidy/<name>`; playback replays it from the source provider.
|
||||||
|
|
@ -74,39 +74,6 @@ confirmation — it removes only the metadata toml, never the shared
|
||||||
store audio, which other saves may reference.
|
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
|
## The `/` live filter
|
||||||
|
|
||||||
Pressing `/` opens a search input that filters the focused pane's items
|
Pressing `/` opens a search input that filters the focused pane's items
|
||||||
|
|
@ -172,7 +139,6 @@ pane).
|
||||||
| Library | `l` | Enter selected folder |
|
| Library | `l` | Enter selected folder |
|
||||||
| Library | `s` | Mark / unmark selection |
|
| Library | `s` | Mark / unmark selection |
|
||||||
| Library | `v` / `V` | Visual mode: movement toggles marks |
|
| Library | `v` / `V` | Visual mode: movement toggles marks |
|
||||||
| Library | `y` | Yank selection into the register |
|
|
||||||
| Library | `Enter` | Replace queue with selection |
|
| Library | `Enter` | Replace queue with selection |
|
||||||
| Library | `a` | Append selection to queue |
|
| Library | `a` | Append selection to queue |
|
||||||
| Library | `L` | Queue selection after current track |
|
| Library | `L` | Queue selection after current track |
|
||||||
|
|
@ -186,15 +152,12 @@ pane).
|
||||||
| Queue | `g` / `G` | Select first / last track |
|
| Queue | `g` / `G` | Select first / last track |
|
||||||
| Queue | `Ctrl-d` | Jump 15 tracks down |
|
| Queue | `Ctrl-d` | Jump 15 tracks down |
|
||||||
| Queue | `Ctrl-u` | Jump 15 tracks up |
|
| 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 | `o` | Select the playing track |
|
||||||
| Queue | `Enter` | Play selected track |
|
| Queue | `Enter` | Play selected track |
|
||||||
| Queue | `p` / `P` | Paste the register after / before |
|
| Queue | `p` | Insert library selection after this track |
|
||||||
| Queue | `d` | Remove selection (into the register) |
|
| Queue | `d` | Remove selected track |
|
||||||
| Queue | `c` | Clear queue except current (to register) |
|
| Queue | `c` | Clear queue except current track |
|
||||||
| Queue | `C` | Clear entire queue (to register) |
|
| Queue | `C` | Clear entire queue |
|
||||||
| Queue | `w` | Save queue under a name |
|
| Queue | `w` | Save queue under a name |
|
||||||
| Queue | `W` | Capture the queue into /crabidy (audio) |
|
| Queue | `W` | Capture the queue into /crabidy (audio) |
|
||||||
| Queue | `/` | Filter this view |
|
| Queue | `/` | Filter this view |
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,8 @@ terminal required, so phones, tablets, and guests can drive the server.
|
||||||
|
|
||||||
Every TUI binding has a clickable equivalent, and the familiar keyboard
|
Every TUI binding has a clickable equivalent, and the familiar keyboard
|
||||||
bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and
|
bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and
|
||||||
queue keys, `?` for help) also work on desktop browsers. That includes
|
queue keys, `?` for help) also work on desktop browsers. The `/` live
|
||||||
marks (`s`), visual mode (`v`/`V`) in both panes, and the register — `y`
|
filter is TUI-only for now.
|
||||||
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
|
## How it is served
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ which the server now also writes on first start.
|
||||||
| `fsdy.toml` | local files | none |
|
| `fsdy.toml` | local files | none |
|
||||||
| `fyyd.toml` | podcasts | none |
|
| `fyyd.toml` | podcasts | none |
|
||||||
| `jamendo.toml` | Jamendo | none (a key is shipped) |
|
| `jamendo.toml` | Jamendo | none (a key is shipped) |
|
||||||
| `rss.toml` | podcast feeds | the feed URLs themselves |
|
|
||||||
| `soundcloud.toml` | SoundCloud | none (token optional) |
|
| `soundcloud.toml` | SoundCloud | none (token optional) |
|
||||||
| `tidaly.toml` | Tidal | device login (interactive) |
|
| `tidaly.toml` | Tidal | device login (interactive) |
|
||||||
| `ytdy.toml` | YouTube | none (cookies optional) |
|
| `ytdy.toml` | YouTube | none (cookies optional) |
|
||||||
|
|
@ -58,7 +57,6 @@ providers = [
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
"jamendo",
|
"jamendo",
|
||||||
"rss",
|
|
||||||
"fs",
|
"fs",
|
||||||
"crabidy",
|
"crabidy",
|
||||||
"orphans",
|
"orphans",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ abs: "/abs — audiobookshelf audiobooks"
|
||||||
fs: "/fs — a local music folder"
|
fs: "/fs — a local music folder"
|
||||||
fyyd: "/fyyd — podcast search"
|
fyyd: "/fyyd — podcast search"
|
||||||
jamendo: "/jamendo — Creative-Commons catalogue"
|
jamendo: "/jamendo — Creative-Commons catalogue"
|
||||||
rss: "/rss — podcast subscriptions"
|
|
||||||
soundcloud: "/soundcloud — SoundCloud"
|
soundcloud: "/soundcloud — SoundCloud"
|
||||||
tidal: "/tidal — Tidal streaming"
|
tidal: "/tidal — Tidal streaming"
|
||||||
youtube: "/youtube — YouTube search & playlists"
|
youtube: "/youtube — YouTube search & playlists"
|
||||||
|
|
@ -32,7 +31,6 @@ root -> abs: "route /abs/*"
|
||||||
root -> fs: "route /fs/*"
|
root -> fs: "route /fs/*"
|
||||||
root -> fyyd: "route /fyyd/*"
|
root -> fyyd: "route /fyyd/*"
|
||||||
root -> jamendo: "route /jamendo/*"
|
root -> jamendo: "route /jamendo/*"
|
||||||
root -> rss: "route /rss/*"
|
|
||||||
root -> soundcloud: "route /soundcloud/*"
|
root -> soundcloud: "route /soundcloud/*"
|
||||||
root -> tidal: "route /tidal/*"
|
root -> tidal: "route /tidal/*"
|
||||||
root -> youtube: "route /youtube/*"
|
root -> youtube: "route /youtube/*"
|
||||||
|
|
@ -92,9 +90,6 @@ Listed in the order the library root serves them.
|
||||||
- **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of
|
- **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of
|
||||||
Creative-Commons music, by search or album. Works out of the box on a
|
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`).
|
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
|
- **[`/soundcloud`](./providers/soundcloud.md)** — SoundCloud search, link
|
||||||
resolving, and playback with no credentials at all; an optional token adds
|
resolving, and playback with no credentials at all; an optional token adds
|
||||||
your likes and playlists (`soundcloud.toml`).
|
your likes and playlists (`soundcloud.toml`).
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
# RSS — /rss
|
|
||||||
|
|
||||||
<!-- toc -->
|
|
||||||
|
|
||||||
Subscribe to podcast feeds by URL and play their episodes. Works with public
|
|
||||||
feeds and with **premium per-subscriber URLs**, and always shows the newest
|
|
||||||
episodes.
|
|
||||||
|
|
||||||
`/fyyd` is the other podcast provider; the two do different jobs. `/fyyd`
|
|
||||||
*discovers* podcasts by searching a public directory. `/rss` plays the feeds
|
|
||||||
**you** name, including ones no directory indexes and ones only you can read.
|
|
||||||
|
|
||||||
## Subscribing
|
|
||||||
|
|
||||||
Press `%` on `/rss`, paste the feed URL, and that is it: the provider fetches
|
|
||||||
the feed once, names the subscription from the feed's own title, and writes it
|
|
||||||
into `rss.toml`. You can also list feeds in the config by hand (below).
|
|
||||||
|
|
||||||
From then on, `e` renames a subscription — its path follows the new name — and
|
|
||||||
`d` unsubscribes, which removes the config entry and nothing else. Audio you
|
|
||||||
captured from it stays under [`/crabidy`](../store.md).
|
|
||||||
|
|
||||||
## Premium feeds are credentials
|
|
||||||
|
|
||||||
A paid podcast hands you a URL with a token in it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
|
|
||||||
```
|
|
||||||
|
|
||||||
Whoever has that URL has the subscription, so crabidy treats it as a secret.
|
|
||||||
It is redacted from logs and debug output, and — the part that shapes the
|
|
||||||
tree — **it never appears in a library path**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/rss/the-economist-podcasts/676f8bfa48c9cac3
|
|
||||||
└ slug of the name └ hash of the episode id
|
|
||||||
```
|
|
||||||
|
|
||||||
Paths are displayed in clients, written into the saved queue, and persisted
|
|
||||||
into bookmark files. A URL in one would leak into all of those, so the path
|
|
||||||
carries a name slug and an episode hash instead.
|
|
||||||
|
|
||||||
```admonish warning
|
|
||||||
`rss.toml` stores feed URLs in cleartext, like every other provider
|
|
||||||
credential. Keep `~/.config/crabidy/` private.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nothing is cached
|
|
||||||
|
|
||||||
Every visit to a subscription **fetches the feed**, so an episode published a
|
|
||||||
minute ago is there when you look. This holds end to end: `/rss` is exempt
|
|
||||||
from the clients' library-listing cache as well, so re-entering a
|
|
||||||
subscription really does re-fetch rather than redraw what you saw before.
|
|
||||||
|
|
||||||
One memo exists, and only to keep the obvious waste away: listing a feed and
|
|
||||||
then queueing its 40 episodes costs **one** fetch, not 41. It is written by
|
|
||||||
listings and read only when resolving a track, so it can never make a listing
|
|
||||||
stale.
|
|
||||||
|
|
||||||
## The tree
|
|
||||||
|
|
||||||
```text
|
|
||||||
/rss
|
|
||||||
├── <subscription> one per feed; queueable and downloadable
|
|
||||||
│ └── <episode> a track, newest first
|
|
||||||
└── …
|
|
||||||
```
|
|
||||||
|
|
||||||
Episodes stream directly from the feed's enclosure URL — no sidecar and no
|
|
||||||
helper binary, the same as [`/fyyd`](./fyyd.md) episodes. `W` captures a whole
|
|
||||||
subscription (or one episode) into the content store.
|
|
||||||
|
|
||||||
## Episode identity, and what it means for bookmarks
|
|
||||||
|
|
||||||
An episode's path segment is a short hash of the publisher's `<guid>` (or of
|
|
||||||
the enclosure URL when a feed omits one), and the guid is also stored as the
|
|
||||||
track's provider id, so capturing the same episode twice de-duplicates in the
|
|
||||||
store.
|
|
||||||
|
|
||||||
Two things follow, both inherent to RSS rather than to crabidy:
|
|
||||||
|
|
||||||
- A **bookmark** (`w`) to an episode that has since aged out of the feed
|
|
||||||
cannot be resolved — there is nothing left to look up. **Capture** (`W`)
|
|
||||||
what you want to keep; that downloads the audio.
|
|
||||||
- A publisher that regenerates guids on every fetch invalidates bookmarks to
|
|
||||||
its episodes.
|
|
||||||
|
|
||||||
## Configuration — `rss.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# One table per subscription. `name` is yours and decides the path slug;
|
|
||||||
# duplicate slugs get a numeric suffix.
|
|
||||||
[[feeds]]
|
|
||||||
name = "The Economist Podcasts"
|
|
||||||
url = "https://feeds.economist.com/v1/rss/…"
|
|
||||||
|
|
||||||
[[feeds]]
|
|
||||||
name = "Cautionary Tales"
|
|
||||||
url = "https://feeds.example.org/cautionary-tales"
|
|
||||||
|
|
||||||
# Optional, defaults shown.
|
|
||||||
episodes_per_feed = 200 # episodes listed per feed
|
|
||||||
call_timeout_secs = 30 # per-request timeout
|
|
||||||
max_feed_bytes = 8388608 # 8 MiB cap on a feed body
|
|
||||||
```
|
|
||||||
|
|
||||||
`%` appends to this file, so subscriptions made from a client persist. An
|
|
||||||
entry with no url is skipped with a warning, and no feeds at all is fine —
|
|
||||||
`/rss` mounts empty and creatable.
|
|
||||||
|
|
||||||
Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed, and a malformed
|
|
||||||
episode is skipped rather than failing the listing.
|
|
||||||
|
|
@ -132,32 +132,6 @@ pass at most. Without that bound, an all-skipped queue with repeat on would
|
||||||
cycle forever and hammer the provider; instead the search gives up after one
|
cycle forever and hammer the provider; instead the search gives up after one
|
||||||
pass and the player stops.
|
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
|
## Persistence
|
||||||
|
|
||||||
The live queue survives a server restart. It is mirrored to the reserved
|
The live queue survives a server restart. It is mirrored to the reserved
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
# Plan — queue selection, visual mode, and the register
|
|
||||||
|
|
||||||
Executes `architecture/queue-register.md` against
|
|
||||||
`quality/queue-register.md`. Ordered by dependency. Three commits: **(A)**
|
|
||||||
the shared mark/visual trait with the library moved onto it, **(B)** queue
|
|
||||||
marks + the register in `cbd-tui`, **(C)** `cbd-web` parity + docs.
|
|
||||||
|
|
||||||
No server work: `Remove` already takes many positions and `Insert` takes a
|
|
||||||
path list (G25).
|
|
||||||
|
|
||||||
## A — Shared mark and visual behaviour (`cbd-tui`)
|
|
||||||
|
|
||||||
- [x] **A1 — Fill in `MarkedPane`** in `app/list.rs`: `toggle_visual`,
|
|
||||||
`toggle_mark`, `toggle_mark_view`, `paint_between`, `selection` — moved
|
|
||||||
verbatim in behaviour from `library.rs`, with the `markable` gate taken
|
|
||||||
from the implementor. *Verifies:* G11, G12, G16.
|
|
||||||
- [x] **A2 — `impl MarkedPane for Library`**, deleting the now-duplicated
|
|
||||||
inherent methods and keeping `get_selected` as a thin wrapper over
|
|
||||||
`selection` so existing call sites are untouched. `markable` =
|
|
||||||
`is_queable`. *Verifies:* the existing library visual-mode suite passes
|
|
||||||
unchanged (G11, G12).
|
|
||||||
- [x] **A3 — `carry_marks`** in `app/list.rs`: greedy in-order path match,
|
|
||||||
drop marks whose track is gone, all-false when nothing matches, tolerant
|
|
||||||
of a short `old_marked`. *Verifies:* the ten `carry_marks_tests` (G2, G3).
|
|
||||||
- [x] **A4 — Commit A.** *Merged into commit B:* group A alone leaves
|
|
||||||
`carry_marks` and `Register` unwired, and committing dead code for the next
|
|
||||||
commit to use is worse than one larger commit. The extraction is still
|
|
||||||
proven behaviour-preserving — the library's whole visual-mode suite passes
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
## B — Queue marks and the register (`cbd-tui`)
|
|
||||||
|
|
||||||
- [x] **B1 — `impl MarkedPane for Queue`** with `markable` = always, and a
|
|
||||||
`visual: Option<usize>` field on `Queue`. *Verifies:* G12.
|
|
||||||
- [x] **B2 — `update_queue` carries marks** through `carry_marks` instead of
|
|
||||||
rebuilding them as `false`: keep the previous path list and mark flags,
|
|
||||||
reconcile, apply. *Verifies:* G1, G2.
|
|
||||||
- [x] **B3 — `Register::set`** (replace the `todo!()`) and an `App.register`
|
|
||||||
field. *Verifies:* the register suite (G6, G10).
|
|
||||||
- [x] **B4 — `y` in both panes.** New `Action::LibraryYank` and
|
|
||||||
`Action::QueueYank`; each takes `selection()`, writes the register, and
|
|
||||||
clears the marks it consumed. *Verifies:* G5, G14, G15.
|
|
||||||
- [x] **B5 — `d` in the queue deletes every marked row** (cursor row when
|
|
||||||
none), writing them to the register first, in one `RemoveTracks` call with
|
|
||||||
positions from the current list. Replaces the `FIXME`. *Verifies:* G1,
|
|
||||||
G13, G14, G15.
|
|
||||||
- [x] **B6 — `c`/`C` fill the register** with the tracks they drop (`C`
|
|
||||||
everything, `c` everything but the current track). *Verifies:* G4.
|
|
||||||
- [x] **B7 — `p`/`P` paste the register.** `Action::QueuePaste` /
|
|
||||||
`QueuePasteBefore` send `InsertTracks(register.paths(), pos)` with `pos` =
|
|
||||||
cursor + 1 for `p`, cursor for `P`; a no-op on an empty register; the
|
|
||||||
register survives. `QueueInsertHere`'s old cross-pane behaviour is gone.
|
|
||||||
*Verifies:* G7, G8, G9.
|
|
||||||
- [x] **B8 — Queue visual mode in dispatch.** A `queue_move` wrapper
|
|
||||||
mirroring `library_move`, and the visual auto-leave guard generalized to
|
|
||||||
whichever pane is in visual mode with its own movement whitelist.
|
|
||||||
*Verifies:* G11.
|
|
||||||
- [x] **B9 — Bindings + help.** Queue scope gains `s`, `v`, `V`, `y`, `P`;
|
|
||||||
library gains `y`; `p`'s description changes. The help modal derives from
|
|
||||||
`BINDINGS`, so it follows. *Verifies:* G19, G20.
|
|
||||||
- [x] **B10 — Status feedback.** The pane title shows `— VISUAL` in the
|
|
||||||
queue as in the library, and a non-empty register is visible somewhere
|
|
||||||
(title or now-playing line) so paste is not blind. *Verifies:* G22 in
|
|
||||||
spirit; keeps G9's no-op explicable.
|
|
||||||
- [x] **B11 — Dispatch tests** for the gates named above. *Verifies:* G1,
|
|
||||||
G4, G5, 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.
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
# Plan — RSS provider
|
|
||||||
|
|
||||||
Executes `architecture/rss-provider.md` against `quality/rss-provider.md`.
|
|
||||||
Two commits: **(A)** the `rssdy` crate, **(B)** wiring (server feature,
|
|
||||||
clients, docs).
|
|
||||||
|
|
||||||
## A — The `rssdy` crate
|
|
||||||
|
|
||||||
- [x] **A1 — `api::Episode::key_for`** — `blake3(guid)` truncated to 16 hex
|
|
||||||
chars. *Verifies:* G7.
|
|
||||||
- [x] **A2 — `api::FeedFetcher`** — `reqwest` client with the timeout,
|
|
||||||
streaming the body while counting bytes so `max_feed_bytes` rejects
|
|
||||||
before buffering it whole; parse with `feed-rs`; map entries to
|
|
||||||
`Episode` (guid → key, title, author, published, `itunes:duration` where
|
|
||||||
present, first audio enclosure); skip entries with no enclosure; sort
|
|
||||||
newest first; truncate to `limit`. Errors carry no URL. *Verifies:* G1,
|
|
||||||
G15, G16, G17.
|
|
||||||
- [x] **A3 — `slugify` + de-duplication** at load and on rename/subscribe.
|
|
||||||
*Verifies:* G8.
|
|
||||||
- [x] **A4 — `parse_path`** for root/feed/episode, rejecting anything else.
|
|
||||||
*Verifies:* G10.
|
|
||||||
- [x] **A5 — `Client::with_api` / `init`** — resolve subscriptions with
|
|
||||||
slugs, drop entries without a url (warning), keep the settings for
|
|
||||||
write-back. No feeds still mounts. *Verifies:* G18.
|
|
||||||
- [x] **A6 — Listings** — `get_lib_root` lists subscriptions and is
|
|
||||||
creatable; `get_lib_node("/rss/<slug>")` fetches, replaces the memo, and
|
|
||||||
returns episodes as tracks (queueable, downloadable). *Verifies:* G3, G9.
|
|
||||||
- [x] **A7 — Memo** — bounded push/replace, listing-written, read by
|
|
||||||
`get_urls_for_track` / `get_metadata_for_track` /
|
|
||||||
`resolve_tracks_into`. *Verifies:* G4, G5.
|
|
||||||
- [x] **A8 — Subscription CRUD** — `create_lib_node` (URL → fetch → name
|
|
||||||
from feed title → add), `rename_lib_node`, `delete_lib_node`, and
|
|
||||||
`settings()` reserializing the live list. *Verifies:* 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,117 +1279,3 @@ provider-less and the `fs,opus` binaries under isolated XDG dirs: startup
|
||||||
logs the feature list, writes a default config listing only built-ins, and
|
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)
|
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).
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# Quality gates — queue selection, visual mode, and the register
|
|
||||||
|
|
||||||
Criteria an implementation of `architecture/queue-register.md` must satisfy.
|
|
||||||
Automated coverage lives in `cbd-tui/src/app/list.rs`
|
|
||||||
(`carry_marks_tests`), `cbd-tui/src/app/register.rs`,
|
|
||||||
`cbd-tui/src/app/mod.rs` (dispatch), and the mirrors of those in
|
|
||||||
`cbd-web/src/state.rs`.
|
|
||||||
|
|
||||||
## Data safety (highest priority)
|
|
||||||
|
|
||||||
- [ ] **G1 — A delete never removes an unmarked track.** The positions sent
|
|
||||||
to `Remove` are computed from the newest queue snapshot, never from an
|
|
||||||
index remembered across an update. *(tests: the `carry_marks` suite plus
|
|
||||||
`deleting_marked_rows_sends_their_current_positions`.)*
|
|
||||||
- [ ] **G2 — Marks follow their track across a snapshot.** Append,
|
|
||||||
removal before a mark, removal of the marked track, streaming resolve,
|
|
||||||
duplicate paths, wholesale replacement, empty queue, and a first snapshot
|
|
||||||
all behave as the `carry_marks` tests state. Divergence clears rather than
|
|
||||||
guesses.
|
|
||||||
- [ ] **G3 — No panic on any mark bookkeeping.** Mismatched lengths, an
|
|
||||||
empty queue, a cursor past the end, and marks on a filtered-out row are
|
|
||||||
all handled without indexing panics. *(test:
|
|
||||||
`a_short_mark_vector_is_tolerated`, plus the empty-queue dispatch tests.)*
|
|
||||||
- [ ] **G4 — `c`/`C` write the register before clearing.** Clearing 200
|
|
||||||
tracks is recoverable with one `p`. *(tests:
|
|
||||||
`clear_fills_the_register_with_what_it_dropped`.)*
|
|
||||||
|
|
||||||
## Register semantics
|
|
||||||
|
|
||||||
- [ ] **G5 — Only `y`, `d`, `c`, `C` write the register.** Marking (`s`),
|
|
||||||
visual mode, cursor movement, `a`/`L`/`Enter`, `w`/`W`, and every playback
|
|
||||||
action leave it untouched. *(test:
|
|
||||||
`marking_and_queueing_do_not_touch_the_register`.)*
|
|
||||||
- [ ] **G6 — A write overwrites.** One slot, no history, no numbered
|
|
||||||
registers. An empty write clears it.
|
|
||||||
- [ ] **G7 — Paste leaves the register intact**, so the same yank can be
|
|
||||||
pasted repeatedly. *(test: `pasting_twice_inserts_twice`.)*
|
|
||||||
- [ ] **G8 — `p` inserts after the cursor, `P` before it**, and `d`
|
|
||||||
followed by `P` restores the deleted rows to their original positions.
|
|
||||||
*(tests: `paste_after_and_before_use_the_right_position`,
|
|
||||||
`delete_then_paste_before_restores_the_positions`.)*
|
|
||||||
- [ ] **G9 — Paste on an empty register is a no-op**, not an empty `Insert`
|
|
||||||
round trip. *(test: `pasting_an_empty_register_sends_nothing`.)*
|
|
||||||
- [ ] **G10 — The register holds paths, and only labels for display.** No
|
|
||||||
label ever reaches the server; `Insert` carries paths in yank order.
|
|
||||||
|
|
||||||
## Selection and visual mode
|
|
||||||
|
|
||||||
- [ ] **G11 — The queue's `s`, `v`, `V` behave exactly as the library's**:
|
|
||||||
`v` and `V` are the same action, entering anchors at the cursor and marks
|
|
||||||
it, movement paints the anchored range so moving back reverses, `Esc` and
|
|
||||||
any non-movement action leave visual mode while keeping the marks.
|
|
||||||
*(tests: the queue mirrors of the library's visual-mode suite.)*
|
|
||||||
- [ ] **G12 — Every queue row is markable**; the library's `is_queable`
|
|
||||||
gate does not leak into the queue (queue rows carry `is_queable: false`).
|
|
||||||
Conversely the library still refuses to mark an unqueueable row.
|
|
||||||
- [ ] **G13 — Marks live on the full list.** A marked row hidden by the `/`
|
|
||||||
filter still counts for `y` and `d`. *(test:
|
|
||||||
`a_filtered_out_marked_row_is_still_deleted`.)*
|
|
||||||
- [ ] **G14 — `y` and `d` consume the marks they acted on** (the pane comes
|
|
||||||
back unmarked), as queueing already does.
|
|
||||||
- [ ] **G15 — With nothing marked, `y`/`d` act on the cursor row only** —
|
|
||||||
today's `d` behaviour is preserved.
|
|
||||||
- [ ] **G16 — Mark and visual logic exists once per client.** `Library` and
|
|
||||||
`Queue` both go through `MarkedPane`; no second copy of the paint rule.
|
|
||||||
|
|
||||||
## Both clients
|
|
||||||
|
|
||||||
- [ ] **G17 — `cbd-web` reaches parity in the same change**: library visual
|
|
||||||
mode (which it lacks today), queue marks, queue visual mode, `y`, `p`,
|
|
||||||
`P`, and `c`/`C` filling the register.
|
|
||||||
- [ ] **G18 — The web client's queue marks reconcile by the same rule.**
|
|
||||||
It owns no queue list, so its marks sit beside the server signal; the
|
|
||||||
reconciliation cases from G2 are tested there too.
|
|
||||||
- [ ] **G19 — Both key tables and both help overlays list the new keys**,
|
|
||||||
and `cbd-web`'s keymap tests keep their lockstep with its `HELP` table.
|
|
||||||
- [ ] **G20 — The TUI's binding table stays the single source of truth**
|
|
||||||
for its help modal (no hand-maintained duplicate list).
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [ ] **G21 — `p`'s changed meaning is documented as a change**, in the
|
|
||||||
book's TUI page, the web page, and the README walkthrough: it pastes the
|
|
||||||
register, and the browse→insert-here flow is now `y` then `p`.
|
|
||||||
- [ ] **G22 — The register's limits are stated**: per client, in memory,
|
|
||||||
one slot, and paste re-resolves so a stale path may not come back.
|
|
||||||
- [ ] **G23 — Key tables match the code.** Every new binding appears in
|
|
||||||
`docs/src/clients/tui.md`, `docs/src/clients/web.md` where it lists keys,
|
|
||||||
and the README's usage section.
|
|
||||||
|
|
||||||
## Hard rules
|
|
||||||
|
|
||||||
- [ ] **G24 — No panics** on user input or empty state anywhere in the new
|
|
||||||
paths; `todo!()`/`unimplemented!()` from the stub stage are all gone.
|
|
||||||
- [ ] **G25 — No new server surface.** No `.proto` change, no new RPC, no
|
|
||||||
change to `crabidy-server`. The feature is two clients and the two
|
|
||||||
existing calls (`Insert`, `Remove`).
|
|
||||||
- [ ] **G26 — Clippy clean under `-D warnings`** for `cbd-tui` and for
|
|
||||||
`cbd-web` on **both** the native and `wasm32-unknown-unknown` targets
|
|
||||||
(`mod app` only compiles for wasm), and the trunk bundle still builds.
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# Quality gates — RSS provider
|
|
||||||
|
|
||||||
Criteria an implementation of `architecture/rss-provider.md` must satisfy.
|
|
||||||
Automated coverage lives in `rssdy/src/tests.rs` (unit, over a fake
|
|
||||||
`Feeds`), plus the settings/toggle tests in `crabidy-server`.
|
|
||||||
|
|
||||||
## Secrets (hard rule — highest priority)
|
|
||||||
|
|
||||||
- [ ] **G1 — A feed URL never reaches a log, an error, or a path.**
|
|
||||||
`FeedEntry`, `Settings`, `Client`, and `FeedFetcher` `Debug` output
|
|
||||||
redact urls (manual impls, not derived). `FetchError` variants carry
|
|
||||||
status/parse context only. No `debug!`/`warn!`/`error!` call takes a
|
|
||||||
feed or enclosure URL. *(tests: `settings_debug_redacts_urls`,
|
|
||||||
`feed_entry_debug_redacts_the_url`.)*
|
|
||||||
- [ ] **G2 — No path contains a URL.** Subscription paths are
|
|
||||||
`/rss/<slug>`, episode paths `/rss/<slug>/<16 hex>`. A tokened URL
|
|
||||||
therefore cannot leak through the UI, the queue, `current`, or a
|
|
||||||
bookmark toml. *(tests: `episode_paths_carry_only_slug_and_key`.)*
|
|
||||||
|
|
||||||
## Freshness — the point of the feature
|
|
||||||
|
|
||||||
- [ ] **G3 — Listing a subscription always fetches.** Two listings of the
|
|
||||||
same node cause two fetches, and the second reflects a feed that gained
|
|
||||||
an episode in between. *(test: `every_listing_refetches_the_feed`.)*
|
|
||||||
- [ ] **G4 — The memo is written by listings and read only by track
|
|
||||||
lookups.** After one listing, resolving every episode of that feed
|
|
||||||
costs **no** further fetches; a track lookup with a cold memo fetches
|
|
||||||
once. *(tests: `track_lookups_reuse_the_listing_fetch`,
|
|
||||||
`a_cold_memo_fetches_once`.)*
|
|
||||||
- [ ] **G5 — The memo is bounded** to `MEMO_CAPACITY` feeds, evicting the
|
|
||||||
oldest, so subscribing to 100 feeds cannot grow it without limit.
|
|
||||||
*(test: `the_memo_evicts_beyond_its_capacity`.)*
|
|
||||||
- [ ] **G6 — `/rss` is in `MUTABLE_ROOTS` in both clients**
|
|
||||||
(`cbd-tui/src/rpc.rs`, `cbd-web/src/state.rs`), or the client cache
|
|
||||||
defeats G3. *(tests: the existing cacheability tests, extended.)*
|
|
||||||
|
|
||||||
## Identity and paths
|
|
||||||
|
|
||||||
- [ ] **G7 — An episode key is stable** across restarts and builds:
|
|
||||||
`blake3(guid)[..16]`, and the guid falls back to the enclosure URL only
|
|
||||||
when the feed omits one. *(tests: `episode_keys_are_stable_and_short`,
|
|
||||||
`a_feed_without_guids_keys_on_the_enclosure_url`.)*
|
|
||||||
- [ ] **G8 — Slugs are derived, bounded, and de-duplicated.** Lowercased,
|
|
||||||
non-alphanumerics folded to single dashes, trimmed, length-capped; an
|
|
||||||
empty or all-punctuation name yields `feed`; two subscriptions that
|
|
||||||
slug alike get `-2`, `-3`. *(tests: `slugify_folds_and_bounds`,
|
|
||||||
`duplicate_slugs_get_a_suffix`.)*
|
|
||||||
- [ ] **G9 — `provider_item_id` is the guid**, so the content store
|
|
||||||
de-duplicates captures of the same episode across visits and feeds.
|
|
||||||
*(test: `tracks_carry_the_guid_as_provider_item_id`.)*
|
|
||||||
- [ ] **G10 — Foreign and malformed paths are typed errors**, never
|
|
||||||
panics: an unknown slug, a wrong-provider path, a bad episode key, and
|
|
||||||
a path with too many segments all yield `MalformedPath` (or
|
|
||||||
`NotSupported` for edits). *(test: `foreign_and_malformed_paths_reject`.)*
|
|
||||||
|
|
||||||
## Subscriptions (`%`, `e`, `d`)
|
|
||||||
|
|
||||||
- [ ] **G11 — `%` on `/rss` takes a URL, fetches it once, and names the
|
|
||||||
subscription from the feed's own title**, de-duplicating the slug. The
|
|
||||||
new node is returned. *(test: `subscribing_names_from_the_feed_title`.)*
|
|
||||||
- [ ] **G12 — `%` with a non-URL or an unfetchable URL fails with a typed
|
|
||||||
error and adds nothing.** *(test: `subscribing_rejects_bad_input`.)*
|
|
||||||
- [ ] **G13 — `e` renames and the path follows the new slug**; `d`
|
|
||||||
removes the subscription and no audio. Both return what a client should
|
|
||||||
display next. *(tests: `renaming_moves_the_slug`,
|
|
||||||
`unsubscribing_removes_only_the_entry`.)*
|
|
||||||
- [ ] **G14 — Every subscription change round-trips through `rss.toml`**
|
|
||||||
— `settings()` reserializes the live list so the server's write-back
|
|
||||||
persists it, and reloading yields the same subscriptions. *(test:
|
|
||||||
`subscription_changes_round_trip_through_toml`.)*
|
|
||||||
|
|
||||||
## Robustness (hard rules)
|
|
||||||
|
|
||||||
- [ ] **G15 — No panics on feed data.** A missing enclosure, empty title,
|
|
||||||
unparseable date, absent duration, or an entry with no guid is skipped
|
|
||||||
or degraded — never a panic and never a failed listing. Only an
|
|
||||||
unfetchable/unparseable *feed* errors, and it fails that node only.
|
|
||||||
*(tests: `malformed_entries_are_skipped`, `backend_failures_are_typed`.)*
|
|
||||||
- [ ] **G16 — Bounded network use.** Every request carries
|
|
||||||
`call_timeout_secs`; a body over `max_feed_bytes` is rejected without
|
|
||||||
being buffered whole; listings are capped at `episodes_per_feed`.
|
|
||||||
- [ ] **G17 — Newest first**, by publication date descending where dates
|
|
||||||
parse, feed order preserved otherwise (stable sort). *(test:
|
|
||||||
`episodes_are_newest_first`.)*
|
|
||||||
- [ ] **G18 — A provider failure never takes the server down.** A feed
|
|
||||||
that cannot be fetched fails its own node; `init` with no feeds still
|
|
||||||
mounts an empty, creatable `/rss`. *(test: `no_feeds_still_mounts`.)*
|
|
||||||
|
|
||||||
## Wiring
|
|
||||||
|
|
||||||
- [ ] **G19 — Behind the `rss` cargo feature**, default on: `dep:rssdy`,
|
|
||||||
a `BUILT_IN_PROVIDERS` entry, an `ALL_PROVIDERS` name, a
|
|
||||||
`ProviderToggles` field, a mount registration, and a `check-features`
|
|
||||||
row. The provider-less and each-provider-alone builds stay clean.
|
|
||||||
- [ ] **G20 — Documented.** A `docs/src/providers/rss.md` page (tree,
|
|
||||||
subscribing, premium feeds and what "not cached" means, every config
|
|
||||||
option), an `rssdy/README.md`, links from the providers index,
|
|
||||||
`SUMMARY.md`, `config.md`, and the root README's tree and config table.
|
|
||||||
- [ ] **G21 — Clippy clean under `-D warnings`** for the workspace, fmt
|
|
||||||
clean, and no `todo!()` from the stub stage left.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "rssdy"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
async-trait.workspace = true
|
|
||||||
# Stable across builds, so an episode key stays valid in a saved bookmark
|
|
||||||
# (architecture/rss-provider.md D4 — `DefaultHasher` explicitly is not).
|
|
||||||
blake3.workspace = true
|
|
||||||
crabidy-core.workspace = true
|
|
||||||
# One parser for RSS 2.0/1.0/0.x, Atom and JSON Feed, with the iTunes
|
|
||||||
# extensions mapped (D-parser). Podcast feeds in the wild are not uniform.
|
|
||||||
feed-rs.workspace = true
|
|
||||||
reqwest.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
tokio = { workspace = true, features = ["time", "sync"] }
|
|
||||||
toml.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { workspace = true, features = ["full"] }
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
# rssdy — the RSS podcast provider
|
|
||||||
|
|
||||||
Mounts the podcast feeds you subscribe to at **`/rss`** in the crabidy
|
|
||||||
library. Works with plain public feeds and with **premium per-subscriber
|
|
||||||
URLs**, and always shows the newest episodes.
|
|
||||||
|
|
||||||
## Logging in
|
|
||||||
|
|
||||||
There is no login. A feed is either public or its URL *is* the credential —
|
|
||||||
a paid podcast gives you a URL with a token in it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://feeds.economist.com/v1/rss/the-economist-podcasts/f74365b0-…
|
|
||||||
```
|
|
||||||
|
|
||||||
Anyone holding that URL has your subscription, so the provider treats it as a
|
|
||||||
secret: it is redacted from `Debug`, never logged, and **never put in a
|
|
||||||
library path**. Paths carry a slug of the subscription name and a hash of the
|
|
||||||
episode id instead — paths are displayed, logged, and persisted into saved
|
|
||||||
queues and bookmarks, so a URL in one would leak everywhere.
|
|
||||||
|
|
||||||
Keep `rss.toml` private; it holds the URLs in cleartext, like every other
|
|
||||||
provider credential.
|
|
||||||
|
|
||||||
## Subscribing
|
|
||||||
|
|
||||||
Either edit the config (below), or from a client: press `%` on `/rss`, paste
|
|
||||||
the feed URL, and the provider fetches it once, names the subscription from
|
|
||||||
the feed's own title, and writes it into `rss.toml`. `e` renames a
|
|
||||||
subscription (its path changes with the name), `d` unsubscribes — that only
|
|
||||||
removes the config entry, never audio you captured from it.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
```text
|
|
||||||
/rss
|
|
||||||
├── <subscription> one node per feed, newest episodes first
|
|
||||||
│ └── <episode> a track; audio is the feed's enclosure URL
|
|
||||||
└── …
|
|
||||||
```
|
|
||||||
|
|
||||||
A subscription is queueable and downloadable, so you can queue or `W`-capture
|
|
||||||
a whole feed. Episodes stream directly from the enclosure URL — no sidecar,
|
|
||||||
no helper binary.
|
|
||||||
|
|
||||||
**Nothing is cached.** Every visit to a subscription fetches the feed, so an
|
|
||||||
episode published a minute ago is there. One memo exists purely so that
|
|
||||||
listing a feed and then queueing its 40 episodes costs one fetch rather than
|
|
||||||
41: it is written by listings and read only when resolving a track, so it can
|
|
||||||
never make a listing stale.
|
|
||||||
|
|
||||||
Episode paths are `blake3(guid)[..16]`, which is stable as long as the
|
|
||||||
publisher keeps its guids stable. Two consequences worth knowing:
|
|
||||||
|
|
||||||
- A bookmark (`w`) to an episode that has since aged out of the feed cannot
|
|
||||||
resolve — there is nothing left to look up. Capture (`W`) what you want to
|
|
||||||
keep.
|
|
||||||
- A publisher that regenerates guids on every fetch invalidates bookmarks.
|
|
||||||
Nothing can be done about that from this side.
|
|
||||||
|
|
||||||
## Configuration — `~/.config/crabidy/rss.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# One table per subscription. `name` is yours and decides the path slug;
|
|
||||||
# duplicates get a numeric suffix.
|
|
||||||
[[feeds]]
|
|
||||||
name = "The Economist Podcasts"
|
|
||||||
url = "https://feeds.economist.com/v1/rss/…"
|
|
||||||
|
|
||||||
[[feeds]]
|
|
||||||
name = "Cautionary Tales"
|
|
||||||
url = "https://feeds.example.org/cautionary-tales"
|
|
||||||
|
|
||||||
# Optional, defaults shown.
|
|
||||||
# episodes_per_feed = 200 # episodes listed per feed
|
|
||||||
# call_timeout_secs = 30 # per-request timeout
|
|
||||||
# max_feed_bytes = 8388608 # 8 MiB cap on a feed body
|
|
||||||
```
|
|
||||||
|
|
||||||
A feed entry with no url is skipped with a warning. No feeds at all is fine:
|
|
||||||
`/rss` mounts empty and you can `%` into it.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed via
|
|
||||||
[`feed-rs`](https://crates.io/crates/feed-rs).
|
|
||||||
- `itunes:duration` is parsed here rather than taken from `feed-rs`, which
|
|
||||||
reads it as NPT — a format with no `MM:SS` form, so `53:25` came back as 53
|
|
||||||
*seconds*. `S`, `MM:SS` and `HH:MM:SS` all work now.
|
|
||||||
- Build the server without the `rss` cargo feature to leave this provider out
|
|
||||||
of the binary entirely.
|
|
||||||
396
rssdy/src/api.rs
396
rssdy/src/api.rs
|
|
@ -1,396 +0,0 @@
|
||||||
//! The feed HTTP seam.
|
|
||||||
//!
|
|
||||||
//! All network access goes through the [`Feeds`] trait so the provider's
|
|
||||||
//! subscription, slug, identity, and ordering logic is unit-tested with a fake
|
|
||||||
//! and no network (architecture/rss-provider.md). [`FeedFetcher`] is the
|
|
||||||
//! production `reqwest` + `feed-rs` implementation; tests supply their own
|
|
||||||
//! [`Feeds`].
|
|
||||||
//!
|
|
||||||
//! **Every feed URL here is a credential.** A premium podcast URL embeds a
|
|
||||||
//! per-subscriber token, so URLs are redacted from `Debug`, never logged, and
|
|
||||||
//! never carried in an error message. Enclosure URLs get the same treatment —
|
|
||||||
//! they can be signed too.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fmt::{self, Debug};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
/// A typed feed failure. Carries only non-secret context — never a feed URL,
|
|
||||||
/// which would put a subscriber token in the logs.
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum FetchError {
|
|
||||||
/// Transport, timeout, or a non-success status.
|
|
||||||
#[error("feed request failed: {0}")]
|
|
||||||
Http(String),
|
|
||||||
/// The body was not a feed we could parse.
|
|
||||||
#[error("feed could not be parsed: {0}")]
|
|
||||||
Parse(String),
|
|
||||||
/// The body exceeded `max_feed_bytes` (D6).
|
|
||||||
#[error("feed is larger than the {limit} byte limit")]
|
|
||||||
TooLarge { limit: u64 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One episode, normalized out of whatever feed dialect produced it.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Episode {
|
|
||||||
/// The publisher's `<guid>`, or the enclosure URL when the feed omits one.
|
|
||||||
/// Keys [`Self::key`] and becomes `Track.provider_item_id` so the content
|
|
||||||
/// store de-duplicates captures of the same episode.
|
|
||||||
pub guid: String,
|
|
||||||
/// Stable short path segment: `blake3(guid)[..16]` (D4).
|
|
||||||
pub key: String,
|
|
||||||
pub title: String,
|
|
||||||
/// Show/author, used as the track artist. Empty when the feed omits it.
|
|
||||||
pub author: String,
|
|
||||||
/// Publication instant as a unix timestamp, when the feed has a parseable
|
|
||||||
/// date. `None` keeps the entry in feed order (D9).
|
|
||||||
pub published: Option<i64>,
|
|
||||||
pub duration_secs: Option<u32>,
|
|
||||||
/// The audio to play. **Secret-ish**: never logged.
|
|
||||||
pub enclosure_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Episode {
|
|
||||||
/// The path segment for `guid` — `blake3` truncated to 16 hex chars, which
|
|
||||||
/// is short, readable, and stable across builds and restarts (D4).
|
|
||||||
pub fn key_for(guid: &str) -> String {
|
|
||||||
blake3::hash(guid.as_bytes()).to_hex()[..KEY_HEX_LEN].to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hex characters of the guid hash kept in a path segment. 64 bits is far more
|
|
||||||
/// than enough to keep one feed's episodes apart, and stays readable.
|
|
||||||
const KEY_HEX_LEN: usize = 16;
|
|
||||||
|
|
||||||
/// A parsed feed: the publisher's own title (used to name a new subscription,
|
|
||||||
/// D5) plus its episodes.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Feed {
|
|
||||||
pub title: String,
|
|
||||||
pub episodes: Vec<Episode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the provider does over the network.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Feeds: Debug + Send + Sync {
|
|
||||||
/// Fetches and parses `url`, returning at most `limit` episodes, newest
|
|
||||||
/// first. Bounded by a per-request timeout and a byte cap; a malformed
|
|
||||||
/// entry is skipped rather than failing the feed.
|
|
||||||
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The production implementation: `reqwest` for transport, `feed-rs` for
|
|
||||||
/// parsing.
|
|
||||||
pub struct FeedFetcher {
|
|
||||||
http: reqwest::Client,
|
|
||||||
max_bytes: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Debug for FeedFetcher {
|
|
||||||
/// No URLs are held here, but the impl is explicit so it stays that way.
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("FeedFetcher")
|
|
||||||
.field("max_bytes", &self.max_bytes)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FeedFetcher {
|
|
||||||
/// `timeout` bounds every request; `max_bytes` caps a response body so a
|
|
||||||
/// broken or hostile feed cannot exhaust memory (D6).
|
|
||||||
pub fn new(timeout: Duration, max_bytes: u64) -> Result<Self, FetchError> {
|
|
||||||
let http = reqwest::Client::builder()
|
|
||||||
.timeout(timeout)
|
|
||||||
.user_agent(USER_AGENT)
|
|
||||||
.build()
|
|
||||||
// `without_url` so a builder error cannot echo a URL.
|
|
||||||
.map_err(|err| FetchError::Http(err.without_url().to_string()))?;
|
|
||||||
Ok(Self { http, max_bytes })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the body in chunks, refusing to buffer more than `max_bytes` —
|
|
||||||
/// the cap has to bite *before* the allocation, not after (D6).
|
|
||||||
async fn bounded_body(&self, mut response: reqwest::Response) -> Result<Vec<u8>, FetchError> {
|
|
||||||
let mut body: Vec<u8> = Vec::new();
|
|
||||||
while let Some(chunk) = response
|
|
||||||
.chunk()
|
|
||||||
.await
|
|
||||||
.map_err(|err| FetchError::Http(err.without_url().to_string()))?
|
|
||||||
{
|
|
||||||
if body.len() as u64 + chunk.len() as u64 > self.max_bytes {
|
|
||||||
return Err(FetchError::TooLarge {
|
|
||||||
limit: self.max_bytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
body.extend_from_slice(&chunk);
|
|
||||||
}
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sent on every feed request. Some publishers reject an empty agent.
|
|
||||||
const USER_AGENT: &str = concat!("crabidy/", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Feeds for FeedFetcher {
|
|
||||||
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
|
|
||||||
// `without_url` on every error: a premium feed URL is a credential and
|
|
||||||
// reqwest puts the URL in its Display output by default (G1).
|
|
||||||
let response = self
|
|
||||||
.http
|
|
||||||
.get(url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| FetchError::Http(err.without_url().to_string()))?;
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(FetchError::Http(status.to_string()));
|
|
||||||
}
|
|
||||||
let body = self.bounded_body(response).await?;
|
|
||||||
// Recovered before parsing, because feed-rs loses this field (see
|
|
||||||
// `itunes_durations`).
|
|
||||||
let durations = itunes_durations(&body);
|
|
||||||
let parsed = feed_rs::parser::parse(body.as_slice())
|
|
||||||
.map_err(|err| FetchError::Parse(err.to_string()))?;
|
|
||||||
Ok(Feed {
|
|
||||||
title: parsed
|
|
||||||
.title
|
|
||||||
.map(|t| t.content)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string(),
|
|
||||||
episodes: episodes_from(parsed.entries, limit, &durations),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalizes feed entries into episodes: newest first, capped, and skipping
|
|
||||||
/// anything unplayable. A single bad entry never fails the feed (G15).
|
|
||||||
fn episodes_from(
|
|
||||||
entries: Vec<feed_rs::model::Entry>,
|
|
||||||
limit: usize,
|
|
||||||
durations: &HashMap<String, u32>,
|
|
||||||
) -> Vec<Episode> {
|
|
||||||
let mut episodes: Vec<Episode> = entries
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
let enclosure_url = audio_url(&entry)?;
|
|
||||||
// RSS `<guid>` normalizes into `id`; a feed without one keys on
|
|
||||||
// the enclosure URL instead (D4). The URL stays out of the path —
|
|
||||||
// only its hash is used.
|
|
||||||
let guid = if entry.id.trim().is_empty() {
|
|
||||||
enclosure_url.clone()
|
|
||||||
} else {
|
|
||||||
entry.id.clone()
|
|
||||||
};
|
|
||||||
let title = entry
|
|
||||||
.title
|
|
||||||
.as_ref()
|
|
||||||
.map(|t| t.content.trim().to_string())
|
|
||||||
.filter(|t| !t.is_empty())
|
|
||||||
.unwrap_or_else(|| "(untitled)".to_string());
|
|
||||||
let duration_secs = durations
|
|
||||||
.get(&guid)
|
|
||||||
.copied()
|
|
||||||
.or_else(|| duration_of(&entry));
|
|
||||||
Some(Episode {
|
|
||||||
key: Episode::key_for(&guid),
|
|
||||||
guid,
|
|
||||||
title,
|
|
||||||
author: entry
|
|
||||||
.authors
|
|
||||||
.first()
|
|
||||||
.map(|p| p.name.trim().to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
published: entry.published.or(entry.updated).map(|d| d.timestamp()),
|
|
||||||
// Our own `itunes:duration` wins over feed-rs's NPT parse.
|
|
||||||
duration_secs,
|
|
||||||
enclosure_url,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// Newest first where dates parse; a stable sort keeps dateless feeds in
|
|
||||||
// publication order (D9).
|
|
||||||
// Descending: `Reverse` keeps clippy's sort_by_key form while
|
|
||||||
// sorting newest first.
|
|
||||||
episodes.sort_by_key(|e| std::cmp::Reverse(e.published));
|
|
||||||
episodes.truncate(limit);
|
|
||||||
episodes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The first playable audio URL of an entry: a media enclosure, else a link
|
|
||||||
/// that advertises audio (feed dialects disagree about where it goes).
|
|
||||||
fn audio_url(entry: &feed_rs::model::Entry) -> Option<String> {
|
|
||||||
let media = entry.media.iter().flat_map(|m| m.content.iter());
|
|
||||||
// Prefer something explicitly typed as audio, then any media url at all.
|
|
||||||
let typed = media.clone().find(|c| {
|
|
||||||
c.url.is_some()
|
|
||||||
&& c.content_type
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|ct| ct.to_string().starts_with("audio"))
|
|
||||||
});
|
|
||||||
if let Some(url) = typed.and_then(|c| c.url.as_ref()) {
|
|
||||||
return Some(url.to_string());
|
|
||||||
}
|
|
||||||
if let Some(url) = media.filter_map(|c| c.url.as_ref()).next() {
|
|
||||||
return Some(url.to_string());
|
|
||||||
}
|
|
||||||
entry
|
|
||||||
.links
|
|
||||||
.iter()
|
|
||||||
.find(|l| {
|
|
||||||
l.rel.as_deref() == Some("enclosure")
|
|
||||||
|| l.media_type
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|ct| ct.starts_with("audio"))
|
|
||||||
})
|
|
||||||
.map(|l| l.href.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `itunes:duration` (or a media duration) in whole seconds.
|
|
||||||
fn duration_of(entry: &feed_rs::model::Entry) -> Option<u32> {
|
|
||||||
entry
|
|
||||||
.media
|
|
||||||
.iter()
|
|
||||||
.find_map(|m| {
|
|
||||||
m.duration
|
|
||||||
.or_else(|| m.content.iter().find_map(|c| c.duration))
|
|
||||||
})
|
|
||||||
.map(|d| d.as_secs().min(u32::MAX as u64) as u32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `itunes:duration` values recovered from the raw feed, keyed by both the
|
|
||||||
/// item's guid and its enclosure URL (the two things an [`Episode`] can key
|
|
||||||
/// on).
|
|
||||||
///
|
|
||||||
/// Why this exists: feed-rs parses `<itunes:duration>` with an **NPT** parser,
|
|
||||||
/// and NPT has no `MM:SS` form. For `<itunes:duration>53:25</itunes:duration>`
|
|
||||||
/// its fallback regex matches the leading number, so a 53-minute episode comes
|
|
||||||
/// back as 53 *seconds*. `1:20:40` happens to parse correctly. The iTunes spec
|
|
||||||
/// allows `S`, `MM:SS` and `HH:MM:SS`, so the field is recovered here and
|
|
||||||
/// overrides what feed-rs produced.
|
|
||||||
///
|
|
||||||
/// This is a deliberately shallow scan, not a second feed parser: it walks
|
|
||||||
/// item chunks and pulls three optional strings. Anything it cannot find falls
|
|
||||||
/// back to feed-rs's value.
|
|
||||||
fn itunes_durations(body: &[u8]) -> HashMap<String, u32> {
|
|
||||||
let text = String::from_utf8_lossy(body);
|
|
||||||
let mut found = HashMap::new();
|
|
||||||
// `<item` for RSS, `<entry` for Atom; the first chunk is the feed header.
|
|
||||||
for chunk in text.split("<item").flat_map(|c| c.split("<entry")).skip(1) {
|
|
||||||
let Some(duration) = tag_text(chunk, "itunes:duration").and_then(parse_itunes_duration)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(guid) = tag_text(chunk, "guid") {
|
|
||||||
found.insert(guid, duration);
|
|
||||||
}
|
|
||||||
if let Some(url) = enclosure_url_attr(chunk) {
|
|
||||||
found.insert(url, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
found
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The text of the first `<name …>text</name>` in `chunk`, trimmed.
|
|
||||||
fn tag_text(chunk: &str, name: &str) -> Option<String> {
|
|
||||||
let open = chunk.find(&format!("<{name}"))?;
|
|
||||||
let after_open = chunk[open..].find('>')? + open + 1;
|
|
||||||
let close = chunk[after_open..].find(&format!("</{name}>"))? + after_open;
|
|
||||||
let inner = chunk[after_open..close]
|
|
||||||
.trim()
|
|
||||||
.trim_start_matches("<![CDATA[")
|
|
||||||
.trim_end_matches("]]>")
|
|
||||||
.trim();
|
|
||||||
(!inner.is_empty()).then(|| inner.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `url="…"` of the chunk's `<enclosure>`, XML-unescaped for `&` — 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
617
rssdy/src/lib.rs
617
rssdy/src/lib.rs
|
|
@ -1,617 +0,0 @@
|
||||||
//! RSS provider: **subscribe to podcast feeds and play their episodes**.
|
|
||||||
//! Mounted at [`PROVIDER_ROOT`] (architecture/rss-provider.md).
|
|
||||||
//!
|
|
||||||
//! Unlike `/fyyd` (discovery through a public directory) this is a
|
|
||||||
//! *subscription* provider: the feeds you name in `rss.toml` are the tree, and
|
|
||||||
//! it works with private, per-subscriber URLs.
|
|
||||||
//!
|
|
||||||
//! Two properties shape everything here:
|
|
||||||
//!
|
|
||||||
//! 1. **A feed URL is a credential.** A premium URL embeds a subscriber
|
|
||||||
//! token, so it is redacted from `Debug`, never logged, and never put in a
|
|
||||||
//! library path — paths carry a slug of the subscription name and a hash
|
|
||||||
//! of the episode guid instead (D1/D4). Paths are displayed, logged, and
|
|
||||||
//! persisted into saved queues and bookmarks; URLs must not be.
|
|
||||||
//! 2. **Feed content is never cached.** A listing always fetches, so a newly
|
|
||||||
//! published episode appears on the next visit (D2). The one memo that
|
|
||||||
//! exists is written by listings and read only when resolving a track
|
|
||||||
//! (D3), so queueing 40 episodes costs one fetch, not 40.
|
|
||||||
|
|
||||||
use std::fmt;
|
|
||||||
use std::sync::RwLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
|
||||||
use crabidy_core::{ProviderClient, ProviderError};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
pub mod api;
|
|
||||||
use api::{Episode, Feeds};
|
|
||||||
|
|
||||||
/// First path segment owned by this provider.
|
|
||||||
pub const PROVIDER_ROOT: &str = "/rss";
|
|
||||||
|
|
||||||
/// Default cap on episodes listed per feed.
|
|
||||||
pub const DEFAULT_EPISODES_PER_FEED: usize = 200;
|
|
||||||
/// Default per-request timeout in seconds.
|
|
||||||
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
|
|
||||||
/// Default cap on a feed body, in bytes (8 MiB). A feed larger than this is a
|
|
||||||
/// publishing bug, not something to load into memory (D6).
|
|
||||||
pub const DEFAULT_MAX_FEED_BYTES: u64 = 8 * 1024 * 1024;
|
|
||||||
/// How many feeds' episode lists the memo keeps (D3).
|
|
||||||
pub const MEMO_CAPACITY: usize = 8;
|
|
||||||
|
|
||||||
/// One subscription. `name` is the user's; the path uses a slug of it.
|
|
||||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
|
||||||
pub struct FeedEntry {
|
|
||||||
/// Display name, and the source of the path slug.
|
|
||||||
pub name: String,
|
|
||||||
/// The feed URL. **Secret**: redacted from `Debug`, never logged, never in
|
|
||||||
/// a path (D1).
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for FeedEntry {
|
|
||||||
/// Redacts `url` (hard rule: credentials never reach logs or reports).
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("FeedEntry")
|
|
||||||
.field("name", &self.name)
|
|
||||||
.field("url", &"<redacted>")
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provider settings, persisted as `rss.toml`. Everything is optional: with no
|
|
||||||
/// feeds the provider still mounts and shows an empty, creatable `/rss` you can
|
|
||||||
/// subscribe into with `%`.
|
|
||||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
|
||||||
pub struct Settings {
|
|
||||||
/// The subscriptions, in config order.
|
|
||||||
#[serde(default, rename = "feeds")]
|
|
||||||
pub feeds: Vec<FeedEntry>,
|
|
||||||
/// Episodes listed per feed. Default [`DEFAULT_EPISODES_PER_FEED`].
|
|
||||||
pub episodes_per_feed: Option<usize>,
|
|
||||||
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
|
|
||||||
pub call_timeout_secs: Option<u64>,
|
|
||||||
/// Cap on a feed body in bytes. Default [`DEFAULT_MAX_FEED_BYTES`].
|
|
||||||
pub max_feed_bytes: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for Settings {
|
|
||||||
/// The feed list is redacted wholesale — each entry hides its own url.
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("Settings")
|
|
||||||
.field("feeds", &self.feeds)
|
|
||||||
.field("episodes_per_feed", &self.episodes_per_feed)
|
|
||||||
.field("call_timeout_secs", &self.call_timeout_secs)
|
|
||||||
.field("max_feed_bytes", &self.max_feed_bytes)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A parsed `/rss/...` path. Subscriptions are addressed by slug, episodes by
|
|
||||||
/// the guid hash — never by URL (D1).
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
enum RssPath<'a> {
|
|
||||||
Root,
|
|
||||||
/// A subscription: `/rss/<slug>`.
|
|
||||||
Feed {
|
|
||||||
slug: &'a str,
|
|
||||||
},
|
|
||||||
/// One episode: `/rss/<slug>/<key>`.
|
|
||||||
Episode {
|
|
||||||
slug: &'a str,
|
|
||||||
key: &'a str,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Splits a `/rss/...` path into its recognized shape. Unknown shapes are
|
|
||||||
/// [`ProviderError::MalformedPath`].
|
|
||||||
fn parse_path(path: &str) -> Result<RssPath<'_>, ProviderError> {
|
|
||||||
if path == PROVIDER_ROOT {
|
|
||||||
return Ok(RssPath::Root);
|
|
||||||
}
|
|
||||||
let rest = path
|
|
||||||
.strip_prefix(PROVIDER_ROOT)
|
|
||||||
.and_then(|rest| rest.strip_prefix('/'))
|
|
||||||
.ok_or(ProviderError::MalformedPath)?;
|
|
||||||
let mut segments = rest.split('/');
|
|
||||||
let slug = segments
|
|
||||||
.next()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.ok_or(ProviderError::MalformedPath)?;
|
|
||||||
let key = segments.next();
|
|
||||||
if segments.next().is_some() {
|
|
||||||
// Nothing lives below an episode.
|
|
||||||
return Err(ProviderError::MalformedPath);
|
|
||||||
}
|
|
||||||
match key {
|
|
||||||
None => Ok(RssPath::Feed { slug }),
|
|
||||||
Some(key) if !key.is_empty() => Ok(RssPath::Episode { slug, key }),
|
|
||||||
Some(_) => Err(ProviderError::MalformedPath),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path slug for a subscription name: lowercased, non-alphanumerics folded
|
|
||||||
/// to single dashes, trimmed, and bounded. Empty or all-punctuation names fall
|
|
||||||
/// back to `feed`; the caller de-duplicates collisions with a numeric suffix
|
|
||||||
/// (D1).
|
|
||||||
pub fn slugify(name: &str) -> String {
|
|
||||||
let mut slug = String::with_capacity(name.len());
|
|
||||||
for ch in name.chars() {
|
|
||||||
if ch.is_ascii_alphanumeric() {
|
|
||||||
slug.push(ch.to_ascii_lowercase());
|
|
||||||
} else if ch.is_alphanumeric() {
|
|
||||||
// Keep non-ASCII letters readable rather than dropping them.
|
|
||||||
slug.extend(ch.to_lowercase());
|
|
||||||
} else if !slug.ends_with('-') {
|
|
||||||
slug.push('-');
|
|
||||||
}
|
|
||||||
if slug.len() >= SLUG_MAX_LEN {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let slug = slug.trim_matches('-').to_string();
|
|
||||||
if slug.is_empty() {
|
|
||||||
// A name of pure punctuation still needs an addressable path.
|
|
||||||
return "feed".to_string();
|
|
||||||
}
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cap on a generated slug, so a pathological feed title cannot produce an
|
|
||||||
/// unwieldy path.
|
|
||||||
const SLUG_MAX_LEN: usize = 48;
|
|
||||||
|
|
||||||
/// Resolves `name` to a slug not already in `taken`, suffixing `-2`, `-3`, …
|
|
||||||
fn unique_slug(name: &str, taken: &[String]) -> String {
|
|
||||||
let base = slugify(name);
|
|
||||||
if !taken.contains(&base) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
(2..)
|
|
||||||
.map(|n| format!("{base}-{n}"))
|
|
||||||
.find(|candidate| !taken.contains(candidate))
|
|
||||||
.unwrap_or(base)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `/rss` provider.
|
|
||||||
///
|
|
||||||
/// Holds the subscriptions (from config) and the episode memo (D3). No listing
|
|
||||||
/// cache: a visit fetches.
|
|
||||||
pub struct Client {
|
|
||||||
api: Box<dyn Feeds>,
|
|
||||||
/// Subscriptions with their resolved slugs, in config order.
|
|
||||||
subscriptions: RwLock<Vec<Subscription>>,
|
|
||||||
/// `slug -> episodes`, written by listings, read by track lookups, capped
|
|
||||||
/// at [`MEMO_CAPACITY`] feeds (D3).
|
|
||||||
memo: RwLock<Vec<(String, Vec<Episode>)>>,
|
|
||||||
settings: RwLock<Settings>,
|
|
||||||
episodes_per_feed: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A subscription with its path slug resolved (and de-duplicated).
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct Subscription {
|
|
||||||
slug: String,
|
|
||||||
name: String,
|
|
||||||
/// **Secret**: never logged, never in a path.
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for Client {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("Client")
|
|
||||||
.field("subscriptions", &self.subscriptions)
|
|
||||||
.field("episodes_per_feed", &self.episodes_per_feed)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Client {
|
|
||||||
/// Builds a client over an arbitrary [`Feeds`] backend — the seam the unit
|
|
||||||
/// tests use.
|
|
||||||
pub fn with_api(api: Box<dyn Feeds>, settings: Settings) -> Self {
|
|
||||||
let episodes_per_feed = settings
|
|
||||||
.episodes_per_feed
|
|
||||||
.filter(|n| *n > 0)
|
|
||||||
.unwrap_or(DEFAULT_EPISODES_PER_FEED);
|
|
||||||
let mut slugs: Vec<String> = Vec::new();
|
|
||||||
let mut subscriptions = Vec::new();
|
|
||||||
for entry in &settings.feeds {
|
|
||||||
if entry.url.trim().is_empty() {
|
|
||||||
// A feed we cannot fetch is not a subscription. Names only —
|
|
||||||
// never the url (G1).
|
|
||||||
warn!(name = %entry.name, "skipping rss feed with no url");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let slug = unique_slug(&entry.name, &slugs);
|
|
||||||
slugs.push(slug.clone());
|
|
||||||
subscriptions.push(Subscription {
|
|
||||||
slug,
|
|
||||||
name: entry.name.trim().to_string(),
|
|
||||||
url: entry.url.trim().to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
api,
|
|
||||||
subscriptions: RwLock::new(subscriptions),
|
|
||||||
memo: RwLock::new(Vec::new()),
|
|
||||||
settings: RwLock::new(settings),
|
|
||||||
episodes_per_feed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read access to the subscriptions, tolerating a poisoned lock rather than
|
|
||||||
/// panicking (a provider must not take the server down).
|
|
||||||
fn subs(&self) -> Vec<Subscription> {
|
|
||||||
match self.subscriptions.read() {
|
|
||||||
Ok(subs) => subs.clone(),
|
|
||||||
Err(poisoned) => poisoned.into_inner().clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The subscription owning `slug`, or `MalformedPath`.
|
|
||||||
fn subscription(&self, slug: &str) -> Result<Subscription, ProviderError> {
|
|
||||||
self.subs()
|
|
||||||
.into_iter()
|
|
||||||
.find(|sub| sub.slug == slug)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
warn!(slug, "no such rss subscription");
|
|
||||||
ProviderError::MalformedPath
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a feed and replaces its memo entry — the listing path (D3).
|
|
||||||
async fn fetch_and_memo(&self, sub: &Subscription) -> Result<Vec<Episode>, ProviderError> {
|
|
||||||
let feed = self
|
|
||||||
.api
|
|
||||||
.fetch(&sub.url, self.episodes_per_feed)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
// The error carries no url by construction (G1).
|
|
||||||
warn!(slug = %sub.slug, "cannot fetch rss feed: {err}");
|
|
||||||
ProviderError::FetchError
|
|
||||||
})?;
|
|
||||||
// The parser already sorts before truncating (it has to, or the cap
|
|
||||||
// would keep the wrong episodes), but the invariant belongs to the
|
|
||||||
// provider: any backend yields newest-first from here (G17).
|
|
||||||
let mut episodes = feed.episodes;
|
|
||||||
// Descending: `Reverse` keeps clippy's sort_by_key form while sorting
|
|
||||||
// newest first.
|
|
||||||
episodes.sort_by_key(|e| std::cmp::Reverse(e.published));
|
|
||||||
self.remember(&sub.slug, episodes.clone());
|
|
||||||
Ok(episodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces `slug`'s memo entry, evicting the oldest beyond
|
|
||||||
/// [`MEMO_CAPACITY`] (D3/G5).
|
|
||||||
fn remember(&self, slug: &str, episodes: Vec<Episode>) {
|
|
||||||
let mut memo = match self.memo.write() {
|
|
||||||
Ok(memo) => memo,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
memo.retain(|(known, _)| known != slug);
|
|
||||||
memo.push((slug.to_string(), episodes));
|
|
||||||
while memo.len() > MEMO_CAPACITY {
|
|
||||||
memo.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drops `slug`'s memo entry (it was renamed or unsubscribed).
|
|
||||||
fn forget(&self, slug: &str) {
|
|
||||||
let mut memo = match self.memo.write() {
|
|
||||||
Ok(memo) => memo,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
memo.retain(|(known, _)| known != slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The memoized episodes for `slug`, if a listing has fetched it.
|
|
||||||
fn recall(&self, slug: &str) -> Option<Vec<Episode>> {
|
|
||||||
let memo = match self.memo.read() {
|
|
||||||
Ok(memo) => memo,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
memo.iter()
|
|
||||||
.find(|(known, _)| known == slug)
|
|
||||||
.map(|(_, episodes)| episodes.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The episodes for `slug` from the memo, fetching only on a miss — the
|
|
||||||
/// track-lookup path (D3). Never consulted to answer a listing.
|
|
||||||
async fn episodes_for(&self, slug: &str) -> Result<Vec<Episode>, ProviderError> {
|
|
||||||
if let Some(episodes) = self.recall(slug) {
|
|
||||||
return Ok(episodes);
|
|
||||||
}
|
|
||||||
let sub = self.subscription(slug)?;
|
|
||||||
self.fetch_and_memo(&sub).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The episode `key` of `slug`, or `MalformedPath` when the feed no longer
|
|
||||||
/// lists it (an episode aged out, or a publisher changed its guid).
|
|
||||||
async fn episode(&self, slug: &str, key: &str) -> Result<Episode, ProviderError> {
|
|
||||||
self.episodes_for(slug)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.find(|episode| episode.key == key)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
warn!(slug, key, "episode is not in the feed any more");
|
|
||||||
ProviderError::MalformedPath
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One episode as a track. `provider_item_id` is the guid so the content
|
|
||||||
/// store de-duplicates captures of it (D4/G9).
|
|
||||||
fn track(&self, sub: &Subscription, episode: &Episode) -> Track {
|
|
||||||
Track {
|
|
||||||
path: format!("{}/{}/{}", PROVIDER_ROOT, sub.slug, episode.key),
|
|
||||||
artist: if episode.author.is_empty() {
|
|
||||||
sub.name.clone()
|
|
||||||
} else {
|
|
||||||
episode.author.clone()
|
|
||||||
},
|
|
||||||
title: episode.title.clone(),
|
|
||||||
duration: episode.duration_secs,
|
|
||||||
album: None,
|
|
||||||
is_skipped: false,
|
|
||||||
provider_item_id: episode.guid.clone(),
|
|
||||||
is_captured: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A subscription node listing `episodes` as tracks.
|
|
||||||
fn feed_node(&self, sub: &Subscription, episodes: &[Episode]) -> LibraryNode {
|
|
||||||
LibraryNode {
|
|
||||||
path: format!("{}/{}", PROVIDER_ROOT, sub.slug),
|
|
||||||
title: sub.name.clone(),
|
|
||||||
parent: Some(PROVIDER_ROOT.to_string()),
|
|
||||||
children: Vec::new(),
|
|
||||||
tracks: episodes.iter().map(|e| self.track(sub, e)).collect(),
|
|
||||||
is_queable: true,
|
|
||||||
is_creatable: false,
|
|
||||||
is_downloadable: true,
|
|
||||||
tracks_deletable: false,
|
|
||||||
is_captured: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrites the persisted feed list from the live subscriptions, so the
|
|
||||||
/// server's settings write-back keeps them (D5/G14).
|
|
||||||
fn sync_settings(&self) {
|
|
||||||
let feeds = self
|
|
||||||
.subs()
|
|
||||||
.into_iter()
|
|
||||||
.map(|sub| FeedEntry {
|
|
||||||
name: sub.name,
|
|
||||||
url: sub.url,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut settings = match self.settings.write() {
|
|
||||||
Ok(settings) => settings,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
settings.feeds = feeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializes the current settings, for the caller to persist after a
|
|
||||||
/// subscription changes (D5).
|
|
||||||
pub fn settings_toml(&self) -> String {
|
|
||||||
let settings = match self.settings.read() {
|
|
||||||
Ok(settings) => settings,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
toml::to_string_pretty(&*settings).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ProviderClient for Client {
|
|
||||||
/// Loads `rss.toml`. A feed entry without a url is skipped with a warning;
|
|
||||||
/// no feeds at all is fine — `/rss` mounts empty and creatable, so `%` can
|
|
||||||
/// subscribe into it.
|
|
||||||
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
|
||||||
let settings: Settings = toml::from_str(raw_toml_settings).map_err(|err| {
|
|
||||||
warn!("could not parse rss.toml: {err}");
|
|
||||||
ProviderError::Config("rss.toml is not valid TOML".to_string())
|
|
||||||
})?;
|
|
||||||
let timeout = Duration::from_secs(
|
|
||||||
settings
|
|
||||||
.call_timeout_secs
|
|
||||||
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
|
|
||||||
);
|
|
||||||
let max_bytes = settings.max_feed_bytes.unwrap_or(DEFAULT_MAX_FEED_BYTES);
|
|
||||||
let api = api::FeedFetcher::new(timeout, max_bytes).map_err(|err| {
|
|
||||||
warn!("cannot build the rss client: {err}");
|
|
||||||
ProviderError::Config(err.to_string())
|
|
||||||
})?;
|
|
||||||
Ok(Self::with_api(Box::new(api), settings))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn settings(&self) -> String {
|
|
||||||
self.settings_toml()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_track_path(&self, path: &str) -> bool {
|
|
||||||
matches!(parse_path(path), Ok(RssPath::Episode { .. }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The episode's enclosure URL. Never logged (G1).
|
|
||||||
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
|
||||||
let RssPath::Episode { slug, key } = parse_path(track_path)? else {
|
|
||||||
return Err(ProviderError::MalformedPath);
|
|
||||||
};
|
|
||||||
Ok(vec![self.episode(slug, key).await?.enclosure_url])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
|
||||||
let RssPath::Episode { slug, key } = parse_path(track_path)? else {
|
|
||||||
return Err(ProviderError::MalformedPath);
|
|
||||||
};
|
|
||||||
let sub = self.subscription(slug)?;
|
|
||||||
let episode = self.episode(slug, key).await?;
|
|
||||||
Ok(self.track(&sub, &episode))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `/rss` itself: one child per subscription, creatable so `%` can
|
|
||||||
/// subscribe with a pasted URL (D5).
|
|
||||||
fn get_lib_root(&self) -> LibraryNode {
|
|
||||||
let children = self
|
|
||||||
.subs()
|
|
||||||
.into_iter()
|
|
||||||
.map(|sub| LibraryNodeChild {
|
|
||||||
is_editable: true,
|
|
||||||
is_deletable: true,
|
|
||||||
is_downloadable: true,
|
|
||||||
..LibraryNodeChild::new(format!("{}/{}", PROVIDER_ROOT, sub.slug), sub.name, true)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
LibraryNode {
|
|
||||||
path: PROVIDER_ROOT.to_string(),
|
|
||||||
title: "rss".to_string(),
|
|
||||||
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
|
||||||
children,
|
|
||||||
tracks: Vec::new(),
|
|
||||||
is_queable: false,
|
|
||||||
is_creatable: true,
|
|
||||||
is_downloadable: false,
|
|
||||||
tracks_deletable: false,
|
|
||||||
is_captured: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A subscription listing **always fetches** — this is the freshness
|
|
||||||
/// guarantee (D2/G3).
|
|
||||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
|
||||||
match parse_path(path)? {
|
|
||||||
RssPath::Root => Ok(self.get_lib_root()),
|
|
||||||
RssPath::Feed { slug } => {
|
|
||||||
let sub = self.subscription(slug)?;
|
|
||||||
let episodes = self.fetch_and_memo(&sub).await?;
|
|
||||||
Ok(self.feed_node(&sub, &episodes))
|
|
||||||
}
|
|
||||||
// An episode is a track, not a node.
|
|
||||||
RssPath::Episode { .. } => Err(ProviderError::MalformedPath),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `%` on `/rss`: `title` is a pasted feed URL. Fetches it once to prove it
|
|
||||||
/// works and to learn the feed's own name, then subscribes (D5).
|
|
||||||
async fn create_lib_node(
|
|
||||||
&self,
|
|
||||||
parent_path: &str,
|
|
||||||
title: &str,
|
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
|
||||||
if parse_path(parent_path)? != RssPath::Root {
|
|
||||||
return Err(ProviderError::NotSupported);
|
|
||||||
}
|
|
||||||
let url = title.trim();
|
|
||||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
|
||||||
warn!("rss subscribe needs an http(s) feed url");
|
|
||||||
return Err(ProviderError::InvalidInput);
|
|
||||||
}
|
|
||||||
let feed = self
|
|
||||||
.api
|
|
||||||
.fetch(url, self.episodes_per_feed)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
warn!("cannot subscribe to that feed: {err}");
|
|
||||||
ProviderError::FetchError
|
|
||||||
})?;
|
|
||||||
let name = if feed.title.is_empty() {
|
|
||||||
"feed".to_string()
|
|
||||||
} else {
|
|
||||||
feed.title.clone()
|
|
||||||
};
|
|
||||||
let sub = {
|
|
||||||
let mut subs = match self.subscriptions.write() {
|
|
||||||
Ok(subs) => subs,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let taken: Vec<String> = subs.iter().map(|s| s.slug.clone()).collect();
|
|
||||||
let sub = Subscription {
|
|
||||||
slug: unique_slug(&name, &taken),
|
|
||||||
name,
|
|
||||||
url: url.to_string(),
|
|
||||||
};
|
|
||||||
subs.push(sub.clone());
|
|
||||||
sub
|
|
||||||
};
|
|
||||||
self.sync_settings();
|
|
||||||
self.remember(&sub.slug, feed.episodes.clone());
|
|
||||||
Ok(self.feed_node(&sub, &feed.episodes))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `e` on a subscription: rename it. The slug — and so the path — changes
|
|
||||||
/// with the name (G13).
|
|
||||||
async fn rename_lib_node(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
new_title: &str,
|
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
|
||||||
let RssPath::Feed { slug } = parse_path(path)? else {
|
|
||||||
return Err(ProviderError::NotSupported);
|
|
||||||
};
|
|
||||||
let name = new_title.trim();
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(ProviderError::InvalidInput);
|
|
||||||
}
|
|
||||||
let renamed = {
|
|
||||||
let mut subs = match self.subscriptions.write() {
|
|
||||||
Ok(subs) => subs,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let index = subs
|
|
||||||
.iter()
|
|
||||||
.position(|sub| sub.slug == slug)
|
|
||||||
.ok_or(ProviderError::MalformedPath)?;
|
|
||||||
let taken: Vec<String> = subs
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(i, _)| *i != index)
|
|
||||||
.map(|(_, sub)| sub.slug.clone())
|
|
||||||
.collect();
|
|
||||||
subs[index].name = name.to_string();
|
|
||||||
subs[index].slug = unique_slug(name, &taken);
|
|
||||||
subs[index].clone()
|
|
||||||
};
|
|
||||||
self.sync_settings();
|
|
||||||
// The old memo entry belongs to a slug that no longer exists.
|
|
||||||
if let Some(episodes) = self.recall(slug) {
|
|
||||||
self.forget(slug);
|
|
||||||
self.remember(&renamed.slug, episodes);
|
|
||||||
}
|
|
||||||
let episodes = self.episodes_for(&renamed.slug).await?;
|
|
||||||
Ok(self.feed_node(&renamed, &episodes))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `d` on a subscription: unsubscribe. Removes the config entry and touches
|
|
||||||
/// no audio — a capture made from it stays under `/crabidy` (G13).
|
|
||||||
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
|
||||||
let RssPath::Feed { slug } = parse_path(path)? else {
|
|
||||||
return Err(ProviderError::NotSupported);
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut subs = match self.subscriptions.write() {
|
|
||||||
Ok(subs) => subs,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let before = subs.len();
|
|
||||||
subs.retain(|sub| sub.slug != slug);
|
|
||||||
if subs.len() == before {
|
|
||||||
return Err(ProviderError::MalformedPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.sync_settings();
|
|
||||||
self.forget(slug);
|
|
||||||
// What the client should show next: the subscription list.
|
|
||||||
Ok(self.get_lib_root())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
include!("tests.rs");
|
|
||||||
}
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
// Unit tests over a fixture-driven `FakeFeeds` backend (no network). They
|
|
||||||
// define the provider's contract (quality/rss-provider.md): secret handling,
|
|
||||||
// freshness, the memo, episode identity, slugs, and subscription CRUD.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use api::{Feed, FetchError};
|
|
||||||
|
|
||||||
/// A feed backend with canned responses and a fetch counter, so tests can
|
|
||||||
/// assert *how many times* a feed was fetched — the freshness gates are about
|
|
||||||
/// exactly that.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct FakeFeeds {
|
|
||||||
feeds: HashMap<String, Feed>,
|
|
||||||
fetches: AtomicUsize,
|
|
||||||
fail: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FakeFeeds {
|
|
||||||
fn with(url: &str, feed: Feed) -> Self {
|
|
||||||
let mut feeds = HashMap::new();
|
|
||||||
feeds.insert(url.to_string(), feed);
|
|
||||||
Self {
|
|
||||||
feeds,
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn failing() -> Self {
|
|
||||||
Self {
|
|
||||||
fail: true,
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn count(&self) -> usize {
|
|
||||||
self.fetches.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lets a test keep a handle on the fake (to read its fetch counter) while the
|
|
||||||
/// client owns it as a `Box<dyn Feeds>`.
|
|
||||||
#[async_trait]
|
|
||||||
impl Feeds for Arc<FakeFeeds> {
|
|
||||||
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
|
|
||||||
self.as_ref().fetch(url, limit).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Feeds for FakeFeeds {
|
|
||||||
async fn fetch(&self, url: &str, limit: usize) -> Result<Feed, FetchError> {
|
|
||||||
self.fetches.fetch_add(1, Ordering::Relaxed);
|
|
||||||
if self.fail {
|
|
||||||
return Err(FetchError::Http("503 Service Unavailable".to_string()));
|
|
||||||
}
|
|
||||||
let mut feed = self
|
|
||||||
.feeds
|
|
||||||
.get(url)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| FetchError::Http("404 Not Found".to_string()))?;
|
|
||||||
feed.episodes.truncate(limit);
|
|
||||||
Ok(feed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn episode(guid: &str, title: &str, published: Option<i64>) -> Episode {
|
|
||||||
Episode {
|
|
||||||
key: Episode::key_for(guid),
|
|
||||||
guid: guid.to_string(),
|
|
||||||
title: title.to_string(),
|
|
||||||
author: String::new(),
|
|
||||||
published,
|
|
||||||
duration_secs: Some(120),
|
|
||||||
enclosure_url: format!("https://cdn.example.org/{guid}.mp3?token=SECRET"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn feed(title: &str, episodes: Vec<Episode>) -> Feed {
|
|
||||||
Feed {
|
|
||||||
title: title.to_string(),
|
|
||||||
episodes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A premium URL, the kind that must never escape into a path or a log.
|
|
||||||
const PREMIUM: &str = "https://feeds.economist.com/v1/rss/pods/f74365b0-TOKEN";
|
|
||||||
|
|
||||||
fn settings_with(name: &str, url: &str) -> Settings {
|
|
||||||
Settings {
|
|
||||||
feeds: vec![FeedEntry {
|
|
||||||
name: name.to_string(),
|
|
||||||
url: url.to_string(),
|
|
||||||
}],
|
|
||||||
..Settings::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn client_with(api: FakeFeeds, settings: Settings) -> Client {
|
|
||||||
Client::with_api(Box::new(api), settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A client plus a handle on its backend, for the fetch-count gates.
|
|
||||||
fn counted(api: FakeFeeds, settings: Settings) -> (Client, Arc<FakeFeeds>) {
|
|
||||||
let api = Arc::new(api);
|
|
||||||
(Client::with_api(Box::new(Arc::clone(&api)), settings), api)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Secrets (G1, G2) -----------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_debug_redacts_urls() {
|
|
||||||
let dumped = format!("{:?}", settings_with("The Economist", PREMIUM));
|
|
||||||
assert!(!dumped.contains("TOKEN"), "{dumped}");
|
|
||||||
assert!(!dumped.contains("economist.com"), "{dumped}");
|
|
||||||
assert!(dumped.contains("<redacted>"), "{dumped}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_entry_debug_redacts_the_url() {
|
|
||||||
let entry = FeedEntry {
|
|
||||||
name: "x".to_string(),
|
|
||||||
url: PREMIUM.to_string(),
|
|
||||||
};
|
|
||||||
let dumped = format!("{entry:?}");
|
|
||||||
assert!(!dumped.contains("TOKEN"), "{dumped}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn episode_paths_carry_only_slug_and_key() {
|
|
||||||
let api = FakeFeeds::with(
|
|
||||||
PREMIUM,
|
|
||||||
feed("The Economist", vec![episode("g1", "Ep", None)]),
|
|
||||||
);
|
|
||||||
let client = client_with(api, settings_with("The Economist", PREMIUM));
|
|
||||||
let node = client
|
|
||||||
.get_lib_node("/rss/the-economist")
|
|
||||||
.await
|
|
||||||
.expect("listing");
|
|
||||||
let path = &node.tracks[0].path;
|
|
||||||
assert_eq!(
|
|
||||||
path,
|
|
||||||
&format!("/rss/the-economist/{}", Episode::key_for("g1"))
|
|
||||||
);
|
|
||||||
// Neither the feed URL nor the enclosure token may appear in a path: paths
|
|
||||||
// are displayed, logged, and persisted into bookmarks.
|
|
||||||
assert!(!path.contains("TOKEN") && !path.contains("http"), "{path}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Freshness and the memo (G3, G4, G5) ----------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn every_listing_refetches_the_feed() {
|
|
||||||
let (client, api) = counted(
|
|
||||||
FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])),
|
|
||||||
settings_with("Econ", PREMIUM),
|
|
||||||
);
|
|
||||||
for expected in 1..=3 {
|
|
||||||
client.get_lib_node("/rss/econ").await.expect("listing");
|
|
||||||
// Listings never answer from the memo — a newly published episode has
|
|
||||||
// to show up on the next visit (G3).
|
|
||||||
assert_eq!(api.count(), expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After one listing, playing or queueing its episodes costs no more fetches
|
|
||||||
/// (G4) — this is what keeps "always fresh" from meaning "fetch 40 times".
|
|
||||||
#[tokio::test]
|
|
||||||
async fn track_lookups_reuse_the_listing_fetch() {
|
|
||||||
let episodes = vec![episode("g1", "One", Some(2)), episode("g2", "Two", Some(1))];
|
|
||||||
let (client, api) = counted(
|
|
||||||
FakeFeeds::with(PREMIUM, feed("Econ", episodes)),
|
|
||||||
settings_with("Econ", PREMIUM),
|
|
||||||
);
|
|
||||||
let node = client.get_lib_node("/rss/econ").await.expect("listing");
|
|
||||||
assert_eq!(api.count(), 1);
|
|
||||||
for track in &node.tracks {
|
|
||||||
client
|
|
||||||
.get_urls_for_track(&track.path)
|
|
||||||
.await
|
|
||||||
.expect("stream url");
|
|
||||||
client
|
|
||||||
.get_metadata_for_track(&track.path)
|
|
||||||
.await
|
|
||||||
.expect("metadata");
|
|
||||||
}
|
|
||||||
assert_eq!(api.count(), 1, "the memo served every track lookup");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A track lookup with a cold memo — a bookmark replayed after a restart —
|
|
||||||
/// fetches once and then serves the rest from the memo (G4).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_cold_memo_fetches_once() {
|
|
||||||
let (client, api) = counted(
|
|
||||||
FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])),
|
|
||||||
settings_with("Econ", PREMIUM),
|
|
||||||
);
|
|
||||||
let path = format!("/rss/econ/{}", Episode::key_for("g1"));
|
|
||||||
client.get_urls_for_track(&path).await.expect("stream url");
|
|
||||||
assert_eq!(api.count(), 1);
|
|
||||||
client.get_urls_for_track(&path).await.expect("stream url");
|
|
||||||
assert_eq!(api.count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An episode the feed no longer lists cannot be resolved — an aged-out
|
|
||||||
/// bookmark fails cleanly instead of panicking.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn an_episode_missing_from_the_feed_is_a_typed_error() {
|
|
||||||
let client = client_with(
|
|
||||||
FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("g1", "One", None)])),
|
|
||||||
settings_with("Econ", PREMIUM),
|
|
||||||
);
|
|
||||||
let gone = format!("/rss/econ/{}", Episode::key_for("aged-out"));
|
|
||||||
assert_eq!(
|
|
||||||
client.get_urls_for_track(&gone).await.err(),
|
|
||||||
Some(ProviderError::MalformedPath)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The memo is bounded, so subscribing to many feeds cannot grow it without
|
|
||||||
/// limit (G5).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn the_memo_evicts_beyond_its_capacity() {
|
|
||||||
let mut feeds = HashMap::new();
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
let total = MEMO_CAPACITY + 2;
|
|
||||||
for i in 0..total {
|
|
||||||
let url = format!("https://feed{i}.example/rss");
|
|
||||||
feeds.insert(
|
|
||||||
url.clone(),
|
|
||||||
feed(
|
|
||||||
&format!("Feed {i}"),
|
|
||||||
vec![episode(&format!("g{i}"), "Ep", None)],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
entries.push(FeedEntry {
|
|
||||||
name: format!("Feed {i}"),
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let api = Arc::new(FakeFeeds {
|
|
||||||
feeds,
|
|
||||||
..FakeFeeds::default()
|
|
||||||
});
|
|
||||||
let client = Client::with_api(
|
|
||||||
Box::new(Arc::clone(&api)),
|
|
||||||
Settings {
|
|
||||||
feeds: entries,
|
|
||||||
..Settings::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
for i in 0..total {
|
|
||||||
client
|
|
||||||
.get_lib_node(&format!("/rss/feed-{i}"))
|
|
||||||
.await
|
|
||||||
.expect("listing");
|
|
||||||
}
|
|
||||||
assert_eq!(api.count(), total);
|
|
||||||
// The oldest two entries were evicted, so their track lookups refetch…
|
|
||||||
let first = format!("/rss/feed-0/{}", Episode::key_for("g0"));
|
|
||||||
client.get_urls_for_track(&first).await.expect("url");
|
|
||||||
assert_eq!(api.count(), total + 1, "evicted feed had to be refetched");
|
|
||||||
// …while the newest is still memoized.
|
|
||||||
let last = format!(
|
|
||||||
"/rss/feed-{}/{}",
|
|
||||||
total - 1,
|
|
||||||
Episode::key_for(&format!("g{}", total - 1))
|
|
||||||
);
|
|
||||||
client.get_urls_for_track(&last).await.expect("url");
|
|
||||||
assert_eq!(api.count(), total + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Identity and slugs (G7, G8, G9) --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn episode_keys_are_stable_and_short() {
|
|
||||||
let key = Episode::key_for("https://example.org/?p=42");
|
|
||||||
assert_eq!(key.len(), 16);
|
|
||||||
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
// Stable across calls — a bookmark written today resolves tomorrow.
|
|
||||||
assert_eq!(key, Episode::key_for("https://example.org/?p=42"));
|
|
||||||
assert_ne!(key, Episode::key_for("https://example.org/?p=43"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slugify_folds_and_bounds() {
|
|
||||||
assert_eq!(slugify("The Economist Podcasts"), "the-economist-podcasts");
|
|
||||||
assert_eq!(slugify(" Hello, World! "), "hello-world");
|
|
||||||
assert_eq!(slugify("!!!"), "feed");
|
|
||||||
assert_eq!(slugify(""), "feed");
|
|
||||||
assert!(slugify(&"x".repeat(200)).len() <= 48);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn duplicate_slugs_get_a_suffix() {
|
|
||||||
let settings = Settings {
|
|
||||||
feeds: vec![
|
|
||||||
FeedEntry {
|
|
||||||
name: "News".to_string(),
|
|
||||||
url: "https://a.example/feed".to_string(),
|
|
||||||
},
|
|
||||||
FeedEntry {
|
|
||||||
name: "news".to_string(),
|
|
||||||
url: "https://b.example/feed".to_string(),
|
|
||||||
},
|
|
||||||
FeedEntry {
|
|
||||||
name: "N E W S".to_string(),
|
|
||||||
url: "https://c.example/feed".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
let client = client_with(FakeFeeds::default(), settings);
|
|
||||||
let slugs: Vec<String> = client
|
|
||||||
.get_lib_root()
|
|
||||||
.children
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| c.path)
|
|
||||||
.collect();
|
|
||||||
// "N E W S" folds each space to a dash, so it does not collide with
|
|
||||||
// "news" — the suffix only appears for a genuine collision.
|
|
||||||
assert_eq!(slugs, ["/rss/news", "/rss/news-2", "/rss/n-e-w-s"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn tracks_carry_the_guid_as_provider_item_id() {
|
|
||||||
let api = FakeFeeds::with(PREMIUM, feed("Econ", vec![episode("guid-42", "Ep", None)]));
|
|
||||||
let client = client_with(api, settings_with("Econ", PREMIUM));
|
|
||||||
let node = client.get_lib_node("/rss/econ").await.expect("listing");
|
|
||||||
assert_eq!(node.tracks[0].provider_item_id, "guid-42");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn episodes_are_newest_first() {
|
|
||||||
let api = FakeFeeds::with(
|
|
||||||
PREMIUM,
|
|
||||||
feed(
|
|
||||||
"Econ",
|
|
||||||
vec![
|
|
||||||
episode("old", "Old", Some(100)),
|
|
||||||
episode("new", "New", Some(300)),
|
|
||||||
episode("mid", "Mid", Some(200)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let client = client_with(api, settings_with("Econ", PREMIUM));
|
|
||||||
let node = client.get_lib_node("/rss/econ").await.expect("listing");
|
|
||||||
let titles: Vec<&str> = node.tracks.iter().map(|t| t.title.as_str()).collect();
|
|
||||||
assert_eq!(titles, ["New", "Mid", "Old"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Errors (G10, G15, G18) ----------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn foreign_and_malformed_paths_reject() {
|
|
||||||
let client = client_with(FakeFeeds::default(), settings_with("Econ", PREMIUM));
|
|
||||||
for path in ["/tidal/x", "/rss/", "/rss/econ/key/extra", "/rssx"] {
|
|
||||||
assert!(client.get_lib_node(path).await.is_err(), "{path}");
|
|
||||||
}
|
|
||||||
// An unknown subscription is not a panic.
|
|
||||||
assert_eq!(
|
|
||||||
client.get_lib_node("/rss/nope").await.err(),
|
|
||||||
Some(ProviderError::MalformedPath)
|
|
||||||
);
|
|
||||||
assert!(!client.is_track_path("/rss/econ"));
|
|
||||||
assert!(client.is_track_path("/rss/econ/abcdef"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn backend_failures_are_typed() {
|
|
||||||
let client = client_with(FakeFeeds::failing(), settings_with("Econ", PREMIUM));
|
|
||||||
assert_eq!(
|
|
||||||
client.get_lib_node("/rss/econ").await.err(),
|
|
||||||
Some(ProviderError::FetchError)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn no_feeds_still_mounts() {
|
|
||||||
let client = client_with(FakeFeeds::default(), Settings::default());
|
|
||||||
let root = client.get_lib_root();
|
|
||||||
assert!(root.children.is_empty());
|
|
||||||
// Empty but creatable, so `%` can subscribe into it.
|
|
||||||
assert!(root.is_creatable);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_feed_entry_without_a_url_is_skipped() {
|
|
||||||
let settings = Settings {
|
|
||||||
feeds: vec![
|
|
||||||
FeedEntry {
|
|
||||||
name: "broken".to_string(),
|
|
||||||
url: " ".to_string(),
|
|
||||||
},
|
|
||||||
FeedEntry {
|
|
||||||
name: "good".to_string(),
|
|
||||||
url: PREMIUM.to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
let client = client_with(FakeFeeds::default(), settings);
|
|
||||||
assert_eq!(client.get_lib_root().children.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Subscription CRUD (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