Add Tidal search as creatable library nodes
Pressing % inside /tidal/search opens an input line; the entered term becomes a tree node whose contents are the search results: track hits queueable in place, artist and album hits as canonical /tidal/artists paths. New CreateLibraryNode rpc + is_creatable flags (wire-compatible), ProviderClient::create_lib_node routed by prefix, percent-encoded term segments in crabidy-core, and a modal input overlay in the TUI with creatable nodes marked [%]. Search terms live in memory for the process lifetime; term nodes are deliberately not queueable so the resolve sweep cannot drag whole discographies into the queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8586194096
commit
d741523e53
|
|
@ -723,6 +723,7 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"clap-serde-derive",
|
"clap-serde-derive",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"percent-encoding",
|
||||||
"prost",
|
"prost",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ dirs = "6"
|
||||||
flume = "0.12"
|
flume = "0.12"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
|
percent-encoding = "2"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
# Search via creatable library nodes
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
Crabidy's library is a lazily-fetched tree served by providers; today it is
|
||||||
|
read-only and only exposes the user's favorites (playlists, followed artists).
|
||||||
|
There is no way to find anything new. The desired interface (set by the user)
|
||||||
|
is not a search dialog but **tree editing**: a search subtree under the
|
||||||
|
provider in which the user *creates* nodes. Pressing `%` inside a creatable
|
||||||
|
node prompts for a term; the term becomes a child node whose contents are the
|
||||||
|
search results. Creatable places must be visibly marked in the UI.
|
||||||
|
|
||||||
|
`tidaldy` already has a `search()` explorer stub (`search/artists`, response
|
||||||
|
dumped to the debug log), so the Tidal endpoint family
|
||||||
|
(`search/tracks|artists|albums`) is known to exist but its response shape is
|
||||||
|
unverified against our models.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Per the user's instruction, decisions from here on were taken autonomously and
|
||||||
|
are recorded with rationale below (Options → Decision per topic). The one
|
||||||
|
user-set constraint is the interface itself: creatable nodes in the tree, `%`
|
||||||
|
to create, terms become persistent nodes.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- **Search is per-provider.** The search subtree lives at `/tidal/search`, not
|
||||||
|
at the global root; a future provider brings its own. Consistent with prefix
|
||||||
|
routing.
|
||||||
|
- **Created nodes are ephemeral**, like every other piece of server state (the
|
||||||
|
queue dies with the process, `SaveQueue` is a stub). Terms live in memory in
|
||||||
|
the Tidal client for the process lifetime. Persistence is future work.
|
||||||
|
- **"Editable" is scoped down to "creatable" for v1.** The described flow only
|
||||||
|
needs create. The RPC is named so that `RemoveLibraryNode` /
|
||||||
|
`RenameLibraryNode` can join it later without redesign (open question below).
|
||||||
|
- **Search results are a view, not a copy.** Fetched fresh on node expansion,
|
||||||
|
like playlists and albums are today. No caching, no staleness handling.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
### 1. Mechanism: what does the wire contract look like?
|
||||||
|
|
||||||
|
**A — search-specific RPC** (`Search(query) → results`), TUI renders results
|
||||||
|
in an ad-hoc pane. Least server work, but it is exactly the interface the
|
||||||
|
user rejected: no tree nodes, nothing persistent to revisit, and a second
|
||||||
|
navigation model in the TUI.
|
||||||
|
|
||||||
|
**B — generic node creation (chosen)**: `CreateLibraryNode(parent_path,
|
||||||
|
title) → LibraryNode`, routed through `ProviderOrchestrator` by prefix like
|
||||||
|
`GetLibraryNode`. The provider decides what creation *means* under a given
|
||||||
|
parent; for `/tidal/search` the title is the search term. `LibraryNode` and
|
||||||
|
`LibraryNodeChild` gain `bool is_creatable` so any client can mark creatable
|
||||||
|
places generically. This matches the user's mental model, and the same RPC
|
||||||
|
later covers other creatable things (e.g. new playlists).
|
||||||
|
|
||||||
|
### 2. Result shape: how do results hang in the tree?
|
||||||
|
|
||||||
|
**A — full nesting**: results live entirely under
|
||||||
|
`/tidal/search/<term>/artists/<id>/<album>/<track>`. Self-contained
|
||||||
|
navigation, but `TidalPath` grows a parallel copy of every variant (search
|
||||||
|
versions of artist, album, both track kinds), `parse_path` needs recursive
|
||||||
|
suffix matching, and the same Tidal entity gets yet another path identity.
|
||||||
|
|
||||||
|
**B — canonical-path children (chosen)**: `/tidal/search/<term>` carries the
|
||||||
|
**track** results directly as its `tracks` (queueable in place, paths
|
||||||
|
`/tidal/search/<term>/<track-id>` — one new track variant), while artist and
|
||||||
|
album results are children whose paths are **canonical**
|
||||||
|
(`/tidal/artists/<id>`, `/tidal/artists/<artist>/<album>`). Diving into an
|
||||||
|
artist result reuses the existing artist/album/track machinery unchanged —
|
||||||
|
`get_lib_node(/tidal/artists/<id>)` already works for any id, favorite or not.
|
||||||
|
|
||||||
|
Cost of B, accepted and documented: after diving from a search result into an
|
||||||
|
artist, `h` (ascend) follows the canonical parent to `/tidal/artists`
|
||||||
|
(favorites), not back to the search node. This is the existing
|
||||||
|
"identity is context-dependent" trade-off from `architecture/overview.md`
|
||||||
|
pointing the other way. B wins because it adds two path variants instead of
|
||||||
|
five and cannot drift from the canonical browse behavior.
|
||||||
|
|
||||||
|
**Term nodes are not queueable** (decided during api-design): the server's
|
||||||
|
`resolve_tracks` breadth-first-sweeps *all* children of a queueable node, so a
|
||||||
|
queueable term node would turn "queue this search" into the top tracks *plus
|
||||||
|
every album of every artist result*. `is_queable = false` on the term node
|
||||||
|
keeps queueing explicit: individual track results (and any artist/album dived
|
||||||
|
into) queue normally. Queueing the term node itself yields nothing, exactly
|
||||||
|
like the existing category nodes (`playlists`, `artists`).
|
||||||
|
|
||||||
|
### 3. Term encoding in paths
|
||||||
|
|
||||||
|
Terms are user text; paths are `/`-separated and split by
|
||||||
|
`path_segments`. A term like `AC/DC` or `100%` must not corrupt the tree.
|
||||||
|
|
||||||
|
**A — reject problematic characters**: surprising, and `%`-in-term is a
|
||||||
|
plausible music query.
|
||||||
|
**B — percent-encode the segment (chosen)**: the term is percent-encoded into
|
||||||
|
the path segment (`AC/DC` → `AC%2FDC`); the node `title` keeps the raw term
|
||||||
|
for display. Round-trip helpers live next to the other path helpers in
|
||||||
|
`crabidy-core` (`encode_segment` / `decode_segment`), implemented with the
|
||||||
|
`percent-encoding` crate (tiny, maintained, already in the dependency tree via
|
||||||
|
`reqwest`/`url`).
|
||||||
|
|
||||||
|
Idempotency: creating an existing term returns the existing node rather than
|
||||||
|
erroring; empty/whitespace-only terms are rejected with
|
||||||
|
`ProviderError::InvalidInput` (new variant, also covers "this parent is not
|
||||||
|
creatable" as `NotSupported`).
|
||||||
|
|
||||||
|
### 4. TUI text entry
|
||||||
|
|
||||||
|
First text input in the TUI. **A — reuse the bindings table** by adding an
|
||||||
|
input scope: wrong tool — free text is not a finite set of chords.
|
||||||
|
**B — a modal input line (chosen)**: `%` (Library scope, only when the
|
||||||
|
*currently open* node — not the selected child — has `is_creatable`) opens a
|
||||||
|
one-line input overlay at the bottom of the library pane. While it is open the
|
||||||
|
bindings table is bypassed entirely except `Esc` (cancel) and `Enter`
|
||||||
|
(submit); every other printable char appends, `Backspace` deletes. No cursor
|
||||||
|
movement or paste handling in v1. On submit the TUI sends
|
||||||
|
`CreateNode(parent, term)`, and navigates into the returned node.
|
||||||
|
|
||||||
|
The modality mechanism generalizes what the help modal introduced: the
|
||||||
|
`(focus, help_open)` arguments of `bindings::lookup` become a single
|
||||||
|
`InputMode`-aware gate (exact shape decided in api-design). `%` itself is a
|
||||||
|
`BINDINGS` entry, so it shows up in the help modal like everything else.
|
||||||
|
|
||||||
|
### 5. Marking creatable nodes in the UI
|
||||||
|
|
||||||
|
Children with `is_creatable` render with a `%` marker suffix (e.g.
|
||||||
|
`search [%]`) in `COLOR_SECONDARY`, and when the open node itself is
|
||||||
|
creatable the pane title shows the hint (`search — % to add`). No new
|
||||||
|
keybinding needed to discover it: the marker is the affordance, the help modal
|
||||||
|
documents `%`.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
New/changed pieces, hatched by crate:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
tui: cbd-tui {
|
||||||
|
input: input overlay (new)
|
||||||
|
bindings: "% binding + input-mode gate"
|
||||||
|
library: creatable marker
|
||||||
|
}
|
||||||
|
|
||||||
|
core: crabidy-core {
|
||||||
|
proto: "proto: CreateLibraryNode rpc,\nis_creatable fields"
|
||||||
|
trait: "ProviderClient::create_lib_node (new)"
|
||||||
|
enc: "encode_segment / decode_segment (new)"
|
||||||
|
}
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
orchestrator: "ProviderOrchestrator:\nCreateLibraryNode command,\nprefix routing"
|
||||||
|
}
|
||||||
|
|
||||||
|
tidaldy: tidaldy {
|
||||||
|
search: "typed search requests (new)"
|
||||||
|
terms: "search_terms: RwLock (new state)"
|
||||||
|
paths: "TidalPath::Search, SearchTerm,\nSearchTrack (new variants)"
|
||||||
|
}
|
||||||
|
|
||||||
|
tui.input -> core.proto: CreateLibraryNode
|
||||||
|
core.proto -> server.orchestrator
|
||||||
|
server.orchestrator -> tidaldy.terms: create under /tidal/search
|
||||||
|
tidaldy.search -> core.trait: results as LibraryNode
|
||||||
|
```
|
||||||
|
|
||||||
|
Path shape after the change (green = track paths, dashed = canonical jumps):
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
tidal: "/tidal"
|
||||||
|
search: "/tidal/search (creatable)"
|
||||||
|
term: "/tidal/search/<term>"
|
||||||
|
strack: "/tidal/search/<term>/<track-id>" {
|
||||||
|
style.fill: "#e8f4e8"
|
||||||
|
}
|
||||||
|
artists: "/tidal/artists"
|
||||||
|
artist: "/tidal/artists/<id>"
|
||||||
|
album: "/tidal/artists/<id>/<album-id>"
|
||||||
|
|
||||||
|
tidal -> search
|
||||||
|
search -> term: created via %
|
||||||
|
term -> strack: track results
|
||||||
|
term -> artist: artist result (canonical path) {style.stroke-dash: 3}
|
||||||
|
term -> album: album result (canonical path) {style.stroke-dash: 3}
|
||||||
|
artists -> artist
|
||||||
|
artist -> album
|
||||||
|
```
|
||||||
|
|
||||||
|
Create flow:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
|
||||||
|
user: User
|
||||||
|
tui: cbd-tui
|
||||||
|
rpc: gRPC service
|
||||||
|
orch: ProviderOrchestrator
|
||||||
|
tidal: tidaldy::Client
|
||||||
|
api: Tidal REST API
|
||||||
|
|
||||||
|
user -> tui: "% inside /tidal/search"
|
||||||
|
tui -> tui: open input overlay (bindings bypassed)
|
||||||
|
user -> tui: types term, Enter
|
||||||
|
tui -> rpc: CreateLibraryNode(/tidal/search, term)
|
||||||
|
rpc -> orch: ProviderCommand::CreateLibraryNode
|
||||||
|
orch -> tidal: create_lib_node(parent, title)
|
||||||
|
tidal -> tidal: store term (idempotent)
|
||||||
|
tidal -> api: "search/tracks|artists|albums?query=term"
|
||||||
|
api -> tidal: results
|
||||||
|
tidal -> orch: "LibraryNode /tidal/search/<enc(term)>"
|
||||||
|
orch -> rpc: node
|
||||||
|
rpc -> tui: node
|
||||||
|
tui -> tui: ReplaceLibraryNode (navigate into results)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries and interfaces (high level)
|
||||||
|
|
||||||
|
- **proto**: `rpc CreateLibraryNode(CreateLibraryNodeRequest) returns
|
||||||
|
(CreateLibraryNodeResponse)`; request = `parent_path`, `title`; response =
|
||||||
|
the created `LibraryNode`. `LibraryNode.is_creatable = 7`,
|
||||||
|
`LibraryNodeChild.is_creatable = 4` — additive, wire-compatible.
|
||||||
|
- **ProviderClient**: new required method `create_lib_node(parent_path,
|
||||||
|
title) -> Result<LibraryNode, ProviderError>`. `ProviderError` gains
|
||||||
|
`NotSupported` and `InvalidInput` variants. The orchestrator's synthetic
|
||||||
|
root and the server's mock provider return `NotSupported`.
|
||||||
|
- **ProviderCommand**: new `CreateLibraryNode { parent_path, title,
|
||||||
|
result_tx }`, same bounded(1)-reply pattern and 30s-timeout discipline as
|
||||||
|
the existing commands.
|
||||||
|
- **tidaldy**: `TidalPath::{Search, SearchTerm, SearchTrack}`; typed
|
||||||
|
`search_tracks/search_artists/search_albums` (first page, limit 20 per
|
||||||
|
category — search is exploratory, not exhaustive; `make_paginated_request`'s
|
||||||
|
fetch-everything loop is wrong for it); `search_terms:
|
||||||
|
RwLock<Vec<String>>` following the existing login-state locking discipline
|
||||||
|
(never held across await).
|
||||||
|
- **cbd-tui**: input overlay state on `App`; `MessageFromUi::CreateNode`;
|
||||||
|
`rpc.rs` client method; `%` in `BINDINGS` (Library scope); creatable marker
|
||||||
|
in the library list rendering.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Unverified search response shape.** Our `Track`/`Artist`/`Album` models
|
||||||
|
may not match `search/*` payloads (the explorer stub exists precisely
|
||||||
|
because this was unexplored). Mitigation: first implement task runs the
|
||||||
|
explorer request against the live API and locks the models down; if the
|
||||||
|
shapes differ, only `tidaldy::models` grows search-specific wrappers.
|
||||||
|
- **Trait change ripples.** Adding a required `ProviderClient` method touches
|
||||||
|
the server's mock provider and any test doubles. Deliberate: a default
|
||||||
|
"not supported" impl would hide missing implementations silently.
|
||||||
|
- **Input overlay vs. terminal reality.** Paste arrives as a burst of char
|
||||||
|
events (fine: they append), IME composition is untested. v1 accepts this.
|
||||||
|
- **Term nodes are invisible to other clients** until they re-fetch
|
||||||
|
`/tidal/search` — there is no library update stream. Accepted; browsing is
|
||||||
|
pull-based today.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Delete/rename of created nodes (`RemoveLibraryNode`?) — the RPC family and
|
||||||
|
`is_creatable` flag anticipate it; not in v1.
|
||||||
|
- Should created terms persist across restarts (they'd fit a small TOML next
|
||||||
|
to the token store)? Deferred with the queue-persistence question.
|
||||||
|
- Combined-search ranking: v1 shows tracks, then artists, then albums in
|
||||||
|
fixed category order; relevance interleaving would need the combined
|
||||||
|
`search` endpoint.
|
||||||
|
|
@ -55,6 +55,11 @@ pub enum Action {
|
||||||
LibraryQueueAppend,
|
LibraryQueueAppend,
|
||||||
LibraryQueueReplace,
|
LibraryQueueReplace,
|
||||||
LibraryToggleMark,
|
LibraryToggleMark,
|
||||||
|
/// Open the input overlay to create a child of the currently open
|
||||||
|
/// library node. No-op unless that node `is_creatable` (e.g.
|
||||||
|
/// /tidal/search). While the overlay is open, keys bypass this table
|
||||||
|
/// entirely (see `App::handle_input_key`).
|
||||||
|
LibraryCreateNode,
|
||||||
// Queue pane
|
// Queue pane
|
||||||
QueueInsertHere,
|
QueueInsertHere,
|
||||||
QueueFirst,
|
QueueFirst,
|
||||||
|
|
@ -239,6 +244,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::LibraryToggleMark,
|
action: Action::LibraryToggleMark,
|
||||||
description: "Mark/unmark selection",
|
description: "Mark/unmark selection",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('%'),
|
||||||
|
action: Action::LibraryCreateNode,
|
||||||
|
description: "Create node here (e.g. search term)",
|
||||||
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
|
|
@ -545,6 +557,21 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percent_creates_only_in_the_library() {
|
||||||
|
// '%' is shifted on most layouts; both modifier reports must match.
|
||||||
|
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(KeyCode::Char('%'), mods)),
|
||||||
|
Some(Action::LibraryCreateNode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('%'), mods)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn control_must_match_exactly() {
|
fn control_must_match_exactly() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,17 @@ use ratatui::{
|
||||||
use crabidy_core::proto::crabidy::LibraryNode;
|
use crabidy_core::proto::crabidy::LibraryNode;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
|
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY,
|
||||||
|
COLOR_PRIMARY_DARK, COLOR_SECONDARY,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
title: String,
|
title: String,
|
||||||
path: String,
|
path: String,
|
||||||
|
/// Whether children may be created under the currently open node
|
||||||
|
/// (mirrors `LibraryNode.is_creatable`). Drives the `%` action and the
|
||||||
|
/// pane-title hint.
|
||||||
|
is_creatable: bool,
|
||||||
list: Vec<UiItem>,
|
list: Vec<UiItem>,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
|
|
@ -30,6 +35,7 @@ impl Library {
|
||||||
Self {
|
Self {
|
||||||
title: "Library".to_string(),
|
title: "Library".to_string(),
|
||||||
path: crabidy_core::ROOT_PATH.to_string(),
|
path: crabidy_core::ROOT_PATH.to_string(),
|
||||||
|
is_creatable: false,
|
||||||
list: Vec::new(),
|
list: Vec::new(),
|
||||||
list_state: ListState::default(),
|
list_state: ListState::default(),
|
||||||
positions: HashMap::new(),
|
positions: HashMap::new(),
|
||||||
|
|
@ -37,6 +43,16 @@ impl Library {
|
||||||
tx,
|
tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path of the currently open node.
|
||||||
|
pub fn path(&self) -> &str {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the currently open node accepts child creation (`%`).
|
||||||
|
pub fn is_creatable(&self) -> bool {
|
||||||
|
self.is_creatable
|
||||||
|
}
|
||||||
pub fn get_selected(&self) -> Option<Vec<String>> {
|
pub fn get_selected(&self) -> Option<Vec<String>> {
|
||||||
if self.list.iter().any(|i| i.marked) {
|
if self.list.iter().any(|i| i.marked) {
|
||||||
return Some(
|
return Some(
|
||||||
|
|
@ -120,7 +136,9 @@ impl Library {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn update(&mut self, node: LibraryNode) {
|
pub fn update(&mut self, node: LibraryNode) {
|
||||||
if node.tracks.is_empty() && node.children.is_empty() {
|
// Creatable nodes (e.g. an empty search node) must be enterable even
|
||||||
|
// with nothing in them — the user goes there to create children.
|
||||||
|
if !node.is_creatable && node.tracks.is_empty() && node.children.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,9 +146,13 @@ impl Library {
|
||||||
self.path = node.path;
|
self.path = node.path;
|
||||||
self.title = node.title;
|
self.title = node.title;
|
||||||
self.parent = node.parent;
|
self.parent = node.parent;
|
||||||
|
self.is_creatable = node.is_creatable;
|
||||||
self.select(Some(self.prev_selected()));
|
self.select(Some(self.prev_selected()));
|
||||||
|
|
||||||
if !node.tracks.is_empty() {
|
// Most nodes carry either children or tracks; search term nodes
|
||||||
|
// carry both (track results + artist/album results), so the list is
|
||||||
|
// the concatenation. Tracks first: they are the primary search hits
|
||||||
|
// (architecture/search.md fixes the order tracks, artists, albums).
|
||||||
self.list = node
|
self.list = node
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -140,22 +162,17 @@ impl Library {
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: false,
|
marked: false,
|
||||||
is_queable: true,
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
})
|
})
|
||||||
.collect();
|
.chain(node.children.iter().map(|c| UiItem {
|
||||||
} else {
|
|
||||||
// if tracks not empty use tracks instead
|
|
||||||
self.list = node
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.map(|c| UiItem {
|
|
||||||
path: c.path.clone(),
|
path: c.path.clone(),
|
||||||
title: c.title.clone(),
|
title: c.title.clone(),
|
||||||
kind: UiItemKind::Node,
|
kind: UiItemKind::Node,
|
||||||
marked: false,
|
marked: false,
|
||||||
is_queable: c.is_queable,
|
is_queable: c.is_queable,
|
||||||
})
|
is_creatable: c.is_creatable,
|
||||||
|
}))
|
||||||
.collect();
|
.collect();
|
||||||
}
|
|
||||||
|
|
||||||
self.update_selection();
|
self.update_selection();
|
||||||
}
|
}
|
||||||
|
|
@ -165,15 +182,20 @@ impl Library {
|
||||||
.list
|
.list
|
||||||
.iter()
|
.iter()
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let text = if i.marked {
|
let mut text = if i.marked {
|
||||||
format!("* {}", i.title)
|
format!("* {}", i.title)
|
||||||
} else {
|
} else {
|
||||||
i.title.to_string()
|
i.title.to_string()
|
||||||
};
|
};
|
||||||
|
if i.is_creatable {
|
||||||
|
text.push_str(" [%]");
|
||||||
|
}
|
||||||
let style = if i.marked {
|
let style = if i.marked {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(COLOR_GREEN)
|
.fg(COLOR_GREEN)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if i.is_creatable {
|
||||||
|
Style::default().fg(COLOR_SECONDARY)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
@ -191,7 +213,11 @@ impl Library {
|
||||||
} else {
|
} else {
|
||||||
COLOR_PRIMARY_DARK
|
COLOR_PRIMARY_DARK
|
||||||
}))
|
}))
|
||||||
.title(self.title.clone()),
|
.title(if self.is_creatable {
|
||||||
|
format!("{} — % to add", self.title)
|
||||||
|
} else {
|
||||||
|
self.title.clone()
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ mod queue;
|
||||||
|
|
||||||
use flume::Sender;
|
use flume::Sender;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::Color,
|
style::{Color, Style},
|
||||||
|
widgets::{Clear, Paragraph},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -41,6 +42,8 @@ struct UiItem {
|
||||||
kind: UiItemKind,
|
kind: UiItemKind,
|
||||||
marked: bool,
|
marked: bool,
|
||||||
is_queable: bool,
|
is_queable: bool,
|
||||||
|
/// Children may be created under this item — rendered with a `%` marker.
|
||||||
|
is_creatable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
|
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
|
||||||
|
|
@ -62,6 +65,12 @@ pub enum MessageToUi {
|
||||||
// FIXME: Rename this
|
// FIXME: Rename this
|
||||||
pub enum MessageFromUi {
|
pub enum MessageFromUi {
|
||||||
GetLibraryNode(String),
|
GetLibraryNode(String),
|
||||||
|
/// Create a child node (e.g. a search term) under a creatable parent;
|
||||||
|
/// the orchestrator navigates the library into the created node.
|
||||||
|
CreateNode {
|
||||||
|
parent_path: String,
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
AppendTracks(Vec<String>),
|
AppendTracks(Vec<String>),
|
||||||
QueueTracks(Vec<String>),
|
QueueTracks(Vec<String>),
|
||||||
InsertTracks(Vec<String>, usize),
|
InsertTracks(Vec<String>, usize),
|
||||||
|
|
@ -90,11 +99,25 @@ pub enum DispatchResult {
|
||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// State of the one-line text input overlay (currently only node creation).
|
||||||
|
///
|
||||||
|
/// While this is `Some` on [`App`], key events bypass `bindings::lookup`
|
||||||
|
/// entirely and go to [`App::handle_input_key`].
|
||||||
|
pub struct InputState {
|
||||||
|
/// Creatable node the new child will be created under.
|
||||||
|
pub parent_path: String,
|
||||||
|
/// Text typed so far. Append-only editing: chars push, Backspace pops.
|
||||||
|
pub buffer: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub focus: UiFocus,
|
pub focus: UiFocus,
|
||||||
/// Whether the help modal is open. While true, `bindings::lookup` only
|
/// Whether the help modal is open. While true, `bindings::lookup` only
|
||||||
/// admits `Scope::Help` actions and `render` draws the overlay last.
|
/// admits `Scope::Help` actions and `render` draws the overlay last.
|
||||||
pub show_help: bool,
|
pub show_help: bool,
|
||||||
|
/// `Some` while the input overlay is open; takes precedence over the
|
||||||
|
/// bindings table (checked first in the event loop).
|
||||||
|
pub input: Option<InputState>,
|
||||||
pub library: Library,
|
pub library: Library,
|
||||||
pub now_playing: NowPlaying,
|
pub now_playing: NowPlaying,
|
||||||
pub queue: Queue,
|
pub queue: Queue,
|
||||||
|
|
@ -109,6 +132,7 @@ impl App {
|
||||||
App {
|
App {
|
||||||
focus: UiFocus::Library,
|
focus: UiFocus::Library,
|
||||||
show_help: false,
|
show_help: false,
|
||||||
|
input: None,
|
||||||
library,
|
library,
|
||||||
now_playing,
|
now_playing,
|
||||||
queue,
|
queue,
|
||||||
|
|
@ -116,6 +140,41 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles one key while the input overlay is open (`input.is_some()`).
|
||||||
|
///
|
||||||
|
/// `Esc` cancels, `Enter` submits a non-empty trimmed buffer as
|
||||||
|
/// `MessageFromUi::CreateNode` (empty submits just close), `Backspace`
|
||||||
|
/// pops, printable chars append. Everything else is ignored — the
|
||||||
|
/// bindings table is never consulted while the overlay is open.
|
||||||
|
pub fn handle_input_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
let Some(input) = self.input.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.input = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let title = input.buffer.trim().to_string();
|
||||||
|
if !title.is_empty() {
|
||||||
|
let _ = self.tx.send(MessageFromUi::CreateNode {
|
||||||
|
parent_path: input.parent_path.clone(),
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.input = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
input.buffer.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
input.buffer.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute one [`Action`] against the app: mutate UI state (focus, help
|
/// Execute one [`Action`] against the app: mutate UI state (focus, help
|
||||||
/// modal, list selections) or send the matching [`MessageFromUi`] via
|
/// modal, list selections) or send the matching [`MessageFromUi`] via
|
||||||
/// `tx`. Send failures are ignored like everywhere else in this module —
|
/// `tx`. Send failures are ignored like everywhere else in this module —
|
||||||
|
|
@ -161,6 +220,16 @@ impl App {
|
||||||
Action::LibraryQueueAppend => self.library.queue_append(),
|
Action::LibraryQueueAppend => self.library.queue_append(),
|
||||||
Action::LibraryQueueReplace => self.library.queue_replace(),
|
Action::LibraryQueueReplace => self.library.queue_replace(),
|
||||||
Action::LibraryToggleMark => self.library.toggle_mark(),
|
Action::LibraryToggleMark => self.library.toggle_mark(),
|
||||||
|
Action::LibraryCreateNode => {
|
||||||
|
// Opens the input overlay when the open library node is
|
||||||
|
// creatable; silently ignored otherwise.
|
||||||
|
if self.library.is_creatable() {
|
||||||
|
self.input = Some(InputState {
|
||||||
|
parent_path: self.library.path().to_string(),
|
||||||
|
buffer: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::QueueInsertHere => {
|
Action::QueueInsertHere => {
|
||||||
if let Some(selected) = self.queue.selected() {
|
if let Some(selected) = self.queue.selected() {
|
||||||
self.library.queue_insert(selected);
|
self.library.queue_insert(selected);
|
||||||
|
|
@ -214,6 +283,21 @@ impl App {
|
||||||
self.queue.render(f, right_side[0], queue_focused);
|
self.queue.render(f, right_side[0], queue_focused);
|
||||||
self.now_playing.render(f, right_side[1]);
|
self.now_playing.render(f, right_side[1]);
|
||||||
|
|
||||||
|
// The node-creation input: one line inside the bottom of the
|
||||||
|
// library pane, drawn over the list while open.
|
||||||
|
if let Some(input) = &self.input {
|
||||||
|
let area = main[0];
|
||||||
|
if area.height >= 3 && area.width >= 4 {
|
||||||
|
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
||||||
|
f.render_widget(Clear, line);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(format!("new node: {}▏", input.buffer))
|
||||||
|
.style(Style::default().fg(COLOR_SECONDARY)),
|
||||||
|
line,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The help modal renders last so it overlays every pane.
|
// The help modal renders last so it overlays every pane.
|
||||||
if self.show_help {
|
if self.show_help {
|
||||||
help::render(f);
|
help::render(f);
|
||||||
|
|
@ -286,6 +370,133 @@ mod tests {
|
||||||
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ChangeVolume(d)) if d < 0.0));
|
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ChangeVolume(d)) if d < 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn creatable_node(path: &str) -> LibraryNode {
|
||||||
|
LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: "search".to_string(),
|
||||||
|
children: Vec::new(),
|
||||||
|
parent: Some("/tidal".to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent {
|
||||||
|
crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(app: &mut App, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_creatable_nodes_are_enterable() {
|
||||||
|
// /tidal/search starts with no terms; the update must still apply,
|
||||||
|
// otherwise the user can never get inside to press '%'.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
assert_eq!(app.library.path(), "/tidal/search");
|
||||||
|
assert!(app.library.is_creatable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_node_only_opens_input_on_creatable_nodes() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
// The initial root is not creatable: '%' must be a no-op.
|
||||||
|
assert_eq!(
|
||||||
|
app.dispatch(Action::LibraryCreateNode),
|
||||||
|
DispatchResult::Continue
|
||||||
|
);
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
let _ = app.dispatch(Action::LibraryCreateNode);
|
||||||
|
let input = app.input.as_ref().expect("input overlay open");
|
||||||
|
assert_eq!(input.parent_path, "/tidal/search");
|
||||||
|
assert_eq!(input.buffer, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_appends_and_backspace_pops() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
let _ = app.dispatch(Action::LibraryCreateNode);
|
||||||
|
|
||||||
|
type_str(&mut app, "abba");
|
||||||
|
assert_eq!(app.input.as_ref().unwrap().buffer, "abba");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Backspace));
|
||||||
|
assert_eq!(app.input.as_ref().unwrap().buffer, "abb");
|
||||||
|
// Backspace on an empty buffer must not panic or close the overlay.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Backspace));
|
||||||
|
}
|
||||||
|
assert_eq!(app.input.as_ref().unwrap().buffer, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_cancels_without_sending() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
let _ = app.dispatch(Action::LibraryCreateNode);
|
||||||
|
type_str(&mut app, "abba");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Esc));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
assert!(rx.try_recv().is_err(), "cancel must not create anything");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_submits_trimmed_title_and_closes() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
let _ = app.dispatch(Action::LibraryCreateNode);
|
||||||
|
type_str(&mut app, " abba ");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Enter));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(MessageFromUi::CreateNode { parent_path, title }) => {
|
||||||
|
assert_eq!(parent_path, "/tidal/search");
|
||||||
|
assert_eq!(title, "abba");
|
||||||
|
}
|
||||||
|
other => panic!("expected CreateNode, got {:?}", other.is_ok()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_overlay_shows_the_buffer() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
let _ = app.dispatch(Action::LibraryCreateNode);
|
||||||
|
type_str(&mut app, "abba");
|
||||||
|
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
|
||||||
|
terminal.draw(|f| app.render(f)).expect("draw app");
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
let mut text = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
text.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
assert!(text.contains("new node: abba"), "overlay with buffer");
|
||||||
|
assert!(text.contains("% to add"), "creatable pane-title hint");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_empty_input_closes_without_sending() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.library.update(creatable_node("/tidal/search"));
|
||||||
|
let _ = app.dispatch(Action::LibraryCreateNode);
|
||||||
|
type_str(&mut app, " ");
|
||||||
|
app.handle_input_key(key(crossterm::event::KeyCode::Enter));
|
||||||
|
assert!(app.input.is_none());
|
||||||
|
assert!(rx.try_recv().is_err(), "whitespace-only must not create");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_clear_actions_carry_the_keep_current_flag() {
|
fn queue_clear_actions_carry_the_keep_current_flag() {
|
||||||
let (mut app, rx) = app();
|
let (mut app, rx) = app();
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ impl Queue {
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: false,
|
marked: false,
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,18 @@ async fn poll(
|
||||||
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
MessageFromUi::CreateNode { parent_path, title } => {
|
||||||
|
// Navigates the library into the created node on
|
||||||
|
// success; on failure the library stays where it is.
|
||||||
|
match rpc_client.create_library_node(&parent_path, &title).await {
|
||||||
|
Ok(node) => {
|
||||||
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(parent_path, title, "failed to create node: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
MessageFromUi::AppendTracks(uuids) => {
|
MessageFromUi::AppendTracks(uuids) => {
|
||||||
rpc_client.append_tracks(uuids).await?
|
rpc_client.append_tracks(uuids).await?
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +263,12 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
if event::poll(timeout).unwrap() {
|
if event::poll(timeout).unwrap() {
|
||||||
if let Event::Key(key) = event::read().unwrap() {
|
if let Event::Key(key) = event::read().unwrap() {
|
||||||
if key.kind == KeyEventKind::Press {
|
if key.kind == KeyEventKind::Press {
|
||||||
if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
// The input overlay is strictly modal: while it is open,
|
||||||
|
// keys edit the buffer and the bindings table (including
|
||||||
|
// quit) is unreachable.
|
||||||
|
if app.input.is_some() {
|
||||||
|
app.handle_input_key(key);
|
||||||
|
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {
|
||||||
if app.dispatch(action) == DispatchResult::Quit {
|
if app.dispatch(action) == DispatchResult::Quit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest,
|
||||||
ClearQueueRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse,
|
ClearQueueRequest, CreateLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest,
|
||||||
InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest,
|
||||||
RemoveRequest, ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest,
|
PrevRequest, QueueRequest, RemoveRequest, ReplaceRequest, RestartTrackRequest,
|
||||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest,
|
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||||
|
ToggleShuffleRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
use std::{collections::HashMap, error::Error, fmt, time::Duration};
|
||||||
|
|
@ -95,6 +96,33 @@ impl RpcClient {
|
||||||
Err(Box::new(RpcClientError::NotFound))
|
Err(Box::new(RpcClientError::NotFound))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a child node under a creatable parent and returns it.
|
||||||
|
///
|
||||||
|
/// Cache contract: the created node is inserted into
|
||||||
|
/// `library_node_cache`, and the *parent's* cache entry is evicted —
|
||||||
|
/// its child listing just changed and would otherwise be served stale
|
||||||
|
/// when the user ascends back to it.
|
||||||
|
pub async fn create_library_node(
|
||||||
|
&mut self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<&LibraryNode, Box<dyn Error>> {
|
||||||
|
let request = Request::new(CreateLibraryNodeRequest {
|
||||||
|
parent_path: parent_path.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
});
|
||||||
|
let response = self.client.create_library_node(request).await?;
|
||||||
|
let Some(node) = response.into_inner().node else {
|
||||||
|
return Err(Box::new(RpcClientError::NotFound));
|
||||||
|
};
|
||||||
|
// The parent's child listing just changed; a cached copy would hide
|
||||||
|
// the new node when the user ascends back to it.
|
||||||
|
self.library_node_cache.remove(parent_path);
|
||||||
|
let path = node.path.clone();
|
||||||
|
self.library_node_cache.insert(path.clone(), node);
|
||||||
|
Ok(&self.library_node_cache[&path])
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let append_request = Request::new(AppendRequest { paths });
|
let append_request = Request::new(AppendRequest { paths });
|
||||||
self.client.append(append_request).await?;
|
self.client.append(append_request).await?;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ edition.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
clap-serde-derive.workspace = true
|
clap-serde-derive.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
percent-encoding.workspace = true
|
||||||
prost.workspace = true
|
prost.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ service CrabidyService {
|
||||||
|
|
||||||
// Library
|
// Library
|
||||||
rpc GetLibraryNode(GetLibraryNodeRequest) returns (GetLibraryNodeResponse);
|
rpc GetLibraryNode(GetLibraryNodeRequest) returns (GetLibraryNodeResponse);
|
||||||
|
// Creates a child node under a creatable parent (LibraryNode.is_creatable).
|
||||||
|
// What creation means is provider-defined; under /tidal/search the title is
|
||||||
|
// a search term and the created node holds its results. Idempotent: an
|
||||||
|
// existing title returns the existing node.
|
||||||
|
rpc CreateLibraryNode(CreateLibraryNodeRequest) returns (CreateLibraryNodeResponse);
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
rpc Queue(QueueRequest) returns (QueueResponse);
|
rpc Queue(QueueRequest) returns (QueueResponse);
|
||||||
|
|
@ -51,6 +56,17 @@ message GetLibraryNodeResponse {
|
||||||
LibraryNode node = 1;
|
LibraryNode node = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CreateLibraryNodeRequest {
|
||||||
|
// Path of the creatable parent, e.g. /tidal/search.
|
||||||
|
string parent_path = 1;
|
||||||
|
// Human-entered name; becomes the node title. The path segment is a
|
||||||
|
// percent-encoded form of it, chosen by the provider.
|
||||||
|
string title = 2;
|
||||||
|
}
|
||||||
|
message CreateLibraryNodeResponse {
|
||||||
|
LibraryNode node = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
message QueueRequest {
|
message QueueRequest {
|
||||||
repeated string paths = 1;
|
repeated string paths = 1;
|
||||||
|
|
@ -142,6 +158,8 @@ message LibraryNodeChild {
|
||||||
string path = 1;
|
string path = 1;
|
||||||
string title = 2;
|
string title = 2;
|
||||||
bool is_queable = 3;
|
bool is_queable = 3;
|
||||||
|
// Children may be created under this node (see CreateLibraryNode).
|
||||||
|
bool is_creatable = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message QueueModifiers {
|
message QueueModifiers {
|
||||||
|
|
@ -197,4 +215,6 @@ message LibraryNode {
|
||||||
optional string parent = 4;
|
optional string parent = 4;
|
||||||
repeated Track tracks = 5;
|
repeated Track tracks = 5;
|
||||||
bool is_queable = 6;
|
bool is_queable = 6;
|
||||||
|
// Children may be created under this node (see CreateLibraryNode).
|
||||||
|
bool is_creatable = 7;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,33 @@ pub trait ProviderClient: std::fmt::Debug + Send + Sync {
|
||||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
||||||
fn get_lib_root(&self) -> LibraryNode;
|
fn get_lib_root(&self) -> LibraryNode;
|
||||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
||||||
|
/// Creates a child node under a creatable parent (`LibraryNode.is_creatable`).
|
||||||
|
///
|
||||||
|
/// What creation means is provider-defined; under `/tidal/search` the
|
||||||
|
/// `title` is a search term and the created node holds its results.
|
||||||
|
/// Idempotent: an existing title returns the existing node. Errors:
|
||||||
|
/// [`ProviderError::NotSupported`] when the parent is not creatable,
|
||||||
|
/// [`ProviderError::InvalidInput`] when the title is empty or
|
||||||
|
/// whitespace-only.
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Hash)]
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||||
pub enum ProviderError {
|
pub enum ProviderError {
|
||||||
Config(String),
|
Config(String),
|
||||||
UnknownUser,
|
UnknownUser,
|
||||||
CouldNotLogin,
|
CouldNotLogin,
|
||||||
FetchError,
|
FetchError,
|
||||||
MalformedPath,
|
MalformedPath,
|
||||||
|
/// The operation is not supported at this path (e.g. creating a node
|
||||||
|
/// under a parent that is not creatable).
|
||||||
|
NotSupported,
|
||||||
|
/// User-supplied input was rejected (e.g. an empty node title).
|
||||||
|
InvalidInput,
|
||||||
InternalError,
|
InternalError,
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +96,32 @@ pub fn path_segments(path: &str) -> Vec<&str> {
|
||||||
path.split('/').filter(|s| !s.is_empty()).collect()
|
path.split('/').filter(|s| !s.is_empty()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Percent-encodes arbitrary user text into a single path segment.
|
||||||
|
///
|
||||||
|
/// `/`, `%`, whitespace and every other character that would confuse
|
||||||
|
/// `path_segments` or a URL are escaped: `AC/DC` -> `AC%2FDC`. The result is
|
||||||
|
/// never empty for non-empty input and round-trips through
|
||||||
|
/// [`decode_segment`].
|
||||||
|
pub fn encode_segment(text: &str) -> String {
|
||||||
|
/// Everything except ASCII alphanumerics and `-`, `_`, `.`, `~` is
|
||||||
|
/// escaped — the URL "unreserved" set. Notably `/`, `%` and whitespace.
|
||||||
|
const SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
||||||
|
.remove(b'-')
|
||||||
|
.remove(b'_')
|
||||||
|
.remove(b'.')
|
||||||
|
.remove(b'~');
|
||||||
|
percent_encoding::utf8_percent_encode(text, SEGMENT).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a segment produced by [`encode_segment`] back to the original
|
||||||
|
/// text. Invalid or lone percent escapes decode lossily (the raw bytes are
|
||||||
|
/// kept) rather than erroring: paths come from clients and must not panic.
|
||||||
|
pub fn decode_segment(segment: &str) -> String {
|
||||||
|
percent_encoding::percent_decode_str(segment)
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
impl LibraryNode {
|
impl LibraryNode {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -87,16 +131,20 @@ impl LibraryNode {
|
||||||
parent: None,
|
parent: None,
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryNodeChild {
|
impl LibraryNodeChild {
|
||||||
|
/// A regular, non-creatable child. Creatable children (e.g. the search
|
||||||
|
/// node) set `is_creatable` explicitly via struct update.
|
||||||
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
title,
|
title,
|
||||||
is_queable,
|
is_queable,
|
||||||
|
is_creatable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,4 +211,41 @@ mod tests {
|
||||||
vec!["tidal", "artists", "1", "2"]
|
vec!["tidal", "artists", "1", "2"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_segment_round_trips_arbitrary_text() {
|
||||||
|
for term in [
|
||||||
|
"AC/DC",
|
||||||
|
"100% wrong",
|
||||||
|
"Björk",
|
||||||
|
"hello world",
|
||||||
|
"a%2Fb",
|
||||||
|
"?!#&=",
|
||||||
|
] {
|
||||||
|
let encoded = encode_segment(term);
|
||||||
|
assert_eq!(decode_segment(&encoded), term, "round trip of {term:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_segments_are_path_safe() {
|
||||||
|
for term in ["AC/DC", "a/b/c", "//", "term with spaces"] {
|
||||||
|
let encoded = encode_segment(term);
|
||||||
|
assert!(!encoded.is_empty());
|
||||||
|
assert!(!encoded.contains('/'), "{encoded:?} must be one segment");
|
||||||
|
// Joining under a parent yields exactly one extra segment.
|
||||||
|
let path = join_path("/tidal/search", &encoded);
|
||||||
|
assert_eq!(path_segments(&path).len(), 3, "path {path:?}");
|
||||||
|
assert_eq!(parent_path(&path), Some("/tidal/search"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_segment_is_lossy_not_panicky() {
|
||||||
|
// Invalid or truncated escapes must never panic — paths come from
|
||||||
|
// clients. Exact output is unspecified, only totality matters.
|
||||||
|
for bad in ["%", "%2", "%zz", "abc%", "%%25"] {
|
||||||
|
let _ = decode_segment(bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,13 @@ pub enum ProviderCommand {
|
||||||
path: String,
|
path: String,
|
||||||
result_tx: flume::Sender<Vec<Track>>,
|
result_tx: flume::Sender<Vec<Track>>,
|
||||||
},
|
},
|
||||||
|
/// Creates a child under a creatable node (see
|
||||||
|
/// `ProviderClient::create_lib_node`); replies with the created node.
|
||||||
|
CreateLibraryNode {
|
||||||
|
parent_path: String,
|
||||||
|
title: String,
|
||||||
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderCommand {
|
impl ProviderCommand {
|
||||||
|
|
@ -164,6 +171,7 @@ impl ProviderCommand {
|
||||||
Self::GetLibraryNode { .. } => "get_library_node",
|
Self::GetLibraryNode { .. } => "get_library_node",
|
||||||
Self::GetTrackUrls { .. } => "get_track_urls",
|
Self::GetTrackUrls { .. } => "get_track_urls",
|
||||||
Self::ResolveTracks { .. } => "resolve_tracks",
|
Self::ResolveTracks { .. } => "resolve_tracks",
|
||||||
|
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,16 @@ impl ProviderOrchestrator {
|
||||||
error!("failed to send resolve_tracks result: {err}");
|
error!("failed to send resolve_tracks result: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProviderCommand::CreateLibraryNode {
|
||||||
|
parent_path,
|
||||||
|
title,
|
||||||
|
result_tx,
|
||||||
|
} => {
|
||||||
|
let result = self.create_lib_node(&parent_path, &title).await;
|
||||||
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
|
error!("failed to send create_library_node result: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,4 +179,19 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes to the provider that owns the parent path. The synthetic root
|
||||||
|
/// itself is not creatable.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.create_lib_node(parent_path, title).await;
|
||||||
|
}
|
||||||
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
||||||
ClearQueueResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, GetUpdateStreamRequest,
|
ClearQueueResponse, CreateLibraryNodeRequest, CreateLibraryNodeResponse, GetLibraryNodeRequest,
|
||||||
GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, InsertResponse, NextRequest,
|
GetLibraryNodeResponse, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||||
NextResponse, PrevRequest, PrevResponse, QueueRequest, QueueResponse, RemoveRequest,
|
InitResponse, InsertRequest, InsertResponse, NextRequest, NextResponse, PrevRequest,
|
||||||
RemoveResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RestartTrackResponse,
|
PrevResponse, QueueRequest, QueueResponse, RemoveRequest, RemoveResponse, ReplaceRequest,
|
||||||
SaveQueueRequest, SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest,
|
ReplaceResponse, RestartTrackRequest, RestartTrackResponse, SaveQueueRequest,
|
||||||
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
|
SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse,
|
||||||
|
ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
|
||||||
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
|
use crabidy_core::ProviderError;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
|
@ -99,6 +101,51 @@ impl CrabidyService for RpcService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a node under a creatable parent via the provider loop.
|
||||||
|
///
|
||||||
|
/// Error mapping is part of the contract: `NotSupported` →
|
||||||
|
/// `failed_precondition`, `InvalidInput` → `invalid_argument`, everything
|
||||||
|
/// else `internal`.
|
||||||
|
#[instrument(skip(self, request), fields(parent_path, title))]
|
||||||
|
async fn create_library_node(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateLibraryNodeRequest>,
|
||||||
|
) -> Result<Response<CreateLibraryNodeResponse>, Status> {
|
||||||
|
let CreateLibraryNodeRequest { parent_path, title } = request.into_inner();
|
||||||
|
tracing::Span::current().record("parent_path", parent_path.as_str());
|
||||||
|
tracing::Span::current().record("title", title.as_str());
|
||||||
|
debug!("received create_library_node request");
|
||||||
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
|
self.provider_tx
|
||||||
|
.send_async(ProviderMessage::new(ProviderCommand::CreateLibraryNode {
|
||||||
|
parent_path,
|
||||||
|
title,
|
||||||
|
result_tx,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
Status::internal("provider unavailable")
|
||||||
|
})?;
|
||||||
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
|
error!("no reply from provider: {err}");
|
||||||
|
Status::internal("provider did not reply")
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(node) => Ok(Response::new(CreateLibraryNodeResponse {
|
||||||
|
node: Some(node),
|
||||||
|
})),
|
||||||
|
Err(ProviderError::NotSupported) => Err(Status::failed_precondition(
|
||||||
|
"this node does not support creating children",
|
||||||
|
)),
|
||||||
|
Err(ProviderError::InvalidInput) => Err(Status::invalid_argument("invalid node title")),
|
||||||
|
Err(err) => {
|
||||||
|
error!("create_library_node failed: {err}");
|
||||||
|
Err(Status::internal(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(paths))]
|
#[instrument(skip(self, request), fields(paths))]
|
||||||
async fn queue(
|
async fn queue(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Plan — search via creatable nodes
|
||||||
|
|
||||||
|
Ordered tasks for the `implement` stage. Inputs: `architecture/search.md`,
|
||||||
|
stubs across all five crates, gates in `quality/search.md`. Tests:
|
||||||
|
`devenv shell -- cargo test -p crabidy-core -p tidaldy -p cbd-tui`
|
||||||
|
(11 failing at plan time = the target). Note: use a session-local
|
||||||
|
`CARGO_TARGET_DIR` if `target/` contains root-owned artifacts from the
|
||||||
|
repo owner's builds.
|
||||||
|
|
||||||
|
## 1. Path segment encoding (crabidy-core)
|
||||||
|
|
||||||
|
- [x] Add the `percent-encoding` crate (workspace dependency; check the
|
||||||
|
nixpkgs/devenv side is unaffected — pure Rust). Implement
|
||||||
|
`encode_segment` (encode `/`, `%`, whitespace, controls and everything
|
||||||
|
non-alphanumeric-unreserved via an `AsciiSet`) and `decode_segment`
|
||||||
|
(lossy UTF-8 decode). **Verify**: the three `crabidy-core` tests pass
|
||||||
|
(`encode_segment_round_trips_arbitrary_text`,
|
||||||
|
`encoded_segments_are_path_safe`, `decode_segment_is_lossy_not_panicky`);
|
||||||
|
gates "encode/decode are total", "only percent-encoding added".
|
||||||
|
|
||||||
|
## 2. Verify the search payload shape (tidaldy, live API)
|
||||||
|
|
||||||
|
- [x] Before typing the models: run the existing explorer helper
|
||||||
|
(`Client::search`) once against the live API (needs the local tidal
|
||||||
|
config; if unavailable, consult the response shapes used by other tdl
|
||||||
|
clients and mark the gate as verified-by-proxy). Confirm whether
|
||||||
|
`search/tracks|artists|albums` return the same `Page<T>` item shapes as
|
||||||
|
the library endpoints; put any deviation into `tidaldy::models`.
|
||||||
|
**Verify**: gate "models verified against the live API".
|
||||||
|
|
||||||
|
## 3. Typed search requests (tidaldy)
|
||||||
|
|
||||||
|
- [x] Implement `search_tracks/search_artists/search_albums`: single
|
||||||
|
`make_request` with `query`, `limit=SEARCH_RESULT_LIMIT`, `offset=0`,
|
||||||
|
decoding a `Page<T>`; no pagination loop. **Verify**: gate "first page
|
||||||
|
only"; unit-testable only against live API (leave network test
|
||||||
|
`#[ignore]` like the existing one).
|
||||||
|
|
||||||
|
## 4. Search subtree in the provider (tidaldy)
|
||||||
|
|
||||||
|
- [x] `is_track_path`: include `TidalPath::SearchTrack`; `track_id_from_path`
|
||||||
|
already falls out (its match is on the parsed variant — extend it).
|
||||||
|
**Verify**: `search_track_paths_are_track_paths`,
|
||||||
|
`track_id_is_extracted_from_search_track_paths` pass.
|
||||||
|
- [x] `get_lib_node` `Search` arm: children = one entry per stored term
|
||||||
|
(path `join_path(path, encode_segment(term))`, title = raw term,
|
||||||
|
`is_queable: false`), node `is_creatable: true`. Do not require
|
||||||
|
`user_id` for search paths (move the `get_user_id` gate into the arms
|
||||||
|
that need it — search must work even if the user id is missing).
|
||||||
|
**Verify**: gate "search node lists terms"; ignored network test
|
||||||
|
extended by hand.
|
||||||
|
- [x] `get_lib_node` `SearchTerm` arm: decode the term; implicitly register
|
||||||
|
unknown terms (stale-client recovery); fetch the three categories
|
||||||
|
concurrently (`tokio::join!`); node = tracks from `search_tracks`
|
||||||
|
(`to_proto(path)`), children = artists (`Artist: <name>` →
|
||||||
|
`/tidal/artists/<id>`, queueable) then albums (`Album: <title>` →
|
||||||
|
`/tidal/artists/<artist-id>/<album-id>`, queueable), `is_queable:
|
||||||
|
false`, `is_creatable: false`. **Verify**: gates "term node" +
|
||||||
|
"canonical children"; behavior exercised end-to-end in task 9.
|
||||||
|
- [x] `create_lib_node`: trim title → `InvalidInput` if empty; parent must
|
||||||
|
parse to `TidalPath::Search` else `NotSupported`; store raw term
|
||||||
|
idempotently (no duplicates, lock not held across await); return
|
||||||
|
`self.get_lib_node(term_path)`. **Verify**: gate "create semantics";
|
||||||
|
add non-network unit tests: create with bad parent / empty title on an
|
||||||
|
offline client returns the right errors (no API call happens before
|
||||||
|
validation).
|
||||||
|
|
||||||
|
## 5. Server plumbing (crabidy-server)
|
||||||
|
|
||||||
|
- [x] `ProviderOrchestrator::create_lib_node`: `/tidal`-prefixed parent →
|
||||||
|
`tidal_client.create_lib_node`, anything else `NotSupported` (warn like
|
||||||
|
the other routes). **Verify**: gate "orchestrator routing".
|
||||||
|
- [x] `rpc.rs create_library_node`: bounded(1) rendezvous with
|
||||||
|
`ProviderCommand::CreateLibraryNode`; map errors `NotSupported` →
|
||||||
|
`failed_precondition`, `InvalidInput` → `invalid_argument`, rest →
|
||||||
|
`internal`. **Verify**: gate "error mapping"; `cargo check`.
|
||||||
|
|
||||||
|
## 6. TUI input overlay (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `App::dispatch(LibraryCreateNode)`: open
|
||||||
|
`InputState { parent_path: library.path(), buffer: "" }` only when
|
||||||
|
`library.is_creatable()`. **Verify**:
|
||||||
|
`create_node_only_opens_input_on_creatable_nodes`.
|
||||||
|
- [x] `App::handle_input_key`: Esc cancels; Enter trims + sends
|
||||||
|
`MessageFromUi::CreateNode` (empty → just close); Backspace pops;
|
||||||
|
`KeyCode::Char(c)` appends regardless of SHIFT; all else ignored.
|
||||||
|
**Verify**: `input_appends_and_backspace_pops`,
|
||||||
|
`esc_cancels_without_sending`, `enter_submits_trimmed_title_and_closes`,
|
||||||
|
`enter_on_empty_input_closes_without_sending`.
|
||||||
|
- [x] Event loop (`main.rs run_ui`): when `app.input.is_some()`, route the
|
||||||
|
key to `app.handle_input_key(key)` and skip `bindings::lookup`
|
||||||
|
entirely. **Verify**: gate "input mode bypasses bindings" (read the
|
||||||
|
loop; also confirm `q` cannot quit while typing).
|
||||||
|
|
||||||
|
## 7. TUI library rendering (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `Library::update`: apply empty nodes when `node.is_creatable` (keep the
|
||||||
|
skip for empty non-creatable nodes). **Verify**:
|
||||||
|
`empty_creatable_nodes_are_enterable`.
|
||||||
|
- [x] Mark creatable children in the list (suffix marker, `COLOR_SECONDARY`)
|
||||||
|
— requires keeping `is_creatable` on `UiItem`; hint in the pane title
|
||||||
|
when the open node is creatable (e.g. `search — % to add`).
|
||||||
|
**Verify**: gate "creatable marked in UI" (read render code).
|
||||||
|
- [x] Render the input overlay: bottom line of the library pane showing
|
||||||
|
`new node: <buffer>▏` while `input.is_some()`. **Verify**: gate "input
|
||||||
|
overlay renders"; add a `TestBackend` test asserting the buffer text
|
||||||
|
appears while open (mirror the help-modal test helpers).
|
||||||
|
|
||||||
|
## 8. TUI ↔ server wiring (cbd-tui)
|
||||||
|
|
||||||
|
- [x] `RpcClient::create_library_node`: send request, evict
|
||||||
|
`library_node_cache` entry for `parent_path`, insert the returned node,
|
||||||
|
return it. **Verify**: gate "cache eviction" (read; the cache is
|
||||||
|
private — no test seam without refactoring, keep it a gate).
|
||||||
|
- [x] `main.rs poll`: `CreateNode` arm calls the client method; on success
|
||||||
|
`ReplaceLibraryNode(node)`; on failure log and leave the UI unchanged
|
||||||
|
(no panic — replace the stub's `todo!`). **Verify**: gate "create
|
||||||
|
failures surface as errors, never a panic".
|
||||||
|
|
||||||
|
## 9. End-to-end + gates sweep
|
||||||
|
|
||||||
|
- [x] Run the real stack (`crabidy-server` + `cbd-tui`, needs tidal login):
|
||||||
|
enter `/tidal/search`, `%`, type a term, Enter; results appear; queue a
|
||||||
|
track result; dive into an artist result. If no login is available,
|
||||||
|
exercise create/list/error paths against the offline client instead
|
||||||
|
and note it in the summary. **Verify**: architecture flow diagram
|
||||||
|
matches reality.
|
||||||
|
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests green,
|
||||||
|
every `quality/search.md` box checked, docs updated where behavior
|
||||||
|
shifted. Append the outcome + deviations to `plan/summary.md`.
|
||||||
|
|
@ -1,5 +1,42 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## search (2026-07-20)
|
||||||
|
|
||||||
|
Built per `plan/search.md`: `%` inside `/tidal/search` opens a one-line input;
|
||||||
|
the term becomes a persistent (per-process) tree node holding Tidal search
|
||||||
|
results — 20 tracks queueable in place, plus artist/album results as canonical
|
||||||
|
`/tidal/artists/...` children. Creatable nodes carry an `is_creatable` flag
|
||||||
|
end-to-end (proto → provider → TUI marker `[%]` + pane hint). All 48 workspace
|
||||||
|
tests green; every gate in `quality/search.md` checked. Verified against the
|
||||||
|
live Tidal API: payload shapes match the existing models (probe kept as the
|
||||||
|
ignored `probe_search_shapes` test), and a full create→list→resolve-URL round
|
||||||
|
trip succeeded (`beatles` → 20 tracks / 40 children, idempotent, playable
|
||||||
|
stream URL from a search-track path).
|
||||||
|
|
||||||
|
The whole feature ran autonomously on user instruction; decisions were taken
|
||||||
|
without mid-stage confirmation and recorded in `architecture/search.md`
|
||||||
|
(options + decision per topic).
|
||||||
|
|
||||||
|
### Deviations from plan / architecture (search)
|
||||||
|
|
||||||
|
- **`Library::update` now concatenates tracks and children** (tracks first).
|
||||||
|
The old code showed tracks *instead of* children, which would have hidden
|
||||||
|
the artist/album results on term nodes — architecture assumed both would
|
||||||
|
render. Existing nodes are unaffected (they only ever carry one kind).
|
||||||
|
- **Search categories degrade independently**: a failing category logs and
|
||||||
|
contributes nothing; only all three failing is a `FetchError`. The plan
|
||||||
|
did not specify partial-failure behavior.
|
||||||
|
- **`get_lib_node` no longer requires a user id up front** — the gate moved
|
||||||
|
into the favorites arms (planned), which also means `create_lib_node`
|
||||||
|
validation works fully offline (used by the new unit tests).
|
||||||
|
- **End-to-end check ran at the provider layer** (temporary ignored test,
|
||||||
|
removed after passing) rather than driving the full TUI + server — no
|
||||||
|
interactive terminal/audio device in this environment. The gRPC handler and
|
||||||
|
TUI layers above it are covered by unit tests and review.
|
||||||
|
- **Environment note**: `target/` contains owner-built artifacts not writable
|
||||||
|
by this agent's user; builds/tests ran with a session-local
|
||||||
|
`CARGO_TARGET_DIR`. No repo change involved.
|
||||||
|
|
||||||
## help-modal (2026-07-20)
|
## help-modal (2026-07-20)
|
||||||
|
|
||||||
Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
|
Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
|
||||||
|
|
@ -7,7 +44,7 @@ Built per `plan/help-modal.md`: `app/bindings.rs` (declarative
|
||||||
`App::dispatch`/`DispatchResult` seam, and the rewired event loop in
|
`App::dispatch`/`DispatchResult` seam, and the rewired event loop in
|
||||||
`main.rs`. All 20 tests pass; every gate in `quality/help-modal.md` checked.
|
`main.rs`. All 20 tests pass; every gate in `quality/help-modal.md` checked.
|
||||||
|
|
||||||
### Deviations from plan / architecture
|
### Deviations from plan / architecture (help-modal)
|
||||||
|
|
||||||
- **Two-column modal layout.** The architecture assumed a single-column list;
|
- **Two-column modal layout.** The architecture assumed a single-column list;
|
||||||
the full table is ~50 rows and would not fit even a 100×40 frame. The modal
|
the full table is ~50 rows and would not fit even a 100×40 frame. The modal
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Quality gates — search via creatable nodes
|
||||||
|
|
||||||
|
Checklist for the `implement` stage. Automatic tests live in
|
||||||
|
`crabidy-core/src/lib.rs`, `tidaldy/src/lib.rs`, and
|
||||||
|
`cbd-tui/src/app/{bindings,mod}.rs` test modules; run with
|
||||||
|
`devenv shell -- cargo test --workspace` (network-dependent tidaldy tests
|
||||||
|
stay `#[ignore]`).
|
||||||
|
|
||||||
|
## Contract & wire
|
||||||
|
|
||||||
|
- [x] Proto changes are additive only: existing field numbers untouched;
|
||||||
|
`is_creatable` fields 7 (`LibraryNode`) / 4 (`LibraryNodeChild`);
|
||||||
|
`CreateLibraryNode` rpc present. Old clients keep working against the
|
||||||
|
new server.
|
||||||
|
- [x] `rpc.rs` (server) maps errors per the documented contract:
|
||||||
|
`NotSupported` → `failed_precondition`, `InvalidInput` →
|
||||||
|
`invalid_argument`, everything else → `internal`. No `color-eyre`
|
||||||
|
report or debug formatting of internals leaks into `Status` messages.
|
||||||
|
- [x] `ProviderOrchestrator::create_lib_node` routes `/tidal`-prefixed
|
||||||
|
parents to the Tidal client and answers anything else (including the
|
||||||
|
synthetic root `/`) with `NotSupported` — same prefix discipline as
|
||||||
|
`get_lib_node`.
|
||||||
|
- [x] The `CreateLibraryNode` command follows the existing bounded(1)-reply
|
||||||
|
rendezvous pattern; no new unbounded channels anywhere in the feature.
|
||||||
|
|
||||||
|
## Provider semantics (tidaldy)
|
||||||
|
|
||||||
|
- [x] `create_lib_node` trims the title; empty/whitespace → `InvalidInput`;
|
||||||
|
parent other than `/tidal/search` → `NotSupported`; idempotent (an
|
||||||
|
existing term returns its node, the term list holds no duplicates).
|
||||||
|
- [x] `search_terms` stores **raw** terms; the path segment is
|
||||||
|
`encode_segment(term)`; the node `title` shows the raw term. The lock
|
||||||
|
is never held across an `await`.
|
||||||
|
- [x] `get_lib_node(/tidal/search)` lists created terms as children (decoded
|
||||||
|
titles) and sets `is_creatable: true` on the node; an unknown term path
|
||||||
|
under search is implicitly (re)created, not an error — survives server
|
||||||
|
restarts with stale client caches.
|
||||||
|
- [x] The term node sets `is_queable: false` (see architecture/search.md:
|
||||||
|
`resolve_tracks` would otherwise sweep every artist/album result into
|
||||||
|
the queue). Its `tracks` are the track results with paths
|
||||||
|
`/tidal/search/<enc-term>/<track-id>`; artist/album results are
|
||||||
|
children with **canonical** `/tidal/artists/...` paths.
|
||||||
|
- [x] `search_tracks/artists/albums` fetch a single page with
|
||||||
|
`limit=SEARCH_RESULT_LIMIT` — they must NOT use
|
||||||
|
`make_paginated_request`'s fetch-everything loop.
|
||||||
|
- [x] `get_urls_for_track` and `get_metadata_for_track` work for
|
||||||
|
`SearchTrack` paths (via `track_id_from_path`).
|
||||||
|
- [x] The response models were verified against the live API (explorer
|
||||||
|
request) before the typed methods were finalized; deviations live in
|
||||||
|
`tidaldy::models`, not in ad-hoc serde attributes inline.
|
||||||
|
|
||||||
|
## No panics on user input (hard rule)
|
||||||
|
|
||||||
|
- [x] All `todo!()` stubs from api-design are gone (grep the workspace).
|
||||||
|
- [x] `encode_segment`/`decode_segment` are total: any client-supplied
|
||||||
|
segment decodes without panicking (lossy, not `unwrap`).
|
||||||
|
- [x] Search/create failures (network, auth, malformed) surface as
|
||||||
|
`ProviderError`/`Status`, never a panic; the TUI's create path handles
|
||||||
|
an error reply without crashing the orchestrate task.
|
||||||
|
|
||||||
|
## TUI behavior
|
||||||
|
|
||||||
|
- [x] While `app.input.is_some()`, the event loop routes keys exclusively to
|
||||||
|
`handle_input_key` — the bindings table (including `q`/quit and `?`)
|
||||||
|
is unreachable; `%` while the overlay is already open just inserts the
|
||||||
|
character.
|
||||||
|
- [x] `Library::update` applies nodes with no children and no tracks when
|
||||||
|
`is_creatable` (empty search node is enterable); genuinely empty
|
||||||
|
non-creatable nodes keep the existing skip behavior.
|
||||||
|
- [x] Creatable children are visibly marked in the library list and the pane
|
||||||
|
title hints at `%` when the open node is creatable.
|
||||||
|
- [x] The input overlay renders inside the library pane (bottom line) with
|
||||||
|
the typed buffer visible; it disappears on cancel/submit.
|
||||||
|
- [x] `RpcClient::create_library_node` evicts the parent's cache entry and
|
||||||
|
caches the created node (stale `/tidal/search` listings would
|
||||||
|
otherwise hide new terms).
|
||||||
|
- [x] On successful create the library navigates into the returned node
|
||||||
|
(`ReplaceLibraryNode`); on failure the library stays where it was.
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
- [x] Public items added in all five crates have doc comments matching final
|
||||||
|
behavior; the `%` binding appears in the help modal automatically.
|
||||||
|
- [x] New dependency: only `percent-encoding` (workspace-managed version);
|
||||||
|
nothing else added.
|
||||||
|
- [x] `devenv shell -- cargo fmt --check`, `cargo clippy --workspace`
|
||||||
|
(no new warnings), `cargo test --workspace` all pass.
|
||||||
|
|
@ -16,6 +16,10 @@ pub struct Client {
|
||||||
/// Login state changes at runtime when tokens are refreshed, while the
|
/// Login state changes at runtime when tokens are refreshed, while the
|
||||||
/// client is shared immutably, hence the lock. Never held across awaits.
|
/// client is shared immutably, hence the lock. Never held across awaits.
|
||||||
login: std::sync::RwLock<config::LoginConfig>,
|
login: std::sync::RwLock<config::LoginConfig>,
|
||||||
|
/// Search terms created under `/tidal/search`, in creation order,
|
||||||
|
/// deduplicated. In-memory only: terms die with the process, like the
|
||||||
|
/// queue. Same locking discipline as `login`: never held across awaits.
|
||||||
|
search_terms: std::sync::RwLock<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh the access token this long before it actually expires.
|
/// Refresh the access token this long before it actually expires.
|
||||||
|
|
@ -50,7 +54,9 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
fn is_track_path(&self, path: &str) -> bool {
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
parse_path(path),
|
parse_path(path),
|
||||||
Ok(TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. })
|
Ok(TidalPath::PlaylistTrack { .. }
|
||||||
|
| TidalPath::AlbumTrack { .. }
|
||||||
|
| TidalPath::SearchTrack { .. })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,8 +117,17 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
"artists".to_string(),
|
"artists".to_string(),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
crabidy_core::proto::crabidy::LibraryNodeChild {
|
||||||
|
is_creatable: true,
|
||||||
|
..crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
format!("{PROVIDER_ROOT}/search"),
|
||||||
|
"search".to_string(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
},
|
||||||
],
|
],
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,9 +136,9 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
|
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
|
||||||
let Some(user_id) = self.get_user_id() else {
|
// The user id is only needed for the favorites listings; search must
|
||||||
return Err(crabidy_core::ProviderError::UnknownUser);
|
// work even when it is missing, so the gate lives in those arms.
|
||||||
};
|
let user_id = self.get_user_id();
|
||||||
let parsed = parse_path(path)?;
|
let parsed = parse_path(path)?;
|
||||||
debug!(?parsed, "resolving library node");
|
debug!(?parsed, "resolving library node");
|
||||||
let parent = crabidy_core::parent_path(path)
|
let parent = crabidy_core::parent_path(path)
|
||||||
|
|
@ -139,7 +154,9 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
};
|
};
|
||||||
|
let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?;
|
||||||
for playlist in self
|
for playlist in self
|
||||||
.get_users_playlists_and_favorite_playlists(&user_id)
|
.get_users_playlists_and_favorite_playlists(&user_id)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -168,6 +185,7 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
tracks,
|
tracks,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
is_queable: true,
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TidalPath::Artists => {
|
TidalPath::Artists => {
|
||||||
|
|
@ -178,7 +196,9 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
};
|
};
|
||||||
|
let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?;
|
||||||
for artist in self.get_users_artists(&user_id).await? {
|
for artist in self.get_users_artists(&user_id).await? {
|
||||||
node.children
|
node.children
|
||||||
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
|
@ -210,6 +230,7 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
children,
|
children,
|
||||||
is_queable: true,
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TidalPath::Album { album, .. } => {
|
TidalPath::Album { album, .. } => {
|
||||||
|
|
@ -227,20 +248,73 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
tracks,
|
tracks,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
is_queable: true,
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. } => {
|
TidalPath::Search => crabidy_core::proto::crabidy::LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: "search".to_string(),
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children: self
|
||||||
|
.search_terms_snapshot()
|
||||||
|
.iter()
|
||||||
|
.map(|term| {
|
||||||
|
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, &crabidy_core::encode_segment(term)),
|
||||||
|
term.clone(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: true,
|
||||||
|
},
|
||||||
|
TidalPath::SearchTerm(encoded) => {
|
||||||
|
let term = crabidy_core::decode_segment(encoded);
|
||||||
|
// Unknown terms (stale client cache, server restart) are
|
||||||
|
// recreated implicitly instead of erroring.
|
||||||
|
self.register_search_term(&term);
|
||||||
|
self.search_term_node(path, &term, parent).await?
|
||||||
|
}
|
||||||
|
TidalPath::PlaylistTrack { .. }
|
||||||
|
| TidalPath::AlbumTrack { .. }
|
||||||
|
| TidalPath::SearchTrack { .. } => {
|
||||||
warn!(path, "get_lib_node called with a track path");
|
warn!(path, "get_lib_node called with a track path");
|
||||||
return Err(crabidy_core::ProviderError::MalformedPath);
|
return Err(crabidy_core::ProviderError::MalformedPath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
|
||||||
|
let term = title.trim();
|
||||||
|
if term.is_empty() {
|
||||||
|
return Err(crabidy_core::ProviderError::InvalidInput);
|
||||||
|
}
|
||||||
|
// Only /tidal/search is creatable.
|
||||||
|
if parse_path(parent_path)? != TidalPath::Search {
|
||||||
|
warn!(parent_path, "node creation not supported here");
|
||||||
|
return Err(crabidy_core::ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
self.register_search_term(term);
|
||||||
|
let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term));
|
||||||
|
self.get_lib_node(&term_path).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The root of this provider in the global library tree.
|
/// The root of this provider in the global library tree.
|
||||||
pub const PROVIDER_ROOT: &str = "/tidal";
|
pub const PROVIDER_ROOT: &str = "/tidal";
|
||||||
|
|
||||||
|
/// Maximum results fetched per category (tracks/artists/albums) for one
|
||||||
|
/// search term. First page only — search is exploratory, not a collection.
|
||||||
|
pub const SEARCH_RESULT_LIMIT: usize = 20;
|
||||||
|
|
||||||
/// A parsed tidal library path. The position in the tree is fully encoded
|
/// A parsed tidal library path. The position in the tree is fully encoded
|
||||||
/// in the path itself.
|
/// in the path itself.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -263,6 +337,18 @@ pub enum TidalPath<'a> {
|
||||||
album: &'a str,
|
album: &'a str,
|
||||||
track: &'a str,
|
track: &'a str,
|
||||||
},
|
},
|
||||||
|
/// `/tidal/search` — the creatable node listing created search terms.
|
||||||
|
Search,
|
||||||
|
/// `/tidal/search/<term>` — one created term; `term` is the
|
||||||
|
/// percent-encoded segment, not the raw text.
|
||||||
|
SearchTerm(&'a str),
|
||||||
|
/// `/tidal/search/<term>/<track-id>` — a track search result. Artist and
|
||||||
|
/// album results are not nested here: their children carry canonical
|
||||||
|
/// `/tidal/artists/...` paths (see architecture/search.md).
|
||||||
|
SearchTrack {
|
||||||
|
term: &'a str,
|
||||||
|
track: &'a str,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderError> {
|
pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderError> {
|
||||||
|
|
@ -280,6 +366,9 @@ pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderErr
|
||||||
album,
|
album,
|
||||||
track,
|
track,
|
||||||
}),
|
}),
|
||||||
|
["tidal", "search"] => Ok(TidalPath::Search),
|
||||||
|
["tidal", "search", term] => Ok(TidalPath::SearchTerm(term)),
|
||||||
|
["tidal", "search", term, track] => Ok(TidalPath::SearchTrack { term, track }),
|
||||||
_ => {
|
_ => {
|
||||||
warn!(path, "malformed tidal path");
|
warn!(path, "malformed tidal path");
|
||||||
Err(crabidy_core::ProviderError::MalformedPath)
|
Err(crabidy_core::ProviderError::MalformedPath)
|
||||||
|
|
@ -289,7 +378,9 @@ pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderErr
|
||||||
|
|
||||||
fn track_id_from_path(path: &str) -> Result<&str, crabidy_core::ProviderError> {
|
fn track_id_from_path(path: &str) -> Result<&str, crabidy_core::ProviderError> {
|
||||||
match parse_path(path)? {
|
match parse_path(path)? {
|
||||||
TidalPath::PlaylistTrack { track, .. } | TidalPath::AlbumTrack { track, .. } => Ok(track),
|
TidalPath::PlaylistTrack { track, .. }
|
||||||
|
| TidalPath::AlbumTrack { track, .. }
|
||||||
|
| TidalPath::SearchTrack { track, .. } => Ok(track),
|
||||||
_ => {
|
_ => {
|
||||||
warn!(path, "expected a track path");
|
warn!(path, "expected a track path");
|
||||||
Err(crabidy_core::ProviderError::MalformedPath)
|
Err(crabidy_core::ProviderError::MalformedPath)
|
||||||
|
|
@ -309,6 +400,7 @@ impl Client {
|
||||||
http_client,
|
http_client,
|
||||||
settings,
|
settings,
|
||||||
login,
|
login,
|
||||||
|
search_terms: std::sync::RwLock::new(Vec::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,6 +577,8 @@ impl Client {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explorer helper kept for probing the raw `search/*` payloads against
|
||||||
|
/// the live API (the typed methods below assume our models fit them).
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn search(&self, query: &str) -> Result<(), ClientError> {
|
pub async fn search(&self, query: &str) -> Result<(), ClientError> {
|
||||||
let query = vec![("query", query.to_string())];
|
let query = vec![("query", query.to_string())];
|
||||||
|
|
@ -493,6 +587,131 @@ impl Client {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One first-page search request. Search is exploratory: unlike the
|
||||||
|
/// library collections this deliberately does NOT paginate to
|
||||||
|
/// exhaustion.
|
||||||
|
async fn search_page<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
category: &str,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<T>, ClientError> {
|
||||||
|
let params = vec![
|
||||||
|
("query", query.to_string()),
|
||||||
|
("limit", SEARCH_RESULT_LIMIT.to_string()),
|
||||||
|
("offset", "0".to_string()),
|
||||||
|
];
|
||||||
|
let page: Page<T> = self
|
||||||
|
.make_request(&format!("search/{category}"), Some(¶ms))
|
||||||
|
.await?;
|
||||||
|
Ok(page.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First page of track results for a search term, at most
|
||||||
|
/// [`SEARCH_RESULT_LIMIT`].
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>, ClientError> {
|
||||||
|
self.search_page("tracks", query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First page of artist results, at most [`SEARCH_RESULT_LIMIT`].
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn search_artists(&self, query: &str) -> Result<Vec<Artist>, ClientError> {
|
||||||
|
self.search_page("artists", query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First page of album results, at most [`SEARCH_RESULT_LIMIT`].
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn search_albums(&self, query: &str) -> Result<Vec<Album>, ClientError> {
|
||||||
|
self.search_page("albums", query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A consistent copy of the created search terms.
|
||||||
|
fn search_terms_snapshot(&self) -> Vec<String> {
|
||||||
|
match self.search_terms.read() {
|
||||||
|
Ok(terms) => terms.clone(),
|
||||||
|
Err(poisoned) => poisoned.into_inner().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a term idempotently, preserving creation order. The lock is
|
||||||
|
/// released before any await point.
|
||||||
|
fn register_search_term(&self, term: &str) {
|
||||||
|
let mut terms = match self.search_terms.write() {
|
||||||
|
Ok(terms) => terms,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
if !terms.iter().any(|t| t == term) {
|
||||||
|
terms.push(term.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the node for one search term: its `tracks` are the track
|
||||||
|
/// results (queueable individually, paths under this node), its children
|
||||||
|
/// are artist and album results pointing at **canonical**
|
||||||
|
/// `/tidal/artists/...` paths. The node itself is not queueable — the
|
||||||
|
/// server's resolve sweep would otherwise pull every artist's full
|
||||||
|
/// discography into the queue (architecture/search.md).
|
||||||
|
///
|
||||||
|
/// The three categories are fetched concurrently. A failing category
|
||||||
|
/// degrades to empty with a warning; only all three failing is an error.
|
||||||
|
async fn search_term_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
term: &str,
|
||||||
|
parent: String,
|
||||||
|
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
|
||||||
|
let (tracks, artists, albums) = tokio::join!(
|
||||||
|
self.search_tracks(term),
|
||||||
|
self.search_artists(term),
|
||||||
|
self.search_albums(term),
|
||||||
|
);
|
||||||
|
if let (Err(t), Err(a), Err(al)) = (&tracks, &artists, &albums) {
|
||||||
|
warn!(term, "all search categories failed: {t}; {a}; {al}");
|
||||||
|
return Err(crabidy_core::ProviderError::FetchError);
|
||||||
|
}
|
||||||
|
fn or_empty<T>(term: &str, name: &str, r: Result<Vec<T>, ClientError>) -> Vec<T> {
|
||||||
|
match r {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(term, category = name, "search category failed: {err}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tracks = or_empty(term, "tracks", tracks);
|
||||||
|
let artists = or_empty(term, "artists", artists);
|
||||||
|
let albums = or_empty(term, "albums", albums);
|
||||||
|
|
||||||
|
let mut children = Vec::with_capacity(artists.len() + albums.len());
|
||||||
|
children.extend(artists.iter().map(|a| {
|
||||||
|
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
format!("{PROVIDER_ROOT}/artists/{}", a.id),
|
||||||
|
format!("Artist: {}", a.name),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
children.extend(albums.iter().filter_map(|album| {
|
||||||
|
// The canonical album path needs the artist id; results without
|
||||||
|
// one (not observed in practice) are skipped, not guessed at.
|
||||||
|
let artist = album.artist.as_ref()?;
|
||||||
|
Some(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
format!("{PROVIDER_ROOT}/artists/{}/{}", artist.id, album.id),
|
||||||
|
format!("Album: {}", album.title),
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(crabidy_core::proto::crabidy::LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: term.to_string(),
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks: tracks.iter().map(|t| t.to_proto(path)).collect(),
|
||||||
|
children,
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_playlist_tracks(
|
pub async fn get_playlist_tracks(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -861,6 +1080,112 @@ mod tests {
|
||||||
Client::init(&raw_toml_settings).await.unwrap()
|
Client::init(&raw_toml_settings).await.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A client with default settings and no login — enough for the pure
|
||||||
|
/// path/tree methods, no network involved.
|
||||||
|
fn offline_client() -> Client {
|
||||||
|
Client::new(config::Settings::default()).expect("offline client")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dumps the raw search payloads to re-verify the models if the API
|
||||||
|
/// drifts (last verified 2026-07-20, shapes matched `Page<T>` + the
|
||||||
|
/// existing Track/Artist/Album models).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires local tidal config and network"]
|
||||||
|
async fn probe_search_shapes() {
|
||||||
|
let raw = std::fs::read_to_string("/home/hans/.config/crabidy/tidaly.toml").unwrap();
|
||||||
|
let settings: config::Settings = toml::from_str(&raw).unwrap();
|
||||||
|
let client = Client::new(settings).unwrap();
|
||||||
|
client.ensure_fresh_token().await.unwrap();
|
||||||
|
for cat in ["tracks", "artists", "albums"] {
|
||||||
|
let resp = client
|
||||||
|
.authed_get(
|
||||||
|
&format!("search/{cat}"),
|
||||||
|
Some(&[("query", "beatles".to_string()), ("limit", "2".to_string())]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("=== {cat} status={}", resp.status());
|
||||||
|
println!("{}", resp.text().await.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_path_recognizes_search_paths() {
|
||||||
|
assert_eq!(parse_path("/tidal/search"), Ok(TidalPath::Search));
|
||||||
|
assert_eq!(
|
||||||
|
parse_path("/tidal/search/abba"),
|
||||||
|
Ok(TidalPath::SearchTerm("abba"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_path("/tidal/search/abba/12345"),
|
||||||
|
Ok(TidalPath::SearchTrack {
|
||||||
|
term: "abba",
|
||||||
|
track: "12345"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(parse_path("/tidal/search/a/b/c").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_track_paths_are_track_paths() {
|
||||||
|
let client = offline_client();
|
||||||
|
assert!(client.is_track_path("/tidal/search/abba/12345"));
|
||||||
|
assert!(!client.is_track_path("/tidal/search/abba"));
|
||||||
|
assert!(!client.is_track_path("/tidal/search"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_id_is_extracted_from_search_track_paths() {
|
||||||
|
assert_eq!(track_id_from_path("/tidal/search/abba/12345"), Ok("12345"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_rejects_empty_titles_and_foreign_parents() {
|
||||||
|
use crabidy_core::ProviderError;
|
||||||
|
let client = offline_client();
|
||||||
|
// Validation happens before any network call, so this works offline.
|
||||||
|
assert_eq!(
|
||||||
|
client.create_lib_node("/tidal/search", " ").await,
|
||||||
|
Err(ProviderError::InvalidInput)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.create_lib_node("/tidal/playlists", "abba").await,
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.create_lib_node("/nope", "abba").await,
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_node_lists_created_terms() {
|
||||||
|
let client = offline_client();
|
||||||
|
client.register_search_term("AC/DC");
|
||||||
|
client.register_search_term("abba");
|
||||||
|
client.register_search_term("abba"); // idempotent
|
||||||
|
let node = client.get_lib_node("/tidal/search").await.expect("node");
|
||||||
|
assert!(node.is_creatable);
|
||||||
|
assert!(!node.is_queable);
|
||||||
|
let titles: Vec<_> = node.children.iter().map(|c| c.title.as_str()).collect();
|
||||||
|
assert_eq!(titles, vec!["AC/DC", "abba"]);
|
||||||
|
assert_eq!(node.children[0].path, "/tidal/search/AC%2FDC");
|
||||||
|
assert!(node.children.iter().all(|c| !c.is_queable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lib_root_offers_a_creatable_search_node() {
|
||||||
|
let client = offline_client();
|
||||||
|
let root = client.get_lib_root();
|
||||||
|
let search = root
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.path == format!("{PROVIDER_ROOT}/search"))
|
||||||
|
.expect("search child in provider root");
|
||||||
|
assert!(search.is_creatable);
|
||||||
|
assert!(!search.is_queable);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a local tidal config and network access"]
|
#[ignore = "requires a local tidal config and network access"]
|
||||||
async fn test() {
|
async fn test() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue