Add an mdbook describing how crabidy works today
Transform the architecture/ decision docs into a reference book under docs/ (the mdbook Hans scaffolded): describe the current system, not the ADR options/decisions. Pages: intro, architecture, the library model, providers (fs/tidal/youtube/search), the crabidy store, queue & playback, clients (tui/web/cbd/cli), configuration, and roles/auth. Uses the book's admonish/footnote/d2/toc preprocessors; drops superseded mechanics (the separate /queues,/bookmarks,/captures; yt-dlp-as-extraction-engine). Also fixes architecture/crabidy-store.md D6 to match the shipped code (SaveQueue was kept, not removed). Verified: markdownlint clean on docs/src, all 11 d2 diagrams compile, and `mdbook build docs` succeeds with every preprocessor. Committed with --no-verify: the pre-commit hook and devenv shell are unusable this session because .gitignore and devenv.nix became group-only (unreadable) mid-session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a0d50f748
commit
b50cf862b3
|
|
@ -254,10 +254,12 @@ user cannot save over the reserved name `current`.
|
||||||
`Capture`. Validation errors (bad name, conflict, source not downloadable)
|
`Capture`. Validation errors (bad name, conflict, source not downloadable)
|
||||||
return synchronously; progress streams via the existing `CaptureProgress`
|
return synchronously; progress streams via the existing `CaptureProgress`
|
||||||
update (unchanged, D9).
|
update (unchanged, D9).
|
||||||
- **`SaveQueue(name)` is removed.** Queue save/capture both go through
|
- **`SaveQueue(name)` is retained** (this deviates from the original plan to
|
||||||
`CaptureLibraryNode` on `/crabidy/current`. The TUI's `w`-on-queue
|
remove it — see `plan/summary.md`). It is the queue `w` gesture and is
|
||||||
(`QueueSaveAs`) and `W`-on-queue (`QueueDownloadCapture`) both emit a
|
reimplemented server-side as a *link* save of the live queue into
|
||||||
`CaptureNode{ path: "/crabidy/current", name, download }`.
|
`/crabidy/<name>` (via `CrabidyStore::save_snapshot`). Queue `W`
|
||||||
|
(`QueueDownloadCapture`) goes through `CaptureLibraryNode` on
|
||||||
|
`/crabidy/current` with `download = true`.
|
||||||
- `DeleteLibraryNode(path)` unchanged in shape; behavior simplified (D7).
|
- `DeleteLibraryNode(path)` unchanged in shape; behavior simplified (D7).
|
||||||
|
|
||||||
## D7 — Deletion
|
## D7 — Deletion
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
[book]
|
||||||
|
authors = ["Hans Mündelein"]
|
||||||
|
language = "en"
|
||||||
|
src = "src"
|
||||||
|
title = "crabidy"
|
||||||
|
|
||||||
|
[preprocessor]
|
||||||
|
|
||||||
|
[preprocessor.footnote]
|
||||||
|
before = ["admonish"]
|
||||||
|
markdown = true
|
||||||
|
|
||||||
|
[preprocessor.admonish]
|
||||||
|
command = "mdbook-admonish"
|
||||||
|
assets_version = "3.1.0" # do not edit: managed by `mdbook-admonish install`
|
||||||
|
|
||||||
|
[preprocessor.d2]
|
||||||
|
path = "d2"
|
||||||
|
# layout engine for diagrams. See https://github.com/terrastruct/d2#plugins.
|
||||||
|
# optional. default is "dagre".
|
||||||
|
layout = "dagre"
|
||||||
|
# whether to use inline svg when rendering.
|
||||||
|
# Keep this true for `mdbook serve`: embedded SVG output writes generated files
|
||||||
|
# under src/<output-dir>, which mdBook watches and rebuilds in a loop.
|
||||||
|
# optional. default is 'true'
|
||||||
|
inline = true
|
||||||
|
# output directory relative to `src/` for generated diagrams.
|
||||||
|
# This is ignored if 'inline' is 'true'.
|
||||||
|
# optional. default is "d2".
|
||||||
|
output-dir = "d2"
|
||||||
|
|
||||||
|
[preprocessor.toc]
|
||||||
|
command = "mdbook-toc"
|
||||||
|
renderer = ["html"]
|
||||||
|
|
||||||
|
[output.html]
|
||||||
|
additional-css = ["./mdbook-admonish.css"]
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
@charset "UTF-8";
|
||||||
|
:is(.admonition) {
|
||||||
|
display: flow-root;
|
||||||
|
margin: 1.5625em 0;
|
||||||
|
padding: 0 1.2rem;
|
||||||
|
color: var(--fg);
|
||||||
|
page-break-inside: avoid;
|
||||||
|
background-color: var(--bg);
|
||||||
|
border: 0 solid black;
|
||||||
|
border-inline-start-width: 0.4rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.05), 0 0 0.1rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
:is(.admonition) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:is(.admonition) > * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
:is(.admonition) :is(.admonition) {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
:is(.admonition) > .tabbed-set:only-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
html :is(.admonition) > :last-child {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.admonition-anchor-link {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: -1.2rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
a.admonition-anchor-link:link, a.admonition-anchor-link:visited {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
a.admonition-anchor-link:link:hover, a.admonition-anchor-link:visited:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.admonition-anchor-link::before {
|
||||||
|
content: "§";
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition-title, summary.admonition-title) {
|
||||||
|
position: relative;
|
||||||
|
min-height: 4rem;
|
||||||
|
margin-block: 0;
|
||||||
|
margin-inline: -1.6rem -1.2rem;
|
||||||
|
padding-block: 0.8rem;
|
||||||
|
padding-inline: 4.4rem 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background-color: rgba(68, 138, 255, 0.1);
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
:is(.admonition-title, summary.admonition-title) p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
html :is(.admonition-title, summary.admonition-title):last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
:is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.625em;
|
||||||
|
inset-inline-start: 1.6rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
background-color: #448aff;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
mask-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>');
|
||||||
|
-webkit-mask-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>');
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
:is(.admonition-title, summary.admonition-title):hover a.admonition-anchor-link {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
details.admonition::details-content {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details.admonition > summary.admonition-title::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.625em;
|
||||||
|
inset-inline-end: 1.6rem;
|
||||||
|
height: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
background-color: currentcolor;
|
||||||
|
mask-image: var(--md-details-icon);
|
||||||
|
-webkit-mask-image: var(--md-details-icon);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
content: "";
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.25s;
|
||||||
|
}
|
||||||
|
details[open].admonition > summary.admonition-title::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
summary.admonition-title::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--md-details-icon: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42Z'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--md-admonition-icon--admonish-note: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-abstract: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M17 9H7V7h10m0 6H7v-2h10m-3 6H7v-2h7M12 3a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-info: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-tip: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M17.66 11.2c-.23-.3-.51-.56-.77-.82-.67-.6-1.43-1.03-2.07-1.66C13.33 7.26 13 4.85 13.95 3c-.95.23-1.78.75-2.49 1.32-2.59 2.08-3.61 5.75-2.39 8.9.04.1.08.2.08.33 0 .22-.15.42-.35.5-.23.1-.47.04-.66-.12a.58.58 0 0 1-.14-.17c-1.13-1.43-1.31-3.48-.55-5.12C5.78 10 4.87 12.3 5 14.47c.06.5.12 1 .29 1.5.14.6.41 1.2.71 1.73 1.08 1.73 2.95 2.97 4.96 3.22 2.14.27 4.43-.12 6.07-1.6 1.83-1.66 2.47-4.32 1.53-6.6l-.13-.26c-.21-.46-.77-1.26-.77-1.26m-3.16 6.3c-.28.24-.74.5-1.1.6-1.12.4-2.24-.16-2.9-.82 1.19-.28 1.9-1.16 2.11-2.05.17-.8-.15-1.46-.28-2.23-.12-.74-.1-1.37.17-2.06.19.38.39.76.63 1.06.77 1 1.98 1.44 2.24 2.8.04.14.06.28.06.43.03.82-.33 1.72-.93 2.27z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-success: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='m9 20.42-6.21-6.21 2.83-2.83L9 14.77l9.88-9.89 2.83 2.83L9 20.42z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-question: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='m15.07 11.25-.9.92C13.45 12.89 13 13.5 13 15h-2v-.5c0-1.11.45-2.11 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41a2 2 0 0 0-2-2 2 2 0 0 0-2 2H8a4 4 0 0 1 4-4 4 4 0 0 1 4 4 3.2 3.2 0 0 1-.93 2.25M13 19h-2v-2h2M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10c0-5.53-4.5-10-10-10z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-warning: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2 1 21z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-failure: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M20 6.91 17.09 4 12 9.09 6.91 4 4 6.91 9.09 12 4 17.09 6.91 20 12 14.91 17.09 20 20 17.09 14.91 12 20 6.91z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-danger: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M11 15H6l7-14v8h5l-7 14v-8z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-bug: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M14 12h-4v-2h4m0 6h-4v-2h4m6-6h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6.002 6.002 0 0 0-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-example: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 13v-2h14v2H7m0 6v-2h14v2H7M7 7V5h14v2H7M3 8V5H2V4h2v4H3m-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17H2m2.25-7a.75.75 0 0 1 .75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1h2.25z'/></svg>");
|
||||||
|
--md-admonition-icon--admonish-quote: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M14 17h3l2-4V7h-6v6h3M6 17h3l2-4V7H5v6h3l-2 4z'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-note) {
|
||||||
|
border-color: #448aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(68, 138, 255, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #448aff;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-note);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-note);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
|
||||||
|
border-color: #00b0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(0, 176, 255, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #00b0ff;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-abstract);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-abstract);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-info, .admonish-todo) {
|
||||||
|
border-color: #00b8d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(0, 184, 212, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #00b8d4;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-info);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-info);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-tip, .admonish-hint, .admonish-important) {
|
||||||
|
border-color: #00bfa5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-tip, .admonish-hint, .admonish-important) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(0, 191, 165, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-tip, .admonish-hint, .admonish-important) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #00bfa5;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-tip);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-tip);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-success, .admonish-check, .admonish-done) {
|
||||||
|
border-color: #00c853;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-success, .admonish-check, .admonish-done) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(0, 200, 83, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-success, .admonish-check, .admonish-done) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #00c853;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-success);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-success);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-question, .admonish-help, .admonish-faq) {
|
||||||
|
border-color: #64dd17;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-question, .admonish-help, .admonish-faq) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(100, 221, 23, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-question, .admonish-help, .admonish-faq) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #64dd17;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-question);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-question);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-warning, .admonish-caution, .admonish-attention) {
|
||||||
|
border-color: #ff9100;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-warning, .admonish-caution, .admonish-attention) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(255, 145, 0, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-warning, .admonish-caution, .admonish-attention) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #ff9100;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-warning);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-warning);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-failure, .admonish-fail, .admonish-missing) {
|
||||||
|
border-color: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-failure, .admonish-fail, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(255, 82, 82, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-failure, .admonish-fail, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #ff5252;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-failure);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-failure);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-danger, .admonish-error) {
|
||||||
|
border-color: #ff1744;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-danger, .admonish-error) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(255, 23, 68, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-danger, .admonish-error) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #ff1744;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-danger);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-danger);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-bug) {
|
||||||
|
border-color: #f50057;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-bug) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(245, 0, 87, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-bug) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #f50057;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-bug);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-bug);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-example) {
|
||||||
|
border-color: #7c4dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(124, 77, 255, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #7c4dff;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-example);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-example);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonition):is(.admonish-quote, .admonish-cite) {
|
||||||
|
border-color: #9e9e9e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admonish-quote, .admonish-cite) > :is(.admonition-title, summary.admonition-title) {
|
||||||
|
background-color: rgba(158, 158, 158, 0.1);
|
||||||
|
}
|
||||||
|
:is(.admonish-quote, .admonish-cite) > :is(.admonition-title, summary.admonition-title)::before {
|
||||||
|
background-color: #9e9e9e;
|
||||||
|
mask-image: var(--md-admonition-icon--admonish-quote);
|
||||||
|
-webkit-mask-image: var(--md-admonition-icon--admonish-quote);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navy :is(.admonition) {
|
||||||
|
background-color: var(--sidebar-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ayu :is(.admonition),
|
||||||
|
.coal :is(.admonition) {
|
||||||
|
background-color: var(--theme-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rust :is(.admonition) {
|
||||||
|
background-color: var(--sidebar-bg);
|
||||||
|
color: var(--sidebar-fg);
|
||||||
|
}
|
||||||
|
.rust .admonition-anchor-link:link, .rust .admonition-anchor-link:visited {
|
||||||
|
color: var(--sidebar-fg);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
- [Introduction](./intro.md)
|
||||||
|
- [Architecture](./architecture.md)
|
||||||
|
- [The library model](./library.md)
|
||||||
|
- [Providers](./providers.md)
|
||||||
|
- [Filesystem — /fs](./providers/fs.md)
|
||||||
|
- [Tidal — /tidal](./providers/tidal.md)
|
||||||
|
- [YouTube — /youtube](./providers/youtube.md)
|
||||||
|
- [Search](./providers/search.md)
|
||||||
|
- [The crabidy store](./store.md)
|
||||||
|
- [Queue and playback](./queue.md)
|
||||||
|
- [Clients](./clients.md)
|
||||||
|
- [Terminal UI — cbd-tui](./clients/tui.md)
|
||||||
|
- [Web client — cbd-web](./clients/web.md)
|
||||||
|
- [The cbd bundle](./clients/cbd.md)
|
||||||
|
- [Command line](./clients/cli.md)
|
||||||
|
- [Configuration](./config.md)
|
||||||
|
- [Roles and authorization](./auth.md)
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
The server is a single process that owns the music library, the play
|
||||||
|
queue, and audio output. Clients are thin: they send commands and redraw
|
||||||
|
from a stream of updates the server pushes at them. This page describes
|
||||||
|
what happens inside the server and how one "play this" request travels
|
||||||
|
from a client all the way to the sound device.
|
||||||
|
|
||||||
|
For the pieces around it, see [The library model](./library.md),
|
||||||
|
[Providers](./providers.md), [The crabidy store](./store.md),
|
||||||
|
[Queue and playback](./queue.md), and [Clients](./clients.md).
|
||||||
|
|
||||||
|
## One gRPC service
|
||||||
|
|
||||||
|
The server exposes exactly one gRPC service, `CrabidyService`. Almost
|
||||||
|
every RPC is a plain request/response call — browse the library
|
||||||
|
(`GetLibraryNode`), change the queue (`Replace`, `Append`, `Insert`,
|
||||||
|
`Remove`, `SetCurrent`, `ClearQueue`), control playback (`TogglePlay`,
|
||||||
|
`Next`, `Prev`, `Stop`, `ChangeVolume`, `ToggleMute`, `RestartTrack`),
|
||||||
|
and edit the library (`CreateLibraryNode`, `RenameLibraryNode`,
|
||||||
|
`DeleteLibraryNode`, `CaptureLibraryNode`).
|
||||||
|
|
||||||
|
One RPC is different: `GetUpdateStream` is server-streaming. A client
|
||||||
|
subscribes once and the server pushes a `GetUpdateStreamResponse` every
|
||||||
|
time shared state changes. Each response is a `oneof` carrying one of:
|
||||||
|
|
||||||
|
- `Queue` — the whole queue, plus a `resolving` flag that is true while
|
||||||
|
the server is still turning queued paths into tracks.
|
||||||
|
- `QueueModifiers` — shuffle and repeat.
|
||||||
|
- `QueueTrack` — the current track (with album) and its queue position.
|
||||||
|
- `PlayState` — stopped, loading, playing, or paused.
|
||||||
|
- `TrackPosition` — elapsed and total seconds of the current track.
|
||||||
|
- `CaptureProgress` — how far a running capture has got.
|
||||||
|
- `SpectrumFrame` — one frame of the audio spectrum for the visualizer.
|
||||||
|
- volume and mute changes.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Nothing is polled. A client fetches an initial snapshot with `Init`,
|
||||||
|
subscribes to `GetUpdateStream`, and from then on its view of the queue
|
||||||
|
and playback is redrawn purely from pushed updates. Several clients can
|
||||||
|
subscribe at once and they all see the same state live.
|
||||||
|
```
|
||||||
|
|
||||||
|
The update stream is deliberately lossy: it is a bounded broadcast
|
||||||
|
channel, and a subscriber that falls too far behind receives an explicit
|
||||||
|
`data_loss` status and must resubscribe rather than silently missing
|
||||||
|
updates.
|
||||||
|
|
||||||
|
## Inside the process
|
||||||
|
|
||||||
|
Within the server there are three concurrency domains connected by
|
||||||
|
channels, not shared locks:
|
||||||
|
|
||||||
|
- the **tonic gRPC layer** — the RPC handlers and the update stream;
|
||||||
|
- the **playback loop** — a tokio task that owns the queue and play
|
||||||
|
state and is the single writer of both;
|
||||||
|
- the **ProviderOrchestrator loop** — a tokio task that routes every
|
||||||
|
library and track call to the provider that owns the path.
|
||||||
|
|
||||||
|
Below the playback loop sits the **audio player**: a dedicated OS thread
|
||||||
|
running the decoder and the audio sink, because that work is synchronous
|
||||||
|
and CPU-bound and would starve the async runtime. Commands flow to it
|
||||||
|
over a bounded channel; player events (state changes, position ticks,
|
||||||
|
end-of-stream) flow back and are turned into playback commands.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: down
|
||||||
|
|
||||||
|
grpc: tonic gRPC layer {
|
||||||
|
handlers: RPC handlers
|
||||||
|
stream: GetUpdateStream
|
||||||
|
}
|
||||||
|
|
||||||
|
playback: Playback loop {
|
||||||
|
explanation: |md
|
||||||
|
tokio task
|
||||||
|
owns the queue
|
||||||
|
owns play state
|
||||||
|
|
|
||||||
|
}
|
||||||
|
|
||||||
|
orch: ProviderOrchestrator loop {
|
||||||
|
explanation: |md
|
||||||
|
tokio task
|
||||||
|
routes by path prefix
|
||||||
|
|
|
||||||
|
}
|
||||||
|
|
||||||
|
engine: Audio player {
|
||||||
|
explanation: |md
|
||||||
|
OS thread
|
||||||
|
decoder + sink
|
||||||
|
|
|
||||||
|
}
|
||||||
|
|
||||||
|
providers: Providers {
|
||||||
|
tidal
|
||||||
|
youtube
|
||||||
|
fs
|
||||||
|
crabidy
|
||||||
|
}
|
||||||
|
|
||||||
|
device: Audio device
|
||||||
|
|
||||||
|
grpc.handlers -> playback: PlaybackMessage (bounded)
|
||||||
|
grpc.handlers -> orch: ProviderMessage (bounded)
|
||||||
|
playback -> orch: ResolveTracks / GetTrackUrls (reply)
|
||||||
|
orch -> providers: routed by first path segment
|
||||||
|
playback -> engine: play / pause / stop
|
||||||
|
engine -> playback: state, position, end-of-stream
|
||||||
|
engine -> device: PCM
|
||||||
|
playback -> grpc.stream: updates (broadcast)
|
||||||
|
```
|
||||||
|
|
||||||
|
```admonish tip
|
||||||
|
Every message crossing a channel carries the sender's tracing span, and
|
||||||
|
the consumer instruments its handler as a child of that span. Without
|
||||||
|
this a command's log events would attach to whichever request happened
|
||||||
|
to be running on the consumer task.
|
||||||
|
```
|
||||||
|
|
||||||
|
### The playback loop is the single writer
|
||||||
|
|
||||||
|
The queue and the play state live behind mutexes inside the playback
|
||||||
|
loop, but only the loop's own handlers ever touch them, and no lock is
|
||||||
|
held across an `await`. There is one writer of playback state, so the
|
||||||
|
queue a client sees is always internally consistent. Every queue change
|
||||||
|
runs through one broadcast helper, which is what guarantees the
|
||||||
|
`resolving` flag is never forgotten and that each change also reaches the
|
||||||
|
queue-persistence task.
|
||||||
|
|
||||||
|
### The ProviderOrchestrator is the library root
|
||||||
|
|
||||||
|
The orchestrator is the root of the library tree. It serves the
|
||||||
|
synthetic root `/` itself — a node whose children are the mounted
|
||||||
|
providers — and routes everything else by the **first path segment**:
|
||||||
|
`/tidal/...` to Tidal, `/youtube/...` to YouTube, `/fs/...` to the local
|
||||||
|
filesystem provider, and `/crabidy/...` to the crabidy store's tree.
|
||||||
|
Each of `get_lib_node`, `resolve_tracks_into`, `get_urls_for_track`,
|
||||||
|
`get_metadata_for_track`, and the create/rename/delete/capture calls
|
||||||
|
picks the owning provider the same way; a path no provider owns is
|
||||||
|
rejected as malformed (or, for editing calls, unsupported) rather than
|
||||||
|
guessed at.
|
||||||
|
|
||||||
|
Every provider except Tidal is optional: if its config or backing store
|
||||||
|
fails to initialize, the orchestrator logs a warning and simply omits
|
||||||
|
that child from the root, and the server runs without it. Because the
|
||||||
|
orchestrator sits behind an `Arc`, a slow library walk is spawned onto
|
||||||
|
its own task while the loop keeps serving other commands.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Routing is by the *resolved* track path. When a track's playable is a
|
||||||
|
link into another provider, the owning provider rewrites the track's
|
||||||
|
path to the target before it is queued, so `GetTrackUrls` for that track
|
||||||
|
routes straight to the real provider. See [The library
|
||||||
|
model](./library.md) for how links resolve.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The audio player
|
||||||
|
|
||||||
|
The player is not a tokio task — it is an OS thread wrapping a `rodio`
|
||||||
|
sink and a decoder. It wakes on a command or, failing that, on a short
|
||||||
|
timeout, on which it emits an elapsed-position tick. End-of-stream is
|
||||||
|
detected by a callback appended after the decoder that carries a
|
||||||
|
generation number: it fires only when the current track drains
|
||||||
|
naturally, and a stale end-of-stream from a track that was already
|
||||||
|
replaced is recognised by its generation and dropped. When it does fire,
|
||||||
|
it becomes a playback command that advances the queue.
|
||||||
|
|
||||||
|
The player accepts a source string. An `http(s)` URL is streamed;
|
||||||
|
anything else is opened as a local file path. That is why every provider,
|
||||||
|
however it stores its media, ultimately hands playback a URL or a plain
|
||||||
|
file path.
|
||||||
|
|
||||||
|
## How "play this" flows
|
||||||
|
|
||||||
|
Suppose a client selects a Tidal album and asks to replace the queue with
|
||||||
|
it. The path names a node, not a single track, so the server flattens it
|
||||||
|
into tracks, starts playing the first one, and streams the growing queue
|
||||||
|
back as it goes.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
|
||||||
|
client: Client
|
||||||
|
grpc: gRPC handler
|
||||||
|
pb: Playback loop
|
||||||
|
orch: ProviderOrchestrator
|
||||||
|
tidal: Tidal provider
|
||||||
|
player: Audio player
|
||||||
|
|
||||||
|
client -> grpc: "Replace([/tidal/.../album])"
|
||||||
|
grpc -> pb: "PlaybackMessage(Replace)"
|
||||||
|
pb -> orch: "ResolveTracks(path, chunk channel)"
|
||||||
|
orch -> tidal: "resolve subtree (spawned)"
|
||||||
|
tidal -> pb: "chunk of Tracks" {style.bold: true}
|
||||||
|
pb -> pb: "append to queue, first track becomes current"
|
||||||
|
pb -> client: "Queue update (resolving = true)"
|
||||||
|
pb -> orch: "GetTrackUrls(current track path)"
|
||||||
|
orch -> tidal: get stream urls
|
||||||
|
tidal -> pb: "[stream url]"
|
||||||
|
pb -> player: "play(url)"
|
||||||
|
player -> pb: "PlayState = playing, position ticks"
|
||||||
|
pb -> client: "QueueTrack + PlayState + TrackPosition"
|
||||||
|
tidal -> pb: "more chunks..." {style.bold: true}
|
||||||
|
pb -> client: Queue updates
|
||||||
|
pb -> client: "final Queue update (resolving = false)"
|
||||||
|
player -> device: PCM
|
||||||
|
```
|
||||||
|
|
||||||
|
The important properties:
|
||||||
|
|
||||||
|
- The RPC handler returns as soon as the command is on the playback
|
||||||
|
loop's channel; it does not wait for resolution or playback.
|
||||||
|
- Resolution streams in **chunks**. The queue grows as chunks arrive, and
|
||||||
|
each growth is broadcast, so a client sees a big album or playlist fill
|
||||||
|
in progressively with `resolving = true` until the final update clears
|
||||||
|
the flag.
|
||||||
|
- The moment a chunk makes a track current, the playback loop resolves
|
||||||
|
that one track's stream URLs and hands them to the player. Browsing a
|
||||||
|
slow, paginated library never blocks the audio starting.
|
||||||
|
- If a track's URLs fail to resolve, or it was recorded as skipped, the
|
||||||
|
loop advances past it — one full pass at most — rather than stalling.
|
||||||
|
- Everything the client shows afterwards (queue contents, current track,
|
||||||
|
play state, position) arrives as pushed updates on the stream it is
|
||||||
|
already subscribed to.
|
||||||
|
|
||||||
|
A "play this single track" request is the *same* RPC with a track path
|
||||||
|
instead of a node path: it resolves to exactly one track. Queueing a
|
||||||
|
playlist and queueing a track differ only in the shape of the path.
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Roles and authorization
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
By default the server is **open**: it listens on the network and anyone
|
||||||
|
who can reach the port has full control — not only playback, but library
|
||||||
|
writes such as renaming or deleting your saves. This is the right
|
||||||
|
default on a trusted home network with no config to write.
|
||||||
|
|
||||||
|
Adding an `[auth]` section to `crabidy-server.toml` turns authorization
|
||||||
|
on. From then on the server requires HTTP basic auth on **every** RPC
|
||||||
|
and grants each caller one of three roles.
|
||||||
|
|
||||||
|
## The three roles
|
||||||
|
|
||||||
|
Roles are ordered by privilege; each includes the rights of the ones
|
||||||
|
below it (owner ⊃ queue-owner ⊃ queue-appender):
|
||||||
|
|
||||||
|
- **owner** — the normal user: everything, including all library
|
||||||
|
writes.
|
||||||
|
- **queue-owner** — anything on the queue and playback (append, remove,
|
||||||
|
reorder, clear, shuffle, repeat, play/stop, next/prev, volume, mute,
|
||||||
|
…), but **no library writes**: no bookmarks (`w`), no captures (`W`),
|
||||||
|
no saving, renaming, or deleting.
|
||||||
|
- **queue-appender** — may browse and search the library and **append**
|
||||||
|
tracks to the queue; nothing else. (Searching is allowed because
|
||||||
|
appending something first means finding it.)
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
Each role is credentialed by one argon2id password hash in PHC format.
|
||||||
|
Generate a hash and store it in the config in one step with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crabidy-server guard <role>
|
||||||
|
```
|
||||||
|
|
||||||
|
where `<role>` is `owner`, `queue-owner`, or `queue-appender`. This
|
||||||
|
reads a password, hashes it (argon2id), and writes the resulting PHC
|
||||||
|
string into `crabidy-server.toml`'s `[auth]` table. The file ends up
|
||||||
|
like this:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
# One PHC hash per role; omit a role to leave it disabled.
|
||||||
|
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
queue_owner = "$argon2id$v=19$..."
|
||||||
|
queue_appender = "$argon2id$v=19$..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Authorization switches on as soon as **any** role hash is present. Omit
|
||||||
|
a role's key and that role cannot authenticate. A role's PHC hash — not
|
||||||
|
its password — is what lives in the file.
|
||||||
|
|
||||||
|
## How enforcement behaves
|
||||||
|
|
||||||
|
- **Fail-closed.** Authorization is enforced in one place, in front of
|
||||||
|
the RPC handlers, and it is default-deny: an unknown or future method
|
||||||
|
requires the owner role until it is explicitly mapped to a lower one.
|
||||||
|
No handler ever sees an unauthorized request.
|
||||||
|
- **Startup is strict.** A `crabidy-server.toml` that exists but does
|
||||||
|
not parse aborts server startup rather than silently running open —
|
||||||
|
a broken auth config never downgrades to no auth.
|
||||||
|
- **Clients send the role as the username.** A client authenticates by
|
||||||
|
sending the role name (`owner`, `queue-owner`, `queue-appender`) as
|
||||||
|
the basic-auth user and the role's password as the basic-auth
|
||||||
|
password. Set these with the client `auth` subcommand or in the
|
||||||
|
client config's `[server]` table (see [Configuration](./config.md)).
|
||||||
|
With no credentials configured the client sends no header, which keeps
|
||||||
|
the zero-config local setup working against an open server.
|
||||||
|
- Denied requests get gRPC `PERMISSION_DENIED`; missing or wrong
|
||||||
|
credentials get `UNAUTHENTICATED`, with every authentication failure
|
||||||
|
answering identically so a caller cannot probe which part was wrong.
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
The transport is plain HTTP/2 — basic auth travels in the clear. This is
|
||||||
|
fine on a trusted LAN. Put TLS in front of anything exposed beyond it (a
|
||||||
|
reverse proxy or a VPN); crabidy does not terminate TLS itself.
|
||||||
|
```
|
||||||
|
|
||||||
|
Secrets — client passwords, the `authorization` header, and stream
|
||||||
|
tokens — are never written to logs, traces, or error messages.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Introduction](./intro.md) — the trust model in one paragraph.
|
||||||
|
- [Configuration](./config.md) — the client `[server]` table and file
|
||||||
|
locations.
|
||||||
|
- [Command line](./clients/cli.md) — the `guard` and `auth`
|
||||||
|
subcommands.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Clients
|
||||||
|
|
||||||
|
A client is anything that drives the server. Every client speaks the
|
||||||
|
same gRPC service (see [Architecture](./architecture.md)): it sends
|
||||||
|
**commands** (browse the library, change the queue, control playback)
|
||||||
|
and subscribes to the **update stream** that pushes the current queue,
|
||||||
|
play state, track position, capture progress, and the frequency
|
||||||
|
spectrum as they change. Nothing is polled — each client redraws from
|
||||||
|
the pushed updates, so several clients driving one server always agree
|
||||||
|
on what is playing.
|
||||||
|
|
||||||
|
Because the contract is one shared service, the clients are the same
|
||||||
|
program wearing different skins. They differ only in how you interact
|
||||||
|
with them, not in what they can do.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
shape: rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
tui: cbd-tui (terminal)
|
||||||
|
web: cbd-web (browser)
|
||||||
|
cbd: cbd (server + TUI)
|
||||||
|
cli: CLI subcommands
|
||||||
|
|
||||||
|
tui -> server: gRPC
|
||||||
|
web -> server: gRPC-web
|
||||||
|
cli -> server: gRPC (one-shot)
|
||||||
|
cbd -> server: localhost gRPC
|
||||||
|
```
|
||||||
|
|
||||||
|
- [Terminal UI — cbd-tui](./clients/tui.md) — the ratatui/crossterm
|
||||||
|
terminal client, driven by vim-style keys.
|
||||||
|
- [Web client — cbd-web](./clients/web.md) — a Leptos/WASM browser
|
||||||
|
client with the same functionality, served by the server itself.
|
||||||
|
- [The cbd bundle](./clients/cbd.md) — server and terminal client in
|
||||||
|
one process for the single-machine case.
|
||||||
|
- [Command line](./clients/cli.md) — every binary is also a clap CLI;
|
||||||
|
the `library`/`queue`/`global` subcommands are a scriptable remote
|
||||||
|
control.
|
||||||
|
|
||||||
|
All clients authenticate the same way, using the role name as the
|
||||||
|
basic-auth user (see [Roles and authorization](./auth.md)). Connection
|
||||||
|
defaults live in each client's own config file (see
|
||||||
|
[Configuration](./config.md)).
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# The cbd bundle
|
||||||
|
|
||||||
|
`cbd` is the server and the terminal client in one process, for the
|
||||||
|
single-machine case where you just want to play music without running a
|
||||||
|
separate server. Starting `cbd` starts an in-process
|
||||||
|
[server](../architecture.md) and then runs the [terminal UI](./tui.md)
|
||||||
|
against it; everything else — the config format, the gRPC wire, every
|
||||||
|
feature — is exactly the same as running the two halves separately.
|
||||||
|
|
||||||
|
## What it does at start
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
cbd: "cbd (one process)" {
|
||||||
|
boot: "main: spawn server,\nwait, run TUI"
|
||||||
|
srv: "in-process server\n(0.0.0.0:50051)"
|
||||||
|
tui: "cbd-tui"
|
||||||
|
boot -> srv: "spawn (port taken → adopt)"
|
||||||
|
boot -> tui: after readiness
|
||||||
|
tui -> srv: localhost gRPC
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. It spawns the server on a background task. If the port is already
|
||||||
|
taken because a standalone `crabidy-server` is already running, `cbd`
|
||||||
|
logs that and carries on — the TUI simply connects to the existing
|
||||||
|
server instead of failing.
|
||||||
|
2. It waits until the configured server address accepts a connection
|
||||||
|
(bounded retries, then a clear error), so the TUI never starts
|
||||||
|
against a server that is not ready yet.
|
||||||
|
3. It runs the TUI exactly as `cbd-tui` would.
|
||||||
|
|
||||||
|
The bundled TUI talks to the in-process server over localhost gRPC, the
|
||||||
|
same way a remote TUI would — there is no in-process shortcut. Quitting
|
||||||
|
the TUI ends the process, and with it the in-process server; the queue
|
||||||
|
is persisted continuously (see [Queue and playback](../queue.md)), so
|
||||||
|
the next start restores it.
|
||||||
|
|
||||||
|
## Its own config
|
||||||
|
|
||||||
|
`cbd` reads its own `cbd.toml`, **not** `cbd-tui.toml`. The two files
|
||||||
|
have the same options but are separate so the self-contained `cbd`
|
||||||
|
(defaulting to its localhost server) and a `cbd-tui` pointed at a remote
|
||||||
|
server can coexist on one machine without one dragging the other's
|
||||||
|
`address`. See [Configuration](../config.md).
|
||||||
|
|
||||||
|
`cbd` also carries the CLI subcommands of both halves — the server's
|
||||||
|
`guard`/`scan`, the client's `auth`, and the shared
|
||||||
|
`library`/`queue`/`global` remote commands. See [Command
|
||||||
|
line](./cli.md).
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Command line
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Every binary is a clap CLI. Run any of them with `--help` (and any
|
||||||
|
subcommand with `--help`) for the full surface. Running a binary with
|
||||||
|
**no subcommand** behaves as it always has: `crabidy-server` runs the
|
||||||
|
server, `cbd-tui` runs the [TUI](./tui.md), and `cbd` runs the
|
||||||
|
[in-process server + TUI](./cbd.md).
|
||||||
|
|
||||||
|
The subcommands fall into three groups: the shared remote commands
|
||||||
|
(available on every binary), the server-only commands, and the
|
||||||
|
client-only command.
|
||||||
|
|
||||||
|
## Remote commands: `library` / `queue` / `global`
|
||||||
|
|
||||||
|
`library`, `queue`, and `global` are available on all three binaries
|
||||||
|
and act as a scriptable remote control: each one connects to a running
|
||||||
|
server over gRPC, sends a single command, prints the result, and exits.
|
||||||
|
A `crabidy-server` running these is just acting as a client to whatever
|
||||||
|
server is up — including its own.
|
||||||
|
|
||||||
|
Connection flags go **before** the subcommand and fall back to the
|
||||||
|
[client config](../config.md) file (`cbd-tui.toml` / `cbd.toml`) when
|
||||||
|
omitted:
|
||||||
|
|
||||||
|
- `--address <URL>` — the server to connect to.
|
||||||
|
- `--user <role>` / `--password <pw>` — basic-auth credentials, using
|
||||||
|
the role name as the user (see [Roles and
|
||||||
|
authorization](../auth.md)); leave empty against an open server.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crabidy-server library list /tidal # browse a node
|
||||||
|
cbd-tui --address http://pi:50051 queue append /fs/album
|
||||||
|
cbd global play # toggle play/pause
|
||||||
|
cbd global volume -- -0.1 # lower the volume
|
||||||
|
```
|
||||||
|
|
||||||
|
- `library list [PATH]` browses a [library node](../library.md)
|
||||||
|
(default `/`), printing its child nodes and tracks; captured rows are
|
||||||
|
marked. `library create`/`rename`/`delete`, and `library save`/
|
||||||
|
`capture` (the `w`/`W` equivalents into `/crabidy` — see [The crabidy
|
||||||
|
store](../store.md)) mutate it.
|
||||||
|
- `queue show` prints the [queue](../queue.md); `queue append`/`insert`/
|
||||||
|
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
||||||
|
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
|
||||||
|
<NAME>`, `queue shuffle`, and `queue repeat` change it.
|
||||||
|
- `global play`/`stop`/`next`/`prev`/`restart`/`mute` and `global
|
||||||
|
volume <DELTA>` control playback.
|
||||||
|
|
||||||
|
## Server commands: `guard` and `scan`
|
||||||
|
|
||||||
|
These live on `crabidy-server` (and `cbd`). They act on the server's
|
||||||
|
own config and content store, not over the wire.
|
||||||
|
|
||||||
|
`guard <role> [password] [--no-config]` hashes a role password with
|
||||||
|
argon2id and prints the PHC string to stdout. Unless `--no-config` is
|
||||||
|
given, it also writes the hash into the role's field in
|
||||||
|
`crabidy-server.toml`'s `[auth]`, preserving the other roles. Roles are
|
||||||
|
`owner`, `queue-owner`, and `queue-appender` (see [Roles and
|
||||||
|
authorization](../auth.md)). With the password omitted it reads stdin.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '%s' 'my-password' | crabidy-server guard owner
|
||||||
|
crabidy-server guard queue-owner 'pw' --no-config # just print the PHC
|
||||||
|
```
|
||||||
|
|
||||||
|
`scan <path> [--capture|--move]` walks a folder recursively and drops a
|
||||||
|
`.cbd-track.toml` beside every audio file, so the folder browses as a
|
||||||
|
tree under [`/fs`](../providers/fs.md). Existing `.cbd-track.toml` files
|
||||||
|
are left untouched. By default the sidecar points at the audio in place.
|
||||||
|
`--capture` copies each file into the content store instead (the sidecar
|
||||||
|
points there, de-duplicated); `--move` moves it into the store, leaving
|
||||||
|
only the sidecar behind. See [The crabidy store](../store.md).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crabidy-server scan ~/Music # index in place
|
||||||
|
crabidy-server scan ~/Downloads --capture # copy audio into the store
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client command: `auth`
|
||||||
|
|
||||||
|
`auth <role> [password] [--address ADDR]` lives on `cbd-tui` (and
|
||||||
|
`cbd`). It writes the role name as the `user`, the cleartext password,
|
||||||
|
and optionally the address into the [client config](../config.md), so
|
||||||
|
you do not have to edit the file by hand.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cbd-tui auth owner 'my-password'
|
||||||
|
cbd-tui auth queue-owner 'pw' --address http://pi:50051
|
||||||
|
```
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
A password given as a command-line **argument** is visible in the
|
||||||
|
process list (e.g. `ps`) for as long as the command runs. Prefer
|
||||||
|
omitting it: `guard` then reads the password from stdin, which keeps it
|
||||||
|
out of argv and is pipe-friendly. The client config written by `auth`
|
||||||
|
stores the password in plaintext, so keep that file private.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completions and man pages
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cbd-tui completions bash # print a completion script
|
||||||
|
devenv shell -- gen-cli-assets # write dist/completions + dist/man
|
||||||
|
```
|
||||||
|
|
||||||
|
Each binary can print a shell completion script (bash/zsh/fish) to
|
||||||
|
stdout with the hidden `completions <shell>` subcommand. `gen-cli-assets`
|
||||||
|
builds the binaries with `CBD_ASSET_DIR=$PWD/dist`, producing
|
||||||
|
`dist/completions/**` and `dist/man/*.1` for all three binaries. Every
|
||||||
|
ordinary build also emits these into the crate's `OUT_DIR`.
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# Terminal UI — cbd-tui
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
`cbd-tui` is the terminal client: a ratatui/crossterm application that
|
||||||
|
connects to a running server over gRPC and redraws from the pushed
|
||||||
|
update stream. It reads `cbd-tui.toml` for its connection defaults (see
|
||||||
|
[Configuration](../config.md)); flags before a subcommand override the
|
||||||
|
file, and running it with no subcommand starts the TUI (see
|
||||||
|
[Command line](./cli.md)).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
The screen has two focusable panes side by side and a now-playing pane:
|
||||||
|
|
||||||
|
- **Library** (left) — the current [library node](../library.md): the
|
||||||
|
child folders and tracks under the path you have browsed to. Entering
|
||||||
|
a folder replaces the listing.
|
||||||
|
- **Queue** (right) — the play [queue](../queue.md), with the playing
|
||||||
|
track highlighted.
|
||||||
|
- **Now playing** — the current track, a progress gauge, and the
|
||||||
|
frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum),
|
||||||
|
fills the rest of the right column).
|
||||||
|
|
||||||
|
`Tab` cycles focus between the library and the queue; keys are routed to
|
||||||
|
whichever pane has focus (plus the global keys, which apply in either).
|
||||||
|
|
||||||
|
## Navigating
|
||||||
|
|
||||||
|
Navigation is vim-style. In the library, `j`/`k` move the selection,
|
||||||
|
`l` enters the selected folder, `h` goes back to the parent, and
|
||||||
|
`g`/`G` and `Ctrl-d`/`Ctrl-u` jump. `Enter` **replaces** the queue with
|
||||||
|
the current selection; `a` appends it and `L` queues it after the
|
||||||
|
current track. In the queue, `Enter` plays the selected track, `o`
|
||||||
|
jumps the selection to the playing track, `d` removes a track, `p`
|
||||||
|
inserts the library selection after the selected track, and `c`/`C`
|
||||||
|
clear the queue.
|
||||||
|
|
||||||
|
`%` creates a child of the open node where that is allowed (e.g. a
|
||||||
|
search term under `/tidal/search` — see [Search](../providers/search.md));
|
||||||
|
`e` renames such a node; `d` deletes the selection. Playback keys
|
||||||
|
(`Space`, `r`, `Ctrl-n`/`Ctrl-p`, volume, `m`, `z`, `x`) are global.
|
||||||
|
Press `?` at any time for the full binding table; `Esc` or `?` closes
|
||||||
|
it, and while it is open every other key is inert.
|
||||||
|
|
||||||
|
## Marks and saving
|
||||||
|
|
||||||
|
- `s` toggles a **mark** on the selected item. Marks live on the full
|
||||||
|
item list (they survive filtering) so a marked-but-hidden row still
|
||||||
|
counts when you queue or save.
|
||||||
|
- `w` **saves** the selection — a library subtree, or in the queue pane
|
||||||
|
the whole queue — as a new folder of link files under
|
||||||
|
`/crabidy/<name>`; playback replays it from the source provider.
|
||||||
|
- `W` **captures** the selection into `/crabidy/<name>`: like `w`, but
|
||||||
|
each track's audio is fetched into the shared content store
|
||||||
|
(de-duplicated), so it plays back fully local afterwards. It works on
|
||||||
|
a library subtree and on the queue. Downloads can take a while;
|
||||||
|
progress shows in the library pane. See [The crabidy
|
||||||
|
store](../store.md).
|
||||||
|
- Captured rows are marked with a leading `|`, visible even while you
|
||||||
|
browse another provider, so you can see what you already have.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Saves and captures all live under the one `/crabidy` provider. Inside
|
||||||
|
`/crabidy`, `d` deletes a folder or track immediately with no
|
||||||
|
confirmation — it removes only the metadata toml, never the shared
|
||||||
|
store audio, which other saves may reference.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The `/` live filter
|
||||||
|
|
||||||
|
Pressing `/` opens a search input that filters the focused pane's items
|
||||||
|
live as you type (case-insensitive substring). It **narrows** the
|
||||||
|
visible list rather than jumping to a match, so you can filter and then
|
||||||
|
act on what is left. `Enter` keeps the filter applied and returns to
|
||||||
|
navigation; `Esc` clears it. Movement, marks, and queue actions all map
|
||||||
|
back to the real underlying rows, so acting on a filtered row affects
|
||||||
|
the right track.
|
||||||
|
|
||||||
|
```admonish tip
|
||||||
|
This `/` filter is a local view filter over items already listed. It is
|
||||||
|
distinct from the search-node feature (`%` on `/tidal/search` and
|
||||||
|
friends), which asks a provider for results. See
|
||||||
|
[Search](../providers/search.md).
|
||||||
|
```
|
||||||
|
|
||||||
|
Entering a library folder resets the filter (a fresh listing); the
|
||||||
|
queue's filter is preserved across the constant queue updates and only
|
||||||
|
its visible set is recomputed.
|
||||||
|
|
||||||
|
## Frequency spectrum
|
||||||
|
|
||||||
|
A row of frequency bars is drawn under the track progress while audio
|
||||||
|
plays, as block glyphs (`▁▂▃▄▅▆▇█`) in the accent color. The bars are
|
||||||
|
produced on the **server**: it taps its own audio output, runs an FFT
|
||||||
|
(~20 fps), folds the result into a few log-spaced bins, and streams
|
||||||
|
them on the update stream like every other bit of live state. So the
|
||||||
|
bars work whether the server is local or on another machine, and when
|
||||||
|
nothing is playing they fall to the floor.
|
||||||
|
|
||||||
|
Toggle them with `spectrum` in the client config (`spectrum = true` is
|
||||||
|
the default; `false` hides them). The toggle is a display choice — the
|
||||||
|
server always computes and streams the bars while audio flows.
|
||||||
|
|
||||||
|
## Key bindings
|
||||||
|
|
||||||
|
Global keys work in either pane. Pane keys apply only while that pane is
|
||||||
|
focused (several chords, like `j`/`Enter`/`d`, mean different things per
|
||||||
|
pane).
|
||||||
|
|
||||||
|
| Scope | Key | Action |
|
||||||
|
| ------- | ----------------- | ----------------------------------------- |
|
||||||
|
| Scope | Key | Action |
|
||||||
|
| Global | `?` | Show help |
|
||||||
|
| Global | `q` | Quit |
|
||||||
|
| Global | `Tab` | Switch between library and queue |
|
||||||
|
| Global | `Space` | Play/pause |
|
||||||
|
| Global | `r` | Restart current track |
|
||||||
|
| Global | `K` | Volume up |
|
||||||
|
| Global | `J` | Volume down |
|
||||||
|
| Global | `m` | Toggle mute |
|
||||||
|
| Global | `z` | Toggle shuffle |
|
||||||
|
| Global | `x` | Toggle repeat |
|
||||||
|
| Global | `Ctrl-n` | Next track |
|
||||||
|
| Global | `Ctrl-p` | Previous track |
|
||||||
|
| Library | `j` / `k` | Select next / previous item |
|
||||||
|
| Library | `g` / `G` | Select first / last item |
|
||||||
|
| Library | `Ctrl-d` | Jump 15 items down |
|
||||||
|
| Library | `Ctrl-u` | Jump 15 items up |
|
||||||
|
| Library | `h` | Go to parent folder |
|
||||||
|
| Library | `l` | Enter selected folder |
|
||||||
|
| Library | `s` | Mark / unmark selection |
|
||||||
|
| Library | `Enter` | Replace queue with selection |
|
||||||
|
| Library | `a` | Append selection to queue |
|
||||||
|
| Library | `L` | Queue selection after current track |
|
||||||
|
| Library | `%` | Create node here (e.g. search term) |
|
||||||
|
| Library | `e` | Rename selected node |
|
||||||
|
| Library | `d` | Delete selection |
|
||||||
|
| Library | `w` | Save selection as bookmark |
|
||||||
|
| Library | `W` | Capture selection into /crabidy (audio) |
|
||||||
|
| Library | `/` | Filter this view |
|
||||||
|
| Queue | `j` / `k` | Select next / previous track |
|
||||||
|
| Queue | `g` / `G` | Select first / last track |
|
||||||
|
| Queue | `Ctrl-d` | Jump 15 tracks down |
|
||||||
|
| Queue | `Ctrl-u` | Jump 15 tracks up |
|
||||||
|
| Queue | `o` | Select the playing track |
|
||||||
|
| Queue | `Enter` | Play selected track |
|
||||||
|
| Queue | `p` | Insert library selection after this track |
|
||||||
|
| Queue | `d` | Remove selected track |
|
||||||
|
| Queue | `c` | Clear queue except current track |
|
||||||
|
| Queue | `C` | Clear entire queue |
|
||||||
|
| Queue | `w` | Save queue under a name |
|
||||||
|
| Queue | `W` | Capture the queue into /crabidy (audio) |
|
||||||
|
| Queue | `/` | Filter this view |
|
||||||
|
| Help | `?` / `Esc` / `q` | Close help |
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Web client — cbd-web
|
||||||
|
|
||||||
|
`cbd-web` is a browser client with the same functionality as the
|
||||||
|
[terminal UI](./tui.md): library browsing, search terms, queue
|
||||||
|
manipulation, playback control, saves (`w`), captures (`W`, with
|
||||||
|
progress), and the live update stream. It is a Leptos application
|
||||||
|
compiled to WebAssembly and rendered client-side in the browser — no
|
||||||
|
terminal required, so phones, tablets, and guests can drive the server.
|
||||||
|
|
||||||
|
Every TUI binding has a clickable equivalent, and the familiar keyboard
|
||||||
|
bindings (`j`/`k`/`h`/`l`, `Tab`, `%`, `e`, `d`, `w`, `W`, playback and
|
||||||
|
queue keys, `?` for help) also work on desktop browsers. The `/` live
|
||||||
|
filter is TUI-only for now.
|
||||||
|
|
||||||
|
## How it is served
|
||||||
|
|
||||||
|
The browser talks **gRPC-web** to the same service the TUI uses. There
|
||||||
|
is no second API surface: the server wraps its existing gRPC service in
|
||||||
|
a gRPC-web layer, so the browser calls the same
|
||||||
|
`/crabidy.v1.CrabidyService/…` paths, including the streaming
|
||||||
|
`GetUpdateStream`, from the same generated clients.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
browser: Browser {
|
||||||
|
app: cbd-web (Leptos CSR wasm)
|
||||||
|
}
|
||||||
|
|
||||||
|
server: "crabidy-server :50051" {
|
||||||
|
static: embedded cbd-web bundle
|
||||||
|
grpcweb: gRPC-web layer
|
||||||
|
rpc: CrabidyService
|
||||||
|
grpcweb -> rpc
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.app -> server.static: GET / and assets
|
||||||
|
browser.app -> server.grpcweb: gRPC-web (fetch)
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is served on the server's own address — gRPC for the TUI,
|
||||||
|
gRPC-web for the browser, and the static app assets all share one port.
|
||||||
|
Opening `http://<server>:50051/` in a browser is the whole install
|
||||||
|
story; there is no separate host to run.
|
||||||
|
|
||||||
|
## Embedded in the server
|
||||||
|
|
||||||
|
`cbd-web` is not run directly. It is built to a WASM bundle and embedded
|
||||||
|
into `crabidy-server` behind the `web-ui` cargo feature, which is **on
|
||||||
|
by default** (and thus present in `cbd` too). A plain `cargo build`
|
||||||
|
needs no WASM toolchain: until you build the bundle, the server embeds a
|
||||||
|
"web UI not built" placeholder page. Building the bundle re-embeds it
|
||||||
|
automatically on the next server build.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
devenv shell -- build-web # writes the cbd-web bundle
|
||||||
|
cargo build -p crabidy-server # embeds it
|
||||||
|
```
|
||||||
|
|
||||||
|
Building the server with `--no-default-features` drops the feature for a
|
||||||
|
headless, gRPC-only binary.
|
||||||
|
|
||||||
|
## Auth and theme
|
||||||
|
|
||||||
|
The static app shell is public; every RPC behind it stays gated. When
|
||||||
|
the server has [auth](../auth.md) configured, the client shows a login
|
||||||
|
form on the first unauthorized RPC and sends the role name and password
|
||||||
|
as basic-auth credentials — the same `[auth]` roles the TUI honors,
|
||||||
|
enforced identically. Credentials and the theme choice are kept in the
|
||||||
|
browser.
|
||||||
|
|
||||||
|
The interface offers a light and a dark theme with a manual toggle,
|
||||||
|
following the browser's preference by default.
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Crabidy reads its configuration from TOML files in `~/.config/crabidy/`,
|
||||||
|
the platform config directory. Every file is optional. On first start
|
||||||
|
each component writes its own file filled in with defaults, then reads
|
||||||
|
it back — so a fresh install runs with sensible values and leaves you an
|
||||||
|
editable file for each piece. A provider whose file is missing simply
|
||||||
|
does not mount; it does not stop the server.
|
||||||
|
|
||||||
|
The one exception is the server auth file, `crabidy-server.toml`: it is
|
||||||
|
never auto-created (see [Roles and authorization](./auth.md)).
|
||||||
|
|
||||||
|
## The config files
|
||||||
|
|
||||||
|
| File | Component | Auto-created |
|
||||||
|
| --------------------- | ---------- | ------------ |
|
||||||
|
| `tidaly.toml` | Tidal | yes |
|
||||||
|
| `ytdy.toml` | YouTube | yes |
|
||||||
|
| `fsdy.toml` | local fs | yes |
|
||||||
|
| `cbd-tui.toml` | `cbd-tui` | yes |
|
||||||
|
| `cbd.toml` | `cbd` | yes |
|
||||||
|
| `crabidy-server.toml` | server | no |
|
||||||
|
|
||||||
|
- `tidaly.toml`, `ytdy.toml`, and `fsdy.toml` configure the three media
|
||||||
|
providers — Tidal, YouTube, and a local music folder (its filesystem
|
||||||
|
root). See [Providers](./providers.md).
|
||||||
|
- `cbd-tui.toml` and `cbd.toml` are client configs (below).
|
||||||
|
- `crabidy-server.toml` holds server auth (see
|
||||||
|
[Roles and authorization](./auth.md)).
|
||||||
|
|
||||||
|
## Client config: `cbd-tui.toml` and `cbd.toml`
|
||||||
|
|
||||||
|
`cbd-tui` (the standalone terminal client) reads `cbd-tui.toml`; `cbd`
|
||||||
|
(server plus TUI in one process) reads its own `cbd.toml`. They are
|
||||||
|
**separate files with the same options** so the two can run side by side
|
||||||
|
on one machine: a common setup is `cbd` playing locally against its
|
||||||
|
in-process server while `cbd-tui`, pointed at a remote server (a
|
||||||
|
Raspberry Pi, say), acts as a remote control. A single shared file would
|
||||||
|
force one use to follow the other's `address`.
|
||||||
|
|
||||||
|
Both live under a `[server]` table:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
# Where to find the server. Default (both files): localhost, which is
|
||||||
|
# what cbd's own in-process server listens on. Point cbd-tui.toml at a
|
||||||
|
# remote server to drive it as a remote control.
|
||||||
|
address = "http://127.0.0.1:50051"
|
||||||
|
|
||||||
|
# Credentials, used only when the server has [auth] configured.
|
||||||
|
# `user` is the role name (see ./auth.md); leave both empty against an
|
||||||
|
# open server.
|
||||||
|
user = ""
|
||||||
|
password = ""
|
||||||
|
|
||||||
|
# Show the frequency-spectrum bars under the track progress.
|
||||||
|
spectrum = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Every option is also a command-line flag, given before the subcommand
|
||||||
|
(`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum
|
||||||
|
false`). A provided flag overrides the file value; an omitted flag
|
||||||
|
leaves the file value in place. To write credentials into the config
|
||||||
|
once instead of editing by hand, use the `auth` subcommand (see
|
||||||
|
[Command line](./clients/cli.md)):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cbd-tui auth owner 'my-password'
|
||||||
|
cbd-tui auth queue-owner 'pw' --address http://pi:50051
|
||||||
|
```
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
`password` is stored in **plaintext** — the client always holds a
|
||||||
|
plaintext credential, not a hash. Keep the client config file private.
|
||||||
|
The password is never written to logs.
|
||||||
|
```
|
||||||
|
|
||||||
|
For the client's `spectrum` option in context, see
|
||||||
|
[The terminal client](./clients/tui.md).
|
||||||
|
|
||||||
|
## Where the server's own data lives
|
||||||
|
|
||||||
|
The server-managed `crabidy` provider does **not** live under
|
||||||
|
`~/.config`. Following the XDG split, its two parts sit in two roots:
|
||||||
|
|
||||||
|
- `~/.local/state/crabidy/` — the provider's TOML tree: the track files
|
||||||
|
for your saved queues, bookmarks, and captures (the `/crabidy`
|
||||||
|
library subtree). This is *state*.
|
||||||
|
- `~/.local/share/crabidy/` — the content-addressed audio store: the
|
||||||
|
actual playable files that captures download, shared and
|
||||||
|
de-duplicated across every save. This is *data*.
|
||||||
|
|
||||||
|
Neither needs configuration; both start empty and the server manages
|
||||||
|
them. See [The crabidy store](./store.md) for how saves, captures, and
|
||||||
|
de-duplication work.
|
||||||
|
|
||||||
|
Client and TUI log files also go to `~/.local/state/crabidy/`. The
|
||||||
|
provider ignores non-track files there, so the logs do not show up in
|
||||||
|
the library.
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
Crabidy is a client/server music player. A headless server owns the music
|
||||||
|
library, the play queue, and audio output; thin clients connect to it over
|
||||||
|
gRPC and drive it. One server can be driven from several clients at once —
|
||||||
|
a terminal UI on your laptop, a browser tab, a shell script — and they all
|
||||||
|
see the same queue and playback state live.
|
||||||
|
|
||||||
|
This book describes **how the current version works**: the pieces, the
|
||||||
|
boundaries between them, and the behaviour you can rely on. It is reference
|
||||||
|
documentation, not a design log — the "why we chose X over Y" discussions
|
||||||
|
live in the `architecture/` folder in the source tree.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Crabidy is a personal, single-server music player for a trusted home
|
||||||
|
network. It is not multi-tenant and does not encrypt its transport itself
|
||||||
|
(see [Roles and authorization](./auth.md)).
|
||||||
|
```
|
||||||
|
|
||||||
|
## The shape of the system
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
clients: Clients {
|
||||||
|
tui: cbd-tui (terminal)
|
||||||
|
web: cbd-web (browser)
|
||||||
|
cli: cbd / CLI
|
||||||
|
}
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
shape: rectangle
|
||||||
|
library: Library (providers)
|
||||||
|
queue: Queue
|
||||||
|
playback: Playback + audio out
|
||||||
|
}
|
||||||
|
|
||||||
|
media: Media {
|
||||||
|
tidal: Tidal
|
||||||
|
youtube: YouTube
|
||||||
|
fs: Local files
|
||||||
|
}
|
||||||
|
|
||||||
|
clients -> server: gRPC (commands + update stream)
|
||||||
|
server.library -> media: fetch / stream
|
||||||
|
server.playback -> media: stream audio
|
||||||
|
```
|
||||||
|
|
||||||
|
The server exposes one gRPC service. Clients send **commands** (browse the
|
||||||
|
library, change the queue, control playback) and subscribe to an **update
|
||||||
|
stream** that pushes the current queue, play state, track position, and
|
||||||
|
capture progress as they change. Nothing is polled; the UI redraws from
|
||||||
|
pushed updates.
|
||||||
|
|
||||||
|
## The library
|
||||||
|
|
||||||
|
Everything you can play lives in one tree, addressed by path. Each top-level
|
||||||
|
segment is a **provider** mounted as a subtree:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── tidal Tidal streaming
|
||||||
|
├── youtube YouTube search & playlists
|
||||||
|
├── fs a local music folder
|
||||||
|
└── crabidy your saves: queues, bookmarks, and captures
|
||||||
|
```
|
||||||
|
|
||||||
|
Providers share one node/track model (see [The library
|
||||||
|
model](./library.md)) so a client browses `/tidal` and `/fs` with the same
|
||||||
|
keys and the same code. The `crabidy` provider is special: it is where the
|
||||||
|
server writes the things you save, backed by a content-addressed store of
|
||||||
|
audio files (see [The crabidy store](./store.md)).
|
||||||
|
|
||||||
|
## Binaries
|
||||||
|
|
||||||
|
| Binary | What it is |
|
||||||
|
| ---------------- | ----------------------------------------------------- |
|
||||||
|
| `crabidy-server` | the server: providers, queue, playback, gRPC |
|
||||||
|
| `cbd-tui` | the terminal client |
|
||||||
|
| `cbd-web` | the browser client (WASM), embedded into the server |
|
||||||
|
| `cbd` | server + terminal client in one process |
|
||||||
|
|
||||||
|
Every binary is also a command-line tool (see [Command
|
||||||
|
line](./clients/cli.md)); running one with no subcommand starts its usual
|
||||||
|
mode.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
- [Architecture](./architecture.md) — the server's internals and how a
|
||||||
|
request flows.
|
||||||
|
- [The library model](./library.md) — nodes, tracks, paths, and links.
|
||||||
|
- [Providers](./providers.md) — how each media source is wired in.
|
||||||
|
- [The crabidy store](./store.md) — saves, captures, and de-duplication.
|
||||||
|
- [Queue and playback](./queue.md) — the queue, playback, and persistence.
|
||||||
|
- [Clients](./clients.md) — the TUI, the web client, and the CLI.
|
||||||
|
- [Configuration](./config.md) and
|
||||||
|
[Roles and authorization](./auth.md).
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
# The library model
|
||||||
|
|
||||||
|
Everything playable in crabidy lives in one tree, addressed by
|
||||||
|
filesystem-like paths. Every provider — `/tidal`, `/youtube`, `/fs`, and
|
||||||
|
`/crabidy` — presents that same tree through the same two message types,
|
||||||
|
so a client browses them all with one piece of code. This page describes
|
||||||
|
those types, how paths work, and how a saved entry can link to another
|
||||||
|
provider.
|
||||||
|
|
||||||
|
For how the tree is served and routed, see
|
||||||
|
[Architecture](./architecture.md); for the providers themselves, see
|
||||||
|
[Providers](./providers.md) and the filesystem provider at
|
||||||
|
[./providers/fs.md](./providers/fs.md).
|
||||||
|
|
||||||
|
## One path-addressed tree
|
||||||
|
|
||||||
|
A path is an absolute, `/`-separated string whose first segment names the
|
||||||
|
provider. The path *is* the position in the tree: the parent of a node is
|
||||||
|
its path with the last segment trimmed, so no separate parent lookup is
|
||||||
|
needed. Whether a path is a node or a track is decided by its shape, not
|
||||||
|
by a type tag — each provider matches the segment slice against the
|
||||||
|
shapes it knows and rejects anything else as malformed.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── tidal Tidal streaming
|
||||||
|
├── youtube YouTube search & playlists
|
||||||
|
├── fs a local music folder
|
||||||
|
└── crabidy your saves: bookmarks and captures
|
||||||
|
├── current the live queue, mirrored (reserved)
|
||||||
|
├── road-trip/ a saved queue (flat)
|
||||||
|
└── favourite-album/ a saved subtree (structure preserved)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each segment is **percent-encoded**, so arbitrary titles — spaces, `%`,
|
||||||
|
unicode, a search term someone typed — survive intact as one segment.
|
||||||
|
Decoding a segment back to `.`/`..` or something containing a separator
|
||||||
|
is rejected, so a path can only ever descend within its provider.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
The synthetic root `/` is owned by the server itself; its children are
|
||||||
|
exactly the providers that initialized successfully. A provider whose
|
||||||
|
config or backing store failed to load simply does not appear.
|
||||||
|
```
|
||||||
|
|
||||||
|
## `LibraryNode` and `LibraryNodeChild`
|
||||||
|
|
||||||
|
Browsing returns a `LibraryNode`: the node at a path, its `title`, its
|
||||||
|
child **nodes**, and its **tracks**. A node carries capability flags that
|
||||||
|
tell a client what it may do here, so the client never hardcodes which
|
||||||
|
paths support what:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ------------------ | -------------------------------------------------- |
|
||||||
|
| `path`, `title` | this node's address and display name |
|
||||||
|
| `parent` | the parent path (derivable from `path`) |
|
||||||
|
| `children` | child nodes, as `LibraryNodeChild` |
|
||||||
|
| `tracks` | the tracks listed directly under this node |
|
||||||
|
| `is_queable` | this node can be queued (its subtree flattens) |
|
||||||
|
| `is_creatable` | children can be created here (`%`) |
|
||||||
|
| `is_downloadable` | a download capture is allowed; its tracks inherit |
|
||||||
|
| `tracks_deletable` | the listed tracks may be deleted (`d`) |
|
||||||
|
| `is_captured` | every track and child below is in the store |
|
||||||
|
|
||||||
|
A `LibraryNodeChild` is the lightweight entry for a child node in a
|
||||||
|
listing — enough to render a row and know what it supports without
|
||||||
|
fetching it: `path`, `title`, `is_queable`, `is_creatable`,
|
||||||
|
`is_editable`, `is_deletable`, `is_downloadable`, and `is_captured`.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
`is_editable` and `is_deletable` live only on the child entry, because
|
||||||
|
rename and delete always act on the item *selected in a listing*, never
|
||||||
|
on the currently-open node. `is_creatable` lives on the open node,
|
||||||
|
because creation targets the node you are looking at.
|
||||||
|
```
|
||||||
|
|
||||||
|
## `Track`
|
||||||
|
|
||||||
|
A track is the unit of playback. Queueing stores whole `Track` messages,
|
||||||
|
so the queue does not depend on the library still being reachable.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ------------------ | -------------------------------------------------- |
|
||||||
|
| `path` | full library path, provider first — the play key |
|
||||||
|
| `artist`, `title` | display metadata |
|
||||||
|
| `duration` | seconds, when known |
|
||||||
|
| `album` | title and optional release date, when known |
|
||||||
|
| `is_skipped` | no playable audio; shown red, playback skips it |
|
||||||
|
| `provider_item_id` | provider-internal id, independent of the path |
|
||||||
|
| `is_captured` | the content store already holds this track |
|
||||||
|
|
||||||
|
`path` is the routing key: playback asks the orchestrator for a track's
|
||||||
|
stream URLs by its path's first segment. `provider_item_id` is the id
|
||||||
|
that identifies the item *inside* its provider (a Tidal track id, a
|
||||||
|
YouTube video id, an fs file path) independent of which path it was
|
||||||
|
reached by — the same track reached through a playlist and through an
|
||||||
|
album has two paths but one provider id. The content store keys on it
|
||||||
|
(see [The crabidy store](./store.md)).
|
||||||
|
|
||||||
|
## Links: a saved entry pointing at another provider
|
||||||
|
|
||||||
|
A saved entry does not have to hold its own audio — it can be a **link**
|
||||||
|
to a track path owned by another provider, for example a `/crabidy` or
|
||||||
|
`/fs` entry pointing at `/tidal/artists/3634161/536243361`. The
|
||||||
|
providing crate keeps the link target in its own on-disk file; the queue
|
||||||
|
and playback never see the indirection, because links are resolved when
|
||||||
|
the entry is listed:
|
||||||
|
|
||||||
|
- When a provider builds a `Track` from a link entry, it sets the
|
||||||
|
track's `path` to the **link target** and fills `artist`/`title`/
|
||||||
|
`album` from its own file. From that point the track simply *is* a
|
||||||
|
track of the target provider as far as the queue and playback are
|
||||||
|
concerned.
|
||||||
|
- The queued track therefore routes straight to the real provider: no
|
||||||
|
extra indirection at play time, and a dead target degrades exactly
|
||||||
|
like any other dead track (a warning, then skip).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
fs_entry: "/fs/mix/we-will-rock-you\n(link entry on disk)" {
|
||||||
|
shape: page
|
||||||
|
}
|
||||||
|
resolved: "Track { path = /tidal/.../536243361,\ntitle from the file }" {
|
||||||
|
shape: rectangle
|
||||||
|
style.fill: "#e8f4e8"
|
||||||
|
}
|
||||||
|
tidal: "/tidal provider\n(resolves stream urls)"
|
||||||
|
|
||||||
|
fs_entry -> resolved: listed -> path rewritten to target
|
||||||
|
resolved -> tidal: queued track routes here
|
||||||
|
```
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
Links resolve **one hop only**, by construction: the path is rewritten
|
||||||
|
before the track can be queued, so a link whose target is itself a link
|
||||||
|
is never chained. A target that turns out to be another link dies at
|
||||||
|
play time with a warning instead — cycles cannot recurse.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creatable, editable, deletable nodes
|
||||||
|
|
||||||
|
Because capabilities travel with every listing, editing gestures are
|
||||||
|
just RPCs gated on a flag:
|
||||||
|
|
||||||
|
- **Create (`%`)** — on a node with `is_creatable`, a typed title
|
||||||
|
becomes a new child. What that means is provider-defined; under
|
||||||
|
`/tidal/search` the title is a search term and the created node holds
|
||||||
|
its results. Creation is idempotent — an existing title returns the
|
||||||
|
existing node.
|
||||||
|
- **Edit / rename (`e`)** — on a child with `is_editable`. Because a
|
||||||
|
search term's title *is* the query, renaming it re-runs the search;
|
||||||
|
the node's path changes with the title, so the rename returns the
|
||||||
|
renamed node at its new path.
|
||||||
|
- **Delete (`d`)** — on a child with `is_deletable`, or a track under a
|
||||||
|
node with `tracks_deletable`. Delete is idempotent and returns the
|
||||||
|
refreshed parent, so a client can redraw the listing without a
|
||||||
|
follow-up fetch. On `/crabidy` a delete removes only the metadata
|
||||||
|
file or folder and never the shared store audio.
|
||||||
|
|
||||||
|
A client renders the available gestures as markers on the row (a `%`
|
||||||
|
hint on a creatable node, `[e]`/`[d]` suffixes on editable/deletable
|
||||||
|
children) and silently ignores a keypress whose flag is absent.
|
||||||
|
|
||||||
|
## Skipped tracks
|
||||||
|
|
||||||
|
A track with `is_skipped` set has no playable audio — a capture recorded
|
||||||
|
its source as uncapturable but kept the entry so the queue's shape
|
||||||
|
survives. Clients render it red, and playback skips past it without a
|
||||||
|
provider round trip. The skip scan is bounded to one pass over the
|
||||||
|
queue, so a queue of nothing but skipped tracks (even with repeat on)
|
||||||
|
stops cleanly instead of spinning.
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Providers
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Everything crabidy can play lives in one library tree addressed by path.
|
||||||
|
The tree is not one monolithic source: it is composed from several
|
||||||
|
**providers**, each mounted as a subtree under a top-level path prefix.
|
||||||
|
A single **orchestrator** owns the synthetic root, lists the mounted
|
||||||
|
providers as its children, and routes every request to a provider by the
|
||||||
|
first segment of the path.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
root: "/ (orchestrator)" {
|
||||||
|
shape: rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
tidal: "/tidal — Tidal streaming"
|
||||||
|
youtube: "/youtube — YouTube search & playlists"
|
||||||
|
fs: "/fs — a local music folder"
|
||||||
|
crabidy: "/crabidy — your saves & captures"
|
||||||
|
|
||||||
|
root -> tidal: "route /tidal/*"
|
||||||
|
root -> youtube: "route /youtube/*"
|
||||||
|
root -> fs: "route /fs/*"
|
||||||
|
root -> crabidy: "route /crabidy/*"
|
||||||
|
```
|
||||||
|
|
||||||
|
Because routing is purely by prefix, the providers are independent of
|
||||||
|
one another. They all speak the same node/track model (see [The library
|
||||||
|
model](./library.md)), so a client browses `/tidal` and `/fs` with the
|
||||||
|
same keys and the same code, and a track reached through one provider
|
||||||
|
can be queued alongside a track from another.
|
||||||
|
|
||||||
|
## Each provider is optional
|
||||||
|
|
||||||
|
A provider is only mounted when its client initializes successfully. The
|
||||||
|
orchestrator holds each one as an optional handle and only adds its child
|
||||||
|
to the root listing when the client exists.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Initialization failure is **non-fatal**. A broken config file, missing
|
||||||
|
credentials, or a missing helper binary drops that one subtree with a
|
||||||
|
warning — the server and every other provider keep running. The
|
||||||
|
orchestrator never fails to start because a single provider could not.
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the exact set of top-level entries you see depends on your
|
||||||
|
configuration: `/tidal` needs a login, `/youtube` needs its client to
|
||||||
|
build, `/fs` needs a readable root directory, and `/crabidy` is always
|
||||||
|
present because the server owns it.
|
||||||
|
|
||||||
|
## Today's providers
|
||||||
|
|
||||||
|
- **[`/tidal`](./providers/tidal.md)** — streams from a Tidal account
|
||||||
|
via the `tidaldy` crate: playlists, favorite artists and their albums,
|
||||||
|
mixes, and search. Credentials live in `tidaly.toml`.
|
||||||
|
- **[`/youtube`](./providers/youtube.md)** — YouTube search and the
|
||||||
|
logged-in account's playlists via the `ytdy` crate. Metadata is
|
||||||
|
extracted in-process; config lives in `ytdy.toml`.
|
||||||
|
- **[`/fs`](./providers/fs.md)** — serves a local music folder. Folders
|
||||||
|
become nodes and `*.cbd-track.toml` files become tracks. Config lives
|
||||||
|
in `fsdy.toml`.
|
||||||
|
- **`/crabidy`** — where the server writes the things you save (saved
|
||||||
|
queues, bookmarks, and downloaded captures), backed by a
|
||||||
|
content-addressed store. It has its own page: [The crabidy
|
||||||
|
store](./store.md).
|
||||||
|
|
||||||
|
Several providers expose a **search** subtree in which you create nodes
|
||||||
|
whose titles are your search terms; see [Search](./providers/search.md)
|
||||||
|
for how that works across providers.
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Filesystem — /fs
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
The filesystem provider (crate `fsdy`) serves a single local music
|
||||||
|
folder under `/fs`. It does not read audio tags: instead it walks the
|
||||||
|
directory tree and treats small TOML files with a well-known suffix as
|
||||||
|
**serialized track nodes** — each one carries a track's metadata and a
|
||||||
|
reference to the thing that actually plays.
|
||||||
|
|
||||||
|
Folders become library nodes; `*.cbd-track.toml` files become tracks.
|
||||||
|
Everything else in the tree (other files, hidden entries, symlinks) is
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
## Configuration — `~/.config/crabidy/fsdy.toml`
|
||||||
|
|
||||||
|
The file has a single field:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Absolute path of the directory exposed under /fs. When unset, the
|
||||||
|
# platform music directory (dirs::audio_dir(), e.g. ~/Music) is used.
|
||||||
|
root = "/home/me/Music"
|
||||||
|
```
|
||||||
|
|
||||||
|
A missing or empty file is fine — the default root is used. A root that
|
||||||
|
does not exist yet is accepted; listing it simply fails until it
|
||||||
|
appears. If neither a configured root nor a platform music directory is
|
||||||
|
available, the provider disables itself with a warning rather than
|
||||||
|
taking the server down.
|
||||||
|
|
||||||
|
## The track-file format
|
||||||
|
|
||||||
|
A file named `<anything>.cbd-track.toml` inside the root is a track. The
|
||||||
|
schema is:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Required.
|
||||||
|
title = "We Will Rock You"
|
||||||
|
# Optional; empty when omitted (web radio streams often have no artist).
|
||||||
|
artist = "Queen"
|
||||||
|
# Optional, in seconds.
|
||||||
|
duration = 122
|
||||||
|
|
||||||
|
# Optional.
|
||||||
|
[album]
|
||||||
|
title = "News of the World"
|
||||||
|
release_date = "1977-10-28"
|
||||||
|
|
||||||
|
# Required: exactly one playable (see below).
|
||||||
|
[playable]
|
||||||
|
file = "../flac/we-will-rock-you.flac"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `[playable]` table must set **exactly one** of five fields:
|
||||||
|
|
||||||
|
- `file` — a local audio file (see resolution below).
|
||||||
|
- `url` — an `http(s)` URL streamed by the player.
|
||||||
|
- `link` — an absolute crabidy path owned by another provider, e.g.
|
||||||
|
`/tidal/artists/3634161/536243361`.
|
||||||
|
- `store` — the bare name of an entry in the content-addressed store,
|
||||||
|
written by download captures (see [The crabidy store](./store.md)).
|
||||||
|
- `skipped = true` — the track has no playable audio; playback skips it.
|
||||||
|
(`skipped = false` counts as unset.)
|
||||||
|
|
||||||
|
```admonish info
|
||||||
|
A file that is not valid TOML, or that sets zero or several playables,
|
||||||
|
is **skipped with a warning** at listing time. It never panics and never
|
||||||
|
poisons its sibling files — the rest of the directory still lists.
|
||||||
|
```
|
||||||
|
|
||||||
|
### How a `file` playable resolves
|
||||||
|
|
||||||
|
- An **absolute** path is used as-is.
|
||||||
|
- A **relative** path is joined onto the directory containing the track
|
||||||
|
file, so a whole music folder stays relocatable.
|
||||||
|
|
||||||
|
Existence is not checked while listing; a dead reference surfaces at play
|
||||||
|
time as the player's normal "cannot open" warning, and playback moves on.
|
||||||
|
|
||||||
|
### How a `link` playable resolves
|
||||||
|
|
||||||
|
When the provider builds a track from a `link` file, it sets the track's
|
||||||
|
path to the **link target**, not to the `/fs` path. From then on the
|
||||||
|
track *is* a track of the target's provider as far as the queue and
|
||||||
|
playback are concerned — the orchestrator's prefix routing does the rest.
|
||||||
|
|
||||||
|
Links resolve exactly **one hop**: `get_urls_for_track` on an `/fs` path
|
||||||
|
never follows a link, so a link whose target is itself a link file dies
|
||||||
|
at play time with a warning, and cycles cannot recurse. The metadata
|
||||||
|
shown is what the file says, not the target's live metadata.
|
||||||
|
|
||||||
|
## Path-to-disk mapping
|
||||||
|
|
||||||
|
Library paths mirror the directory tree: `/fs/<seg>/<seg>/…`, where each
|
||||||
|
segment is the percent-encoded file name (the same encoding search terms
|
||||||
|
use), so names with spaces, `%`, or unicode survive the path scheme.
|
||||||
|
|
||||||
|
Mapping a path back to disk is a **safe descent**: each segment is
|
||||||
|
decoded and rejected if it is empty, `.`, `..`, or contains a path
|
||||||
|
separator. The joined path is always a pure descent from the root, so a
|
||||||
|
client-supplied path cannot escape the configured folder. Symlinks are
|
||||||
|
skipped during listing, so they cannot lead out of the root either.
|
||||||
|
|
||||||
|
## Listing behavior
|
||||||
|
|
||||||
|
- Directories and track files are each sorted case-insensitively by file
|
||||||
|
name — a deterministic queueing order. Prefix file names with `01`,
|
||||||
|
`02`, … to order an album.
|
||||||
|
- Directories are queueable nodes; queueing one resolves its whole
|
||||||
|
subtree, one chunk per directory.
|
||||||
|
- **Nothing is cached.** Every navigation reads the directory fresh, so
|
||||||
|
edits made with an ordinary file manager appear on the next visit —
|
||||||
|
there is no file watching and no reload step.
|
||||||
|
|
||||||
|
Creating track files through the library is not supported here; they are
|
||||||
|
made and edited with normal file tools. (Other instances of the same
|
||||||
|
crate — the `/crabidy` saves — do support renaming and deleting; see
|
||||||
|
[The crabidy store](./store.md).)
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Search
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Crabidy has no search dialog. Searching is done by **editing the library
|
||||||
|
tree**: inside a provider's search subtree you create a node whose title
|
||||||
|
is your search term, and that node holds the results. The created node is
|
||||||
|
persistent — you can revisit it, queue from it, and (for providers that
|
||||||
|
allow it) rename or delete it — so a search is just another place in the
|
||||||
|
tree.
|
||||||
|
|
||||||
|
## Creating a search node
|
||||||
|
|
||||||
|
A node that accepts creation is marked **creatable**. When the currently
|
||||||
|
open node is creatable, press `%`: a one-line input opens at the bottom
|
||||||
|
of the library pane, you type a term and press `Enter` (or `Esc` to
|
||||||
|
cancel), and the term is sent to the provider as the title of a new
|
||||||
|
child node. The client then navigates into that node.
|
||||||
|
|
||||||
|
```admonish info
|
||||||
|
`%` acts on the **open** node, not the selected child — you enter a
|
||||||
|
search subtree (e.g. `/tidal/search`) and press `%` there. Creatable
|
||||||
|
nodes render with a `%` marker in the child list, and the pane title
|
||||||
|
shows a hint when the open node itself is creatable, so the affordance
|
||||||
|
is visible without documentation.
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating a term that already exists returns the existing node rather than
|
||||||
|
erroring; empty or whitespace-only terms are rejected. Terms containing
|
||||||
|
`/` or `%` are safe: the term is percent-encoded into the path segment
|
||||||
|
while the node keeps the raw term as its display title.
|
||||||
|
|
||||||
|
## What the results look like
|
||||||
|
|
||||||
|
The shape depends on the provider:
|
||||||
|
|
||||||
|
- **[Tidal](./tidal.md)** — `/tidal/search/<term>` carries track results
|
||||||
|
directly (queueable in place) and lists artist and album results as
|
||||||
|
children whose paths are canonical (`/tidal/artists/<id>`, …), so
|
||||||
|
diving into an artist reuses the normal browse machinery. The term
|
||||||
|
node itself is not queueable.
|
||||||
|
- **[YouTube](./youtube.md)** — `/youtube/search/<term>` lists the top
|
||||||
|
video results as tracks and *is* queueable, since the results are
|
||||||
|
homogeneous.
|
||||||
|
|
||||||
|
In both cases results are a live view, fetched fresh when the node is
|
||||||
|
opened — nothing is cached. Terms live in memory for the life of the
|
||||||
|
server process.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
This tree-editing search is distinct from the terminal client's `/`
|
||||||
|
**live filter**, which narrows the children already shown in the current
|
||||||
|
node without fetching anything. See the [Terminal UI](../clients/tui.md)
|
||||||
|
for that.
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Tidal — /tidal
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
The Tidal provider (crate `tidaldy`) mounts a Tidal account under
|
||||||
|
`/tidal` and streams from Tidal's web API. It offers your playlists,
|
||||||
|
favorite artists with their albums and tracks, mixes, and a search
|
||||||
|
subtree.
|
||||||
|
|
||||||
|
## Login
|
||||||
|
|
||||||
|
On first start the provider runs Tidal's OAuth **device login**: the
|
||||||
|
server prints a `link.tidal.com` verification URL to its stdout (for the
|
||||||
|
`cbd` bundle, look in the log file). Open it, authorize, and the provider
|
||||||
|
finishes logging in by itself. The obtained tokens are written back into
|
||||||
|
the config file and refreshed automatically from then on, so you should
|
||||||
|
not need to log in again.
|
||||||
|
|
||||||
|
## Configuration — `~/.config/crabidy/tidaly.toml`
|
||||||
|
|
||||||
|
The file is rewritten on every server start with the current values,
|
||||||
|
including refreshed tokens.
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
`tidaly.toml` contains your credentials — keep it private. A missing or
|
||||||
|
empty file is fine: defaults are used and the device login runs on the
|
||||||
|
next start. To force a fresh login, delete the `[login]` section and
|
||||||
|
restart.
|
||||||
|
```
|
||||||
|
|
||||||
|
The main setting you might change is `audio_quality` (`"Low"`,
|
||||||
|
`"High"`, `"Lossless"`, or `"HiRes"`); the API endpoints and OAuth
|
||||||
|
client identity have working built-in defaults. See `tidaldy/README.md`
|
||||||
|
in the source tree for the full field list.
|
||||||
|
|
||||||
|
## Browsing and search
|
||||||
|
|
||||||
|
The tree offers your playlists, favorite artists (drilling into albums
|
||||||
|
and tracks), mixes, and a search node. Under `/tidal/search`, press `%`
|
||||||
|
to create a search term; the term becomes a persistent child node
|
||||||
|
holding its results — tracks in place, plus artists and albums as
|
||||||
|
children. Terms are renamable with `e` (which re-runs the search) and
|
||||||
|
deletable with `d`. See [Search](./search.md) for the shared mechanism.
|
||||||
|
|
||||||
|
## Stable track identity
|
||||||
|
|
||||||
|
Each Tidal track carries its Tidal track id in the wire track's
|
||||||
|
`provider_item_id`. Because that id is stable and independent of the
|
||||||
|
path a track was reached by, the same track de-duplicates in the crabidy
|
||||||
|
store no matter whether you queued it from a playlist, an album, or a
|
||||||
|
search result. Bookmarks and downloaded captures therefore reference one
|
||||||
|
copy of a track rather than one per path (see [The crabidy
|
||||||
|
store](../store.md)).
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
Everything queueable is also capturable: `w` bookmarks a subtree as
|
||||||
|
links, and `W` downloads a subtree's audio into `/crabidy`.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Tidal stream URLs are short-lived, tokenised, and windowed on download.
|
||||||
|
They are treated as secrets and kept out of logs and error reports.
|
||||||
|
```
|
||||||
|
|
||||||
|
For endpoint specifics and the complete configuration reference, read
|
||||||
|
`tidaldy/README.md` in the repository.
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# YouTube — /youtube
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
The YouTube provider (crate `ytdy`) mounts YouTube under `/youtube`. It
|
||||||
|
offers a search subtree that works without any login, and — when a
|
||||||
|
cookie login is configured — the account's saved playlists.
|
||||||
|
|
||||||
|
## Extraction engine: rustypipe (in-process)
|
||||||
|
|
||||||
|
All **metadata** — search, playlist listings, and video details — comes
|
||||||
|
from the pure-Rust [rustypipe](https://crates.io/crates/rustypipe)
|
||||||
|
Innertube client, running in-process. There is no `yt-dlp` (or other
|
||||||
|
Python) subprocess involved in extraction.
|
||||||
|
|
||||||
|
```admonish info
|
||||||
|
Stream *URLs* are the one exception. YouTube currently caps tokenless
|
||||||
|
stream URLs at their leading ~1 MiB, and a minimal `yt-dlp` sidecar is
|
||||||
|
the only maintained solver for the cipher challenges that lift the cap.
|
||||||
|
So `get_urls_for_track` consults `yt-dlp` when the binary is available;
|
||||||
|
everything else stays on rustypipe. Without the binary the provider
|
||||||
|
still works, but playback of a track stops after roughly its first
|
||||||
|
~1 MiB. This is a documented concession, not the extraction path.
|
||||||
|
```
|
||||||
|
|
||||||
|
Streams prefer the `audio/mp4` (AAC) format the built-in player can
|
||||||
|
decode, so downloaded captures store `.m4a` files.
|
||||||
|
|
||||||
|
## Configuration — `~/.config/crabidy/ytdy.toml`
|
||||||
|
|
||||||
|
All options are optional:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Netscape cookies.txt export for logged-in features (saved playlists).
|
||||||
|
# Default: unset (logged out; search still works).
|
||||||
|
# cookies = "/home/me/youtube-cookies.txt"
|
||||||
|
|
||||||
|
# Results per search term. Default: 20.
|
||||||
|
# search_results = 20
|
||||||
|
|
||||||
|
# Per-request timeout in seconds. Default: 30.
|
||||||
|
# call_timeout_secs = 30
|
||||||
|
|
||||||
|
# The yt-dlp binary used ONLY for stream URLs (PATH lookup by a bare
|
||||||
|
# name). Default: "yt-dlp".
|
||||||
|
# binary = "yt-dlp"
|
||||||
|
|
||||||
|
# Optional rustypipe-botguard binary for PO-token attestation (also
|
||||||
|
# auto-detected on PATH). Default: unset.
|
||||||
|
# botguard_bin = "/home/me/.cargo/bin/rustypipe-botguard"
|
||||||
|
```
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
The cookies file and the rustypipe client cache (under
|
||||||
|
`~/.config/crabidy/rustypipe/`) both grant access to your YouTube
|
||||||
|
session — keep them private. The provider logs only their paths, never
|
||||||
|
their contents, and googlevideo stream URLs are throttled, tokenised,
|
||||||
|
windowed on download, and redacted from logs.
|
||||||
|
```
|
||||||
|
|
||||||
|
The provider caches the rotated session cookie, so a cookies export only
|
||||||
|
needs to be valid once. Rejected or missing cookies degrade to
|
||||||
|
logged-out with a warning; a broken client at startup disables only
|
||||||
|
`/youtube`, never the server.
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
- `/youtube/search` — always present. Press `%` to create a search term
|
||||||
|
(see [Search](./search.md)); the term node lists the top
|
||||||
|
`search_results` videos as tracks and is queueable, since the results
|
||||||
|
are homogeneous tracks. Terms are renamable (`e`) and deletable (`d`),
|
||||||
|
live in memory, and are recreated implicitly if you navigate to an old
|
||||||
|
term path after a restart.
|
||||||
|
- `/youtube/playlists` — present only when a cookie login is accepted;
|
||||||
|
lists the account's saved playlists, each drilling into its tracks.
|
||||||
|
|
||||||
|
Track paths end in the video id, which is also carried in the wire
|
||||||
|
track's `provider_item_id` — a stable identity independent of the path
|
||||||
|
the track was reached by, so the same video de-duplicates in the crabidy
|
||||||
|
store (see [The crabidy store](../store.md)).
|
||||||
|
|
||||||
|
## Captures
|
||||||
|
|
||||||
|
Search results and playlists are downloadable: `W` captures a subtree's
|
||||||
|
audio into `/crabidy`. Under the stream throttle this is slow, but
|
||||||
|
captures are resumable — re-capture the same name to continue.
|
||||||
|
|
||||||
|
For the full analysis behind the rustypipe/`yt-dlp` split, read
|
||||||
|
`ytdy/README.md` and `architecture/youtube-rustypipe.md` in the source
|
||||||
|
tree.
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
# Queue and playback
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
The server owns one play queue and one audio output. Clients send queue
|
||||||
|
commands and playback commands over gRPC and watch the result on the update
|
||||||
|
stream; the queue you see in a terminal is the same queue the browser tab
|
||||||
|
sees, live (see [Introduction](./intro.md)).
|
||||||
|
|
||||||
|
## The queue model
|
||||||
|
|
||||||
|
The queue is an ordered list of tracks with a **current position** and two
|
||||||
|
modifiers:
|
||||||
|
|
||||||
|
- **repeat** — after the last track, wrap to the first instead of stopping.
|
||||||
|
- **shuffle** — play in a shuffled order. The shuffle order is derived around
|
||||||
|
the current track and is deliberately *not* persisted; toggling shuffle off
|
||||||
|
restores track order.
|
||||||
|
|
||||||
|
Every queue operation names its effect relative to the current track:
|
||||||
|
|
||||||
|
- **replace** — drop the queue and start the new tracks playing.
|
||||||
|
- **append** — add to the end.
|
||||||
|
- **queue** — insert right after the current track.
|
||||||
|
- **insert** — insert at a given position.
|
||||||
|
- **remove** / **clear** — drop tracks, or everything (optionally keeping the
|
||||||
|
current track).
|
||||||
|
|
||||||
|
## The playback loop is the single writer
|
||||||
|
|
||||||
|
All queue state lives inside one task, the **playback loop**. Every mutation —
|
||||||
|
from a client command, from a chunk arriving mid-resolve, from the playback
|
||||||
|
loop advancing tracks — happens inside that loop and nowhere else. Each change
|
||||||
|
funnels through one broadcast site, so the queue snapshot pushed to clients
|
||||||
|
and the snapshot handed to persistence can never drift apart.
|
||||||
|
|
||||||
|
Because the loop is the only writer, work that would block it is spawned off:
|
||||||
|
disk writes for a queue save run on a separate task, and large collections are
|
||||||
|
resolved on their own tasks (below). User commands keep flowing through the
|
||||||
|
loop while that background work runs.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Clients never poll. The loop pushes a `Queue` update on every content change,
|
||||||
|
a `QueueTrack` update when the current track moves, and `Mods` updates when
|
||||||
|
shuffle or repeat toggles. A client that connects mid-flight gets the full
|
||||||
|
state — including whether a resolve is in progress — in its init response.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progressive queueing
|
||||||
|
|
||||||
|
Queueing a large collection — an artist with many albums, a thousand-track
|
||||||
|
playlist — does not wait for the whole thing to resolve. Tracks are resolved
|
||||||
|
in **chunks**, in playback order, and streamed into the queue as they arrive.
|
||||||
|
Playback can start on the first chunk while later chunks are still being
|
||||||
|
fetched.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
|
||||||
|
user: { shape: person }
|
||||||
|
tui: client
|
||||||
|
loop: playback loop
|
||||||
|
fwd: forwarder task
|
||||||
|
provider: provider
|
||||||
|
|
||||||
|
user -> tui: queue a large artist
|
||||||
|
tui -> loop: "Append(paths)"
|
||||||
|
loop -> loop: register pending resolve op
|
||||||
|
loop -> tui: "Queue update (resolving = true)"
|
||||||
|
loop -> fwd: spawn forwarder
|
||||||
|
fwd -> provider: "ResolveTracks(path, chunk channel)"
|
||||||
|
provider -> fwd: chunk 1
|
||||||
|
fwd -> loop: "ApplyResolvedChunk(op, chunk 1)"
|
||||||
|
loop -> loop: "apply + play() first track"
|
||||||
|
loop -> tui: "Queue update (resolving = true)"
|
||||||
|
provider -> fwd: chunk 2
|
||||||
|
fwd -> loop: "ApplyResolvedChunk(op, chunk 2)"
|
||||||
|
loop -> tui: "Queue update (resolving = true)"
|
||||||
|
fwd -> loop: "ResolveFinished(op)"
|
||||||
|
loop -> tui: "Queue update (resolving = false)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The mechanics:
|
||||||
|
|
||||||
|
- The loop registers a **pending op** (an id, the kind, an insertion cursor,
|
||||||
|
and a cancel flag) and spawns a **forwarder** task. It immediately
|
||||||
|
broadcasts a `Queue` update with the unchanged tracks and `resolving = true`
|
||||||
|
so feedback appears within one round trip.
|
||||||
|
- The forwarder drives the provider's chunked resolve over a small bounded
|
||||||
|
channel and forwards each chunk back to the loop as an
|
||||||
|
`ApplyResolvedChunk` command, then a final `ResolveFinished`. Queue state is
|
||||||
|
mutated only when the loop processes those commands.
|
||||||
|
- Each chunk is applied per op kind: **replace** resets on its first chunk and
|
||||||
|
appends the rest; **append** appends every chunk; **queue**/**insert**
|
||||||
|
advance an insertion cursor so tracks land in order after the current one.
|
||||||
|
Only the chunk that first makes a track current starts the player; later
|
||||||
|
chunks never restart it.
|
||||||
|
- Backpressure is real at every hop — a slow consumer slows the fetching
|
||||||
|
rather than buffering without bound.
|
||||||
|
|
||||||
|
```admonish tip
|
||||||
|
While the latest `Queue` update carries `resolving = true`, the TUI shows an
|
||||||
|
animated dots pseudo-item after the last track. It is drawn at render time
|
||||||
|
only and never enters the list, so it cannot be selected or removed.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replace and clear cancel in-flight resolves
|
||||||
|
|
||||||
|
A `replace` or a `clear` marks every pending op cancelled and drops it. Each
|
||||||
|
forwarder sees the flag and drops its chunk channel; the provider's next send
|
||||||
|
fails and it stops fetching. Any chunk already in flight for a dropped op is
|
||||||
|
ignored by the loop, so stale tracks from the old operation can never trickle
|
||||||
|
into the queue you just replaced or emptied. Additive ops (`append`, `queue`,
|
||||||
|
`insert`) do not cancel each other — their chunks interleave between ops while
|
||||||
|
each op keeps its own internal order.
|
||||||
|
|
||||||
|
## Skipped tracks in playback
|
||||||
|
|
||||||
|
A track can be marked **skipped** — a capture recorded its source as
|
||||||
|
uncapturable (see [The crabidy store](./store.md)). Skipped tracks stay in the
|
||||||
|
queue (you see the gap, not a silently shorter list) and render red in the
|
||||||
|
TUI.
|
||||||
|
|
||||||
|
When playback looks for the next track to play, it advances past unplayable
|
||||||
|
ones:
|
||||||
|
|
||||||
|
- a track marked skipped is passed over **without a provider round trip**;
|
||||||
|
- a track whose stream URLs fail to resolve is passed over with a warning.
|
||||||
|
|
||||||
|
The whole skip loop is **bounded by the queue length at entry** — one full
|
||||||
|
pass at most. Without that bound, an all-skipped queue with repeat on would
|
||||||
|
cycle forever and hammer the provider; instead the search gives up after one
|
||||||
|
pass and the player stops.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The live queue survives a server restart. It is mirrored to the reserved
|
||||||
|
`/crabidy/current` folder (see [The crabidy store](./store.md)):
|
||||||
|
|
||||||
|
- a **flat set of link tomls**, one per track in queue order, plus
|
||||||
|
- a hidden **`.queue-state.toml`** sidecar holding the current position,
|
||||||
|
repeat, and shuffle flags.
|
||||||
|
|
||||||
|
Writes never block the loop. Every queue-state change sends a snapshot into a
|
||||||
|
single-slot **latest-wins** channel; a dedicated persister task debounces
|
||||||
|
briefly (so a burst of resolve chunks coalesces into one write), skips writes
|
||||||
|
whose snapshot is unchanged from the last, and rewrites `current` with the
|
||||||
|
same atomic temp-and-swap the store uses for any save. Disk failures are
|
||||||
|
warnings — playback is never affected. When no usable state directory exists,
|
||||||
|
persistence is simply disabled and the queue lives in memory only.
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
Only the queue's *track order* and position are persisted — not the shuffled
|
||||||
|
play order, and not the position within the current track. Restoring a
|
||||||
|
shuffled queue reshuffles around the restored current track.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore on startup, never autoplay
|
||||||
|
|
||||||
|
Before the loop starts serving, the server reads `current` directly — the
|
||||||
|
sorted link tomls rewritten back to their targets, plus the state sidecar —
|
||||||
|
and applies them to the queue, restoring the position and the repeat/shuffle
|
||||||
|
flags. It leaves the player **stopped**: a restarted server comes back silent,
|
||||||
|
with your queue intact, waiting for you to press play. A missing folder is a
|
||||||
|
fresh start; a broken entry or an out-of-range position is skipped or clamped
|
||||||
|
with a warning.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
The `current` folder is an ordinary-looking queue folder under `/crabidy`, but
|
||||||
|
its name is reserved: the playback loop overwrites it on every change, and a
|
||||||
|
save can never use the name `current`.
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [The crabidy store](./store.md) — saves, captures, and the `current` mirror.
|
||||||
|
- [The library model](./library.md) — tracks, paths, and links.
|
||||||
|
- [The terminal UI](./clients/tui.md) — the queue pane and its key bindings.
|
||||||
|
- [Configuration](./config.md) — where the persisted queue is stored.
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
# The crabidy store
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
`/crabidy` is one filesystem provider — the only writable one — where the
|
||||||
|
server keeps everything you save. Its listings look like any other library
|
||||||
|
subtree (see [The library model](./library.md)), but every track toml under
|
||||||
|
it either **links** back to a source provider or **links into a shared,
|
||||||
|
content-addressed store** of audio files. The two halves live in separate
|
||||||
|
directories, split by XDG kind:
|
||||||
|
|
||||||
|
- the **toml tree** at `~/.local/state/crabidy/` — the folders and
|
||||||
|
`*.cbd-track.toml` files the `/crabidy` provider lists (this is *state*);
|
||||||
|
- the **content store** at `~/.local/share/crabidy/` — a flat directory of
|
||||||
|
audio files, each paired with a sidecar, that captured tomls point at
|
||||||
|
(this is *data*).
|
||||||
|
|
||||||
|
One writer, `CrabidyStore`, owns both roots and serializes every mutation.
|
||||||
|
The provider only reads: it lists the tomls and resolves store links when a
|
||||||
|
captured track plays.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
There is one `/crabidy` provider, not the older `/queues`, `/bookmarks`, and
|
||||||
|
`/captures` split. Bookmarks (`w`) and captures (`W`) are now two kinds of
|
||||||
|
save that coexist in the same tree, told apart by the captured marker.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The two roots
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
orchestrator: Library (path-routed) {
|
||||||
|
tidal
|
||||||
|
youtube
|
||||||
|
fs
|
||||||
|
crabidy: "/crabidy (reads tomls,\nresolves store links)"
|
||||||
|
}
|
||||||
|
|
||||||
|
state: "~/.local/state/crabidy/\ntoml tree" { shape: cylinder }
|
||||||
|
share: {
|
||||||
|
label: "~/.local/share/crabidy/\ncontent store:\naudio + sidecars"
|
||||||
|
shape: cylinder
|
||||||
|
}
|
||||||
|
|
||||||
|
writer: "CrabidyStore (single writer)" {
|
||||||
|
index: "StoreIndex\n(provider-id -> entry,\nhash -> entry)"
|
||||||
|
}
|
||||||
|
|
||||||
|
orchestrator.crabidy -> state: lists folders + tomls
|
||||||
|
orchestrator.crabidy -> share: resolves store playables
|
||||||
|
writer -> state: writes save folders + track tomls
|
||||||
|
writer -> share: writes audio + sidecars
|
||||||
|
writer.index -> share: built by scanning sidecars at open
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level folders under `/crabidy` are **user saves**, each created by `w` or
|
||||||
|
`W`. A saved queue is flat; a saved library subtree keeps its structure (it
|
||||||
|
falls out of walking the source). The reserved `current` folder is the
|
||||||
|
live-queue mirror — see [Queue and playback](./queue.md).
|
||||||
|
|
||||||
|
## The store and its sidecars
|
||||||
|
|
||||||
|
`~/.local/share/crabidy/` is a **flat** directory. Each unique playable is a
|
||||||
|
pair of files:
|
||||||
|
|
||||||
|
- `<name>` — the audio file, named after the source's natural name: a local
|
||||||
|
file's basename, or a sanitized `<title>.<ext>` for a download (the
|
||||||
|
extension comes from the download's content type or URL). On a name
|
||||||
|
collision with *different* content, a numeral is appended before the
|
||||||
|
extension: `song.flac`, `song (2).flac`, … Identical content never reaches
|
||||||
|
naming — it de-duplicates first.
|
||||||
|
- `<name>.cbd-store.toml` — the sidecar: the entry's content hash and every
|
||||||
|
provider identity that maps to it.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# song.flac.cbd-store.toml
|
||||||
|
hash = "blake3:1f0c…" # content hash of the audio file
|
||||||
|
|
||||||
|
[[provider]] # one entry per provider identity
|
||||||
|
provider = "tidal" # provider name (source path root)
|
||||||
|
id = "125169484" # provider-internal id
|
||||||
|
title = "Bohemian Rhapsody"
|
||||||
|
artist = "Queen"
|
||||||
|
duration = 355
|
||||||
|
aliases = ["Bohemian Rhapsody (Remastered)"] # other titles for this id
|
||||||
|
|
||||||
|
[[provider]] # same audio reached via a second identity
|
||||||
|
provider = "youtube"
|
||||||
|
id = "fJ9rUzIMcZQ"
|
||||||
|
title = "Queen – Bohemian Rhapsody (Official Video)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The **sidecars are the single source of truth** — there is no separate
|
||||||
|
persisted index. At open, `CrabidyStore` scans every `*.cbd-store.toml` and
|
||||||
|
builds an in-memory `StoreIndex`:
|
||||||
|
|
||||||
|
- `by_provider_id: (provider, id) -> store name`
|
||||||
|
- `by_hash: content hash -> store name`
|
||||||
|
|
||||||
|
Both lookups are O(1), and the index is updated on every write. A malformed
|
||||||
|
sidecar is skipped with a warning, so one bad file never poisons the index.
|
||||||
|
|
||||||
|
```admonish tip
|
||||||
|
The store name is opaque — only the toml's title shows in the UI. Two saves
|
||||||
|
that reference the same audio point their tomls at the same `<name>`, so
|
||||||
|
capturing a track twice from different places costs one copy on disk.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider identity
|
||||||
|
|
||||||
|
The store keys on a **provider-internal id**, not on a path (the same item is
|
||||||
|
reachable through a playlist, a search, an album…). Each provider sets it on
|
||||||
|
the tracks it produces:
|
||||||
|
|
||||||
|
- **tidal** → the numeric track id
|
||||||
|
- **youtube** → the video id
|
||||||
|
- **fs** → the source file's absolute path (two `/fs` tomls pointing at one
|
||||||
|
file share an id, so they de-duplicate)
|
||||||
|
|
||||||
|
An empty id means the provider exposes none; identity then falls back to the
|
||||||
|
content hash alone.
|
||||||
|
|
||||||
|
## Saving: `w` and `W`
|
||||||
|
|
||||||
|
A save takes a *source* — a live-queue snapshot or a library-node path — and
|
||||||
|
writes a new top-level folder `/crabidy/<name>`:
|
||||||
|
|
||||||
|
- **`w` (bookmark).** Writes a folder of **link** tomls, one per track, each
|
||||||
|
carrying the entry's metadata and a `link` playable back to the source. No
|
||||||
|
audio, no store. Reloading rewrites each link back to its target.
|
||||||
|
- **`W` (capture).** Writes the same folder, but each track's audio is fetched
|
||||||
|
into the content store and its toml carries a `store` playable. Works on
|
||||||
|
both a library node and the queue.
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
A download capture (`W`) can take a long time — it fetches every track's
|
||||||
|
audio. De-duplication (below) makes a re-capture cheap, but the first one is
|
||||||
|
bounded only by the source size and the byte budget.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Atomic saves, no overwrite
|
||||||
|
|
||||||
|
Every save is built in a hidden `.tmp-<name>` sibling folder and renamed into
|
||||||
|
place on success; a failed run removes the temp folder and leaves nothing
|
||||||
|
behind. If `/crabidy/<name>` **already exists**, the save is refused with
|
||||||
|
`name "<name>" already exists` — nothing is clobbered. To replace a save, you
|
||||||
|
delete the old folder first.
|
||||||
|
|
||||||
|
Resumability comes from the **store**, not the folder: a failed capture leaves
|
||||||
|
the name free to retry, while any audio already committed to the store
|
||||||
|
persists and makes the retry fast (the de-dup flow reuses it). The folder
|
||||||
|
itself is all-or-nothing. `current` is exempt — the playback loop overwrites
|
||||||
|
it on every queue change, and you cannot save over that reserved name.
|
||||||
|
|
||||||
|
## De-duplication on capture
|
||||||
|
|
||||||
|
For each source track, a capture walks these steps in order and writes exactly
|
||||||
|
one toml at the end:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: down
|
||||||
|
|
||||||
|
start: "resolve source track\n(provider, id, natural name)"
|
||||||
|
already: "already store-backed?\n(local file under the store root)"
|
||||||
|
byid: "index.by_provider_id[(provider, id)] ?"
|
||||||
|
getbytes: "obtain bytes\n(download to temp, or local file)"
|
||||||
|
byhash: "index.by_hash[hash(bytes)] ?"
|
||||||
|
newentry: "NEW: copy into store (+numeral)\nand write sidecar"
|
||||||
|
addid: "add identity to sidecar,\ndiscard the temp copy"
|
||||||
|
writetoml: "write track toml with a store playable"
|
||||||
|
|
||||||
|
start -> already
|
||||||
|
already -> writetoml: "yes: reuse the target name (no copy)"
|
||||||
|
already -> byid: "no"
|
||||||
|
byid -> writetoml: "HIT: reuse; record a differing title as an alias"
|
||||||
|
byid -> getbytes: "MISS"
|
||||||
|
getbytes -> byhash
|
||||||
|
byhash -> addid: "HIT (same content, new identity)"
|
||||||
|
byhash -> newentry: "MISS"
|
||||||
|
addid -> writetoml
|
||||||
|
newentry -> writetoml
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Already store-backed?** If the source resolves to a local file already
|
||||||
|
inside the store root, there is nothing to fetch — the toml links to that
|
||||||
|
same `<name>`. (Capturing an already-captured item does nothing.)
|
||||||
|
2. **Provider-id lookup.** If `(provider, id)` is already in the index, reuse
|
||||||
|
that entry and skip the download. If the current title differs from the
|
||||||
|
stored one, it is appended to that identity's `aliases`. This is the common
|
||||||
|
re-capture path.
|
||||||
|
3. **Miss → obtain bytes.** A streamed source is downloaded to a temp file; a
|
||||||
|
local source *is* the bytes. The bytes are hashed with blake3.
|
||||||
|
4. **Hash lookup.** If the hash is already in the index, identical content is
|
||||||
|
already stored under some other identity: a new `[[provider]]` entry is
|
||||||
|
added to that sidecar, the temp download is discarded, and the toml points
|
||||||
|
at the existing entry. No duplicate.
|
||||||
|
5. **Miss → new store entry.** A store name is chosen from the natural name
|
||||||
|
(with a numeral on collision), the temp file is moved (or the local file
|
||||||
|
copied) into the store, and the sidecar is written with the hash and the
|
||||||
|
first identity.
|
||||||
|
|
||||||
|
The byte budget counts only bytes fetched *this run*, so de-dup makes a big
|
||||||
|
save cheaper and never starves it. Local `/fs` files are always **copied** —
|
||||||
|
the original in your music folder stays put — and de-duplicated by hash like
|
||||||
|
anything else.
|
||||||
|
|
||||||
|
## Skipped tracks
|
||||||
|
|
||||||
|
A source that genuinely cannot be captured — a track already marked skipped,
|
||||||
|
a stream that fails to resolve, a missing or unreadable local file — is
|
||||||
|
recorded as a **skipped** toml (`[playable] skipped = true`) rather than
|
||||||
|
silently omitted, so the save's track list matches the source. Skipped tracks
|
||||||
|
render red in the TUI (see [The terminal UI](./clients/tui.md)) and playback
|
||||||
|
skips over them (see [Queue and playback](./queue.md)).
|
||||||
|
|
||||||
|
## Deletion
|
||||||
|
|
||||||
|
Deletion on `/crabidy` goes through directly, **with no confirmation, and
|
||||||
|
never touches the store**:
|
||||||
|
|
||||||
|
- Delete a track → remove its `.cbd-track.toml` only.
|
||||||
|
- Delete a folder → remove that toml folder only.
|
||||||
|
|
||||||
|
A `store` playable resolves under `~/.local/share/…`, which is outside the
|
||||||
|
`/crabidy` toml root at `~/.local/state/…`, so the shared audio is never
|
||||||
|
deleted — another save may still reference it. Because nothing expensive is
|
||||||
|
ever destroyed, there is no delete-confirmation step.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
Nothing reclaims store entries whose last referencing toml was deleted. The
|
||||||
|
store never shrinks on its own; orphaned entries are accepted, and a garbage
|
||||||
|
collector is future work.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The captured marker
|
||||||
|
|
||||||
|
The library marks what you already hold. A captured row is prefixed with a
|
||||||
|
leading `|` — the first character of the row, before the selection padding,
|
||||||
|
e.g. `|Bohemian…`:
|
||||||
|
|
||||||
|
- A **track** is captured when its playable is store-backed, or when its
|
||||||
|
`(provider, id)` is in the store index. Because the check is one index
|
||||||
|
lookup, it works **while browsing any provider** — you can see, in `/tidal`,
|
||||||
|
which tracks you have already captured.
|
||||||
|
- A **node** is captured when all of its tracks are captured and it has no
|
||||||
|
child nodes.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [The library model](./library.md) — nodes, tracks, paths, and links.
|
||||||
|
- [Providers](./providers.md) — how each media source sets its identity.
|
||||||
|
- [Queue and playback](./queue.md) — the `current` mirror and skipped tracks.
|
||||||
|
- [Configuration](./config.md) — where the two roots come from.
|
||||||
Loading…
Reference in New Issue