From d741523e536f134bc11043b7b2e1d6e6068d4978 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 20 Jul 2026 17:58:44 +0200 Subject: [PATCH] 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 --- Cargo.lock | 1 + Cargo.toml | 1 + architecture/search.md | 264 ++++++++++++++++++++ cbd-tui/src/app/bindings.rs | 27 +++ cbd-tui/src/app/library.rs | 86 ++++--- cbd-tui/src/app/mod.rs | 215 +++++++++++++++- cbd-tui/src/app/queue.rs | 1 + cbd-tui/src/main.rs | 19 +- cbd-tui/src/rpc.rs | 36 ++- crabidy-core/Cargo.toml | 1 + crabidy-core/crabidy/v1/crabidy.proto | 20 ++ crabidy-core/src/lib.rs | 87 ++++++- crabidy-server/src/main.rs | 8 + crabidy-server/src/provider.rs | 25 ++ crabidy-server/src/rpc.rs | 59 ++++- plan/search.md | 130 ++++++++++ plan/summary.md | 39 ++- quality/search.md | 87 +++++++ tidaldy/src/lib.rs | 337 +++++++++++++++++++++++++- 19 files changed, 1392 insertions(+), 51 deletions(-) create mode 100644 architecture/search.md create mode 100644 plan/search.md create mode 100644 quality/search.md diff --git a/Cargo.lock b/Cargo.lock index 64d58e4..8349aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,7 @@ dependencies = [ "async-trait", "clap-serde-derive", "dirs", + "percent-encoding", "prost", "serde", "toml", diff --git a/Cargo.toml b/Cargo.toml index c086f69..2a0df3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ dirs = "6" flume = "0.12" futures = "0.3" notify-rust = "4" +percent-encoding = "2" prost = "0.14" rand = "0.10" ratatui = "0.30" diff --git a/architecture/search.md b/architecture/search.md new file mode 100644 index 0000000..d6d8e3e --- /dev/null +++ b/architecture/search.md @@ -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//artists///`. 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/` carries the +**track** results directly as its `tracks` (queueable in place, paths +`/tidal/search//` — one new track variant), while artist and +album results are children whose paths are **canonical** +(`/tidal/artists/`, `/tidal/artists//`). Diving into an +artist result reuses the existing artist/album/track machinery unchanged — +`get_lib_node(/tidal/artists/)` 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/" +strack: "/tidal/search//" { + style.fill: "#e8f4e8" +} +artists: "/tidal/artists" +artist: "/tidal/artists/" +album: "/tidal/artists//" + +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/" +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`. `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>` 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. diff --git a/cbd-tui/src/app/bindings.rs b/cbd-tui/src/app/bindings.rs index c1a9d54..8bd99a3 100644 --- a/cbd-tui/src/app/bindings.rs +++ b/cbd-tui/src/app/bindings.rs @@ -55,6 +55,11 @@ pub enum Action { LibraryQueueAppend, LibraryQueueReplace, 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 QueueInsertHere, QueueFirst, @@ -239,6 +244,13 @@ pub const BINDINGS: &[Binding] = &[ action: Action::LibraryToggleMark, 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 { scope: Scope::Library, 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] fn control_must_match_exactly() { assert_eq!( diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs index b123828..15fa275 100644 --- a/cbd-tui/src/app/library.rs +++ b/cbd-tui/src/app/library.rs @@ -12,12 +12,17 @@ use ratatui::{ use crabidy_core::proto::crabidy::LibraryNode; 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 { title: 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, list_state: ListState, parent: Option, @@ -30,6 +35,7 @@ impl Library { Self { title: "Library".to_string(), path: crabidy_core::ROOT_PATH.to_string(), + is_creatable: false, list: Vec::new(), list_state: ListState::default(), positions: HashMap::new(), @@ -37,6 +43,16 @@ impl Library { 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> { if self.list.iter().any(|i| i.marked) { return Some( @@ -120,7 +136,9 @@ impl Library { } } 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; } @@ -128,34 +146,33 @@ impl Library { self.path = node.path; self.title = node.title; self.parent = node.parent; + self.is_creatable = node.is_creatable; self.select(Some(self.prev_selected())); - if !node.tracks.is_empty() { - self.list = node - .tracks - .iter() - .map(|t| UiItem { - path: t.path.clone(), - title: format!("{} - {}", t.artist, t.title), - kind: UiItemKind::Track, - marked: false, - is_queable: true, - }) - .collect(); - } else { - // if tracks not empty use tracks instead - self.list = node - .children - .iter() - .map(|c| UiItem { - path: c.path.clone(), - title: c.title.clone(), - kind: UiItemKind::Node, - marked: false, - is_queable: c.is_queable, - }) - .collect(); - } + // 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 + .tracks + .iter() + .map(|t| UiItem { + path: t.path.clone(), + title: format!("{} - {}", t.artist, t.title), + kind: UiItemKind::Track, + marked: false, + is_queable: true, + is_creatable: false, + }) + .chain(node.children.iter().map(|c| UiItem { + path: c.path.clone(), + title: c.title.clone(), + kind: UiItemKind::Node, + marked: false, + is_queable: c.is_queable, + is_creatable: c.is_creatable, + })) + .collect(); self.update_selection(); } @@ -165,15 +182,20 @@ impl Library { .list .iter() .map(|i| { - let text = if i.marked { + let mut text = if i.marked { format!("* {}", i.title) } else { i.title.to_string() }; + if i.is_creatable { + text.push_str(" [%]"); + } let style = if i.marked { Style::default() .fg(COLOR_GREEN) .add_modifier(Modifier::BOLD) + } else if i.is_creatable { + Style::default().fg(COLOR_SECONDARY) } else { Style::default() }; @@ -191,7 +213,11 @@ impl Library { } else { COLOR_PRIMARY_DARK })) - .title(self.title.clone()), + .title(if self.is_creatable { + format!("{} — % to add", self.title) + } else { + self.title.clone() + }), ) .highlight_style( Style::default() diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 014d2fb..e116801 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -7,8 +7,9 @@ mod queue; use flume::Sender; use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::Color, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets::{Clear, Paragraph}, Frame, }; @@ -41,6 +42,8 @@ struct UiItem { kind: UiItemKind, marked: 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); @@ -62,6 +65,12 @@ pub enum MessageToUi { // FIXME: Rename this pub enum MessageFromUi { 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), QueueTracks(Vec), InsertTracks(Vec, usize), @@ -90,11 +99,25 @@ pub enum DispatchResult { 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 focus: UiFocus, /// Whether the help modal is open. While true, `bindings::lookup` only /// admits `Scope::Help` actions and `render` draws the overlay last. 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, pub library: Library, pub now_playing: NowPlaying, pub queue: Queue, @@ -109,6 +132,7 @@ impl App { App { focus: UiFocus::Library, show_help: false, + input: None, library, now_playing, 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 /// modal, list selections) or send the matching [`MessageFromUi`] via /// `tx`. Send failures are ignored like everywhere else in this module — @@ -161,6 +220,16 @@ impl App { Action::LibraryQueueAppend => self.library.queue_append(), Action::LibraryQueueReplace => self.library.queue_replace(), 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 => { if let Some(selected) = self.queue.selected() { self.library.queue_insert(selected); @@ -214,6 +283,21 @@ impl App { self.queue.render(f, right_side[0], queue_focused); 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. if self.show_help { help::render(f); @@ -286,6 +370,133 @@ mod tests { 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] fn queue_clear_actions_carry_the_keep_current_flag() { let (mut app, rx) = app(); diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs index 542d394..5334851 100644 --- a/cbd-tui/src/app/queue.rs +++ b/cbd-tui/src/app/queue.rs @@ -63,6 +63,7 @@ impl Queue { kind: UiItemKind::Track, marked: false, is_queable: false, + is_creatable: false, }) .collect(); diff --git a/cbd-tui/src/main.rs b/cbd-tui/src/main.rs index 81ac853..6e0e7ec 100644 --- a/cbd-tui/src/main.rs +++ b/cbd-tui/src/main.rs @@ -115,6 +115,18 @@ async fn poll( 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) => { rpc_client.append_tracks(uuids).await? } @@ -251,7 +263,12 @@ fn run_ui(tx: Sender, rx: Receiver) { if event::poll(timeout).unwrap() { if let Event::Key(key) = event::read().unwrap() { 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 { break; } diff --git a/cbd-tui/src/rpc.rs b/cbd-tui/src/rpc.rs index a8d5657..5e06596 100644 --- a/cbd-tui/src/rpc.rs +++ b/cbd-tui/src/rpc.rs @@ -1,9 +1,10 @@ use crabidy_core::proto::crabidy::{ crabidy_service_client::CrabidyServiceClient, AppendRequest, ChangeVolumeRequest, - ClearQueueRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, - InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, - RemoveRequest, ReplaceRequest, RestartTrackRequest, SetCurrentRequest, ToggleMuteRequest, - TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, + ClearQueueRequest, CreateLibraryNodeRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, + GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, + PrevRequest, QueueRequest, RemoveRequest, ReplaceRequest, RestartTrackRequest, + SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, + ToggleShuffleRequest, }; use std::{collections::HashMap, error::Error, fmt, time::Duration}; @@ -95,6 +96,33 @@ impl RpcClient { 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> { + 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) -> Result<(), Box> { let append_request = Request::new(AppendRequest { paths }); self.client.append(append_request).await?; diff --git a/crabidy-core/Cargo.toml b/crabidy-core/Cargo.toml index 7e9d71b..5ef067b 100644 --- a/crabidy-core/Cargo.toml +++ b/crabidy-core/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true async-trait.workspace = true clap-serde-derive.workspace = true dirs.workspace = true +percent-encoding.workspace = true prost.workspace = true serde.workspace = true toml.workspace = true diff --git a/crabidy-core/crabidy/v1/crabidy.proto b/crabidy-core/crabidy/v1/crabidy.proto index db35bf6..eeadeab 100644 --- a/crabidy-core/crabidy/v1/crabidy.proto +++ b/crabidy-core/crabidy/v1/crabidy.proto @@ -7,6 +7,11 @@ service CrabidyService { // Library 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 rpc Queue(QueueRequest) returns (QueueResponse); @@ -51,6 +56,17 @@ message GetLibraryNodeResponse { 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 message QueueRequest { repeated string paths = 1; @@ -142,6 +158,8 @@ message LibraryNodeChild { string path = 1; string title = 2; bool is_queable = 3; + // Children may be created under this node (see CreateLibraryNode). + bool is_creatable = 4; } message QueueModifiers { @@ -197,4 +215,6 @@ message LibraryNode { optional string parent = 4; repeated Track tracks = 5; bool is_queable = 6; + // Children may be created under this node (see CreateLibraryNode). + bool is_creatable = 7; } diff --git a/crabidy-core/src/lib.rs b/crabidy-core/src/lib.rs index 6316146..a94d640 100644 --- a/crabidy-core/src/lib.rs +++ b/crabidy-core/src/lib.rs @@ -28,15 +28,33 @@ pub trait ProviderClient: std::fmt::Debug + Send + Sync { async fn get_metadata_for_track(&self, track_path: &str) -> Result; fn get_lib_root(&self) -> LibraryNode; async fn get_lib_node(&self, path: &str) -> Result; + /// 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; } -#[derive(Clone, Debug, Hash)] +#[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum ProviderError { Config(String), UnknownUser, CouldNotLogin, FetchError, 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, Other, } @@ -78,6 +96,32 @@ pub fn path_segments(path: &str) -> Vec<&str> { 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 { pub fn new() -> Self { Self { @@ -87,16 +131,20 @@ impl LibraryNode { parent: None, tracks: Vec::new(), is_queable: false, + is_creatable: false, } } } 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 { Self { path, title, is_queable, + is_creatable: false, } } } @@ -163,4 +211,41 @@ mod tests { 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); + } + } } diff --git a/crabidy-server/src/main.rs b/crabidy-server/src/main.rs index f29bd2f..578f0ef 100644 --- a/crabidy-server/src/main.rs +++ b/crabidy-server/src/main.rs @@ -156,6 +156,13 @@ pub enum ProviderCommand { path: String, result_tx: flume::Sender>, }, + /// 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>, + }, } impl ProviderCommand { @@ -164,6 +171,7 @@ impl ProviderCommand { Self::GetLibraryNode { .. } => "get_library_node", Self::GetTrackUrls { .. } => "get_track_urls", Self::ResolveTracks { .. } => "resolve_tracks", + Self::CreateLibraryNode { .. } => "create_library_node", } } } diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 966185b..c272c1b 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -46,6 +46,16 @@ impl ProviderOrchestrator { 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"); 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 { + 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) + } } diff --git a/crabidy-server/src/rpc.rs b/crabidy-server/src/rpc.rs index 4c323fd..7645add 100644 --- a/crabidy-server/src/rpc.rs +++ b/crabidy-server/src/rpc.rs @@ -2,14 +2,16 @@ use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crabidy_core::proto::crabidy::{ crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate, AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, - ClearQueueResponse, GetLibraryNodeRequest, GetLibraryNodeResponse, GetUpdateStreamRequest, - GetUpdateStreamResponse, InitRequest, InitResponse, InsertRequest, InsertResponse, NextRequest, - NextResponse, PrevRequest, PrevResponse, QueueRequest, QueueResponse, RemoveRequest, - RemoveResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RestartTrackResponse, - SaveQueueRequest, SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest, - StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, + ClearQueueResponse, CreateLibraryNodeRequest, CreateLibraryNodeResponse, GetLibraryNodeRequest, + GetLibraryNodeResponse, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, + InitResponse, InsertRequest, InsertResponse, NextRequest, NextResponse, PrevRequest, + PrevResponse, QueueRequest, QueueResponse, RemoveRequest, RemoveResponse, ReplaceRequest, + ReplaceResponse, RestartTrackRequest, RestartTrackResponse, SaveQueueRequest, + SaveQueueResponse, SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, + ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse, }; +use crabidy_core::ProviderError; use std::pin::Pin; use tokio_stream::StreamExt; 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, + ) -> Result, 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))] async fn queue( &self, diff --git a/plan/search.md b/plan/search.md new file mode 100644 index 0000000..6f32496 --- /dev/null +++ b/plan/search.md @@ -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` 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`; 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: ` → + `/tidal/artists/`, queueable) then albums (`Album: ` → + `/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`. diff --git a/plan/summary.md b/plan/summary.md index a08a4db..3ce103d 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1,5 +1,42 @@ # 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) 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 `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; the full table is ~50 rows and would not fit even a 100×40 frame. The modal diff --git a/quality/search.md b/quality/search.md new file mode 100644 index 0000000..1c2f8d1 --- /dev/null +++ b/quality/search.md @@ -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. diff --git a/tidaldy/src/lib.rs b/tidaldy/src/lib.rs index cf918d2..3fca51e 100644 --- a/tidaldy/src/lib.rs +++ b/tidaldy/src/lib.rs @@ -16,6 +16,10 @@ pub struct Client { /// Login state changes at runtime when tokens are refreshed, while the /// client is shared immutably, hence the lock. Never held across awaits. 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. @@ -50,7 +54,9 @@ impl crabidy_core::ProviderClient for Client { fn is_track_path(&self, path: &str) -> bool { matches!( 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(), 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_creatable: false, } } @@ -121,9 +136,9 @@ impl crabidy_core::ProviderClient for Client { &self, path: &str, ) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> { - let Some(user_id) = self.get_user_id() else { - return Err(crabidy_core::ProviderError::UnknownUser); - }; + // The user id is only needed for the favorites listings; search must + // 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)?; debug!(?parsed, "resolving library node"); let parent = crabidy_core::parent_path(path) @@ -139,7 +154,9 @@ impl crabidy_core::ProviderClient for Client { tracks: Vec::new(), children: Vec::new(), is_queable: false, + is_creatable: false, }; + let user_id = user_id.ok_or(crabidy_core::ProviderError::UnknownUser)?; for playlist in self .get_users_playlists_and_favorite_playlists(&user_id) .await? @@ -168,6 +185,7 @@ impl crabidy_core::ProviderClient for Client { tracks, children: Vec::new(), is_queable: true, + is_creatable: false, } } TidalPath::Artists => { @@ -178,7 +196,9 @@ impl crabidy_core::ProviderClient for Client { tracks: Vec::new(), children: Vec::new(), 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? { node.children .push(crabidy_core::proto::crabidy::LibraryNodeChild::new( @@ -210,6 +230,7 @@ impl crabidy_core::ProviderClient for Client { tracks: Vec::new(), children, is_queable: true, + is_creatable: false, } } TidalPath::Album { album, .. } => { @@ -227,20 +248,73 @@ impl crabidy_core::ProviderClient for Client { tracks, children: Vec::new(), 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"); return Err(crabidy_core::ProviderError::MalformedPath); } }; 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. 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 /// in the path itself. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -263,6 +337,18 @@ pub enum TidalPath<'a> { album: &'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> { @@ -280,6 +366,9 @@ pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderErr album, 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"); 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> { 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"); Err(crabidy_core::ProviderError::MalformedPath) @@ -309,6 +400,7 @@ impl Client { http_client, settings, login, + search_terms: std::sync::RwLock::new(Vec::new()), }) } @@ -485,6 +577,8 @@ impl Client { 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))] pub async fn search(&self, query: &str) -> Result<(), ClientError> { let query = vec![("query", query.to_string())]; @@ -493,6 +587,131 @@ impl Client { 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))] pub async fn get_playlist_tracks( &self, @@ -861,6 +1080,112 @@ mod tests { 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] #[ignore = "requires a local tidal config and network access"] async fn test() {