crabidy/architecture/search.md

265 lines
11 KiB
Markdown

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