crabidy/architecture/search.md

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, 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/DCAC%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 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.