11 KiB
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,
SaveQueueis 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/RenameLibraryNodecan 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:
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):
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:
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 createdLibraryNode.LibraryNode.is_creatable = 7,LibraryNodeChild.is_creatable = 4— additive, wire-compatible. - ProviderClient: new required method
create_lib_node(parent_path, title) -> Result<LibraryNode, ProviderError>.ProviderErrorgainsNotSupportedandInvalidInputvariants. The orchestrator's synthetic root and the server's mock provider returnNotSupported. - 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}; typedsearch_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.rsclient method;%inBINDINGS(Library scope); creatable marker in the library list rendering.
Risks
- Unverified search response shape. Our
Track/Artist/Albummodels may not matchsearch/*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, onlytidaldy::modelsgrows search-specific wrappers. - Trait change ripples. Adding a required
ProviderClientmethod 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 andis_creatableflag 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
searchendpoint.