Add Tidal search as creatable library nodes
Pressing % inside /tidal/search opens an input line; the entered term becomes a tree node whose contents are the search results: track hits queueable in place, artist and album hits as canonical /tidal/artists paths. New CreateLibraryNode rpc + is_creatable flags (wire-compatible), ProviderClient::create_lib_node routed by prefix, percent-encoded term segments in crabidy-core, and a modal input overlay in the TUI with creatable nodes marked [%]. Search terms live in memory for the process lifetime; term nodes are deliberately not queueable so the resolve sweep cannot drag whole discographies into the queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8586194096
commit
d741523e53
|
|
@ -723,6 +723,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"clap-serde-derive",
|
||||
"dirs",
|
||||
"percent-encoding",
|
||||
"prost",
|
||||
"serde",
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
# Search via creatable library nodes
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Crabidy's library is a lazily-fetched tree served by providers; today it is
|
||||
read-only and only exposes the user's favorites (playlists, followed artists).
|
||||
There is no way to find anything new. The desired interface (set by the user)
|
||||
is not a search dialog but **tree editing**: a search subtree under the
|
||||
provider in which the user *creates* nodes. Pressing `%` inside a creatable
|
||||
node prompts for a term; the term becomes a child node whose contents are the
|
||||
search results. Creatable places must be visibly marked in the UI.
|
||||
|
||||
`tidaldy` already has a `search()` explorer stub (`search/artists`, response
|
||||
dumped to the debug log), so the Tidal endpoint family
|
||||
(`search/tracks|artists|albums`) is known to exist but its response shape is
|
||||
unverified against our models.
|
||||
|
||||
## Decisions
|
||||
|
||||
Per the user's instruction, decisions from here on were taken autonomously and
|
||||
are recorded with rationale below (Options → Decision per topic). The one
|
||||
user-set constraint is the interface itself: creatable nodes in the tree, `%`
|
||||
to create, terms become persistent nodes.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Search is per-provider.** The search subtree lives at `/tidal/search`, not
|
||||
at the global root; a future provider brings its own. Consistent with prefix
|
||||
routing.
|
||||
- **Created nodes are ephemeral**, like every other piece of server state (the
|
||||
queue dies with the process, `SaveQueue` is a stub). Terms live in memory in
|
||||
the Tidal client for the process lifetime. Persistence is future work.
|
||||
- **"Editable" is scoped down to "creatable" for v1.** The described flow only
|
||||
needs create. The RPC is named so that `RemoveLibraryNode` /
|
||||
`RenameLibraryNode` can join it later without redesign (open question below).
|
||||
- **Search results are a view, not a copy.** Fetched fresh on node expansion,
|
||||
like playlists and albums are today. No caching, no staleness handling.
|
||||
|
||||
## Options considered
|
||||
|
||||
### 1. Mechanism: what does the wire contract look like?
|
||||
|
||||
**A — search-specific RPC** (`Search(query) → results`), TUI renders results
|
||||
in an ad-hoc pane. Least server work, but it is exactly the interface the
|
||||
user rejected: no tree nodes, nothing persistent to revisit, and a second
|
||||
navigation model in the TUI.
|
||||
|
||||
**B — generic node creation (chosen)**: `CreateLibraryNode(parent_path,
|
||||
title) → LibraryNode`, routed through `ProviderOrchestrator` by prefix like
|
||||
`GetLibraryNode`. The provider decides what creation *means* under a given
|
||||
parent; for `/tidal/search` the title is the search term. `LibraryNode` and
|
||||
`LibraryNodeChild` gain `bool is_creatable` so any client can mark creatable
|
||||
places generically. This matches the user's mental model, and the same RPC
|
||||
later covers other creatable things (e.g. new playlists).
|
||||
|
||||
### 2. Result shape: how do results hang in the tree?
|
||||
|
||||
**A — full nesting**: results live entirely under
|
||||
`/tidal/search/<term>/artists/<id>/<album>/<track>`. Self-contained
|
||||
navigation, but `TidalPath` grows a parallel copy of every variant (search
|
||||
versions of artist, album, both track kinds), `parse_path` needs recursive
|
||||
suffix matching, and the same Tidal entity gets yet another path identity.
|
||||
|
||||
**B — canonical-path children (chosen)**: `/tidal/search/<term>` carries the
|
||||
**track** results directly as its `tracks` (queueable in place, paths
|
||||
`/tidal/search/<term>/<track-id>` — one new track variant), while artist and
|
||||
album results are children whose paths are **canonical**
|
||||
(`/tidal/artists/<id>`, `/tidal/artists/<artist>/<album>`). Diving into an
|
||||
artist result reuses the existing artist/album/track machinery unchanged —
|
||||
`get_lib_node(/tidal/artists/<id>)` already works for any id, favorite or not.
|
||||
|
||||
Cost of B, accepted and documented: after diving from a search result into an
|
||||
artist, `h` (ascend) follows the canonical parent to `/tidal/artists`
|
||||
(favorites), not back to the search node. This is the existing
|
||||
"identity is context-dependent" trade-off from `architecture/overview.md`
|
||||
pointing the other way. B wins because it adds two path variants instead of
|
||||
five and cannot drift from the canonical browse behavior.
|
||||
|
||||
**Term nodes are not queueable** (decided during api-design): the server's
|
||||
`resolve_tracks` breadth-first-sweeps *all* children of a queueable node, so a
|
||||
queueable term node would turn "queue this search" into the top tracks *plus
|
||||
every album of every artist result*. `is_queable = false` on the term node
|
||||
keeps queueing explicit: individual track results (and any artist/album dived
|
||||
into) queue normally. Queueing the term node itself yields nothing, exactly
|
||||
like the existing category nodes (`playlists`, `artists`).
|
||||
|
||||
### 3. Term encoding in paths
|
||||
|
||||
Terms are user text; paths are `/`-separated and split by
|
||||
`path_segments`. A term like `AC/DC` or `100%` must not corrupt the tree.
|
||||
|
||||
**A — reject problematic characters**: surprising, and `%`-in-term is a
|
||||
plausible music query.
|
||||
**B — percent-encode the segment (chosen)**: the term is percent-encoded into
|
||||
the path segment (`AC/DC` → `AC%2FDC`); the node `title` keeps the raw term
|
||||
for display. Round-trip helpers live next to the other path helpers in
|
||||
`crabidy-core` (`encode_segment` / `decode_segment`), implemented with the
|
||||
`percent-encoding` crate (tiny, maintained, already in the dependency tree via
|
||||
`reqwest`/`url`).
|
||||
|
||||
Idempotency: creating an existing term returns the existing node rather than
|
||||
erroring; empty/whitespace-only terms are rejected with
|
||||
`ProviderError::InvalidInput` (new variant, also covers "this parent is not
|
||||
creatable" as `NotSupported`).
|
||||
|
||||
### 4. TUI text entry
|
||||
|
||||
First text input in the TUI. **A — reuse the bindings table** by adding an
|
||||
input scope: wrong tool — free text is not a finite set of chords.
|
||||
**B — a modal input line (chosen)**: `%` (Library scope, only when the
|
||||
*currently open* node — not the selected child — has `is_creatable`) opens a
|
||||
one-line input overlay at the bottom of the library pane. While it is open the
|
||||
bindings table is bypassed entirely except `Esc` (cancel) and `Enter`
|
||||
(submit); every other printable char appends, `Backspace` deletes. No cursor
|
||||
movement or paste handling in v1. On submit the TUI sends
|
||||
`CreateNode(parent, term)`, and navigates into the returned node.
|
||||
|
||||
The modality mechanism generalizes what the help modal introduced: the
|
||||
`(focus, help_open)` arguments of `bindings::lookup` become a single
|
||||
`InputMode`-aware gate (exact shape decided in api-design). `%` itself is a
|
||||
`BINDINGS` entry, so it shows up in the help modal like everything else.
|
||||
|
||||
### 5. Marking creatable nodes in the UI
|
||||
|
||||
Children with `is_creatable` render with a `%` marker suffix (e.g.
|
||||
`search [%]`) in `COLOR_SECONDARY`, and when the open node itself is
|
||||
creatable the pane title shows the hint (`search — % to add`). No new
|
||||
keybinding needed to discover it: the marker is the affordance, the help modal
|
||||
documents `%`.
|
||||
|
||||
## Structure
|
||||
|
||||
New/changed pieces, hatched by crate:
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
tui: cbd-tui {
|
||||
input: input overlay (new)
|
||||
bindings: "% binding + input-mode gate"
|
||||
library: creatable marker
|
||||
}
|
||||
|
||||
core: crabidy-core {
|
||||
proto: "proto: CreateLibraryNode rpc,\nis_creatable fields"
|
||||
trait: "ProviderClient::create_lib_node (new)"
|
||||
enc: "encode_segment / decode_segment (new)"
|
||||
}
|
||||
|
||||
server: crabidy-server {
|
||||
orchestrator: "ProviderOrchestrator:\nCreateLibraryNode command,\nprefix routing"
|
||||
}
|
||||
|
||||
tidaldy: tidaldy {
|
||||
search: "typed search requests (new)"
|
||||
terms: "search_terms: RwLock (new state)"
|
||||
paths: "TidalPath::Search, SearchTerm,\nSearchTrack (new variants)"
|
||||
}
|
||||
|
||||
tui.input -> core.proto: CreateLibraryNode
|
||||
core.proto -> server.orchestrator
|
||||
server.orchestrator -> tidaldy.terms: create under /tidal/search
|
||||
tidaldy.search -> core.trait: results as LibraryNode
|
||||
```
|
||||
|
||||
Path shape after the change (green = track paths, dashed = canonical jumps):
|
||||
|
||||
```d2
|
||||
direction: right
|
||||
|
||||
tidal: "/tidal"
|
||||
search: "/tidal/search (creatable)"
|
||||
term: "/tidal/search/<term>"
|
||||
strack: "/tidal/search/<term>/<track-id>" {
|
||||
style.fill: "#e8f4e8"
|
||||
}
|
||||
artists: "/tidal/artists"
|
||||
artist: "/tidal/artists/<id>"
|
||||
album: "/tidal/artists/<id>/<album-id>"
|
||||
|
||||
tidal -> search
|
||||
search -> term: created via %
|
||||
term -> strack: track results
|
||||
term -> artist: artist result (canonical path) {style.stroke-dash: 3}
|
||||
term -> album: album result (canonical path) {style.stroke-dash: 3}
|
||||
artists -> artist
|
||||
artist -> album
|
||||
```
|
||||
|
||||
Create flow:
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
|
||||
user: User
|
||||
tui: cbd-tui
|
||||
rpc: gRPC service
|
||||
orch: ProviderOrchestrator
|
||||
tidal: tidaldy::Client
|
||||
api: Tidal REST API
|
||||
|
||||
user -> tui: "% inside /tidal/search"
|
||||
tui -> tui: open input overlay (bindings bypassed)
|
||||
user -> tui: types term, Enter
|
||||
tui -> rpc: CreateLibraryNode(/tidal/search, term)
|
||||
rpc -> orch: ProviderCommand::CreateLibraryNode
|
||||
orch -> tidal: create_lib_node(parent, title)
|
||||
tidal -> tidal: store term (idempotent)
|
||||
tidal -> api: "search/tracks|artists|albums?query=term"
|
||||
api -> tidal: results
|
||||
tidal -> orch: "LibraryNode /tidal/search/<enc(term)>"
|
||||
orch -> rpc: node
|
||||
rpc -> tui: node
|
||||
tui -> tui: ReplaceLibraryNode (navigate into results)
|
||||
```
|
||||
|
||||
## Boundaries and interfaces (high level)
|
||||
|
||||
- **proto**: `rpc CreateLibraryNode(CreateLibraryNodeRequest) returns
|
||||
(CreateLibraryNodeResponse)`; request = `parent_path`, `title`; response =
|
||||
the created `LibraryNode`. `LibraryNode.is_creatable = 7`,
|
||||
`LibraryNodeChild.is_creatable = 4` — additive, wire-compatible.
|
||||
- **ProviderClient**: new required method `create_lib_node(parent_path,
|
||||
title) -> Result<LibraryNode, ProviderError>`. `ProviderError` gains
|
||||
`NotSupported` and `InvalidInput` variants. The orchestrator's synthetic
|
||||
root and the server's mock provider return `NotSupported`.
|
||||
- **ProviderCommand**: new `CreateLibraryNode { parent_path, title,
|
||||
result_tx }`, same bounded(1)-reply pattern and 30s-timeout discipline as
|
||||
the existing commands.
|
||||
- **tidaldy**: `TidalPath::{Search, SearchTerm, SearchTrack}`; typed
|
||||
`search_tracks/search_artists/search_albums` (first page, limit 20 per
|
||||
category — search is exploratory, not exhaustive; `make_paginated_request`'s
|
||||
fetch-everything loop is wrong for it); `search_terms:
|
||||
RwLock<Vec<String>>` following the existing login-state locking discipline
|
||||
(never held across await).
|
||||
- **cbd-tui**: input overlay state on `App`; `MessageFromUi::CreateNode`;
|
||||
`rpc.rs` client method; `%` in `BINDINGS` (Library scope); creatable marker
|
||||
in the library list rendering.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Unverified search response shape.** Our `Track`/`Artist`/`Album` models
|
||||
may not match `search/*` payloads (the explorer stub exists precisely
|
||||
because this was unexplored). Mitigation: first implement task runs the
|
||||
explorer request against the live API and locks the models down; if the
|
||||
shapes differ, only `tidaldy::models` grows search-specific wrappers.
|
||||
- **Trait change ripples.** Adding a required `ProviderClient` method touches
|
||||
the server's mock provider and any test doubles. Deliberate: a default
|
||||
"not supported" impl would hide missing implementations silently.
|
||||
- **Input overlay vs. terminal reality.** Paste arrives as a burst of char
|
||||
events (fine: they append), IME composition is untested. v1 accepts this.
|
||||
- **Term nodes are invisible to other clients** until they re-fetch
|
||||
`/tidal/search` — there is no library update stream. Accepted; browsing is
|
||||
pull-based today.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Delete/rename of created nodes (`RemoveLibraryNode`?) — the RPC family and
|
||||
`is_creatable` flag anticipate it; not in v1.
|
||||
- Should created terms persist across restarts (they'd fit a small TOML next
|
||||
to the token store)? Deferred with the queue-persistence question.
|
||||
- Combined-search ranking: v1 shows tracks, then artists, then albums in
|
||||
fixed category order; relevance interleaving would need the combined
|
||||
`search` endpoint.
|
||||
|
|
@ -55,6 +55,11 @@ pub enum Action {
|
|||
LibraryQueueAppend,
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -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<UiItem>,
|
||||
list_state: ListState,
|
||||
parent: Option<String>,
|
||||
|
|
@ -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<Vec<String>> {
|
||||
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,9 +146,13 @@ 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() {
|
||||
// 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()
|
||||
|
|
@ -140,22 +162,17 @@ impl Library {
|
|||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
is_queable: true,
|
||||
is_creatable: false,
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
// if tracks not empty use tracks instead
|
||||
self.list = node
|
||||
.children
|
||||
.iter()
|
||||
.map(|c| UiItem {
|
||||
.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()
|
||||
|
|
|
|||
|
|
@ -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<String>),
|
||||
QueueTracks(Vec<String>),
|
||||
InsertTracks(Vec<String>, 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<InputState>,
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ impl Queue {
|
|||
kind: UiItemKind::Track,
|
||||
marked: false,
|
||||
is_queable: false,
|
||||
is_creatable: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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>> {
|
||||
let append_request = Request::new(AppendRequest { paths });
|
||||
self.client.append(append_request).await?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
fn get_lib_root(&self) -> LibraryNode;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,13 @@ pub enum ProviderCommand {
|
|||
path: String,
|
||||
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 {
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LibraryNode, ProviderError> {
|
||||
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
||||
return self.tidal_client.create_lib_node(parent_path, title).await;
|
||||
}
|
||||
warn!(parent_path, "no provider supports creating nodes here");
|
||||
Err(ProviderError::NotSupported)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
|||
use crabidy_core::proto::crabidy::{
|
||||
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<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))]
|
||||
async fn queue(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
# Plan — search via creatable nodes
|
||||
|
||||
Ordered tasks for the `implement` stage. Inputs: `architecture/search.md`,
|
||||
stubs across all five crates, gates in `quality/search.md`. Tests:
|
||||
`devenv shell -- cargo test -p crabidy-core -p tidaldy -p cbd-tui`
|
||||
(11 failing at plan time = the target). Note: use a session-local
|
||||
`CARGO_TARGET_DIR` if `target/` contains root-owned artifacts from the
|
||||
repo owner's builds.
|
||||
|
||||
## 1. Path segment encoding (crabidy-core)
|
||||
|
||||
- [x] Add the `percent-encoding` crate (workspace dependency; check the
|
||||
nixpkgs/devenv side is unaffected — pure Rust). Implement
|
||||
`encode_segment` (encode `/`, `%`, whitespace, controls and everything
|
||||
non-alphanumeric-unreserved via an `AsciiSet`) and `decode_segment`
|
||||
(lossy UTF-8 decode). **Verify**: the three `crabidy-core` tests pass
|
||||
(`encode_segment_round_trips_arbitrary_text`,
|
||||
`encoded_segments_are_path_safe`, `decode_segment_is_lossy_not_panicky`);
|
||||
gates "encode/decode are total", "only percent-encoding added".
|
||||
|
||||
## 2. Verify the search payload shape (tidaldy, live API)
|
||||
|
||||
- [x] Before typing the models: run the existing explorer helper
|
||||
(`Client::search`) once against the live API (needs the local tidal
|
||||
config; if unavailable, consult the response shapes used by other tdl
|
||||
clients and mark the gate as verified-by-proxy). Confirm whether
|
||||
`search/tracks|artists|albums` return the same `Page<T>` item shapes as
|
||||
the library endpoints; put any deviation into `tidaldy::models`.
|
||||
**Verify**: gate "models verified against the live API".
|
||||
|
||||
## 3. Typed search requests (tidaldy)
|
||||
|
||||
- [x] Implement `search_tracks/search_artists/search_albums`: single
|
||||
`make_request` with `query`, `limit=SEARCH_RESULT_LIMIT`, `offset=0`,
|
||||
decoding a `Page<T>`; no pagination loop. **Verify**: gate "first page
|
||||
only"; unit-testable only against live API (leave network test
|
||||
`#[ignore]` like the existing one).
|
||||
|
||||
## 4. Search subtree in the provider (tidaldy)
|
||||
|
||||
- [x] `is_track_path`: include `TidalPath::SearchTrack`; `track_id_from_path`
|
||||
already falls out (its match is on the parsed variant — extend it).
|
||||
**Verify**: `search_track_paths_are_track_paths`,
|
||||
`track_id_is_extracted_from_search_track_paths` pass.
|
||||
- [x] `get_lib_node` `Search` arm: children = one entry per stored term
|
||||
(path `join_path(path, encode_segment(term))`, title = raw term,
|
||||
`is_queable: false`), node `is_creatable: true`. Do not require
|
||||
`user_id` for search paths (move the `get_user_id` gate into the arms
|
||||
that need it — search must work even if the user id is missing).
|
||||
**Verify**: gate "search node lists terms"; ignored network test
|
||||
extended by hand.
|
||||
- [x] `get_lib_node` `SearchTerm` arm: decode the term; implicitly register
|
||||
unknown terms (stale-client recovery); fetch the three categories
|
||||
concurrently (`tokio::join!`); node = tracks from `search_tracks`
|
||||
(`to_proto(path)`), children = artists (`Artist: <name>` →
|
||||
`/tidal/artists/<id>`, queueable) then albums (`Album: <title>` →
|
||||
`/tidal/artists/<artist-id>/<album-id>`, queueable), `is_queable:
|
||||
false`, `is_creatable: false`. **Verify**: gates "term node" +
|
||||
"canonical children"; behavior exercised end-to-end in task 9.
|
||||
- [x] `create_lib_node`: trim title → `InvalidInput` if empty; parent must
|
||||
parse to `TidalPath::Search` else `NotSupported`; store raw term
|
||||
idempotently (no duplicates, lock not held across await); return
|
||||
`self.get_lib_node(term_path)`. **Verify**: gate "create semantics";
|
||||
add non-network unit tests: create with bad parent / empty title on an
|
||||
offline client returns the right errors (no API call happens before
|
||||
validation).
|
||||
|
||||
## 5. Server plumbing (crabidy-server)
|
||||
|
||||
- [x] `ProviderOrchestrator::create_lib_node`: `/tidal`-prefixed parent →
|
||||
`tidal_client.create_lib_node`, anything else `NotSupported` (warn like
|
||||
the other routes). **Verify**: gate "orchestrator routing".
|
||||
- [x] `rpc.rs create_library_node`: bounded(1) rendezvous with
|
||||
`ProviderCommand::CreateLibraryNode`; map errors `NotSupported` →
|
||||
`failed_precondition`, `InvalidInput` → `invalid_argument`, rest →
|
||||
`internal`. **Verify**: gate "error mapping"; `cargo check`.
|
||||
|
||||
## 6. TUI input overlay (cbd-tui)
|
||||
|
||||
- [x] `App::dispatch(LibraryCreateNode)`: open
|
||||
`InputState { parent_path: library.path(), buffer: "" }` only when
|
||||
`library.is_creatable()`. **Verify**:
|
||||
`create_node_only_opens_input_on_creatable_nodes`.
|
||||
- [x] `App::handle_input_key`: Esc cancels; Enter trims + sends
|
||||
`MessageFromUi::CreateNode` (empty → just close); Backspace pops;
|
||||
`KeyCode::Char(c)` appends regardless of SHIFT; all else ignored.
|
||||
**Verify**: `input_appends_and_backspace_pops`,
|
||||
`esc_cancels_without_sending`, `enter_submits_trimmed_title_and_closes`,
|
||||
`enter_on_empty_input_closes_without_sending`.
|
||||
- [x] Event loop (`main.rs run_ui`): when `app.input.is_some()`, route the
|
||||
key to `app.handle_input_key(key)` and skip `bindings::lookup`
|
||||
entirely. **Verify**: gate "input mode bypasses bindings" (read the
|
||||
loop; also confirm `q` cannot quit while typing).
|
||||
|
||||
## 7. TUI library rendering (cbd-tui)
|
||||
|
||||
- [x] `Library::update`: apply empty nodes when `node.is_creatable` (keep the
|
||||
skip for empty non-creatable nodes). **Verify**:
|
||||
`empty_creatable_nodes_are_enterable`.
|
||||
- [x] Mark creatable children in the list (suffix marker, `COLOR_SECONDARY`)
|
||||
— requires keeping `is_creatable` on `UiItem`; hint in the pane title
|
||||
when the open node is creatable (e.g. `search — % to add`).
|
||||
**Verify**: gate "creatable marked in UI" (read render code).
|
||||
- [x] Render the input overlay: bottom line of the library pane showing
|
||||
`new node: <buffer>▏` while `input.is_some()`. **Verify**: gate "input
|
||||
overlay renders"; add a `TestBackend` test asserting the buffer text
|
||||
appears while open (mirror the help-modal test helpers).
|
||||
|
||||
## 8. TUI ↔ server wiring (cbd-tui)
|
||||
|
||||
- [x] `RpcClient::create_library_node`: send request, evict
|
||||
`library_node_cache` entry for `parent_path`, insert the returned node,
|
||||
return it. **Verify**: gate "cache eviction" (read; the cache is
|
||||
private — no test seam without refactoring, keep it a gate).
|
||||
- [x] `main.rs poll`: `CreateNode` arm calls the client method; on success
|
||||
`ReplaceLibraryNode(node)`; on failure log and leave the UI unchanged
|
||||
(no panic — replace the stub's `todo!`). **Verify**: gate "create
|
||||
failures surface as errors, never a panic".
|
||||
|
||||
## 9. End-to-end + gates sweep
|
||||
|
||||
- [x] Run the real stack (`crabidy-server` + `cbd-tui`, needs tidal login):
|
||||
enter `/tidal/search`, `%`, type a term, Enter; results appear; queue a
|
||||
track result; dive into an artist result. If no login is available,
|
||||
exercise create/list/error paths against the offline client instead
|
||||
and note it in the summary. **Verify**: architecture flow diagram
|
||||
matches reality.
|
||||
- [x] Sweep: no `todo!()` left (workspace grep), fmt + clippy + tests green,
|
||||
every `quality/search.md` box checked, docs updated where behavior
|
||||
shifted. Append the outcome + deviations to `plan/summary.md`.
|
||||
|
|
@ -1,5 +1,42 @@
|
|||
# Implementation summaries
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
# Quality gates — search via creatable nodes
|
||||
|
||||
Checklist for the `implement` stage. Automatic tests live in
|
||||
`crabidy-core/src/lib.rs`, `tidaldy/src/lib.rs`, and
|
||||
`cbd-tui/src/app/{bindings,mod}.rs` test modules; run with
|
||||
`devenv shell -- cargo test --workspace` (network-dependent tidaldy tests
|
||||
stay `#[ignore]`).
|
||||
|
||||
## Contract & wire
|
||||
|
||||
- [x] Proto changes are additive only: existing field numbers untouched;
|
||||
`is_creatable` fields 7 (`LibraryNode`) / 4 (`LibraryNodeChild`);
|
||||
`CreateLibraryNode` rpc present. Old clients keep working against the
|
||||
new server.
|
||||
- [x] `rpc.rs` (server) maps errors per the documented contract:
|
||||
`NotSupported` → `failed_precondition`, `InvalidInput` →
|
||||
`invalid_argument`, everything else → `internal`. No `color-eyre`
|
||||
report or debug formatting of internals leaks into `Status` messages.
|
||||
- [x] `ProviderOrchestrator::create_lib_node` routes `/tidal`-prefixed
|
||||
parents to the Tidal client and answers anything else (including the
|
||||
synthetic root `/`) with `NotSupported` — same prefix discipline as
|
||||
`get_lib_node`.
|
||||
- [x] The `CreateLibraryNode` command follows the existing bounded(1)-reply
|
||||
rendezvous pattern; no new unbounded channels anywhere in the feature.
|
||||
|
||||
## Provider semantics (tidaldy)
|
||||
|
||||
- [x] `create_lib_node` trims the title; empty/whitespace → `InvalidInput`;
|
||||
parent other than `/tidal/search` → `NotSupported`; idempotent (an
|
||||
existing term returns its node, the term list holds no duplicates).
|
||||
- [x] `search_terms` stores **raw** terms; the path segment is
|
||||
`encode_segment(term)`; the node `title` shows the raw term. The lock
|
||||
is never held across an `await`.
|
||||
- [x] `get_lib_node(/tidal/search)` lists created terms as children (decoded
|
||||
titles) and sets `is_creatable: true` on the node; an unknown term path
|
||||
under search is implicitly (re)created, not an error — survives server
|
||||
restarts with stale client caches.
|
||||
- [x] The term node sets `is_queable: false` (see architecture/search.md:
|
||||
`resolve_tracks` would otherwise sweep every artist/album result into
|
||||
the queue). Its `tracks` are the track results with paths
|
||||
`/tidal/search/<enc-term>/<track-id>`; artist/album results are
|
||||
children with **canonical** `/tidal/artists/...` paths.
|
||||
- [x] `search_tracks/artists/albums` fetch a single page with
|
||||
`limit=SEARCH_RESULT_LIMIT` — they must NOT use
|
||||
`make_paginated_request`'s fetch-everything loop.
|
||||
- [x] `get_urls_for_track` and `get_metadata_for_track` work for
|
||||
`SearchTrack` paths (via `track_id_from_path`).
|
||||
- [x] The response models were verified against the live API (explorer
|
||||
request) before the typed methods were finalized; deviations live in
|
||||
`tidaldy::models`, not in ad-hoc serde attributes inline.
|
||||
|
||||
## No panics on user input (hard rule)
|
||||
|
||||
- [x] All `todo!()` stubs from api-design are gone (grep the workspace).
|
||||
- [x] `encode_segment`/`decode_segment` are total: any client-supplied
|
||||
segment decodes without panicking (lossy, not `unwrap`).
|
||||
- [x] Search/create failures (network, auth, malformed) surface as
|
||||
`ProviderError`/`Status`, never a panic; the TUI's create path handles
|
||||
an error reply without crashing the orchestrate task.
|
||||
|
||||
## TUI behavior
|
||||
|
||||
- [x] While `app.input.is_some()`, the event loop routes keys exclusively to
|
||||
`handle_input_key` — the bindings table (including `q`/quit and `?`)
|
||||
is unreachable; `%` while the overlay is already open just inserts the
|
||||
character.
|
||||
- [x] `Library::update` applies nodes with no children and no tracks when
|
||||
`is_creatable` (empty search node is enterable); genuinely empty
|
||||
non-creatable nodes keep the existing skip behavior.
|
||||
- [x] Creatable children are visibly marked in the library list and the pane
|
||||
title hints at `%` when the open node is creatable.
|
||||
- [x] The input overlay renders inside the library pane (bottom line) with
|
||||
the typed buffer visible; it disappears on cancel/submit.
|
||||
- [x] `RpcClient::create_library_node` evicts the parent's cache entry and
|
||||
caches the created node (stale `/tidal/search` listings would
|
||||
otherwise hide new terms).
|
||||
- [x] On successful create the library navigates into the returned node
|
||||
(`ReplaceLibraryNode`); on failure the library stays where it was.
|
||||
|
||||
## Code quality
|
||||
|
||||
- [x] Public items added in all five crates have doc comments matching final
|
||||
behavior; the `%` binding appears in the help modal automatically.
|
||||
- [x] New dependency: only `percent-encoding` (workspace-managed version);
|
||||
nothing else added.
|
||||
- [x] `devenv shell -- cargo fmt --check`, `cargo clippy --workspace`
|
||||
(no new warnings), `cargo test --workspace` all pass.
|
||||
|
|
@ -16,6 +16,10 @@ pub struct Client {
|
|||
/// Login state changes at runtime when tokens are refreshed, while the
|
||||
/// 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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue