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:
Test User 2026-07-20 17:58:44 +02:00
parent 8586194096
commit d741523e53
19 changed files with 1392 additions and 51 deletions

1
Cargo.lock generated
View File

@ -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",

View File

@ -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"

264
architecture/search.md Normal file
View File

@ -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.

View File

@ -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!(

View File

@ -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()

View File

@ -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();

View File

@ -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();

View File

@ -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;
} }

View File

@ -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?;

View File

@ -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

View File

@ -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;
} }

View File

@ -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);
}
}
} }

View File

@ -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",
} }
} }
} }

View File

@ -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)
}
} }

View File

@ -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,

130
plan/search.md Normal file
View File

@ -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`.

View File

@ -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

87
quality/search.md Normal file
View File

@ -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.

View File

@ -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(&params))
.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() {