265 lines
11 KiB
Markdown
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.
|