Compare commits
50 Commits
ef69afdd5f
...
e2c1b44cdb
| Author | SHA1 | Date |
|---|---|---|
|
|
e2c1b44cdb | |
|
|
d630c9e550 | |
|
|
ba4775b0bb | |
|
|
ab3bd7c63a | |
|
|
af04573b72 | |
|
|
cd3a16f95c | |
|
|
a03e3de84e | |
|
|
1b838578e2 | |
|
|
6f3b60254e | |
|
|
2e94760a69 | |
|
|
0a9456e173 | |
|
|
238e7c8f87 | |
|
|
7df5f0a1c2 | |
|
|
06381949ab | |
|
|
c4001df74d | |
|
|
739c5a805a | |
|
|
6abda3aa58 | |
|
|
7091d37c32 | |
|
|
9fba9708bf | |
|
|
f4309aa327 | |
|
|
11b2a1bd38 | |
|
|
4e69261e02 | |
|
|
28a4c155d0 | |
|
|
4c0c5f1401 | |
|
|
33fd3b227c | |
|
|
ef1e56e1f4 | |
|
|
bc3d2e099e | |
|
|
b6f1275d6b | |
|
|
42c5e9dbc2 | |
|
|
659e678522 | |
|
|
0a0c35c531 | |
|
|
3b81faeb9d | |
|
|
a275d3bc77 | |
|
|
5bcddb9027 | |
|
|
89d5fffd48 | |
|
|
d078e65a7d | |
|
|
3a03114cb9 | |
|
|
cd33b790b5 | |
|
|
7eaa8fa9b4 | |
|
|
be2676080d | |
|
|
95ea1e44a0 | |
|
|
6ca8607586 | |
|
|
b50cf862b3 | |
|
|
0a0d50f748 | |
|
|
c4abadc8e4 | |
|
|
e215dd8b87 | |
|
|
56098f7c26 | |
|
|
99419dcdcf | |
|
|
dbd1b955fb | |
|
|
21d4fddb2f |
|
|
@ -2,6 +2,9 @@ target
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
|
||||||
|
# generated CLI assets (gen-cli-assets writes here)
|
||||||
|
/dist
|
||||||
|
|
||||||
# devenv
|
# devenv
|
||||||
.devenv*
|
.devenv*
|
||||||
devenv.local.nix
|
devenv.local.nix
|
||||||
|
|
@ -23,3 +26,9 @@ opencode.json
|
||||||
.opencode/
|
.opencode/
|
||||||
.explained/
|
.explained/
|
||||||
*.kickstart-new
|
*.kickstart-new
|
||||||
|
|
||||||
|
# mdbook build output
|
||||||
|
docs/book/
|
||||||
|
|
||||||
|
# audiobookshelf API key (secret, do not commit)
|
||||||
|
abs-api-key
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "absdy"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"crabidy-core",
|
||||||
|
"reqwest 0.13.1",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|
@ -157,6 +171,12 @@ dependencies = [
|
||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
|
|
@ -368,6 +388,8 @@ dependencies = [
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"rodio",
|
"rodio",
|
||||||
"stream-download",
|
"stream-download",
|
||||||
|
"symphonia",
|
||||||
|
"symphonia-adapter-libopus",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -524,6 +546,20 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake3"
|
||||||
|
version = "1.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"constant_time_eq",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -634,7 +670,9 @@ dependencies = [
|
||||||
name = "cbd"
|
name = "cbd"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cbd-cli",
|
||||||
"cbd-tui",
|
"cbd-tui",
|
||||||
|
"clap",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
@ -644,11 +682,26 @@ dependencies = [
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbd-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
|
"clap_mangen",
|
||||||
|
"crabidy-core",
|
||||||
|
"tokio",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbd-tui"
|
name = "cbd-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
|
"cbd-cli",
|
||||||
|
"clap",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
@ -656,8 +709,10 @@ dependencies = [
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"toml",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
|
|
@ -777,6 +832,15 @@ dependencies = [
|
||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
|
|
@ -795,6 +859,16 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_mangen"
|
||||||
|
version = "0.2.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"roff",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
|
|
@ -934,6 +1008,12 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|
@ -1071,24 +1151,30 @@ dependencies = [
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"absdy",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"audio-player",
|
"audio-player",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
|
"blake3",
|
||||||
|
"cbd-cli",
|
||||||
"clap",
|
"clap",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flume",
|
"flume",
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"fyyd",
|
||||||
"http",
|
"http",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"jamendody",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"realfft",
|
"realfft",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
"soundclouddy",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
|
|
@ -1756,6 +1842,20 @@ dependencies = [
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fyyd"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"crabidy-core",
|
||||||
|
"reqwest 0.13.1",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
|
|
@ -2339,6 +2439,21 @@ version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jamendody"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"crabidy-core",
|
||||||
|
"reqwest 0.13.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
|
|
@ -3119,6 +3234,15 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opusic-sys"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2804e694ef0de3b4cbb254de565053b7cb48d3398df7fd60c6c62bed40c5372a"
|
||||||
|
dependencies = [
|
||||||
|
"cmake",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "or_poisoned"
|
name = "or_poisoned"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -4092,6 +4216,12 @@ dependencies = [
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roff"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rquickjs"
|
name = "rquickjs"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -4658,6 +4788,20 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soundclouddy"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"crabidy-core",
|
||||||
|
"reqwest 0.13.1",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
|
|
@ -4767,6 +4911,17 @@ dependencies = [
|
||||||
"symphonia-metadata",
|
"symphonia-metadata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-adapter-libopus"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bfc8e95f95c23ed1b5328eb66920ad28d9968c797f9c7aa755d4b45a5f47a41"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"opusic-sys",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "symphonia-bundle-flac"
|
name = "symphonia-bundle-flac"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
|
|
|
||||||
29
Cargo.toml
29
Cargo.toml
|
|
@ -1,13 +1,18 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
|
"absdy",
|
||||||
"audio-player",
|
"audio-player",
|
||||||
"cbd",
|
"cbd",
|
||||||
|
"cbd-cli",
|
||||||
"cbd-tui",
|
"cbd-tui",
|
||||||
"cbd-web",
|
"cbd-web",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
"fsdy",
|
"fsdy",
|
||||||
|
"fyyd",
|
||||||
|
"jamendody",
|
||||||
|
"soundclouddy",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
"ytdy",
|
"ytdy",
|
||||||
]
|
]
|
||||||
|
|
@ -21,11 +26,14 @@ anyhow = "1"
|
||||||
argon2 = { version = "0.5", features = ["std"] }
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
blake3 = "1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap-serde-derive = "0.2"
|
clap-serde-derive = "0.2"
|
||||||
|
clap_complete = "4"
|
||||||
|
clap_mangen = "0.2"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
|
@ -61,6 +69,11 @@ rustypipe = { version = "0.11", default-features = false, features = [
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
|
# Opus decoding: symphonia has no Opus decoder, so we demux Ogg-Opus with
|
||||||
|
# symphonia's Ogg reader and decode via the libopus adapter (matches
|
||||||
|
# rodio's symphonia 0.5). The adapter bundles libopus (needs cmake).
|
||||||
|
symphonia = { version = "0.5", default-features = false, features = ["ogg"] }
|
||||||
|
symphonia-adapter-libopus = "0.2"
|
||||||
stream-download = { version = "0.24", default-features = false, features = [
|
stream-download = { version = "0.24", default-features = false, features = [
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"reqwest-rustls",
|
"reqwest-rustls",
|
||||||
|
|
@ -88,11 +101,19 @@ tracing-appender = "0.2"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
# Local crates
|
# Local crates. `default-features = false` on the three feature-carrying
|
||||||
audio-player = { path = "audio-player" }
|
# crates: their dependents select what they want (a member cannot *drop* a
|
||||||
cbd-tui = { path = "cbd-tui" }
|
# workspace-inherited default), which is how a tailored build stays tailored —
|
||||||
|
# see architecture/build-features.md D1.
|
||||||
|
absdy = { path = "absdy" }
|
||||||
|
audio-player = { path = "audio-player", default-features = false }
|
||||||
|
cbd-cli = { path = "cbd-cli" }
|
||||||
|
cbd-tui = { path = "cbd-tui", default-features = false }
|
||||||
crabidy-core = { path = "crabidy-core" }
|
crabidy-core = { path = "crabidy-core" }
|
||||||
crabidy-server = { path = "crabidy-server" }
|
crabidy-server = { path = "crabidy-server", default-features = false }
|
||||||
fsdy = { path = "fsdy" }
|
fsdy = { path = "fsdy" }
|
||||||
|
fyyd = { path = "fyyd" }
|
||||||
|
jamendody = { path = "jamendody" }
|
||||||
|
soundclouddy = { path = "soundclouddy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
ytdy = { path = "ytdy" }
|
ytdy = { path = "ytdy" }
|
||||||
|
|
|
||||||
336
README.md
336
README.md
|
|
@ -7,12 +7,24 @@ each mounted as a subtree of one library:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/
|
/
|
||||||
├── tidal Tidal streaming (see tidaldy/README.md)
|
├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
|
||||||
├── youtube YouTube search & playlists (see ytdy/README.md)
|
│ managed by the server
|
||||||
├── fs a local music folder (see fsdy/README.md)
|
├── abs audiobookshelf audiobooks (absdy/README.md)
|
||||||
├── queues saved play queues (managed by the server)
|
├── fs a local music folder (fsdy/README.md)
|
||||||
├── bookmarks link snapshots of library subtrees (`w`)
|
├── fyyd podcast search (fyyd/README.md)
|
||||||
└── captures downloaded snapshots with local audio (`W`)
|
├── jamendo Creative-Commons music (jamendody/README.md)
|
||||||
|
├── soundcloud SoundCloud (soundclouddy/README.md)
|
||||||
|
├── tidal Tidal streaming (tidaldy/README.md)
|
||||||
|
├── youtube YouTube search & playlists (ytdy/README.md)
|
||||||
|
└── orphans store audio no save references any more — rename, delete,
|
||||||
|
or queue it
|
||||||
|
```
|
||||||
|
|
||||||
|
Full documentation — every provider, how to log in to each, every config
|
||||||
|
option, and the architecture — is the **book in [`docs/`](docs/src/)**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
devenv shell -- docs # serve it locally
|
||||||
```
|
```
|
||||||
|
|
||||||
## Binaries
|
## Binaries
|
||||||
|
|
@ -42,6 +54,25 @@ cargo run -p cbd # server + TUI in one process
|
||||||
Or run the halves separately: `cargo run -p crabidy-server` and, in
|
Or run the halves separately: `cargo run -p crabidy-server` and, in
|
||||||
another terminal, `cargo run -p cbd-tui`.
|
another terminal, `cargo run -p cbd-tui`.
|
||||||
|
|
||||||
|
### Tailored builds
|
||||||
|
|
||||||
|
Every provider — plus Opus decoding, the spectrum bars, the embedded web
|
||||||
|
UI, and the TUI's desktop notifications — sits behind a Cargo feature,
|
||||||
|
all on by default. Drop what you do not need and the dependencies go with
|
||||||
|
it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# a local-files appliance: no network providers, no web UI, no FFT
|
||||||
|
cargo build --release -p crabidy-server --no-default-features --features fs,opus
|
||||||
|
```
|
||||||
|
|
||||||
|
`crabidy-server features` prints what a binary was built with. See
|
||||||
|
[docs/src/build-features.md](docs/src/build-features.md) for the full table,
|
||||||
|
what `fs` takes with it, and more examples.
|
||||||
|
|
||||||
|
**Note:** `--no-default-features` on its own drops **every** provider (it
|
||||||
|
compiles and runs, but plays nothing). Always name what you want.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration lives in `~/.config/crabidy/` (the platform config
|
All configuration lives in `~/.config/crabidy/` (the platform config
|
||||||
|
|
@ -49,19 +80,35 @@ directory). Every file is optional; missing providers simply do not
|
||||||
mount. Files are created/rewritten on first start with their defaults
|
mount. Files are created/rewritten on first start with their defaults
|
||||||
filled in.
|
filled in.
|
||||||
|
|
||||||
| File | Component | Documentation |
|
| File | Configures | Login |
|
||||||
| -------------------- | ---------- | -------------------------------------- |
|
| --------------------- | ---------------- | ------------------ |
|
||||||
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
| `abs.toml` | audiobookshelf | URL + API key |
|
||||||
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
| `fsdy.toml` | local files | — |
|
||||||
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
|
| `fyyd.toml` | podcasts | none needed |
|
||||||
| `cbd-tui.toml` | `cbd-tui` | below |
|
| `jamendo.toml` | Jamendo | none (key shipped) |
|
||||||
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
|
| `soundcloud.toml` | SoundCloud | optional token |
|
||||||
| `crabidy-server.toml`| server | below (never auto-created) |
|
| `tidaly.toml` | Tidal | device login |
|
||||||
|
| `ytdy.toml` | YouTube | optional cookies |
|
||||||
|
| `cbd-tui.toml` | `cbd-tui` | server role + pw |
|
||||||
|
| `cbd.toml` | `cbd` | server role + pw |
|
||||||
|
| `crabidy-server.toml` | the server | its `[auth]` hashes|
|
||||||
|
|
||||||
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
|
Each provider's README — linked from the library tree at the top — explains
|
||||||
live in `~/.config/crabidy/`; they need no configuration and hold plain
|
**how to log in** and documents every option in its file. The same material
|
||||||
folders of track files in the format documented in
|
is in the book under [`docs/src/providers/`](docs/src/providers/); the client
|
||||||
[fsdy/README.md](fsdy/README.md).
|
and server files are covered below.
|
||||||
|
|
||||||
|
Provider credentials and the client password are stored in **cleartext** —
|
||||||
|
these are config files, not a keyring. Keep `~/.config/crabidy/` private.
|
||||||
|
Crabidy redacts secrets from logs, errors, and config dumps.
|
||||||
|
|
||||||
|
The server-managed `crabidy` provider does not live under `~/.config`. Its
|
||||||
|
track-file tree (saved queues, bookmarks, and captures) lives in
|
||||||
|
`~/.local/state/crabidy/` (the platform *state* directory) and the audio it
|
||||||
|
captures lives in a single content-addressed store under
|
||||||
|
`~/.local/share/crabidy/` (the *data* directory), shared and de-duplicated
|
||||||
|
across saves. Neither needs configuration; see
|
||||||
|
[docs/src/store.md](docs/src/store.md).
|
||||||
|
|
||||||
### `cbd-tui.toml` and `cbd.toml`
|
### `cbd-tui.toml` and `cbd.toml`
|
||||||
|
|
||||||
|
|
@ -74,6 +121,7 @@ server (e.g. a Raspberry Pi). A shared file would force one to follow
|
||||||
the other's `address`.
|
the other's `address`.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
[server]
|
||||||
# Where to find the server. Default (both files): localhost, which is
|
# 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
|
# what cbd's own in-process server listens on. Point cbd-tui.toml at a
|
||||||
# remote server to use it as a remote control.
|
# remote server to use it as a remote control.
|
||||||
|
|
@ -89,14 +137,46 @@ password = ""
|
||||||
spectrum = true
|
spectrum = true
|
||||||
```
|
```
|
||||||
|
|
||||||
Every option is also available as a command-line flag
|
Every option is also available as a command-line flag before the
|
||||||
(`cbd-tui --address ...`, `cbd --address ...`).
|
subcommand (`cbd-tui --address ... --user owner`, `cbd --spectrum
|
||||||
|
false`); a flag overrides the file value. To write the credentials into
|
||||||
|
the config once, use the `auth` subcommand (see below) instead of
|
||||||
|
editing the file by hand:
|
||||||
|
|
||||||
### `crabidy-server.toml` — roles and rights
|
```sh
|
||||||
|
cbd-tui auth owner 'my-password' # sets user + password
|
||||||
|
cbd-tui auth queue-owner 'pw' --address http://pi:50051
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crabidy-server.toml` — providers and rights
|
||||||
|
|
||||||
|
On first start the server writes this file with every provider enabled:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
providers = [
|
||||||
|
"tidal", "youtube", "fyyd", "abs", "soundcloud", "jamendo",
|
||||||
|
"fs", "crabidy", "orphans",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove a name to disable that provider** — it no longer mounts and does
|
||||||
|
not appear in the library. Deleting the whole `providers` line re-enables
|
||||||
|
everything (a fresh install with no file behaves the same). Disabling
|
||||||
|
`crabidy` also drops `orphans`, which is a view over the store. A disabled
|
||||||
|
provider's own config file (`tidaly.toml`, etc.) is simply left unread.
|
||||||
|
|
||||||
|
The list can only offer what the binary was **built** with (see [Tailored
|
||||||
|
builds](#tailored-builds)): the default list names only the providers this
|
||||||
|
build has, and naming one it lacks logs a warning at startup instead of
|
||||||
|
failing.
|
||||||
|
|
||||||
|
#### Roles and rights
|
||||||
|
|
||||||
By default the server is open: everyone who can reach the port has
|
By default the server is open: everyone who can reach the port has
|
||||||
full control. Adding an `[auth]` section turns on HTTP basic auth for
|
full control. Setting password hashes in `[auth]` locks the server
|
||||||
every RPC and hands out *roles* (see `architecture/roles-auth.md`):
|
**from the top down** — each password you set lowers what a
|
||||||
|
no-credential caller may do, while a matching password elevates a
|
||||||
|
caller to that role:
|
||||||
|
|
||||||
- **owner** — everything (the normal user).
|
- **owner** — everything (the normal user).
|
||||||
- **queue-owner** — anything on the queue and playback, but no
|
- **queue-owner** — anything on the queue and playback, but no
|
||||||
|
|
@ -105,21 +185,125 @@ every RPC and hands out *roles* (see `architecture/roles-auth.md`):
|
||||||
- **queue-appender** — may browse/search and append tracks to the
|
- **queue-appender** — may browse/search and append tracks to the
|
||||||
queue; nothing else.
|
queue; nothing else.
|
||||||
|
|
||||||
|
A caller with no credentials gets the highest role you left *unguarded*:
|
||||||
|
nothing guarded → owner (the open default); guard `owner` → anonymous is
|
||||||
|
queue-owner; guard `owner` + `queue_owner` → anonymous is queue-appender;
|
||||||
|
guard all three → credentials required for everything.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[auth]
|
[auth]
|
||||||
# One PHC hash per role; omit a role to disable it. Generate with:
|
# One PHC hash per role. Guard from the top down. Generate and store a
|
||||||
# crabidy-server hash-password (reads the password from stdin)
|
# hash with: crabidy-server guard <role> (see the CLI section).
|
||||||
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
queue_owner = "$argon2id$..."
|
queue_owner = "$argon2id$..."
|
||||||
queue_appender = "$argon2id$..."
|
queue_appender = "$argon2id$..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Clients authenticate with the role name as the basic-auth user (see
|
Clients authenticate with the role name as the basic-auth user (see
|
||||||
`cbd-tui.toml` above). A malformed `crabidy-server.toml` aborts server
|
`cbd-tui.toml` above). A malformed `crabidy-server.toml` — or one that
|
||||||
|
guards a lower role while a higher one is still open — aborts server
|
||||||
startup rather than silently running open. Note that the transport is
|
startup rather than silently running open. Note that the transport is
|
||||||
plain HTTP/2: fine on a trusted home network, but anything exposed
|
plain HTTP/2: fine on a trusted home network, but anything exposed
|
||||||
further needs TLS termination (reverse proxy, VPN) in front.
|
further needs TLS termination (reverse proxy, VPN) in front.
|
||||||
|
|
||||||
|
#### Audio output device
|
||||||
|
|
||||||
|
By default the server plays to the system default output device. On a
|
||||||
|
Raspberry Pi that is often HDMI, so playback runs but you hear nothing on
|
||||||
|
the headphone jack or a USB/DAC. List the devices the server can see:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ crabidy-server audio-devices
|
||||||
|
Audio output devices (* = selected by the current config):
|
||||||
|
hdmi:CARD=vc4hdmi,DEV=0
|
||||||
|
sysdefault:CARD=Headphones
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pin one by passing it to the same command — the value is matched
|
||||||
|
case-insensitively as a substring of the name, so a memorable fragment is
|
||||||
|
enough — and restart the server:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ crabidy-server audio-devices Headphones
|
||||||
|
Set [audio] device = "Headphones" in .../crabidy-server.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
That writes `[audio] device` for you; you can also edit it by hand:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[audio]
|
||||||
|
device = "Headphones"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the name matches nothing, both the command and the server warn, and the
|
||||||
|
server falls back to the system default.
|
||||||
|
|
||||||
|
## Command line
|
||||||
|
|
||||||
|
Every binary is a clap CLI: run it 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, `cbd` runs the in-process server + TUI.
|
||||||
|
|
||||||
|
The `library`, `queue`, and `global` subcommands are available on all
|
||||||
|
three binaries and act as a remote control over gRPC (they connect to a
|
||||||
|
running server, honouring the same `[auth]` credentials as the TUI):
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Connection flags (`--address/--user/--password`) go **before** the
|
||||||
|
subcommand; omitted, they fall back to the client config file.
|
||||||
|
|
||||||
|
Server-only subcommands (`crabidy-server`, and `cbd`):
|
||||||
|
|
||||||
|
- `guard <role> [password]` — hash a role password (argon2id), print
|
||||||
|
the PHC string, and (unless `--no-config`) write it into
|
||||||
|
`crabidy-server.toml`'s `[auth]`. Roles: `owner`, `queue-owner`,
|
||||||
|
`queue-appender`.
|
||||||
|
- `scan <path> [--capture|--move]` — walk a folder and drop a
|
||||||
|
`.cbd-track.toml` beside every audio file so it browses under `/fs`.
|
||||||
|
`--capture` copies each file into the content store (the toml points
|
||||||
|
there); `--move` moves it instead of copying.
|
||||||
|
- `audio-devices [device]` — list the audio output devices, or pin one into
|
||||||
|
`[audio] device` (see above).
|
||||||
|
- `features` — print the build features this binary has (see [Tailored
|
||||||
|
builds](#tailored-builds)).
|
||||||
|
|
||||||
|
Client-only subcommand (`cbd-tui`, and `cbd`):
|
||||||
|
|
||||||
|
- `auth <role> [password] [--address ADDR]` — write the role name and
|
||||||
|
cleartext password (and address) into the client config.
|
||||||
|
|
||||||
|
**Password caveat.** A password given as a command-line argument is
|
||||||
|
visible in the process list (e.g. `ps`). Omit it and `guard` reads the
|
||||||
|
password from stdin instead, which keeps it out of argv and is
|
||||||
|
pipe-friendly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '%s' 'my-password' | crabidy-server guard owner
|
||||||
|
```
|
||||||
|
|
||||||
|
The client config stores the password in plaintext, so keep the 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
|
||||||
|
```
|
||||||
|
|
||||||
|
`gen-cli-assets` builds the binaries with `CBD_ASSET_DIR=$PWD/dist`, so
|
||||||
|
`dist/completions/**` (bash/zsh/fish) and `dist/man/*.1` are produced
|
||||||
|
for all three binaries. Every ordinary build also emits them into the
|
||||||
|
crate's `OUT_DIR`.
|
||||||
|
|
||||||
## Using the library
|
## Using the library
|
||||||
|
|
||||||
Navigation is vim-style: `j`/`k` select, `l` enters the selected
|
Navigation is vim-style: `j`/`k` select, `l` enters the selected
|
||||||
|
|
@ -129,29 +313,41 @@ node where the pane title shows `% to add` (e.g. a search term), `e`
|
||||||
renames, `d` deletes. `/` filters the current pane (library or queue)
|
renames, `d` deletes. `/` filters the current pane (library or queue)
|
||||||
live as you type — `Enter` keeps the filter, `Esc` clears it.
|
live as you type — `Enter` keeps the filter, `Esc` clears it.
|
||||||
|
|
||||||
- `w` saves the selection as a **bookmark** (links; needs the source
|
`s` marks the selected row; `v` (or `V`) enters **visual mode**, where
|
||||||
provider to replay) or, in the queue pane, saves the queue.
|
movement marks or unmarks everything you sweep over, vim-style. The sweep
|
||||||
- `W` **captures** the selection: the subtree is mirrored under
|
is anchored where you entered it, so moving back reverses it. `Esc` (or
|
||||||
`/captures/<name>` with every track's audio downloaded next to its
|
any non-movement key) leaves visual mode.
|
||||||
metadata — fully local playback afterwards. In the queue pane `W`
|
|
||||||
captures the current queue directly (no need to save it first).
|
- `w` saves the selection (a library subtree, or in the queue pane the
|
||||||
Tracks that are already local (from `/fs` or another capture) are
|
queue) as a new folder under `/crabidy/<name>` of **link** files —
|
||||||
copied in rather than re-downloaded, so a queue mixing streamed and
|
needs the source provider to replay. On a name that already exists the
|
||||||
local tracks captures completely. Captures are incremental:
|
save is refused with a warning; delete the old folder and save again.
|
||||||
re-capturing the same name resumes and completes it; tracks whose
|
- `W` **captures** the selection into `/crabidy/<name>`: same as `w`, but
|
||||||
source genuinely cannot be captured are recorded as *skipped* (red in
|
every track's audio is fetched into the shared content store under
|
||||||
the UI, skipped by playback). Download captures can take long;
|
`~/.local/share/crabidy/` and the saved tomls link to it — fully local
|
||||||
progress is shown in the library pane. Inside `/captures`, `d` deletes
|
playback afterwards. Works on a library subtree and on the queue (no
|
||||||
any
|
need to save it first). Audio is **de-duplicated**: capturing the same
|
||||||
folder or single track *from disk* (audio included) after a `y/N`
|
track again (from a playlist, a search, another save) reuses the stored
|
||||||
confirmation.
|
file instead of downloading it twice, matched first by provider id and
|
||||||
|
then by content hash. Tracks already local (from `/fs`) are copied into
|
||||||
|
the store rather than re-downloaded. A source that genuinely cannot be
|
||||||
|
captured is recorded as *skipped* (red in the UI, skipped by playback).
|
||||||
|
Download captures can take long; progress is shown in the library pane.
|
||||||
|
- Captured rows are marked with a trailing `↓` (down-arrow) at the end of
|
||||||
|
the row — visible even while browsing another provider, so you can see
|
||||||
|
what you already have.
|
||||||
|
- Inside `/crabidy`, `d` deletes a folder or track immediately (no
|
||||||
|
confirmation): it removes only the metadata toml, never the shared store
|
||||||
|
audio, which other saves may reference.
|
||||||
|
|
||||||
Press `?` for the full binding table.
|
Press `?` for the full binding table.
|
||||||
|
|
||||||
A row of frequency-spectrum bars is drawn under the track progress
|
A row of frequency-spectrum bars is drawn under the track progress
|
||||||
while audio plays (the server taps its own output, runs the FFT, and
|
while audio plays (the server taps its own output, runs the FFT, and
|
||||||
streams the bars, so it works whether the server is local or remote).
|
streams the bars, so it works whether the server is local or remote).
|
||||||
Turn it off with `spectrum = false` in the client config.
|
Toggle it at runtime with `f`, or set the startup default with
|
||||||
|
`spectrum = false` in the client config. Servers built without the
|
||||||
|
`spectrum` feature simply never send bars.
|
||||||
|
|
||||||
## Web client
|
## Web client
|
||||||
|
|
||||||
|
|
@ -173,9 +369,45 @@ devenv shell -- build-web # writes cbd-web/dist
|
||||||
cargo build -p crabidy-server # embeds it
|
cargo build -p crabidy-server # embeds it
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the server with `--no-default-features` for a headless,
|
For a headless, gRPC-only binary, build with every feature *except*
|
||||||
gRPC-only binary. See [cbd-web/README.md](cbd-web/README.md) for the
|
`web-ui` — e.g. `--no-default-features --features
|
||||||
dev loop and details.
|
all-providers,opus,spectrum`. See [cbd-web/README.md](cbd-web/README.md) for
|
||||||
|
the dev loop and details.
|
||||||
|
|
||||||
|
## Nix packages and cross-compiling
|
||||||
|
|
||||||
|
`flake.nix` (built with [crane](https://github.com/ipetkov/crane)) packages
|
||||||
|
the binaries as Nix derivations — for your own machines, and cross-compiled
|
||||||
|
for a Raspberry Pi. No Docker required.
|
||||||
|
|
||||||
|
Native — install on any machine with Nix:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#cbd-tui # run without installing
|
||||||
|
nix build .#crabidy # cbd, cbd-tui, crabidy-server → ./result/bin
|
||||||
|
nix profile install .#crabidy # or github:OWNER/crabidy once pushed
|
||||||
|
```
|
||||||
|
|
||||||
|
Raspberry Pi (or any aarch64 Linux) — a fully **static musl** binary, so it
|
||||||
|
has no glibc-version or loader dependency and runs on stock Raspberry Pi OS
|
||||||
|
(bookworm and newer):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#crabidy-server-aarch64
|
||||||
|
scp ./result/bin/crabidy-server pi:/usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nix cross-compiles the Rust *and* the C dependencies (ALSA, aws-lc)
|
||||||
|
hermetically on an x86_64 host — the isolated build avoids the host-linker
|
||||||
|
pitfalls of cross-compiling in a plain shell. The aarch64 server **includes
|
||||||
|
the embedded web UI**: the flake builds the `cbd-web` wasm bundle with trunk
|
||||||
|
(pinning a `wasm-bindgen` CLI that matches the crate) and stages it into the
|
||||||
|
server's `web-ui` feature. The native `.#crabidy` package stays headless; use
|
||||||
|
a normal `cargo build` there if you want the bundle.
|
||||||
|
|
||||||
|
A container-based `cross` setup also exists (`Cross.toml` + the
|
||||||
|
`*-Dockerfile`s) for building against Debian's glibc, but the flake is the
|
||||||
|
recommended path.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
|
|
@ -185,6 +417,12 @@ redacted from logs by design.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Design documents live in `architecture/`, per-feature quality gates in
|
```sh
|
||||||
`quality/`, and implementation plans in `plan/`. See `CLAUDE.md` /
|
devenv shell -- bash -lc 'cargo test --workspace --exclude cbd-web'
|
||||||
`AGENTS.md` for the development workflow and coding rules.
|
devenv shell -- check-features # the build-feature matrix
|
||||||
|
devenv shell -- docs # serve the documentation book
|
||||||
|
```
|
||||||
|
|
||||||
|
`AGENTS.md` (and `CLAUDE.md`) carry the coding rules and the toolchain
|
||||||
|
conventions. The reference documentation for the system itself lives in
|
||||||
|
[`docs/`](docs/src/).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge
|
FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge
|
||||||
RUN dpkg --add-architecture arm64
|
RUN dpkg --add-architecture arm64
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt-get update && apt-get install -y alsa:arm64 librust-alsa-sys-dev:arm64 libasound2-dev:arm64 portaudio19-dev:arm64 build-essential libpulse-dev:arm64 libdbus-1-dev:arm64 pkg-config apt-utils unzip
|
RUN apt-get update && apt-get install -y alsa:arm64 librust-alsa-sys-dev:arm64 libasound2-dev:arm64 portaudio19-dev:arm64 build-essential cmake libpulse-dev:arm64 libdbus-1-dev:arm64 pkg-config apt-utils unzip
|
||||||
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip
|
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "absdy"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait.workspace = true
|
||||||
|
crabidy-core.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["time"] }
|
||||||
|
toml.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# absdy — audiobookshelf provider
|
||||||
|
|
||||||
|
Mounts a self-hosted [audiobookshelf](https://www.audiobookshelf.org/) (ABS)
|
||||||
|
server at `/abs`, so you can browse, search, and play your audiobooks from
|
||||||
|
crabidy. Audiobooks only for now — podcast libraries on the ABS server are
|
||||||
|
not listed.
|
||||||
|
|
||||||
|
## Logging in
|
||||||
|
|
||||||
|
The provider needs your server URL and an **API key**; without both, `/abs`
|
||||||
|
does not mount (the rest of the server is unaffected).
|
||||||
|
|
||||||
|
1. Open the audiobookshelf web UI as the user whose libraries you want.
|
||||||
|
2. **Settings → Users → (your user) → API Keys** (older versions:
|
||||||
|
**Settings → API Keys**) and create a key.
|
||||||
|
3. Put it in `abs.toml` (below) and restart the server.
|
||||||
|
|
||||||
|
There is no interactive login and no token to refresh — the API key is a
|
||||||
|
long-lived bearer token. Revoke it in the same screen to cut access.
|
||||||
|
|
||||||
|
## Configuration — `~/.config/crabidy/abs.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Required.
|
||||||
|
base_url = "https://audiobookshelf.example.com"
|
||||||
|
api_key = "<your audiobookshelf API key>"
|
||||||
|
|
||||||
|
# Optional (defaults shown).
|
||||||
|
items_per_library = 200 # books listed per library
|
||||||
|
search_results = 50 # books listed per search term
|
||||||
|
call_timeout_secs = 30 # per-request timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is a secret: it is redacted from logs and never printed, but keep
|
||||||
|
`abs.toml` private anyway (it is stored in cleartext).
|
||||||
|
|
||||||
|
## The library tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
/abs
|
||||||
|
└── <library> one node per "book" library
|
||||||
|
├── search create a search term with `%`
|
||||||
|
│ └── <term> books matching the term
|
||||||
|
│ └── <book> the book's audio files as tracks
|
||||||
|
└── <book> an audiobook; its files are tracks
|
||||||
|
└── <file> a track (one audio file)
|
||||||
|
```
|
||||||
|
|
||||||
|
- A **library** lists its books plus a creatable `search` node. Search is
|
||||||
|
per-library — a term created under one library does not appear under
|
||||||
|
another.
|
||||||
|
- A **book** lists its audio files as tracks; queue or capture (`W`) the whole
|
||||||
|
book, or a single file. An ebook-only item (no audio) is shown but is not
|
||||||
|
queueable.
|
||||||
|
- Podcast libraries are not shown (audiobooks only, for now).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Playback streams each file directly from ABS with a per-file token URL and
|
||||||
|
HTTP range requests — no transcoding session.
|
||||||
|
- Listings are capped (see the config) and fetched fresh; only your typed
|
||||||
|
search terms are remembered, in memory, until the server restarts.
|
||||||
|
- Many audiobookshelf libraries store Opus audio, which needs the server's
|
||||||
|
`opus` cargo feature (on by default).
|
||||||
|
- Build the server without the `abs` cargo feature to leave this provider out
|
||||||
|
of the binary entirely.
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
//! The audiobookshelf HTTP seam.
|
||||||
|
//!
|
||||||
|
//! All network access to an audiobookshelf server goes through the [`Abs`]
|
||||||
|
//! trait so the provider's tree/path logic is unit-tested with a fake and no
|
||||||
|
//! network (architecture/audiobookshelf-provider.md D2). [`AbsApi`] is the
|
||||||
|
//! production `reqwest` implementation; tests supply their own [`Abs`].
|
||||||
|
//!
|
||||||
|
//! Every browse call carries `Authorization: Bearer <token>`. The playable
|
||||||
|
//! stream URL instead embeds the token as a `?token=` query parameter — that
|
||||||
|
//! is what the audio player fetches directly — and is built by
|
||||||
|
//! [`Abs::stream_url`] so the **secret never leaves this module**: it is
|
||||||
|
//! never logged, and it never appears in a `reqwest` error (browse URLs carry
|
||||||
|
//! no token, and the stream URL is built, not requested, here).
|
||||||
|
|
||||||
|
use std::fmt::{self, Debug};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// A typed audiobookshelf request failure. Carries only non-secret context
|
||||||
|
/// (paths, ids, decode messages) — never the bearer token or a token URL.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FetchError {
|
||||||
|
/// The HTTP call failed (transport, timeout, or non-success status).
|
||||||
|
#[error("audiobookshelf request failed: {0}")]
|
||||||
|
Http(String),
|
||||||
|
/// The response body did not decode into the expected shape.
|
||||||
|
#[error("audiobookshelf returned malformed data: {0}")]
|
||||||
|
Decode(String),
|
||||||
|
/// The resource does not exist (HTTP 404).
|
||||||
|
#[error("audiobookshelf resource not found")]
|
||||||
|
NotFound,
|
||||||
|
/// Authentication was rejected (HTTP 401/403) — a bad or expired key.
|
||||||
|
#[error("audiobookshelf authentication failed")]
|
||||||
|
Unauthorized,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library on the server. `is_book` is `true` for `mediaType == "book"` —
|
||||||
|
/// the only kind this provider serves (podcast libraries are out of scope,
|
||||||
|
/// architecture/audiobookshelf-provider.md D6).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Library {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_book: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A book item as listed in a library or a search result. `num_audio_files`
|
||||||
|
/// drives queueability without opening the item: an ebook-only item reports
|
||||||
|
/// `0` and is shown but not queueable.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Book {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub author: String,
|
||||||
|
pub num_audio_files: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One audio file of a book — a playable track. `ino` is the server's stable
|
||||||
|
/// file id (an integer string, already URL-safe, used as a path segment and
|
||||||
|
/// in the stream URL). `duration` is in seconds.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AudioTrack {
|
||||||
|
pub ino: String,
|
||||||
|
pub title: String,
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A book's detail: its metadata plus its audio files in playback order.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BookDetail {
|
||||||
|
pub title: String,
|
||||||
|
pub author: String,
|
||||||
|
pub tracks: Vec<AudioTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audiobookshelf operations the provider needs. Behind `Box<dyn Abs>` so
|
||||||
|
/// tests fake it (architecture/audiobookshelf-provider.md D2).
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Abs: Debug + Send + Sync {
|
||||||
|
/// All libraries on the server.
|
||||||
|
async fn libraries(&self) -> Result<Vec<Library>, FetchError>;
|
||||||
|
|
||||||
|
/// A library's book items (at most `limit`), title-sorted.
|
||||||
|
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError>;
|
||||||
|
|
||||||
|
/// Books in a library matching a free-text term (at most `limit`).
|
||||||
|
async fn search_items(
|
||||||
|
&self,
|
||||||
|
library_id: &str,
|
||||||
|
term: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Book>, FetchError>;
|
||||||
|
|
||||||
|
/// One book's detail, including its audio files as ordered tracks.
|
||||||
|
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError>;
|
||||||
|
|
||||||
|
/// The token-authenticated stream URL for a file. Pure string building
|
||||||
|
/// (no I/O), but on the seam because only the backend holds the base URL
|
||||||
|
/// and the secret token. The returned URL contains the token and must
|
||||||
|
/// never be logged.
|
||||||
|
fn stream_url(&self, item_id: &str, ino: &str) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production `reqwest` client for an audiobookshelf server. `Debug` redacts
|
||||||
|
/// the token (hard rule: redact secrets from logs and error reports).
|
||||||
|
pub struct AbsApi {
|
||||||
|
http: reqwest::Client,
|
||||||
|
/// Base URL without a trailing slash (e.g. `https://host`).
|
||||||
|
base_url: String,
|
||||||
|
/// The API key / bearer token. Secret — redacted from `Debug`, never
|
||||||
|
/// logged.
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for AbsApi {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("AbsApi")
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.field("token", &"<redacted>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AbsApi {
|
||||||
|
/// Builds the client with a per-call `timeout` (hard rule: timeouts on
|
||||||
|
/// external calls). `base_url` is the server root (any trailing slash is
|
||||||
|
/// trimmed); `token` is the API key.
|
||||||
|
pub fn new(base_url: String, token: String, timeout: Duration) -> Result<Self, FetchError> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(timeout)
|
||||||
|
.user_agent(concat!("crabidy-absdy/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.map_err(|err| FetchError::Http(err.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GETs `path` with `query` under bearer auth and decodes the JSON body.
|
||||||
|
/// The logged URL carries no token (auth is a header); only
|
||||||
|
/// [`Abs::stream_url`] embeds the secret.
|
||||||
|
async fn get<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
) -> Result<T, FetchError> {
|
||||||
|
let url = format!("{}{}", self.base_url, path);
|
||||||
|
debug!(url, "abs GET");
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.query(query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| FetchError::Http(err.to_string()))?;
|
||||||
|
match resp.status() {
|
||||||
|
reqwest::StatusCode::NOT_FOUND => return Err(FetchError::NotFound),
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
|
||||||
|
return Err(FetchError::Unauthorized)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let resp = resp
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|err| FetchError::Http(err.to_string()))?;
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|err| FetchError::Decode(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Abs for AbsApi {
|
||||||
|
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
|
||||||
|
let dto: LibrariesDto = self.get("/api/libraries", &[]).await?;
|
||||||
|
Ok(dto
|
||||||
|
.libraries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(LibraryDto::into_library)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError> {
|
||||||
|
let count = limit.to_string();
|
||||||
|
let dto: ItemsDto = self
|
||||||
|
.get(
|
||||||
|
&format!("/api/libraries/{library_id}/items"),
|
||||||
|
&[("limit", &count), ("sort", "media.metadata.title")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(dto
|
||||||
|
.results
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(ItemDto::into_book)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_items(
|
||||||
|
&self,
|
||||||
|
library_id: &str,
|
||||||
|
term: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Book>, FetchError> {
|
||||||
|
let count = limit.to_string();
|
||||||
|
let dto: SearchDto = self
|
||||||
|
.get(
|
||||||
|
&format!("/api/libraries/{library_id}/search"),
|
||||||
|
&[("q", term), ("limit", &count)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(dto
|
||||||
|
.book
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|hit| hit.library_item.into_book())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError> {
|
||||||
|
let dto: ItemDto = self
|
||||||
|
.get(&format!("/api/items/{item_id}"), &[("expanded", "1")])
|
||||||
|
.await?;
|
||||||
|
Ok(dto.into_detail())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_url(&self, item_id: &str, ino: &str) -> String {
|
||||||
|
// The token is appended last and never logged (D4). ABS serves the
|
||||||
|
// raw file here with HTTP range support, so the audio player streams
|
||||||
|
// it directly.
|
||||||
|
format!(
|
||||||
|
"{}/api/items/{item_id}/file/{ino}?token={}",
|
||||||
|
self.base_url, self.token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire DTOs: decode defensively, missing fields degrade, never panic. ---
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct LibrariesDto {
|
||||||
|
#[serde(default)]
|
||||||
|
libraries: Vec<LibraryDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct LibraryDto {
|
||||||
|
#[serde(default)]
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default, rename = "mediaType")]
|
||||||
|
media_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryDto {
|
||||||
|
/// Drops entries without an id.
|
||||||
|
fn into_library(self) -> Option<Library> {
|
||||||
|
(!self.id.is_empty()).then(|| Library {
|
||||||
|
is_book: self.media_type == "book",
|
||||||
|
id: self.id,
|
||||||
|
name: self.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ItemsDto {
|
||||||
|
#[serde(default)]
|
||||||
|
results: Vec<ItemDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ItemDto {
|
||||||
|
#[serde(default)]
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
media: MediaDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct MediaDto {
|
||||||
|
#[serde(default)]
|
||||||
|
metadata: MetadataDto,
|
||||||
|
#[serde(default, rename = "numAudioFiles")]
|
||||||
|
num_audio_files: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
tracks: Vec<TrackDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct MetadataDto {
|
||||||
|
#[serde(default)]
|
||||||
|
title: String,
|
||||||
|
#[serde(default, rename = "authorName")]
|
||||||
|
author_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct TrackDto {
|
||||||
|
#[serde(default)]
|
||||||
|
ino: String,
|
||||||
|
#[serde(default)]
|
||||||
|
title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemDto {
|
||||||
|
/// A listed book, dropping entries without an id. `num_audio_files`
|
||||||
|
/// clamps negatives to 0.
|
||||||
|
fn into_book(self) -> Option<Book> {
|
||||||
|
(!self.id.is_empty()).then(|| Book {
|
||||||
|
title: self.media.metadata.title,
|
||||||
|
author: self.media.metadata.author_name,
|
||||||
|
num_audio_files: u32::try_from(self.media.num_audio_files).unwrap_or(0),
|
||||||
|
id: self.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The book's detail with its audio files as ordered tracks (files
|
||||||
|
/// without an ino are dropped — they cannot be addressed or streamed).
|
||||||
|
fn into_detail(self) -> BookDetail {
|
||||||
|
BookDetail {
|
||||||
|
title: self.media.metadata.title,
|
||||||
|
author: self.media.metadata.author_name,
|
||||||
|
tracks: self
|
||||||
|
.media
|
||||||
|
.tracks
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(TrackDto::into_track)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackDto {
|
||||||
|
/// A domain track, dropping files without an ino. A non-positive
|
||||||
|
/// duration degrades to `None`.
|
||||||
|
fn into_track(self) -> Option<AudioTrack> {
|
||||||
|
(!self.ino.is_empty()).then(|| AudioTrack {
|
||||||
|
ino: self.ino,
|
||||||
|
title: self.title,
|
||||||
|
duration: (self.duration > 0.0).then_some(self.duration.round() as u32),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct SearchDto {
|
||||||
|
#[serde(default)]
|
||||||
|
book: Vec<SearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SearchHit {
|
||||||
|
#[serde(rename = "libraryItem")]
|
||||||
|
library_item: ItemDto,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,651 @@
|
||||||
|
//! audiobookshelf provider: **browse, search, and play** the audiobooks on a
|
||||||
|
//! self-hosted [audiobookshelf](https://www.audiobookshelf.org/) server.
|
||||||
|
//! Mounted at [`PROVIDER_ROOT`].
|
||||||
|
//!
|
||||||
|
//! Shaped on the fyyd provider. The tree is `library → book → tracks`, where a
|
||||||
|
//! book's tracks are its audio files, plus a per-library `search` subtree with
|
||||||
|
//! creatable/renamable/deletable search-term nodes like tidal/youtube/fyyd. A
|
||||||
|
//! track is one audio file whose `?token=` stream URL the audio player fetches
|
||||||
|
//! directly; every node that serves tracks is downloadable, so `W` captures
|
||||||
|
//! work out of the box.
|
||||||
|
//!
|
||||||
|
//! audiobookshelf is the user's private server, so — unlike fyyd — a usable
|
||||||
|
//! `abs.toml` must carry a `base_url` and an `api_key`; a missing or
|
||||||
|
//! incomplete config disables the provider non-fatally. The `api_key` and the
|
||||||
|
//! token-bearing stream URL are secrets, redacted from `Debug` and never
|
||||||
|
//! logged.
|
||||||
|
//!
|
||||||
|
//! See architecture/audiobookshelf-provider.md for the tree shape and
|
||||||
|
//! decisions.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crabidy_core::proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track};
|
||||||
|
use crabidy_core::{ProviderClient, ProviderError};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
use api::{Abs, AbsApi, Book, BookDetail};
|
||||||
|
|
||||||
|
/// First path segment owned by this provider.
|
||||||
|
pub const PROVIDER_ROOT: &str = "/abs";
|
||||||
|
|
||||||
|
/// Reserved second-level segment separating the search subtree from item ids
|
||||||
|
/// (item ids are UUIDs, so they never collide with this literal).
|
||||||
|
const SEARCH_SEGMENT: &str = "search";
|
||||||
|
|
||||||
|
/// Default (and cap on) items listed under a library — a huge library must
|
||||||
|
/// not stall the tree or queue resolution.
|
||||||
|
pub const DEFAULT_ITEMS_PER_LIBRARY: usize = 200;
|
||||||
|
|
||||||
|
/// Default number of books listed per search term.
|
||||||
|
pub const DEFAULT_SEARCH_RESULTS: usize = 50;
|
||||||
|
|
||||||
|
/// Default per-request timeout in seconds.
|
||||||
|
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Provider settings, persisted as `abs.toml`. `base_url` and `api_key` are
|
||||||
|
/// required for the provider to run; the rest have defaults.
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// The audiobookshelf server root, e.g. `https://abs.example.com`.
|
||||||
|
/// Required — without it the provider is disabled.
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
/// The API key (bearer token). Required — without it the provider is
|
||||||
|
/// disabled. **Secret**: redacted from `Debug`, never logged.
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Items listed per library. Default [`DEFAULT_ITEMS_PER_LIBRARY`].
|
||||||
|
pub items_per_library: Option<usize>,
|
||||||
|
/// Books listed per search term. Default [`DEFAULT_SEARCH_RESULTS`].
|
||||||
|
pub search_results: Option<usize>,
|
||||||
|
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
|
||||||
|
pub call_timeout_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Settings {
|
||||||
|
/// Redacts `api_key` (hard rule: secrets never reach logs or reports).
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Settings")
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||||
|
.field("items_per_library", &self.items_per_library)
|
||||||
|
.field("search_results", &self.search_results)
|
||||||
|
.field("call_timeout_secs", &self.call_timeout_secs)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed `/abs/...` path. The direct-browse and search branches share the
|
||||||
|
/// same book/track shapes below a library.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum AbsPath<'a> {
|
||||||
|
Root,
|
||||||
|
Library(&'a str),
|
||||||
|
LibraryBook {
|
||||||
|
library: &'a str,
|
||||||
|
item: &'a str,
|
||||||
|
},
|
||||||
|
LibraryTrack {
|
||||||
|
library: &'a str,
|
||||||
|
item: &'a str,
|
||||||
|
ino: &'a str,
|
||||||
|
},
|
||||||
|
Search(&'a str),
|
||||||
|
/// Percent-encoded search-term segment.
|
||||||
|
SearchTerm {
|
||||||
|
library: &'a str,
|
||||||
|
term: &'a str,
|
||||||
|
},
|
||||||
|
SearchBook {
|
||||||
|
library: &'a str,
|
||||||
|
term: &'a str,
|
||||||
|
item: &'a str,
|
||||||
|
},
|
||||||
|
SearchTrack {
|
||||||
|
library: &'a str,
|
||||||
|
term: &'a str,
|
||||||
|
item: &'a str,
|
||||||
|
ino: &'a str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits an `/abs/...` path into its recognized shape. Unknown shapes are
|
||||||
|
/// [`ProviderError::MalformedPath`].
|
||||||
|
fn parse_path(path: &str) -> Result<AbsPath<'_>, ProviderError> {
|
||||||
|
if path == PROVIDER_ROOT {
|
||||||
|
return Ok(AbsPath::Root);
|
||||||
|
}
|
||||||
|
let rest = path
|
||||||
|
.strip_prefix("/abs/")
|
||||||
|
.ok_or(ProviderError::MalformedPath)?;
|
||||||
|
let segments: Vec<&str> = rest.split('/').collect();
|
||||||
|
if segments.iter().any(|segment| segment.is_empty()) {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
// Literal-`search` arms first so a `search` second segment routes to the
|
||||||
|
// search branch; item ids (UUIDs) never equal `search`.
|
||||||
|
match segments.as_slice() {
|
||||||
|
[lib, s] if *s == SEARCH_SEGMENT => Ok(AbsPath::Search(lib)),
|
||||||
|
[lib, s, term] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTerm { library: lib, term }),
|
||||||
|
[lib, s, term, item] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchBook {
|
||||||
|
library: lib,
|
||||||
|
term,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
|
[lib, s, term, item, ino] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTrack {
|
||||||
|
library: lib,
|
||||||
|
term,
|
||||||
|
item,
|
||||||
|
ino,
|
||||||
|
}),
|
||||||
|
[lib] => Ok(AbsPath::Library(lib)),
|
||||||
|
[lib, item] => Ok(AbsPath::LibraryBook { library: lib, item }),
|
||||||
|
[lib, item, ino] => Ok(AbsPath::LibraryTrack {
|
||||||
|
library: lib,
|
||||||
|
item,
|
||||||
|
ino,
|
||||||
|
}),
|
||||||
|
_ => Err(ProviderError::MalformedPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a fetch failure to the trait-level error, logging the typed cause.
|
||||||
|
fn fetch_err(context: &str, err: api::FetchError) -> ProviderError {
|
||||||
|
warn!(context, "abs fetch failed: {err}");
|
||||||
|
ProviderError::FetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audiobookshelf provider client.
|
||||||
|
pub struct Client {
|
||||||
|
api: Box<dyn Abs>,
|
||||||
|
settings: Settings,
|
||||||
|
/// Search terms created under `/abs/<lib>/search`, keyed by library id, in
|
||||||
|
/// creation order, deduplicated. In-memory only. Never held across awaits.
|
||||||
|
search_terms: RwLock<HashMap<String, Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Client {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Client")
|
||||||
|
.field("api", &self.api)
|
||||||
|
.field("settings", &self.settings)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// A client over any [`Abs`] backend — the seam the tests use.
|
||||||
|
fn with_api(api: Box<dyn Abs>, settings: Settings) -> Self {
|
||||||
|
Self {
|
||||||
|
api,
|
||||||
|
settings,
|
||||||
|
search_terms: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items_limit(&self) -> usize {
|
||||||
|
self.settings
|
||||||
|
.items_per_library
|
||||||
|
.unwrap_or(DEFAULT_ITEMS_PER_LIBRARY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_results_limit(&self) -> usize {
|
||||||
|
self.settings
|
||||||
|
.search_results
|
||||||
|
.unwrap_or(DEFAULT_SEARCH_RESULTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node listing `books` as children. A book is queueable only when it
|
||||||
|
/// has audio files (an ebook-only item reports zero and is shown but not
|
||||||
|
/// queueable). Used for both a library browse and a search-term node.
|
||||||
|
fn book_listing_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
title: String,
|
||||||
|
parent: String,
|
||||||
|
books: &[Book],
|
||||||
|
extra_children: Vec<LibraryNodeChild>,
|
||||||
|
) -> LibraryNode {
|
||||||
|
let mut children = extra_children;
|
||||||
|
children.extend(books.iter().map(|book| LibraryNodeChild {
|
||||||
|
..LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, &book.id),
|
||||||
|
book.title.clone(),
|
||||||
|
book.num_audio_files > 0,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title,
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children,
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One book's audio files as tracks. Queueable and downloadable —
|
||||||
|
/// homogeneous tracks. An audio-less item (ebook) yields an empty,
|
||||||
|
/// non-queueable node rather than an error.
|
||||||
|
async fn book_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
item_id: &str,
|
||||||
|
parent: String,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let detail = self
|
||||||
|
.api
|
||||||
|
.item_detail(item_id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("item detail", err))?;
|
||||||
|
let tracks: Vec<Track> = detail
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| book_track(path, item_id, &detail, track))
|
||||||
|
.collect();
|
||||||
|
let is_queable = !tracks.is_empty();
|
||||||
|
Ok(LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: detail.title,
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks,
|
||||||
|
children: Vec::new(),
|
||||||
|
is_queable,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library node: its books as children, prefixed by the creatable
|
||||||
|
/// `search` child.
|
||||||
|
async fn library_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
library_id: &str,
|
||||||
|
parent: String,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let books = self
|
||||||
|
.api
|
||||||
|
.library_items(library_id, self.items_limit())
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("library items", err))?;
|
||||||
|
if books.len() >= self.items_limit() {
|
||||||
|
debug!(
|
||||||
|
library_id,
|
||||||
|
limit = self.items_limit(),
|
||||||
|
"library listing truncated to the configured cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let search_child = LibraryNodeChild {
|
||||||
|
is_creatable: true,
|
||||||
|
..LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, SEARCH_SEGMENT),
|
||||||
|
SEARCH_SEGMENT.to_string(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
Ok(self.book_listing_node(
|
||||||
|
path,
|
||||||
|
library_id.to_string(),
|
||||||
|
parent,
|
||||||
|
&books,
|
||||||
|
vec![search_child],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search-term node: books matching the term as children.
|
||||||
|
async fn search_term_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
library_id: &str,
|
||||||
|
term: &str,
|
||||||
|
parent: String,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let books = self
|
||||||
|
.api
|
||||||
|
.search_items(library_id, term, self.search_results_limit())
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("search", err))?;
|
||||||
|
Ok(self.book_listing_node(path, term.to_string(), parent, &books, Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/abs/<lib>/search` node listing the library's search terms.
|
||||||
|
fn search_node(&self, path: &str, library_id: &str, parent: String) -> LibraryNode {
|
||||||
|
LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: SEARCH_SEGMENT.to_string(),
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children: self
|
||||||
|
.search_terms_snapshot(library_id)
|
||||||
|
.iter()
|
||||||
|
.map(|term| LibraryNodeChild {
|
||||||
|
// Term nodes are the modifiable nodes: renamable (`e`) and
|
||||||
|
// deletable (`d`), like tidal/youtube/fyyd.
|
||||||
|
is_editable: true,
|
||||||
|
is_deletable: true,
|
||||||
|
..LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, &crabidy_core::encode_segment(term)),
|
||||||
|
term.clone(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: true,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_terms_snapshot(&self, library_id: &str) -> Vec<String> {
|
||||||
|
self.search_terms
|
||||||
|
.read()
|
||||||
|
.ok()
|
||||||
|
.and_then(|terms| terms.get(library_id).cloned())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_search_term(&self, library_id: &str, term: &str) {
|
||||||
|
if let Ok(mut terms) = self.search_terms.write() {
|
||||||
|
let list = terms.entry(library_id.to_string()).or_default();
|
||||||
|
if !list.iter().any(|existing| existing == term) {
|
||||||
|
list.push(term.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a term; `true` when it existed.
|
||||||
|
fn remove_search_term(&self, library_id: &str, term: &str) -> bool {
|
||||||
|
match self.search_terms.write() {
|
||||||
|
Ok(mut terms) => {
|
||||||
|
let Some(list) = terms.get_mut(library_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let before = list.len();
|
||||||
|
list.retain(|existing| existing != term);
|
||||||
|
list.len() != before
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(item, ino)` addressed by a track path, or `MalformedPath`.
|
||||||
|
fn track_item_ino<'a>(&self, path: &'a str) -> Result<(&'a str, &'a str), ProviderError> {
|
||||||
|
match parse_path(path)? {
|
||||||
|
AbsPath::LibraryTrack { item, ino, .. } | AbsPath::SearchTrack { item, ino, .. } => {
|
||||||
|
Ok((item, ino))
|
||||||
|
}
|
||||||
|
_ => Err(ProviderError::MalformedPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the wire track for one audio file. `artist` is the book's author,
|
||||||
|
/// `album` the book title; missing fields degrade to empty/`None`.
|
||||||
|
fn book_track(
|
||||||
|
node_path: &str,
|
||||||
|
item_id: &str,
|
||||||
|
detail: &BookDetail,
|
||||||
|
track: &api::AudioTrack,
|
||||||
|
) -> Track {
|
||||||
|
Track {
|
||||||
|
path: crabidy_core::join_path(node_path, &track.ino),
|
||||||
|
artist: detail.author.clone(),
|
||||||
|
title: track.title.clone(),
|
||||||
|
duration: track.duration,
|
||||||
|
album: (!detail.title.is_empty()).then(|| Album {
|
||||||
|
title: detail.title.clone(),
|
||||||
|
release_date: None,
|
||||||
|
}),
|
||||||
|
is_skipped: false,
|
||||||
|
provider_item_id: format!("{item_id}:{}", track.ino),
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderClient for Client {
|
||||||
|
/// Builds the `reqwest`-backed client. A missing `base_url`/`api_key`, or
|
||||||
|
/// a client that cannot be built, fails init — the orchestrator then
|
||||||
|
/// disables the provider non-fatally
|
||||||
|
/// (architecture/audiobookshelf-provider.md D1).
|
||||||
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
||||||
|
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
|
||||||
|
warn!("could not parse abs.toml, using defaults");
|
||||||
|
Settings::default()
|
||||||
|
});
|
||||||
|
let base_url = settings
|
||||||
|
.base_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
warn!("abs provider disabled: no base_url in abs.toml");
|
||||||
|
ProviderError::Config("audiobookshelf base_url is required".to_string())
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let token = settings
|
||||||
|
.api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
warn!("abs provider disabled: no api_key in abs.toml");
|
||||||
|
ProviderError::Config("audiobookshelf api_key is required".to_string())
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let timeout = Duration::from_secs(
|
||||||
|
settings
|
||||||
|
.call_timeout_secs
|
||||||
|
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
|
||||||
|
);
|
||||||
|
let api = AbsApi::new(base_url, token, timeout).map_err(|err| {
|
||||||
|
warn!("cannot build the abs client: {err}");
|
||||||
|
ProviderError::Config(err.to_string())
|
||||||
|
})?;
|
||||||
|
debug!("abs provider ready");
|
||||||
|
Ok(Self::with_api(Box::new(api), settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(&self) -> String {
|
||||||
|
toml::to_string_pretty(&self.settings).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
parse_path(path),
|
||||||
|
Ok(AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's `?token=` stream URL — built from the path, no API call.
|
||||||
|
/// The token is embedded and must never be logged (D4).
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
|
let (item, ino) = self.track_item_ino(track_path)?;
|
||||||
|
Ok(vec![self.api.stream_url(item, ino)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-track metadata: fetch the book detail and pick the file by ino.
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
|
let (item, ino) = self.track_item_ino(track_path)?;
|
||||||
|
let detail = self
|
||||||
|
.api
|
||||||
|
.item_detail(item)
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("track metadata", err))?;
|
||||||
|
let track = detail
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.find(|track| track.ino == ino)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
warn!(track_path, "abs item has no file with this ino");
|
||||||
|
ProviderError::FetchError
|
||||||
|
})?;
|
||||||
|
let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT);
|
||||||
|
let mut wire = book_track(parent, item, &detail, track);
|
||||||
|
// The caller's path is canonical.
|
||||||
|
wire.path = track_path.to_string();
|
||||||
|
Ok(wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A placeholder — the real `/abs` root lists libraries, which needs a
|
||||||
|
/// network call, so it is served by [`Self::get_lib_node`] (the sync trait
|
||||||
|
/// method cannot await). The orchestrator only builds the global-root link
|
||||||
|
/// from [`PROVIDER_ROOT`], never from these children.
|
||||||
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
|
LibraryNode {
|
||||||
|
path: PROVIDER_ROOT.to_string(),
|
||||||
|
title: "abs".to_string(),
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children: Vec::new(),
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let parent = crabidy_core::parent_path(path)
|
||||||
|
.unwrap_or(crabidy_core::ROOT_PATH)
|
||||||
|
.to_string();
|
||||||
|
let node = match parse_path(path)? {
|
||||||
|
AbsPath::Root => {
|
||||||
|
// The real root: book libraries as children (podcast
|
||||||
|
// libraries are out of scope, D6).
|
||||||
|
let libraries = self
|
||||||
|
.api
|
||||||
|
.libraries()
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("libraries", err))?;
|
||||||
|
LibraryNode {
|
||||||
|
children: libraries
|
||||||
|
.iter()
|
||||||
|
.filter(|library| library.is_book)
|
||||||
|
.map(|library| {
|
||||||
|
LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(PROVIDER_ROOT, &library.id),
|
||||||
|
library.name.clone(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
..self.get_lib_root()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AbsPath::Library(library) => self.library_node(path, library, parent).await?,
|
||||||
|
AbsPath::Search(library) => self.search_node(path, library, parent),
|
||||||
|
AbsPath::SearchTerm { library, term } => {
|
||||||
|
let decoded = crabidy_core::decode_segment(term);
|
||||||
|
// Unknown terms (stale client cache, server restart) are
|
||||||
|
// recreated implicitly instead of erroring.
|
||||||
|
self.register_search_term(library, &decoded);
|
||||||
|
self.search_term_node(path, library, &decoded, parent)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
AbsPath::LibraryBook { item, .. } | AbsPath::SearchBook { item, .. } => {
|
||||||
|
self.book_node(path, item, parent).await?
|
||||||
|
}
|
||||||
|
AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. } => {
|
||||||
|
warn!(path, "get_lib_node called with a track path");
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The central download blessing (same rule as fyyd/youtube/tidal):
|
||||||
|
// every node serving playable content allows `W`; children mirror
|
||||||
|
// their queueability (a book child is queueable, so a whole book is
|
||||||
|
// capturable). The `search` child stays non-queueable, so it is not
|
||||||
|
// blessed.
|
||||||
|
let mut node = node;
|
||||||
|
node.is_downloadable = node.is_queable || !node.tracks.is_empty();
|
||||||
|
for child in &mut node.children {
|
||||||
|
child.is_downloadable = child.is_queable;
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only `/abs/<lib>/search` is creatable: registers the term and returns
|
||||||
|
/// its node (implicit recreation on stale paths, like tidal/youtube/fyyd).
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let term = title.trim();
|
||||||
|
if term.is_empty() {
|
||||||
|
return Err(ProviderError::InvalidInput);
|
||||||
|
}
|
||||||
|
let AbsPath::Search(library) = parse_path(parent_path)? else {
|
||||||
|
warn!(parent_path, "node creation not supported here");
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
};
|
||||||
|
self.register_search_term(library, term);
|
||||||
|
let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term));
|
||||||
|
self.get_lib_node(&term_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renaming a search term re-runs the search under the new term.
|
||||||
|
async fn rename_lib_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let AbsPath::SearchTerm { library, term } = parse_path(path)? else {
|
||||||
|
warn!(path, "only search terms are renamable");
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
};
|
||||||
|
let new_term = new_title.trim();
|
||||||
|
if new_term.is_empty() {
|
||||||
|
return Err(ProviderError::InvalidInput);
|
||||||
|
}
|
||||||
|
let old_term = crabidy_core::decode_segment(term);
|
||||||
|
// Replace in place; renaming onto an existing term merges (the
|
||||||
|
// duplicate disappears), like tidal's/fyyd's terms.
|
||||||
|
self.remove_search_term(library, &old_term);
|
||||||
|
self.register_search_term(library, new_term);
|
||||||
|
let new_path = crabidy_core::join_path(
|
||||||
|
&crabidy_core::join_path(
|
||||||
|
&crabidy_core::join_path(PROVIDER_ROOT, library),
|
||||||
|
SEARCH_SEGMENT,
|
||||||
|
),
|
||||||
|
&crabidy_core::encode_segment(new_term),
|
||||||
|
);
|
||||||
|
self.get_lib_node(&new_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleting a search term is idempotent and returns the refreshed search
|
||||||
|
/// node.
|
||||||
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let AbsPath::SearchTerm { library, term } = parse_path(path)? else {
|
||||||
|
warn!(path, "only search terms are deletable");
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
};
|
||||||
|
let decoded = crabidy_core::decode_segment(term);
|
||||||
|
self.remove_search_term(library, &decoded);
|
||||||
|
let search_path = crabidy_core::join_path(
|
||||||
|
&crabidy_core::join_path(PROVIDER_ROOT, library),
|
||||||
|
SEARCH_SEGMENT,
|
||||||
|
);
|
||||||
|
self.get_lib_node(&search_path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
include!("tests.rs");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
// Included from lib.rs `mod tests`. Provider logic (tree shaping, path
|
||||||
|
// parsing, term store, stream-URL building, secret redaction) is exercised
|
||||||
|
// against a programmable [`Abs`] backend with zero network
|
||||||
|
// (architecture/audiobookshelf-provider.md D2, quality/audiobookshelf-provider.md).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use api::{AudioTrack, Book, BookDetail, FetchError, Library};
|
||||||
|
|
||||||
|
/// A programmable audiobookshelf backend.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct FakeApi {
|
||||||
|
libraries: Vec<Library>,
|
||||||
|
/// library id → books.
|
||||||
|
items: HashMap<String, Vec<Book>>,
|
||||||
|
/// (library id, term) → books.
|
||||||
|
searches: HashMap<(String, String), Vec<Book>>,
|
||||||
|
/// item id → detail.
|
||||||
|
details: HashMap<String, BookDetail>,
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Abs for FakeApi {
|
||||||
|
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
|
||||||
|
Ok(self.libraries.clone())
|
||||||
|
}
|
||||||
|
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, FetchError> {
|
||||||
|
self.items
|
||||||
|
.get(library_id)
|
||||||
|
.map(|books| books.iter().take(limit).cloned().collect())
|
||||||
|
.ok_or(FetchError::NotFound)
|
||||||
|
}
|
||||||
|
async fn search_items(
|
||||||
|
&self,
|
||||||
|
library_id: &str,
|
||||||
|
term: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Book>, FetchError> {
|
||||||
|
self.searches
|
||||||
|
.get(&(library_id.to_string(), term.to_string()))
|
||||||
|
.map(|books| books.iter().take(limit).cloned().collect())
|
||||||
|
.ok_or_else(|| FetchError::Http(format!("no search fixture for {term:?}")))
|
||||||
|
}
|
||||||
|
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError> {
|
||||||
|
self.details.get(item_id).cloned().ok_or(FetchError::NotFound)
|
||||||
|
}
|
||||||
|
fn stream_url(&self, item_id: &str, ino: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/api/items/{item_id}/file/{ino}?token={}",
|
||||||
|
self.base_url, self.token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn book(id: &str, title: &str, author: &str, num_audio_files: u32) -> Book {
|
||||||
|
Book {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
author: author.to_string(),
|
||||||
|
num_audio_files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(ino: &str, title: &str, duration: Option<u32>) -> AudioTrack {
|
||||||
|
AudioTrack {
|
||||||
|
ino: ino.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard fixture: two book libraries and one podcast library; `lib1`
|
||||||
|
/// has an audiobook (`b1`, two files) and an ebook (`e1`, no audio); a search
|
||||||
|
/// for `neuro` in `lib1` matches `b1`.
|
||||||
|
fn fake() -> FakeApi {
|
||||||
|
FakeApi {
|
||||||
|
libraries: vec![
|
||||||
|
Library {
|
||||||
|
id: "lib1".into(),
|
||||||
|
name: "Audiobook".into(),
|
||||||
|
is_book: true,
|
||||||
|
},
|
||||||
|
Library {
|
||||||
|
id: "lib2".into(),
|
||||||
|
name: "Manning".into(),
|
||||||
|
is_book: true,
|
||||||
|
},
|
||||||
|
Library {
|
||||||
|
id: "pod1".into(),
|
||||||
|
name: "Podcasts".into(),
|
||||||
|
is_book: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
items: HashMap::from([(
|
||||||
|
"lib1".to_string(),
|
||||||
|
vec![
|
||||||
|
book("b1", "Neuromancer", "William Gibson", 2),
|
||||||
|
book("e1", "An Ebook", "Nobody", 0),
|
||||||
|
],
|
||||||
|
)]),
|
||||||
|
searches: HashMap::from([(
|
||||||
|
("lib1".to_string(), "neuro".to_string()),
|
||||||
|
vec![book("b1", "Neuromancer", "William Gibson", 2)],
|
||||||
|
)]),
|
||||||
|
details: HashMap::from([
|
||||||
|
(
|
||||||
|
"b1".to_string(),
|
||||||
|
BookDetail {
|
||||||
|
title: "Neuromancer".into(),
|
||||||
|
author: "William Gibson".into(),
|
||||||
|
tracks: vec![
|
||||||
|
track("111", "part1.opus", Some(60)),
|
||||||
|
track("222", "part2.opus", None),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e1".to_string(),
|
||||||
|
BookDetail {
|
||||||
|
title: "An Ebook".into(),
|
||||||
|
author: "Nobody".into(),
|
||||||
|
tracks: vec![],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
base_url: "https://abs.test".into(),
|
||||||
|
token: "SECRET".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_with(api: FakeApi) -> Client {
|
||||||
|
Client::with_api(Box::new(api), Settings::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> Client {
|
||||||
|
client_with(fake())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_lists_only_book_libraries() {
|
||||||
|
let node = client().get_lib_node("/abs").await.expect("root");
|
||||||
|
let names: Vec<&str> = node.children.iter().map(|c| c.title.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["Audiobook", "Manning"], "podcast library filtered");
|
||||||
|
assert_eq!(node.children[0].path, "/abs/lib1");
|
||||||
|
assert!(!node.children[0].is_queable, "a library is a container");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_library_lists_search_then_books_with_audio_gating_queueability() {
|
||||||
|
let node = client().get_lib_node("/abs/lib1").await.expect("library");
|
||||||
|
assert!(!node.is_creatable && !node.is_queable);
|
||||||
|
// search first, then the books.
|
||||||
|
let first = &node.children[0];
|
||||||
|
assert_eq!(first.title, "search");
|
||||||
|
assert_eq!(first.path, "/abs/lib1/search");
|
||||||
|
assert!(first.is_creatable && !first.is_queable && !first.is_downloadable);
|
||||||
|
|
||||||
|
let audiobook = &node.children[1];
|
||||||
|
assert_eq!(audiobook.path, "/abs/lib1/b1");
|
||||||
|
assert_eq!(audiobook.title, "Neuromancer");
|
||||||
|
assert!(
|
||||||
|
audiobook.is_queable && audiobook.is_downloadable,
|
||||||
|
"a book with audio queues/captures whole"
|
||||||
|
);
|
||||||
|
let ebook = &node.children[2];
|
||||||
|
assert_eq!(ebook.title, "An Ebook");
|
||||||
|
assert!(
|
||||||
|
!ebook.is_queable && !ebook.is_downloadable,
|
||||||
|
"an ebook (no audio) is shown but not queueable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_book_lists_its_files_as_tracks() {
|
||||||
|
let node = client().get_lib_node("/abs/lib1/b1").await.expect("book");
|
||||||
|
assert_eq!(node.title, "Neuromancer");
|
||||||
|
assert!(node.is_queable && node.is_downloadable);
|
||||||
|
assert_eq!(node.tracks.len(), 2);
|
||||||
|
let one = &node.tracks[0];
|
||||||
|
assert_eq!(one.path, "/abs/lib1/b1/111");
|
||||||
|
assert_eq!(one.title, "part1.opus");
|
||||||
|
assert_eq!(one.artist, "William Gibson", "artist is the author");
|
||||||
|
assert_eq!(
|
||||||
|
one.album.as_ref().map(|a| a.title.as_str()),
|
||||||
|
Some("Neuromancer"),
|
||||||
|
"album is the book"
|
||||||
|
);
|
||||||
|
assert_eq!(one.duration, Some(60));
|
||||||
|
assert_eq!(one.provider_item_id, "b1:111");
|
||||||
|
// Missing duration degrades to None, never an error.
|
||||||
|
assert_eq!(node.tracks[1].duration, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_audio_less_book_is_empty_and_not_queueable() {
|
||||||
|
let node = client().get_lib_node("/abs/lib1/e1").await.expect("ebook");
|
||||||
|
assert!(node.tracks.is_empty());
|
||||||
|
assert!(!node.is_queable && !node.is_downloadable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_terms_list_books_and_are_per_library() {
|
||||||
|
let client = client();
|
||||||
|
let node = client
|
||||||
|
.create_lib_node("/abs/lib1/search", "neuro")
|
||||||
|
.await
|
||||||
|
.expect("create term");
|
||||||
|
assert_eq!(node.path, "/abs/lib1/search/neuro");
|
||||||
|
assert!(!node.is_queable && node.tracks.is_empty());
|
||||||
|
assert_eq!(node.children.len(), 1);
|
||||||
|
let hit = &node.children[0];
|
||||||
|
assert_eq!(hit.path, "/abs/lib1/search/neuro/b1");
|
||||||
|
assert_eq!(hit.title, "Neuromancer");
|
||||||
|
assert!(hit.is_queable && hit.is_downloadable);
|
||||||
|
|
||||||
|
// The term shows under this library's search node, editable/deletable ...
|
||||||
|
let search = client.get_lib_node("/abs/lib1/search").await.expect("search");
|
||||||
|
assert!(search.is_creatable);
|
||||||
|
assert_eq!(search.children.len(), 1);
|
||||||
|
assert!(search.children[0].is_editable && search.children[0].is_deletable);
|
||||||
|
// ... but not under another library's search node (per-library store).
|
||||||
|
let other = client.get_lib_node("/abs/lib2/search").await.expect("search");
|
||||||
|
assert!(other.children.is_empty(), "terms do not leak across libraries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_book_under_a_search_term_lists_the_same_tracks() {
|
||||||
|
let client = client();
|
||||||
|
client
|
||||||
|
.create_lib_node("/abs/lib1/search", "neuro")
|
||||||
|
.await
|
||||||
|
.expect("create term");
|
||||||
|
let node = client
|
||||||
|
.get_lib_node("/abs/lib1/search/neuro/b1")
|
||||||
|
.await
|
||||||
|
.expect("book via search");
|
||||||
|
assert_eq!(node.tracks.len(), 2);
|
||||||
|
assert_eq!(node.tracks[0].path, "/abs/lib1/search/neuro/b1/111");
|
||||||
|
assert_eq!(node.tracks[0].provider_item_id, "b1:111");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_terms_rename_and_delete() {
|
||||||
|
let client = client();
|
||||||
|
client
|
||||||
|
.create_lib_node("/abs/lib1/search", "neuro")
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
let renamed = client
|
||||||
|
.rename_lib_node("/abs/lib1/search/neuro", "neuro")
|
||||||
|
.await
|
||||||
|
.expect("rename re-searches");
|
||||||
|
assert_eq!(renamed.path, "/abs/lib1/search/neuro");
|
||||||
|
assert_eq!(client.search_terms_snapshot("lib1"), vec!["neuro".to_string()]);
|
||||||
|
|
||||||
|
let search = client
|
||||||
|
.delete_lib_node("/abs/lib1/search/neuro")
|
||||||
|
.await
|
||||||
|
.expect("delete");
|
||||||
|
assert!(search.children.is_empty());
|
||||||
|
// Idempotent.
|
||||||
|
let again = client
|
||||||
|
.delete_lib_node("/abs/lib1/search/neuro")
|
||||||
|
.await
|
||||||
|
.expect("idempotent delete");
|
||||||
|
assert!(again.children.is_empty());
|
||||||
|
// Only search terms are mutable.
|
||||||
|
assert!(client.rename_lib_node("/abs/lib1", "nope").await.is_err());
|
||||||
|
assert!(client.delete_lib_node("/abs/lib1/b1").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track_stream_url_embeds_the_token_and_path_ids_without_a_call() {
|
||||||
|
let client = client();
|
||||||
|
assert!(client.is_track_path("/abs/lib1/b1/111"));
|
||||||
|
assert!(client.is_track_path("/abs/lib1/search/neuro/b1/111"));
|
||||||
|
assert!(!client.is_track_path("/abs/lib1/b1"));
|
||||||
|
assert!(!client.is_track_path("/abs/lib1"));
|
||||||
|
|
||||||
|
let urls = client
|
||||||
|
.get_urls_for_track("/abs/lib1/b1/111")
|
||||||
|
.await
|
||||||
|
.expect("stream url");
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec!["https://abs.test/api/items/b1/file/111?token=SECRET".to_string()]
|
||||||
|
);
|
||||||
|
// The search branch resolves to the same file URL.
|
||||||
|
let via_search = client
|
||||||
|
.get_urls_for_track("/abs/lib1/search/neuro/b1/111")
|
||||||
|
.await
|
||||||
|
.expect("stream url");
|
||||||
|
assert_eq!(via_search, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track_metadata_picks_the_file_by_ino() {
|
||||||
|
let client = client();
|
||||||
|
let track = client
|
||||||
|
.get_metadata_for_track("/abs/lib1/b1/222")
|
||||||
|
.await
|
||||||
|
.expect("metadata");
|
||||||
|
assert_eq!(track.title, "part2.opus");
|
||||||
|
assert_eq!(track.artist, "William Gibson");
|
||||||
|
assert_eq!(track.path, "/abs/lib1/b1/222");
|
||||||
|
assert_eq!(track.duration, None);
|
||||||
|
// A file id that the book does not have is a typed error, not a panic.
|
||||||
|
assert!(client
|
||||||
|
.get_metadata_for_track("/abs/lib1/b1/999")
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backend_failures_are_typed_never_panics() {
|
||||||
|
let client = client_with(FakeApi::default());
|
||||||
|
// An empty server (no libraries) is a valid empty root, not an error.
|
||||||
|
assert!(client.get_lib_node("/abs").await.expect("empty root").children.is_empty());
|
||||||
|
// But a missing library / item / search is a typed fetch error.
|
||||||
|
assert!(client.get_lib_node("/abs/lib1").await.is_err());
|
||||||
|
assert!(client.get_lib_node("/abs/lib1/b1").await.is_err());
|
||||||
|
client.register_search_term("lib1", "neuro");
|
||||||
|
assert!(client.get_lib_node("/abs/lib1/search/neuro").await.is_err());
|
||||||
|
assert!(client.get_metadata_for_track("/abs/lib1/b1/111").await.is_err());
|
||||||
|
// But building a stream URL never touches the backend, so it still works.
|
||||||
|
assert!(client.get_urls_for_track("/abs/lib1/b1/111").await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn foreign_and_malformed_paths_are_rejected() {
|
||||||
|
let client = client();
|
||||||
|
for path in [
|
||||||
|
"/tidal/artists",
|
||||||
|
"/abs/lib1/b1/111/extra",
|
||||||
|
"/abs//b1",
|
||||||
|
"/absnope",
|
||||||
|
] {
|
||||||
|
assert!(client.get_lib_node(path).await.is_err(), "{path}");
|
||||||
|
}
|
||||||
|
assert!(client.create_lib_node("/abs/lib1", "term").await.is_err());
|
||||||
|
assert!(client.create_lib_node("/abs/lib1/search", " ").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_debug_redacts_the_api_key() {
|
||||||
|
let settings = Settings {
|
||||||
|
base_url: Some("https://abs.test".into()),
|
||||||
|
api_key: Some("super-secret-token".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let shown = format!("{settings:?}");
|
||||||
|
assert!(!shown.contains("super-secret-token"), "api_key must be redacted");
|
||||||
|
assert!(shown.contains("<redacted>"));
|
||||||
|
assert!(shown.contains("abs.test"), "non-secret fields are shown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_round_trip() {
|
||||||
|
let settings: Settings = toml::from_str(
|
||||||
|
"base_url = \"https://abs.test\"\napi_key = \"k\"\nitems_per_library = 10\nsearch_results = 5\ncall_timeout_secs = 10\n",
|
||||||
|
)
|
||||||
|
.expect("parses");
|
||||||
|
assert_eq!(settings.base_url.as_deref(), Some("https://abs.test"));
|
||||||
|
assert_eq!(settings.items_per_library, Some(10));
|
||||||
|
assert_eq!(settings.search_results, Some(5));
|
||||||
|
assert_eq!(settings.call_timeout_secs, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn init_requires_base_url_and_api_key() {
|
||||||
|
// Empty config disables the provider (non-fatal at the orchestrator).
|
||||||
|
assert!(Client::init("").await.is_err());
|
||||||
|
assert!(Client::init("base_url = \"https://abs.test\"").await.is_err());
|
||||||
|
assert!(Client::init("api_key = \"k\"").await.is_err());
|
||||||
|
// Both present: the client builds (no network until a call).
|
||||||
|
assert!(Client::init("base_url = \"https://abs.test\"\napi_key = \"k\"")
|
||||||
|
.await
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
//! Live validation against a real audiobookshelf server. `#[ignore]`d so it
|
||||||
|
//! never runs in CI or hits the network by default; run it deliberately with
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! ABS_BASE_URL=https://host ABS_API_KEY=<key> \
|
||||||
|
//! cargo test -p absdy --test live -- --ignored --nocapture
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! It exercises the real endpoints end-to-end (libraries → items → search →
|
||||||
|
//! detail) so the `AbsApi` DTOs are confirmed against the live JSON shapes —
|
||||||
|
//! the drift risk called out in architecture/audiobookshelf-provider.md.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use absdy::api::{Abs, AbsApi};
|
||||||
|
|
||||||
|
fn creds() -> Option<(String, String)> {
|
||||||
|
let base = std::env::var("ABS_BASE_URL").ok()?;
|
||||||
|
let key = std::env::var("ABS_API_KEY").ok()?;
|
||||||
|
(!base.is_empty() && !key.is_empty()).then_some((base, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "hits a real audiobookshelf server; set ABS_BASE_URL and ABS_API_KEY"]
|
||||||
|
async fn live_browse_search_and_detail() {
|
||||||
|
let Some((base, key)) = creds() else {
|
||||||
|
eprintln!("ABS_BASE_URL / ABS_API_KEY unset — skipping live test");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let api = AbsApi::new(base, key, Duration::from_secs(30)).expect("client");
|
||||||
|
|
||||||
|
let libraries = api.libraries().await.expect("libraries decode");
|
||||||
|
assert!(!libraries.is_empty(), "server has at least one library");
|
||||||
|
let book_lib = libraries
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.is_book)
|
||||||
|
.expect("at least one book library");
|
||||||
|
println!("book library: {} ({})", book_lib.name, book_lib.id);
|
||||||
|
|
||||||
|
let items = api
|
||||||
|
.library_items(&book_lib.id, 5)
|
||||||
|
.await
|
||||||
|
.expect("items decode");
|
||||||
|
assert!(!items.is_empty(), "book library has items");
|
||||||
|
let with_audio = items
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.num_audio_files > 0)
|
||||||
|
.expect("an audiobook item");
|
||||||
|
println!(
|
||||||
|
"item: {} by {} ({} files)",
|
||||||
|
with_audio.title, with_audio.author, with_audio.num_audio_files
|
||||||
|
);
|
||||||
|
|
||||||
|
let detail = api
|
||||||
|
.item_detail(&with_audio.id)
|
||||||
|
.await
|
||||||
|
.expect("detail decode");
|
||||||
|
assert!(!detail.tracks.is_empty(), "audiobook has tracks");
|
||||||
|
let first = &detail.tracks[0];
|
||||||
|
println!("track0: ino={} title={}", first.ino, first.title);
|
||||||
|
|
||||||
|
// The stream URL is fully derivable and carries the token.
|
||||||
|
let url = api.stream_url(&with_audio.id, &first.ino);
|
||||||
|
assert!(url.contains(&format!("/api/items/{}/file/{}", with_audio.id, first.ino)));
|
||||||
|
assert!(url.contains("token="));
|
||||||
|
|
||||||
|
// Search should decode too (term may legitimately match nothing).
|
||||||
|
let hits = api
|
||||||
|
.search_items(&book_lib.id, "a", 3)
|
||||||
|
.await
|
||||||
|
.expect("search decode");
|
||||||
|
println!("search 'a' -> {} hits", hits.len());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
# audiobookshelf provider (audiobooks)
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
A new library provider mounted at `/abs` that lets a user **browse, search,
|
||||||
|
and play** the audiobooks on a self-hosted
|
||||||
|
[audiobookshelf](https://www.audiobookshelf.org/) (ABS) server.
|
||||||
|
|
||||||
|
- Unlike fyyd/tidal/youtube, ABS is **the user's own private server**, reached
|
||||||
|
over an authenticated HTTP API with an **API key** (a bearer token). So —
|
||||||
|
like tidal — the provider needs credentials, and — unlike tidal — a missing
|
||||||
|
or incomplete config disables it non-fatally (it is one optional server, not
|
||||||
|
the whole app).
|
||||||
|
- ABS organizes content as **libraries → items → audio files**. An audiobook
|
||||||
|
*item* is usually split into many audio files (e.g. `Neuromancer-01.opus` …
|
||||||
|
`-30.opus`); each file is one **track**. So the tree carries the same
|
||||||
|
"one extra level" as fyyd — `library → book → tracks` — plus a per-library
|
||||||
|
**search** subtree that mirrors tidal/youtube search-term semantics.
|
||||||
|
- **Playing** a track means streaming the file's content endpoint. ABS accepts
|
||||||
|
the API key as a `?token=` query parameter on that endpoint and honors HTTP
|
||||||
|
range requests, so the URL is exactly the shape the audio player already
|
||||||
|
handles — **and the whole URL is derivable from the path**, so playback
|
||||||
|
needs no extra API call, no sidecar, no byte-proxying (contrast `/youtube`).
|
||||||
|
- **Captures** (`W`, download) come for free: any node that serves tracks and
|
||||||
|
raises `is_downloadable` gets `w`/`W` with no wire or TUI work.
|
||||||
|
|
||||||
|
## The audiobookshelf API (grounding — live-validated against the test server)
|
||||||
|
|
||||||
|
Base `https://<host>/api/`. Every call carries `Authorization: Bearer <key>`,
|
||||||
|
**except** the file endpoint which also accepts `?token=<key>`. The endpoints
|
||||||
|
we use (all verified against the provided test server):
|
||||||
|
|
||||||
|
| Purpose | Endpoint |
|
||||||
|
| --- | --- |
|
||||||
|
| List libraries | `GET /api/libraries` |
|
||||||
|
| List a library's items | `GET /api/libraries/<lib>/items?limit=<n>&sort=…` |
|
||||||
|
| Search within a library | `GET /api/libraries/<lib>/search?q=<t>&limit=<n>` |
|
||||||
|
| Item detail (tracks) | `GET /api/items/<item>?expanded=1` |
|
||||||
|
| **Stream a file** | `GET /api/items/<item>/file/<ino>?token=<key>` |
|
||||||
|
|
||||||
|
Objects (fields we read):
|
||||||
|
|
||||||
|
- **library**: `id`, `name`, `mediaType` (we handle `book`).
|
||||||
|
- **item summary** (in the items list): `id`, `mediaType`, `media.metadata`
|
||||||
|
(`title`, `authorName`), `media.numAudioFiles`, `media.duration`.
|
||||||
|
- **item detail**: `media.metadata.{title,authorName}` and `media.tracks[]`,
|
||||||
|
each track: `index`, `title` (the filename, e.g. `Neuromancer-01.opus`),
|
||||||
|
`duration` (seconds, float), **`ino`** (stable file id, an integer string),
|
||||||
|
`contentUrl` (`/api/items/<item>/file/<ino>` — same components as the path).
|
||||||
|
- **search** response: `{ "book": [ { "libraryItem": <item> }, … ], … }`.
|
||||||
|
|
||||||
|
Verified facts that shape the design: the file endpoint returns `200` with
|
||||||
|
`?token=` and `401` without; a `Range` request returns `206`; ebook-only items
|
||||||
|
report `numAudioFiles == 0`.
|
||||||
|
|
||||||
|
## Assumptions (decided here)
|
||||||
|
|
||||||
|
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
|
||||||
|
(confirmed by `/youtube` and `/fyyd`): mirroring tidal's search-term
|
||||||
|
semantics costs no TUI or wire change. No proto change, no new
|
||||||
|
`ProviderCommand`.
|
||||||
|
- ABS needs credentials, so — unlike fyyd — a usable `abs.toml` must carry a
|
||||||
|
`base_url` and an `api_key`. A missing file, or one lacking either field,
|
||||||
|
**disables the provider non-fatally** (like fyyd/youtube on a failed probe):
|
||||||
|
it only costs the `/abs` subtree, never startup.
|
||||||
|
- The audio player streams the `?token=` file URL directly (its windowed-HTTP
|
||||||
|
path): ABS serves the raw file with range support, so there is no
|
||||||
|
`/youtube`-style URL-lifetime or ~1 MiB-cap problem. Audiobook files are
|
||||||
|
ordinary media (opus/mp3/m4a/flac) the existing decoder already handles.
|
||||||
|
- Items and files are addressed by their **ABS ids**: the library id and item
|
||||||
|
id are UUIDs, and the file `ino` is an integer string — all already
|
||||||
|
URL-safe. Only user-typed **search terms** are percent-encoded.
|
||||||
|
- The stream URL embeds a secret (`?token=`), so it must **never** be logged;
|
||||||
|
and `api_key` must be redacted from `Debug`/config dumps (hard rule:
|
||||||
|
redact secrets from logs and error reports).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Crate `absdy`, mounted at `/abs`, non-fatal init
|
||||||
|
|
||||||
|
New workspace crate `absdy` implementing `ProviderClient`, shaped on `fyyd`
|
||||||
|
(the closest analog: remote, search-driven, one extra container level, plain
|
||||||
|
streamable URLs). Wired into `ProviderOrchestrator` with an
|
||||||
|
`abs_client: Option<Arc<absdy::Client>>` field, `abs_owns()`/`abs_provider()`
|
||||||
|
helpers, a `build()` block that reads `abs.toml` (non-fatal), a `get_lib_root`
|
||||||
|
child gated on `self.abs_client.is_some()`, and one routing arm in each
|
||||||
|
dispatch method. `crabidy-server`'s settings gain `abs` in `ALL_PROVIDERS` (now
|
||||||
|
7), in `ProviderToggles`, in `all()`, and in `provider_toggles()`.
|
||||||
|
|
||||||
|
### D2 — HTTP behind a trait, faked in tests
|
||||||
|
|
||||||
|
All network access goes through one seam — an `Abs` trait
|
||||||
|
(`libraries`, `library_items`, `search_items`, `item_detail`) behind
|
||||||
|
`Box<dyn Abs>` — with a `reqwest`-based `AbsApi` (bearer auth) for production
|
||||||
|
and a `FakeApi` in tests, exactly as `fyyd` hides `reqwest` behind `Fyyd`.
|
||||||
|
Provider logic (tree shaping, path parsing, term store, stream-URL building) is
|
||||||
|
then unit-tested with zero network. The trait's error type maps to
|
||||||
|
`ProviderError::FetchError` at the boundary; malformed paths are
|
||||||
|
`MalformedPath`; empty create/rename input is `InvalidInput`; missing
|
||||||
|
credentials at init are `Config`.
|
||||||
|
|
||||||
|
### D3 — Tree shape (libraries, books, tracks, per-library search)
|
||||||
|
|
||||||
|
Item ids are UUIDs and file inos are integers, so neither can equal the
|
||||||
|
reserved segment `search`; the parser uses that to split the two branches.
|
||||||
|
|
||||||
|
- `/abs` — children: one node per library from `/api/libraries` (a fixed
|
||||||
|
browse; the provider is useful with zero typing). Not itself queueable.
|
||||||
|
- `/abs/<lib>` — children: a reserved `search` child (`is_creatable`) **plus**
|
||||||
|
the library's items (bounded, see D5) as book children. A book child's
|
||||||
|
`is_queable` is set from `numAudioFiles > 0`, so ebook-only items show but
|
||||||
|
are not queueable/capturable.
|
||||||
|
- `/abs/<lib>/<item>` — lists that book's audio files as **tracks**;
|
||||||
|
queueable and downloadable (homogeneous tracks).
|
||||||
|
- `/abs/<lib>/<item>/<ino>` — the track leaf.
|
||||||
|
- `/abs/<lib>/search` — `is_creatable`; children are the in-memory search
|
||||||
|
terms (`RwLock<Vec<String>>`, dedup, recreated implicitly on stale paths),
|
||||||
|
each `is_editable` + `is_deletable`, like tidal/youtube/fyyd. Search terms
|
||||||
|
are stored **per library** (keyed by library id).
|
||||||
|
- `/abs/<lib>/search/<term>` — lists matching **books** as children (same book
|
||||||
|
shape as a direct library child).
|
||||||
|
- `/abs/<lib>/search/<term>/<item>` and `.../<item>/<ino>` — identical book
|
||||||
|
node and track leaf as under the direct browse; the two branches share the
|
||||||
|
book-node and track-leaf builders.
|
||||||
|
|
||||||
|
Terms are percent-encoded into one segment (`encode_segment`/
|
||||||
|
`decode_segment`); library ids, item ids, and inos are already URL-safe.
|
||||||
|
|
||||||
|
### D4 — Streams, metadata, no extra call for playback
|
||||||
|
|
||||||
|
- `get_urls_for_track`: parse `item` + `ino` from the path and build
|
||||||
|
`<base>/api/items/<item>/file/<ino>?token=<key>`. **No API call** — the URL
|
||||||
|
is fully derivable from the path; the token is appended last and never
|
||||||
|
logged. An unparseable path is `MalformedPath`.
|
||||||
|
- Track fields (from the item detail's `tracks[]`): `title` = track title (the
|
||||||
|
file name), `artist` = the book's `authorName`, `album` = the book title,
|
||||||
|
`duration` = track duration (seconds → `Option<u32>`), `provider_item_id` =
|
||||||
|
`"<item>:<ino>"` (stable per file, keys the content store for captures).
|
||||||
|
- **Metadata source.** Listing a book fetches the item detail once and builds
|
||||||
|
every track from `tracks[]` (title/duration/author all present). A
|
||||||
|
*directly* fetched track (`get_metadata_for_track` on a bare track path)
|
||||||
|
fetches the same item detail and picks the matching `ino`. A missing field
|
||||||
|
degrades to an empty string / `None`, never an error.
|
||||||
|
|
||||||
|
### D5 — Bounds and freshness
|
||||||
|
|
||||||
|
- `items_per_library` (default 200), `search_results` (default 50) cap every
|
||||||
|
listing so a huge library cannot stall the tree or queue resolution;
|
||||||
|
`call_timeout_secs` (default 30) bounds each HTTP call (hard rule: timeouts
|
||||||
|
on external calls).
|
||||||
|
- Listings are fetched fresh per call (no cross-call cache), like the other
|
||||||
|
remote providers. Only the search *terms* are stored, in memory, per library.
|
||||||
|
|
||||||
|
### D6 — Out of scope (explicitly)
|
||||||
|
|
||||||
|
- **Podcast** libraries (`mediaType: "podcast"`, `media.episodes`): the test
|
||||||
|
server has none; the book-node builder reads `media.tracks`. A podcast
|
||||||
|
library's items would show no tracks (non-queueable). Adding an episodes
|
||||||
|
branch is a later, additive change — noted as the extension point.
|
||||||
|
- ABS user accounts beyond the single API key, playback-progress sync back to
|
||||||
|
ABS, series/authors/collections/genre browse, and tag filtering.
|
||||||
|
- Transcoding / HLS sessions — we stream the raw file with range requests.
|
||||||
|
- Cover art, chapters, and ebook reading.
|
||||||
|
- Pagination past the configured caps (one page is fetched per listing).
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
orch: ProviderOrchestrator
|
||||||
|
}
|
||||||
|
|
||||||
|
absdy: "absdy (crate)" {
|
||||||
|
client: "Client\n(ProviderClient)"
|
||||||
|
terms: "search terms\n(in-memory, per library)"
|
||||||
|
api: "AbsApi\n(reqwest seam: Abs trait,\nbearer auth)"
|
||||||
|
client -> terms
|
||||||
|
client -> api
|
||||||
|
}
|
||||||
|
|
||||||
|
abs: "audiobookshelf\n(/api, bearer auth)" { shape: cloud }
|
||||||
|
player: audio-player { shape: hexagon }
|
||||||
|
|
||||||
|
server.orch -> absdy.client: "/abs/..."
|
||||||
|
absdy.api -> abs: "libraries / items / search / detail (JSON, timeout)"
|
||||||
|
server.orch -> player: "file URL with ?token="
|
||||||
|
player -> abs: "windowed HTTP stream (Range → 206)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key flow: browse a library, play a track
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
tui: TUI
|
||||||
|
orch: Orchestrator
|
||||||
|
a: absdy
|
||||||
|
api: audiobookshelf
|
||||||
|
|
||||||
|
tui -> orch: "open /abs"
|
||||||
|
orch -> a: "get_lib_root / get_lib_node"
|
||||||
|
a -> api: "GET /api/libraries"
|
||||||
|
api -> a: "libraries (id, name)"
|
||||||
|
a -> tui: "libraries as children"
|
||||||
|
tui -> orch: "open a library"
|
||||||
|
orch -> a: "get_lib_node(/abs/<lib>)"
|
||||||
|
a -> api: "GET /api/libraries/<lib>/items"
|
||||||
|
api -> a: "items (id, title, numAudioFiles)"
|
||||||
|
a -> tui: "[search] + books as children"
|
||||||
|
tui -> orch: "open a book"
|
||||||
|
orch -> a: "get_lib_node(/abs/<lib>/<item>)"
|
||||||
|
a -> api: "GET /api/items/<item>?expanded=1"
|
||||||
|
api -> a: "tracks (ino, title, duration)"
|
||||||
|
a -> tui: "book node: files as tracks (queueable)"
|
||||||
|
tui -> orch: "queue + play a track"
|
||||||
|
orch -> a: "get_urls_for_track(.../<ino>)"
|
||||||
|
a -> a: "build /api/items/<item>/file/<ino>?token= (no call)"
|
||||||
|
orch -> orch: "player streams the file"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **API-key lifetime.** ABS API keys are long-lived tokens, but if the
|
||||||
|
configured value is a short-lived session JWT it will eventually expire; a
|
||||||
|
`401` then surfaces as a typed `FetchError` (a skipped track / an unreadable
|
||||||
|
node), never a crash. Re-issue the key in `abs.toml` to recover.
|
||||||
|
- **Token leakage.** The stream URL embeds the key. It must never reach a log,
|
||||||
|
trace, or error report — enforced by building the URL only at the boundary,
|
||||||
|
a redacting `Debug`, and logging paths/context (never the built URL). This
|
||||||
|
is a quality gate.
|
||||||
|
- **Summary vs detail drift.** A book child's `is_queable` comes from the
|
||||||
|
summary's `numAudioFiles`; the actual track count comes from the detail.
|
||||||
|
A mismatch only means an optimistic flag — resolution of an empty book
|
||||||
|
yields no tracks (skipped), never an error.
|
||||||
|
- **Large libraries.** Capped by `items_per_library`; deep browsing past the
|
||||||
|
cap needs pagination (out of scope). The cap is `log`-ged so truncation is
|
||||||
|
visible, not silent.
|
||||||
|
- **Field / envelope drift across ABS versions.** DTOs decode defensively
|
||||||
|
(`#[serde(default)]`, missing fields degrade); a renamed field is a local
|
||||||
|
fix in `AbsApi`. **Live validation is a task-plan gate** (already exercised
|
||||||
|
during design against the test server).
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# Bookmarks: capturing library subtrees
|
# Bookmarks: capturing library subtrees
|
||||||
|
|
||||||
|
> **Superseded** by `crabidy-store.md`: `/bookmarks` is folded into the single
|
||||||
|
> `/crabidy` provider; a `w`-save writes link tomls into a `/crabidy/<name>`
|
||||||
|
> folder. Kept for the link-vs-store rationale.
|
||||||
|
|
||||||
## Context and problem statement
|
## Context and problem statement
|
||||||
|
|
||||||
Queue persistence (architecture/queue-persistence.md) flattens the queue
|
Queue persistence (architecture/queue-persistence.md) flattens the queue
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
# Build features (tailored, non-bloated builds)
|
||||||
|
|
||||||
|
Cargo features that let a build drop whole subsystems — each provider,
|
||||||
|
Opus decoding, the spectrum, the embedded web UI, desktop notifications —
|
||||||
|
together with the dependencies those subsystems pull in. Everything is
|
||||||
|
**on by default**: a plain `cargo build` produces today's binary, and
|
||||||
|
`--no-default-features --features …` produces an appliance build.
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
`crabidy-server` links every provider unconditionally: `tidaldy`,
|
||||||
|
`ytdy` (and through it `rustypipe`), `fyyd`, `absdy`, `soundclouddy`,
|
||||||
|
`jamendody`, `fsdy`. `audio-player` always links `symphonia` plus
|
||||||
|
`symphonia-adapter-libopus`, which **bundles libopus and therefore
|
||||||
|
requires `cmake` + `ninja` at build time**. `cbd-tui` always links
|
||||||
|
`notify-rust` (a D-Bus stack on Linux). A user who wants "a Raspberry Pi
|
||||||
|
that plays my local flac collection" compiles, links, and ships all of
|
||||||
|
it.
|
||||||
|
|
||||||
|
There is already a **runtime** switch — `crabidy-server.toml`'s
|
||||||
|
`providers = [...]` list ([`settings::ProviderToggles`]) — but it only
|
||||||
|
decides what gets *mounted*; every dependency is still compiled and
|
||||||
|
linked. The ask is the compile-time half, aligned with the dependency
|
||||||
|
graph so a tailored build is genuinely smaller.
|
||||||
|
|
||||||
|
Two questions from the request are answered here: `scan` **does**
|
||||||
|
already treat `.opus` as playable (`cli.rs: AUDIO_EXTENSIONS`), and that
|
||||||
|
entry now becomes conditional on the `opus` feature (D6); and disabling
|
||||||
|
`fs` also drops `/crabidy` and `/orphans` (D5).
|
||||||
|
|
||||||
|
## Assumptions (decided)
|
||||||
|
|
||||||
|
- **Default-on, opt-out.** No user's build changes unless they ask. The
|
||||||
|
entry point for tailoring is `--no-default-features`.
|
||||||
|
- **Two layers, different jobs.** Compile-time features decide what is
|
||||||
|
*linked*; the existing `providers` list decides what is *mounted*.
|
||||||
|
A provider that is compiled in can still be turned off in the toml;
|
||||||
|
a provider that is not compiled in cannot be turned on.
|
||||||
|
- **The wire protocol is feature-independent.** No `.proto` changes, no
|
||||||
|
feature-conditional RPCs, no client/server feature coupling. A client
|
||||||
|
asking for `/tidal` on a tidal-less server gets exactly what it gets
|
||||||
|
today from a runtime-disabled provider (`MalformedPath` → gRPC
|
||||||
|
`InvalidArgument`), and the root listing simply does not offer it.
|
||||||
|
Clients (`cbd-tui`, `cbd-web`) need **no** knowledge of server
|
||||||
|
features.
|
||||||
|
- **A feature must pay for itself in dependencies.** Adding `#[cfg]`
|
||||||
|
noise to gate code that shares its dependencies with code that stays
|
||||||
|
is a net loss — that is what the runtime toggles are for. See D5 for
|
||||||
|
where this bites (`crabidy`/`orphans`) and D9 for what we refuse to
|
||||||
|
gate.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
### How to gate the providers inside the orchestrator
|
||||||
|
|
||||||
|
`ProviderOrchestrator` holds nine `Option<Arc<ConcreteClient>>` fields
|
||||||
|
and dispatches with a hand-written `if …_owns(path) { … }` chain,
|
||||||
|
repeated verbatim across eight trait methods (`is_track_path`,
|
||||||
|
`get_urls_for_track`, `get_metadata_for_track`, `get_lib_node`,
|
||||||
|
`create_lib_node`, `rename_lib_node`, `delete_lib_node`,
|
||||||
|
`resolve_tracks_into`) plus `get_lib_root`.
|
||||||
|
|
||||||
|
1. **Sprinkle `#[cfg(feature = …)]`** on every field, every
|
||||||
|
`*_owns`/`*_provider` helper, and every branch of every chain:
|
||||||
|
~130 attributes, nine of them per method body, in a 1000-line file
|
||||||
|
that then only compiles in one shape per feature combination.
|
||||||
|
Rejected: unmaintainable, and each new provider multiplies it.
|
||||||
|
2. **A mount registry** (chosen). The nine dispatch chains collapse into
|
||||||
|
one lookup, because every branch is already the *same* code modulo
|
||||||
|
the client. `ProviderClient` is dyn-compatible (its only non-`&self`
|
||||||
|
method, `init`, carries `where Self: Sized`), so mounts can be held
|
||||||
|
as `Arc<dyn ProviderClient>`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Mount {
|
||||||
|
root: &'static str, // "/tidal", "/fs", …
|
||||||
|
name: &'static str, // root-listing title
|
||||||
|
client: Arc<dyn ProviderClient>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dispatch becomes "find the mount whose root owns this path, or
|
||||||
|
`MalformedPath`". A provider is then gated in exactly **one** place —
|
||||||
|
its registration in `build()` — plus its `Cargo.toml` line. This
|
||||||
|
deletes ~600 lines of repetition and is a strict prerequisite for the
|
||||||
|
feature work, so it lands first, on its own, with behaviour
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Ordering of the root listing (crabidy first, orphans last, rest
|
||||||
|
alphabetical) is a property of the assembled child list and is
|
||||||
|
preserved by the registry (it sorts the same way).
|
||||||
|
|
||||||
|
### Where the feature flags live
|
||||||
|
|
||||||
|
`crabidy-server` is the hub and owns the user-facing names.
|
||||||
|
`audio-player` gets internal features that the server turns on
|
||||||
|
(`opus`, `hls`, `spectrum`); `cbd` (the bundle) forwards the server's
|
||||||
|
set so `-p cbd` is tailorable too; `cbd-tui` owns `notifications`.
|
||||||
|
`crabidy-core` stays feature-free — it is the shared proto/trait crate
|
||||||
|
and every configuration needs all of it.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**D1 — Feature set.** `crabidy-server`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
default = ["all-providers", "opus", "spectrum", "web-ui"]
|
||||||
|
all-providers = ["tidal", "youtube", "fyyd", "abs", "soundcloud",
|
||||||
|
"jamendo", "fs"]
|
||||||
|
|
||||||
|
tidal = ["dep:tidaldy"]
|
||||||
|
youtube = ["dep:ytdy"]
|
||||||
|
fyyd = ["dep:fyyd"]
|
||||||
|
abs = ["dep:absdy"]
|
||||||
|
soundcloud = ["dep:soundclouddy"]
|
||||||
|
jamendo = ["dep:jamendody"]
|
||||||
|
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest"]
|
||||||
|
opus = ["audio-player/opus"]
|
||||||
|
spectrum = ["dep:realfft"]
|
||||||
|
web-ui = ["dep:tonic-web", "dep:include_dir"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`cbd` mirrors every one of them as a pass-through
|
||||||
|
(`tidal = ["crabidy-server/tidal"]`, …) and adds
|
||||||
|
`notifications = ["cbd-tui/notifications"]`. `cbd-tui`:
|
||||||
|
`default = ["notifications"]`, `notifications = ["dep:notify-rust"]`.
|
||||||
|
`audio-player` gets exactly one feature — `default = ["opus"]`,
|
||||||
|
`opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]` — for the
|
||||||
|
reason in D7.
|
||||||
|
|
||||||
|
**D2 — A build with no providers is legal.** `--no-default-features`
|
||||||
|
must compile and run: the server starts, serves an empty library root,
|
||||||
|
and plays nothing. It is the base case of the matrix (D11) and the
|
||||||
|
cheapest possible smoke test of the gating. It is not a *useful*
|
||||||
|
deployment, and the startup log says so.
|
||||||
|
|
||||||
|
**D3 — Names match the runtime toggles.** The feature names are exactly
|
||||||
|
the strings in `providers = [...]` (`tidal`, `youtube`, `fyyd`, `abs`,
|
||||||
|
`soundcloud`, `jamendo`, `fs`). One vocabulary for both layers.
|
||||||
|
|
||||||
|
**D4 — The compiled-in set is discoverable.** `settings` gains a
|
||||||
|
compile-time `BUILT_IN_PROVIDERS` (the feature-filtered version of
|
||||||
|
today's `ALL_PROVIDERS`). Consequences:
|
||||||
|
|
||||||
|
- the default `crabidy-server.toml` written on first run lists only
|
||||||
|
providers this binary has;
|
||||||
|
- a name in the user's list that this binary lacks logs one clear
|
||||||
|
warning at startup (`providers lists "tidal", but this binary was
|
||||||
|
built without it`) — a warning, not a startup abort, because the
|
||||||
|
library layer is fail-open by design (unlike `[auth]`);
|
||||||
|
- an unknown name (a typo) warns the same way;
|
||||||
|
- `crabidy-server features` / `cbd features` prints the compiled set
|
||||||
|
(providers + `opus`/`spectrum`/`web-ui`/`notifications`), and the
|
||||||
|
same list goes into one `info!` line at startup. "Why is `/tidal`
|
||||||
|
missing?" is then answerable from the binary and the log.
|
||||||
|
|
||||||
|
**D5 — `fs` owns local files *and* persistent state; `crabidy` and
|
||||||
|
`orphans` stay runtime-only.** As requested, disabling `fs` disables
|
||||||
|
`/crabidy` and `/orphans` too — but they are not separate *features*,
|
||||||
|
because they add no dependency of their own: `crabidy_store.rs`,
|
||||||
|
`capture.rs`, and `orphans.rs` are all written in terms of `fsdy` types
|
||||||
|
(`fsdy::TrackFile`, `Playable`, `AlbumMeta`, `dir_name`). Gating them
|
||||||
|
separately would buy nothing and cost three more `#[cfg]` dimensions.
|
||||||
|
So the `fs` feature is one coherent unit — "local files and persistent
|
||||||
|
state" — covering:
|
||||||
|
|
||||||
|
| gated by `fs` | consequence when off |
|
||||||
|
| --- | --- |
|
||||||
|
| `fsdy` client, `/fs` mount | no `/fs` |
|
||||||
|
| `crabidy_store` + `/crabidy` mount | no saved queues, bookmarks, captures |
|
||||||
|
| `orphans` + `/orphans` mount | no GC view |
|
||||||
|
| queue persistence | in-memory only; a restart starts empty |
|
||||||
|
| `capture` (bookmarks, downloads) | capture/save RPCs `Unimplemented` |
|
||||||
|
| `annotate_captured` | no captured markers (clients handle absent flags) |
|
||||||
|
| the `scan` CLI command | fails with "built without the `fs` feature" |
|
||||||
|
|
||||||
|
Users who want `/fs` but not `/crabidy` keep doing what they do today:
|
||||||
|
prune the `providers` list.
|
||||||
|
|
||||||
|
**D6 — `opus` is the biggest single win and is not tied to a provider.**
|
||||||
|
It drops `symphonia`, `symphonia-adapter-libopus`, and with them the
|
||||||
|
bundled libopus C build (`cmake` + `ninja` disappear from the build
|
||||||
|
requirements — the reason this feature is worth its `#[cfg]`s).
|
||||||
|
Ogg-Opus files reach the player from `/abs`, `/fs`, and `/crabidy`
|
||||||
|
alike, so it stays an independent axis. With `opus` off:
|
||||||
|
|
||||||
|
- `player_engine::build_source` skips the sniff and hands everything to
|
||||||
|
rodio's decoder — an Ogg-Opus file then fails to decode with a clear
|
||||||
|
error (`this build has no Opus decoder`) and playback skips the track,
|
||||||
|
exactly as any undecodable file does today. **No panic** (hard rule).
|
||||||
|
- `cli.rs: AUDIO_EXTENSIONS` drops `"opus"`, so `scan` no longer indexes
|
||||||
|
`.opus` files it could not play. (It *does* index them today; that is
|
||||||
|
correct behaviour for a build that has the decoder.)
|
||||||
|
|
||||||
|
**D7 — HLS playback is *not* gated.** SoundCloud is the only provider
|
||||||
|
that returns an `.m3u8` (`soundclouddy` resolves HLS media URLs), so
|
||||||
|
`audio-player/src/hls.rs` is dead code in a build without `soundcloud`
|
||||||
|
— but it imports only crates the player needs anyway (`bytes`,
|
||||||
|
`futures`, `stream-download`, `url`, `reqwest`). Gating it would buy a
|
||||||
|
few KB of code and cost a `#[cfg]` dimension across the decode path, so
|
||||||
|
it stays unconditional. Same reasoning for the windowed-HTTP source. The
|
||||||
|
rule this follows is the one in the assumptions: **a feature must pay
|
||||||
|
for itself in dependencies.**
|
||||||
|
|
||||||
|
**D8 — `spectrum` gates the server side only.** `realfft`, `spectrum.rs`
|
||||||
|
and `spawn_spectrum_task` go away; the `SpectrumFrame` proto message,
|
||||||
|
the player's sample tap, and both clients' rendering stay. A client
|
||||||
|
subscribed to the update stream simply never receives a frame, which it
|
||||||
|
already handles (the bars stay dark). The tap itself
|
||||||
|
(`audio-player/src/spectrum_tap.rs`) is not gated — like `hls.rs` it
|
||||||
|
brings no dependency (std + rodio), and it is woven through
|
||||||
|
`player_engine`'s decode path via `TappingSource`, so `Player`'s public
|
||||||
|
surface stays feature-invariant.
|
||||||
|
|
||||||
|
**D9 — Deliberately *not* behind features.**
|
||||||
|
|
||||||
|
- **`[auth]` / `argon2`.** A binary built without auth would ignore
|
||||||
|
configured role hashes and run open — a fail-open security hole for a
|
||||||
|
~200 KB dependency. Refused. (If it is ever added, it must *abort*
|
||||||
|
startup when `[auth]` is non-empty.)
|
||||||
|
- **Audio output / `rodio` / ALSA.** The server *is* the player; a
|
||||||
|
server with no audio output has no purpose here.
|
||||||
|
- **`web-ui` on the clients.** `cbd-web` is its own crate; not building
|
||||||
|
it is already the way to not have it.
|
||||||
|
- **TUI spectrum rendering, TUI/web feature parity.** No dependency
|
||||||
|
behind them (D8).
|
||||||
|
- **The CLI surface.** The clap definitions live in `cbd-cli`, which
|
||||||
|
depends on none of the gated crates. Keeping the surface constant
|
||||||
|
means completions and the man page do not vary per build; a command
|
||||||
|
whose backing feature is absent fails with a clear message (D5).
|
||||||
|
|
||||||
|
**D10 — `flake.nix` must be updated in the same change.** Its native
|
||||||
|
build passes a bare `--no-default-features` (today: "everything except
|
||||||
|
`web-ui`"). After D1 that would silently produce a **provider-less**
|
||||||
|
binary. It becomes an explicit list — `--no-default-features --features
|
||||||
|
all-providers,opus,spectrum` — and the aarch64 cross build (full
|
||||||
|
defaults, `web-ui` on) stays as is. `devenv.nix` gains scripts for the
|
||||||
|
tailored builds so the matrix is one command.
|
||||||
|
|
||||||
|
**D11 — Verification is a build matrix, not a powerset.** Feature
|
||||||
|
combinatorics are the real risk: unused imports/dead code under odd
|
||||||
|
combinations, and `-D warnings` in the pre-commit hook. A curated matrix
|
||||||
|
(D2's empty build, defaults, each provider alone, `fs`-only,
|
||||||
|
`opus`-off, `spectrum`-off, `web-ui`-off, and the two client crates)
|
||||||
|
gives the coverage that matters; `cargo hack --each-feature` is
|
||||||
|
available in devenv for a deeper sweep when the flags change.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
features: crabidy-server features {
|
||||||
|
providers: "tidal · youtube · fyyd\nabs · soundcloud · jamendo · fs"
|
||||||
|
opus: opus
|
||||||
|
spectrum: spectrum
|
||||||
|
webui: web-ui
|
||||||
|
}
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
registry: "mount registry\nArc<dyn ProviderClient>"
|
||||||
|
store: "crabidy_store + capture\n+ orphans"
|
||||||
|
fft: "spectrum.rs (realfft)"
|
||||||
|
web: "web.rs (tonic-web,\ninclude_dir)"
|
||||||
|
}
|
||||||
|
|
||||||
|
player: audio-player {
|
||||||
|
ap_opus: "opus_source\n(symphonia + libopus,\nneeds cmake)"
|
||||||
|
ap_rest: "hls · windowed_http\n· spectrum_tap\n(never gated: no own deps)"
|
||||||
|
}
|
||||||
|
|
||||||
|
deps: provider crates {
|
||||||
|
tidaldy
|
||||||
|
ytdy: "ytdy → rustypipe"
|
||||||
|
fyyd
|
||||||
|
absdy
|
||||||
|
soundclouddy
|
||||||
|
jamendody
|
||||||
|
fsdy: "fsdy + blake3"
|
||||||
|
}
|
||||||
|
|
||||||
|
features.providers -> server.registry: mounts
|
||||||
|
features.providers -> deps: "dep:*"
|
||||||
|
features.providers -> server.store: "fs only"
|
||||||
|
features.opus -> player.ap_opus
|
||||||
|
features.spectrum -> server.fft
|
||||||
|
features.webui -> server.web
|
||||||
|
```
|
||||||
|
|
||||||
|
Dispatch after the registry refactor — one path, whatever is compiled
|
||||||
|
in:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
rpc: RPC / provider loop
|
||||||
|
root: "path == /"
|
||||||
|
lookup: "mount whose root owns the path"
|
||||||
|
client: "Arc<dyn ProviderClient>"
|
||||||
|
none: "MalformedPath / NotSupported"
|
||||||
|
listing: "root listing\n(crabidy, …, orphans)"
|
||||||
|
|
||||||
|
rpc -> root
|
||||||
|
root -> listing: yes
|
||||||
|
root -> lookup: no
|
||||||
|
lookup -> client: found
|
||||||
|
lookup -> none: no owner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries and interfaces
|
||||||
|
|
||||||
|
- **`ProviderOrchestrator`** — the only place a provider is named. New
|
||||||
|
shape: `mounts: Vec<Mount>` plus the `Option<Arc<CrabidyStore>>` that
|
||||||
|
`fs` brings (the store is not a mount; it is a writer other
|
||||||
|
subsystems share). Public surface (`build`, `run`, `provider_tx`,
|
||||||
|
`crabidy_store`, the `ProviderClient` impl) is unchanged.
|
||||||
|
- **`settings`** — `BUILT_IN_PROVIDERS` (compile-time) and
|
||||||
|
`ProviderToggles` (runtime) meet here; `provider_toggles()` returns
|
||||||
|
toggles only for providers this binary has.
|
||||||
|
- **`audio-player`** — public API is feature-invariant (`Player`,
|
||||||
|
`PlayerMessage`, `SpectrumTap`, `output_device_names`); features
|
||||||
|
change only what is inside.
|
||||||
|
- **Proto / clients** — untouched.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`#[cfg]` rot.** A combination nobody builds breaks silently.
|
||||||
|
Mitigated by D11's matrix in devenv scripts (and CI when there is
|
||||||
|
one).
|
||||||
|
- **Startup surprise.** A user upgrading a distro package built without
|
||||||
|
`youtube` sees `/youtube` vanish with no clue. Mitigated by D4
|
||||||
|
(startup log line, `features` command, warning when the toml names a
|
||||||
|
provider the binary lacks).
|
||||||
|
- **`flake.nix` silently shipping an empty build.** D10; it is the first
|
||||||
|
thing the plan changes after the manifests.
|
||||||
|
- **The registry refactor touching every dispatch path.** Landed as its
|
||||||
|
own commit with no feature changes, so a regression bisects cleanly.
|
||||||
|
`resolve_tracks_into`'s per-provider overrides keep working — it is a
|
||||||
|
trait method, dispatched dynamically like the rest.
|
||||||
|
- **Opus files in an opus-less build.** They fail to decode and are
|
||||||
|
skipped, with a message naming the missing feature (D6) — never a
|
||||||
|
panic.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None blocking. Deferred by choice: gating `[auth]` (D9, refused),
|
||||||
|
per-provider *runtime* dynamic loading (out of scope — features are
|
||||||
|
compile-time), and a `minimal` convenience feature (users compose
|
||||||
|
`--no-default-features --features fs,opus` instead).
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# Capture deletion
|
# Capture deletion
|
||||||
|
|
||||||
|
> **Superseded** by `crabidy-store.md`: audio now lives in a shared store that
|
||||||
|
> track deletion never touches, so deletion on `/crabidy` goes through directly
|
||||||
|
> with no confirmation and no disk reclamation. This whole feature is removed.
|
||||||
|
|
||||||
Deleting under `/captures` reclaims disk: downloaded audio is the one
|
Deleting under `/captures` reclaims disk: downloaded audio is the one
|
||||||
library content that is expensive to recreate (slow, throttled downloads
|
library content that is expensive to recreate (slow, throttled downloads
|
||||||
— architecture/youtube-rustypipe.md), so stale captures must be
|
— architecture/youtube-rustypipe.md), so stale captures must be
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
# Captures (downloaded subtrees)
|
# Captures (downloaded subtrees)
|
||||||
|
|
||||||
> **Partially superseded** by `incremental-captures.md`: download captures
|
> **Superseded** by `crabidy-store.md`: captures now link into a shared
|
||||||
> are now incremental (no tmp-and-swap, re-capturing a name resumes it),
|
> content-addressed store under `~/.local/share/crabidy/`, de-duplicated by
|
||||||
> uncapturable tracks are recorded as *skipped* tomls instead of being
|
> provider id and by content hash, instead of downloading audio next to each
|
||||||
> omitted, and the capture RPC streams progress. Bookmarks keep the
|
> toml; `/captures` folds into `/crabidy`. Before that, download captures were
|
||||||
> tmp-and-swap described here.
|
> already **partially superseded** by `incremental-captures.md`: they became
|
||||||
|
> incremental (re-capturing a name resumes; no tmp-and-swap), uncapturable
|
||||||
|
> tracks are recorded as *skipped* tomls instead of omitted, and the capture
|
||||||
|
> RPC streams progress. Bookmarks kept the tmp-and-swap described here.
|
||||||
|
|
||||||
## Context and problem statement
|
## Context and problem statement
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
# A comprehensive CLI for every binary
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
Today the command line is thin and inconsistent:
|
||||||
|
|
||||||
|
- `crabidy-server` uses clap-derive but exposes only `hash-password` and no
|
||||||
|
flags (it always binds the fixed `LISTEN_ADDR`).
|
||||||
|
- `cbd-tui` and `cbd` share a ClapSerde `Config` (`-a/-u/-p`, `--spectrum`)
|
||||||
|
that doubles as the TOML schema; neither has subcommands.
|
||||||
|
- Every user operation is reachable *only* through the interactive TUI. There
|
||||||
|
is no way to script the player, set up auth, or index a music folder from the
|
||||||
|
shell.
|
||||||
|
|
||||||
|
The goal: **every binary is a clap-derive CLI with `--help`; every operation
|
||||||
|
the TUI can do is also a subcommand; there are shell completions and man pages;
|
||||||
|
and running a binary with no subcommand behaves exactly as today** (TUI, run the
|
||||||
|
server, or `cbd`'s server+TUI).
|
||||||
|
|
||||||
|
New capabilities requested:
|
||||||
|
|
||||||
|
- **`guard`** (server): hash a role password and, by default, write it into
|
||||||
|
`crabidy-server.toml`'s `[auth]`.
|
||||||
|
- **`scan`** (server): walk a path, drop a `.cbd-track.toml` beside every
|
||||||
|
playable file; `--capture` copies the audio into the content store (toml
|
||||||
|
points there), `--move` moves it instead of copying.
|
||||||
|
- **`auth`** (client): write a role + cleartext password into the client config.
|
||||||
|
- **`library` / `queue` / `global`** (server *and* client): the full set of
|
||||||
|
library, queue, and playback operations, run against a server over gRPC.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The `library`/`queue`/`global` commands are **remote**: they connect to a
|
||||||
|
running server at `--address` (default localhost) with the same basic-auth as
|
||||||
|
the TUI, reusing the generated gRPC client. A "server" binary running them is
|
||||||
|
just acting as a client to whatever server is up — including its own.
|
||||||
|
- One config file per client binary stays the source of truth for connection
|
||||||
|
defaults (`cbd-tui.toml`, `cbd.toml`); flags override it.
|
||||||
|
- Passwords may be given as an argument *or* on stdin. Argv is visible in the
|
||||||
|
process list — the help text says so, and omitting the argument reads stdin
|
||||||
|
(pipe-friendly, the current `hash-password` behavior).
|
||||||
|
- No tag extraction in `scan` v1 (title = file stem); no machine-readable
|
||||||
|
output format v1 (human-readable text). Both are noted as future work.
|
||||||
|
|
||||||
|
## D1 — A shared `cbd-cli` crate
|
||||||
|
|
||||||
|
A new library crate **`cbd-cli`** holds the clap definitions so all three
|
||||||
|
binaries share one command surface and each binary's `build.rs` can generate
|
||||||
|
assets from it. It is feature-split to keep `build.rs` light:
|
||||||
|
|
||||||
|
- **default features (clap only)**: the `Parser`/`Subcommand` types
|
||||||
|
(`LibraryCmd`, `QueueCmd`, `GlobalCmd`, `GuardCmd`, `ScanArgs`, `AuthCmd`, the
|
||||||
|
per-binary top-level `ServerCli`/`TuiCli`/`CbdCli`), a `Role` enum, a
|
||||||
|
`RemoteArgs` flatten (`--address/--user/--password`), and
|
||||||
|
`generate_assets(cmd, out_dir)` (wrapping `clap_complete` + `clap_mangen`).
|
||||||
|
Depends only on `clap`, `clap_complete`, `clap_mangen`.
|
||||||
|
- **feature `client`**: `run_remote(remote: &RemoteArgs, cmd: RemoteCmd)` — the
|
||||||
|
executor that connects a `crabidy_core` gRPC client (with a standalone
|
||||||
|
basic-auth interceptor) and runs a `library`/`queue`/`global` subcommand,
|
||||||
|
printing results. Adds `tonic`, `crabidy-core`, `tokio`.
|
||||||
|
|
||||||
|
`guard`/`scan`/`auth` *definitions* live in `cbd-cli` (so completions and man
|
||||||
|
pages cover them) but their *execution* lives in the owning binary, which has
|
||||||
|
the server config / store / client config internals `cbd-cli` must not depend
|
||||||
|
on.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
cbd_cli: cbd-cli (clap defs + asset gen) {
|
||||||
|
defs: "Parser/Subcommand types\nRemoteArgs, Role\ngenerate_assets()"
|
||||||
|
client: "feature=client:\nrun_remote() gRPC executor"
|
||||||
|
}
|
||||||
|
server: crabidy-server {
|
||||||
|
guard_scan: "guard + scan\n(config writer, store)"
|
||||||
|
}
|
||||||
|
tui: cbd-tui {
|
||||||
|
auth: "auth (client config writer)"
|
||||||
|
}
|
||||||
|
cbd: cbd (bundle)
|
||||||
|
core: crabidy-core (generated client)
|
||||||
|
cbd_cli.defs -> server.guard_scan: defines
|
||||||
|
cbd_cli.defs -> tui.auth: defines
|
||||||
|
cbd_cli.client -> core: gRPC to a running server
|
||||||
|
server -> cbd_cli.client: library/queue/global
|
||||||
|
tui -> cbd_cli.client: library/queue/global
|
||||||
|
cbd -> server: reuses guard/scan
|
||||||
|
cbd -> tui: reuses auth
|
||||||
|
cbd -> cbd_cli.client: library/queue/global
|
||||||
|
```
|
||||||
|
|
||||||
|
## D2 — Per-binary CLI and the no-subcommand default
|
||||||
|
|
||||||
|
Each binary defines a top-level clap `Parser` (in `cbd-cli`) with global
|
||||||
|
connection flags and an **optional** subcommand:
|
||||||
|
|
||||||
|
- `ServerCli`: `[guard|scan|library|queue|global|completions]`; none → run the
|
||||||
|
server (as today).
|
||||||
|
- `TuiCli`: `RemoteArgs` + `--spectrum` + `[auth|library|queue|global|
|
||||||
|
completions]`; none → run the TUI.
|
||||||
|
- `CbdCli`: the union — `[guard|scan|auth|library|queue|global|completions]`;
|
||||||
|
none → server + TUI (as today). This is "cbd has the commands from both."
|
||||||
|
|
||||||
|
**Config merge.** The no-subcommand path must keep today's behavior: read the
|
||||||
|
TOML (writing defaults on first run) and let flags override. ClapSerde did this
|
||||||
|
implicitly; a top-level `Parser` with a subcommand does not compose cleanly with
|
||||||
|
ClapSerde's `Opt`. Decision: replace ClapSerde for the client binaries with a
|
||||||
|
plain `serde` config load plus an explicit override step — the `RemoteArgs`
|
||||||
|
fields are `Option`, and a provided flag overrides the file value. `init_config`
|
||||||
|
keeps writing a defaults file on first run. This is a small, well-contained
|
||||||
|
change to `cbd-tui/src/config.rs` and the two `main.rs` files.
|
||||||
|
|
||||||
|
Alternative considered: sniff argv for a known subcommand before ClapSerde
|
||||||
|
parsing and branch. Rejected — it forfeits a unified `--help`/completions and is
|
||||||
|
fragile.
|
||||||
|
|
||||||
|
## D3 — `library` / `queue` / `global` (remote)
|
||||||
|
|
||||||
|
The executor (`cbd-cli` feature `client`) maps subcommands to the existing RPCs
|
||||||
|
(`RpcClient` already wraps all but `Stop`, which gains a wrapper):
|
||||||
|
|
||||||
|
- `library list [PATH]` (default `/`) → `GetLibraryNode`; prints child nodes and
|
||||||
|
tracks (path, title; captured rows marked, per architecture/crabidy-store.md).
|
||||||
|
- `library create <PARENT> <TITLE>`, `rename <PATH> <TITLE>`,
|
||||||
|
`delete <PATH>` → the matching library RPCs.
|
||||||
|
- `library save <PATH> <NAME>` → `CaptureLibraryNode{download:false}`;
|
||||||
|
`library capture <PATH> <NAME>` → `{download:true}`.
|
||||||
|
- `queue show` → `Queue`; `queue append|insert|replace <PATH>…`,
|
||||||
|
`queue remove <POS>…`, `queue clear [--keep-current]`,
|
||||||
|
`queue set-current <POS>`, `queue save <NAME>`, `queue capture <NAME>`
|
||||||
|
(→ `CaptureLibraryNode` on `/crabidy/current`), `queue shuffle`,
|
||||||
|
`queue repeat` (the `QueueModifiers` toggles).
|
||||||
|
- `global play|stop|next|prev|restart|mute`, `global volume <DELTA>` (or
|
||||||
|
`up|down` sugar around `ChangeVolume`).
|
||||||
|
|
||||||
|
Connection: `RemoteArgs` → a lazily-connected `CrabidyServiceClient` with a
|
||||||
|
basic-auth interceptor built from `--user/--password` (empty user = no header,
|
||||||
|
i.e. an open server). Each command is a one-shot: connect, call, print, exit.
|
||||||
|
`RpcClient::connect`'s `&'static ServerConfig` signature is loosened (or the
|
||||||
|
executor builds the client directly) so a CLI can pass an owned config.
|
||||||
|
|
||||||
|
Output is human-readable text; a `--json` flag is future work (D9).
|
||||||
|
|
||||||
|
## D4 — `guard` (server)
|
||||||
|
|
||||||
|
`crabidy-server guard <ROLE> [PASSWORD] [--no-config]` where `ROLE` ∈
|
||||||
|
`owner|queue-owner|appender`:
|
||||||
|
|
||||||
|
1. Read the password from the argument, or from stdin if omitted.
|
||||||
|
2. Hash it (`auth::hash_password`, argon2id) and **print the PHC string** to
|
||||||
|
stdout (so it stays pipeable and `--no-config` reproduces today's
|
||||||
|
`hash-password`).
|
||||||
|
3. Unless `--no-config`: load `crabidy-server.toml` (or defaults), set the role's
|
||||||
|
field (`owner`/`queue_owner`/`queue_appender`) to the hash, and write it back,
|
||||||
|
preserving the flat `[auth]` shape and the other roles. This needs a **config
|
||||||
|
writer** (D8) — none exists today.
|
||||||
|
|
||||||
|
`hash-password` is removed in favor of `guard` (`guard owner <pw> --no-config`
|
||||||
|
is the exact replacement). The role names match the basic-auth user names.
|
||||||
|
|
||||||
|
## D5 — `scan` (server)
|
||||||
|
|
||||||
|
`crabidy-server scan <PATH> [--capture] [--move]`:
|
||||||
|
|
||||||
|
- Walk `<PATH>` recursively (bounded, skipping hidden entries), selecting files
|
||||||
|
by audio extension (flac/mp3/m4a/ogg/opus/wav/webm/aac…).
|
||||||
|
- For each audio file, write a `<stem>.cbd-track.toml` **beside it** using
|
||||||
|
`fsdy::TrackFile` (`to_toml`, the shared naming), so the folder becomes a
|
||||||
|
browsable `/fs` tree. Title defaults to the file stem (tag extraction is
|
||||||
|
future work).
|
||||||
|
- Default (neither flag): the toml's playable is `Playable::File` pointing at
|
||||||
|
the audio's own file name (relative) — the audio stays where it is.
|
||||||
|
- `--capture`: ingest the audio into the content store (hash + de-dup + sidecar,
|
||||||
|
reusing `CrabidyStore`), and the beside-file toml gets a `Playable::Store`
|
||||||
|
entry instead. Idempotent: an already-stored file de-dups.
|
||||||
|
- `--move`: like `--capture` but the source audio is moved into the store, not
|
||||||
|
copied — the original location keeps only the toml.
|
||||||
|
- `--capture`/`--move` require the content store (a state/data dir);
|
||||||
|
`scan` opens a `CrabidyStore` directly and calls a new `ingest_file(path,
|
||||||
|
move) -> StoreName` helper (the local-source half of the D4 capture flow,
|
||||||
|
factored out).
|
||||||
|
|
||||||
|
Existing `.cbd-track.toml` files are left untouched (scan never clobbers a
|
||||||
|
hand-edited toml); a warning notes skips.
|
||||||
|
|
||||||
|
## D6 — `auth` (client)
|
||||||
|
|
||||||
|
`cbd-tui auth <ROLE> [PASSWORD] [--address ADDR]` (and the same on `cbd`):
|
||||||
|
loads the client config (`cbd-tui.toml` / `cbd.toml`), sets `user` to the role
|
||||||
|
name and `password` to the cleartext (optionally `address`), and writes it back
|
||||||
|
via the **client config writer** (D8). The file is chmod-private-friendly; the
|
||||||
|
help text repeats that the client config holds a plaintext password.
|
||||||
|
|
||||||
|
## D7 — Shell completions and man pages
|
||||||
|
|
||||||
|
Add `clap_complete` and `clap_mangen`. Each binary's `build.rs` build-depends on
|
||||||
|
`cbd-cli` (default features — clap only, cheap) and, from its top-level
|
||||||
|
`Command`, writes bash/zsh/fish completions and a `man` page into `OUT_DIR`
|
||||||
|
every build (a genuine build step). When `CBD_ASSET_DIR` is set, `build.rs` also
|
||||||
|
copies them into that stable directory. A devenv `gen-cli-assets` script sets
|
||||||
|
`CBD_ASSET_DIR=$PWD/dist` and builds, so `dist/completions/**` and `dist/man/*.1`
|
||||||
|
are produced on demand. A hidden `completions <shell>` subcommand on each binary
|
||||||
|
prints a completion script to stdout for ad-hoc use.
|
||||||
|
|
||||||
|
Alternative considered: an `xtask` generator binary. Rejected — a `build.rs`
|
||||||
|
keeps generation automatic and in lockstep with the CLI definition; the
|
||||||
|
`CBD_ASSET_DIR` copy covers the "get them into the repo" need.
|
||||||
|
|
||||||
|
## D8 — Config writers (new)
|
||||||
|
|
||||||
|
Two small, careful serializers, both load-modify-write preserving shape:
|
||||||
|
|
||||||
|
- **Server** (`crabidy-server`): `ServerSettings` gains a `store(config_dir)`
|
||||||
|
that serializes the current `[auth]` (round-tripping the existing file so
|
||||||
|
unknown-field rejection stays satisfiable) — used by `guard`.
|
||||||
|
- **Client** (`cbd-tui`): the `Config`/`ServerConfig` is already `Serialize`;
|
||||||
|
`auth` loads it, sets the fields, and writes `toml::to_string_pretty` back to
|
||||||
|
the config path — used by `auth`.
|
||||||
|
|
||||||
|
Both write to `dirs::config_dir()/crabidy/<file>` and create it if missing.
|
||||||
|
|
||||||
|
## D9 — Out of scope / future
|
||||||
|
|
||||||
|
- Tag extraction in `scan` (a `lofty`-backed title/artist/album/duration).
|
||||||
|
- `--json` machine-readable output for `library list` / `queue show`.
|
||||||
|
- Watching/streaming (`global watch` over `GetUpdateStream`).
|
||||||
|
- Bulk auth (multiple roles in one `guard` invocation).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Password in argv** is visible process-wide; mitigated by the stdin fallback
|
||||||
|
and documented in help. Accepted per the explicit request.
|
||||||
|
- **ClapSerde → clap migration** for the client config changes the parse path;
|
||||||
|
the first-run-writes-defaults and flag-overrides-file behaviors must be
|
||||||
|
preserved by tests.
|
||||||
|
- **`build.rs` writing outside `OUT_DIR`** (the `CBD_ASSET_DIR` copy) is
|
||||||
|
unconventional; gated on the env var so ordinary builds only touch `OUT_DIR`.
|
||||||
|
- **`cbd-cli` as a build-dependency** must stay clap-only by default, or every
|
||||||
|
build drags in tonic — enforced by the feature split.
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
# The `crabidy` provider and the content-addressed store
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
Today the server exposes three hand-managed subtrees — `/queues`, `/bookmarks`,
|
||||||
|
`/captures` — each a separate `fsdy::Client` instance rooted under
|
||||||
|
`~/.config/crabidy/`. Bookmarks (`w`) write *link* tomls; captures (`W`)
|
||||||
|
download audio next to each toml (`capture-deletion.md`,
|
||||||
|
`incremental-captures.md`). This has three problems the user wants fixed
|
||||||
|
green-field (no data migration):
|
||||||
|
|
||||||
|
1. **No de-duplication.** Capturing the same track from two places (a playlist
|
||||||
|
and a search, or two saved queues) downloads and stores the audio twice.
|
||||||
|
Re-capturing after a reorder re-downloads everything.
|
||||||
|
2. **Audio lives next to metadata.** Deleting a capture folder must carefully
|
||||||
|
remove downloaded audio from disk behind a confirmation
|
||||||
|
(`capture-deletion.md`), because the audio is only referenced from that one
|
||||||
|
folder. This couples deletion to expensive-payload bookkeeping.
|
||||||
|
3. **Three UI concepts** (queues, bookmarks, captures) for what the user thinks
|
||||||
|
of as "my crabidy stuff." They want one provider.
|
||||||
|
|
||||||
|
This design replaces all three with **one filesystem provider, `/crabidy`**,
|
||||||
|
whose track tomls *link into a single content-addressed store* of playable
|
||||||
|
files. Captures de-duplicate by provider identity and by content hash; deletion
|
||||||
|
becomes a plain toml removal that never touches the store.
|
||||||
|
|
||||||
|
This **supersedes** `bookmarks.md`, `captures.md`, `capture-deletion.md`, and
|
||||||
|
the on-disk/resumption parts of `incremental-captures.md` (the skipped-track and
|
||||||
|
progress-stream parts of that doc survive; see D9).
|
||||||
|
|
||||||
|
## Assumptions (confirmed by the request)
|
||||||
|
|
||||||
|
- **Green-field.** No migration of existing `~/.config/crabidy/{queues,
|
||||||
|
bookmarks,captures}` data. On first run the new locations are simply empty.
|
||||||
|
- **Two roots, split by XDG kind.** The store (playable audio + its sidecars)
|
||||||
|
is *data* → `~/.local/share/crabidy/` (`dirs::data_dir()`). The tomls (the
|
||||||
|
`/crabidy` tree) are *state* → `~/.local/state/crabidy/` (`dirs::state_dir()`).
|
||||||
|
- **Single server, single writer.** One process owns both roots; capture
|
||||||
|
mutations are serialized. No cross-host concurrent writers.
|
||||||
|
- **The store never shrinks automatically.** Deleting a toml never deletes store
|
||||||
|
audio (D7). Orphaned store entries are accepted; a garbage collector is future
|
||||||
|
work (D10).
|
||||||
|
- **`current` stays special.** The live queue is still mirrored to a reserved,
|
||||||
|
user-untouchable folder — now `/crabidy/current`, flat as before.
|
||||||
|
|
||||||
|
## D1 — One provider: `/crabidy`
|
||||||
|
|
||||||
|
`ProviderOrchestrator` drops the `queues`/`bookmarks`/`captures` fields and gains
|
||||||
|
one `crabidy` field: an `fsdy::Client` rooted at `~/.local/state/crabidy/`,
|
||||||
|
mounted at `/crabidy`, built `with_editable_top_level(&["current"])` (top-level
|
||||||
|
saves are renamable/deletable; `current` is reserved) `.with_downloadable_nodes()`
|
||||||
|
`.with_deletable_tree()` (every node deletes directly — see D7). A companion
|
||||||
|
writer, `CrabidyStore`, owns *both* roots and all mutation.
|
||||||
|
|
||||||
|
- Top-level folders under `/crabidy` are **user saves**, each created by `w`/`W`.
|
||||||
|
- **Saved queues are flat**; **saved library subtrees preserve their structure**
|
||||||
|
(falls out of walking the source — a flat queue yields a flat save).
|
||||||
|
- `/crabidy/current` is the live-queue mirror the playback loop keeps in sync
|
||||||
|
(replaces `/queues/current`). It is flat and reserved.
|
||||||
|
|
||||||
|
Non-toml files in a save folder (e.g. client log files that also land in
|
||||||
|
`~/.local/state/crabidy/`) are ignored by listing, as today — the provider only
|
||||||
|
surfaces subdirectories and `*.cbd-track.toml` files. (Optional tidy-up, not
|
||||||
|
required: move client logs to `~/.local/state/crabidy/logs/`.)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
orchestrator: ProviderOrchestrator (root, path-routed) {
|
||||||
|
tidal
|
||||||
|
youtube
|
||||||
|
fs
|
||||||
|
crabidy: /crabidy (fsdy::Client, read + delete)
|
||||||
|
}
|
||||||
|
state: "~/.local/state/crabidy/\n(toml tree)" { shape: cylinder }
|
||||||
|
share: {
|
||||||
|
label: "~/.local/share/crabidy/\ncontent store: audio + sidecars"
|
||||||
|
shape: cylinder
|
||||||
|
}
|
||||||
|
store_writer: CrabidyStore (single writer, owns both roots) {
|
||||||
|
index: "StoreIndex\n(provider-id → entry,\nhash → entry)"
|
||||||
|
}
|
||||||
|
orchestrator.crabidy -> state: lists tomls
|
||||||
|
orchestrator.crabidy -> share: resolves Store playables
|
||||||
|
store_writer -> state: writes save folders / track tomls
|
||||||
|
store_writer -> share: writes audio + sidecars
|
||||||
|
store_writer.index -> share: derived by scanning sidecars at open
|
||||||
|
```
|
||||||
|
|
||||||
|
## D2 — The store and its sidecars
|
||||||
|
|
||||||
|
`~/.local/share/crabidy/` is a **flat** directory. Each unique playable is a
|
||||||
|
pair:
|
||||||
|
|
||||||
|
- `<name>` — the audio file, named after the source's natural name (a local
|
||||||
|
file's basename, else a sanitized `<title>.<ext>` with the extension from the
|
||||||
|
download's `Content-Type`/URL). On name collision with *different* content,
|
||||||
|
append a numeral: `song.flac`, `song (2).flac`, … (identical content never
|
||||||
|
reaches naming — it de-dupes first, D4).
|
||||||
|
- `<name>.cbd-store.toml` — the sidecar, the store entry's metadata:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# song.flac.cbd-store.toml
|
||||||
|
hash = "blake3:1f0c…" # content hash of the audio file
|
||||||
|
|
||||||
|
[[provider]] # one entry per provider-id that maps here
|
||||||
|
provider = "tidal" # provider name (path root of the source)
|
||||||
|
id = "125169484" # provider-internal id (see D3)
|
||||||
|
title = "Bohemian Rhapsody"
|
||||||
|
artist = "Queen"
|
||||||
|
duration = 355
|
||||||
|
aliases = ["Bohemian Rhapsody (Remastered)"] # other titles for this id
|
||||||
|
[provider.album]
|
||||||
|
title = "A Night at the Opera"
|
||||||
|
release_date = "1975-11-21"
|
||||||
|
|
||||||
|
[[provider]] # same content 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 file. `CrabidyStore` builds an in-memory `StoreIndex` by scanning
|
||||||
|
`*.cbd-store.toml` at open and updates it on every write:
|
||||||
|
|
||||||
|
- `by_provider_id: HashMap<(provider, id), StoreRef>`
|
||||||
|
- `by_hash: HashMap<Hash, StoreRef>`
|
||||||
|
|
||||||
|
A `StoreRef` is the store `<name>` (which is the toml link target and the sidecar
|
||||||
|
key). Lookups are O(1).
|
||||||
|
|
||||||
|
> **"grep without shelling out."** The request describes searching sidecars
|
||||||
|
> for a provider id / hash like ripgrep, in-process. The `StoreIndex` *is* it,
|
||||||
|
> memoized: it is derived by reading the sidecars in-process at open (no shell,
|
||||||
|
> no external index), so the store stays self-describing. A live
|
||||||
|
> content-scan-then-parse (via the `grep-searcher` crate) was considered as the
|
||||||
|
> literal realization; rejected because an in-memory map built once is simpler,
|
||||||
|
> strictly faster for repeated captures in a session, and needs no new
|
||||||
|
> dependency. If the store ever grows beyond memory, a lazy content-scan is the
|
||||||
|
> fallback (D10).
|
||||||
|
|
||||||
|
## D3 — Provider identity on the wire
|
||||||
|
|
||||||
|
The store keys on a **provider-internal id** — "the id that clearly identifies
|
||||||
|
the item inside the provider," which the path cannot be (the same item is
|
||||||
|
reachable via a playlist, a search, an album…). Today no such id exists; identity
|
||||||
|
is the path string. We add it to the wire model:
|
||||||
|
|
||||||
|
- `Track.provider_item_id` (proto field 7, `string`) — set by the owning
|
||||||
|
provider when it produces a `Track`. Empty when unknown.
|
||||||
|
- **tidal** → the numeric track id.
|
||||||
|
- **youtube** → the video id.
|
||||||
|
- **fs** → the source file's canonical absolute path (two `/fs` tomls pointing
|
||||||
|
at the same file share an id → they de-dupe).
|
||||||
|
- **crabidy** (already store-backed) → the store `<name>`; used only to detect
|
||||||
|
"already captured" (D5).
|
||||||
|
- The **provider name** is the first path segment of the (resolved) source track
|
||||||
|
path — `to_track` already rewrites a link track's `path` to its target, so a
|
||||||
|
queued link routes to the real provider and carries that provider's id.
|
||||||
|
|
||||||
|
For the capture indicator (D8) we also add:
|
||||||
|
|
||||||
|
- `Track.is_captured` (field 8, `bool`) — the server sets it at listing time when
|
||||||
|
the store index has an entry for this track's `(provider, id)` **or** its
|
||||||
|
playable is already a store link. Cheap (one hashmap lookup) and works while
|
||||||
|
browsing *any* provider, so you can see what you have captured.
|
||||||
|
- `LibraryNode.is_captured` (field 10) and `LibraryNodeChild.is_captured`
|
||||||
|
(field 8) — a node is captured iff all its tracks and child nodes are captured
|
||||||
|
(D8 covers how the `/crabidy` provider computes this cheaply).
|
||||||
|
|
||||||
|
## D4 — Capturing one track: the de-dup flow
|
||||||
|
|
||||||
|
`store = true` means the track links into the store; `link` means a bookmark link
|
||||||
|
to the source provider. Capture (`W`) produces store links; bookmark (`w`)
|
||||||
|
produces plain links and never touches the store.
|
||||||
|
|
||||||
|
For each source track a capture walk visits, in order:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: down
|
||||||
|
start: "resolve source track\n(provider, id, natural name)"
|
||||||
|
already: "already store-backed?\n(Store playable, or fs file under the store root)"
|
||||||
|
byid: "index.by_provider_id[(provider,id)] ?"
|
||||||
|
getbytes: "obtain bytes\n(download → temp, or local file)"
|
||||||
|
byhash: "index.by_hash[hash(bytes)] ?"
|
||||||
|
newentry: "NEW: copy into store\n(+numeral) + write sidecar"
|
||||||
|
addid: "add provider to sidecar,\ndiscard the temp copy"
|
||||||
|
writetoml: "write track toml with playable.store = <name>"
|
||||||
|
|
||||||
|
start -> already
|
||||||
|
already -> writetoml: "yes → reuse target store name (no-op copy)"
|
||||||
|
already -> byid: "no"
|
||||||
|
byid -> writetoml: "HIT → reuse; record alias if title differs"
|
||||||
|
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 track's playable is a `Store` link, or
|
||||||
|
it is an `/fs` file whose path is already under the store root, there is
|
||||||
|
nothing to fetch — the save's toml links to the same store `<name>`. (This is
|
||||||
|
the "capture on an already-captured fs item → do nothing" case.)
|
||||||
|
2. **Provider-id lookup.** `index.by_provider_id[(provider, id)]` — a hit means
|
||||||
|
we already have this exact provider item. Point the save's toml at that store
|
||||||
|
entry. If the current title differs from the stored one, append it to that
|
||||||
|
entry's `aliases`. **No download.** (Common re-capture path — makes redoing a
|
||||||
|
save cheap.)
|
||||||
|
3. **Miss → obtain bytes.** Streamed source: windowed HTTP download to a temp
|
||||||
|
file (unchanged mechanics). Local source (`/fs` pointing at a normal file, not
|
||||||
|
under the store): the file is the bytes. Hash the bytes (blake3).
|
||||||
|
4. **Hash lookup.** `index.by_hash[hash]` — a hit means identical content is
|
||||||
|
already stored under some other identity. **Add** a `[[provider]]` entry to
|
||||||
|
that sidecar, **discard** the temp download (or do not copy the fs file),
|
||||||
|
point the toml at the existing store name. No duplicate.
|
||||||
|
5. **Miss → new store entry.** Choose a store name from the natural name
|
||||||
|
(+numeral on collision), move the temp file (or copy the fs file) into the
|
||||||
|
store, write the sidecar with `hash` and the first `[[provider]]` entry, update
|
||||||
|
the index. The `/fs` case *copies* — the original music-folder file stays put.
|
||||||
|
|
||||||
|
The byte budget (`DOWNLOAD_CAPS.max_bytes`) still counts bytes fetched *this run*
|
||||||
|
(hits cost nothing), so dedup makes big saves cheaper, never starves them.
|
||||||
|
|
||||||
|
## D5 — Save (`w`/`W`) and conflict handling
|
||||||
|
|
||||||
|
A **save** takes a *source* (a live-queue snapshot or a library-node path) and a
|
||||||
|
*mode* (`Link` for `w`, `Capture` for `W`) and writes a new top-level folder
|
||||||
|
`/crabidy/<name>`:
|
||||||
|
|
||||||
|
- **`w`** on a library node or the queue → folder of **link** tomls
|
||||||
|
(`fsdy::TrackFile::from_track`), the bookmark semantics, no store, no audio.
|
||||||
|
- **`W`** on a library node or the queue → `w` **plus** run the D4 capture per
|
||||||
|
track; tomls carry `playable.store`. Works on both library nodes and the queue.
|
||||||
|
|
||||||
|
**Conflict handling.** Each save records its origin in a hidden
|
||||||
|
`.cbd-save.toml` marker at the save root (`source = "<captured node path>"`,
|
||||||
|
`capture = true|false`). When `/crabidy/<name>` already exists:
|
||||||
|
|
||||||
|
- **`w` (link save)** always refuses — do nothing and warn `name "<name>"
|
||||||
|
already exists`; the user deletes the old folder and saves again.
|
||||||
|
- **`W` (capture)** refuses **unless it is a re-capture of the same source**:
|
||||||
|
if the existing save's marker `source` equals the node being captured, the
|
||||||
|
save **replaces** it; a different source still refuses. So pressing `W`
|
||||||
|
again on the same item (or `W` on the queue again) refreshes the capture in
|
||||||
|
place, while `W` naming a *different* item after an existing save is still
|
||||||
|
protected.
|
||||||
|
|
||||||
|
The save is built in a hidden `.tmp-<name>` sibling and swapped in atomically;
|
||||||
|
a permitted replace removes the old folder and renames the temp over it. A
|
||||||
|
crashed/failed run leaves **no** blocking partial folder, and any audio already
|
||||||
|
committed to the store persists and makes a retry fast via D4 (resumability
|
||||||
|
lives in the store, not the folder). Replacing is cheap and safe because a
|
||||||
|
save folder holds only tomls — the shared store audio is never rewritten or
|
||||||
|
deleted (D7). A save that predates the marker (no `.cbd-save.toml`) is treated
|
||||||
|
as a different source and refuses; delete it once to re-establish it.
|
||||||
|
|
||||||
|
`current` is exempt: the playback loop overwrites it on every queue change; the
|
||||||
|
user cannot save over the reserved name `current`.
|
||||||
|
|
||||||
|
## D6 — RPC surface
|
||||||
|
|
||||||
|
- **`CaptureLibraryNode(path, name, download)`** stays and now covers *all four*
|
||||||
|
gestures: `w`/`W` on a library node (`path = /tidal/...`), and `w`/`W` on the
|
||||||
|
queue (`path = /crabidy/current`). `download=false` → `Link`, `true` →
|
||||||
|
`Capture`. Validation errors (bad name, conflict, source not downloadable)
|
||||||
|
return synchronously; progress streams via the existing `CaptureProgress`
|
||||||
|
update (unchanged, D9).
|
||||||
|
- **`SaveQueue(name)` is retained** (this deviates from the original plan to
|
||||||
|
remove it — see `plan/summary.md`). It is the queue `w` gesture and is
|
||||||
|
reimplemented server-side as a *link* save of the live queue into
|
||||||
|
`/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).
|
||||||
|
|
||||||
|
## D7 — Deletion
|
||||||
|
|
||||||
|
Deletion on `/crabidy` (the only writable fs provider) **goes through directly,
|
||||||
|
no confirmation, and never touches the store**:
|
||||||
|
|
||||||
|
- Delete a track → remove its `.cbd-track.toml` only.
|
||||||
|
- Delete a folder → `remove_dir_all` of the toml folder only.
|
||||||
|
|
||||||
|
The existing `fsdy::delete_track_file` guard — "only delete the referenced audio
|
||||||
|
if it is contained under the instance root" — already makes this safe: a
|
||||||
|
`Store` playable resolves under `~/.local/share/…`, which is *outside* the
|
||||||
|
`/crabidy` toml root at `~/.local/state/…`, so the audio is never deleted. And
|
||||||
|
`w`-saves are links with no audio at all.
|
||||||
|
|
||||||
|
Consequences: drop the TUI's `delete_needs_confirmation` / `ConfirmDelete` path
|
||||||
|
(the whole `capture-deletion.md` confirmation feature is gone — nothing expensive
|
||||||
|
is destroyed anymore) and drop `with_deletable_tree`'s audio-removal branch usage
|
||||||
|
for this provider (folder/toml removal remains).
|
||||||
|
|
||||||
|
## D8 — UI: collapse to one provider, mark captured nodes
|
||||||
|
|
||||||
|
- `/queues`, `/bookmarks`, `/captures` disappear from the root; one `/crabidy`
|
||||||
|
child appears (title `crabidy`). Inside it, `w`-saves (links) and `W`-saves
|
||||||
|
(store-backed) coexist, distinguished by the captured marker.
|
||||||
|
- **Captured marker:** a captured row carries a trailing `↓` at the *end of
|
||||||
|
the row* — a status marker after the action-key brackets (outside them),
|
||||||
|
e.g. `Bohemian… ↓`.
|
||||||
|
- A **track** row is captured per `Track.is_captured` (D3): store-backed, or its
|
||||||
|
`(provider, id)` is in the store index — visible even while browsing tidal.
|
||||||
|
- A **node/child** row is captured iff all its tracks and child nodes are
|
||||||
|
captured. The `/crabidy` provider computes a node's own `is_captured` when it
|
||||||
|
lists it (it reads every track toml anyway → all `Store`?). To mark child
|
||||||
|
*folders* in a parent listing without deep recursion, a save records its mode
|
||||||
|
in a one-line marker at the save root written by `w`/`W`; nested folders under
|
||||||
|
a `W`-save are captured by construction. Exact recursion depth is an
|
||||||
|
implementation detail (see plan) — the invariant is "captured = fully local."
|
||||||
|
- The existing capture **progress** lines (`capturing <name> 12/34 …`) stay as-is.
|
||||||
|
|
||||||
|
## D9 — What carries over from `incremental-captures.md`
|
||||||
|
|
||||||
|
Kept: the `Skipped` playable (D1 there), skipped tracks in the queue and playback
|
||||||
|
skipping them (D3), the `CaptureProgress` stream and accept-then-stream RPC (D4),
|
||||||
|
and the focused-selection contrast fix (D7). A source that genuinely cannot be
|
||||||
|
fetched still writes a skipped toml. What changes: the *store write mode* (audio
|
||||||
|
now goes to the shared store, tomls carry `store`, dedup per D4) and *resume
|
||||||
|
semantics* (folder is atomic; store provides the savings, per D5).
|
||||||
|
|
||||||
|
## D10 — Out of scope / future
|
||||||
|
|
||||||
|
- **Store garbage collection.** Nothing reclaims store entries whose last
|
||||||
|
referencing toml was deleted. A future GC would scan all `/crabidy` tomls for
|
||||||
|
live `store` names and remove unreferenced pairs; needs its own design.
|
||||||
|
- **Lazy content-scan** instead of the in-memory index, for stores too large to
|
||||||
|
index in memory (D2).
|
||||||
|
- **Cross-provider captured marking of folders** (e.g. a tidal album shown
|
||||||
|
captured) beyond the cheap track-level lookup.
|
||||||
|
- **Cancelling a running capture** (unchanged from prior scope).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Wasted download on a hash-only match** (provider-id missed but content is
|
||||||
|
identical): we download, then discard. Unavoidable for content dedup; the
|
||||||
|
provider-id path avoids it in the common case.
|
||||||
|
- **Natural-name collisions** across unrelated tracks are handled by the numeral
|
||||||
|
suffix; the store name is opaque to users (only the toml title shows in UI).
|
||||||
|
- **Index/disk skew** if something outside the server edits the store: the index
|
||||||
|
is rebuilt at every start, and the server is the sole writer, so skew is
|
||||||
|
bounded to a single run — acceptable.
|
||||||
|
- **blake3 dependency** added (fast, maintained, no C toolchain). Alternative
|
||||||
|
`sha2` rejected for speed; hashing whole tracks is on the capture hot path.
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
# fyyd provider (podcasts)
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
A new library provider mounted at `/fyyd` that lets a user **find and play
|
||||||
|
podcasts**, backed by [fyyd](https://fyyd.de)'s public search engine.
|
||||||
|
|
||||||
|
- **Search** works with no account — creatable search-term nodes exactly
|
||||||
|
like `/tidal/search` and `/youtube/search` (`%` creates a term, results
|
||||||
|
appear underneath).
|
||||||
|
- Unlike YouTube, a podcast search does **not** return tracks directly: it
|
||||||
|
returns *podcasts*, each of which is a container of *episodes*. So the
|
||||||
|
tree carries **one extra level** — `search-term → podcast → episodes` —
|
||||||
|
where YouTube is `search-term → tracks`.
|
||||||
|
- **Playing** an episode means streaming its `enclosure` URL (the plain
|
||||||
|
HTTP(S) audio file from the podcast's RSS feed). That is exactly the
|
||||||
|
URL-returning shape the audio player already handles; no sidecar, no
|
||||||
|
cipher solving, no byte-proxying (contrast `/youtube`).
|
||||||
|
- **Captures** (`W`, download) come for free: any node that serves tracks
|
||||||
|
and raises `is_downloadable` gets `w`/`W` with no wire or TUI work.
|
||||||
|
|
||||||
|
## The fyyd API (grounding)
|
||||||
|
|
||||||
|
Base `https://api.fyyd.de/0.2/`, no key or auth for search and browse.
|
||||||
|
Responses are wrapped in a JSON envelope `{ "status", "msg", "data", … }`;
|
||||||
|
list endpoints add `meta.paging`. The endpoints we use:
|
||||||
|
|
||||||
|
| Purpose | Endpoint |
|
||||||
|
| --- | --- |
|
||||||
|
| Search podcasts | `GET /search/podcast?term=<t>&count=<n>` |
|
||||||
|
| Podcast episodes | `GET /podcast/episodes?podcast_id=<id>&count=<n>` |
|
||||||
|
| Hot (featured) podcasts | `GET /feature/podcast/hot?count=<n>` |
|
||||||
|
| Single episode | `GET /episode?episode_id=<id>` |
|
||||||
|
|
||||||
|
Objects (fields we read):
|
||||||
|
|
||||||
|
- **podcast**: `id` (int), `title`, `xmlURL`, `imgURL`, `description`,
|
||||||
|
`language`.
|
||||||
|
- **episode**: `id` (int), `title`, `guid`, **`enclosure`** (audio URL),
|
||||||
|
`podcast_id`, `duration` (seconds), `pubdate`.
|
||||||
|
|
||||||
|
The episode's `enclosure` is the only field playback needs. The single
|
||||||
|
episode endpoint does not carry the podcast's title, so an episode fetched
|
||||||
|
on its own has no "artist" until we look the podcast up (see D4).
|
||||||
|
|
||||||
|
## Assumptions (decided here)
|
||||||
|
|
||||||
|
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
|
||||||
|
(confirmed by `/youtube`): mirroring tidal's search-term semantics costs
|
||||||
|
no TUI or wire change. No proto change, no new `ProviderCommand`.
|
||||||
|
- fyyd's public API needs no credentials, so — unlike tidal — a missing or
|
||||||
|
empty `fyyd.toml` is the normal case, and init never needs a login. The
|
||||||
|
provider is non-fatal at startup like the local providers: a build/parse
|
||||||
|
failure only costs the `/fyyd` subtree.
|
||||||
|
- The audio player streams plain https `enclosure` URLs directly
|
||||||
|
(`audio-player` windowed-HTTP path). Podcast enclosures are ordinary
|
||||||
|
media files, so no `/youtube`-style URL-lifetime or ~1 MiB-cap problem.
|
||||||
|
- Episodes are addressed by fyyd's stable numeric `id` (as a string
|
||||||
|
segment). We do not use the RSS `guid` as the path key — the fyyd id is
|
||||||
|
shorter, already URL-safe, and is what the episode endpoint takes.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Crate `fyyd`, mounted at `/fyyd`, non-fatal init
|
||||||
|
|
||||||
|
New workspace crate `fyyd` implementing `ProviderClient`, shaped on `ytdy`
|
||||||
|
(the closest analog: remote, search-driven, in-memory terms). Wired into
|
||||||
|
`ProviderOrchestrator` with a `fyyd_client: Option<Arc<fyyd::Client>>`
|
||||||
|
field, `fyyd_owns()`/`fyyd_provider()` helpers, a `build()` block that
|
||||||
|
reads `fyyd.toml` (non-fatal), a `get_lib_root` child gated on
|
||||||
|
`self.fyyd_client.is_some()`, and one routing arm in each dispatch method.
|
||||||
|
`crabidy-server`'s settings gain `fyyd` in `ALL_PROVIDERS`, in
|
||||||
|
`ProviderToggles`, in `all()`, and in `provider_toggles()`.
|
||||||
|
|
||||||
|
### D2 — HTTP behind a trait, faked in tests
|
||||||
|
|
||||||
|
All network access goes through one seam — a `Fyyd` trait (`search_podcasts`,
|
||||||
|
`hot_podcasts`, `podcast_episodes`, `episode`) behind `Box<dyn Fyyd>` — with
|
||||||
|
a `reqwest`-based `FyydApi` for production and a `FakeApi` in tests, exactly
|
||||||
|
as `ytdy` hides `rustypipe` behind `Extract`. Provider logic (tree shaping,
|
||||||
|
path parsing, term store) is then unit-tested with zero network. The trait's
|
||||||
|
error type maps to `ProviderError::FetchError` at the boundary; malformed
|
||||||
|
paths are `MalformedPath`; empty create/rename input is `InvalidInput`.
|
||||||
|
|
||||||
|
### D3 — Tree shape (the extra level)
|
||||||
|
|
||||||
|
- `/fyyd` — children: `search` (always, `is_creatable`), `hot` (always, a
|
||||||
|
fixed featured-podcasts browse so the provider is useful with zero
|
||||||
|
typing).
|
||||||
|
- `/fyyd/search` — `is_creatable`; children are the in-memory search terms
|
||||||
|
(`RwLock<Vec<String>>`, dedup, recreated implicitly on stale paths),
|
||||||
|
each `is_editable` + `is_deletable`, like tidal/youtube.
|
||||||
|
- `/fyyd/search/<term>` — lists the top-N matching **podcasts** as
|
||||||
|
children (containers, not tracks). Not directly queueable itself; each
|
||||||
|
podcast child *is* queueable.
|
||||||
|
- `/fyyd/search/<term>/<podcast-id>` — lists that podcast's episodes as
|
||||||
|
**tracks**; queueable and downloadable (homogeneous tracks). Queueing the
|
||||||
|
podcast node enqueues its listed episodes (bounded, see D5).
|
||||||
|
- `/fyyd/search/<term>/<podcast-id>/<episode-id>` — the track leaf.
|
||||||
|
- `/fyyd/hot` — lists featured podcasts as children (same podcast shape as
|
||||||
|
a search-term node, but the list comes from `/feature/podcast/hot`
|
||||||
|
instead of a term).
|
||||||
|
- `/fyyd/hot/<podcast-id>` and `/fyyd/hot/<podcast-id>/<episode-id>` —
|
||||||
|
identical podcast/episode shapes as under `search`; the two branches
|
||||||
|
share the podcast-node and episode-leaf builders.
|
||||||
|
|
||||||
|
Terms are percent-encoded into one segment (`encode_segment`/
|
||||||
|
`decode_segment`); podcast and episode ids are already URL-safe integers.
|
||||||
|
|
||||||
|
### D4 — Streams, metadata, artist
|
||||||
|
|
||||||
|
- `get_urls_for_track`: parse the episode id from the path →
|
||||||
|
`episode(id)` → `vec![enclosure]`. An empty/missing enclosure is
|
||||||
|
`FetchError`, not a panic; the queue skips it.
|
||||||
|
- Track fields: `title` = episode title, `artist` = the **podcast** title,
|
||||||
|
`duration` = episode duration (seconds → `Option<u32>`),
|
||||||
|
`provider_item_id` = fyyd episode id (keys the content store for
|
||||||
|
captures), `album` = `None`.
|
||||||
|
- **Artist source.** When episodes are listed under a podcast we already
|
||||||
|
hold the podcast title, so listed tracks get the right artist for free.
|
||||||
|
A *directly* fetched episode (`get_metadata_for_track` on a bare episode
|
||||||
|
path, or a stream resolve) does not carry the podcast title from the
|
||||||
|
episode endpoint; the `FyydApi` fills it with one extra `/podcast`
|
||||||
|
lookup, and the `Episode` model carries `artist: Option<String>` so the
|
||||||
|
fake can supply it in tests. A missing artist degrades to an empty
|
||||||
|
string, never an error.
|
||||||
|
|
||||||
|
### D5 — Bounds and freshness
|
||||||
|
|
||||||
|
- `search_results` (podcasts per term, default 20), `hot_count` (default
|
||||||
|
20), and `episodes_per_podcast` (default 100) are configurable in
|
||||||
|
`fyyd.toml`; every listing is capped so a huge podcast cannot stall the
|
||||||
|
library or queue resolution. A `call_timeout_secs` (default 30) bounds
|
||||||
|
each HTTP call (hard rule: timeouts on external calls).
|
||||||
|
- Listings are fetched fresh per call (no cross-call cache), like tidal's
|
||||||
|
and youtube's fresh remote calls. Only the search *terms* are stored, in
|
||||||
|
memory.
|
||||||
|
|
||||||
|
### D6 — Out of scope (explicitly)
|
||||||
|
|
||||||
|
- fyyd user accounts, OAuth, subscriptions, personal collections, or the
|
||||||
|
"hot languages" / category browse — search + hot cover the "find and
|
||||||
|
play" ask.
|
||||||
|
- Episode chapters, transcripts, or per-episode images in the queue.
|
||||||
|
- Pagination past the configured caps (one page is fetched per listing).
|
||||||
|
- Caching or persisting episodes to disk beyond the existing `W` captures.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
orch: ProviderOrchestrator
|
||||||
|
}
|
||||||
|
|
||||||
|
fyyd: "fyyd (crate)" {
|
||||||
|
client: "Client\n(ProviderClient)"
|
||||||
|
terms: "search terms\n(in-memory, like tidal)"
|
||||||
|
api: "FyydApi\n(reqwest seam: Fyyd trait)"
|
||||||
|
client -> terms
|
||||||
|
client -> api
|
||||||
|
}
|
||||||
|
|
||||||
|
svc: "api.fyyd.de\n(public, keyless)" { shape: cloud }
|
||||||
|
cdn: "podcast enclosure\n(RSS media host)" { shape: cloud }
|
||||||
|
player: audio-player { shape: hexagon }
|
||||||
|
|
||||||
|
server.orch -> fyyd.client: "/fyyd/..."
|
||||||
|
fyyd.api -> svc: "search / episodes / hot (JSON, timeout)"
|
||||||
|
server.orch -> player: "enclosure URL"
|
||||||
|
player -> cdn: "windowed HTTP stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key flow: search a podcast, play an episode
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
tui: TUI
|
||||||
|
orch: Orchestrator
|
||||||
|
f: fyyd
|
||||||
|
api: api.fyyd.de
|
||||||
|
|
||||||
|
tui -> orch: "% on /fyyd/search: 'history'"
|
||||||
|
orch -> f: "create_lib_node"
|
||||||
|
f -> api: "GET /search/podcast?term=history"
|
||||||
|
api -> f: "podcasts (id, title)"
|
||||||
|
f -> tui: "term node: podcasts as children"
|
||||||
|
tui -> orch: "open a podcast"
|
||||||
|
orch -> f: "get_lib_node(/fyyd/search/history/<pid>)"
|
||||||
|
f -> api: "GET /podcast/episodes?podcast_id=<pid>"
|
||||||
|
api -> f: "episodes (id, title, enclosure, duration)"
|
||||||
|
f -> tui: "podcast node: episodes as tracks (queueable)"
|
||||||
|
tui -> orch: "queue + play an episode"
|
||||||
|
orch -> f: "get_urls_for_track(.../<eid>)"
|
||||||
|
f -> api: "GET /episode?episode_id=<eid>"
|
||||||
|
api -> f: "enclosure URL"
|
||||||
|
orch -> orch: "player streams the enclosure"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **fyyd envelope / field drift.** The `data` envelope and field names
|
||||||
|
(`enclosure`, `xmlURL`, numeric `id`) are read from the current public
|
||||||
|
docs but were not live-validated during design. The `FyydApi` decodes
|
||||||
|
defensively (serde with `#[serde(default)]`, missing fields degrade, no
|
||||||
|
panic) and every failure is a typed `FetchError`; if a field name is
|
||||||
|
wrong the fix is local to `FyydApi`'s DTOs. **Live validation is a
|
||||||
|
task-plan gate.**
|
||||||
|
- **Episode enclosure availability / expiry.** Some feeds proxy or expire
|
||||||
|
enclosures; a dead URL surfaces as a skipped track, never a crash.
|
||||||
|
- **Artist double-fetch.** Filling a directly-fetched episode's artist
|
||||||
|
costs one extra `/podcast` call; acceptable because direct metadata
|
||||||
|
fetches are rare (listing is the common path and already has the title).
|
||||||
|
- **Rate limits.** fyyd does not document limits; the per-call timeout and
|
||||||
|
the absence of background polling keep request volume to user actions.
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
# jamendo provider (free / Creative-Commons music)
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
A new library provider mounted at `/jamendo` that lets a user **search and
|
||||||
|
play** the Jamendo catalogue — hundreds of thousands of Creative-Commons
|
||||||
|
tracks — and browse the album a track belongs to, with **capture/download**
|
||||||
|
coming for free.
|
||||||
|
|
||||||
|
Jamendo is a **remote, search-driven** streaming service like
|
||||||
|
tidal/soundcloud/fyyd, but it is the *easy* one, and the design leans on that:
|
||||||
|
|
||||||
|
- **It has a real, stable, official API** (`api.jamendo.com/v3.0`). Unlike
|
||||||
|
SoundCloud there is **no `client_id` scraping and no rotation**: the developer
|
||||||
|
registers a `client_id` once at `devportal.jamendo.com` and drops it in
|
||||||
|
`jamendo.toml`. Every request just carries `client_id` + `format=json`.
|
||||||
|
- **The public catalogue needs no login.** Browsing and streaming are anonymous
|
||||||
|
with only a `client_id`; OAuth 2.0 exists solely for a user's own account
|
||||||
|
(favourites, personal playlists) and is **out of scope for v1**. So there is
|
||||||
|
no token lifecycle, no optional-login branch — the whole provider is one flat
|
||||||
|
public surface.
|
||||||
|
- **Tracks stream as a plain MP3 URL.** Each track object carries an `audio`
|
||||||
|
field that is a direct, range-streamable MP3 (`mp3d.jamendo.com/...`). crabidy
|
||||||
|
streams a single byte source over its existing windowed-HTTP path, so —
|
||||||
|
unlike SoundCloud's HLS work — **there is no `audio-player` change at all**.
|
||||||
|
This is the `absdy` shape: nodes serve tracks, `get_urls_for_track` returns a
|
||||||
|
URL, the existing MP3 (symphonia) decoder handles the rest.
|
||||||
|
- **Duration is already in seconds**, which is exactly the unit
|
||||||
|
`Track.duration` carries (soundclouddy divides its ms by 1000 at
|
||||||
|
`lib.rs:436`; absdy passes seconds straight through). No conversion, no
|
||||||
|
repeat of the web ms/seconds bug.
|
||||||
|
- **Captures** (`W`, download) come for free once nodes serve tracks and raise
|
||||||
|
`is_downloadable`: Jamendo tracks carry an `audiodownload` URL and the
|
||||||
|
content is CC-licensed and explicitly downloadable. No wire or TUI work.
|
||||||
|
|
||||||
|
## The Jamendo API (grounding)
|
||||||
|
|
||||||
|
Base `https://api.jamendo.com/v3.0`. **Every** request carries
|
||||||
|
`client_id=<id>&format=json` (omitted from the cells below). List calls add
|
||||||
|
`&limit=<n>&offset=<o>`; `limit` max is **200** (default 10). Endpoints we use:
|
||||||
|
|
||||||
|
| Purpose | Endpoint |
|
||||||
|
| --- | --- |
|
||||||
|
| Search tracks | `GET /tracks?search=<t>` (or `namesearch`, `tags`) |
|
||||||
|
| Track detail (stream URL) | `GET /tracks?id=<id>` |
|
||||||
|
| Search albums | `GET /albums?namesearch=<t>` |
|
||||||
|
| Album's tracks | `GET /albums/tracks?id=<album_id>` |
|
||||||
|
|
||||||
|
Objects (fields we read):
|
||||||
|
|
||||||
|
- **track**: `id` (numeric string), `name` (→ title), `artist_name`,
|
||||||
|
`album_name`, `duration` (**seconds**), `audio` (direct streaming MP3 URL),
|
||||||
|
`audiodownload` (download URL), `license_ccurl`. `audioformat=mp32` requests
|
||||||
|
the higher-bitrate stream (default is a low-bitrate `mp31`).
|
||||||
|
- **album**: `id`, `name`, `artist_name`; `/albums/tracks` returns the album
|
||||||
|
wrapping a `tracks[]` array of the same track shape.
|
||||||
|
|
||||||
|
**A `User-Agent` header is mandatory** (live-discovered 2026-07-24): Jamendo's
|
||||||
|
API returns HTTP 200 `success` with an **empty** result set to any request that
|
||||||
|
carries none — and `reqwest` sends none by default — so `JamApi` sets one. This
|
||||||
|
is silent (no error), so a missing UA looks exactly like "no matches".
|
||||||
|
|
||||||
|
Search parameters: `search` (free text across track/album/artist/tags),
|
||||||
|
`namesearch` (name match), `tags` (AND) / `fuzzytags` (fuzzy OR),
|
||||||
|
`order` (relevance, popularity, downloads, listens, releasedate, …). v1 uses
|
||||||
|
`search` with the default relevance order.
|
||||||
|
|
||||||
|
## Assumptions (decided here)
|
||||||
|
|
||||||
|
- The captures / creatable / editable / deletable TUI + wire flows are
|
||||||
|
provider-agnostic (proven by `/youtube`, `/fyyd`, `/abs`, `/soundcloud`):
|
||||||
|
a search-term provider costs **no proto, wire, TUI, or `ProviderCommand`
|
||||||
|
change**. It is a pure path-prefix subtree.
|
||||||
|
- The Jamendo `audio` URL is a real streaming MP3 the existing windowed-HTTP
|
||||||
|
source plays unmodified — **no HLS, no new `audio-player` component**. (Risk
|
||||||
|
R1 gates this with a live play.)
|
||||||
|
- A missing / malformed / rejected `client_id` must **never crash startup or a
|
||||||
|
browse**. With no `client_id` the `/jamendo` subtree is simply **not
|
||||||
|
mounted** (like `/abs` with missing config); a `client_id` that the API later
|
||||||
|
rejects surfaces a typed error and degrades the subtree, never the app.
|
||||||
|
- `client_id` is a semi-secret account key: **redact it from `Debug`, config
|
||||||
|
dumps, and logs**, and never log a built stream URL (they can embed a signed
|
||||||
|
`from` token). (Hard rule: redact secrets.)
|
||||||
|
- Jamendo ids are numeric and URL-safe; only user-typed **search terms** are
|
||||||
|
percent-encoded into a path segment.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Crate `jamendody`, mounted at `/jamendo`, non-fatal init
|
||||||
|
|
||||||
|
New workspace crate `jamendody` implementing `ProviderClient`, shaped on
|
||||||
|
`absdy`/`soundclouddy` (remote, search-driven, plain leaf tracks with direct
|
||||||
|
URLs). Wired into `ProviderOrchestrator` with a
|
||||||
|
`jamendo_client: Option<Arc<jamendody::Client>>` field, `jamendo_owns()` /
|
||||||
|
`jamendo_provider()` helpers, a `build()` block that reads `jamendo.toml`
|
||||||
|
(non-fatal — absent or `client_id`-less ⇒ `None`), a `get_lib_root` child gated
|
||||||
|
on `self.jamendo_client.is_some()`, and one routing arm in each dispatch method.
|
||||||
|
`crabidy-server` settings gain `jamendo` in `ALL_PROVIDERS` (8 → 9), in
|
||||||
|
`ProviderToggles`, in the defaults, and in `provider_toggles()`. No
|
||||||
|
`cli.rs` / `main.rs` change.
|
||||||
|
|
||||||
|
### D2 — HTTP behind a trait, faked in tests
|
||||||
|
|
||||||
|
All network access goes through one seam — a `Jam` trait
|
||||||
|
(`search_tracks`, `search_albums`, `album_tracks`, `track_detail`) behind
|
||||||
|
`Box<dyn Jam>` — with a `reqwest`-based `JamApi` for production and a `FakeApi`
|
||||||
|
in tests (as `absdy` hides `reqwest` behind `Abs`, `soundclouddy` behind `Sc`).
|
||||||
|
Provider logic (tree shaping, path parsing, term store) is unit-tested with zero
|
||||||
|
network. Errors map to `ProviderError::FetchError` at the boundary; malformed
|
||||||
|
paths → `MalformedPath`; empty create/rename → `InvalidInput`. Every call is
|
||||||
|
bounded by `call_timeout_secs` (D5, hard rule: timeouts on external calls).
|
||||||
|
|
||||||
|
### D3 — Tree shape (search → tracks + albums; canonical leaves)
|
||||||
|
|
||||||
|
Track and album ids are both numeric, so canonical paths are **type-tagged** to
|
||||||
|
disambiguate: `track/<id>` (leaf) and `album/<id>` (container). Browse nodes
|
||||||
|
point their children at these canonical paths, so playback and album expansion
|
||||||
|
never depend on the branch they were reached through.
|
||||||
|
|
||||||
|
- `/jamendo` — child: `search` (creatable). Not itself queueable.
|
||||||
|
- `/jamendo/search` — `is_creatable`; children are the in-memory search terms
|
||||||
|
(`RwLock<Vec<String>>`, dedup), each `is_editable` + `is_deletable`, exactly
|
||||||
|
like the tidal/youtube/soundcloud search stores.
|
||||||
|
- `/jamendo/search/<term>` — the results: matching **tracks** as queueable
|
||||||
|
leaves (pointing at `/jamendo/track/<id>`) and matching **albums** as
|
||||||
|
queueable containers (pointing at `/jamendo/album/<id>`).
|
||||||
|
- `/jamendo/album/<id>` — the album's tracks (queueable, downloadable); the
|
||||||
|
canonical container path.
|
||||||
|
- `/jamendo/track/<id>` — the canonical **track leaf**. A track id alone
|
||||||
|
resolves a stream, so every branch's track children point here and playback
|
||||||
|
needs no browse context.
|
||||||
|
|
||||||
|
Terms are percent-encoded into one segment (`encode_segment` / `decode_segment`,
|
||||||
|
shared helpers already used by the other search providers); numeric ids are
|
||||||
|
already URL-safe.
|
||||||
|
|
||||||
|
### D4 — Playback: direct MP3, no player change
|
||||||
|
|
||||||
|
`get_urls_for_track(/jamendo/track/<id>)`: `GET /tracks?id=<id>&audioformat=…`,
|
||||||
|
read `audio`, and **return it as `urls[0]`** (the player consumes only the
|
||||||
|
first). One API round-trip because the URL can embed a signed token; a track
|
||||||
|
with no `audio` (unstreamable) surfaces `NotStreamable`/`NotFound` and is
|
||||||
|
skipped, never a crash. The URL is a normal HTTP MP3 → the existing
|
||||||
|
`WindowedHttpStream` + symphonia MP3 decoder play it unchanged; `open_source`
|
||||||
|
routing is untouched. Track fields: `title` = `name`, `artist` = `artist_name`,
|
||||||
|
`album` = `Album { title: album_name }` when present else `None`,
|
||||||
|
`duration` = `duration` seconds → `Option<u32>` (filtered `> 0`),
|
||||||
|
`provider_item_id` = `"track:<id>"` (keys the capture store), and nodes/tracks
|
||||||
|
raise `is_downloadable` (backed by `audiodownload`).
|
||||||
|
|
||||||
|
### D5 — Auth and bounds
|
||||||
|
|
||||||
|
- `Settings`: `client_id: String` (**required** — without it the provider does
|
||||||
|
not mount), `audioformat: Option<String>` (default `mp32`),
|
||||||
|
`search_results: usize` (default 50, capped at the API's 200),
|
||||||
|
`album_tracks_limit: usize` (default 200), `call_timeout_secs: u64`
|
||||||
|
(default 30). Hand-written `Debug` redacts `client_id`.
|
||||||
|
- No scraping, no OAuth, no token refresh in v1 — the `client_id` is read once
|
||||||
|
from config and used on every call. An API `401`/`403` (revoked/invalid key)
|
||||||
|
maps to a typed `FetchError`; the subtree degrades, the app survives.
|
||||||
|
- Caps are `log`-ged when they truncate a listing, so truncation is visible,
|
||||||
|
not silent (hard rule: no silent caps). Listings are fetched fresh per call
|
||||||
|
(no cross-call cache), like the other remote providers; only search terms are
|
||||||
|
held in memory.
|
||||||
|
|
||||||
|
### D6 — Out of scope (explicitly)
|
||||||
|
|
||||||
|
- **OAuth user features**: personal favourites, a user's own playlists, and
|
||||||
|
writing to a Jamendo account. Additive later behind an optional token,
|
||||||
|
mirroring the soundcloud "login optional" branch.
|
||||||
|
- **Tag / genre / popular / radio browse** and **artist browse**: v1 is
|
||||||
|
search-driven (`search` → tracks + albums). Tag and popularity browse nodes
|
||||||
|
are a clean phase-2 add (same DTOs, new root children).
|
||||||
|
- **Pagination past the configured caps** (one page per listing).
|
||||||
|
- **Download-format negotiation** beyond the single `audioformat` setting.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
orch: ProviderOrchestrator
|
||||||
|
}
|
||||||
|
|
||||||
|
jam: "jamendody (crate)" {
|
||||||
|
client: "Client\n(ProviderClient)"
|
||||||
|
terms: "search terms\n(in-memory RwLock<Vec>)"
|
||||||
|
api: "JamApi\n(reqwest seam: Jam trait)"
|
||||||
|
client -> terms
|
||||||
|
client -> api
|
||||||
|
}
|
||||||
|
|
||||||
|
player: "audio-player" {
|
||||||
|
http: "WindowedHttpStream\n(existing, unchanged)"
|
||||||
|
dec: "rodio / symphonia (mp3)"
|
||||||
|
http -> dec: "mp3 bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
japi: "Jamendo api.jamendo.com/v3.0" { shape: cloud }
|
||||||
|
cdn: "mp3d.jamendo.com (MP3)" { shape: cloud }
|
||||||
|
|
||||||
|
server.orch -> jam.client: "/jamendo/..."
|
||||||
|
jam.api -> japi: "search / tracks / albums (JSON, client_id, timeout)"
|
||||||
|
server.orch -> player.http: "audio MP3 URL"
|
||||||
|
player.http -> cdn: "GET mp3 (range)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key flow: search and play a track
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
tui: TUI
|
||||||
|
orch: Orchestrator
|
||||||
|
j: jamendody
|
||||||
|
api: "Jamendo api-v3"
|
||||||
|
http: "WindowedHttpStream"
|
||||||
|
cdn: "mp3d.jamendo.com"
|
||||||
|
|
||||||
|
tui -> orch: "open /jamendo/search"
|
||||||
|
tui -> orch: "create term \"lofi piano\""
|
||||||
|
orch -> j: "create_lib_node(search, term)"
|
||||||
|
j -> tui: "term stored"
|
||||||
|
tui -> orch: "open /jamendo/search/<term>"
|
||||||
|
orch -> j: "get_lib_node"
|
||||||
|
j -> api: "GET /tracks?search=…&client_id="
|
||||||
|
api -> j: "tracks (id, name, artist, album, duration s, audio)"
|
||||||
|
j -> tui: "tracks as leaves (/jamendo/track/<id>) + albums"
|
||||||
|
tui -> orch: "queue + play a track"
|
||||||
|
orch -> j: "get_urls_for_track(/jamendo/track/<id>)"
|
||||||
|
j -> api: "GET /tracks?id=<id> → read audio URL"
|
||||||
|
j -> orch: "urls = [ audio ]"
|
||||||
|
orch -> http: "player.play(audio)"
|
||||||
|
http -> cdn: "GET mp3 (range) → symphonia decodes"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries / interfaces
|
||||||
|
|
||||||
|
- **Inbound**: `ProviderClient` (crabidy-core) — the orchestrator dispatches
|
||||||
|
`/jamendo/...` paths here. No new trait methods; search-term semantics reuse
|
||||||
|
`create_lib_node` / `rename_lib_node` / `delete_lib_node`.
|
||||||
|
- **Outbound**: the `Jam` trait (network seam) — the only place `reqwest` and
|
||||||
|
the `client_id` live. Everything above it is pure and unit-tested.
|
||||||
|
- **Config**: `jamendo.toml` (`client_id`, optional bounds), round-tripped via
|
||||||
|
`settings()`; wired through `crabidy-server` settings like every provider.
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **R1 — `audio` URL plays on the windowed-HTTP path.** The whole
|
||||||
|
"no player change" claim rests on the `audio` MP3 streaming cleanly (range
|
||||||
|
requests, clean EOS, correct duration from metadata). **Live-test gate**: play
|
||||||
|
a Jamendo track end-to-end and confirm no panic, correct seek bar, clean EOS.
|
||||||
|
If a signed URL turns out non-range or short-lived, the fallback is the same
|
||||||
|
as `/youtube`: resolve-just-before-play and treat a stale URL as a skipped
|
||||||
|
track.
|
||||||
|
- **R2 — `audioformat` availability.** `mp32` may not exist for every track;
|
||||||
|
decode defensively and fall back to whatever `audio` the listing returned
|
||||||
|
(the `audio` field already reflects the requested format or the default).
|
||||||
|
- **R3 — field / envelope drift.** DTOs decode defensively
|
||||||
|
(`#[serde(default)]`, ids as strings); a renamed field is a local fix in
|
||||||
|
`JamApi`. Live validation is a task-plan gate.
|
||||||
|
- **R4 — `client_id` validity at startup.** We do not verify the key at init
|
||||||
|
(no blocking network in `build()`); the first browse reveals a bad key as a
|
||||||
|
typed `FetchError`. Acceptable — matches how the other remote providers fail
|
||||||
|
lazily rather than at boot.
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
# The `orphans` provider — a store garbage-collection view
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
The content-addressed store (`architecture/crabidy-store.md`) never shrinks on
|
||||||
|
its own. Capturing writes audio + a `<name>.cbd-store.toml` sidecar into
|
||||||
|
`~/.local/share/crabidy/`; deleting a save (or the `current` queue rolling over,
|
||||||
|
or a `scan --capture` toml being removed) only ever removes the *toml that
|
||||||
|
pointed at* a store entry — never the entry itself (store `D7`). That is
|
||||||
|
deliberate: a store entry may be shared by many tomls, so no single deletion can
|
||||||
|
know it is safe to reclaim. The consequence, called out as future work in store
|
||||||
|
`D10`, is that store entries accumulate that **no toml references any more**.
|
||||||
|
There is today no way to see them or reclaim their disk.
|
||||||
|
|
||||||
|
This design adds a read-mostly management provider, **`/orphans`**, that surfaces
|
||||||
|
exactly those unreferenced store entries and lets the user rename or delete them,
|
||||||
|
or queue them for a listen before deciding. It is the store's garbage-collection
|
||||||
|
UI, expressed as an ordinary library subtree so it needs no new client concepts.
|
||||||
|
|
||||||
|
This **realizes** store `D10` (orphan reclamation). It changes nothing about how
|
||||||
|
captures are written or de-duplicated; it only *reads* the store's residue and
|
||||||
|
offers targeted rename/delete.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- **Confirmed by the request.** `/orphans` lists every store item, walks all
|
||||||
|
local file providers, crosses off referenced items, and presents the rest;
|
||||||
|
entries are renamable (audio file *and* sidecar), deletable (files on disk),
|
||||||
|
and queueable.
|
||||||
|
- **"Referenced" means reachable through a mounted local file provider.** The
|
||||||
|
reference scan walks the disk roots of the running file providers — the
|
||||||
|
`/crabidy` toml tree (`~/.local/state/crabidy/`, which holds `current`, every
|
||||||
|
save, every capture) and the `/fs` root (which can hold `Playable::Store`
|
||||||
|
tomls written by `scan --capture`/`--move`). A `.cbd-track.toml` that lives
|
||||||
|
*outside* every mounted provider root (e.g. `scan --capture` run on a folder
|
||||||
|
that is not under `/fs`) is invisible to the walk, so its target counts as an
|
||||||
|
orphan. This is the only sound definition available without a global
|
||||||
|
reference index, and it matches the request's wording ("walks all local file
|
||||||
|
providers"). It is a documented boundary, not a bug (see Risks).
|
||||||
|
- **Single writer.** As with the rest of the store, one process owns both roots
|
||||||
|
and serializes mutations under the store's index mutex; there is no concurrent
|
||||||
|
external writer.
|
||||||
|
- **Orphan-ness is recomputed on every listing.** There is no persisted orphan
|
||||||
|
list — consistent with `fsdy`'s "read the tree fresh every visit" philosophy.
|
||||||
|
A capture that adds a reference makes an entry stop being an orphan on the
|
||||||
|
next listing.
|
||||||
|
|
||||||
|
## What is a "store item" and when is it an orphan
|
||||||
|
|
||||||
|
- A **store item** is a pair in `store_root`: an audio file `<name>` and its
|
||||||
|
sidecar `<name>.cbd-store.toml`. The sidecar is the source of truth (store
|
||||||
|
`D2`); the set of items is the set of sidecars that have a readable audio file
|
||||||
|
beside them. (A sidecar without audio, or audio without a sidecar, is
|
||||||
|
malformed residue — reported so it can be reclaimed too; see D4.)
|
||||||
|
- A store item `<name>` is **referenced** iff some `.cbd-track.toml` under a
|
||||||
|
mounted file-provider root validates to `Playable::Store(<name>)`.
|
||||||
|
- **Orphans = all store items − referenced store items.**
|
||||||
|
|
||||||
|
Because `/orphans` only ever exposes unreferenced entries, renaming or deleting
|
||||||
|
one **cannot break any toml reference** — that is what makes the destructive
|
||||||
|
operations safe by construction (subject to the narrow race in Risks).
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
### Presentation: tracks vs. child nodes
|
||||||
|
|
||||||
|
Each orphan must be renamable, deletable, and queueable. The wire has two
|
||||||
|
carriers with capability flags:
|
||||||
|
|
||||||
|
- **`Track`** — has `is_captured`/`is_skipped` but **no** `is_editable`. Tracks
|
||||||
|
can be deleted (`tracks_deletable`) and queued, but the library has **no
|
||||||
|
rename-a-track gesture** anywhere; adding one means new proto surface plus TUI
|
||||||
|
and web changes.
|
||||||
|
- **`LibraryNodeChild`** — already carries `is_editable`, `is_deletable`,
|
||||||
|
`is_queable`, `is_captured`. The node-editing feature already binds `e` →
|
||||||
|
`rename_lib_node(child_path, new_title)` and `d` → `delete_lib_node(child_path)`
|
||||||
|
for children that advertise the flags (that is how `/tidal/search` terms and
|
||||||
|
`/crabidy` saves are renamed/deleted today), and queueing a queueable child
|
||||||
|
resolves its tracks.
|
||||||
|
|
||||||
|
**Decision: present each orphan as an editable + deletable + queueable child
|
||||||
|
node** of `/orphans`, titled by its store file `<name>` (the thing a rename
|
||||||
|
edits). Entering the node lists its single track (the store audio, with metadata
|
||||||
|
from the sidecar); queueing the node — or the whole `/orphans` root — resolves
|
||||||
|
that track. This reuses `rename_lib_node`/`delete_lib_node`/`resolve_tracks_into`
|
||||||
|
and every client gesture **with zero proto, TUI, or web changes**. The only
|
||||||
|
minor wart — each orphan is a one-track "folder" — is acceptable and is exactly
|
||||||
|
how a single-track save already presents. The track-carrier option was rejected
|
||||||
|
purely on cost: it buys nothing the node model lacks and forces a wire change
|
||||||
|
just to gain a rename gesture.
|
||||||
|
|
||||||
|
### Home of the logic: new crate vs. server module
|
||||||
|
|
||||||
|
The orphan computation needs the store root and index (to enumerate items and
|
||||||
|
mutate them) *and* the file-provider disk roots (to find references). A standalone
|
||||||
|
`orphandy` crate would have to duplicate `CrabidyStore` internals it does not own.
|
||||||
|
|
||||||
|
**Decision: keep it in `crabidy-server`.** Orphan enumeration, rename, and delete
|
||||||
|
become methods on `CrabidyStore` (it already owns `store_root` and the index). A
|
||||||
|
thin new `OrphansProvider` (`crabidy-server/src/orphans.rs`) implements
|
||||||
|
`ProviderClient`, holding `Arc<CrabidyStore>` plus the list of reference roots to
|
||||||
|
walk, and delegates to those store methods. `ProviderOrchestrator` mounts and
|
||||||
|
routes `/orphans` exactly like the other providers.
|
||||||
|
|
||||||
|
## Boundaries and interfaces
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
tui: TUI / web / cbd-cli { shape: person }
|
||||||
|
|
||||||
|
orchestrator: ProviderOrchestrator {
|
||||||
|
routes: "routes /orphans/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
orphans: OrphansProvider {
|
||||||
|
refroots: "ref_roots: Vec<PathBuf>"
|
||||||
|
}
|
||||||
|
|
||||||
|
store: CrabidyStore {
|
||||||
|
index: "StoreIndex (by_hash / by_provider_id)"
|
||||||
|
ops: "list_orphans / rename_orphan / delete_orphan"
|
||||||
|
}
|
||||||
|
|
||||||
|
data: content store\n~/.local/share/crabidy {
|
||||||
|
shape: cylinder
|
||||||
|
items: "<name> + <name>.cbd-store.toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
crabidytree: /crabidy tree\n~/.local/state/crabidy { shape: cylinder }
|
||||||
|
fstree: /fs root { shape: cylinder }
|
||||||
|
|
||||||
|
tui -> orchestrator: "get / rename / delete / queue /orphans/*"
|
||||||
|
orchestrator -> orphans: delegate
|
||||||
|
orphans -> store: "enumerate + mutate (by name)"
|
||||||
|
store -> data: "read sidecars, rename/delete files"
|
||||||
|
orphans -> crabidytree: "walk for Playable::Store refs"
|
||||||
|
orphans -> fstree: "walk for Playable::Store refs"
|
||||||
|
```
|
||||||
|
|
||||||
|
### The orphan diff (what a listing computes)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: down
|
||||||
|
|
||||||
|
allitems: "all store items\n(scan *.cbd-store.toml in store_root)"
|
||||||
|
refs: "referenced set\n(walk ref_roots for\nPlayable::Store(name))"
|
||||||
|
diff: "orphans = all − referenced" { shape: diamond }
|
||||||
|
node: "/orphans node:\none editable/deletable/queueable\nchild per orphan"
|
||||||
|
|
||||||
|
allitems -> diff
|
||||||
|
refs -> diff
|
||||||
|
diff -> node
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider surface (`OrphansProvider: ProviderClient`)
|
||||||
|
|
||||||
|
Mounted at `/orphans` only when `crabidy_store` is present (it is the store's
|
||||||
|
view). Paths: the root `/orphans`, and one child per orphan at
|
||||||
|
`/orphans/<encode_segment(name)>`. There are no deeper levels.
|
||||||
|
|
||||||
|
- `get_lib_root` / `get_lib_node("/orphans")` — a queueable, non-creatable node
|
||||||
|
whose children are the current orphans (recomputed by the diff above). Each
|
||||||
|
child: `title = <name>`, `is_queable = true`, `is_editable = true`,
|
||||||
|
`is_deletable = true`, `is_downloadable = false`, `is_captured = true`.
|
||||||
|
- `get_lib_node("/orphans/<seg>")` — a queueable, childless node carrying the
|
||||||
|
single `Track` for that store entry (metadata from the sidecar's first
|
||||||
|
provider entry; `is_captured = true`). Unknown/renamed-away segment →
|
||||||
|
`MalformedPath`.
|
||||||
|
- `is_track_path` — always `false`: orphans are addressed as nodes, and the one
|
||||||
|
track is reached by resolving the node (so the default `resolve_tracks_into`
|
||||||
|
walk queues it). `get_metadata_for_track` therefore is not the entry point;
|
||||||
|
`get_urls_for_track("/orphans/<seg>")` returns `store_root/<name>` (a local
|
||||||
|
file path, exactly like a resolved `Playable::Store`) so the resolved track
|
||||||
|
still plays.
|
||||||
|
- `rename_lib_node("/orphans/<seg>", new)` — validates `new` as a bare store
|
||||||
|
file name (`validate_folder_name(new, &[])`: non-empty, no separators/NUL, no
|
||||||
|
leading dot), refuses a name already taken by another store entry
|
||||||
|
(`InvalidInput`), then renames **both** `<old>`→`<new>` audio and
|
||||||
|
`<old>.cbd-store.toml`→`<new>.cbd-store.toml`, and updates the in-memory index
|
||||||
|
(drop the old name's mappings, re-insert under the new name; hash and provider
|
||||||
|
ids are unchanged). Returns the renamed node at `/orphans/<encode(new)>`.
|
||||||
|
- `delete_lib_node("/orphans/<seg>")` — removes the audio file and the sidecar
|
||||||
|
from `store_root` and drops the entry from the index; returns the refreshed
|
||||||
|
`/orphans` root. Idempotent (an already-gone entry succeeds).
|
||||||
|
- `create_lib_node` — `NotSupported` (the root is not creatable).
|
||||||
|
|
||||||
|
### Store methods added to `CrabidyStore`
|
||||||
|
|
||||||
|
- `list_orphans(&self, ref_roots: &[PathBuf]) -> Result<Vec<OrphanEntry>, StoreError>`
|
||||||
|
— scan `store_root` for `*.cbd-store.toml`; build the referenced set by walking
|
||||||
|
each `ref_root` recursively for `*.cbd-track.toml` and collecting
|
||||||
|
`Playable::Store(name)`; return the difference as `OrphanEntry { name, title,
|
||||||
|
artist, duration, album }` (metadata from the sidecar's first provider entry).
|
||||||
|
- `orphan_track(&self, name) -> Result<Track, StoreError>` /
|
||||||
|
`orphan_url(&self, name) -> Result<String, StoreError>` — build the wire track
|
||||||
|
/ resolve the store audio path for a single entry.
|
||||||
|
- `rename_orphan(&self, old, new)` / `delete_orphan(&self, name)` — the mutations
|
||||||
|
above, under the index mutex, with the index kept in sync.
|
||||||
|
- `StoreIndex::remove(&mut self, name, sidecar)` — the inverse of `insert`, so
|
||||||
|
rename/delete can update the derived index without a full rescan.
|
||||||
|
|
||||||
|
### Reference roots wiring
|
||||||
|
|
||||||
|
`OrphansProvider` is constructed in `ProviderOrchestrator::init` with
|
||||||
|
`ref_roots` = the disk roots of the mounted file providers: the `/crabidy` tree
|
||||||
|
(`store.tree_dir()`) and, when enabled, the `/fs` root. A new
|
||||||
|
`fsdy::Client::disk_root(&self) -> &Path` accessor exposes the `/fs` root (the
|
||||||
|
`/crabidy` tree root is already available via `CrabidyStore::tree_dir`). If more
|
||||||
|
`fsdy` instances are ever mounted, they are added to this list — the definition
|
||||||
|
of "local file provider" is "an `fsdy` instance whose root can hold store
|
||||||
|
references."
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **Capture-then-delete race (TOCTOU).** Between a `/orphans` listing and a
|
||||||
|
delete, a concurrent `W` capture could hash-hit the very entry the user is
|
||||||
|
about to delete and write a fresh `Playable::Store` reference to it; deleting
|
||||||
|
then leaves that new toml dangling. The window is small (store mutations
|
||||||
|
serialize under the index mutex and orphan-ness is recomputed every listing),
|
||||||
|
and the failure is benign: a dangling store reference already resolves to
|
||||||
|
`MalformedPath` at play time and is skipped, not a crash. Accepted; noted here
|
||||||
|
rather than engineered away.
|
||||||
|
- **References outside mounted roots are not counted.** As stated in Assumptions,
|
||||||
|
a `scan --capture` toml under a folder that is not mounted under `/fs` will not
|
||||||
|
be seen, so its target shows as an orphan. Deleting it would orphan that
|
||||||
|
toml's audio. The mitigation is scope discipline (scan under `/fs`); the
|
||||||
|
alternative — a persisted global reference index — is out of scope and would
|
||||||
|
fight the "read fresh" design.
|
||||||
|
- **Cost.** A `/orphans` listing scans the whole store plus both trees on every
|
||||||
|
visit (no cache), i.e. O(store entries + tomls under the roots). This matches
|
||||||
|
`fsdy`'s existing per-visit read cost and is fine for personal-library sizes;
|
||||||
|
if it ever bites, memoizing behind the index's mutation counter is the escape
|
||||||
|
hatch. Not premature-optimized here.
|
||||||
|
- **Malformed residue.** A sidecar with no audio (or vice versa) is itself
|
||||||
|
reclaimable junk. `list_orphans` reports such half-entries as orphans (titled
|
||||||
|
by whatever is present) so a delete cleans them up; it never treats a
|
||||||
|
half-entry as "referenced."
|
||||||
|
- **Open question:** should the `/orphans` root also expose a single "delete all"
|
||||||
|
affordance? Deferred — per-item delete covers the request; bulk reclaim can be
|
||||||
|
a later addition (a client could multi-select and delete, once marks exist
|
||||||
|
there).
|
||||||
|
|
@ -130,7 +130,8 @@ Options considered:
|
||||||
Other provider commands keep flowing while (possibly several) resolves run.
|
Other provider commands keep flowing while (possibly several) resolves run.
|
||||||
- **Playback side**: each queue op registers a *pending op* (id from an
|
- **Playback side**: each queue op registers a *pending op* (id from an
|
||||||
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
|
`AtomicU64`, kind, insertion cursor) and spawns a forwarder task that
|
||||||
drives `ProviderCommand::ResolveTracks` per path (sequentially, preserving
|
drives `ProviderCommand::ResolveTracks` per path (concurrently, under the
|
||||||
|
exponential read-ahead window of D8, but forwarding chunks in strict
|
||||||
multi-path order) and forwards each chunk to the playback channel as
|
multi-path order) and forwards each chunk to the playback channel as
|
||||||
`PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by
|
`PlaybackCommand::ApplyResolvedChunk { op_id, tracks }`, followed by
|
||||||
`ResolveFinished { op_id }`. Queue state is only ever mutated inside the
|
`ResolveFinished { op_id }`. Queue state is only ever mutated inside the
|
||||||
|
|
@ -155,7 +156,21 @@ Each pending op keeps an insertion cursor:
|
||||||
Playback start reuses the existing `Option<Track>` returns from the
|
Playback start reuses the existing `Option<Track>` returns from the
|
||||||
`QueueManager` mutations — only a chunk that makes a track current (replace,
|
`QueueManager` mutations — only a chunk that makes a track current (replace,
|
||||||
or any insert into an empty queue) yields one, so exactly the first relevant
|
or any insert into an empty queue) yields one, so exactly the first relevant
|
||||||
chunk starts the player and later chunks never restart it.
|
chunk starts the player and later chunks never restart a playing track.
|
||||||
|
|
||||||
|
That first start can *fail to find anything playable yet*: if the head
|
||||||
|
tracks are `is_skipped` or the collection's first track is very short, the
|
||||||
|
player can run dry before the next chunk resolves — and then, since later
|
||||||
|
chunks return `None`, playback would stay stopped with playable tracks
|
||||||
|
arriving right behind it. So the op carries a `wants_start` flag: set when a
|
||||||
|
chunk makes a track current, cleared only once a start is *confirmed*
|
||||||
|
(`play` returned that it handed a track to the player). While it is set, each
|
||||||
|
arriving chunk retries the start from the current position — `next_playable`
|
||||||
|
advances past skipped/unplayable heads to the first track that has now
|
||||||
|
resolved. The exponential read-ahead (D8) makes that next track arrive
|
||||||
|
sooner; the retry makes sure it actually plays when it does. An op whose
|
||||||
|
whole resolve finishes with nothing playable is dropped by `finish_resolve`
|
||||||
|
and the player simply stays stopped.
|
||||||
|
|
||||||
Interleaved edits from other clients during a resolve can shift the cursor's
|
Interleaved edits from other clients during a resolve can shift the cursor's
|
||||||
target (e.g. removing tracks before it). This is accepted as benign:
|
target (e.g. removing tracks before it). This is accepted as benign:
|
||||||
|
|
@ -193,6 +208,41 @@ appended at render time only and never enters `self.list`, so selection,
|
||||||
removal, and `get_size` cannot reach it — no new input states, nothing to
|
removal, and `get_size` cannot reach it — no new input states, nothing to
|
||||||
misclick.
|
misclick.
|
||||||
|
|
||||||
|
### D8 — Exponential read-ahead across paths
|
||||||
|
|
||||||
|
The forwarder of D4 first resolved paths one at a time. That starts the first
|
||||||
|
track quickly (good) but fills the rest only as fast as one provider resolve
|
||||||
|
at a time. When enumeration is slow (per-path Tidal/YouTube/fyyd round trips)
|
||||||
|
and the leading tracks are very short or `is_skipped`, playback drains the
|
||||||
|
resolved queue faster than a sequential resolver refills it — and stalls into
|
||||||
|
silence, the exact thing progressive queueing exists to avoid.
|
||||||
|
|
||||||
|
Options considered:
|
||||||
|
|
||||||
|
1. **Sequential per path** (original): simplest, but a short/skipped head can
|
||||||
|
outrun a slow resolver.
|
||||||
|
2. **Resolve every path at once**: fills fastest, but a large marked
|
||||||
|
selection fires an unbounded burst of concurrent provider calls (rate
|
||||||
|
limits, memory) the instant playback starts — wasteful when the user skips
|
||||||
|
away after two tracks.
|
||||||
|
3. **Exponential read-ahead window**: a concurrency window that starts at 1
|
||||||
|
and doubles (1, 2, 4, 8, 16, then steady 16) after each path completes.
|
||||||
|
|
||||||
|
**Decision: (3).** The first path resolves alone, so time-to-first-track is
|
||||||
|
unchanged from (1); the window then grows geometrically, so the resolved
|
||||||
|
queue runs exponentially ahead of linear playback and a short/skipped head
|
||||||
|
cannot catch it after the first couple of tracks. The cap (16) bounds
|
||||||
|
concurrent provider load. Chunks are still forwarded in strict path order —
|
||||||
|
the forwarder fully drains the oldest in-flight resolve before the next, so
|
||||||
|
concurrency never reorders the queue (D5's per-op cursor and "first chunk
|
||||||
|
starts the player" are untouched). Cancellation (D6) drops the in-flight
|
||||||
|
receivers, stopping every concurrent resolve at once.
|
||||||
|
|
||||||
|
This is a read-ahead over *paths*. A single collection path (one album or
|
||||||
|
playlist) is still enumerated by its provider's `resolve_tracks_into` — the
|
||||||
|
per-page streaming of D3 is that path's read-ahead — so the window is the win
|
||||||
|
for multi-item selections; single-collection latency stays a provider concern.
|
||||||
|
|
||||||
## Flows
|
## Flows
|
||||||
|
|
||||||
```d2
|
```d2
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,26 @@ Three roles, credentialed by password hashes in the server config:
|
||||||
acceptable on a trusted home network; anything else (Internet
|
acceptable on a trusted home network; anything else (Internet
|
||||||
exposure) needs TLS termination in front (reverse proxy, VPN) and is
|
exposure) needs TLS termination in front (reverse proxy, VPN) and is
|
||||||
out of scope. The README says so.
|
out of scope. The README says so.
|
||||||
- No auth configured (no `[auth]` section, or no hashes in it) means
|
- **Anonymous callers inherit the highest *unguarded* role.** A request
|
||||||
the server behaves exactly as before: open, everyone is owner. Auth
|
with no credentials is not rejected outright; it is granted the most
|
||||||
switches on as soon as **any** role hash is configured; from then on
|
privileged role whose password is *not* set. So the config guards
|
||||||
every RPC requires credentials.
|
from the top down and each password lowers what anonymous users can
|
||||||
|
do:
|
||||||
|
- nothing guarded → anonymous is **owner** (today's open server);
|
||||||
|
- `owner` guarded → anonymous is **queue-owner**;
|
||||||
|
- `owner` + `queue_owner` guarded → anonymous is **queue-appender**;
|
||||||
|
- all three guarded → anonymous can do **nothing** (`UNAUTHENTICATED`).
|
||||||
|
A credential still elevates a caller to its role; the anonymous role
|
||||||
|
is only the floor. A *present-but-wrong* credential is denied, never
|
||||||
|
silently downgraded to the anonymous role.
|
||||||
|
- **Guarding order is enforced.** Because anonymous callers get the
|
||||||
|
highest unguarded role, guarding a lower role while a higher one is
|
||||||
|
open is meaningless — the anonymous role would still outrank it. The
|
||||||
|
valid guarded sets are therefore prefixes of `[owner, queue_owner,
|
||||||
|
queue_appender]`. A config that sets `queue_owner` without `owner`
|
||||||
|
(or `queue_appender` without `queue_owner`) is **broken and aborts
|
||||||
|
startup**, fail-closed; `crabidy-server guard` likewise refuses to
|
||||||
|
write such a config.
|
||||||
- One password per role, not per person. The Basic-auth *username*
|
- One password per role, not per person. The Basic-auth *username*
|
||||||
selects the role (`owner`, `queue-owner`, `queue-appender`), the
|
selects the role (`owner`, `queue-owner`, `queue-appender`), the
|
||||||
password is verified against that role's hash.
|
password is verified against that role's hash.
|
||||||
|
|
@ -88,9 +104,10 @@ Minimum role per RPC; higher roles include lower ones
|
||||||
layer, so renames/deletes stay owner-only, fail-closed).
|
layer, so renames/deletes stay owner-only, fail-closed).
|
||||||
- **unknown / future methods**: owner only.
|
- **unknown / future methods**: owner only.
|
||||||
|
|
||||||
Denied requests get `PERMISSION_DENIED`; missing or wrong credentials
|
A caller whose role (anonymous or credentialed) is below the method's
|
||||||
get `UNAUTHENTICATED`. Credentials are never logged (hard rule:
|
minimum gets `PERMISSION_DENIED`; a present-but-wrong credential, and
|
||||||
secrets redacted).
|
an anonymous request to a fully-locked server, get `UNAUTHENTICATED`.
|
||||||
|
Credentials are never logged (hard rule: secrets redacted).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -99,8 +116,12 @@ both standalone and inside `cbd`; absent file = auth off):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[auth]
|
[auth]
|
||||||
# One PHC hash per role; omit a role to disable it.
|
# One PHC hash per role. Guard from the top down: setting a role's hash
|
||||||
# Generate with: crabidy-server hash-password
|
# lowers what an anonymous (no-credential) caller may do to the next
|
||||||
|
# role below. Omitting a role leaves it open, so anonymous callers get
|
||||||
|
# the highest omitted role. Setting a lower role without the higher one
|
||||||
|
# (e.g. queue_owner without owner) is rejected at startup.
|
||||||
|
# Generate with: crabidy-server guard <role>
|
||||||
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
queue_owner = "$argon2id$v=19$..."
|
queue_owner = "$argon2id$v=19$..."
|
||||||
queue_appender = "$argon2id$v=19$..."
|
queue_appender = "$argon2id$v=19$..."
|
||||||
|
|
@ -121,6 +142,18 @@ attaches `authorization: Basic …` to every request through a tonic
|
||||||
interceptor; without configured credentials it sends no header, which
|
interceptor; without configured credentials it sends no header, which
|
||||||
keeps today's zero-config local setup working against an open server.
|
keeps today's zero-config local setup working against an open server.
|
||||||
|
|
||||||
|
The web client stores its credentials in `localStorage` and attaches
|
||||||
|
them the same way. Because an anonymous browser silently connects as the
|
||||||
|
fallback role, it would otherwise never learn a login is possible, so
|
||||||
|
the `Init` response (reachable anonymously) carries an `auth_enabled`
|
||||||
|
flag: on first connect with no stored credentials against an
|
||||||
|
auth-enabled server, the client raises a login dialog. That dialog is
|
||||||
|
**dismissible** — "continue as guest" keeps the fallback role — so the
|
||||||
|
zero-typing browse path is preserved. When the server denies anonymous
|
||||||
|
access outright (all roles guarded → `UNAUTHENTICATED`), the same dialog
|
||||||
|
appears without the guest option, because credentials are then the only
|
||||||
|
way in.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```d2
|
```d2
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
# soundcloud provider (streaming music)
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
A new library provider mounted at `/soundcloud` that lets a user **search,
|
||||||
|
resolve share links, and play** SoundCloud tracks and playlists — and, **when
|
||||||
|
they opt in with a token, browse their own likes and playlists**.
|
||||||
|
|
||||||
|
- Like fyyd/tidal/youtube, SoundCloud is a **remote, search-driven** service.
|
||||||
|
Unlike them, SoundCloud offers **no official public API**: the modern
|
||||||
|
`api-v2.soundcloud.com` requires a `client_id` that SoundCloud embeds in its
|
||||||
|
web app and **rotates periodically**, and personal-account access needs an
|
||||||
|
**OAuth token**. So the provider must (a) obtain a `client_id` on its own and
|
||||||
|
survive rotation, and (b) treat login as **optional** — public browse + play
|
||||||
|
works with only a `client_id`; a token merely adds personal nodes.
|
||||||
|
- Content is organized as **tracks** and **playlists** (a playlist is a
|
||||||
|
container of tracks). There is no per-track container level like abs books —
|
||||||
|
the tree is `search-term → tracks`, `playlist → tracks`, and (logged in)
|
||||||
|
`likes → tracks` / `playlists → playlist → tracks`, plus a **resolve** entry
|
||||||
|
that turns a pasted permalink URL into a track or playlist.
|
||||||
|
- **Playing** a track is the biggest divergence from every existing provider.
|
||||||
|
SoundCloud does not serve a plain file URL: each track carries a set of
|
||||||
|
`media.transcodings`, and the playable ones are **HLS** — an `.m3u8` playlist
|
||||||
|
of short **mp3 segments**. crabidy's player streams a single byte source, so
|
||||||
|
this requires a new **HLS source** in `audio-player` that fetches the playlist
|
||||||
|
and streams the mp3 segments in order as one continuous mp3 (mp3 frames
|
||||||
|
byte-concatenate into a valid stream — the same fact `ffmpeg -c copy` relies
|
||||||
|
on). rodio's existing symphonia mp3 path then decodes it, unchanged.
|
||||||
|
- **Captures** (`W`, download) come for free once nodes serve tracks and raise
|
||||||
|
`is_downloadable`, exactly as for abs/fyyd — no wire or TUI work.
|
||||||
|
|
||||||
|
## The SoundCloud API (grounding)
|
||||||
|
|
||||||
|
Base `https://api-v2.soundcloud.com`. **Every** request carries `?client_id`
|
||||||
|
(plus `app_version`, `app_locale=en`), omitted from the cells below; personal
|
||||||
|
calls also send `Authorization: OAuth <token>`. `search/*` add
|
||||||
|
`&limit=<n>&offset=<o>&linked_partitioning=1`. Endpoints we use:
|
||||||
|
|
||||||
|
| Purpose | Endpoint |
|
||||||
|
| --- | --- |
|
||||||
|
| Resolve a permalink URL | `GET /resolve?url=<permalink>` |
|
||||||
|
| Search tracks | `GET /search/tracks?q=<t>` |
|
||||||
|
| Search playlists | `GET /search/playlists?q=<t>` |
|
||||||
|
| Track detail | `GET /tracks/<id>` |
|
||||||
|
| Playlist detail | `GET /playlists/<id>` |
|
||||||
|
| Transcoding → media URL | `GET <transcoding.url>` → `{"url": "<m3u8>"}` |
|
||||||
|
| (login) My likes | `GET /me/likes/tracks` (OAuth) |
|
||||||
|
| (login) My playlists | `GET /me/playlists` (OAuth) |
|
||||||
|
|
||||||
|
Objects (fields we read):
|
||||||
|
|
||||||
|
- **track**: `id` (numeric), `title`, `user.username` (→ artist), `duration`
|
||||||
|
(ms), `permalink_url`, `media.transcodings[]`, `policy`/`streamable`,
|
||||||
|
`publisher_metadata` (optional album/release).
|
||||||
|
- **transcoding**: `url` (a second API URL, not the CDN), `preset`,
|
||||||
|
`format.{protocol, mime_type}`, `quality`. We select
|
||||||
|
`protocol == "hls" && mime_type == "audio/mpeg"` (mp3-HLS), which SoundCloud
|
||||||
|
offers for essentially every playable track.
|
||||||
|
- **playlist**: `id`, `title`, `user.username`, `tracks[]` — often returned as
|
||||||
|
**stubs** (`{id}` only); missing tracks are hydrated in batches of ≤50 via
|
||||||
|
`GET /tracks?ids=<csv>&client_id=…`.
|
||||||
|
- **resolve**: returns a track or a playlist object (discriminated by `kind`).
|
||||||
|
|
||||||
|
`client_id` acquisition (no login): `GET https://soundcloud.com`, find the
|
||||||
|
referenced JS bundles, fetch them, regex `client_id:"(\w+)"`; `app_version`
|
||||||
|
from `window.__sc_version="(\d+)"`. This is exactly the streamrip approach.
|
||||||
|
|
||||||
|
## Assumptions (decided here)
|
||||||
|
|
||||||
|
- The captures/creatable/editable/deletable TUI flows are provider-agnostic
|
||||||
|
(confirmed by `/youtube`, `/fyyd`, `/abs`): search-term + resolve semantics
|
||||||
|
cost no TUI or wire change. No proto change, no new `ProviderCommand`.
|
||||||
|
- A missing/rotated/invalid `client_id` must never crash startup or a browse.
|
||||||
|
The provider **self-heals** by scraping and by re-scraping on `401/403`; only
|
||||||
|
if scraping itself fails does the `/soundcloud` subtree degrade (typed
|
||||||
|
errors, skipped tracks), never the app.
|
||||||
|
- **Login is optional.** With no `oauth_token`, personal nodes (`likes`,
|
||||||
|
`playlists`) are simply **not shown**; public search/resolve/play still work.
|
||||||
|
A token unlocks the personal nodes and is refreshed/persisted like tidal's.
|
||||||
|
- HLS media/segment URLs and the `client_id`/`oauth_token` are **secrets or
|
||||||
|
ephemeral signed URLs**: redact from `Debug`/config dumps, never log the built
|
||||||
|
stream/segment URLs (hard rule: redact secrets from logs and error reports).
|
||||||
|
- SoundCloud `id`s are numeric (URL-safe); only user-typed **search terms** and
|
||||||
|
**pasted URLs** are percent-encoded into a path segment.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Crate `soundclouddy`, mounted at `/soundcloud`, non-fatal init
|
||||||
|
|
||||||
|
New workspace crate `soundclouddy` implementing `ProviderClient`, shaped on
|
||||||
|
`fyyd`/`absdy` (remote, search-driven, plain leaf tracks). Wired into
|
||||||
|
`ProviderOrchestrator` with a `sc_client: Option<Arc<soundclouddy::Client>>`
|
||||||
|
field, `sc_owns()`/`sc_provider()` helpers, a `build()` block that reads
|
||||||
|
`soundcloud.toml` (non-fatal), a `get_lib_root` child gated on
|
||||||
|
`self.sc_client.is_some()`, and one routing arm in each dispatch method.
|
||||||
|
`crabidy-server` settings gain `soundcloud` in `ALL_PROVIDERS` (now 8), in
|
||||||
|
`ProviderToggles`, in `all()`, and in `provider_toggles()`. No `cli.rs`/
|
||||||
|
`main.rs` change (providers are pure path-prefix subtrees).
|
||||||
|
|
||||||
|
### D2 — HTTP behind a trait, faked in tests; client_id lifecycle inside the seam
|
||||||
|
|
||||||
|
All network access goes through one seam — an `Sc` trait (`resolve`,
|
||||||
|
`search_tracks`, `search_playlists`, `track_detail`, `playlist_detail`,
|
||||||
|
`hydrate_tracks`, `resolve_stream_url`, and, when logged in, `my_likes`,
|
||||||
|
`my_playlists`) behind `Box<dyn Sc>` — with a `reqwest`-based `ScApi` for
|
||||||
|
production and a `FakeApi` in tests (as `absdy` hides `reqwest` behind `Abs`).
|
||||||
|
The **client_id acquisition, caching, and re-scrape-on-401** live entirely
|
||||||
|
inside `ScApi` so provider logic (tree shaping, path parsing, term store) is
|
||||||
|
unit-tested with zero network. Errors map to `ProviderError::FetchError` at the
|
||||||
|
boundary; malformed paths → `MalformedPath`; empty create/rename → `InvalidInput`.
|
||||||
|
|
||||||
|
### D3 — Tree shape (search, resolve, optional personal)
|
||||||
|
|
||||||
|
Track ids and playlist ids are both numeric, so leaf/container paths use a
|
||||||
|
**type-tagged** canonical segment to disambiguate: `track/<id>` and
|
||||||
|
`playlist/<id>`. Browse nodes point their children at these canonical paths.
|
||||||
|
|
||||||
|
- `/soundcloud` — children: `search` (creatable), `resolve` (creatable), and —
|
||||||
|
**only if logged in** — `likes` and `playlists`. Not itself queueable.
|
||||||
|
- `/soundcloud/search` / `/soundcloud/resolve` — `is_creatable`; children are
|
||||||
|
the in-memory terms/URLs (`RwLock<Vec<String>>`, dedup), each editable and
|
||||||
|
deletable, like tidal/youtube/fyyd/abs search terms.
|
||||||
|
- `/soundcloud/search/<term>` — matching **tracks** as queueable leaves (and,
|
||||||
|
optionally, matching playlists as containers).
|
||||||
|
- `/soundcloud/resolve/<url>` — the resolved permalink: a single track leaf, or
|
||||||
|
a playlist container.
|
||||||
|
- `/soundcloud/likes` (login) — the user's liked **tracks**.
|
||||||
|
- `/soundcloud/playlists` (login) — the user's playlists as containers.
|
||||||
|
- `/soundcloud/playlist/<id>` — a playlist's tracks (queueable, downloadable);
|
||||||
|
the canonical container path, reached from search/resolve/likes/playlists.
|
||||||
|
- `/soundcloud/track/<id>` — the canonical **track leaf**. A track id alone is
|
||||||
|
sufficient to resolve a stream, so every branch's track children point here
|
||||||
|
and playback needs no browse context.
|
||||||
|
|
||||||
|
Terms/URLs are percent-encoded into one segment (`encode_segment`/
|
||||||
|
`decode_segment`); numeric ids are already URL-safe.
|
||||||
|
|
||||||
|
### D4 — Playback: progressive mp3 (HLS source retained as fallback)
|
||||||
|
|
||||||
|
**Revised after live testing (2026-07-24).** The original plan chose HLS mp3,
|
||||||
|
but live probing showed SoundCloud's plain `hls + audio/mpeg` transcoding
|
||||||
|
exchange **404s for anonymous clients** (every track, streamable or not), while
|
||||||
|
the **`progressive + audio/mpeg`** transcoding returns 200 with a direct,
|
||||||
|
range-streamable mp3 URL (`cf-media.sndcdn.com`, `206`, `audio/mpeg`). So the
|
||||||
|
provider now **prefers progressive**, which the player streams on its normal
|
||||||
|
windowed-HTTP path — no HLS needed for the common case. The `HlsStream` built
|
||||||
|
for the original plan is kept as a fallback for any track that offers only HLS.
|
||||||
|
|
||||||
|
- `get_urls_for_track(/soundcloud/track/<id>)`: `GET /tracks/<id>`, pick the
|
||||||
|
best mp3 transcoding (`progressive` first, then `hls`), `GET
|
||||||
|
<transcoding.url>?client_id=…` → the media URL, and **return it as `urls[0]`**
|
||||||
|
(the player consumes only the first). One API round-trip — unlike abs's pure
|
||||||
|
string-building — because the media URL is signed and ephemeral. A track
|
||||||
|
whose exchange 404s (Go+/label preview, geo-blocked) surfaces
|
||||||
|
`NotStreamable`/`NotFound` and is skipped, never a crash.
|
||||||
|
- **New `audio-player` component `HlsStream`** — a `stream-download`
|
||||||
|
`SourceStream`, sibling to `WindowedHttpStream`: on create it fetches the
|
||||||
|
`.m3u8` (a media playlist), parses `#EXTINF`/segment URIs (resolving relative
|
||||||
|
URIs against the playlist URL, and following one level if handed a master
|
||||||
|
playlist); on poll it streams each mp3 segment's bytes in order, advancing at
|
||||||
|
segment boundaries, finishing after the last. The concatenated bytes are a
|
||||||
|
valid mp3 → rodio's symphonia mp3 decoder handles them unchanged.
|
||||||
|
- **Routing**: `open_source` selects `HlsStream` when the URL path ends in
|
||||||
|
`.m3u8` (SoundCloud's media URLs carry it); all other http URLs keep the
|
||||||
|
windowed-HTTP path, and content-sniffing (opus vs the rest) is unchanged
|
||||||
|
downstream. `#EXTM3U` content-sniff is a hardening fallback if needed.
|
||||||
|
- **Duration** comes from the track metadata (`duration` ms → `Track.duration`),
|
||||||
|
not from the stream, so the seek bar is correct even though the concatenated
|
||||||
|
HLS stream carries no container duration.
|
||||||
|
- Track fields: `title` = track title, `artist` = `user.username`, `album` from
|
||||||
|
`publisher_metadata` when present else `None`, `duration` = ms→`Option<u32>`,
|
||||||
|
`provider_item_id` = `"track:<id>"` (keys the capture store).
|
||||||
|
|
||||||
|
### D5 — Auth: client_id self-heal, optional OAuth login
|
||||||
|
|
||||||
|
- `Settings`: `client_id: Option<String>`, `app_version: Option<String>`
|
||||||
|
(both **cached** after first scrape and round-tripped via `settings()` so we
|
||||||
|
don't re-scrape every start), `oauth_token: Option<String>` (optional),
|
||||||
|
and bounds (D6). Hand-written `Debug` redacts `client_id`/`oauth_token`.
|
||||||
|
- **No login (baseline)**: if `client_id` is unset, `ScApi` scrapes it from
|
||||||
|
`soundcloud.com` at init; on any `401/403` it re-scrapes **once** and retries
|
||||||
|
(rotation recovery). The freshly scraped id is persisted.
|
||||||
|
- **Optional login**: if `oauth_token` is present, `get_lib_root` adds `likes`
|
||||||
|
and `playlists`, and personal calls send `Authorization: OAuth <token>`. If a
|
||||||
|
refresh-token flow is configured later it mirrors tidal's persist-on-refresh;
|
||||||
|
v1 accepts a static token and, on `401`, drops the personal subtree with a
|
||||||
|
typed error (never a crash) — public browse is unaffected.
|
||||||
|
|
||||||
|
### D6 — Bounds and freshness
|
||||||
|
|
||||||
|
- `search_results` (default 50), `playlist_tracks_limit` (default 500, hydrated
|
||||||
|
in ≤50-id batches), `call_timeout_secs` (default 30) bound each HTTP call, and
|
||||||
|
`hls_total_deadline_secs` (default 300) bounds a whole HLS fetch (segments are
|
||||||
|
retried with jitter under this deadline). Caps are `log`-ged so truncation is
|
||||||
|
visible, not silent (hard rule: no silent caps).
|
||||||
|
- Listings are fetched fresh per call (no cross-call cache), like the other
|
||||||
|
remote providers; only search terms / resolve URLs are stored in memory. The
|
||||||
|
chosen transcoding may be briefly cached per track id to save the extra
|
||||||
|
round-trip on replay.
|
||||||
|
|
||||||
|
### D7 — Out of scope (explicitly)
|
||||||
|
|
||||||
|
- **opus-HLS** (`audio/ogg; codecs=opus`) and **progressive** transcodings: v1
|
||||||
|
targets mp3-HLS uniformly (offered for ~all tracks). opus-HLS is a phase-2
|
||||||
|
add that reuses the new `OpusSource` per segment; the `HlsStream` seam makes
|
||||||
|
it additive.
|
||||||
|
- **HLS seeking**: v1 HLS is forward-only and reports the source as
|
||||||
|
non-seekable, so symphonia does not attempt an end-seek (which would need a
|
||||||
|
known byte length). In-track seek is a later add (open at a segment offset).
|
||||||
|
- Go+ / high-quality / lossless streams (need a premium account), uploads,
|
||||||
|
comments, reposts feed, waveforms, social graph, and playback-progress sync.
|
||||||
|
- Pagination past the configured caps (one page per listing).
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
server: crabidy-server {
|
||||||
|
orch: ProviderOrchestrator
|
||||||
|
}
|
||||||
|
|
||||||
|
sc: "soundclouddy (crate)" {
|
||||||
|
client: "Client\n(ProviderClient)"
|
||||||
|
terms: "search terms + resolve URLs\n(in-memory)"
|
||||||
|
api: "ScApi\n(reqwest seam: Sc trait,\nclient_id self-heal, opt OAuth)"
|
||||||
|
client -> terms
|
||||||
|
client -> api
|
||||||
|
}
|
||||||
|
|
||||||
|
player: "audio-player" {
|
||||||
|
hls: "HlsStream\n(new SourceStream)"
|
||||||
|
dec: "rodio / symphonia\n(mp3, opus)"
|
||||||
|
hls -> dec: "concatenated mp3 bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
scapi: "SoundCloud\napi-v2 + HLS CDN" { shape: cloud }
|
||||||
|
web: "soundcloud.com\n(HTML + JS)" { shape: cloud }
|
||||||
|
|
||||||
|
server.orch -> sc.client: "/soundcloud/..."
|
||||||
|
sc.api -> scapi: "resolve / search / tracks / transcoding (JSON, timeout)"
|
||||||
|
sc.api -> web: "scrape client_id (init, on 401)"
|
||||||
|
server.orch -> player.hls: ".m3u8 media URL"
|
||||||
|
player.hls -> scapi: "GET m3u8 + mp3 segments"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key flow: search and play a track
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
tui: TUI
|
||||||
|
orch: Orchestrator
|
||||||
|
s: soundclouddy
|
||||||
|
api: "SoundCloud api-v2"
|
||||||
|
hls: "HlsStream (audio-player)"
|
||||||
|
cdn: "HLS CDN"
|
||||||
|
|
||||||
|
tui -> orch: "open /soundcloud/search"
|
||||||
|
tui -> orch: "create term \"boards of canada\""
|
||||||
|
orch -> s: "create_lib_node(search, term)"
|
||||||
|
s -> tui: "term stored"
|
||||||
|
tui -> orch: "open /soundcloud/search/<term>"
|
||||||
|
orch -> s: "get_lib_node"
|
||||||
|
s -> api: "GET /search/tracks?q=…&client_id="
|
||||||
|
api -> s: "tracks (id, title, user, duration)"
|
||||||
|
s -> tui: "tracks as queueable leaves (/soundcloud/track/<id>)"
|
||||||
|
tui -> orch: "queue + play a track"
|
||||||
|
orch -> s: "get_urls_for_track(/soundcloud/track/<id>)"
|
||||||
|
s -> api: "GET /tracks/<id> → pick hls+audio/mpeg transcoding"
|
||||||
|
s -> api: "GET <transcoding.url>?client_id= → { url: m3u8 }"
|
||||||
|
s -> orch: "urls = [ m3u8 ]"
|
||||||
|
orch -> hls: "player.play(m3u8)"
|
||||||
|
hls -> cdn: "GET m3u8 (segments)"
|
||||||
|
hls -> cdn: "GET segment 1..N (mp3, in order)"
|
||||||
|
hls -> orch: "continuous mp3 → symphonia decodes"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **client_id scraping fragility.** The scrape regexes depend on
|
||||||
|
soundcloud.com's HTML/JS shape and can break on a redesign. Mitigation: a
|
||||||
|
config override (`client_id` in `soundcloud.toml`) always wins, and failures
|
||||||
|
are typed (subtree degrades, app survives). The scrape is the one piece with
|
||||||
|
no test-double coverage of the *live* format — flagged as a **live-test gate**.
|
||||||
|
- **Ephemeral media URLs.** The resolved `.m3u8` and its segments are signed and
|
||||||
|
short-lived; playback must start promptly after resolution (like `/youtube`).
|
||||||
|
A stale URL surfaces as a skipped track, never a crash. Never logged.
|
||||||
|
- **HLS without a known length.** The concatenated stream has no total byte
|
||||||
|
length; reported non-seekable so symphonia won't end-seek (the rodio-0.22
|
||||||
|
panic the opus work documented). **Gate:** verify an end-to-end mp3-HLS play
|
||||||
|
does not panic and reaches EOS cleanly.
|
||||||
|
- **Master vs media playlist.** SoundCloud returns a media (segment) playlist
|
||||||
|
for the chosen transcoding; `HlsStream` follows one level of master playlist
|
||||||
|
defensively and picks the first variant.
|
||||||
|
- **Playlist stubs.** Playlist detail may return track stubs; hydration in
|
||||||
|
≤50-id batches is bounded by `playlist_tracks_limit`. A hydration miss drops
|
||||||
|
that track (skipped), never an error.
|
||||||
|
- **OAuth token lifetime.** v1 accepts a static token; expiry drops the personal
|
||||||
|
subtree with a typed `401` (public browse unaffected). A device-flow/refresh
|
||||||
|
upgrade mirrors tidal and is additive.
|
||||||
|
- **Field / envelope drift.** DTOs decode defensively (`#[serde(default)]`);
|
||||||
|
a renamed field is a local fix in `ScApi`. Live validation is a task-plan gate.
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
# TUI visual mode (paint-select with movement)
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
The library pane supports **marks**: `s` toggles the selected row's mark (gated
|
||||||
|
on `is_queable`), and the multi-item actions (`a` append, `L` queue-next, `Enter`
|
||||||
|
replace, `w`/`W` capture… — via `get_selected`) operate on the marked set,
|
||||||
|
falling back to the bare selection. Marking a contiguous run today means pressing
|
||||||
|
`s`, moving, `s`, moving, `s`… — one keystroke per row.
|
||||||
|
|
||||||
|
The request: a vim-style **visual mode**. Press `v` (and `V` — both do the same)
|
||||||
|
to enter it; then **movement toggles the mark of the rows it sweeps over**, so a
|
||||||
|
run is selected by `v` then `j j j` (or `v G`). Pressing `v`/`V` again — or `Esc`
|
||||||
|
— leaves visual mode; the marks persist for the next action.
|
||||||
|
|
||||||
|
This frees no key, so the **spectrum toggle currently on `v` must move**
|
||||||
|
(architecture/spectrum.md; it is a client-only display toggle).
|
||||||
|
|
||||||
|
## Assumptions (decided here)
|
||||||
|
|
||||||
|
- **Library-only.** Marks exist only in the library pane; the queue's marks are
|
||||||
|
a standing `FIXME` (`queue.rs`), so visual mode binds in the **Library scope**
|
||||||
|
only. Queue visual mode is out of scope until the queue grows marks (D5).
|
||||||
|
- **Marks are the selection.** Visual mode is pure UI over the existing
|
||||||
|
`UiItem.marked` — no new wire types, no server calls, no proto change. It only
|
||||||
|
changes *how* marks get toggled.
|
||||||
|
- **`is_queable` gating is preserved.** `toggle_mark` only marks queueable rows;
|
||||||
|
paint-toggle does the same, so sweeping over a non-queueable row leaves it
|
||||||
|
unmarked (consistent with `s`).
|
||||||
|
- TUI-only, like `tui-search`. Web-client parity is a follow-up (D6).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — `v`/`V` enter visual mode; spectrum moves to `f`
|
||||||
|
|
||||||
|
Two new Library-scoped bindings, `v` (`NONE`) and `V` (`SHIFT`), both mapping to
|
||||||
|
one new `Action::LibraryVisualMode` (same two-binding/one-action pattern as
|
||||||
|
`K`/`J`, `W`, etc.). `Action::ToggleSpectrum` moves from Global `v` to **Global
|
||||||
|
`f`** ("frequency"), a key free in every scope. *This letter is a pure
|
||||||
|
preference — trivially changed in `bindings.rs`; `f` is the chosen default.*
|
||||||
|
|
||||||
|
### D2 — Paint-toggle semantics: sweep toggles, endpoints included
|
||||||
|
|
||||||
|
Visual mode holds one piece of state — that it is **active** (the anchor is
|
||||||
|
the **anchor** — the view index where `v` was pressed — plus the cursor).
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- **Enter** (`v`/`V` while normal): activate, **anchor at the current row**, and
|
||||||
|
**toggle its mark** (vim includes the row you start on). A lone `v … v` thus
|
||||||
|
behaves like a single `s`.
|
||||||
|
- **Move** (any of `j`/`k`, `g`/`G`, `Ctrl-d`/`Ctrl-u` while active): perform the
|
||||||
|
move, then reconcile marks to the contiguous **range `[anchor, cursor]`** —
|
||||||
|
toggle exactly the rows whose membership in that range **changed** (relative to
|
||||||
|
the anchor). Growing the range marks the rows entered; **shrinking it unmarks
|
||||||
|
the rows left**, so moving back down/up **cleanly reverses** a move and the row
|
||||||
|
you turn around on is never stranded. Jumps (`G`, `Ctrl-d`) reconcile the whole
|
||||||
|
span at once. It is a *toggle* against range membership, so sweeping over a
|
||||||
|
row that was already marked (by `s`) flips it, and sweeping back flips it back.
|
||||||
|
- **Exit**: `v`/`V` again, or `Esc`, deactivates (marks persist). Any other
|
||||||
|
action key (`a`, `Enter`, `w`, …) also **exits first, then runs normally**, so
|
||||||
|
`v j j a` selects three rows and appends them. Changing node (`h`/`l`) or focus
|
||||||
|
(`Tab`) also exits — the anchor/indices would otherwise be stale.
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right
|
||||||
|
shape: sequence_diagram
|
||||||
|
Normal
|
||||||
|
Visual
|
||||||
|
Normal -> Visual: "v / V (anchor here, toggle current row)"
|
||||||
|
Visual -> Visual: "j k g G C-d C-u (reconcile marks to [anchor, cursor])"
|
||||||
|
Visual -> Normal: "v / V / Esc (marks kept)"
|
||||||
|
Visual -> Normal: "a / Enter / w / … (exit, then act on marks)"
|
||||||
|
Visual -> Normal: "h / l / Tab (node/focus change)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Worked example (rows `0..9`, all unmarked, cursor at `0` = anchor):
|
||||||
|
|
||||||
|
```text
|
||||||
|
v -> {0} anchor 0, toggle current
|
||||||
|
j -> {0,1} range [0,1]
|
||||||
|
j -> {0,1,2} range [0,2]
|
||||||
|
G -> {0..9} range [0,9] (jump reconciles the span)
|
||||||
|
k -> {0..8} range [0,8] -> 9 leaves, unmarked
|
||||||
|
g -> {0} range [0,0] -> 1..8 leave, unmarked
|
||||||
|
v -> exit, marks {0} kept
|
||||||
|
```
|
||||||
|
|
||||||
|
### D3 — Where the state lives and how dispatch routes it
|
||||||
|
|
||||||
|
`Library` owns the visual state as `visual: Option<usize>` — `Some(anchor_view)`
|
||||||
|
while active, so the mode and its anchor are one field, exposed as `is_visual`,
|
||||||
|
`toggle_visual`, `exit_visual`. `App::dispatch` is the single choke point (it
|
||||||
|
already maps every `Action`):
|
||||||
|
|
||||||
|
- `LibraryVisualMode` → `library.toggle_visual()` (activate: anchor at the cursor
|
||||||
|
and toggle its mark; or deactivate). Only reachable while the library is
|
||||||
|
focused (Library-scope binding).
|
||||||
|
- The six movement actions → a `library_move` helper: when visual is active, read
|
||||||
|
the cursor, run the existing move, read the cursor again, and call
|
||||||
|
`Library::paint_between(old_view, new_view)`; otherwise just move.
|
||||||
|
- `LibraryAscend`/`LibraryDive`/`CycleFocus` → covered by the catch-all below
|
||||||
|
(they exit visual, then proceed).
|
||||||
|
- `ClearSearch` (`Esc`) → if visual is active, just leave it (do not also clear
|
||||||
|
the search filter); else unchanged.
|
||||||
|
- Every other action → leave visual mode first, then proceed. Implemented as a
|
||||||
|
guard at the top of `dispatch`: capture `was_visual`, and exit unless the
|
||||||
|
action is one of the six movements, `LibraryVisualMode`, or `ClearSearch`.
|
||||||
|
|
||||||
|
`Library` gains `selected_view()` (the current view index) and
|
||||||
|
`paint_between(from_view, to_view)` — using the stored `anchor`, toggle the mark
|
||||||
|
(respecting `is_queable`) of every view index whose membership in `[anchor,
|
||||||
|
cursor]` changed between the old and new cursor, mapping each through the `/`
|
||||||
|
filter to its real index exactly as `toggle_mark` does. No change to `select`, so
|
||||||
|
non-visual selection (filter re-select, `update_selection`) never paints.
|
||||||
|
|
||||||
|
### D4 — Visual indicator
|
||||||
|
|
||||||
|
The library pane title shows `— VISUAL` while active (same title slot as the
|
||||||
|
`— /query▏` search hint and `— % to add`). The help modal lists `v`/`V`
|
||||||
|
("Enter visual mode: movement toggles marks") in the Library group and the moved
|
||||||
|
`f` spectrum toggle in the Global group — both derived from `BINDINGS`, so they
|
||||||
|
stay correct for free.
|
||||||
|
|
||||||
|
### D5 — Out of scope: queue visual mode
|
||||||
|
|
||||||
|
The queue has no marks, so `v`/`V` are unbound there (a no-op). Extending visual
|
||||||
|
mode to the queue is gated on giving the queue a mark set (the existing
|
||||||
|
`queue.rs` `FIXME`) and is left for that work.
|
||||||
|
|
||||||
|
### D6 — Out of scope: web-client parity
|
||||||
|
|
||||||
|
`cbd-web` mirrors the TUI keymap; a visual mode there is a clean follow-up (its
|
||||||
|
`state.rs`/`keymap.rs` are the analog seams), not part of this TUI change.
|
||||||
|
|
||||||
|
## Boundaries / interfaces
|
||||||
|
|
||||||
|
- **`bindings.rs`** (pure data): `+LibraryVisualMode`, its two Library bindings,
|
||||||
|
and the `ToggleSpectrum` chord moves `v`→`f`. All dispatch/help/label logic is
|
||||||
|
already derived from the table.
|
||||||
|
- **`app/mod.rs`** (`App`): the `was_visual` guard + `library_move` + the
|
||||||
|
dispatch routing above.
|
||||||
|
- **`app/library.rs`** (`Library`): the `visual: Option<usize>` anchor,
|
||||||
|
`is_visual`/`toggle_visual`/`exit_visual`, `selected_view`, `paint_between`, and
|
||||||
|
the title indicator. Marks, filter, and `select` are reused unchanged.
|
||||||
|
|
||||||
|
## Risks and open questions
|
||||||
|
|
||||||
|
- **Anchor semantics chosen over per-step toggle.** An earlier half-open
|
||||||
|
per-step design stranded the turnaround row (down-then-up left the furthest row
|
||||||
|
marked). The anchored `[anchor, cursor]` range reconciliation fixes that:
|
||||||
|
moving back cleanly reverses. Sweeping over a pre-existing (`s`) mark still
|
||||||
|
*toggles* it against range membership — reversible, but worth knowing.
|
||||||
|
- **Filter interaction.** With a `/` filter active, paint sweeps **view** indices
|
||||||
|
and toggles their real rows, so only visible rows are affected — consistent
|
||||||
|
with how `s` and `get_selected` already treat marks vs. the filtered view.
|
||||||
|
- **Stale indices on list change.** Any node/focus change exits visual mode, so
|
||||||
|
a reload can never paint against a previous list.
|
||||||
|
|
@ -161,7 +161,7 @@ keys -> ui
|
||||||
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
|
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
|
||||||
| skipped tracks red | same, via `Track.is_skipped` |
|
| skipped tracks red | same, via `Track.is_skipped` |
|
||||||
| update stream reconnect | same, backoff + disconnect banner |
|
| update stream reconnect | same, backoff + disconnect banner |
|
||||||
| auth via config file | login form on `UNAUTHENTICATED`, localStorage |
|
| auth via config file | login form (proactive + forced), localStorage |
|
||||||
| `?` help modal | `?` help overlay listing keys |
|
| `?` help modal | `?` help overlay listing keys |
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
FROM ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge
|
FROM ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge
|
||||||
RUN dpkg --add-architecture armhf
|
RUN dpkg --add-architecture armhf
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt-get update && apt-get install -y alsa:armhf librust-alsa-sys-dev:armhf libasound2-dev:armhf portaudio19-dev:armhf build-essential libpulse-dev:armhf libdbus-1-dev:armhf pkg-config apt-utils unzip
|
RUN apt-get update && apt-get install -y alsa:armhf librust-alsa-sys-dev:armhf libasound2-dev:armhf portaudio19-dev:armhf build-essential cmake libpulse-dev:armhf libdbus-1-dev:armhf pkg-config apt-utils unzip
|
||||||
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip
|
RUN curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v23.2/protoc-23.2-linux-x86_64.zip && unzip protoc-23.2-linux-x86_64.zip
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ name = "audio-player"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
# The one axis worth a flag: Ogg-Opus decoding pulls symphonia *and*
|
||||||
|
# `symphonia-adapter-libopus`, which bundles libopus and so makes cmake +
|
||||||
|
# ninja build requirements. Everything else in this crate (HLS, the
|
||||||
|
# windowed-HTTP source, the spectrum tap) shares its dependencies with
|
||||||
|
# code that always ships, so gating it would buy nothing
|
||||||
|
# (architecture/build-features.md D6/D7/D8).
|
||||||
|
[features]
|
||||||
|
default = ["opus"]
|
||||||
|
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
|
|
@ -11,6 +21,8 @@ futures.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rodio.workspace = true
|
rodio.workspace = true
|
||||||
stream-download.workspace = true
|
stream-download.workspace = true
|
||||||
|
symphonia = { workspace = true, optional = true }
|
||||||
|
symphonia-adapter-libopus = { workspace = true, optional = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
//! An HLS [`SourceStream`]: fetches an `.m3u8` media playlist and streams its
|
||||||
|
//! mp3 segments in order as one continuous byte stream.
|
||||||
|
//!
|
||||||
|
//! SoundCloud (and other HLS sources) serve audio as a playlist of short mp3
|
||||||
|
//! segments rather than one file. mp3 frames byte-concatenate into a valid
|
||||||
|
//! stream (the fact `ffmpeg -c copy` relies on), so streaming the segments in
|
||||||
|
//! order yields bytes rodio's symphonia mp3 decoder handles unchanged
|
||||||
|
//! (architecture/soundcloud-provider.md D4).
|
||||||
|
//!
|
||||||
|
//! Sibling to [`crate::windowed_http::WindowedHttpStream`]. This is **forward
|
||||||
|
//! only**: it reports the source as non-seekable and length-less, so symphonia
|
||||||
|
//! does not attempt an end-seek that would need a known total length (the
|
||||||
|
//! rodio-0.22 panic the opus work documented).
|
||||||
|
//!
|
||||||
|
//! Never logs the playlist or segment URLs — they are signed and ephemeral.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::{Future, Stream};
|
||||||
|
use stream_download::source::{DecodeError, SourceStream};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Parameters for [`HlsStream::create`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HlsParams {
|
||||||
|
/// The `.m3u8` media-playlist URL.
|
||||||
|
pub url: Url,
|
||||||
|
pub client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HlsParams {
|
||||||
|
pub fn new(url: Url, client: reqwest::Client) -> Self {
|
||||||
|
Self { url, client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error creating the stream (playlist fetch/parse failed).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("{0}")]
|
||||||
|
pub struct HlsError(String);
|
||||||
|
|
||||||
|
impl DecodeError for HlsError {}
|
||||||
|
|
||||||
|
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
|
||||||
|
type SegmentFuture = Pin<Box<dyn Future<Output = io::Result<BytesStream>> + Send + Sync>>;
|
||||||
|
|
||||||
|
/// A parsed playlist: either the media (segment) playlist we want, or a master
|
||||||
|
/// playlist pointing at variant playlists (we follow the first).
|
||||||
|
enum Playlist {
|
||||||
|
Media(Vec<Url>),
|
||||||
|
Master(Url),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
/// Ready to fetch `segments[cursor]`.
|
||||||
|
Idle,
|
||||||
|
/// Waiting for a segment response.
|
||||||
|
Requesting(SegmentFuture),
|
||||||
|
/// Draining a segment body.
|
||||||
|
Streaming(BytesStream),
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See the module docs. Streams `segments[cursor]` bytes, advancing at segment
|
||||||
|
/// boundaries; finishes after the last.
|
||||||
|
pub struct HlsStream {
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// Ordered mp3 segment URLs parsed from the media playlist.
|
||||||
|
segments: Vec<Url>,
|
||||||
|
/// Index of the next segment to fetch.
|
||||||
|
cursor: usize,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for HlsStream {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("HlsStream")
|
||||||
|
.field("segments", &self.segments.len())
|
||||||
|
.field("cursor", &self.cursor)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HlsStream {
|
||||||
|
/// Schedules the fetch of the segment at `cursor`, or finishes when none
|
||||||
|
/// remain.
|
||||||
|
fn schedule_next(&mut self) {
|
||||||
|
let Some(url) = self.segments.get(self.cursor).cloned() else {
|
||||||
|
self.state = State::Finished;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.cursor += 1;
|
||||||
|
self.state = State::Requesting(Box::pin(fetch_segment(self.client.clone(), url)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GETs one segment, returning its body byte-stream. A non-success status is an
|
||||||
|
/// error carrying the status only (never the signed URL).
|
||||||
|
async fn fetch_segment(client: reqwest::Client, url: Url) -> io::Result<BytesStream> {
|
||||||
|
trace!("fetching hls segment");
|
||||||
|
let resp = client.get(url).send().await.map_err(|err| {
|
||||||
|
io::Error::other(format!("segment request failed: {}", err.without_url()))
|
||||||
|
})?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"segment request rejected: {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Box::pin(resp.bytes_stream()))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream for HlsStream {
|
||||||
|
type Item = io::Result<Bytes>;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
let this = &mut *self;
|
||||||
|
loop {
|
||||||
|
match &mut this.state {
|
||||||
|
State::Finished => return Poll::Ready(None),
|
||||||
|
State::Idle => this.schedule_next(),
|
||||||
|
State::Requesting(future) => match future.as_mut().poll(cx) {
|
||||||
|
Poll::Pending => return Poll::Pending,
|
||||||
|
Poll::Ready(Ok(stream)) => this.state = State::Streaming(stream),
|
||||||
|
Poll::Ready(Err(err)) => {
|
||||||
|
this.state = State::Finished;
|
||||||
|
return Poll::Ready(Some(Err(err)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
State::Streaming(stream) => match stream.as_mut().poll_next(cx) {
|
||||||
|
Poll::Pending => return Poll::Pending,
|
||||||
|
Poll::Ready(Some(Ok(bytes))) => return Poll::Ready(Some(Ok(bytes))),
|
||||||
|
Poll::Ready(Some(Err(err))) => {
|
||||||
|
return Poll::Ready(Some(Err(io::Error::other(format!(
|
||||||
|
"segment body failed: {}",
|
||||||
|
err.without_url()
|
||||||
|
)))));
|
||||||
|
}
|
||||||
|
// Segment drained: move to the next one.
|
||||||
|
Poll::Ready(None) => this.schedule_next(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceStream for HlsStream {
|
||||||
|
type Params = HlsParams;
|
||||||
|
type StreamCreationError = HlsError;
|
||||||
|
|
||||||
|
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
|
||||||
|
let segments = load_segments(¶ms.client, params.url).await?;
|
||||||
|
debug!(segments = segments.len(), "hls stream open");
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Err(HlsError("hls playlist has no segments".to_string()));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
client: params.client,
|
||||||
|
segments,
|
||||||
|
cursor: 0,
|
||||||
|
state: State::Idle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown up front (segment sizes are not in the playlist), so `None` —
|
||||||
|
/// which, with `supports_seek() == false`, keeps symphonia off the
|
||||||
|
/// end-seek path.
|
||||||
|
fn content_length(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward-only. In-track seeking (open at a segment offset) is a later,
|
||||||
|
/// additive change (architecture/soundcloud-provider.md D7).
|
||||||
|
async fn seek_range(&mut self, _start: u64, _end: Option<u64>) -> io::Result<()> {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Unsupported,
|
||||||
|
"HLS stream is forward-only",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort resume after a dropped connection: re-open the current
|
||||||
|
/// segment. `stream-download`'s temp storage already holds everything up to
|
||||||
|
/// `current_position`, and its `Read` side serves that; this only needs to
|
||||||
|
/// resume producing fresh bytes, so re-fetching the in-flight segment is
|
||||||
|
/// acceptable (a short overlap at worst).
|
||||||
|
async fn reconnect(&mut self, _current_position: u64) -> io::Result<()> {
|
||||||
|
let restart = self.cursor.saturating_sub(1);
|
||||||
|
warn!(
|
||||||
|
segment = restart,
|
||||||
|
"reconnecting hls stream at current segment"
|
||||||
|
);
|
||||||
|
self.cursor = restart;
|
||||||
|
self.state = State::Idle;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_seek(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the playlist at `url`, following one level of master playlist, and
|
||||||
|
/// returns the ordered media-segment URLs.
|
||||||
|
async fn load_segments(client: &reqwest::Client, url: Url) -> Result<Vec<Url>, HlsError> {
|
||||||
|
let body = fetch_text(client, url.clone()).await?;
|
||||||
|
match parse_playlist(&body, &url)? {
|
||||||
|
Playlist::Media(segments) => Ok(segments),
|
||||||
|
Playlist::Master(variant) => {
|
||||||
|
let body = fetch_text(client, variant.clone()).await?;
|
||||||
|
match parse_playlist(&body, &variant)? {
|
||||||
|
Playlist::Media(segments) => Ok(segments),
|
||||||
|
// A master pointing at another master is not something
|
||||||
|
// SoundCloud produces; refuse rather than recurse.
|
||||||
|
Playlist::Master(_) => Err(HlsError("nested master playlist".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_text(client: &reqwest::Client, url: Url) -> Result<String, HlsError> {
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| HlsError(format!("playlist request failed: {}", e.without_url())))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(HlsError(format!("playlist rejected: {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| HlsError(format!("playlist read failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an m3u8 body. A master playlist (`#EXT-X-STREAM-INF`) yields the
|
||||||
|
/// first variant URI; otherwise every non-comment line is a media segment.
|
||||||
|
/// Relative URIs resolve against `base`.
|
||||||
|
fn parse_playlist(body: &str, base: &Url) -> Result<Playlist, HlsError> {
|
||||||
|
let is_master = body.contains("#EXT-X-STREAM-INF");
|
||||||
|
let mut uris = Vec::new();
|
||||||
|
for line in body.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resolved = base
|
||||||
|
.join(line)
|
||||||
|
.map_err(|e| HlsError(format!("bad segment URI: {e}")))?;
|
||||||
|
uris.push(resolved);
|
||||||
|
if is_master {
|
||||||
|
// Only the first variant is needed.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_master {
|
||||||
|
uris.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(Playlist::Master)
|
||||||
|
.ok_or_else(|| HlsError("master playlist has no variant".to_string()))
|
||||||
|
} else {
|
||||||
|
Ok(Playlist::Media(uris))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn base() -> Url {
|
||||||
|
Url::parse("https://cf-hls.sndcdn.com/media/0/playlist.m3u8?token=x").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_media_playlist_absolute_and_relative() {
|
||||||
|
let body = "#EXTM3U\n\
|
||||||
|
#EXT-X-VERSION:6\n\
|
||||||
|
#EXTINF:10.0,\n\
|
||||||
|
https://cdn.sndcdn.com/media/0/1.ts\n\
|
||||||
|
#EXTINF:9.9,\n\
|
||||||
|
2.ts\n\
|
||||||
|
#EXT-X-ENDLIST\n";
|
||||||
|
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
|
||||||
|
panic!("expected media playlist");
|
||||||
|
};
|
||||||
|
assert_eq!(segs.len(), 2);
|
||||||
|
assert_eq!(segs[0].as_str(), "https://cdn.sndcdn.com/media/0/1.ts");
|
||||||
|
// Relative URI resolved against the playlist URL.
|
||||||
|
assert_eq!(segs[1].as_str(), "https://cf-hls.sndcdn.com/media/0/2.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follows_first_master_variant() {
|
||||||
|
let body = "#EXTM3U\n\
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=128000\n\
|
||||||
|
variant-128.m3u8\n\
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=64000\n\
|
||||||
|
variant-64.m3u8\n";
|
||||||
|
let Playlist::Master(url) = parse_playlist(body, &base()).unwrap() else {
|
||||||
|
panic!("expected master playlist");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
url.as_str(),
|
||||||
|
"https://cf-hls.sndcdn.com/media/0/variant-128.m3u8"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_comments_and_blank_lines() {
|
||||||
|
let body = "#EXTM3U\n\n#EXTINF:1,\nonly.ts\n\n";
|
||||||
|
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
|
||||||
|
panic!("expected media playlist");
|
||||||
|
};
|
||||||
|
assert_eq!(segs.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
|
mod hls;
|
||||||
|
// Ogg-Opus needs symphonia's Ogg demuxer plus the bundled libopus decoder, so
|
||||||
|
// it is the crate's one feature (architecture/build-features.md D6).
|
||||||
|
#[cfg(feature = "opus")]
|
||||||
|
mod opus_source;
|
||||||
mod player;
|
mod player;
|
||||||
mod player_engine;
|
mod player_engine;
|
||||||
mod spectrum_tap;
|
mod spectrum_tap;
|
||||||
pub mod windowed_http;
|
pub mod windowed_http;
|
||||||
|
|
||||||
pub use player::{Player, PlayerError};
|
pub use player::{Player, PlayerError};
|
||||||
pub use player_engine::{MediaInfo, PlayerMessage};
|
pub use player_engine::{output_device_names, MediaInfo, PlayerMessage};
|
||||||
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
|
pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
//! Ogg-Opus decoding for the rodio pipeline.
|
||||||
|
//!
|
||||||
|
//! rodio decodes through symphonia, and symphonia 0.5 ships no Opus
|
||||||
|
//! decoder — so raw `.opus` streams (notably audiobookshelf files, but any
|
||||||
|
//! provider serving Opus) fail rodio's `Decoder::build()`. This module fills
|
||||||
|
//! that gap: it demuxes the container with symphonia's own Ogg reader and
|
||||||
|
//! decodes the Opus packets with libopus via [`symphonia_adapter_libopus`],
|
||||||
|
//! then exposes the result as a rodio [`Source`] so the rest of the player
|
||||||
|
//! (sink, spectrum tap, seeking) is unchanged.
|
||||||
|
//!
|
||||||
|
//! Only Ogg-*Opus* is handled here; every other format (including Ogg-Vorbis
|
||||||
|
//! and Ogg-FLAC, which symphonia decodes natively) stays on rodio's decoder.
|
||||||
|
//! [`is_ogg_opus`] does the sniffing and the player routes accordingly.
|
||||||
|
//!
|
||||||
|
//! The decode loop, buffering, and seek refinement mirror rodio 0.22's own
|
||||||
|
//! `SymphoniaDecoder`; the only substantive difference is that we build the
|
||||||
|
//! format reader and the codec from an explicit registry that includes the
|
||||||
|
//! libopus adapter, instead of symphonia's default `get_codecs()`.
|
||||||
|
|
||||||
|
use std::io::{Read, Result as IoResult, Seek, SeekFrom};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use symphonia::core::audio::{AudioBufferRef, SampleBuffer, SignalSpec};
|
||||||
|
use symphonia::core::codecs::{
|
||||||
|
CodecRegistry, Decoder as SymphoniaDecoderTrait, DecoderOptions, CODEC_TYPE_OPUS,
|
||||||
|
};
|
||||||
|
use symphonia::core::errors::Error as SymphoniaError;
|
||||||
|
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, SeekedTo};
|
||||||
|
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
|
||||||
|
use symphonia::core::units;
|
||||||
|
use symphonia::default::formats::OggReader;
|
||||||
|
use symphonia_adapter_libopus::OpusDecoder;
|
||||||
|
|
||||||
|
use rodio::source::SeekError;
|
||||||
|
use rodio::{ChannelCount, Sample, SampleRate, Source};
|
||||||
|
|
||||||
|
/// The Ogg page capture pattern (every page starts with it).
|
||||||
|
const OGG_MAGIC: &[u8] = b"OggS";
|
||||||
|
/// The magic signature of the Opus identification header packet, which is
|
||||||
|
/// the first packet of an Ogg-Opus stream (RFC 7845 §5.1).
|
||||||
|
const OPUS_HEAD_MAGIC: &[u8] = b"OpusHead";
|
||||||
|
|
||||||
|
/// Returns true if `header` looks like the start of an Ogg-Opus stream: an
|
||||||
|
/// Ogg page whose first packet is the Opus identification header. `header`
|
||||||
|
/// should be at least the first Ogg page (~64 bytes is plenty; `OpusHead`
|
||||||
|
/// sits at offset 28 in a well-formed stream). Ogg-Vorbis/FLAC deliberately
|
||||||
|
/// do *not* match, so they keep flowing through rodio's native decoder.
|
||||||
|
pub fn is_ogg_opus(header: &[u8]) -> bool {
|
||||||
|
header.starts_with(OGG_MAGIC) && contains(header, OPUS_HEAD_MAGIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
haystack
|
||||||
|
.windows(needle.len())
|
||||||
|
.any(|window| window == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rodio [`Source`] that decodes an Ogg-Opus stream to interleaved `f32`
|
||||||
|
/// samples at libopus's native 48 kHz.
|
||||||
|
pub struct OpusSource {
|
||||||
|
decoder: Box<dyn SymphoniaDecoderTrait>,
|
||||||
|
format: Box<dyn FormatReader>,
|
||||||
|
track_id: u32,
|
||||||
|
/// Interleaved samples of the most recently decoded packet.
|
||||||
|
buffer: SampleBuffer<Sample>,
|
||||||
|
/// Read cursor into `buffer`, in samples (not frames).
|
||||||
|
span_offset: usize,
|
||||||
|
spec: SignalSpec,
|
||||||
|
total_duration: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpusSource {
|
||||||
|
/// Builds an Opus source from a seekable byte stream. `byte_len` (the
|
||||||
|
/// content length) and `is_seekable` mirror the values the player already
|
||||||
|
/// computes for rodio; supplying them lets symphonia's Ogg reader learn
|
||||||
|
/// the final granule position (hence duration) and seek by time.
|
||||||
|
pub fn new<R>(reader: R, byte_len: Option<u64>, is_seekable: bool) -> Result<Self>
|
||||||
|
where
|
||||||
|
R: Read + Seek + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let source = Box::new(ReadSeekSource {
|
||||||
|
inner: reader,
|
||||||
|
byte_len,
|
||||||
|
is_seekable,
|
||||||
|
});
|
||||||
|
let mss = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
|
||||||
|
|
||||||
|
let format = OggReader::try_new(mss, &FormatOptions::default())
|
||||||
|
.context("failed to open Ogg-Opus container")?;
|
||||||
|
let format: Box<dyn FormatReader> = Box::new(format);
|
||||||
|
|
||||||
|
let track = format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec == CODEC_TYPE_OPUS)
|
||||||
|
.context("Ogg stream has no Opus track")?;
|
||||||
|
let track_id = track.id;
|
||||||
|
|
||||||
|
// A registry holding only the libopus adapter — the container is
|
||||||
|
// Opus-only, so no other codec can appear.
|
||||||
|
let mut registry = CodecRegistry::new();
|
||||||
|
registry.register_all::<OpusDecoder>();
|
||||||
|
let decoder = registry
|
||||||
|
.make(&track.codec_params, &DecoderOptions::default())
|
||||||
|
.context("failed to create libopus decoder")?;
|
||||||
|
|
||||||
|
let total_duration = track
|
||||||
|
.codec_params
|
||||||
|
.time_base
|
||||||
|
.zip(track.codec_params.n_frames)
|
||||||
|
.map(|(base, frames)| Duration::from(base.calc_time(frames)))
|
||||||
|
.filter(|d| !d.is_zero());
|
||||||
|
|
||||||
|
// Decode the first packet up front so the struct always holds a real
|
||||||
|
// buffer and spec. symphonia's `SampleBuffer::new` divides by the
|
||||||
|
// channel count, so a zero-channel placeholder would panic; this also
|
||||||
|
// makes channel/rate queries valid before the first `next()` (rodio
|
||||||
|
// inspects the source right after construction).
|
||||||
|
let mut format = format;
|
||||||
|
let mut decoder = decoder;
|
||||||
|
let (spec, buffer) = decode_next_buffer(&mut format, &mut decoder, track_id)?
|
||||||
|
.context("Opus stream produced no audio")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
decoder,
|
||||||
|
format,
|
||||||
|
track_id,
|
||||||
|
buffer,
|
||||||
|
span_offset: 0,
|
||||||
|
spec,
|
||||||
|
total_duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes packets — skipping ones from other logical streams and benign
|
||||||
|
/// decode errors — until one yields audio frames, returning that frame's
|
||||||
|
/// interleaved samples and signal spec. `Ok(None)` is a clean end of stream.
|
||||||
|
fn decode_next_buffer(
|
||||||
|
format: &mut Box<dyn FormatReader>,
|
||||||
|
decoder: &mut Box<dyn SymphoniaDecoderTrait>,
|
||||||
|
track_id: u32,
|
||||||
|
) -> Result<Option<(SignalSpec, SampleBuffer<Sample>)>> {
|
||||||
|
loop {
|
||||||
|
let packet = match format.next_packet() {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
// An IO error at a page boundary is how symphonia's Ogg reader
|
||||||
|
// signals end-of-stream for a bounded file.
|
||||||
|
Err(SymphoniaError::IoError(_)) => return Ok(None),
|
||||||
|
Err(e) => return Err(anyhow!(e)).context("reading Opus packet"),
|
||||||
|
};
|
||||||
|
if packet.track_id() != track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match decoder.decode(&packet) {
|
||||||
|
Ok(decoded) if decoded.frames() > 0 => {
|
||||||
|
let spec = *decoded.spec();
|
||||||
|
return Ok(Some((spec, copy_to_buffer(decoded, &spec))));
|
||||||
|
}
|
||||||
|
// A metadata-only packet (0 frames) or a recoverable decode error:
|
||||||
|
// skip it and keep going, matching rodio's behaviour.
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||||
|
Err(e) => return Err(anyhow!(e)).context("decoding Opus packet"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies a decoded symphonia buffer into an interleaved `f32` sample buffer.
|
||||||
|
fn copy_to_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<Sample> {
|
||||||
|
let capacity = units::Duration::from(decoded.capacity() as u64);
|
||||||
|
let mut buffer = SampleBuffer::<Sample>::new(capacity, *spec);
|
||||||
|
buffer.copy_interleaved_ref(decoded);
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for OpusSource {
|
||||||
|
type Item = Sample;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Sample> {
|
||||||
|
if self.span_offset >= self.buffer.len() {
|
||||||
|
match decode_next_buffer(&mut self.format, &mut self.decoder, self.track_id) {
|
||||||
|
Ok(Some((spec, buffer))) => {
|
||||||
|
self.spec = spec;
|
||||||
|
self.buffer = buffer;
|
||||||
|
self.span_offset = 0;
|
||||||
|
}
|
||||||
|
// Clean EOS or a fatal error both end the source.
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sample = *self.buffer.samples().get(self.span_offset)?;
|
||||||
|
self.span_offset += 1;
|
||||||
|
Some(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source for OpusSource {
|
||||||
|
fn current_span_len(&self) -> Option<usize> {
|
||||||
|
Some(self.buffer.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channels(&self) -> ChannelCount {
|
||||||
|
let count = u16::try_from(self.spec.channels.count().max(1)).unwrap_or(2);
|
||||||
|
ChannelCount::new(count).unwrap_or(ChannelCount::new(2).expect("2 is nonzero"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_rate(&self) -> SampleRate {
|
||||||
|
SampleRate::new(self.spec.rate)
|
||||||
|
.unwrap_or(SampleRate::new(48_000).expect("48000 is nonzero"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_duration(&self) -> Option<Duration> {
|
||||||
|
self.total_duration
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||||
|
// Clamp beyond-end seeks to the end (saturating), like rodio.
|
||||||
|
let mut target = pos;
|
||||||
|
if let Some(total) = self.total_duration {
|
||||||
|
if target > total {
|
||||||
|
target = total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the active channel so we resume on a frame boundary.
|
||||||
|
let channels = self.channels().get() as usize;
|
||||||
|
let active_channel = self.span_offset % channels;
|
||||||
|
|
||||||
|
let seeked = self
|
||||||
|
.format
|
||||||
|
.seek(
|
||||||
|
SeekMode::Accurate,
|
||||||
|
SeekTo::Time {
|
||||||
|
time: target.into(),
|
||||||
|
track_id: Some(self.track_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| SeekError::Other(Arc::new(e)))?;
|
||||||
|
|
||||||
|
// The demuxer moved without telling the decoder; reset it and force
|
||||||
|
// the next `next()` to refill from the new position (`span_offset`
|
||||||
|
// past the current buffer's end triggers a decode in `next()`).
|
||||||
|
self.decoder.reset();
|
||||||
|
self.span_offset = usize::MAX;
|
||||||
|
|
||||||
|
// Ogg seeks land on a page boundary before the target; fast-forward
|
||||||
|
// the residual so playback resumes at the requested instant.
|
||||||
|
self.refine_position(seeked);
|
||||||
|
|
||||||
|
// Re-align to the channel we were on before seeking.
|
||||||
|
for _ in 0..active_channel {
|
||||||
|
self.next();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpusSource {
|
||||||
|
/// Skips the samples between the keyframe symphonia seeked to and the
|
||||||
|
/// exact requested timestamp. No-op when the demuxer hit the target.
|
||||||
|
fn refine_position(&mut self, seeked: SeekedTo) {
|
||||||
|
let Some(base) = self.decoder.codec_params().time_base else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let residual = seeked.required_ts.saturating_sub(seeked.actual_ts);
|
||||||
|
if residual == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let seconds = Duration::from(base.calc_time(residual)).as_secs_f64();
|
||||||
|
let channels = self.channels().get() as f64;
|
||||||
|
let mut samples = (seconds * self.sample_rate().get() as f64 * channels).ceil() as usize;
|
||||||
|
samples -= samples % self.channels().get() as usize;
|
||||||
|
for _ in 0..samples {
|
||||||
|
if self.next().is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapts a `Read + Seek` byte stream into symphonia's [`MediaSource`]
|
||||||
|
/// (equivalent to rodio's private `ReadSeekSource`).
|
||||||
|
struct ReadSeekSource<R> {
|
||||||
|
inner: R,
|
||||||
|
byte_len: Option<u64>,
|
||||||
|
is_seekable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Read + Seek + Send + Sync> MediaSource for ReadSeekSource<R> {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
self.is_seekable
|
||||||
|
}
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
self.byte_len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Read> Read for ReadSeekSource<R> {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
|
||||||
|
self.inner.read(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Seek> Seek for ReadSeekSource<R> {
|
||||||
|
fn seek(&mut self, pos: SeekFrom) -> IoResult<u64> {
|
||||||
|
self.inner.seek(pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_ogg_opus_header() {
|
||||||
|
// "OggS" page header with an "OpusHead" first packet.
|
||||||
|
let mut header = Vec::new();
|
||||||
|
header.extend_from_slice(b"OggS");
|
||||||
|
header.extend_from_slice(&[0u8; 24]); // rest of the 28-byte page header
|
||||||
|
header.extend_from_slice(b"OpusHead");
|
||||||
|
assert!(is_ogg_opus(&header));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ogg_vorbis() {
|
||||||
|
let mut header = Vec::new();
|
||||||
|
header.extend_from_slice(b"OggS");
|
||||||
|
header.extend_from_slice(&[0u8; 24]);
|
||||||
|
header.extend_from_slice(b"\x01vorbis");
|
||||||
|
assert!(!is_ogg_opus(&header));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_ogg() {
|
||||||
|
assert!(!is_ogg_opus(b"ID3\x04OpusHead")); // mp3 with a coincidental needle
|
||||||
|
assert!(!is_ogg_opus(b"fLaC"));
|
||||||
|
assert!(!is_ogg_opus(b""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,16 @@ pub struct Player {
|
||||||
|
|
||||||
impl Default for Player {
|
impl Default for Player {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
Self::new(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
/// Spawns the player engine on its own thread, sending audio to the
|
||||||
|
/// output device whose name contains `device` (case-insensitive), or the
|
||||||
|
/// system default when `device` is `None`. See
|
||||||
|
/// [`crate::output_device_names`] to discover names.
|
||||||
|
pub fn new(device: Option<String>) -> Self {
|
||||||
let (tx_engine, rx_engine) = flume::bounded(16);
|
let (tx_engine, rx_engine) = flume::bounded(16);
|
||||||
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
|
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
|
||||||
flume::bounded(16);
|
flume::bounded(16);
|
||||||
|
|
@ -36,7 +46,8 @@ impl Default for Player {
|
||||||
let spectrum = SpectrumTap::new();
|
let spectrum = SpectrumTap::new();
|
||||||
let engine_tap = spectrum.clone();
|
let engine_tap = spectrum.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) {
|
let engine =
|
||||||
|
match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Could not initialize player: {}", e);
|
error!("Could not initialize player: {}", e);
|
||||||
return;
|
return;
|
||||||
|
|
@ -52,9 +63,7 @@ impl Default for Player {
|
||||||
spectrum,
|
spectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Player {
|
|
||||||
/// The spectrum tap the played audio is mirrored into.
|
/// The spectrum tap the played audio is mirrored into.
|
||||||
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
|
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
|
||||||
self.spectrum.clone()
|
self.spectrum.clone()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::{BufReader, Read, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -11,6 +11,9 @@ use rodio::{Decoder, Source};
|
||||||
use stream_download::storage::temp::TempStorageProvider;
|
use stream_download::storage::temp::TempStorageProvider;
|
||||||
use stream_download::{Settings, StreamDownload};
|
use stream_download::{Settings, StreamDownload};
|
||||||
|
|
||||||
|
use crate::hls::{HlsParams, HlsStream};
|
||||||
|
#[cfg(feature = "opus")]
|
||||||
|
use crate::opus_source::{is_ogg_opus, OpusSource};
|
||||||
use crate::spectrum_tap::{SpectrumTap, TappingSource};
|
use crate::spectrum_tap::{SpectrumTap, TappingSource};
|
||||||
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -107,15 +110,78 @@ pub struct PlayerEngine {
|
||||||
pre_mute_volume: f32,
|
pre_mute_volume: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The names of the available audio output devices, best-effort (an empty
|
||||||
|
/// list if the host cannot be queried). Shown by `crabidy-server
|
||||||
|
/// audio-devices` so a user can pick one for the `[audio] device` config.
|
||||||
|
//
|
||||||
|
// `DeviceTrait::name` is deprecated in cpal 0.17 in favor of `description()`,
|
||||||
|
// but on the ALSA backend (the Raspberry Pi target) `name()` returns the
|
||||||
|
// stable device string users already see in `aplay -l` and match against;
|
||||||
|
// `description().name()` is newer and less proven there. Keep `name()` so the
|
||||||
|
// listing and the server's device matching use the same reliable string.
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub fn output_device_names() -> Vec<String> {
|
||||||
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
match rodio::cpal::default_host().output_devices() {
|
||||||
|
Ok(devices) => devices.filter_map(|device| device.name().ok()).collect(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("could not enumerate audio output devices: {err}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the audio output. With `preferred` set, picks the first output
|
||||||
|
/// device whose name contains that string (case-insensitive) — so a user can
|
||||||
|
/// name a memorable fragment ("Headphones", "USB") instead of the full ALSA
|
||||||
|
/// string — and falls back to the system default with a warning if none
|
||||||
|
/// matches (the Pi's default is often HDMI, which is exactly the silent-output
|
||||||
|
/// case this selection exists to fix). With `preferred` unset, uses the
|
||||||
|
/// system default.
|
||||||
|
// `name()` deprecation: see the note on `output_device_names`.
|
||||||
|
#[allow(deprecated)]
|
||||||
|
fn open_output_device(preferred: Option<&str>) -> Result<MixerDeviceSink> {
|
||||||
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
if let Some(wanted) = preferred {
|
||||||
|
let needle = wanted.to_lowercase();
|
||||||
|
let matched = rodio::cpal::default_host()
|
||||||
|
.output_devices()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut devices| {
|
||||||
|
devices.find(|device| {
|
||||||
|
device
|
||||||
|
.name()
|
||||||
|
.map(|name| name.to_lowercase().contains(&needle))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match matched {
|
||||||
|
Some(device) => {
|
||||||
|
let name = device.name().unwrap_or_else(|_| "?".to_string());
|
||||||
|
info!(device = %name, "opening selected audio output device");
|
||||||
|
return DeviceSinkBuilder::from_device(device)
|
||||||
|
.with_context(|| format!("failed to open audio device {name}"))?
|
||||||
|
.open_stream()
|
||||||
|
.with_context(|| format!("failed to open a stream on audio device {name}"));
|
||||||
|
}
|
||||||
|
None => warn!(
|
||||||
|
requested = wanted,
|
||||||
|
"no audio output device name matched; using the system default"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")
|
||||||
|
}
|
||||||
|
|
||||||
impl PlayerEngine {
|
impl PlayerEngine {
|
||||||
pub fn init(
|
pub fn init(
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
tx_engine: Sender<PlayerEngineCommand>,
|
||||||
tx_player: Sender<PlayerMessage>,
|
tx_player: Sender<PlayerMessage>,
|
||||||
runtime: Option<tokio::runtime::Handle>,
|
runtime: Option<tokio::runtime::Handle>,
|
||||||
spectrum: Arc<SpectrumTap>,
|
spectrum: Arc<SpectrumTap>,
|
||||||
|
device: Option<String>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let stream =
|
let stream = open_output_device(device.as_deref())?;
|
||||||
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
|
||||||
let sink = rodio::Player::connect_new(stream.mixer());
|
let sink = rodio::Player::connect_new(stream.mixer());
|
||||||
let (runtime, owned_runtime) = match runtime {
|
let (runtime, owned_runtime) = match runtime {
|
||||||
Some(handle) => (handle, None),
|
Some(handle) => (handle, None),
|
||||||
|
|
@ -206,11 +272,19 @@ impl PlayerEngine {
|
||||||
|
|
||||||
#[instrument(skip_all, fields(source = %display_source(source_str)))]
|
#[instrument(skip_all, fields(source = %display_source(source_str)))]
|
||||||
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
||||||
|
// Make-before-break: open and decode the new source *before* stopping
|
||||||
|
// the current one. Opening a network stream blocks up to
|
||||||
|
// STREAM_OPEN_TIMEOUT, and the audio sink runs on its own thread, so
|
||||||
|
// the previous track stays audible throughout — the audible gap
|
||||||
|
// shrinks to the near-instant sink swap below. If the open fails, the
|
||||||
|
// current track keeps playing and the error propagates untouched. This
|
||||||
|
// covers every transition: Replace, Next, and end-of-track re-plays.
|
||||||
|
let (source, duration) = self.open_source(source_str)?;
|
||||||
|
|
||||||
self.reset();
|
self.reset();
|
||||||
|
self.append_source(source);
|
||||||
|
|
||||||
let duration = self.start_source(source_str)?;
|
|
||||||
let media_info = MediaInfo { duration };
|
let media_info = MediaInfo { duration };
|
||||||
|
|
||||||
self.media_info = Some(media_info.clone());
|
self.media_info = Some(media_info.clone());
|
||||||
self.current_source = Some(source_str.to_string());
|
self.current_source = Some(source_str.to_string());
|
||||||
|
|
||||||
|
|
@ -225,80 +299,164 @@ impl PlayerEngine {
|
||||||
Ok(media_info)
|
Ok(media_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes the source and appends it (plus an end-of-stream callback) to
|
/// Opens and decodes a source into a ready-to-play boxed rodio source,
|
||||||
/// the sink. Returns the total duration if known.
|
/// touching neither the sink nor the generation counter. This is the slow,
|
||||||
fn start_source(&mut self, source_str: &str) -> Result<Option<Duration>> {
|
/// network-bound step (the initial stream prefetch); keeping it off the
|
||||||
self.generation += 1;
|
/// sink lets the currently-playing track continue while it runs. Returns
|
||||||
let duration = match Url::parse(source_str) {
|
/// the decoded source and its total duration if known.
|
||||||
|
fn open_source(&self, source_str: &str) -> Result<(Box<dyn Source + Send>, Option<Duration>)> {
|
||||||
|
match Url::parse(source_str) {
|
||||||
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
||||||
trace!(
|
trace!(
|
||||||
host = url.host_str().unwrap_or("?"),
|
host = url.host_str().unwrap_or("?"),
|
||||||
"opening network stream"
|
"opening network stream"
|
||||||
);
|
);
|
||||||
// Windowed fetching: some CDNs (googlevideo) reject plain
|
// HLS (`.m3u8`, e.g. SoundCloud) is a playlist of mp3 segments,
|
||||||
// and open-ended requests with 403 and only serve bounded
|
// streamed in order by HlsStream; every other http URL uses the
|
||||||
// ranges (see audio-player/src/windowed_http.rs).
|
// windowed fetcher (some CDNs like googlevideo 403 plain and
|
||||||
let params = WindowedHttpParams::new(url.clone(), self.http.clone());
|
// open-ended requests, serving only bounded ranges — see
|
||||||
|
// audio-player/src/windowed_http.rs).
|
||||||
|
let is_hls = Path::new(url.path())
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("m3u8"));
|
||||||
let reader = self.runtime.block_on(async {
|
let reader = self.runtime.block_on(async {
|
||||||
tokio::time::timeout(
|
// Each arm normalizes its distinct `StreamInitError<S>` to
|
||||||
STREAM_OPEN_TIMEOUT,
|
// `anyhow` so the branches unify.
|
||||||
StreamDownload::new::<WindowedHttpStream>(
|
let open = async {
|
||||||
params,
|
if is_hls {
|
||||||
|
StreamDownload::new::<HlsStream>(
|
||||||
|
HlsParams::new(url.clone(), self.http.clone()),
|
||||||
TempStorageProvider::new(),
|
TempStorageProvider::new(),
|
||||||
Settings::default(),
|
Settings::default(),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
|
.map_err(|err| anyhow!("{err}"))
|
||||||
|
} else {
|
||||||
|
StreamDownload::new::<WindowedHttpStream>(
|
||||||
|
WindowedHttpParams::new(url.clone(), self.http.clone()),
|
||||||
|
TempStorageProvider::new(),
|
||||||
|
Settings::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow!("{err}"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::time::timeout(STREAM_OPEN_TIMEOUT, open)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}")
|
||||||
|
})?
|
||||||
.context("failed to open http stream")
|
.context("failed to open http stream")
|
||||||
})?;
|
})?;
|
||||||
// Symphonia probes the container length during init; without a
|
|
||||||
// known byte length it seeks from the end, which rodio 0.22 turns
|
|
||||||
// into an `unreachable!` panic on a streamed source. Handing it the
|
|
||||||
// content length up front avoids that seek entirely.
|
|
||||||
let byte_len = reader.content_length();
|
let byte_len = reader.content_length();
|
||||||
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
// HLS carries mp3 segments and no total length; hint mp3 and
|
||||||
if let Some(len) = byte_len {
|
// mark non-seekable so symphonia never end-seeks (which panics
|
||||||
builder = builder.with_byte_len(len);
|
// rodio 0.22 on a length-less stream).
|
||||||
|
let hint = if is_hls {
|
||||||
|
Some("mp3")
|
||||||
|
} else {
|
||||||
|
Path::new(url.path()).extension().and_then(|e| e.to_str())
|
||||||
|
};
|
||||||
|
self.build_source(
|
||||||
|
reader,
|
||||||
|
byte_len,
|
||||||
|
hint,
|
||||||
|
!is_hls,
|
||||||
|
"failed to decode http stream",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str())
|
Ok(url) => Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
||||||
{
|
|
||||||
builder = builder.with_hint(extension);
|
|
||||||
}
|
|
||||||
let decoder = builder.build().context("failed to decode http stream")?;
|
|
||||||
let duration = decoder.total_duration();
|
|
||||||
// Mirror the played audio into the spectrum tap (it only
|
|
||||||
// observes; playback is unaffected).
|
|
||||||
self.sink
|
|
||||||
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
|
||||||
duration
|
|
||||||
}
|
|
||||||
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
trace!(path = source_str, "opening local file");
|
trace!(path = source_str, "opening local file");
|
||||||
let file = File::open(source_str)
|
let file = File::open(source_str)
|
||||||
.with_context(|| format!("failed to open file {source_str}"))?;
|
.with_context(|| format!("failed to open file {source_str}"))?;
|
||||||
let byte_len = file.metadata().ok().map(|m| m.len());
|
let byte_len = file.metadata().ok().map(|m| m.len());
|
||||||
let mut builder = Decoder::builder()
|
let hint = Path::new(source_str)
|
||||||
.with_data(BufReader::new(file))
|
.extension()
|
||||||
.with_seekable(true);
|
.and_then(|e| e.to_str())
|
||||||
|
.map(str::to_owned);
|
||||||
|
self.build_source(
|
||||||
|
BufReader::new(file),
|
||||||
|
byte_len,
|
||||||
|
hint.as_deref(),
|
||||||
|
true,
|
||||||
|
"failed to decode file",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sniffs the stream's header and decodes it. Ogg-Opus — which symphonia
|
||||||
|
/// (hence rodio) cannot decode — is routed to [`OpusSource`]; every other
|
||||||
|
/// format goes through rodio's decoder. The reader is rewound after
|
||||||
|
/// sniffing so the chosen decoder sees the whole stream, and both paths
|
||||||
|
/// are wrapped in the spectrum tap (it only observes; playback is
|
||||||
|
/// unaffected).
|
||||||
|
fn build_source<R>(
|
||||||
|
&self,
|
||||||
|
mut reader: R,
|
||||||
|
byte_len: Option<u64>,
|
||||||
|
hint: Option<&str>,
|
||||||
|
seekable: bool,
|
||||||
|
decode_err: &'static str,
|
||||||
|
) -> Result<(Box<dyn Source + Send>, Option<Duration>)>
|
||||||
|
where
|
||||||
|
R: Read + Seek + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let mut header = [0u8; 64];
|
||||||
|
let n = read_header(&mut reader, &mut header)?;
|
||||||
|
reader
|
||||||
|
.seek(SeekFrom::Start(0))
|
||||||
|
.context("failed to rewind after sniffing the header")?;
|
||||||
|
|
||||||
|
#[cfg(feature = "opus")]
|
||||||
|
if is_ogg_opus(&header[..n]) {
|
||||||
|
debug!("decoding Ogg-Opus via libopus");
|
||||||
|
let source = OpusSource::new(reader, byte_len, seekable)?;
|
||||||
|
let duration = source.total_duration();
|
||||||
|
let tapped: Box<dyn Source + Send> =
|
||||||
|
Box::new(TappingSource::new(source, self.spectrum.clone()));
|
||||||
|
return Ok((tapped, duration));
|
||||||
|
}
|
||||||
|
// Built without the `opus` feature: rodio's decoder rejects Ogg-Opus
|
||||||
|
// (symphonia has no Opus decoder), so say why rather than let a
|
||||||
|
// "malformed stream" error stand in. Playback skips the track exactly
|
||||||
|
// as it does for any undecodable file — never a panic
|
||||||
|
// (architecture/build-features.md D6).
|
||||||
|
#[cfg(not(feature = "opus"))]
|
||||||
|
if header[..n].starts_with(b"OggS") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"cannot decode Ogg-Opus: this build has no Opus decoder (rebuild with the \
|
||||||
|
`opus` feature)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symphonia probes the container length during init; without a known
|
||||||
|
// byte length it seeks from the end, which rodio 0.22 turns into an
|
||||||
|
// `unreachable!` panic on a streamed source. Handing it the content
|
||||||
|
// length up front avoids that seek entirely; for length-less streams
|
||||||
|
// (HLS) `seekable` is false so symphonia never attempts that seek.
|
||||||
|
let mut builder = Decoder::builder().with_data(reader).with_seekable(seekable);
|
||||||
if let Some(len) = byte_len {
|
if let Some(len) = byte_len {
|
||||||
builder = builder.with_byte_len(len);
|
builder = builder.with_byte_len(len);
|
||||||
}
|
}
|
||||||
if let Some(extension) = Path::new(source_str).extension().and_then(|e| e.to_str())
|
if let Some(extension) = hint {
|
||||||
{
|
|
||||||
builder = builder.with_hint(extension);
|
builder = builder.with_hint(extension);
|
||||||
}
|
}
|
||||||
let decoder = builder.build().context("failed to decode file")?;
|
let decoder = builder.build().context(decode_err)?;
|
||||||
let duration = decoder.total_duration();
|
let duration = decoder.total_duration();
|
||||||
self.sink
|
let tapped: Box<dyn Source + Send> =
|
||||||
.append(TappingSource::new(decoder, self.spectrum.clone()));
|
Box::new(TappingSource::new(decoder, self.spectrum.clone()));
|
||||||
duration
|
Ok((tapped, duration))
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Fires only when the decoder ahead of it finished naturally; a
|
/// Appends an already-decoded source to the freshly-reset sink, followed
|
||||||
// stop/replace clears the queue before this source is ever played.
|
/// by an end-of-stream callback tagged with the current generation. The
|
||||||
|
/// callback fires only when this source finishes naturally; a later
|
||||||
|
/// stop/replace bumps the generation via `reset`, so a stale source that
|
||||||
|
/// was swapped out can never signal `Next`.
|
||||||
|
fn append_source(&mut self, source: Box<dyn Source + Send>) {
|
||||||
|
self.sink.append(source);
|
||||||
let tx_engine = self.tx_engine.clone();
|
let tx_engine = self.tx_engine.clone();
|
||||||
let generation = self.generation;
|
let generation = self.generation;
|
||||||
self.sink.append(EmptyCallback::new(Box::new(move || {
|
self.sink.append(EmptyCallback::new(Box::new(move || {
|
||||||
|
|
@ -306,8 +464,6 @@ impl PlayerEngine {
|
||||||
warn!("failed to send end-of-stream signal: {err}");
|
warn!("failed to send end-of-stream signal: {err}");
|
||||||
}
|
}
|
||||||
})));
|
})));
|
||||||
|
|
||||||
Ok(duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restart(&mut self) -> Result<MediaInfo> {
|
pub fn restart(&mut self) -> Result<MediaInfo> {
|
||||||
|
|
@ -461,6 +617,22 @@ fn send_reply<T>(tx: Sender<T>, value: T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads up to `buf.len()` bytes for format sniffing, tolerating short reads
|
||||||
|
/// and `Interrupted`. Returns how many bytes were read (fewer than the buffer
|
||||||
|
/// only at end-of-stream), so a stream shorter than the buffer is not an error.
|
||||||
|
fn read_header<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
|
||||||
|
let mut filled = 0;
|
||||||
|
while filled < buf.len() {
|
||||||
|
match reader.read(&mut buf[filled..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => filled += n,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(e) => return Err(e).context("failed to read stream header"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(filled)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "cbd-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
clap_complete.workspace = true
|
||||||
|
clap_mangen.workspace = true
|
||||||
|
|
||||||
|
# The `client` feature adds the gRPC executor for the remote
|
||||||
|
# library/queue/global commands. Kept optional so binaries' build.rs can
|
||||||
|
# depend on this crate (default features = clap only) without dragging in
|
||||||
|
# tonic on every build (architecture/cli.md D1/D7).
|
||||||
|
crabidy-core = { workspace = true, optional = true }
|
||||||
|
tonic = { workspace = true, optional = true, features = [
|
||||||
|
"transport",
|
||||||
|
"codegen",
|
||||||
|
] }
|
||||||
|
tokio = { workspace = true, optional = true, features = [
|
||||||
|
"rt-multi-thread",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
base64 = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
client = ["dep:crabidy-core", "dep:tonic", "dep:tokio", "dep:base64"]
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
//! The gRPC executor for the remote `library`/`queue`/`global` commands
|
||||||
|
//! (`client` feature). Connects a generated `crabidy-core` client with a
|
||||||
|
//! basic-auth interceptor and runs one command against a server, printing a
|
||||||
|
//! human-readable result.
|
||||||
|
//!
|
||||||
|
//! Transport and gRPC `Status` errors are mapped to a short one-line message
|
||||||
|
//! (no color-eyre chain dump) — an ordinary "server unreachable" reads as a
|
||||||
|
//! single line (architecture/cli.md D3, quality gate "remote errors").
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use tonic::metadata::MetadataValue;
|
||||||
|
use tonic::service::{interceptor::InterceptedService, Interceptor};
|
||||||
|
use tonic::transport::{Channel, Endpoint};
|
||||||
|
use tonic::{Request, Status};
|
||||||
|
|
||||||
|
use crabidy_core::proto::crabidy::{
|
||||||
|
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
|
||||||
|
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||||
|
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
|
||||||
|
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest,
|
||||||
|
SaveQueueRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest,
|
||||||
|
ToggleRepeatRequest, ToggleShuffleRequest, Track,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd};
|
||||||
|
|
||||||
|
/// The reserved library path the live queue mirrors into; `queue capture`
|
||||||
|
/// captures it (architecture/crabidy-store.md).
|
||||||
|
const CURRENT_NODE: &str = "/crabidy/current";
|
||||||
|
|
||||||
|
/// How long to wait for the TCP connect before giving up — a CLI must not
|
||||||
|
/// hang forever against an unreachable server.
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
/// Per-request deadline. Generous: `CaptureLibraryNode` returns once the
|
||||||
|
/// capture is *accepted*; the walk runs server-side.
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Attaches `authorization: Basic …` when a user is configured; an empty user
|
||||||
|
/// means an open server. The header value is a secret and never logged.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthInterceptor {
|
||||||
|
header: Option<MetadataValue<tonic::metadata::Ascii>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthInterceptor {
|
||||||
|
fn new(user: &str, password: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
if user.is_empty() {
|
||||||
|
return Ok(Self { header: None });
|
||||||
|
}
|
||||||
|
let encoded =
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
|
||||||
|
let header = format!("Basic {encoded}")
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "cannot encode credentials header")?;
|
||||||
|
Ok(Self {
|
||||||
|
header: Some(header),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interceptor for AuthInterceptor {
|
||||||
|
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
|
||||||
|
if let Some(header) = &self.header {
|
||||||
|
request
|
||||||
|
.metadata_mut()
|
||||||
|
.insert("authorization", header.clone());
|
||||||
|
}
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
|
||||||
|
|
||||||
|
/// Connects (lazily) to the server described by `conn`, with a bounded
|
||||||
|
/// connect timeout and per-request deadline.
|
||||||
|
async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>> {
|
||||||
|
let endpoint = Endpoint::from_shared(conn.address.clone())?
|
||||||
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.connect_lazy();
|
||||||
|
let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?;
|
||||||
|
Ok(CrabidyServiceClient::with_interceptor(
|
||||||
|
endpoint,
|
||||||
|
interceptor,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a gRPC [`Status`] to a short, single-line error — the code plus its
|
||||||
|
/// message, never an internal report chain. A transport failure surfaces as
|
||||||
|
/// `Unavailable` with tonic's one-line reason.
|
||||||
|
fn rpc_error(status: Status) -> Box<dyn std::error::Error> {
|
||||||
|
let code = status.code();
|
||||||
|
let message = status.message();
|
||||||
|
if message.is_empty() {
|
||||||
|
format!("server error: {code}").into()
|
||||||
|
} else {
|
||||||
|
format!("{message} ({code})").into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one remote command against the server and prints the result.
|
||||||
|
pub async fn run_remote(
|
||||||
|
conn: &Connection,
|
||||||
|
cmd: RemoteCmd,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut client = connect(conn).await?;
|
||||||
|
match cmd {
|
||||||
|
RemoteCmd::Library(cmd) => run_library(&mut client, cmd).await,
|
||||||
|
RemoteCmd::Queue(cmd) => run_queue(&mut client, cmd).await,
|
||||||
|
RemoteCmd::Global(cmd) => run_global(&mut client, cmd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_library(
|
||||||
|
client: &mut Client,
|
||||||
|
cmd: LibraryCmd,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match cmd {
|
||||||
|
LibraryCmd::List { path } => {
|
||||||
|
let response = client
|
||||||
|
.get_library_node(GetLibraryNodeRequest { path: path.clone() })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
match response.into_inner().node {
|
||||||
|
Some(node) => print_node(&node),
|
||||||
|
None => return Err(format!("no such library node: {path}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LibraryCmd::Create { parent, title } => {
|
||||||
|
client
|
||||||
|
.create_library_node(CreateLibraryNodeRequest {
|
||||||
|
parent_path: parent.clone(),
|
||||||
|
title: title.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("created \"{title}\" under {parent}");
|
||||||
|
}
|
||||||
|
LibraryCmd::Rename { path, title } => {
|
||||||
|
client
|
||||||
|
.rename_library_node(RenameLibraryNodeRequest {
|
||||||
|
path: path.clone(),
|
||||||
|
new_title: title.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("renamed {path} to \"{title}\"");
|
||||||
|
}
|
||||||
|
LibraryCmd::Delete { path } => {
|
||||||
|
client
|
||||||
|
.delete_library_node(DeleteLibraryNodeRequest { path: path.clone() })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("deleted {path}");
|
||||||
|
}
|
||||||
|
LibraryCmd::Save { path, name } => {
|
||||||
|
capture(client, &path, &name, false).await?;
|
||||||
|
println!("saving {path} as /crabidy/{name} (link)");
|
||||||
|
}
|
||||||
|
LibraryCmd::Capture { path, name } => {
|
||||||
|
capture(client, &path, &name, true).await?;
|
||||||
|
println!("capturing {path} into /crabidy/{name} (download)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match cmd {
|
||||||
|
QueueCmd::Show => {
|
||||||
|
let response = client.init(InitRequest {}).await.map_err(rpc_error)?;
|
||||||
|
match response.into_inner().queue {
|
||||||
|
Some(queue) => print_queue(&queue),
|
||||||
|
None => println!("the queue is empty"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QueueCmd::Append { paths } => {
|
||||||
|
let n = paths.len();
|
||||||
|
client
|
||||||
|
.append(AppendRequest { paths })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("appended {n} path(s)");
|
||||||
|
}
|
||||||
|
QueueCmd::Insert { position, paths } => {
|
||||||
|
let n = paths.len();
|
||||||
|
client
|
||||||
|
.insert(InsertRequest { position, paths })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("inserted {n} path(s) at {position}");
|
||||||
|
}
|
||||||
|
QueueCmd::Replace { paths } => {
|
||||||
|
let n = paths.len();
|
||||||
|
client
|
||||||
|
.replace(ReplaceRequest { paths })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("replaced the queue with {n} path(s)");
|
||||||
|
}
|
||||||
|
QueueCmd::Remove { positions } => {
|
||||||
|
let n = positions.len();
|
||||||
|
client
|
||||||
|
.remove(RemoveRequest { positions })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("removed {n} entry(ies)");
|
||||||
|
}
|
||||||
|
QueueCmd::Clear { keep_current } => {
|
||||||
|
client
|
||||||
|
.clear_queue(ClearQueueRequest {
|
||||||
|
exclude_current: keep_current,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("cleared the queue");
|
||||||
|
}
|
||||||
|
QueueCmd::SetCurrent { position } => {
|
||||||
|
client
|
||||||
|
.set_current(SetCurrentRequest { position })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("jumped to position {position}");
|
||||||
|
}
|
||||||
|
QueueCmd::Save { name } => {
|
||||||
|
client
|
||||||
|
.save_queue(SaveQueueRequest { name: name.clone() })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("saving the queue as /crabidy/{name} (link)");
|
||||||
|
}
|
||||||
|
QueueCmd::Capture { name } => {
|
||||||
|
capture(client, CURRENT_NODE, &name, true).await?;
|
||||||
|
println!("capturing the queue into /crabidy/{name} (download)");
|
||||||
|
}
|
||||||
|
QueueCmd::Shuffle => {
|
||||||
|
client
|
||||||
|
.toggle_shuffle(ToggleShuffleRequest {})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("toggled shuffle");
|
||||||
|
}
|
||||||
|
QueueCmd::Repeat => {
|
||||||
|
client
|
||||||
|
.toggle_repeat(ToggleRepeatRequest {})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("toggled repeat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match cmd {
|
||||||
|
GlobalCmd::Play => {
|
||||||
|
client
|
||||||
|
.toggle_play(TogglePlayRequest {})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("toggled play/pause");
|
||||||
|
}
|
||||||
|
GlobalCmd::Stop => {
|
||||||
|
client.stop(StopRequest {}).await.map_err(rpc_error)?;
|
||||||
|
println!("stopped");
|
||||||
|
}
|
||||||
|
GlobalCmd::Next => {
|
||||||
|
client.next(NextRequest {}).await.map_err(rpc_error)?;
|
||||||
|
println!("next track");
|
||||||
|
}
|
||||||
|
GlobalCmd::Prev => {
|
||||||
|
client.prev(PrevRequest {}).await.map_err(rpc_error)?;
|
||||||
|
println!("previous track");
|
||||||
|
}
|
||||||
|
GlobalCmd::Restart => {
|
||||||
|
client
|
||||||
|
.restart_track(RestartTrackRequest {})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("restarted the current track");
|
||||||
|
}
|
||||||
|
GlobalCmd::Mute => {
|
||||||
|
client
|
||||||
|
.toggle_mute(ToggleMuteRequest {})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("toggled mute");
|
||||||
|
}
|
||||||
|
GlobalCmd::Volume { delta } => {
|
||||||
|
client
|
||||||
|
.change_volume(ChangeVolumeRequest { delta })
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
println!("changed volume by {delta:+}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues a `CaptureLibraryNode` (link save or download capture).
|
||||||
|
async fn capture(
|
||||||
|
client: &mut Client,
|
||||||
|
path: &str,
|
||||||
|
name: &str,
|
||||||
|
download: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
client
|
||||||
|
.capture_library_node(CaptureLibraryNodeRequest {
|
||||||
|
path: path.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
download,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(rpc_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints a library node: its path/title, child nodes, then tracks. Captured
|
||||||
|
/// rows are marked `*` (architecture/crabidy-store.md).
|
||||||
|
fn print_node(node: &LibraryNode) {
|
||||||
|
let marker = if node.is_captured { " *" } else { "" };
|
||||||
|
println!("{} \"{}\"{marker}", node.path, node.title);
|
||||||
|
if !node.children.is_empty() {
|
||||||
|
println!("nodes:");
|
||||||
|
for child in &node.children {
|
||||||
|
let marker = if child.is_captured { " *" } else { "" };
|
||||||
|
println!(" {} \"{}\"{marker}", child.path, child.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !node.tracks.is_empty() {
|
||||||
|
println!("tracks:");
|
||||||
|
for track in &node.tracks {
|
||||||
|
println!(" {} {}", track.path, track_label(track));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node.children.is_empty() && node.tracks.is_empty() {
|
||||||
|
println!("(empty)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints the current queue with a marker on the current track.
|
||||||
|
fn print_queue(queue: &Queue) {
|
||||||
|
if queue.tracks.is_empty() {
|
||||||
|
println!("the queue is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (index, track) in queue.tracks.iter().enumerate() {
|
||||||
|
let here = if index as u32 == queue.current_position {
|
||||||
|
">"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
println!("{here} {index:>3} {}", track_label(track));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line `artist - title` label, marking captured/skipped tracks.
|
||||||
|
fn track_label(track: &Track) -> String {
|
||||||
|
let mut label = if track.artist.is_empty() {
|
||||||
|
track.title.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} - {}", track.artist, track.title)
|
||||||
|
};
|
||||||
|
if track.is_captured {
|
||||||
|
label.push_str(" *");
|
||||||
|
}
|
||||||
|
if track.is_skipped {
|
||||||
|
label.push_str(" (skipped)");
|
||||||
|
}
|
||||||
|
label
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
//! Shared command-line definitions for the crabidy binaries
|
||||||
|
//! (`crabidy-server`, `cbd-tui`, `cbd`), plus a feature-gated gRPC executor
|
||||||
|
//! for the remote `library`/`queue`/`global` commands.
|
||||||
|
//!
|
||||||
|
//! See `architecture/cli.md`. The clap types live here so every binary shares
|
||||||
|
//! one command surface and each binary's `build.rs` can generate shell
|
||||||
|
//! completions and man pages from its own top-level [`clap::Command`] with the
|
||||||
|
//! default (clap-only) feature set. The `client` feature adds [`run_remote`],
|
||||||
|
//! which the binaries call to execute a remote command against a running
|
||||||
|
//! server.
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
|
|
||||||
|
/// A server auth role. The `ValueEnum` names double as the basic-auth user
|
||||||
|
/// names the server expects.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum Role {
|
||||||
|
/// Full control (the normal user).
|
||||||
|
Owner,
|
||||||
|
/// Anything on the queue and playback, no library writes.
|
||||||
|
QueueOwner,
|
||||||
|
/// May browse and append to the queue; nothing else.
|
||||||
|
Appender,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
/// The basic-auth user name for this role.
|
||||||
|
pub fn user_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Role::Owner => "owner",
|
||||||
|
Role::QueueOwner => "queue-owner",
|
||||||
|
Role::Appender => "queue-appender",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection flags shared by every remote command. Defaults match the client
|
||||||
|
/// config; a flag overrides the config-file value.
|
||||||
|
#[derive(Debug, Clone, Args)]
|
||||||
|
pub struct RemoteArgs {
|
||||||
|
/// Server address (default: the client config's, else localhost).
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub address: Option<String>,
|
||||||
|
/// Basic-auth user / role name (empty against an open server).
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub user: Option<String>,
|
||||||
|
/// Basic-auth password.
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Library operations against a running server.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum LibraryCmd {
|
||||||
|
/// List a node's child nodes and tracks (default path `/`).
|
||||||
|
List {
|
||||||
|
#[arg(default_value = "/")]
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Create a child node under a creatable parent (e.g. a search term).
|
||||||
|
Create { parent: String, title: String },
|
||||||
|
/// Rename an editable node.
|
||||||
|
Rename { path: String, title: String },
|
||||||
|
/// Delete a deletable node or track (never touches the content store).
|
||||||
|
Delete { path: String },
|
||||||
|
/// Save a subtree as a link bookmark under `/crabidy/<name>`.
|
||||||
|
Save { path: String, name: String },
|
||||||
|
/// Capture a subtree under `/crabidy/<name>` (downloads audio).
|
||||||
|
Capture { path: String, name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue operations against a running server.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum QueueCmd {
|
||||||
|
/// Print the current queue.
|
||||||
|
Show,
|
||||||
|
/// Append tracks/subtrees (by library path) to the end of the queue.
|
||||||
|
Append { paths: Vec<String> },
|
||||||
|
/// Insert tracks/subtrees at a position.
|
||||||
|
Insert { position: u32, paths: Vec<String> },
|
||||||
|
/// Replace the whole queue with the given tracks/subtrees.
|
||||||
|
Replace { paths: Vec<String> },
|
||||||
|
/// Remove queue entries by position.
|
||||||
|
Remove { positions: Vec<u32> },
|
||||||
|
/// Clear the queue (optionally keeping the current track).
|
||||||
|
Clear {
|
||||||
|
#[arg(long)]
|
||||||
|
keep_current: bool,
|
||||||
|
},
|
||||||
|
/// Jump to a queue position.
|
||||||
|
SetCurrent { position: u32 },
|
||||||
|
/// Link-save the current queue as `/crabidy/<name>`.
|
||||||
|
Save { name: String },
|
||||||
|
/// Capture the current queue into `/crabidy/<name>` (downloads audio).
|
||||||
|
Capture { name: String },
|
||||||
|
/// Toggle shuffle.
|
||||||
|
Shuffle,
|
||||||
|
/// Toggle repeat.
|
||||||
|
Repeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback and global operations against a running server.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum GlobalCmd {
|
||||||
|
/// Toggle play/pause.
|
||||||
|
Play,
|
||||||
|
/// Stop playback.
|
||||||
|
Stop,
|
||||||
|
/// Skip to the next track.
|
||||||
|
Next,
|
||||||
|
/// Go to the previous track.
|
||||||
|
Prev,
|
||||||
|
/// Restart the current track.
|
||||||
|
Restart,
|
||||||
|
/// Toggle mute.
|
||||||
|
Mute,
|
||||||
|
/// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`).
|
||||||
|
Volume { delta: f32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The remote command groups an executor can run (`client` feature).
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum RemoteCmd {
|
||||||
|
/// Library operations.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Library(LibraryCmd),
|
||||||
|
/// Queue operations.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Queue(QueueCmd),
|
||||||
|
/// Playback / global operations.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Global(GlobalCmd),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `guard <role> [password]`: hash a role password and (unless `--no-config`)
|
||||||
|
/// write it into `crabidy-server.toml`'s `[auth]`.
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct GuardArgs {
|
||||||
|
/// Which role's password to set.
|
||||||
|
pub role: Role,
|
||||||
|
/// The password. If omitted, it is read from stdin (pipe-friendly, and
|
||||||
|
/// keeps it out of the process list).
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// Only print the PHC hash; do not modify `crabidy-server.toml`.
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_config: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `scan <path> [--capture|--move]`: index a music folder.
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ScanArgs {
|
||||||
|
/// Directory to walk for playable files.
|
||||||
|
pub path: std::path::PathBuf,
|
||||||
|
/// Copy each file into the content store; the toml points there.
|
||||||
|
#[arg(long)]
|
||||||
|
pub capture: bool,
|
||||||
|
/// Move each file into the content store instead of copying.
|
||||||
|
#[arg(long, name = "move")]
|
||||||
|
pub move_: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `auth <role> [password]`: write a role + cleartext password into the client
|
||||||
|
/// config.
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct AuthArgs {
|
||||||
|
/// Which role to authenticate as.
|
||||||
|
pub role: Role,
|
||||||
|
/// The cleartext password (read from stdin if omitted).
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// Also set the server address in the client config.
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub address: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `completions <shell>`: print a completion script to stdout.
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct CompletionsArgs {
|
||||||
|
/// Target shell.
|
||||||
|
pub shell: clap_complete::Shell,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `audio-devices [device]`: list the output devices, or — with `device` — set
|
||||||
|
/// `[audio] device` in `crabidy-server.toml`.
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct AudioDevicesArgs {
|
||||||
|
/// A name (or case-insensitive fragment) of an output device to write into
|
||||||
|
/// `[audio] device`. Omit to just list the available devices.
|
||||||
|
pub device: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subcommands of `crabidy-server`.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum ServerCommand {
|
||||||
|
/// Set a role password (hash + write config).
|
||||||
|
Guard(GuardArgs),
|
||||||
|
/// Index a music folder with `.cbd-track.toml` files.
|
||||||
|
Scan(ScanArgs),
|
||||||
|
/// Library operations against a running server.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Library(LibraryCmd),
|
||||||
|
/// Queue operations against a running server.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Queue(QueueCmd),
|
||||||
|
/// Playback / global operations against a running server.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Global(GlobalCmd),
|
||||||
|
/// List audio output devices, or set `[audio] device` if one is given.
|
||||||
|
AudioDevices(AudioDevicesArgs),
|
||||||
|
/// Print the build features this binary was compiled with.
|
||||||
|
Features,
|
||||||
|
/// Print a shell completion script.
|
||||||
|
Completions(CompletionsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `crabidy-server`: no subcommand runs the server.
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "crabidy-server", author, version, about)]
|
||||||
|
pub struct ServerCli {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub remote: RemoteArgs,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<ServerCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subcommands of `cbd-tui`.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum TuiCommand {
|
||||||
|
/// Write a role + password into the client config.
|
||||||
|
Auth(AuthArgs),
|
||||||
|
#[command(subcommand)]
|
||||||
|
Library(LibraryCmd),
|
||||||
|
#[command(subcommand)]
|
||||||
|
Queue(QueueCmd),
|
||||||
|
#[command(subcommand)]
|
||||||
|
Global(GlobalCmd),
|
||||||
|
/// Print a shell completion script.
|
||||||
|
Completions(CompletionsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cbd-tui`: no subcommand runs the TUI.
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "cbd-tui", author, version, about)]
|
||||||
|
pub struct TuiCli {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub remote: RemoteArgs,
|
||||||
|
/// Show the frequency-spectrum bars under the track progress.
|
||||||
|
#[arg(long)]
|
||||||
|
pub spectrum: Option<bool>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<TuiCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subcommands of `cbd` — the union of the server and client commands.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum CbdCommand {
|
||||||
|
Guard(GuardArgs),
|
||||||
|
Scan(ScanArgs),
|
||||||
|
Auth(AuthArgs),
|
||||||
|
#[command(subcommand)]
|
||||||
|
Library(LibraryCmd),
|
||||||
|
#[command(subcommand)]
|
||||||
|
Queue(QueueCmd),
|
||||||
|
#[command(subcommand)]
|
||||||
|
Global(GlobalCmd),
|
||||||
|
/// List audio output devices, or set `[audio] device` if one is given.
|
||||||
|
AudioDevices(AudioDevicesArgs),
|
||||||
|
/// Print the build features this binary was compiled with.
|
||||||
|
Features,
|
||||||
|
/// Print a shell completion script.
|
||||||
|
Completions(CompletionsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cbd`: no subcommand runs the in-process server + TUI.
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "cbd", author, version, about)]
|
||||||
|
pub struct CbdCli {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub remote: RemoteArgs,
|
||||||
|
#[arg(long)]
|
||||||
|
pub spectrum: Option<bool>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<CbdCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved connection settings for the executor (owned, unlike the TUI's
|
||||||
|
/// `&'static ServerConfig`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Connection {
|
||||||
|
pub address: String,
|
||||||
|
pub user: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes bash/zsh/fish completions and a man page for `cmd` into `dir`.
|
||||||
|
/// Used by each binary's `build.rs` (architecture/cli.md D7).
|
||||||
|
pub fn generate_assets(
|
||||||
|
mut cmd: clap::Command,
|
||||||
|
bin_name: &str,
|
||||||
|
dir: &std::path::Path,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
use clap_complete::Shell;
|
||||||
|
std::fs::create_dir_all(dir.join("completions"))?;
|
||||||
|
std::fs::create_dir_all(dir.join("man"))?;
|
||||||
|
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
|
||||||
|
clap_complete::generate_to(shell, &mut cmd, bin_name, dir.join("completions"))?;
|
||||||
|
}
|
||||||
|
let man = clap_mangen::Man::new(cmd);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
man.render(&mut out)?;
|
||||||
|
std::fs::write(dir.join("man").join(format!("{bin_name}.1")), out)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints a completion script for `shell` to stdout (the `completions`
|
||||||
|
/// subcommand).
|
||||||
|
pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, bin_name: &str) {
|
||||||
|
clap_complete::generate(shell, cmd, bin_name, &mut std::io::stdout());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
mod client;
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
pub use client::run_remote;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_subcommand_parses_on_every_binary() {
|
||||||
|
assert!(ServerCli::try_parse_from(["crabidy-server"])
|
||||||
|
.expect("server")
|
||||||
|
.command
|
||||||
|
.is_none());
|
||||||
|
assert!(TuiCli::try_parse_from(["cbd-tui"])
|
||||||
|
.expect("tui")
|
||||||
|
.command
|
||||||
|
.is_none());
|
||||||
|
assert!(CbdCli::try_parse_from(["cbd"])
|
||||||
|
.expect("cbd")
|
||||||
|
.command
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_flags_override_when_present_and_are_none_otherwise() {
|
||||||
|
let cli = TuiCli::try_parse_from(["cbd-tui", "--address", "http://x:1", "-u", "owner"])
|
||||||
|
.expect("parse");
|
||||||
|
assert_eq!(cli.remote.address.as_deref(), Some("http://x:1"));
|
||||||
|
assert_eq!(cli.remote.user.as_deref(), Some("owner"));
|
||||||
|
assert!(cli.remote.password.is_none());
|
||||||
|
|
||||||
|
let bare = TuiCli::try_parse_from(["cbd-tui"]).expect("bare");
|
||||||
|
assert!(bare.remote.address.is_none());
|
||||||
|
assert!(bare.remote.user.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_list_defaults_to_root() {
|
||||||
|
let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list"]).expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/"),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list", "/tidal"])
|
||||||
|
.expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/tidal"),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_append_collects_many_paths() {
|
||||||
|
let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "append", "/a", "/b", "/c"])
|
||||||
|
.expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(TuiCommand::Queue(QueueCmd::Append { paths })) => {
|
||||||
|
assert_eq!(paths, vec!["/a", "/b", "/c"]);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_clear_keep_current_is_a_flag() {
|
||||||
|
let cli =
|
||||||
|
TuiCli::try_parse_from(["cbd-tui", "queue", "clear", "--keep-current"]).expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(TuiCommand::Queue(QueueCmd::Clear { keep_current })) => assert!(keep_current),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_volume_parses_a_signed_delta() {
|
||||||
|
let cli =
|
||||||
|
TuiCli::try_parse_from(["cbd-tui", "global", "volume", "--", "-0.1"]).expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(TuiCommand::Global(GlobalCmd::Volume { delta })) => {
|
||||||
|
assert!((delta - -0.1).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_reads_a_role_and_optional_password() {
|
||||||
|
let cli = ServerCli::try_parse_from(["crabidy-server", "guard", "owner"]).expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(ServerCommand::Guard(args)) => {
|
||||||
|
assert_eq!(args.role, Role::Owner);
|
||||||
|
assert!(args.password.is_none());
|
||||||
|
assert!(!args.no_config);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
let cli = ServerCli::try_parse_from([
|
||||||
|
"crabidy-server",
|
||||||
|
"guard",
|
||||||
|
"queue-owner",
|
||||||
|
"secret",
|
||||||
|
"--no-config",
|
||||||
|
])
|
||||||
|
.expect("parse");
|
||||||
|
match cli.command {
|
||||||
|
Some(ServerCommand::Guard(args)) => {
|
||||||
|
assert_eq!(args.role, Role::QueueOwner);
|
||||||
|
assert_eq!(args.password.as_deref(), Some("secret"));
|
||||||
|
assert!(args.no_config);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_flags_parse() {
|
||||||
|
let cli = ServerCli::try_parse_from(["crabidy-server", "scan", "/music", "--capture"])
|
||||||
|
.expect("p");
|
||||||
|
match cli.command {
|
||||||
|
Some(ServerCommand::Scan(args)) => {
|
||||||
|
assert_eq!(args.path, std::path::PathBuf::from("/music"));
|
||||||
|
assert!(args.capture);
|
||||||
|
assert!(!args.move_);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cbd_is_the_union_of_server_and_client_commands() {
|
||||||
|
assert!(matches!(
|
||||||
|
CbdCli::try_parse_from(["cbd", "guard", "owner"])
|
||||||
|
.expect("p")
|
||||||
|
.command,
|
||||||
|
Some(CbdCommand::Guard(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
CbdCli::try_parse_from(["cbd", "auth", "owner", "pw"])
|
||||||
|
.expect("p")
|
||||||
|
.command,
|
||||||
|
Some(CbdCommand::Auth(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
CbdCli::try_parse_from(["cbd", "global", "play"])
|
||||||
|
.expect("p")
|
||||||
|
.command,
|
||||||
|
Some(CbdCommand::Global(GlobalCmd::Play))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,13 +3,23 @@ name = "cbd-tui"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
# Desktop "now playing" notifications pull notify-rust and, on Linux, a
|
||||||
|
# D-Bus stack. On by default; off for a terminal-only client
|
||||||
|
# (architecture/build-features.md D1).
|
||||||
|
[features]
|
||||||
|
default = ["notifications"]
|
||||||
|
notifications = ["dep:notify-rust"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
cbd-cli = { workspace = true, features = ["client"] }
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
notify-rust.workspace = true
|
notify-rust = { workspace = true, optional = true }
|
||||||
ratatui.workspace = true
|
ratatui.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
@ -18,3 +28,11 @@ tonic = { workspace = true, features = ["channel", "codegen"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
# Default features only (clap-only) so asset generation stays cheap.
|
||||||
|
[build-dependencies]
|
||||||
|
cbd-cli.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
//! Generates shell completions and a man page for `cbd-tui` from its
|
||||||
|
//! top-level clap command (architecture/cli.md D7). Written into `OUT_DIR`
|
||||||
|
//! every build, and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli`
|
||||||
|
//! is a default-features (clap-only) build-dependency, so this never pulls
|
||||||
|
//! tonic into ordinary builds.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
use clap::CommandFactory;
|
||||||
|
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
|
||||||
|
let bin = "cbd-tui";
|
||||||
|
let command = cbd_cli::TuiCli::command();
|
||||||
|
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
|
||||||
|
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
|
||||||
|
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
|
||||||
|
}
|
||||||
|
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
|
||||||
|
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
|
||||||
|
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,9 @@ pub enum Action {
|
||||||
ToggleRepeat,
|
ToggleRepeat,
|
||||||
NextTrack,
|
NextTrack,
|
||||||
PrevTrack,
|
PrevTrack,
|
||||||
|
/// Show or hide the frequency-spectrum visualizer in the now-playing
|
||||||
|
/// pane (client-side only; the server keeps streaming the bars).
|
||||||
|
ToggleSpectrum,
|
||||||
// Library pane
|
// Library pane
|
||||||
LibraryFirst,
|
LibraryFirst,
|
||||||
LibraryLast,
|
LibraryLast,
|
||||||
|
|
@ -55,6 +58,11 @@ pub enum Action {
|
||||||
LibraryQueueAppend,
|
LibraryQueueAppend,
|
||||||
LibraryQueueReplace,
|
LibraryQueueReplace,
|
||||||
LibraryToggleMark,
|
LibraryToggleMark,
|
||||||
|
/// Enter (or leave) visual mode: while active, movement toggles the mark
|
||||||
|
/// of every row it sweeps over, so a run of items is marked by `v` then
|
||||||
|
/// moving (architecture/visual-mode.md). Entering toggles the current row;
|
||||||
|
/// `v`/`V`/`Esc` (or any other action) leaves it, marks kept.
|
||||||
|
LibraryVisualMode,
|
||||||
/// Open the input overlay to create a child of the currently open
|
/// Open the input overlay to create a child of the currently open
|
||||||
/// library node. No-op unless that node `is_creatable` (e.g.
|
/// library node. No-op unless that node `is_creatable` (e.g.
|
||||||
/// /tidal/search). While the overlay is open, keys bypass this table
|
/// /tidal/search). While the overlay is open, keys bypass this table
|
||||||
|
|
@ -65,24 +73,29 @@ pub enum Action {
|
||||||
/// term, whose rename re-runs the search).
|
/// term, whose rename re-runs the search).
|
||||||
LibraryEditNode,
|
LibraryEditNode,
|
||||||
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
/// Delete the selected item. No-op unless the selection `is_deletable`.
|
||||||
/// Cheap deletables (search terms, bookmarks, saved queues) delete
|
/// Deletes go through directly (no confirmation): a `/crabidy` delete
|
||||||
/// unconfirmed (architecture/node-editing.md, D4); captures hold
|
/// removes only the metadata toml, never the shared content store
|
||||||
/// downloaded audio and open a y/N confirmation instead
|
/// (architecture/crabidy-store.md D7).
|
||||||
/// (architecture/capture-deletion.md).
|
|
||||||
LibraryDeleteNode,
|
LibraryDeleteNode,
|
||||||
/// Open the input overlay (prefilled with the selection's title) to
|
/// Open the input overlay (prefilled with the selection's title) to
|
||||||
/// capture the selected queueable subtree as a bookmark under
|
/// save the selected queueable subtree as a link bookmark under
|
||||||
/// `/bookmarks`. No-op unless the bare selection `is_queable`.
|
/// `/crabidy/<name>`. No-op unless the bare selection `is_queable`.
|
||||||
LibraryCaptureNode,
|
LibraryCaptureNode,
|
||||||
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
/// Like [`Self::LibraryCaptureNode`], but the capture **downloads**
|
||||||
/// every track's audio into `/captures`. No-op unless the bare
|
/// every track's audio into the shared content store (de-duplicated).
|
||||||
/// selection is queueable *and* downloadable (e.g. Tidal subtrees).
|
/// No-op unless the bare selection is queueable *and* downloadable
|
||||||
|
/// (e.g. Tidal subtrees).
|
||||||
LibraryDownloadNode,
|
LibraryDownloadNode,
|
||||||
/// Open the `/` search input for the focused pane. Typing filters the
|
/// Open the `/` search input for the focused pane. Typing filters the
|
||||||
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
/// pane's items live (case-insensitive substring); `Enter` keeps the
|
||||||
/// filter, `Esc` clears it. Bound in both the library and queue
|
/// filter and returns to normal navigation of the filtered view;
|
||||||
/// scopes; the dispatch targets whichever pane has focus.
|
/// `/` again re-opens the input with the current query for editing.
|
||||||
|
/// Bound in both the library and queue scopes; the dispatch targets
|
||||||
|
/// whichever pane has focus.
|
||||||
OpenSearch,
|
OpenSearch,
|
||||||
|
/// Clear the focused pane's active `/` filter (bound to `Esc` in the
|
||||||
|
/// library and queue scopes). A no-op when no filter is applied.
|
||||||
|
ClearSearch,
|
||||||
// Queue pane
|
// Queue pane
|
||||||
QueueInsertHere,
|
QueueInsertHere,
|
||||||
QueueFirst,
|
QueueFirst,
|
||||||
|
|
@ -97,12 +110,12 @@ pub enum Action {
|
||||||
QueueClearKeepCurrent,
|
QueueClearKeepCurrent,
|
||||||
QueueClearAll,
|
QueueClearAll,
|
||||||
/// Open the input overlay asking for a name to save the queue under
|
/// Open the input overlay asking for a name to save the queue under
|
||||||
/// (persisted as `/queues/<name>`). No-op while the queue is empty.
|
/// (a link save at `/crabidy/<name>`). No-op while the queue is empty.
|
||||||
QueueSaveAs,
|
QueueSaveAs,
|
||||||
/// Open the input overlay to **download-capture** the current queue
|
/// Open the input overlay to **download-capture** the current queue
|
||||||
/// straight into `/captures/<name>` (the queue equivalent of the
|
/// into `/crabidy/<name>` (the queue equivalent of the library's `W`),
|
||||||
/// library's `W`), instead of having to save it and then capture the
|
/// audio de-duplicated into the shared store. No-op while the queue is
|
||||||
/// saved copy. No-op while the queue is empty.
|
/// empty.
|
||||||
QueueDownloadCapture,
|
QueueDownloadCapture,
|
||||||
// Help modal
|
// Help modal
|
||||||
CloseHelp,
|
CloseHelp,
|
||||||
|
|
@ -211,6 +224,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::PrevTrack,
|
action: Action::PrevTrack,
|
||||||
description: "Previous track",
|
description: "Previous track",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Global,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('f'),
|
||||||
|
action: Action::ToggleSpectrum,
|
||||||
|
description: "Toggle the frequency spectrum",
|
||||||
|
},
|
||||||
// -- Library ---------------------------------------------------------
|
// -- Library ---------------------------------------------------------
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
|
|
@ -275,6 +295,20 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::LibraryToggleMark,
|
action: Action::LibraryToggleMark,
|
||||||
description: "Mark/unmark selection",
|
description: "Mark/unmark selection",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Char('v'),
|
||||||
|
action: Action::LibraryVisualMode,
|
||||||
|
description: "Visual mode: movement toggles marks (v/V; Esc to leave)",
|
||||||
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::SHIFT,
|
||||||
|
code: KeyCode::Char('V'),
|
||||||
|
action: Action::LibraryVisualMode,
|
||||||
|
description: "Visual mode (same as v)",
|
||||||
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
|
|
@ -287,7 +321,7 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
mods: KeyModifiers::SHIFT,
|
mods: KeyModifiers::SHIFT,
|
||||||
code: KeyCode::Char('W'),
|
code: KeyCode::Char('W'),
|
||||||
action: Action::LibraryDownloadNode,
|
action: Action::LibraryDownloadNode,
|
||||||
description: "Download selection as capture (can take long; same name resumes)",
|
description: "Capture selection into /crabidy (downloads audio; can take long)",
|
||||||
},
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
|
|
@ -308,7 +342,7 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
mods: KeyModifiers::NONE,
|
mods: KeyModifiers::NONE,
|
||||||
code: KeyCode::Char('d'),
|
code: KeyCode::Char('d'),
|
||||||
action: Action::LibraryDeleteNode,
|
action: Action::LibraryDeleteNode,
|
||||||
description: "Delete selection (captures ask y/N, and delete files)",
|
description: "Delete selection",
|
||||||
},
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Library,
|
scope: Scope::Library,
|
||||||
|
|
@ -338,6 +372,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::OpenSearch,
|
action: Action::OpenSearch,
|
||||||
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Library,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Esc,
|
||||||
|
action: Action::ClearSearch,
|
||||||
|
description: "Clear the search filter",
|
||||||
|
},
|
||||||
// -- Queue -----------------------------------------------------------
|
// -- Queue -----------------------------------------------------------
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
|
|
@ -435,7 +476,7 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
mods: KeyModifiers::SHIFT,
|
mods: KeyModifiers::SHIFT,
|
||||||
code: KeyCode::Char('W'),
|
code: KeyCode::Char('W'),
|
||||||
action: Action::QueueDownloadCapture,
|
action: Action::QueueDownloadCapture,
|
||||||
description: "Capture the queue (download audio; same name resumes)",
|
description: "Capture the queue into /crabidy (downloads audio)",
|
||||||
},
|
},
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Queue,
|
scope: Scope::Queue,
|
||||||
|
|
@ -444,6 +485,13 @@ pub const BINDINGS: &[Binding] = &[
|
||||||
action: Action::OpenSearch,
|
action: Action::OpenSearch,
|
||||||
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
description: "Filter this view (type to search, Enter keeps, Esc clears)",
|
||||||
},
|
},
|
||||||
|
Binding {
|
||||||
|
scope: Scope::Queue,
|
||||||
|
mods: KeyModifiers::NONE,
|
||||||
|
code: KeyCode::Esc,
|
||||||
|
action: Action::ClearSearch,
|
||||||
|
description: "Clear the search filter",
|
||||||
|
},
|
||||||
// -- Help modal ------------------------------------------------------
|
// -- Help modal ------------------------------------------------------
|
||||||
Binding {
|
Binding {
|
||||||
scope: Scope::Help,
|
scope: Scope::Help,
|
||||||
|
|
@ -763,6 +811,50 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spectrum_moved_off_v_to_f() {
|
||||||
|
// The frequency-spectrum toggle now lives on Global `f`, in any focus.
|
||||||
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)),
|
||||||
|
Some(Action::ToggleSpectrum)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `v`/`V` no longer toggle the spectrum (they are visual mode now).
|
||||||
|
assert_ne!(
|
||||||
|
lookup(
|
||||||
|
UiFocus::Library,
|
||||||
|
false,
|
||||||
|
key(KeyCode::Char('v'), KeyModifiers::NONE)
|
||||||
|
),
|
||||||
|
Some(Action::ToggleSpectrum)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v_and_v_enter_visual_in_library_only() {
|
||||||
|
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual mode
|
||||||
|
// in the library; neither is bound in the queue (no marks there).
|
||||||
|
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
|
||||||
|
Some(Action::LibraryVisualMode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Library, false, key(KeyCode::Char('V'), mods)),
|
||||||
|
Some(Action::LibraryVisualMode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_help_swallows_everything_but_close() {
|
fn open_help_swallows_everything_but_close() {
|
||||||
for focus in [UiFocus::Library, UiFocus::Queue] {
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||||
|
|
@ -791,11 +883,12 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_scope_never_matches_while_help_is_closed() {
|
fn help_scope_never_matches_while_help_is_closed() {
|
||||||
// Esc is only bound inside the modal.
|
// With help closed, Esc drives the pane's ClearSearch, never the
|
||||||
|
// modal's CloseHelp — Help-scope bindings don't leak into navigation.
|
||||||
for focus in [UiFocus::Library, UiFocus::Queue] {
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
|
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
|
||||||
None
|
Some(Action::ClearSearch)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ pub struct Library {
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
positions: HashMap<String, usize>,
|
positions: HashMap<String, usize>,
|
||||||
|
/// Visual (paint-select) mode: `Some(anchor_view)` while active. Movement
|
||||||
|
/// marks the contiguous range between the anchor (where `v` was pressed)
|
||||||
|
/// and the cursor, toggling rows as they enter/leave it — so moving back
|
||||||
|
/// cleanly reverses (architecture/visual-mode.md).
|
||||||
|
visual: Option<usize>,
|
||||||
tx: Sender<MessageFromUi>,
|
tx: Sender<MessageFromUi>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +48,7 @@ impl Library {
|
||||||
filter: Filter::default(),
|
filter: Filter::default(),
|
||||||
positions: HashMap::new(),
|
positions: HashMap::new(),
|
||||||
parent: None,
|
parent: None,
|
||||||
|
visual: None,
|
||||||
tx,
|
tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,11 +179,16 @@ impl Library {
|
||||||
*self.positions.get(&self.path).unwrap_or(&0)
|
*self.positions.get(&self.path).unwrap_or(&0)
|
||||||
}
|
}
|
||||||
pub fn toggle_mark(&mut self) {
|
pub fn toggle_mark(&mut self) {
|
||||||
if let Some(real) = self
|
if let Some(view) = self.list_state.selected() {
|
||||||
.list_state
|
self.toggle_mark_view(view);
|
||||||
.selected()
|
}
|
||||||
.and_then(|view| self.filter.to_real(view))
|
}
|
||||||
{
|
|
||||||
|
/// Toggle the mark of the row at a **view** index (mapped through the `/`
|
||||||
|
/// filter to its real row), honoring the `is_queable` gate. Shared by
|
||||||
|
/// `toggle_mark` and visual-mode painting.
|
||||||
|
fn toggle_mark_view(&mut self, view: usize) {
|
||||||
|
if let Some(real) = self.filter.to_real(view) {
|
||||||
let item = &mut self.list[real];
|
let item = &mut self.list[real];
|
||||||
if !item.is_queable {
|
if !item.is_queable {
|
||||||
return;
|
return;
|
||||||
|
|
@ -185,6 +196,63 @@ impl Library {
|
||||||
item.marked = !item.marked;
|
item.marked = !item.marked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether visual (paint-select) mode is active.
|
||||||
|
pub fn is_visual(&self) -> bool {
|
||||||
|
self.visual.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Titles of the currently marked rows, in list order (inspection helper).
|
||||||
|
pub fn marked_titles(&self) -> Vec<String> {
|
||||||
|
self.list
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.marked)
|
||||||
|
.map(|i| i.title.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter or leave visual mode. Entering anchors at the current row and
|
||||||
|
/// toggles its mark (vim includes the row you start on, D2); leaving keeps
|
||||||
|
/// the marks.
|
||||||
|
pub fn toggle_visual(&mut self) {
|
||||||
|
if self.visual.is_some() {
|
||||||
|
self.visual = None;
|
||||||
|
} else {
|
||||||
|
self.visual = Some(self.list_state.selected().unwrap_or(0));
|
||||||
|
self.toggle_mark();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave visual mode (marks kept). Idempotent.
|
||||||
|
pub fn exit_visual(&mut self) {
|
||||||
|
self.visual = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current cursor position as a view index.
|
||||||
|
pub fn selected_view(&self) -> Option<usize> {
|
||||||
|
self.list_state.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaint after a visual-mode move from `from_view` to `to_view`. The
|
||||||
|
/// selection is the contiguous range `[anchor, cursor]`; a move that grows
|
||||||
|
/// or shrinks it toggles exactly the rows whose range membership changed
|
||||||
|
/// (relative to the anchor), so moving back reverses a move cleanly and the
|
||||||
|
/// row you turn around on is never stranded. No-op unless visual is active.
|
||||||
|
pub fn paint_between(&mut self, from_view: usize, to_view: usize) {
|
||||||
|
let Some(anchor) = self.visual else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
|
||||||
|
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
|
||||||
|
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
|
||||||
|
let in_old = (old_lo..=old_hi).contains(&view);
|
||||||
|
let in_new = (new_lo..=new_hi).contains(&view);
|
||||||
|
if in_old != in_new {
|
||||||
|
self.toggle_mark_view(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn remove_marks(&mut self) {
|
pub fn remove_marks(&mut self) {
|
||||||
if self.list.iter().any(|i| i.marked) {
|
if self.list.iter().any(|i| i.marked) {
|
||||||
self.list
|
self.list
|
||||||
|
|
@ -226,6 +294,7 @@ impl Library {
|
||||||
is_deletable: node.tracks_deletable,
|
is_deletable: node.tracks_deletable,
|
||||||
is_downloadable: node.is_downloadable,
|
is_downloadable: node.is_downloadable,
|
||||||
is_skipped: t.is_skipped,
|
is_skipped: t.is_skipped,
|
||||||
|
is_captured: t.is_captured,
|
||||||
})
|
})
|
||||||
.chain(node.children.iter().map(|c| UiItem {
|
.chain(node.children.iter().map(|c| UiItem {
|
||||||
path: c.path.clone(),
|
path: c.path.clone(),
|
||||||
|
|
@ -238,6 +307,7 @@ impl Library {
|
||||||
is_deletable: c.is_deletable,
|
is_deletable: c.is_deletable,
|
||||||
is_downloadable: c.is_downloadable,
|
is_downloadable: c.is_downloadable,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
is_captured: c.is_captured,
|
||||||
}))
|
}))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -269,7 +339,8 @@ impl Library {
|
||||||
if i.is_creatable {
|
if i.is_creatable {
|
||||||
text.push_str(" [%]");
|
text.push_str(" [%]");
|
||||||
}
|
}
|
||||||
// Modifiable items advertise their keys: [e], [d] or [ed].
|
// Modifiable items advertise their action keys: [e], [d] or
|
||||||
|
// [ed].
|
||||||
if i.is_editable || i.is_deletable {
|
if i.is_editable || i.is_deletable {
|
||||||
text.push_str(" [");
|
text.push_str(" [");
|
||||||
if i.is_editable {
|
if i.is_editable {
|
||||||
|
|
@ -280,6 +351,12 @@ impl Library {
|
||||||
}
|
}
|
||||||
text.push(']');
|
text.push(']');
|
||||||
}
|
}
|
||||||
|
// A trailing `↓` (a status, not an action, so kept outside
|
||||||
|
// the key brackets) marks a row whose audio is in the content
|
||||||
|
// store (downloaded).
|
||||||
|
if i.is_captured {
|
||||||
|
text.push_str(" ↓");
|
||||||
|
}
|
||||||
let mut style = if i.marked {
|
let mut style = if i.marked {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(COLOR_GREEN)
|
.fg(COLOR_GREEN)
|
||||||
|
|
@ -312,7 +389,10 @@ impl Library {
|
||||||
} else {
|
} else {
|
||||||
COLOR_PRIMARY_DARK
|
COLOR_PRIMARY_DARK
|
||||||
}))
|
}))
|
||||||
.title(if let Some(query) = self.filter.query() {
|
.title(if self.visual.is_some() {
|
||||||
|
// Visual (paint-select) mode: movement toggles marks.
|
||||||
|
format!("{} — VISUAL", self.title)
|
||||||
|
} else if let Some(query) = self.filter.query() {
|
||||||
// Search mode: show the live query with a cursor.
|
// Search mode: show the live query with a cursor.
|
||||||
format!("{} — /{query}▏", self.title)
|
format!("{} — /{query}▏", self.title)
|
||||||
} else if self.is_creatable {
|
} else if self.is_creatable {
|
||||||
|
|
@ -360,3 +440,53 @@ impl StatefulList for Library {
|
||||||
self.list_state.selected()
|
self.list_state.selected()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crabidy_core::proto::crabidy::LibraryNodeChild;
|
||||||
|
|
||||||
|
/// Renders a `Library` into an 80x24 test terminal and returns its text.
|
||||||
|
fn render_text(library: &mut Library) -> String {
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
|
||||||
|
terminal
|
||||||
|
.draw(|f| library.render(f, f.area(), true))
|
||||||
|
.expect("draw library");
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
let mut text = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
text.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_rows_render_a_trailing_arrow() {
|
||||||
|
let (tx, _rx) = flume::unbounded();
|
||||||
|
let mut library = Library::new(tx);
|
||||||
|
library.update(LibraryNode {
|
||||||
|
path: "/crabidy/mix".to_string(),
|
||||||
|
title: "mix".to_string(),
|
||||||
|
children: vec![LibraryNodeChild {
|
||||||
|
is_captured: true,
|
||||||
|
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
|
||||||
|
}],
|
||||||
|
parent: Some("/crabidy".to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
});
|
||||||
|
let text = render_text(&mut library);
|
||||||
|
assert!(
|
||||||
|
text.contains("album ↓"),
|
||||||
|
"captured row carries a trailing down-arrow: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ struct UiItem {
|
||||||
/// The track has no playable audio (`Track.is_skipped`) — rendered
|
/// The track has no playable audio (`Track.is_skipped`) — rendered
|
||||||
/// red; playback skips it. Always false for nodes.
|
/// red; playback skips it. Always false for nodes.
|
||||||
is_skipped: bool,
|
is_skipped: bool,
|
||||||
|
/// The item's audio is present in the capture content store
|
||||||
|
/// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered
|
||||||
|
/// with a trailing `↓` marker at the end of the row.
|
||||||
|
is_captured: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
|
pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193);
|
||||||
|
|
@ -68,8 +72,9 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
|
||||||
|
|
||||||
/// Library path of the continuously persisted live queue — what the
|
/// Library path of the continuously persisted live queue — what the
|
||||||
/// queue pane's `W` download-captures (server: `queue_store`'s
|
/// queue pane's `W` download-captures (server: `queue_store`'s
|
||||||
/// `CURRENT_QUEUE_NAME` under the `/queues` instance).
|
/// `CURRENT_QUEUE_NAME`, now mirrored under the single `/crabidy`
|
||||||
const CURRENT_QUEUE_PATH: &str = "/queues/current";
|
/// provider).
|
||||||
|
const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
|
||||||
|
|
||||||
// FIXME: Rename this
|
// FIXME: Rename this
|
||||||
pub enum MessageToUi {
|
pub enum MessageToUi {
|
||||||
|
|
@ -99,11 +104,11 @@ pub enum MessageFromUi {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
/// Save the current queue under a name (server-side snapshot; appears
|
/// Save the current queue under a name (server-side snapshot; appears
|
||||||
/// as `/queues/<name>` in the library on the next visit).
|
/// as `/crabidy/<name>` in the library on the next visit).
|
||||||
SaveQueue(String),
|
SaveQueue(String),
|
||||||
/// Capture the queueable subtree at `path` as the bookmark `name`
|
/// Capture the queueable subtree at `path` as the bookmark `name`
|
||||||
/// (structure-preserving snapshot under `/bookmarks/<name>`), or —
|
/// (structure-preserving snapshot under `/crabidy/<name>`), or —
|
||||||
/// with `download` — as the capture `name` under `/captures/<name>`
|
/// with `download` — as the capture `name` under `/crabidy/<name>`
|
||||||
/// with every track's audio downloaded next to its toml.
|
/// with every track's audio downloaded next to its toml.
|
||||||
CaptureNode {
|
CaptureNode {
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -247,24 +252,6 @@ pub struct InputState {
|
||||||
pub buffer: String,
|
pub buffer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A delete waiting for its `y` — opened instead of sending when the
|
|
||||||
/// selection is a capture (`d` there destroys downloaded data on disk,
|
|
||||||
/// architecture/capture-deletion.md). Modal like [`InputState`]: while
|
|
||||||
/// `Some`, keys go to [`App::handle_confirm_key`] only.
|
|
||||||
pub struct ConfirmDelete {
|
|
||||||
pub path: String,
|
|
||||||
/// Display title of the doomed item, shown in the prompt.
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether deleting `path` needs a confirmation: deletes under
|
|
||||||
/// `/captures` remove downloaded audio from disk — expensive to redo —
|
|
||||||
/// while every other deletable node (search terms, bookmarks, saved
|
|
||||||
/// queues) is cheap to recreate and stays one keypress.
|
|
||||||
fn delete_needs_confirmation(path: &str) -> bool {
|
|
||||||
path == "/captures" || path.starts_with("/captures/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An open `/` search input, filtering one pane live as the user types.
|
/// An open `/` search input, filtering one pane live as the user types.
|
||||||
/// The filter itself lives on the pane (it survives closing this input);
|
/// The filter itself lives on the pane (it survives closing this input);
|
||||||
/// this only holds the editing buffer and which pane is being filtered.
|
/// this only holds the editing buffer and which pane is being filtered.
|
||||||
|
|
@ -282,9 +269,6 @@ pub struct App {
|
||||||
/// `Some` while the input overlay is open; takes precedence over the
|
/// `Some` while the input overlay is open; takes precedence over the
|
||||||
/// bindings table (checked first in the event loop).
|
/// bindings table (checked first in the event loop).
|
||||||
pub input: Option<InputState>,
|
pub input: Option<InputState>,
|
||||||
/// `Some` while a capture delete awaits confirmation; modal like
|
|
||||||
/// `input` and checked before it in the event loop.
|
|
||||||
pub confirm: Option<ConfirmDelete>,
|
|
||||||
/// `Some` while a `/` search input is open; modal like the others.
|
/// `Some` while a `/` search input is open; modal like the others.
|
||||||
pub search: Option<SearchState>,
|
pub search: Option<SearchState>,
|
||||||
/// Progress of running (and recently finished) captures, rendered as
|
/// Progress of running (and recently finished) captures, rendered as
|
||||||
|
|
@ -305,7 +289,6 @@ impl App {
|
||||||
focus: UiFocus::Library,
|
focus: UiFocus::Library,
|
||||||
show_help: false,
|
show_help: false,
|
||||||
input: None,
|
input: None,
|
||||||
confirm: None,
|
|
||||||
search: None,
|
search: None,
|
||||||
captures: CaptureBoard::default(),
|
captures: CaptureBoard::default(),
|
||||||
library,
|
library,
|
||||||
|
|
@ -315,21 +298,6 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles one key while a delete confirmation is open
|
|
||||||
/// (`confirm.is_some()`). Only `y`/`Y` confirms and sends the delete;
|
|
||||||
/// every other key cancels — the safe answer is any answer.
|
|
||||||
pub fn handle_confirm_key(&mut self, key: crossterm::event::KeyEvent) {
|
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
let Some(confirm) = self.confirm.take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
|
|
||||||
let _ = self
|
|
||||||
.tx
|
|
||||||
.send(MessageFromUi::DeleteNode { path: confirm.path });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles one key while a `/` search input is open
|
/// Handles one key while a `/` search input is open
|
||||||
/// (`search.is_some()`). Typing filters the focused pane live;
|
/// (`search.is_some()`). Typing filters the focused pane live;
|
||||||
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
/// `Enter` keeps the filter and returns to navigation; `Esc` clears
|
||||||
|
|
@ -433,7 +401,41 @@ impl App {
|
||||||
/// modal, list selections) or send the matching [`MessageFromUi`] via
|
/// modal, list selections) or send the matching [`MessageFromUi`] via
|
||||||
/// `tx`. Send failures are ignored like everywhere else in this module —
|
/// `tx`. Send failures are ignored like everywhere else in this module —
|
||||||
/// the orchestrator side owns error reporting.
|
/// the orchestrator side owns error reporting.
|
||||||
|
/// Run a library cursor move, painting the swept `(old, new]` range when
|
||||||
|
/// visual mode is active (architecture/visual-mode.md D3).
|
||||||
|
fn library_move(&mut self, mv: impl FnOnce(&mut Library)) {
|
||||||
|
if self.library.is_visual() {
|
||||||
|
let old = self.library.selected_view();
|
||||||
|
mv(&mut self.library);
|
||||||
|
let new = self.library.selected_view();
|
||||||
|
if let (Some(old), Some(new)) = (old, new) {
|
||||||
|
self.library.paint_between(old, new);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mv(&mut self.library);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn dispatch(&mut self, action: Action) -> DispatchResult {
|
pub fn dispatch(&mut self, action: Action) -> DispatchResult {
|
||||||
|
// Visual mode (library paint-select): the six movements paint (handled
|
||||||
|
// in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other
|
||||||
|
// action leaves visual mode before running (architecture/visual-mode.md).
|
||||||
|
let was_visual = self.library.is_visual();
|
||||||
|
if was_visual
|
||||||
|
&& !matches!(
|
||||||
|
action,
|
||||||
|
Action::LibraryFirst
|
||||||
|
| Action::LibraryLast
|
||||||
|
| Action::LibraryNext
|
||||||
|
| Action::LibraryPrev
|
||||||
|
| Action::LibraryJumpDown
|
||||||
|
| Action::LibraryJumpUp
|
||||||
|
| Action::LibraryVisualMode
|
||||||
|
| Action::ClearSearch
|
||||||
|
)
|
||||||
|
{
|
||||||
|
self.library.exit_visual();
|
||||||
|
}
|
||||||
match action {
|
match action {
|
||||||
Action::Quit => return DispatchResult::Quit,
|
Action::Quit => return DispatchResult::Quit,
|
||||||
Action::OpenHelp => self.show_help = true,
|
Action::OpenHelp => self.show_help = true,
|
||||||
|
|
@ -462,18 +464,20 @@ impl App {
|
||||||
}
|
}
|
||||||
Action::NextTrack => self.queue.play_next(),
|
Action::NextTrack => self.queue.play_next(),
|
||||||
Action::PrevTrack => self.queue.play_prev(),
|
Action::PrevTrack => self.queue.play_prev(),
|
||||||
Action::LibraryFirst => self.library.first(),
|
Action::ToggleSpectrum => self.now_playing.toggle_spectrum(),
|
||||||
Action::LibraryLast => self.library.last(),
|
Action::LibraryFirst => self.library_move(|l| l.first()),
|
||||||
Action::LibraryNext => self.library.next(),
|
Action::LibraryLast => self.library_move(|l| l.last()),
|
||||||
Action::LibraryPrev => self.library.prev(),
|
Action::LibraryNext => self.library_move(|l| l.next()),
|
||||||
Action::LibraryJumpDown => self.library.down(),
|
Action::LibraryPrev => self.library_move(|l| l.prev()),
|
||||||
Action::LibraryJumpUp => self.library.up(),
|
Action::LibraryJumpDown => self.library_move(|l| l.down()),
|
||||||
|
Action::LibraryJumpUp => self.library_move(|l| l.up()),
|
||||||
Action::LibraryAscend => self.library.ascend(),
|
Action::LibraryAscend => self.library.ascend(),
|
||||||
Action::LibraryDive => self.library.dive(),
|
Action::LibraryDive => self.library.dive(),
|
||||||
Action::LibraryQueueNext => self.library.queue_queue(),
|
Action::LibraryQueueNext => self.library.queue_queue(),
|
||||||
Action::LibraryQueueAppend => self.library.queue_append(),
|
Action::LibraryQueueAppend => self.library.queue_append(),
|
||||||
Action::LibraryQueueReplace => self.library.queue_replace(),
|
Action::LibraryQueueReplace => self.library.queue_replace(),
|
||||||
Action::LibraryToggleMark => self.library.toggle_mark(),
|
Action::LibraryToggleMark => self.library.toggle_mark(),
|
||||||
|
Action::LibraryVisualMode => self.library.toggle_visual(),
|
||||||
Action::LibraryCreateNode => {
|
Action::LibraryCreateNode => {
|
||||||
// Opens the input overlay when the open library node is
|
// Opens the input overlay when the open library node is
|
||||||
// creatable; silently ignored otherwise.
|
// creatable; silently ignored otherwise.
|
||||||
|
|
@ -497,18 +501,13 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::LibraryDeleteNode => {
|
Action::LibraryDeleteNode => {
|
||||||
// Cheap deletables (search terms, bookmarks, saved queues)
|
// Deletes go through directly on every provider: audio lives
|
||||||
// stay unconfirmed by design (architecture/node-editing.md,
|
// in a shared content store that track-deletion never
|
||||||
// D4); captures hold downloaded audio and get a y/N prompt
|
// touches, so capture deletes are no longer destructive.
|
||||||
// first (architecture/capture-deletion.md).
|
if let Some((path, _title)) = self.library.selected_deletable() {
|
||||||
if let Some((path, title)) = self.library.selected_deletable() {
|
|
||||||
if delete_needs_confirmation(&path) {
|
|
||||||
self.confirm = Some(ConfirmDelete { path, title });
|
|
||||||
} else {
|
|
||||||
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
let _ = self.tx.send(MessageFromUi::DeleteNode { path });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Action::LibraryCaptureNode => {
|
Action::LibraryCaptureNode => {
|
||||||
// Prefilled with the selection's title; silently ignored on
|
// Prefilled with the selection's title; silently ignored on
|
||||||
// non-queueable selections like the other gated openers.
|
// non-queueable selections like the other gated openers.
|
||||||
|
|
@ -550,6 +549,21 @@ impl App {
|
||||||
Self::apply_search(&mut self.library, &mut self.queue, &search);
|
Self::apply_search(&mut self.library, &mut self.queue, &search);
|
||||||
self.search = Some(search);
|
self.search = Some(search);
|
||||||
}
|
}
|
||||||
|
Action::ClearSearch => {
|
||||||
|
if was_visual {
|
||||||
|
// In visual mode `Esc` leaves the mode (marks kept) and
|
||||||
|
// does not also clear the search filter.
|
||||||
|
self.library.exit_visual();
|
||||||
|
} else {
|
||||||
|
// `Esc` in normal navigation clears the focused pane's
|
||||||
|
// filter, restoring the full listing. A no-op when nothing
|
||||||
|
// is filtered.
|
||||||
|
match self.focus {
|
||||||
|
UiFocus::Library => self.library.set_filter(None),
|
||||||
|
UiFocus::Queue => self.queue.set_filter(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::QueueInsertHere => {
|
Action::QueueInsertHere => {
|
||||||
if let Some(selected) = self.queue.selected() {
|
if let Some(selected) = self.queue.selected() {
|
||||||
self.library.queue_insert(selected);
|
self.library.queue_insert(selected);
|
||||||
|
|
@ -582,7 +596,7 @@ impl App {
|
||||||
}
|
}
|
||||||
Action::QueueDownloadCapture => {
|
Action::QueueDownloadCapture => {
|
||||||
// Capture the live queue directly (its continuously
|
// Capture the live queue directly (its continuously
|
||||||
// persisted `/queues/current` snapshot), so the user need
|
// persisted `/crabidy/current` snapshot), so the user need
|
||||||
// not save-then-capture. Empty queue: nothing to capture.
|
// not save-then-capture. Empty queue: nothing to capture.
|
||||||
if !self.queue.is_empty() {
|
if !self.queue.is_empty() {
|
||||||
self.input = Some(InputState {
|
self.input = Some(InputState {
|
||||||
|
|
@ -658,22 +672,6 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The capture-delete confirmation: same one-line slot as the text
|
|
||||||
// input (the two are never open together), red — it is the one
|
|
||||||
// destructive prompt in the UI.
|
|
||||||
if let Some(confirm) = &self.confirm {
|
|
||||||
let area = main[0];
|
|
||||||
if area.height >= 3 && area.width >= 4 {
|
|
||||||
let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1);
|
|
||||||
f.render_widget(Clear, line);
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(format!("delete {}? [y/N]", confirm.title))
|
|
||||||
.style(Style::default().fg(COLOR_RED)),
|
|
||||||
line,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture progress lines: stacked up from the bottom of the library
|
// Capture progress lines: stacked up from the bottom of the library
|
||||||
// pane, above the input overlay when that is open. Failures render
|
// pane, above the input overlay when that is open. Failures render
|
||||||
// red. At most three — more concurrent captures than that keep
|
// red. At most three — more concurrent captures than that keep
|
||||||
|
|
@ -681,11 +679,7 @@ impl App {
|
||||||
let capture_lines = self.captures.lines();
|
let capture_lines = self.captures.lines();
|
||||||
if !capture_lines.is_empty() {
|
if !capture_lines.is_empty() {
|
||||||
let area = main[0];
|
let area = main[0];
|
||||||
let bottom_offset = if self.input.is_some() || self.confirm.is_some() {
|
let bottom_offset = if self.input.is_some() { 3 } else { 2 };
|
||||||
3
|
|
||||||
} else {
|
|
||||||
2
|
|
||||||
};
|
|
||||||
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
|
for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() {
|
||||||
let offset = bottom_offset + i as u16;
|
let offset = bottom_offset + i as u16;
|
||||||
if area.height <= offset + 1 || area.width < 4 {
|
if area.height <= offset + 1 || area.width < 4 {
|
||||||
|
|
@ -793,6 +787,7 @@ mod tests {
|
||||||
is_creatable: true,
|
is_creatable: true,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
tracks_deletable: false,
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -822,6 +817,7 @@ mod tests {
|
||||||
is_creatable: false,
|
is_creatable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
tracks_deletable: false,
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -831,6 +827,200 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A listing whose child queueability follows `queueable` per title.
|
||||||
|
fn mixed_listing(items: &[(&str, bool)]) -> LibraryNode {
|
||||||
|
use crabidy_core::proto::crabidy::LibraryNodeChild;
|
||||||
|
LibraryNode {
|
||||||
|
path: "/fs/music".to_string(),
|
||||||
|
title: "music".to_string(),
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
children: items
|
||||||
|
.iter()
|
||||||
|
.map(|(t, q)| LibraryNodeChild::new(format!("/fs/music/{t}"), t.to_string(), *q))
|
||||||
|
.collect(),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_enter_toggles_current_mark() {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode);
|
||||||
|
assert!(app.library.is_visual(), "visual mode active");
|
||||||
|
assert_eq!(app.library.marked_titles(), vec!["alpha".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_move_paints_swept_range() {
|
||||||
|
// Step moves paint one row each.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode); // marks alpha (anchor)
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // marks beta
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // marks gamma
|
||||||
|
assert_eq!(
|
||||||
|
app.library.marked_titles(),
|
||||||
|
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_jump_paints_whole_span() {
|
||||||
|
// A jump paints every row in the span, not just the endpoint.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["a", "b", "c", "d", "e"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode); // marks a
|
||||||
|
let _ = app.dispatch(Action::LibraryLast); // paints b,c,d,e
|
||||||
|
assert_eq!(app.library.marked_titles().len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_back_sweep_unpaints() {
|
||||||
|
// Retreating shrinks the [anchor, cursor] range: the row turned around
|
||||||
|
// on (gamma) is unmarked, not stranded.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor)
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // beta
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // gamma
|
||||||
|
let _ = app.dispatch(Action::LibraryPrev); // back onto beta
|
||||||
|
assert_eq!(
|
||||||
|
app.library.marked_titles(),
|
||||||
|
vec!["alpha".to_string(), "beta".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_down_then_fully_up_leaves_only_the_anchor() {
|
||||||
|
// Regression: going down and back up to the start must not strand the
|
||||||
|
// furthest row marked (the reported bug).
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor)
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // beta
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // gamma
|
||||||
|
let _ = app.dispatch(Action::LibraryPrev); // beta
|
||||||
|
let _ = app.dispatch(Action::LibraryPrev); // alpha
|
||||||
|
assert_eq!(
|
||||||
|
app.library.marked_titles(),
|
||||||
|
vec!["alpha".to_string()],
|
||||||
|
"only the anchor stays marked after returning to it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_exit_keeps_marks() {
|
||||||
|
// Second v exits; marks persist.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode);
|
||||||
|
let _ = app.dispatch(Action::LibraryNext);
|
||||||
|
let before = app.library.marked_titles();
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode);
|
||||||
|
assert!(!app.library.is_visual());
|
||||||
|
assert_eq!(app.library.marked_titles(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_esc_exits_keeping_marks() {
|
||||||
|
// Esc exits visual and keeps the marks.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode);
|
||||||
|
let _ = app.dispatch(Action::LibraryNext);
|
||||||
|
let _ = app.dispatch(Action::ClearSearch);
|
||||||
|
assert!(!app.library.is_visual());
|
||||||
|
assert_eq!(
|
||||||
|
app.library.marked_titles(),
|
||||||
|
vec!["alpha".to_string(), "beta".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_non_move_action_exits_then_runs() {
|
||||||
|
let (mut app, rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode); // alpha
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // beta
|
||||||
|
let _ = app.dispatch(Action::LibraryQueueAppend); // exits + appends marks
|
||||||
|
assert!(!app.library.is_visual(), "append leaves visual mode");
|
||||||
|
assert!(
|
||||||
|
app.library.marked_titles().is_empty(),
|
||||||
|
"append consumes the marks"
|
||||||
|
);
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(MessageFromUi::AppendTracks(paths)) => assert_eq!(paths.len(), 2),
|
||||||
|
_ => panic!("expected AppendTracks(2)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_exits_on_node_or_focus_change() {
|
||||||
|
for action in [
|
||||||
|
Action::LibraryDive,
|
||||||
|
Action::LibraryAscend,
|
||||||
|
Action::CycleFocus,
|
||||||
|
] {
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode);
|
||||||
|
assert!(app.library.is_visual());
|
||||||
|
let _ = app.dispatch(action);
|
||||||
|
assert!(!app.library.is_visual(), "{action:?} leaves visual mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_paint_respects_is_queable() {
|
||||||
|
// Sweeping over a non-queueable row leaves it unmarked, like `s`.
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(mixed_listing(&[("a", true), ("b", false), ("c", true)]));
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode); // marks a
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // sweeps b (not queueable)
|
||||||
|
let _ = app.dispatch(Action::LibraryNext); // marks c
|
||||||
|
assert_eq!(
|
||||||
|
app.library.marked_titles(),
|
||||||
|
vec!["a".to_string(), "c".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_in_visual_only_exits_visual_keeping_the_filter() {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
// Apply a filter and return to navigation.
|
||||||
|
let _ = app.dispatch(Action::OpenSearch);
|
||||||
|
search_key(&mut app, "a");
|
||||||
|
app.handle_search_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(app.library.filter_query(), Some("a"));
|
||||||
|
// Enter visual, then Esc: leaves visual but keeps the filter.
|
||||||
|
let _ = app.dispatch(Action::LibraryVisualMode);
|
||||||
|
let _ = app.dispatch(Action::ClearSearch);
|
||||||
|
assert!(!app.library.is_visual());
|
||||||
|
assert_eq!(app.library.filter_query(), Some("a"), "filter untouched");
|
||||||
|
// A second Esc (not visual) now clears the filter.
|
||||||
|
let _ = app.dispatch(Action::ClearSearch);
|
||||||
|
assert_eq!(app.library.filter_query(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_filters_the_focused_library_pane_enter_keeps_esc_clears() {
|
fn slash_filters_the_focused_library_pane_enter_keeps_esc_clears() {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
|
@ -863,6 +1053,31 @@ mod tests {
|
||||||
assert_eq!(app.library.get_size(), 3, "full listing restored");
|
assert_eq!(app.library.get_size(), 3, "full listing restored");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_in_navigation_clears_an_active_filter() {
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
let (mut app, _rx) = app();
|
||||||
|
app.library
|
||||||
|
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||||
|
|
||||||
|
// Filter, then Enter back to normal navigation (the filter stays).
|
||||||
|
let _ = app.dispatch(Action::OpenSearch);
|
||||||
|
search_key(&mut app, "bet");
|
||||||
|
app.handle_search_key(key(KeyCode::Enter));
|
||||||
|
assert!(app.search.is_none());
|
||||||
|
assert_eq!(app.library.filter_query(), Some("bet"));
|
||||||
|
|
||||||
|
// Esc now resolves to ClearSearch (no modal open), clearing the
|
||||||
|
// filter while staying in normal navigation.
|
||||||
|
let _ = app.dispatch(Action::ClearSearch);
|
||||||
|
assert_eq!(app.library.filter_query(), None);
|
||||||
|
assert_eq!(app.library.get_size(), 3, "full listing restored");
|
||||||
|
|
||||||
|
// With no filter, ClearSearch is a harmless no-op.
|
||||||
|
let _ = app.dispatch(Action::ClearSearch);
|
||||||
|
assert_eq!(app.library.filter_query(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_targets_the_focused_pane() {
|
fn search_targets_the_focused_pane() {
|
||||||
let (mut app, _rx) = app();
|
let (mut app, _rx) = app();
|
||||||
|
|
@ -1126,104 +1341,64 @@ mod tests {
|
||||||
}
|
}
|
||||||
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||||
}
|
}
|
||||||
assert!(app.confirm.is_none(), "cheap deletes are unconfirmed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A /captures/mix listing: one deletable track (tracks come first in
|
/// A /crabidy/mix listing: one deletable track (tracks come first in
|
||||||
/// the pane) and one deletable album folder.
|
/// the pane) and one deletable album folder.
|
||||||
fn captures_listing() -> LibraryNode {
|
fn captures_listing() -> LibraryNode {
|
||||||
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
|
use crabidy_core::proto::crabidy::{LibraryNodeChild, Track};
|
||||||
LibraryNode {
|
LibraryNode {
|
||||||
path: "/captures/mix".to_string(),
|
path: "/crabidy/mix".to_string(),
|
||||||
title: "mix".to_string(),
|
title: "mix".to_string(),
|
||||||
children: vec![LibraryNodeChild {
|
children: vec![LibraryNodeChild {
|
||||||
is_deletable: true,
|
is_deletable: true,
|
||||||
..LibraryNodeChild::new(
|
..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true)
|
||||||
"/captures/mix/album".to_string(),
|
|
||||||
"album".to_string(),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}],
|
}],
|
||||||
parent: Some("/captures".to_string()),
|
parent: Some("/crabidy".to_string()),
|
||||||
tracks: vec![Track {
|
tracks: vec![Track {
|
||||||
path: "/captures/mix/0001%20song.cbd-track.toml".to_string(),
|
path: "/crabidy/mix/0001%20song.cbd-track.toml".to_string(),
|
||||||
artist: "artist".to_string(),
|
artist: "artist".to_string(),
|
||||||
title: "song".to_string(),
|
title: "song".to_string(),
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
}],
|
}],
|
||||||
is_queable: true,
|
is_queable: true,
|
||||||
is_creatable: false,
|
is_creatable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
tracks_deletable: true,
|
tracks_deletable: true,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capture_deletes_open_a_confirmation_instead_of_sending() {
|
fn capture_deletes_send_immediately_without_confirmation() {
|
||||||
let (mut app, rx) = app();
|
let (mut app, rx) = app();
|
||||||
app.library.update(captures_listing());
|
app.library.update(captures_listing());
|
||||||
// The first item is the capture's track, deletable via the node's
|
// The first item is the capture's track, deletable via the node's
|
||||||
// tracks_deletable flag.
|
// tracks_deletable flag. Deletes under `/crabidy` now go through
|
||||||
|
// directly: audio lives in a shared content store that
|
||||||
|
// track-deletion never touches, so the delete is not destructive.
|
||||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
let _ = app.dispatch(Action::LibraryDeleteNode);
|
||||||
assert!(rx.try_recv().is_err(), "nothing sent before the y");
|
|
||||||
let confirm = app.confirm.as_ref().expect("confirmation open");
|
|
||||||
assert_eq!(confirm.path, "/captures/mix/0001%20song.cbd-track.toml");
|
|
||||||
assert_eq!(confirm.title, "artist - song");
|
|
||||||
app.handle_confirm_key(key(crossterm::event::KeyCode::Char('y')));
|
|
||||||
assert!(app.confirm.is_none());
|
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(MessageFromUi::DeleteNode { path }) => {
|
Ok(MessageFromUi::DeleteNode { path }) => {
|
||||||
assert_eq!(path, "/captures/mix/0001%20song.cbd-track.toml");
|
assert_eq!(path, "/crabidy/mix/0001%20song.cbd-track.toml");
|
||||||
}
|
}
|
||||||
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folders below the top level are deletable (and confirmed) too.
|
// Folders below the top level delete immediately too.
|
||||||
let mut app_state = app;
|
|
||||||
app_state.library.update(captures_listing());
|
|
||||||
app_state.library.last();
|
|
||||||
let _ = app_state.dispatch(Action::LibraryDeleteNode);
|
|
||||||
let confirm = app_state.confirm.as_ref().expect("confirmation open");
|
|
||||||
assert_eq!(confirm.path, "/captures/mix/album");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capture_delete_confirmations_cancel_on_anything_but_y() {
|
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
let (mut app, rx) = app();
|
|
||||||
for cancel in [KeyCode::Esc, KeyCode::Char('n'), KeyCode::Enter] {
|
|
||||||
app.library.update(captures_listing());
|
app.library.update(captures_listing());
|
||||||
|
app.library.last();
|
||||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
let _ = app.dispatch(Action::LibraryDeleteNode);
|
||||||
assert!(app.confirm.is_some(), "confirmation open");
|
match rx.try_recv() {
|
||||||
app.handle_confirm_key(key(cancel));
|
Ok(MessageFromUi::DeleteNode { path }) => {
|
||||||
assert!(app.confirm.is_none(), "{cancel:?} closes");
|
assert_eq!(path, "/crabidy/mix/album");
|
||||||
assert!(rx.try_recv().is_err(), "{cancel:?} must not delete");
|
|
||||||
}
|
}
|
||||||
|
other => panic!("expected DeleteNode, got {:?}", other.is_ok()),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capture_delete_confirmation_renders_its_prompt() {
|
|
||||||
let (mut app, _rx) = app();
|
|
||||||
app.library.update(captures_listing());
|
|
||||||
let _ = app.dispatch(Action::LibraryDeleteNode);
|
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
|
||||||
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
|
|
||||||
terminal.draw(|f| app.render(f)).expect("draw app");
|
|
||||||
let buf = terminal.backend().buffer();
|
|
||||||
let mut text = String::new();
|
|
||||||
for y in 0..buf.area.height {
|
|
||||||
for x in 0..buf.area.width {
|
|
||||||
text.push_str(buf[(x, y)].symbol());
|
|
||||||
}
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
text.contains("delete artist - song? [y/N]"),
|
|
||||||
"prompt line visible"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1368,6 +1543,8 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
};
|
};
|
||||||
let listing = |downloadable| LibraryNode {
|
let listing = |downloadable| LibraryNode {
|
||||||
tracks: vec![track.clone()],
|
tracks: vec![track.clone()],
|
||||||
|
|
@ -1426,6 +1603,8 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
}],
|
}],
|
||||||
resolving: false,
|
resolving: false,
|
||||||
}
|
}
|
||||||
|
|
@ -1461,9 +1640,9 @@ mod tests {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
&input.purpose,
|
&input.purpose,
|
||||||
InputPurpose::Capture { path, download: true } if path == "/queues/current"
|
InputPurpose::Capture { path, download: true } if path == "/crabidy/current"
|
||||||
),
|
),
|
||||||
"queue W download-captures /queues/current"
|
"queue W download-captures /crabidy/current"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Submitting sends a download capture of the live queue.
|
// Submitting sends a download capture of the live queue.
|
||||||
|
|
@ -1475,7 +1654,7 @@ mod tests {
|
||||||
name,
|
name,
|
||||||
download,
|
download,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(path, "/queues/current");
|
assert_eq!(path, "/crabidy/current");
|
||||||
assert_eq!(name, "party");
|
assert_eq!(name, "party");
|
||||||
assert!(download);
|
assert!(download);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::{ops::Div, time::Duration};
|
use std::{ops::Div, time::Duration};
|
||||||
|
|
||||||
|
#[cfg(feature = "notifications")]
|
||||||
use notify_rust::Notification;
|
use notify_rust::Notification;
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
|
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
|
||||||
|
|
@ -84,30 +85,7 @@ impl NowPlaying {
|
||||||
}
|
}
|
||||||
pub fn update_track(&mut self, active: Option<Track>) {
|
pub fn update_track(&mut self, active: Option<Track>) {
|
||||||
if let Some(track) = &active {
|
if let Some(track) = &active {
|
||||||
let body = if let Some(ref album) = track.album {
|
notify_now_playing(track);
|
||||||
format!(
|
|
||||||
"{} by {}\n\n{} ({})",
|
|
||||||
track.title,
|
|
||||||
track.artist,
|
|
||||||
album.title,
|
|
||||||
// FIXME: get out year and format differently if it's missing
|
|
||||||
album.release_date()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("{} by {}", track.title, track.artist,)
|
|
||||||
};
|
|
||||||
// A missing notification daemon must not crash the TUI.
|
|
||||||
// The explicit appname keeps notification-daemon rules
|
|
||||||
// (e.g. mako `app-name=` criteria) stable even if the
|
|
||||||
// binary is renamed or wrapped.
|
|
||||||
if let Err(err) = Notification::new()
|
|
||||||
.appname("crabidy")
|
|
||||||
.summary("Now playing")
|
|
||||||
.body(&body)
|
|
||||||
.show()
|
|
||||||
{
|
|
||||||
tracing::debug!("could not show desktop notification: {err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.track = active;
|
self.track = active;
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +100,11 @@ impl NowPlaying {
|
||||||
pub fn set_spectrum_enabled(&mut self, enabled: bool) {
|
pub fn set_spectrum_enabled(&mut self, enabled: bool) {
|
||||||
self.spectrum_enabled = enabled;
|
self.spectrum_enabled = enabled;
|
||||||
}
|
}
|
||||||
|
/// Shows or hides the spectrum row (the `v` keybinding). The server
|
||||||
|
/// keeps streaming the bars; this only gates rendering.
|
||||||
|
pub fn toggle_spectrum(&mut self) {
|
||||||
|
self.spectrum_enabled = !self.spectrum_enabled;
|
||||||
|
}
|
||||||
/// Reflects the server's mute state.
|
/// Reflects the server's mute state.
|
||||||
pub fn update_mute(&mut self, muted: bool) {
|
pub fn update_mute(&mut self, muted: bool) {
|
||||||
self.muted = muted;
|
self.muted = muted;
|
||||||
|
|
@ -273,6 +256,39 @@ impl NowPlaying {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows the desktop "now playing" notification. A missing notification daemon
|
||||||
|
/// must not crash the TUI, so a failure is only logged. The explicit appname
|
||||||
|
/// keeps notification-daemon rules (e.g. mako `app-name=` criteria) stable even
|
||||||
|
/// if the binary is renamed or wrapped.
|
||||||
|
#[cfg(feature = "notifications")]
|
||||||
|
fn notify_now_playing(track: &Track) {
|
||||||
|
let body = if let Some(ref album) = track.album {
|
||||||
|
format!(
|
||||||
|
"{} by {}\n\n{} ({})",
|
||||||
|
track.title,
|
||||||
|
track.artist,
|
||||||
|
album.title,
|
||||||
|
// FIXME: get out year and format differently if it's missing
|
||||||
|
album.release_date()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{} by {}", track.title, track.artist)
|
||||||
|
};
|
||||||
|
if let Err(err) = Notification::new()
|
||||||
|
.appname("crabidy")
|
||||||
|
.summary("Now playing")
|
||||||
|
.body(&body)
|
||||||
|
.show()
|
||||||
|
{
|
||||||
|
tracing::debug!("could not show desktop notification: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built without the `notifications` feature: nothing to show
|
||||||
|
/// (architecture/build-features.md D1).
|
||||||
|
#[cfg(not(feature = "notifications"))]
|
||||||
|
fn notify_now_playing(_track: &Track) {}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -293,6 +309,8 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
}),
|
}),
|
||||||
spectrum: Vec::new(),
|
spectrum: Vec::new(),
|
||||||
spectrum_enabled: true,
|
spectrum_enabled: true,
|
||||||
|
|
@ -373,6 +391,19 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_spectrum_hides_then_shows_the_bars() {
|
||||||
|
let mut pane = now_playing(10_000, 60_000);
|
||||||
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
|
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
||||||
|
// `v` hides the bars…
|
||||||
|
pane.toggle_spectrum();
|
||||||
|
assert!(!rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
||||||
|
// …and again brings them back.
|
||||||
|
pane.toggle_spectrum();
|
||||||
|
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
||||||
|
}
|
||||||
|
|
||||||
/// The position can overrun a stale or wrong duration (streams,
|
/// The position can overrun a stale or wrong duration (streams,
|
||||||
/// hand-written track files); the gauge must clamp instead of hitting
|
/// hand-written track files); the gauge must clamp instead of hitting
|
||||||
/// ratatui's `ratio should be between 0 and 1` panic.
|
/// ratatui's `ratio should be between 0 and 1` panic.
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ impl Queue {
|
||||||
is_deletable: false,
|
is_deletable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
is_skipped: t.is_skipped,
|
is_skipped: t.is_skipped,
|
||||||
|
is_captured: t.is_captured,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -233,6 +234,8 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
resolving,
|
resolving,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
|
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
|
||||||
|
|
||||||
#[derive(ClapSerde, Serialize, Debug)]
|
#[derive(ClapSerde, Serialize, Debug)]
|
||||||
|
|
@ -8,6 +10,140 @@ pub struct Config {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The on-disk path of a client config file:
|
||||||
|
/// `dirs::config_dir()/crabidy/<file_name>`.
|
||||||
|
pub fn config_path(file_name: &str) -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|dir| dir.join("crabidy").join(file_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a client config, writing a defaults file on first run.
|
||||||
|
///
|
||||||
|
/// This is the no-subcommand read path, replacing the general
|
||||||
|
/// `crabidy_core::init_config` for the clap-derive binaries: it reads the
|
||||||
|
/// file (or writes defaults when absent) using the same TOML serialization,
|
||||||
|
/// but never calls `merge_clap` — argv is parsed by `cbd_cli::TuiCli`, and
|
||||||
|
/// the flags are applied afterwards with [`apply_overrides`]. A missing
|
||||||
|
/// config directory falls back to the built-in defaults without writing.
|
||||||
|
pub fn load_first_run(file_name: &str) -> Config {
|
||||||
|
let Some(path) = config_path(file_name) else {
|
||||||
|
return Config::default();
|
||||||
|
};
|
||||||
|
load_first_run_at(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`load_first_run`] against an explicit path (the testable core).
|
||||||
|
pub fn load_first_run_at(path: &Path) -> Config {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if let Err(err) = std::fs::create_dir_all(parent) {
|
||||||
|
eprintln!(
|
||||||
|
"could not create config directory {}: {err}",
|
||||||
|
parent.display()
|
||||||
|
);
|
||||||
|
return Config::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !path.is_file() {
|
||||||
|
let config = Config::default();
|
||||||
|
match toml::to_string_pretty(&config) {
|
||||||
|
Ok(text) => {
|
||||||
|
if let Err(err) = std::fs::write(path, text) {
|
||||||
|
eprintln!("could not write config {}: {err}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!("could not serialize config: {err}"),
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
load_existing(path).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads an existing config file into a [`Config`], returning `None` on a
|
||||||
|
/// missing/unreadable/unparsable file (the caller falls back to defaults).
|
||||||
|
fn load_existing(path: &Path) -> Option<Config> {
|
||||||
|
let text = std::fs::read_to_string(path).ok()?;
|
||||||
|
match toml::from_str::<<Config as ClapSerde>::Opt>(&text) {
|
||||||
|
Ok(opt) => Some(Config::from(opt)),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("invalid config {}: {err}", path.display());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies CLI overrides onto a loaded config: a provided flag wins over the
|
||||||
|
/// file value; an omitted flag leaves the file value in place
|
||||||
|
/// (architecture/cli.md D2).
|
||||||
|
pub fn apply_overrides(
|
||||||
|
config: &mut Config,
|
||||||
|
address: Option<String>,
|
||||||
|
user: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
spectrum: Option<bool>,
|
||||||
|
) {
|
||||||
|
if let Some(address) = address {
|
||||||
|
config.server.address = address;
|
||||||
|
}
|
||||||
|
if let Some(user) = user {
|
||||||
|
config.server.user = user;
|
||||||
|
}
|
||||||
|
if let Some(password) = password {
|
||||||
|
config.server.password = password;
|
||||||
|
}
|
||||||
|
if let Some(spectrum) = spectrum {
|
||||||
|
config.server.spectrum = spectrum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `config` back to `file_name` (same TOML shape as [`load_first_run`]
|
||||||
|
/// wrote), creating the config directory if missing. Returns the path
|
||||||
|
/// written. The password is stored in plaintext — keep the file private.
|
||||||
|
pub fn store(file_name: &str, config: &Config) -> Result<PathBuf, String> {
|
||||||
|
let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?;
|
||||||
|
store_at(&path, config)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`store`] against an explicit path (the testable core).
|
||||||
|
pub fn store_at(path: &Path, config: &Config) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let text =
|
||||||
|
toml::to_string_pretty(config).map_err(|err| format!("cannot serialize config: {err}"))?;
|
||||||
|
std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `auth` subcommand writer: loads `file_name` (or defaults), sets the
|
||||||
|
/// basic-auth `user` (role name) and cleartext `password` (and `address`
|
||||||
|
/// when given), preserving other fields, and writes it back.
|
||||||
|
pub fn write_auth(
|
||||||
|
file_name: &str,
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
address: Option<&str>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?;
|
||||||
|
write_auth_at(&path, user, password, address)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`write_auth`] against an explicit path (the testable core).
|
||||||
|
pub fn write_auth_at(
|
||||||
|
path: &Path,
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
address: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut config = load_existing(path).unwrap_or_default();
|
||||||
|
config.server.user = user.to_string();
|
||||||
|
config.server.password = password.to_string();
|
||||||
|
if let Some(address) = address {
|
||||||
|
config.server.address = address.to_string();
|
||||||
|
}
|
||||||
|
store_at(path, &config)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(ClapSerde, Serialize, Debug)]
|
#[derive(ClapSerde, Serialize, Debug)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
/// Server address
|
/// Server address
|
||||||
|
|
@ -34,3 +170,69 @@ pub struct ServerConfig {
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub spectrum: bool,
|
pub spectrum: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_run_writes_a_defaults_file_and_reloads_it() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let path = dir.path().join("cbd-tui.toml");
|
||||||
|
assert!(!path.is_file());
|
||||||
|
let config = load_first_run_at(&path);
|
||||||
|
// Defaults written on first run.
|
||||||
|
assert!(path.is_file());
|
||||||
|
assert_eq!(config.server.address, "http://127.0.0.1:50051");
|
||||||
|
assert_eq!(config.server.user, "");
|
||||||
|
assert!(config.server.spectrum);
|
||||||
|
// Reloading reads the file (no second write needed).
|
||||||
|
let reloaded = load_first_run_at(&path);
|
||||||
|
assert_eq!(reloaded.server.address, config.server.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_provided_flag_overrides_the_file_and_omitted_flags_do_not() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let path = dir.path().join("cbd-tui.toml");
|
||||||
|
let mut config = load_first_run_at(&path);
|
||||||
|
apply_overrides(
|
||||||
|
&mut config,
|
||||||
|
Some("http://pi:50051".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(false),
|
||||||
|
);
|
||||||
|
assert_eq!(config.server.address, "http://pi:50051");
|
||||||
|
// user was omitted: falls back to the file value.
|
||||||
|
assert_eq!(config.server.user, "");
|
||||||
|
assert!(!config.server.spectrum);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_auth_round_trips_user_password_address() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let path = dir.path().join("cbd-tui.toml");
|
||||||
|
// Seed a defaults file, then set credentials.
|
||||||
|
load_first_run_at(&path);
|
||||||
|
write_auth_at(&path, "queue-owner", "s3cret", Some("http://pi:50051")).expect("write auth");
|
||||||
|
let reloaded = load_first_run_at(&path);
|
||||||
|
assert_eq!(reloaded.server.user, "queue-owner");
|
||||||
|
assert_eq!(reloaded.server.password, "s3cret");
|
||||||
|
assert_eq!(reloaded.server.address, "http://pi:50051");
|
||||||
|
// spectrum (an unrelated field) is preserved at its default.
|
||||||
|
assert!(reloaded.server.spectrum);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_auth_without_address_keeps_the_existing_one() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let path = dir.path().join("cbd-tui.toml");
|
||||||
|
write_auth_at(&path, "owner", "pw", Some("http://a:1")).expect("first");
|
||||||
|
write_auth_at(&path, "owner", "pw2", None).expect("second");
|
||||||
|
let reloaded = load_first_run_at(&path);
|
||||||
|
assert_eq!(reloaded.server.password, "pw2");
|
||||||
|
assert_eq!(reloaded.server.address, "http://a:1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,9 +285,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
|
||||||
// The overlays are strictly modal: while one is open,
|
// The overlays are strictly modal: while one is open,
|
||||||
// keys answer it and the bindings table (including
|
// keys answer it and the bindings table (including
|
||||||
// quit) is unreachable.
|
// quit) is unreachable.
|
||||||
if app.confirm.is_some() {
|
if app.search.is_some() {
|
||||||
app.handle_confirm_key(key);
|
|
||||||
} else if app.search.is_some() {
|
|
||||||
app.handle_search_key(key);
|
app.handle_search_key(key);
|
||||||
} else if app.input.is_some() {
|
} else if app.input.is_some() {
|
||||||
app.handle_input_key(key);
|
app.handle_input_key(key);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
//! The standalone TUI binary: file-based tracing (the terminal belongs
|
//! The standalone TUI binary: a clap-derive CLI ([`cbd_cli::TuiCli`]). With
|
||||||
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives
|
//! no subcommand it loads the client config (writing defaults on first run),
|
||||||
//! in the library so the bundled `cbd` binary can host it too
|
//! applies the `--address/--user/--password/--spectrum` overrides, and runs
|
||||||
//! (architecture/cbd-bundle.md D1).
|
//! the TUI — exactly as before. Subcommands cover `auth` (write credentials
|
||||||
|
//! into the config), remote control (`library`/`queue`/`global`), and shell
|
||||||
|
//! completions. All client logic lives in the library so the bundled `cbd`
|
||||||
|
//! binary can host it too (architecture/cbd-bundle.md D1).
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use cbd_tui::config::Config;
|
use cbd_cli::{RemoteCmd, TuiCli, TuiCommand};
|
||||||
|
use cbd_tui::config::{self, Config};
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
|
||||||
|
/// The config file name for the standalone TUI.
|
||||||
|
const CONFIG_FILE: &str = "cbd-tui.toml";
|
||||||
|
|
||||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||||
|
|
||||||
|
|
@ -43,7 +51,80 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cli = TuiCli::parse();
|
||||||
|
match cli.command {
|
||||||
|
// No subcommand: load config, apply overrides, run the TUI.
|
||||||
|
None => {
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
let mut config = config::load_first_run(CONFIG_FILE);
|
||||||
|
config::apply_overrides(
|
||||||
|
&mut config,
|
||||||
|
cli.remote.address,
|
||||||
|
cli.remote.user,
|
||||||
|
cli.remote.password,
|
||||||
|
cli.spectrum,
|
||||||
|
);
|
||||||
|
let config = CONFIG.get_or_init(|| config);
|
||||||
cbd_tui::run(config).await
|
cbd_tui::run(config).await
|
||||||
}
|
}
|
||||||
|
// Subcommands are one-shot CLI actions; a failure prints a short
|
||||||
|
// message and exits non-zero.
|
||||||
|
Some(command) => {
|
||||||
|
if let Err(err) = run_command(&cli.remote, command).await {
|
||||||
|
eprintln!("error: {err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches a TUI subcommand.
|
||||||
|
async fn run_command(
|
||||||
|
remote: &cbd_cli::RemoteArgs,
|
||||||
|
command: TuiCommand,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match command {
|
||||||
|
TuiCommand::Auth(args) => {
|
||||||
|
let password = args
|
||||||
|
.password
|
||||||
|
.ok_or("missing password (pass it as an argument)")?;
|
||||||
|
let path = config::write_auth(
|
||||||
|
CONFIG_FILE,
|
||||||
|
args.role.user_name(),
|
||||||
|
&password,
|
||||||
|
args.address.as_deref(),
|
||||||
|
)?;
|
||||||
|
println!(
|
||||||
|
"wrote credentials for {} to {}",
|
||||||
|
args.role.user_name(),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
TuiCommand::Library(cmd) => {
|
||||||
|
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
|
||||||
|
}
|
||||||
|
TuiCommand::Queue(cmd) => {
|
||||||
|
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
|
||||||
|
}
|
||||||
|
TuiCommand::Global(cmd) => {
|
||||||
|
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
|
||||||
|
}
|
||||||
|
TuiCommand::Completions(args) => {
|
||||||
|
cbd_cli::print_completions(args.shell, &mut TuiCli::command(), "cbd-tui");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the connection for a remote command: the CLI flags win over the
|
||||||
|
/// client config, which supplies the fallback (address and credentials).
|
||||||
|
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
|
||||||
|
let config = config::load_first_run(CONFIG_FILE);
|
||||||
|
cbd_cli::Connection {
|
||||||
|
address: remote.address.clone().unwrap_or(config.server.address),
|
||||||
|
user: remote.user.clone().unwrap_or(config.server.user),
|
||||||
|
password: remote.password.clone().unwrap_or(config.server.password),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,14 @@ pub struct RpcClient {
|
||||||
/// Whether a library listing may be served from the session cache.
|
/// Whether a library listing may be served from the session cache.
|
||||||
///
|
///
|
||||||
/// The server-side folder providers mutate behind the client's back —
|
/// The server-side folder providers mutate behind the client's back —
|
||||||
/// captures finish (`W`), queues get saved (`w`), bookmarks appear,
|
/// saves and captures appear under `/crabidy` (`w`/`W`), files change on
|
||||||
/// files change on disk — so a cached listing turns freshly captured
|
/// disk under `/fs`, and `/orphans` is recomputed from the store on every
|
||||||
/// content invisible until a restart. Their listings are cheap local
|
/// visit — so a cached listing turns freshly captured content (or a changed
|
||||||
/// directory walks on the server; always refetch them. Remote provider
|
/// orphan set) invisible until a restart. Their listings are cheap local
|
||||||
/// nodes (tidal, youtube) keep the cache that makes back-navigation
|
/// walks on the server; always refetch them. Remote provider nodes (tidal,
|
||||||
/// instant.
|
/// youtube) keep the cache that makes back-navigation instant.
|
||||||
fn is_cacheable(path: &str) -> bool {
|
fn is_cacheable(path: &str) -> bool {
|
||||||
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"];
|
const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
|
||||||
!MUTABLE_ROOTS.iter().any(|root| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||||
})
|
})
|
||||||
|
|
@ -392,14 +392,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn mutable_provider_listings_are_never_cached() {
|
fn mutable_provider_listings_are_never_cached() {
|
||||||
// Freshly captured/saved content must show up on the next visit
|
// Freshly captured/saved content must show up on the next visit
|
||||||
// (a cached /captures hid new captures until a TUI restart).
|
// (a cached /crabidy hid new saves until a TUI restart).
|
||||||
for path in [
|
for path in [
|
||||||
"/captures",
|
"/crabidy",
|
||||||
"/captures/faves",
|
"/crabidy/faves",
|
||||||
"/queues",
|
"/crabidy/current",
|
||||||
"/queues/road trip",
|
"/crabidy/road trip",
|
||||||
"/bookmarks/b",
|
|
||||||
"/fs/music",
|
"/fs/music",
|
||||||
|
"/orphans",
|
||||||
|
"/orphans/stray.flac",
|
||||||
] {
|
] {
|
||||||
assert!(!is_cacheable(path), "{path}");
|
assert!(!is_cacheable(path), "{path}");
|
||||||
}
|
}
|
||||||
|
|
@ -409,6 +410,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
// …and prefix look-alikes are not swept up.
|
// …and prefix look-alikes are not swept up.
|
||||||
assert!(is_cacheable("/fsdy"));
|
assert!(is_cacheable("/fsdy"));
|
||||||
assert!(is_cacheable("/queuestore"));
|
assert!(is_cacheable("/crabidystore"));
|
||||||
|
assert!(is_cacheable("/orphansaurus"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ web-sys = { workspace = true, features = [
|
||||||
"KeyboardEvent",
|
"KeyboardEvent",
|
||||||
"Location",
|
"Location",
|
||||||
"Performance",
|
"Performance",
|
||||||
|
"ScrollIntoViewOptions",
|
||||||
|
"ScrollLogicalPosition",
|
||||||
"Storage",
|
"Storage",
|
||||||
"Window",
|
"Window",
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
# cbd-web — the browser client
|
# cbd-web — the browser client
|
||||||
|
|
||||||
A [Leptos](https://leptos.dev) client-side WASM app with the same
|
A [Leptos](https://leptos.dev) client-side WASM app with the same
|
||||||
functionality as `cbd-tui`, served by `crabidy-server` itself. See
|
functionality as `cbd-tui`, served by `crabidy-server` itself.
|
||||||
`architecture/web-client.md` for the design.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
|
|
@ -10,8 +9,8 @@ functionality as `cbd-tui`, served by `crabidy-server` itself. See
|
||||||
generated client and proto types the TUI uses (`crabidy-core`). No
|
generated client and proto types the TUI uses (`crabidy-core`). No
|
||||||
second API surface — feature parity is structural. The server wraps
|
second API surface — feature parity is structural. The server wraps
|
||||||
its existing gRPC service in `tonic-web`, so the browser and the TUI
|
its existing gRPC service in `tonic-web`, so the browser and the TUI
|
||||||
hit identical `/crabidy.v1.CrabidyService/…` paths, and the role
|
hit identical `/crabidy.v1.CrabidyService/…` paths, and the same role
|
||||||
auth layer (`architecture/roles-auth.md`) gates both.
|
auth layer gates both.
|
||||||
- **Serving**: the built bundle (`cbd-web/dist`) is embedded into
|
- **Serving**: the built bundle (`cbd-web/dist`) is embedded into
|
||||||
`crabidy-server` at compile time behind the default-on `web-ui`
|
`crabidy-server` at compile time behind the default-on `web-ui`
|
||||||
feature and served as the fallback route on port 50051. gRPC and
|
feature and served as the fallback route on port 50051. gRPC and
|
||||||
|
|
@ -27,13 +26,12 @@ functionality as `cbd-tui`, served by `crabidy-server` itself. See
|
||||||
## Functionality
|
## Functionality
|
||||||
|
|
||||||
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click),
|
Everything the TUI does: browse the library (`j`/`k`/`h`/`l`, click),
|
||||||
marks, create/rename/delete nodes (`%`/`e`/`d`, with the capture-delete
|
marks, create/rename/delete nodes (`%`/`e`/`d`), bookmark and capture
|
||||||
`y/N` confirmation), bookmark and capture (`w`/`W`, with live progress
|
(`w`/`W`, with live progress lines and skipped-track marking), the full queue
|
||||||
lines and skipped-track marking), the full queue and playback controls,
|
and playback controls, volume, shuffle/repeat, and a `?` help overlay listing
|
||||||
volume, shuffle/repeat, and a `?` help overlay listing the keys. Keys
|
the keys. Keys mirror the TUI; every key also has a clickable control. A
|
||||||
mirror the TUI; every key also has a clickable control. A light/dark
|
light/dark theme follows the OS and can be toggled (persisted). The accent
|
||||||
theme follows the OS and can be toggled (persisted). The accent color
|
color is the crab orange-red.
|
||||||
is the crab orange-red.
|
|
||||||
|
|
||||||
When the server requires credentials, a login form collects the role
|
When the server requires credentials, a login form collects the role
|
||||||
(`owner` / `queue-owner` / `queue-appender`) and password; they are
|
(`owner` / `queue-owner` / `queue-appender`) and password; they are
|
||||||
|
|
@ -54,9 +52,10 @@ cargo build -p crabidy-server # embeds cbd-web/dist
|
||||||
mold linker, which `rust-lld` (the wasm linker) cannot parse.
|
mold linker, which `rust-lld` (the wasm linker) cannot parse.
|
||||||
|
|
||||||
Building `crabidy-server` without a `cbd-web/dist` present is fine — it
|
Building `crabidy-server` without a `cbd-web/dist` present is fine — it
|
||||||
embeds a placeholder page telling you to run `build-web`. Build the
|
embeds a placeholder page telling you to run `build-web`. To drop the web
|
||||||
server `--no-default-features` to drop the web client (and the
|
client (and the `tonic-web` layer) entirely, build the server without its
|
||||||
`tonic-web` layer) entirely.
|
`web-ui` cargo feature — e.g. `--no-default-features --features
|
||||||
|
all-providers,opus,spectrum`; see `docs/src/build-features.md`.
|
||||||
|
|
||||||
## Dev loop
|
## Dev loop
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ use leptos::task::spawn_local;
|
||||||
use crate::keymap::{self, Action};
|
use crate::keymap::{self, Action};
|
||||||
use crate::rpc::Rpc;
|
use crate::rpc::Rpc;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
delete_needs_confirmation, format_seconds, is_cacheable, track_label, CaptureBoard, Dialog,
|
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
|
||||||
Focus, LibraryPane, NamePurpose, QueueCursor, UiItemKind,
|
NamePurpose, QueueCursor, UiItemKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VOLUME_STEP: f32 = 0.1;
|
const VOLUME_STEP: f32 = 0.1;
|
||||||
|
|
@ -65,6 +65,27 @@ fn apply_theme(theme: &str) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scrolls the selected row of a pane back into view after a keyboard move,
|
||||||
|
/// so the cursor never drifts out of the scroll box (the click handlers
|
||||||
|
/// need no help — the pointer is already on-screen). `pane_selector` picks
|
||||||
|
/// the pane (`.pane.library` / `.pane.queue`); we then find its `.selected`
|
||||||
|
/// row. Deferred to the next animation frame so the freshly-rendered row is
|
||||||
|
/// in the DOM. `block: nearest` scrolls the minimum amount — an already
|
||||||
|
/// visible row does not move.
|
||||||
|
fn scroll_selected_into_view(pane_selector: &'static str) {
|
||||||
|
request_animation_frame(move || {
|
||||||
|
let selector = format!("{pane_selector} .list li.selected");
|
||||||
|
if let Some(el) = web_sys::window()
|
||||||
|
.and_then(|w| w.document())
|
||||||
|
.and_then(|d| d.query_selector(&selector).ok().flatten())
|
||||||
|
{
|
||||||
|
let opts = web_sys::ScrollIntoViewOptions::new();
|
||||||
|
opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
|
||||||
|
el.scroll_into_view_with_scroll_into_view_options(&opts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the store ---------------------------------------------------------
|
// ---- the store ---------------------------------------------------------
|
||||||
|
|
||||||
/// Every signal the components share. `Copy` so closures capture it
|
/// Every signal the components share. `Copy` so closures capture it
|
||||||
|
|
@ -73,6 +94,16 @@ fn apply_theme(theme: &str) {
|
||||||
struct Store {
|
struct Store {
|
||||||
connected: RwSignal<bool>,
|
connected: RwSignal<bool>,
|
||||||
needs_login: RwSignal<bool>,
|
needs_login: RwSignal<bool>,
|
||||||
|
/// Whether the open login dialog may be dismissed to keep the
|
||||||
|
/// unauthenticated fallback role (true) or credentials are required
|
||||||
|
/// because the server denied anonymous access (false).
|
||||||
|
guest_ok: RwSignal<bool>,
|
||||||
|
/// Set once the proactive "auth is enabled" prompt has been shown, so
|
||||||
|
/// stream reconnects do not re-open it after the user dismissed it.
|
||||||
|
login_prompted: RwSignal<bool>,
|
||||||
|
/// Whether the server has auth configured (from `Init.auth_enabled`),
|
||||||
|
/// so the top bar can offer a login only where it is meaningful.
|
||||||
|
auth_enabled: RwSignal<bool>,
|
||||||
queue: RwSignal<Vec<Track>>,
|
queue: RwSignal<Vec<Track>>,
|
||||||
queue_pos: RwSignal<u32>,
|
queue_pos: RwSignal<u32>,
|
||||||
resolving: RwSignal<bool>,
|
resolving: RwSignal<bool>,
|
||||||
|
|
@ -101,6 +132,9 @@ impl Store {
|
||||||
Self {
|
Self {
|
||||||
connected: RwSignal::new(false),
|
connected: RwSignal::new(false),
|
||||||
needs_login: RwSignal::new(false),
|
needs_login: RwSignal::new(false),
|
||||||
|
guest_ok: RwSignal::new(false),
|
||||||
|
login_prompted: RwSignal::new(false),
|
||||||
|
auth_enabled: RwSignal::new(false),
|
||||||
queue: RwSignal::new(Vec::new()),
|
queue: RwSignal::new(Vec::new()),
|
||||||
queue_pos: RwSignal::new(0),
|
queue_pos: RwSignal::new(0),
|
||||||
resolving: RwSignal::new(false),
|
resolving: RwSignal::new(false),
|
||||||
|
|
@ -132,6 +166,7 @@ impl Store {
|
||||||
fn fail(&self, status: tonic::Status) {
|
fn fail(&self, status: tonic::Status) {
|
||||||
if status.code() == tonic::Code::Unauthenticated {
|
if status.code() == tonic::Code::Unauthenticated {
|
||||||
self.needs_login.set(true);
|
self.needs_login.set(true);
|
||||||
|
self.guest_ok.set(false);
|
||||||
self.dialog.set(Some(Dialog::Login));
|
self.dialog.set(Some(Dialog::Login));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -353,16 +388,16 @@ impl Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::LibraryDeleteNode => {
|
Action::LibraryDeleteNode => {
|
||||||
if let Some((path, title)) =
|
// Deleting under `/crabidy` drops the metadata toml only; the
|
||||||
|
// shared store audio survives and resurfaces under
|
||||||
|
// `/orphans`, so there is nothing to confirm — same as the
|
||||||
|
// TUI.
|
||||||
|
if let Some((path, _title)) =
|
||||||
self.library.with_untracked(LibraryPane::selected_deletable)
|
self.library.with_untracked(LibraryPane::selected_deletable)
|
||||||
{
|
{
|
||||||
if delete_needs_confirmation(&path) {
|
|
||||||
self.dialog.set(Some(Dialog::ConfirmDelete { path, title }));
|
|
||||||
} else {
|
|
||||||
self.delete_node(path);
|
self.delete_node(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Action::LibraryCaptureNode => {
|
Action::LibraryCaptureNode => {
|
||||||
if let Some((path, title)) =
|
if let Some((path, title)) =
|
||||||
self.library.with_untracked(LibraryPane::selected_queueable)
|
self.library.with_untracked(LibraryPane::selected_queueable)
|
||||||
|
|
@ -519,6 +554,22 @@ fn run_stream(store: Store) {
|
||||||
if let Some(position) = init.position {
|
if let Some(position) = init.position {
|
||||||
store.apply(StreamUpdate::Position(position));
|
store.apply(StreamUpdate::Position(position));
|
||||||
}
|
}
|
||||||
|
// We connected — as the unauthenticated fallback
|
||||||
|
// role if we sent no credentials. Record whether
|
||||||
|
// the server has auth at all (drives the top-bar
|
||||||
|
// login button), then, when it does and the user
|
||||||
|
// has none stored, offer a login once — dismissible
|
||||||
|
// so they can stay on the fallback role.
|
||||||
|
store.auth_enabled.set(init.auth_enabled);
|
||||||
|
let no_stored_creds = load_pref("user").unwrap_or_default().is_empty();
|
||||||
|
if init.auth_enabled
|
||||||
|
&& no_stored_creds
|
||||||
|
&& !store.login_prompted.get_untracked()
|
||||||
|
{
|
||||||
|
store.login_prompted.set(true);
|
||||||
|
store.guest_ok.set(true);
|
||||||
|
store.dialog.set(Some(Dialog::Login));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(status) => store.fail(status),
|
Err(status) => store.fail(status),
|
||||||
}
|
}
|
||||||
|
|
@ -543,6 +594,7 @@ fn run_stream(store: Store) {
|
||||||
}
|
}
|
||||||
Err(status) if status.code() == tonic::Code::Unauthenticated => {
|
Err(status) if status.code() == tonic::Code::Unauthenticated => {
|
||||||
store.needs_login.set(true);
|
store.needs_login.set(true);
|
||||||
|
store.guest_ok.set(false);
|
||||||
store.dialog.set(Some(Dialog::Login));
|
store.dialog.set(Some(Dialog::Login));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -641,6 +693,47 @@ fn TopBar(store: Store) -> impl IntoView {
|
||||||
{move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }}
|
{move || if store.connected.get() { "" } else { "disconnected — reconnecting…" }}
|
||||||
</span>
|
</span>
|
||||||
<span class="topbar-actions">
|
<span class="topbar-actions">
|
||||||
|
{move || {
|
||||||
|
// A login is only meaningful where the server has auth
|
||||||
|
// (offer "log in"); once credentials are stored, offer
|
||||||
|
// "log out" to drop back to the guest/fallback role.
|
||||||
|
let logged_in = !load_pref("user").unwrap_or_default().is_empty();
|
||||||
|
(store.auth_enabled.get() || logged_in)
|
||||||
|
.then(|| {
|
||||||
|
if logged_in {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
title="log out (return to the guest role)"
|
||||||
|
on:click=move |_| {
|
||||||
|
save_pref("user", "");
|
||||||
|
save_pref("password", "");
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"log out"
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
title="log in"
|
||||||
|
on:click=move |_| {
|
||||||
|
store.guest_ok.set(true);
|
||||||
|
store.dialog.set(Some(Dialog::Login));
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"log in"
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
<button class="ghost" title="cycle theme (auto/light/dark)" on:click=cycle_theme>
|
<button class="ghost" title="cycle theme (auto/light/dark)" on:click=cycle_theme>
|
||||||
"◐"
|
"◐"
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -661,6 +754,13 @@ fn LibraryView(store: Store) -> impl IntoView {
|
||||||
let focused = move || store.focus.get() == Focus::Library;
|
let focused = move || store.focus.get() == Focus::Library;
|
||||||
let library = store.library;
|
let library = store.library;
|
||||||
|
|
||||||
|
// Keep the keyboard cursor visible: whenever the library cursor (or its
|
||||||
|
// listing) changes, scroll the selected row back into the scroll box.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _ = library.with(|p| p.selected);
|
||||||
|
scroll_selected_into_view(".pane.library");
|
||||||
|
});
|
||||||
|
|
||||||
let toolbar = move || {
|
let toolbar = move || {
|
||||||
let pane = library.get();
|
let pane = library.get();
|
||||||
let selection = pane.selected_item();
|
let selection = pane.selected_item();
|
||||||
|
|
@ -816,6 +916,14 @@ fn LibraryView(store: Store) -> impl IntoView {
|
||||||
#[component]
|
#[component]
|
||||||
fn QueueView(store: Store) -> impl IntoView {
|
fn QueueView(store: Store) -> impl IntoView {
|
||||||
let focused = move || store.focus.get() == Focus::Queue;
|
let focused = move || store.focus.get() == Focus::Queue;
|
||||||
|
|
||||||
|
// Keep the keyboard cursor visible. The cursor is its own signal, so this
|
||||||
|
// fires on moves but not on every stream-driven queue refresh.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _ = store.queue_cursor.get();
|
||||||
|
scroll_selected_into_view(".pane.queue");
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<section
|
<section
|
||||||
class="pane queue"
|
class="pane queue"
|
||||||
|
|
@ -960,7 +1068,7 @@ fn Transport(store: Store) -> impl IntoView {
|
||||||
</span>
|
</span>
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<span class="time">
|
<span class="time">
|
||||||
{move || format_seconds(store.position.get().position)}
|
{move || format_seconds(store.position.get().position / 1000)}
|
||||||
</span>
|
</span>
|
||||||
<div class="gauge">
|
<div class="gauge">
|
||||||
<div
|
<div
|
||||||
|
|
@ -980,7 +1088,7 @@ fn Transport(store: Store) -> impl IntoView {
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="time">
|
<span class="time">
|
||||||
{move || format_seconds(store.position.get().duration)}
|
{move || format_seconds(store.position.get().duration / 1000)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1014,9 +1122,6 @@ fn Dialogs(store: Store) -> impl IntoView {
|
||||||
Dialog::Name { purpose, buffer } => {
|
Dialog::Name { purpose, buffer } => {
|
||||||
view! { <NameDialog store=store purpose=purpose buffer=buffer /> }.into_any()
|
view! { <NameDialog store=store purpose=purpose buffer=buffer /> }.into_any()
|
||||||
}
|
}
|
||||||
Dialog::ConfirmDelete { path, title } => {
|
|
||||||
view! { <ConfirmDialog store=store path=path title=title /> }.into_any()
|
|
||||||
}
|
|
||||||
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
|
Dialog::Login => view! { <LoginDialog store=store /> }.into_any(),
|
||||||
Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(),
|
Dialog::Help => view! { <HelpOverlay store=store /> }.into_any(),
|
||||||
})
|
})
|
||||||
|
|
@ -1058,52 +1163,8 @@ fn NameDialog(store: Store, purpose: NamePurpose, buffer: String) -> impl IntoVi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn ConfirmDialog(store: Store, path: String, title: String) -> impl IntoView {
|
|
||||||
let confirm_path = path.clone();
|
|
||||||
let confirm = move |_| {
|
|
||||||
store.dialog.set(None);
|
|
||||||
store.delete_node(confirm_path.clone());
|
|
||||||
};
|
|
||||||
// y/N without leaving the keyboard, like the TUI: the overlay grabs
|
|
||||||
// the keys while it is open.
|
|
||||||
let key_path = path;
|
|
||||||
let handle = window_event_listener(leptos::ev::keydown, move |ev| {
|
|
||||||
if !matches!(
|
|
||||||
store.dialog.get_untracked(),
|
|
||||||
Some(Dialog::ConfirmDelete { .. })
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ev.prevent_default();
|
|
||||||
store.dialog.set(None);
|
|
||||||
if matches!(ev.key().as_str(), "y" | "Y") {
|
|
||||||
store.delete_node(key_path.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
on_cleanup(move || handle.remove());
|
|
||||||
view! {
|
|
||||||
<div class="overlay" on:click=move |_| store.dialog.set(None)>
|
|
||||||
<div class="dialog danger-dialog" on:click=|ev| ev.stop_propagation()>
|
|
||||||
<p>
|
|
||||||
"Delete "<strong>{title}</strong>
|
|
||||||
" from disk (downloaded audio included)?"
|
|
||||||
</p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="ghost" on:click=move |_| store.dialog.set(None)>
|
|
||||||
"cancel (N)"
|
|
||||||
</button>
|
|
||||||
<button class="danger" on:click=confirm>"delete (y)"</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn LoginDialog(store: Store) -> impl IntoView {
|
fn LoginDialog(store: Store) -> impl IntoView {
|
||||||
// The dialog needs no store access: submitting reloads the page.
|
|
||||||
let _ = store;
|
|
||||||
let user = RwSignal::new(load_pref("user").unwrap_or_default());
|
let user = RwSignal::new(load_pref("user").unwrap_or_default());
|
||||||
let password = RwSignal::new(String::new());
|
let password = RwSignal::new(String::new());
|
||||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
|
@ -1115,10 +1176,22 @@ fn LoginDialog(store: Store) -> impl IntoView {
|
||||||
let _ = window.location().reload();
|
let _ = window.location().reload();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// When the server granted an unauthenticated fallback role we are
|
||||||
|
// already connected under it, so the prompt is skippable; when it
|
||||||
|
// denied anonymous access, credentials are the only way in.
|
||||||
|
let guest_ok = store.guest_ok;
|
||||||
|
let dismiss = move |_| store.dialog.set(None);
|
||||||
|
let label = move || {
|
||||||
|
if guest_ok.get() {
|
||||||
|
"Log in for more access, or continue as guest (architecture/roles-auth.md)"
|
||||||
|
} else {
|
||||||
|
"This server requires credentials (architecture/roles-auth.md)"
|
||||||
|
}
|
||||||
|
};
|
||||||
view! {
|
view! {
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<form class="dialog" on:submit=submit>
|
<form class="dialog" on:submit=submit>
|
||||||
<label>"This server requires credentials (architecture/roles-auth.md)"</label>
|
<label>{label}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="role: owner | queue-owner | queue-appender"
|
placeholder="role: owner | queue-owner | queue-appender"
|
||||||
|
|
@ -1133,6 +1206,17 @@ fn LoginDialog(store: Store) -> impl IntoView {
|
||||||
on:input=move |ev| password.set(event_target_value(&ev))
|
on:input=move |ev| password.set(event_target_value(&ev))
|
||||||
/>
|
/>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
|
{move || {
|
||||||
|
guest_ok
|
||||||
|
.get()
|
||||||
|
.then(|| {
|
||||||
|
view! {
|
||||||
|
<button type="button" on:click=dismiss>
|
||||||
|
"continue as guest"
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
<button type="submit">"connect"</button>
|
<button type="submit">"connect"</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -52,26 +52,22 @@ pub enum Dialog {
|
||||||
purpose: NamePurpose,
|
purpose: NamePurpose,
|
||||||
buffer: String,
|
buffer: String,
|
||||||
},
|
},
|
||||||
/// The capture-delete confirmation (architecture/capture-deletion.md).
|
/// Credentials form. Shown on `UNAUTHENTICATED` responses (creds
|
||||||
ConfirmDelete { path: String, title: String },
|
/// required), and proactively — but dismissible — on first connect
|
||||||
/// Credentials form, shown on `UNAUTHENTICATED` responses.
|
/// when the server reports auth is enabled and we hold none.
|
||||||
Login,
|
Login,
|
||||||
/// The `?` key binding overlay.
|
/// The `?` key binding overlay.
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether deleting `path` needs the y/N confirmation — same rule as
|
|
||||||
/// the TUI: captures hold downloaded audio, everything else deletable
|
|
||||||
/// is cheap to recreate.
|
|
||||||
pub fn delete_needs_confirmation(path: &str) -> bool {
|
|
||||||
path == "/captures" || path.starts_with("/captures/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a library listing may be cached client-side — same rule as
|
/// Whether a library listing may be cached client-side — same rule as
|
||||||
/// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate
|
/// the TUI (`cbd-tui/src/rpc.rs`): server-side folder providers mutate
|
||||||
/// behind the client's back and are cheap to re-list.
|
/// behind the client's back and are cheap to re-list. `/crabidy` (saves and
|
||||||
|
/// captures), `/fs` (files on disk), and `/orphans` (recomputed from the
|
||||||
|
/// store on every visit) all change server-side, so their listings are
|
||||||
|
/// always refetched.
|
||||||
pub fn is_cacheable(path: &str) -> bool {
|
pub fn is_cacheable(path: &str) -> bool {
|
||||||
const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"];
|
const MUTABLE_ROOTS: [&str; 3] = ["/crabidy", "/fs", "/orphans"];
|
||||||
!MUTABLE_ROOTS.iter().any(|root| {
|
!MUTABLE_ROOTS.iter().any(|root| {
|
||||||
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
|
||||||
})
|
})
|
||||||
|
|
@ -340,9 +336,17 @@ impl CaptureBoard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `mm:ss` for progress and duration displays.
|
/// `mm:ss`, or `h:mm:ss` once past an hour — matching the TUI's now-playing
|
||||||
|
/// clock so minutes zero-pad and roll into hours instead of counting past 60.
|
||||||
pub fn format_seconds(total: u32) -> String {
|
pub fn format_seconds(total: u32) -> String {
|
||||||
format!("{}:{:02}", total / 60, total % 60)
|
let secs = total % 60;
|
||||||
|
let mins = (total / 60) % 60;
|
||||||
|
let hours = total / 3600;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}:{mins:02}:{secs:02}")
|
||||||
|
} else {
|
||||||
|
format!("{mins:02}:{secs:02}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The now-playing line for a track, `artist - title` falling back to
|
/// The now-playing line for a track, `artist - title` falling back to
|
||||||
|
|
@ -376,12 +380,15 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
is_queable: true,
|
is_queable: true,
|
||||||
is_creatable: false,
|
is_creatable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
tracks_deletable: false,
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,21 +458,23 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capture_deletes_need_confirmation_cheap_deletes_do_not() {
|
|
||||||
assert!(delete_needs_confirmation("/captures/mix"));
|
|
||||||
assert!(delete_needs_confirmation("/captures/mix/a.cbd-track.toml"));
|
|
||||||
assert!(!delete_needs_confirmation("/queues/roadtrip"));
|
|
||||||
assert!(!delete_needs_confirmation("/tidal/search/abba"));
|
|
||||||
assert!(!delete_needs_confirmation("/capturesque"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mutable_roots_are_never_cacheable() {
|
fn mutable_roots_are_never_cacheable() {
|
||||||
for path in ["/captures", "/queues/x", "/bookmarks", "/fs/music"] {
|
for path in [
|
||||||
|
"/crabidy",
|
||||||
|
"/crabidy/faves",
|
||||||
|
"/fs/music",
|
||||||
|
"/orphans",
|
||||||
|
"/orphans/stray.flac",
|
||||||
|
] {
|
||||||
assert!(!is_cacheable(path), "{path}");
|
assert!(!is_cacheable(path), "{path}");
|
||||||
}
|
}
|
||||||
for path in ["/tidal/playlists", "/youtube/search", "/capturesque"] {
|
for path in [
|
||||||
|
"/tidal/playlists",
|
||||||
|
"/youtube/search",
|
||||||
|
"/crabidystore",
|
||||||
|
"/orphansaurus",
|
||||||
|
] {
|
||||||
assert!(is_cacheable(path), "{path}");
|
assert!(is_cacheable(path), "{path}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -510,8 +519,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn time_formatting_is_mm_ss() {
|
fn time_formatting_is_mm_ss() {
|
||||||
assert_eq!(format_seconds(0), "0:00");
|
assert_eq!(format_seconds(0), "00:00");
|
||||||
assert_eq!(format_seconds(61), "1:01");
|
assert_eq!(format_seconds(61), "01:01");
|
||||||
assert_eq!(format_seconds(3599), "59:59");
|
assert_eq!(format_seconds(3599), "59:59");
|
||||||
|
// Past an hour it rolls into h:mm:ss instead of counting past 60 min.
|
||||||
|
assert_eq!(format_seconds(3600), "1:00:00");
|
||||||
|
assert_eq!(format_seconds(3661), "1:01:01");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,6 @@ button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger {
|
|
||||||
background: var(--danger);
|
|
||||||
color: var(--on-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ghost.danger {
|
&.ghost.danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|
@ -395,10 +390,6 @@ input {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger-dialog {
|
|
||||||
border-inline-start: 4px solid var(--danger);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.help {
|
.help {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,38 @@ name = "cbd"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
# The bundle owns both halves, so it forwards both feature sets
|
||||||
|
# (architecture/build-features.md D1). `cargo build -p cbd
|
||||||
|
# --no-default-features --features fs,opus` is a local-files-only bundle.
|
||||||
|
[features]
|
||||||
|
default = ["all-providers", "opus", "spectrum", "web-ui", "notifications"]
|
||||||
|
|
||||||
|
all-providers = ["crabidy-server/all-providers"]
|
||||||
|
tidal = ["crabidy-server/tidal"]
|
||||||
|
youtube = ["crabidy-server/youtube"]
|
||||||
|
fyyd = ["crabidy-server/fyyd"]
|
||||||
|
abs = ["crabidy-server/abs"]
|
||||||
|
soundcloud = ["crabidy-server/soundcloud"]
|
||||||
|
jamendo = ["crabidy-server/jamendo"]
|
||||||
|
fs = ["crabidy-server/fs"]
|
||||||
|
opus = ["crabidy-server/opus"]
|
||||||
|
spectrum = ["crabidy-server/spectrum"]
|
||||||
|
web-ui = ["crabidy-server/web-ui"]
|
||||||
|
notifications = ["cbd-tui/notifications"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cbd-tui.workspace = true
|
cbd-cli = { workspace = true, features = ["client"] }
|
||||||
|
cbd-tui = { workspace = true, default-features = false }
|
||||||
|
clap.workspace = true
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
crabidy-server.workspace = true
|
crabidy-server = { workspace = true, default-features = false }
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
# Default features only (clap-only) so asset generation stays cheap.
|
||||||
|
[build-dependencies]
|
||||||
|
cbd-cli.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
//! Generates shell completions and a man page for `cbd` from its top-level
|
||||||
|
//! clap command (architecture/cli.md D7). Written into `OUT_DIR` every build,
|
||||||
|
//! and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli` is a
|
||||||
|
//! default-features (clap-only) build-dependency, so this never pulls tonic
|
||||||
|
//! into ordinary builds.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
use clap::CommandFactory;
|
||||||
|
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
|
||||||
|
let bin = "cbd";
|
||||||
|
let command = cbd_cli::CbdCli::command();
|
||||||
|
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
|
||||||
|
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
|
||||||
|
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
|
||||||
|
}
|
||||||
|
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
|
||||||
|
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
|
||||||
|
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,15 @@ use std::error::Error;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use cbd_tui::config::Config;
|
use cbd_cli::{CbdCli, CbdCommand, RemoteCmd};
|
||||||
|
use cbd_tui::config::{self, Config};
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
use crabidy_server::cli as server_cli;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// The config file name for the bundled binary (separate from `cbd-tui.toml`).
|
||||||
|
const CONFIG_FILE: &str = "cbd.toml";
|
||||||
|
|
||||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||||
|
|
||||||
/// How long to wait for the server socket before giving up. Generous:
|
/// How long to wait for the server socket before giving up. Generous:
|
||||||
|
|
@ -25,6 +31,25 @@ const READINESS_DELAY: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
// `cbd` is the union of the server and client command surfaces
|
||||||
|
// (architecture/cli.md D2): no subcommand runs the in-process server +
|
||||||
|
// TUI, exactly as before.
|
||||||
|
let cli = CbdCli::parse();
|
||||||
|
match cli.command {
|
||||||
|
None => run_bundle(cli).await,
|
||||||
|
Some(command) => {
|
||||||
|
if let Err(err) = run_command(&cli.remote, command).await {
|
||||||
|
eprintln!("error: {err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the bundled server + TUI (the no-subcommand default), unchanged from
|
||||||
|
/// before save for the clap-based config load and flag overrides.
|
||||||
|
async fn run_bundle(cli: CbdCli) -> Result<(), Box<dyn Error>> {
|
||||||
// Both halves share one file-based subscriber: the terminal belongs
|
// Both halves share one file-based subscriber: the terminal belongs
|
||||||
// to the TUI, so the server's usual stderr logging would corrupt it.
|
// to the TUI, so the server's usual stderr logging would corrupt it.
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
|
|
@ -34,7 +59,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
|
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
|
||||||
// shared `address` would force one to follow the other. `cbd`
|
// shared `address` would force one to follow the other. `cbd`
|
||||||
// defaults to localhost, which matches its embedded server.
|
// defaults to localhost, which matches its embedded server.
|
||||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd.toml"));
|
let mut config = config::load_first_run(CONFIG_FILE);
|
||||||
|
config::apply_overrides(
|
||||||
|
&mut config,
|
||||||
|
cli.remote.address,
|
||||||
|
cli.remote.user,
|
||||||
|
cli.remote.password,
|
||||||
|
cli.spectrum,
|
||||||
|
);
|
||||||
|
let config = CONFIG.get_or_init(|| config);
|
||||||
|
|
||||||
let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
|
let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
|
||||||
let mut server = tokio::spawn(crabidy_server::serve(addr));
|
let mut server = tokio::spawn(crabidy_server::serve(addr));
|
||||||
|
|
@ -49,6 +82,62 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
cbd_tui::run(config).await
|
cbd_tui::run(config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatches a `cbd` subcommand: `guard`/`scan` reuse the server logic,
|
||||||
|
/// `auth` writes `cbd.toml`, and `library`/`queue`/`global` run against a
|
||||||
|
/// server over gRPC.
|
||||||
|
async fn run_command(
|
||||||
|
remote: &cbd_cli::RemoteArgs,
|
||||||
|
command: CbdCommand,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
match command {
|
||||||
|
CbdCommand::Guard(args) => server_cli::guard(args).await,
|
||||||
|
CbdCommand::Scan(args) => server_cli::scan(args).await,
|
||||||
|
CbdCommand::AudioDevices(args) => server_cli::audio_devices(args.device),
|
||||||
|
CbdCommand::Features => server_cli::features(),
|
||||||
|
CbdCommand::Auth(args) => {
|
||||||
|
let password = args
|
||||||
|
.password
|
||||||
|
.ok_or("missing password (pass it as an argument)")?;
|
||||||
|
let path = config::write_auth(
|
||||||
|
CONFIG_FILE,
|
||||||
|
args.role.user_name(),
|
||||||
|
&password,
|
||||||
|
args.address.as_deref(),
|
||||||
|
)?;
|
||||||
|
println!(
|
||||||
|
"wrote credentials for {} to {}",
|
||||||
|
args.role.user_name(),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
CbdCommand::Library(cmd) => {
|
||||||
|
cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await
|
||||||
|
}
|
||||||
|
CbdCommand::Queue(cmd) => {
|
||||||
|
cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await
|
||||||
|
}
|
||||||
|
CbdCommand::Global(cmd) => {
|
||||||
|
cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await
|
||||||
|
}
|
||||||
|
CbdCommand::Completions(args) => {
|
||||||
|
cbd_cli::print_completions(args.shell, &mut CbdCli::command(), "cbd");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the connection for a remote command: the CLI flags win over
|
||||||
|
/// `cbd.toml`, which supplies the fallback (address and credentials).
|
||||||
|
fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
|
||||||
|
let config = config::load_first_run(CONFIG_FILE);
|
||||||
|
cbd_cli::Connection {
|
||||||
|
address: remote.address.clone().unwrap_or(config.server.address),
|
||||||
|
user: remote.user.clone().unwrap_or(config.server.user),
|
||||||
|
password: remote.password.clone().unwrap_or(config.server.password),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Waits until something accepts TCP connections on the TUI's configured
|
/// Waits until something accepts TCP connections on the TUI's configured
|
||||||
/// server address (scheme stripped): the in-process server coming up, or
|
/// server address (scheme stripped): the in-process server coming up, or
|
||||||
/// an already-running standalone one (in which case our `serve` fails
|
/// an already-running standalone one (in which case our `serve` fails
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ message InitResponse {
|
||||||
float volume = 5;
|
float volume = 5;
|
||||||
bool mute = 6;
|
bool mute = 6;
|
||||||
TrackPosition position = 7;
|
TrackPosition position = 7;
|
||||||
|
// Whether the server has any credentials configured (the auth on/off
|
||||||
|
// switch). Reachable anonymously, so a client that connected as the
|
||||||
|
// unauthenticated fallback role can learn a higher role is available
|
||||||
|
// and offer a login (architecture/roles-auth.md).
|
||||||
|
bool auth_enabled = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Library
|
// Library
|
||||||
|
|
@ -254,6 +259,9 @@ message LibraryNodeChild {
|
||||||
bool is_deletable = 6;
|
bool is_deletable = 6;
|
||||||
// This node allows download captures (CaptureLibraryNode with download).
|
// This node allows download captures (CaptureLibraryNode with download).
|
||||||
bool is_downloadable = 7;
|
bool is_downloadable = 7;
|
||||||
|
// Every track and child node below this child is captured (fully local in
|
||||||
|
// the content store). Clients mark captured rows.
|
||||||
|
bool is_captured = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message QueueModifiers {
|
message QueueModifiers {
|
||||||
|
|
@ -306,6 +314,14 @@ message Track {
|
||||||
// The track has no playable audio (a capture recorded its source as
|
// The track has no playable audio (a capture recorded its source as
|
||||||
// uncapturable). Clients mark it; playback skips it.
|
// uncapturable). Clients mark it; playback skips it.
|
||||||
bool is_skipped = 6;
|
bool is_skipped = 6;
|
||||||
|
// Provider-internal id that identifies this item inside its provider,
|
||||||
|
// independent of the path it was reached by (playlist, search, album…).
|
||||||
|
// Set by the owning provider; empty when unknown. Keys the content store.
|
||||||
|
string provider_item_id = 7;
|
||||||
|
// The content store already holds this track (by provider id or, once
|
||||||
|
// captured, as a store-backed playable). Set at listing time; clients
|
||||||
|
// mark captured rows. See architecture/crabidy-store.md.
|
||||||
|
bool is_captured = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LibraryNode {
|
message LibraryNode {
|
||||||
|
|
@ -324,4 +340,8 @@ message LibraryNode {
|
||||||
// This node's listed tracks may be deleted (see DeleteLibraryNode) —
|
// This node's listed tracks may be deleted (see DeleteLibraryNode) —
|
||||||
// like is_downloadable, tracks inherit the node's flag.
|
// like is_downloadable, tracks inherit the node's flag.
|
||||||
bool tracks_deletable = 9;
|
bool tracks_deletable = 9;
|
||||||
|
// Every track and child node below this node is captured (fully local in
|
||||||
|
// the content store). Clients mark captured nodes. See
|
||||||
|
// architecture/crabidy-store.md.
|
||||||
|
bool is_captured = 10;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ impl LibraryNode {
|
||||||
is_creatable: false,
|
is_creatable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
tracks_deletable: false,
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +232,7 @@ impl LibraryNodeChild {
|
||||||
is_editable: false,
|
is_editable: false,
|
||||||
is_deletable: false,
|
is_deletable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -365,12 +367,15 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
is_queable,
|
is_queable,
|
||||||
is_creatable: false,
|
is_creatable: false,
|
||||||
is_downloadable: false,
|
is_downloadable: false,
|
||||||
tracks_deletable: false,
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,6 +406,8 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn get_lib_root(&self) -> LibraryNode {
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,48 @@ edition.workspace = true
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Everything is on by default: a plain build is the full server. Tailor a
|
||||||
|
# smaller binary with `--no-default-features --features …`
|
||||||
|
# (architecture/build-features.md D1). Feature names are exactly the
|
||||||
|
# names in `crabidy-server.toml`'s `providers` list (D3), so one
|
||||||
|
# vocabulary covers the compile-time and the runtime switch.
|
||||||
[features]
|
[features]
|
||||||
# The embedded web client (architecture/web-client.md). On by default;
|
default = ["all-providers", "opus", "spectrum", "web-ui"]
|
||||||
# disable for a headless-only binary without the bundle.
|
|
||||||
default = ["web-ui"]
|
# Every provider this binary can mount.
|
||||||
|
all-providers = [
|
||||||
|
"tidal",
|
||||||
|
"youtube",
|
||||||
|
"fyyd",
|
||||||
|
"abs",
|
||||||
|
"soundcloud",
|
||||||
|
"jamendo",
|
||||||
|
"fs",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Internal marker: every provider feature enables it, so code can ask "is any
|
||||||
|
# provider compiled in?" — which Cargo features cannot express directly. Only
|
||||||
|
# a build with no providers at all (legal, see D2) leaves it off.
|
||||||
|
_any-provider = []
|
||||||
|
|
||||||
|
tidal = ["dep:tidaldy", "_any-provider"]
|
||||||
|
youtube = ["dep:ytdy", "_any-provider"]
|
||||||
|
fyyd = ["dep:fyyd", "_any-provider"]
|
||||||
|
abs = ["dep:absdy", "_any-provider"]
|
||||||
|
soundcloud = ["dep:soundclouddy", "_any-provider"]
|
||||||
|
jamendo = ["dep:jamendody", "_any-provider"]
|
||||||
|
# Local files *and* persistent state (D5): the `/fs` mount, the content
|
||||||
|
# store behind `/crabidy` and `/orphans`, bookmarks/captures, queue
|
||||||
|
# persistence, and the `scan` command. Off means the server keeps its
|
||||||
|
# queue in memory only.
|
||||||
|
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest", "_any-provider"]
|
||||||
|
# Ogg-Opus decoding. Off also drops the bundled libopus C build
|
||||||
|
# (cmake + ninja) and `.opus` from what `scan` indexes (D6).
|
||||||
|
opus = ["audio-player/opus"]
|
||||||
|
# The server-side FFT that feeds clients' spectrum bars (D8).
|
||||||
|
spectrum = ["dep:realfft"]
|
||||||
|
# The embedded web client (architecture/web-client.md). Disable for a
|
||||||
|
# headless-only binary without the bundle.
|
||||||
web-ui = ["dep:tonic-web", "dep:include_dir"]
|
web-ui = ["dep:tonic-web", "dep:include_dir"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
@ -19,23 +57,33 @@ argon2.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
blake3 = { workspace = true, optional = true }
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
include_dir = { workspace = true, optional = true }
|
include_dir = { workspace = true, optional = true }
|
||||||
realfft.workspace = true
|
realfft = { workspace = true, optional = true }
|
||||||
tonic-web = { workspace = true, optional = true }
|
tonic-web = { workspace = true, optional = true }
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
audio-player.workspace = true
|
# default-features = false so the server's own `opus` feature decides
|
||||||
|
# whether the libopus decoder is linked (D1).
|
||||||
|
audio-player = { workspace = true, default-features = false }
|
||||||
|
# The `client` feature pulls in the gRPC executor used by the
|
||||||
|
# library/queue/global subcommands (architecture/cli.md D1/D3).
|
||||||
|
cbd-cli = { workspace = true, features = ["client"] }
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
fsdy.workspace = true
|
absdy = { workspace = true, optional = true }
|
||||||
|
fsdy = { workspace = true, optional = true }
|
||||||
|
fyyd = { workspace = true, optional = true }
|
||||||
|
jamendody = { workspace = true, optional = true }
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest = { workspace = true, optional = true }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
soundclouddy = { workspace = true, optional = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tidaldy.workspace = true
|
tidaldy = { workspace = true, optional = true }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
|
|
@ -43,7 +91,7 @@ tonic = { workspace = true, features = ["router", "transport", "codegen"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-appender.workspace = true
|
tracing-appender.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
ytdy.workspace = true
|
ytdy = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
|
|
@ -51,3 +99,9 @@ base64.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
|
|
||||||
|
# Default features only (clap-only): generating completions and a man page
|
||||||
|
# must not drag tonic into ordinary builds (architecture/cli.md D7).
|
||||||
|
[build-dependencies]
|
||||||
|
cbd-cli.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,32 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
generate_cli_assets();
|
||||||
|
stage_web_bundle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes shell completions and a man page for `crabidy-server` into `OUT_DIR`
|
||||||
|
/// every build, and additionally into `$CBD_ASSET_DIR` when set
|
||||||
|
/// (architecture/cli.md D7). `cbd-cli` is a default-features (clap-only)
|
||||||
|
/// build-dependency, so this never pulls tonic into ordinary builds.
|
||||||
|
fn generate_cli_assets() {
|
||||||
|
use clap::CommandFactory;
|
||||||
|
println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR");
|
||||||
|
let bin = "crabidy-server";
|
||||||
|
let command = cbd_cli::ServerCli::command();
|
||||||
|
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts");
|
||||||
|
if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) {
|
||||||
|
println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}");
|
||||||
|
}
|
||||||
|
if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") {
|
||||||
|
if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) {
|
||||||
|
println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stages the web client bundle for embedding (feature `web-ui`).
|
||||||
|
fn stage_web_bundle() {
|
||||||
// Rerun when the bundle changes (or appears).
|
// Rerun when the bundle changes (or appears).
|
||||||
println!("cargo:rerun-if-changed=../cbd-web/dist");
|
println!("cargo:rerun-if-changed=../cbd-web/dist");
|
||||||
if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() {
|
if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,13 @@ pub fn hash_password(password: &str) -> Result<String, String> {
|
||||||
pub struct Authenticator {
|
pub struct Authenticator {
|
||||||
/// `(role, PHC hash)` pairs from the config; empty = auth off.
|
/// `(role, PHC hash)` pairs from the config; empty = auth off.
|
||||||
hashes: Vec<(Role, String)>,
|
hashes: Vec<(Role, String)>,
|
||||||
|
/// The role granted to requests that carry no credentials: the most
|
||||||
|
/// privileged role left *unguarded* (no hash in the config), or
|
||||||
|
/// `None` when every role is guarded and anonymous access is denied.
|
||||||
|
/// Guarding runs from the top down (`AuthSettings::validate`), so
|
||||||
|
/// this is simply the first role, owner → queue-owner →
|
||||||
|
/// queue-appender, whose hash is absent.
|
||||||
|
unauthenticated: Option<Role>,
|
||||||
verified: RwLock<HashMap<String, Role>>,
|
verified: RwLock<HashMap<String, Role>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,8 +135,22 @@ impl Authenticator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Anonymous callers inherit the highest role that is *not*
|
||||||
|
// guarded. A hash that is present but unusable still counts as
|
||||||
|
// guarded here (fail-closed): that level is neither reachable by
|
||||||
|
// login nor handed to anonymous callers.
|
||||||
|
let unauthenticated = if settings.owner.is_none() {
|
||||||
|
Some(Role::Owner)
|
||||||
|
} else if settings.queue_owner.is_none() {
|
||||||
|
Some(Role::QueueOwner)
|
||||||
|
} else if settings.queue_appender.is_none() {
|
||||||
|
Some(Role::QueueAppender)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
hashes,
|
hashes,
|
||||||
|
unauthenticated,
|
||||||
verified: RwLock::new(HashMap::new()),
|
verified: RwLock::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,18 +160,28 @@ impl Authenticator {
|
||||||
!self.hashes.is_empty()
|
!self.hashes.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The role an anonymous (headerless) request receives — the most
|
||||||
|
/// privileged unguarded role, or `None` when every role is guarded.
|
||||||
|
pub fn unauthenticated_role(&self) -> Option<Role> {
|
||||||
|
self.unauthenticated
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves the request's `authorization` header value to a role.
|
/// Resolves the request's `authorization` header value to a role.
|
||||||
///
|
///
|
||||||
/// With auth disabled everyone is [`Role::Owner`]. Every failure —
|
/// A request with **no** `authorization` header is anonymous and
|
||||||
/// missing header, wrong scheme, broken base64, unknown user,
|
/// receives [`Authenticator::unauthenticated_role`] — the most
|
||||||
/// wrong password — answers the same `UNAUTHENTICATED` so callers
|
/// privileged unguarded role — or is denied when every role is
|
||||||
/// cannot probe which part was wrong. Never panics on input.
|
/// guarded. A request that **does** carry a header is attempting to
|
||||||
|
/// authenticate; every way that can fail — wrong scheme, broken
|
||||||
|
/// base64, unknown user, wrong password — answers the same
|
||||||
|
/// `UNAUTHENTICATED` so callers cannot probe which part was wrong.
|
||||||
|
/// It never silently falls back to the anonymous role. Never panics
|
||||||
|
/// on input.
|
||||||
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
|
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
|
||||||
if !self.enabled() {
|
|
||||||
return Ok(Role::Owner);
|
|
||||||
}
|
|
||||||
let denied = || Status::unauthenticated("credentials required");
|
let denied = || Status::unauthenticated("credentials required");
|
||||||
let header = header.ok_or_else(denied)?;
|
let Some(header) = header else {
|
||||||
|
return self.unauthenticated.ok_or_else(denied);
|
||||||
|
};
|
||||||
if let Some(role) = self
|
if let Some(role) = self
|
||||||
.verified
|
.verified
|
||||||
.read()
|
.read()
|
||||||
|
|
@ -371,9 +402,56 @@ mod tests {
|
||||||
fn without_configured_hashes_everyone_is_owner() {
|
fn without_configured_hashes_everyone_is_owner() {
|
||||||
let auth = Authenticator::new(&AuthSettings::default());
|
let auth = Authenticator::new(&AuthSettings::default());
|
||||||
assert!(!auth.enabled());
|
assert!(!auth.enabled());
|
||||||
|
assert_eq!(auth.unauthenticated_role(), Some(Role::Owner));
|
||||||
assert_eq!(auth.authenticate(None).expect("open"), Role::Owner);
|
assert_eq!(auth.authenticate(None).expect("open"), Role::Owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_unauthenticated_role_is_the_highest_unguarded_one() {
|
||||||
|
let owner = hash("o");
|
||||||
|
let qo = hash("qo");
|
||||||
|
let qa = hash("qa");
|
||||||
|
// Nothing guarded → owner. Owner guarded → queue-owner. Owner +
|
||||||
|
// queue-owner guarded → queue-appender. All guarded → denied.
|
||||||
|
let cases = [
|
||||||
|
(None, None, None, Some(Role::Owner)),
|
||||||
|
(Some(&owner), None, None, Some(Role::QueueOwner)),
|
||||||
|
(Some(&owner), Some(&qo), None, Some(Role::QueueAppender)),
|
||||||
|
(Some(&owner), Some(&qo), Some(&qa), None),
|
||||||
|
];
|
||||||
|
for (owner, queue_owner, queue_appender, expected) in cases {
|
||||||
|
let auth = Authenticator::new(&AuthSettings {
|
||||||
|
owner: owner.cloned(),
|
||||||
|
queue_owner: queue_owner.cloned(),
|
||||||
|
queue_appender: queue_appender.cloned(),
|
||||||
|
});
|
||||||
|
assert_eq!(auth.unauthenticated_role(), expected);
|
||||||
|
match expected {
|
||||||
|
Some(role) => assert_eq!(auth.authenticate(None).expect("anon"), role),
|
||||||
|
None => {
|
||||||
|
let err = auth.authenticate(None).expect_err("locked");
|
||||||
|
assert_eq!(err.code(), tonic::Code::Unauthenticated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_carrying_bad_credentials_never_falls_back_to_anonymous() {
|
||||||
|
// owner guarded, so anonymous callers are queue-owner — but a
|
||||||
|
// present-yet-wrong owner login is denied, not downgraded.
|
||||||
|
let auth = Authenticator::new(&AuthSettings {
|
||||||
|
owner: Some(hash("os")),
|
||||||
|
queue_owner: None,
|
||||||
|
queue_appender: None,
|
||||||
|
});
|
||||||
|
assert_eq!(auth.unauthenticated_role(), Some(Role::QueueOwner));
|
||||||
|
let err = auth
|
||||||
|
.authenticate(Some(&basic("owner", "wrong")))
|
||||||
|
.expect_err("wrong password denied");
|
||||||
|
assert_eq!(err.code(), tonic::Code::Unauthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn valid_credentials_resolve_their_role() {
|
fn valid_credentials_resolve_their_role() {
|
||||||
let auth = authenticator();
|
let auth = authenticator();
|
||||||
|
|
@ -392,8 +470,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn every_failure_is_the_same_unauthenticated() {
|
fn every_failure_is_the_same_unauthenticated() {
|
||||||
let auth = authenticator();
|
let auth = authenticator();
|
||||||
|
// A missing header is *not* a failure here — it yields the
|
||||||
|
// anonymous role. Only present-but-bad headers are failures, and
|
||||||
|
// they must be indistinguishable.
|
||||||
let cases: Vec<Option<String>> = vec![
|
let cases: Vec<Option<String>> = vec![
|
||||||
None, // no header
|
|
||||||
Some("Bearer token".to_string()), // wrong scheme
|
Some("Bearer token".to_string()), // wrong scheme
|
||||||
Some("Basic !!!not-base64!!!".to_string()), // broken base64
|
Some("Basic !!!not-base64!!!".to_string()), // broken base64
|
||||||
Some("Basic bm9jb2xvbg==".to_string()), // no colon
|
Some("Basic bm9jb2xvbg==".to_string()), // no colon
|
||||||
|
|
@ -487,17 +567,26 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_layer_forwards_authorized_requests_only() {
|
fn the_layer_forwards_authorized_requests_only() {
|
||||||
|
// owner + queue-owner guarded, appender open → anonymous callers
|
||||||
|
// are queue-appenders.
|
||||||
let auth = Arc::new(authenticator());
|
let auth = Arc::new(authenticator());
|
||||||
let append = "http://s/crabidy.v1.CrabidyService/Append";
|
let append = "http://s/crabidy.v1.CrabidyService/Append";
|
||||||
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
|
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
|
||||||
|
|
||||||
// No credentials: unauthenticated, handler never runs.
|
// No credentials: the anonymous role (queue-appender) is enough
|
||||||
|
// for Append, so the handler runs.
|
||||||
let (response, reached) = call_layer(auth.clone(), append, None);
|
let (response, reached) = call_layer(auth.clone(), append, None);
|
||||||
assert!(!reached);
|
assert!(reached);
|
||||||
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
|
assert_eq!(response.body(), "handled");
|
||||||
|
|
||||||
// Sufficient role: forwarded.
|
// No credentials against an owner-only method: the anonymous role
|
||||||
let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os")));
|
// is insufficient, denied before the handler runs.
|
||||||
|
let (response, reached) = call_layer(auth.clone(), capture, None);
|
||||||
|
assert!(!reached);
|
||||||
|
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
|
||||||
|
|
||||||
|
// Sufficient role via credentials: forwarded.
|
||||||
|
let (response, reached) = call_layer(auth.clone(), capture, Some(&basic("owner", "os")));
|
||||||
assert!(reached);
|
assert!(reached);
|
||||||
assert_eq!(response.body(), "handled");
|
assert_eq!(response.body(), "handled");
|
||||||
|
|
||||||
|
|
@ -509,6 +598,17 @@ mod tests {
|
||||||
assert!(!reached);
|
assert!(!reached);
|
||||||
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
|
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
|
||||||
|
|
||||||
|
// Fully locked (every role guarded): an anonymous request is
|
||||||
|
// denied with UNAUTHENTICATED before any handler runs.
|
||||||
|
let locked = Arc::new(Authenticator::new(&AuthSettings {
|
||||||
|
owner: Some(hash("os")),
|
||||||
|
queue_owner: Some(hash("qos")),
|
||||||
|
queue_appender: Some(hash("aps")),
|
||||||
|
}));
|
||||||
|
let (response, reached) = call_layer(locked, append, None);
|
||||||
|
assert!(!reached);
|
||||||
|
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
|
||||||
|
|
||||||
// Auth disabled: everything forwards without a header.
|
// Auth disabled: everything forwards without a header.
|
||||||
let open = Arc::new(Authenticator::new(&AuthSettings::default()));
|
let open = Arc::new(Authenticator::new(&AuthSettings::default()));
|
||||||
let (_, reached) = call_layer(open, capture, None);
|
let (_, reached) = call_layer(open, capture, None);
|
||||||
|
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
//! Captured library subtrees ("bookmarks") on disk
|
|
||||||
//! (see `architecture/bookmarks.md`).
|
|
||||||
//!
|
|
||||||
//! Every bookmark is a folder under `<config>/crabidy/bookmarks/` that
|
|
||||||
//! mirrors the captured subtree: one order-prefixed folder per child node,
|
|
||||||
//! one order-prefixed `*.cbd-track.toml` **link** file per track. The same
|
|
||||||
//! directory is mounted read-only into the library as `/bookmarks` by an
|
|
||||||
//! `fsdy` instance (with editable top-level folders) — this module is the
|
|
||||||
//! only writer.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crabidy_core::ProviderClient;
|
|
||||||
|
|
||||||
/// The library mount point of the bookmarks directory.
|
|
||||||
pub const BOOKMARKS_PROVIDER_ROOT: &str = "/bookmarks";
|
|
||||||
|
|
||||||
/// The walk aborts beyond this many directories — a runaway provider tree
|
|
||||||
/// must not fill the disk.
|
|
||||||
pub const MAX_CAPTURE_DIRS: usize = crate::capture::BOOKMARK_CAPS.max_dirs;
|
|
||||||
|
|
||||||
/// The walk aborts beyond this many track files.
|
|
||||||
pub const MAX_CAPTURE_TRACKS: usize = crate::capture::BOOKMARK_CAPS.max_tracks;
|
|
||||||
|
|
||||||
/// The bookmarks directory: `bookmarks/` inside the crabidy config
|
|
||||||
/// directory. `None` when the platform has no config directory.
|
|
||||||
pub fn bookmarks_dir() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|d| d.join("crabidy").join("bookmarks"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use crate::capture::CaptureError;
|
|
||||||
|
|
||||||
/// Writes captured subtrees. All I/O is `tokio::fs`; the whole bookmark is
|
|
||||||
/// built as a hidden temp sibling and swapped into place, so a crash never
|
|
||||||
/// leaves a half-written bookmark next to intact ones.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct BookmarkStore {
|
|
||||||
dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BookmarkStore {
|
|
||||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
|
||||||
/// missing.
|
|
||||||
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
|
||||||
tokio::fs::create_dir_all(&dir).await?;
|
|
||||||
Ok(Self { dir })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The store directory (what the `/bookmarks` provider instance
|
|
||||||
/// mounts).
|
|
||||||
pub fn dir(&self) -> &Path {
|
|
||||||
&self.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates a bookmark request without writing anything: the name
|
|
||||||
/// must be a legal folder name. The accept-then-stream RPC replies
|
|
||||||
/// after this and runs [`Self::capture`] detached
|
|
||||||
/// (architecture/incremental-captures.md D4).
|
|
||||||
pub fn validate(&self, name: &str) -> Result<(), CaptureError> {
|
|
||||||
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Captures the subtree at `source_path` as the bookmark `name`,
|
|
||||||
/// overwriting an existing bookmark of that name. Reports through
|
|
||||||
/// `progress` (non-terminal events only; the caller sends the
|
|
||||||
/// terminal one).
|
|
||||||
///
|
|
||||||
/// Walks `client` (the orchestrator, so any provider is reachable)
|
|
||||||
/// iteratively in pre-order: every child node becomes an
|
|
||||||
/// order-prefixed folder, every track an order-prefixed link file
|
|
||||||
/// ([`fsdy::TrackFile::from_track`]; a skipped source track writes a
|
|
||||||
/// skipped toml). A `source_path` that is itself a track captures as
|
|
||||||
/// a folder with one file. Aborts with [`CaptureError::TooLarge`]
|
|
||||||
/// beyond [`MAX_CAPTURE_DIRS`] / [`MAX_CAPTURE_TRACKS`]; an
|
|
||||||
/// unreadable source is [`CaptureError::BadSource`]. Never panics on
|
|
||||||
/// provider contents.
|
|
||||||
pub async fn capture<C>(
|
|
||||||
&self,
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
name: &str,
|
|
||||||
progress: &crate::capture::Progress,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
self.capture_with_caps(
|
|
||||||
client,
|
|
||||||
source_path,
|
|
||||||
name,
|
|
||||||
MAX_CAPTURE_DIRS,
|
|
||||||
MAX_CAPTURE_TRACKS,
|
|
||||||
progress,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
|
||||||
async fn capture_with_caps<C>(
|
|
||||||
&self,
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
name: &str,
|
|
||||||
max_dirs: usize,
|
|
||||||
max_tracks: usize,
|
|
||||||
progress: &crate::capture::Progress,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
let caps = crate::capture::Caps {
|
|
||||||
max_dirs,
|
|
||||||
max_tracks,
|
|
||||||
..crate::capture::BOOKMARK_CAPS
|
|
||||||
};
|
|
||||||
crate::capture::capture_into(
|
|
||||||
&self.dir,
|
|
||||||
client,
|
|
||||||
source_path,
|
|
||||||
name,
|
|
||||||
caps,
|
|
||||||
&crate::capture::Sink::Link,
|
|
||||||
progress,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
/// A silent progress reporter; bookmark tests assert on disk state.
|
|
||||||
fn silent() -> crate::capture::Progress {
|
|
||||||
crate::capture::Progress::silent("test", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A real fsdy instance as the capture source: an artist with two
|
|
||||||
/// albums holding url tracks, plus one link track pointing at Tidal.
|
|
||||||
async fn source() -> (fsdy::Client, TempDir) {
|
|
||||||
let dir = TempDir::new().expect("source tempdir");
|
|
||||||
let al1 = dir.path().join("artist/Album One");
|
|
||||||
let al2 = dir.path().join("artist/Album Two");
|
|
||||||
fs::create_dir_all(&al1).expect("mkdir");
|
|
||||||
fs::create_dir_all(&al2).expect("mkdir");
|
|
||||||
let url =
|
|
||||||
|t: &str| format!("title = {t:?}\n[playable]\nurl = \"https://example.org/s.mp3\"\n");
|
|
||||||
fs::write(al1.join("01 one.cbd-track.toml"), url("one")).expect("write");
|
|
||||||
fs::write(al1.join("02 two.cbd-track.toml"), url("two")).expect("write");
|
|
||||||
fs::write(
|
|
||||||
al2.join("01 linked.cbd-track.toml"),
|
|
||||||
"title = \"linked\"\n[playable]\nlink = \"/tidal/artists/1/2\"\n",
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
let client = fsdy::Client::new("/fs", dir.path().to_path_buf()).expect("source instance");
|
|
||||||
(client, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn store() -> (BookmarkStore, TempDir) {
|
|
||||||
let dir = TempDir::new().expect("store tempdir");
|
|
||||||
let store = BookmarkStore::open(dir.path().join("bookmarks"))
|
|
||||||
.await
|
|
||||||
.expect("open creates the directory");
|
|
||||||
(store, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visible(dir: &Path) -> Vec<String> {
|
|
||||||
let mut names: Vec<String> = fs::read_dir(dir)
|
|
||||||
.expect("dir")
|
|
||||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
|
||||||
.filter(|n| !n.starts_with('.'))
|
|
||||||
.collect();
|
|
||||||
names.sort_by_key(|n| n.to_lowercase());
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_mirrors_a_subtree_with_order_prefixes() {
|
|
||||||
let (client, _src) = source().await;
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&client, "/fs/artist", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture");
|
|
||||||
|
|
||||||
let root = store.dir().join("faves");
|
|
||||||
assert_eq!(visible(&root), vec!["0001 Album One", "0002 Album Two"]);
|
|
||||||
let album1 = visible(&root.join("0001 Album One"));
|
|
||||||
assert_eq!(
|
|
||||||
album1,
|
|
||||||
vec![
|
|
||||||
"0001 one.cbd-track.toml".to_string(),
|
|
||||||
"0002 two.cbd-track.toml".into()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
// Entries are link files; the url track links back to its /fs path,
|
|
||||||
// the link track re-links to its original target (no chains).
|
|
||||||
let one = fs::read_to_string(root.join("0001 Album One/0001 one.cbd-track.toml"))
|
|
||||||
.expect("read entry");
|
|
||||||
let one = fsdy::TrackFile::parse(&one).expect("entry parses");
|
|
||||||
assert_eq!(
|
|
||||||
one.to_track("/bookmarks/irrelevant").path,
|
|
||||||
"/fs/artist/Album%20One/01%20one.cbd-track.toml"
|
|
||||||
);
|
|
||||||
let linked = fs::read_to_string(root.join("0002 Album Two/0001 linked.cbd-track.toml"))
|
|
||||||
.expect("read entry");
|
|
||||||
let linked = fsdy::TrackFile::parse(&linked).expect("entry parses");
|
|
||||||
assert_eq!(
|
|
||||||
linked.to_track("/bookmarks/irrelevant").path,
|
|
||||||
"/tidal/artists/1/2"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn captured_trees_replay_through_a_bookmarks_instance() {
|
|
||||||
let (client, _src) = source().await;
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&client, "/fs/artist", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture");
|
|
||||||
|
|
||||||
let bookmarks = fsdy::Client::new(BOOKMARKS_PROVIDER_ROOT, store.dir().to_path_buf())
|
|
||||||
.expect("bookmarks instance");
|
|
||||||
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
|
||||||
bookmarks
|
|
||||||
.resolve_tracks_into("/bookmarks/faves", chunk_tx)
|
|
||||||
.await
|
|
||||||
.expect("resolve");
|
|
||||||
let titles: Vec<String> = chunk_rx.into_iter().flatten().map(|t| t.title).collect();
|
|
||||||
// Pre-order over the mirrored structure == source listing order.
|
|
||||||
assert_eq!(
|
|
||||||
titles,
|
|
||||||
vec!["one".to_string(), "two".into(), "linked".into()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capturing_a_single_track_writes_one_file() {
|
|
||||||
let (client, _src) = source().await;
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(
|
|
||||||
&client,
|
|
||||||
"/fs/artist/Album%20One/01%20one.cbd-track.toml",
|
|
||||||
"just one",
|
|
||||||
&silent(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("capture track");
|
|
||||||
let entries = visible(&store.dir().join("just one"));
|
|
||||||
assert_eq!(entries, vec!["0001 one.cbd-track.toml"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_validates_names_and_overwrites() {
|
|
||||||
let (client, _src) = source().await;
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
for bad in ["", " ", "a/b", ".hidden"] {
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
store.capture(&client, "/fs/artist", bad, &silent()).await,
|
|
||||||
Err(CaptureError::InvalidName(_))
|
|
||||||
),
|
|
||||||
"name {bad:?} must be rejected"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
store
|
|
||||||
.capture(&client, "/fs/artist", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("first capture");
|
|
||||||
store
|
|
||||||
.capture(&client, "/fs/artist/Album%20Two", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("overwrite");
|
|
||||||
// The overwrite fully replaces the older, larger capture.
|
|
||||||
assert_eq!(
|
|
||||||
visible(&store.dir().join("faves")),
|
|
||||||
vec!["0001 linked.cbd-track.toml"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_rejects_an_unreadable_source() {
|
|
||||||
let (client, _src) = source().await;
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
assert!(matches!(
|
|
||||||
store.capture(&client, "/fs/nope", "x", &silent()).await,
|
|
||||||
Err(CaptureError::BadSource(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_aborts_over_the_caps_and_cleans_up() {
|
|
||||||
let (client, _src) = source().await;
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
// The tree has 3 directories (artist + 2 albums); a 2-dir cap trips.
|
|
||||||
let err = store
|
|
||||||
.capture_with_caps(
|
|
||||||
&client,
|
|
||||||
"/fs/artist",
|
|
||||||
"big",
|
|
||||||
2,
|
|
||||||
MAX_CAPTURE_TRACKS,
|
|
||||||
&silent(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("over the dir cap");
|
|
||||||
assert!(matches!(err, CaptureError::TooLarge(_)));
|
|
||||||
// ... same for the track cap.
|
|
||||||
let err = store
|
|
||||||
.capture_with_caps(&client, "/fs/artist", "big", MAX_CAPTURE_DIRS, 1, &silent())
|
|
||||||
.await
|
|
||||||
.expect_err("over the track cap");
|
|
||||||
assert!(matches!(err, CaptureError::TooLarge(_)));
|
|
||||||
// Nothing half-written survives, not even hidden temp folders.
|
|
||||||
let leftovers = fs::read_dir(store.dir()).expect("store dir").count();
|
|
||||||
assert_eq!(leftovers, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,19 @@
|
||||||
//! The shared subtree-capture walk
|
//! Capture primitives shared by the `/crabidy` store
|
||||||
//! (see `architecture/captures.md` D2 and
|
//! (see `architecture/crabidy-store.md` D4).
|
||||||
//! `architecture/incremental-captures.md` D2).
|
|
||||||
//!
|
//!
|
||||||
//! Both bookmark captures (`w`, link files) and download captures (`W`,
|
//! [`enumerate`] walks a library subtree into a flat list of tracks (each
|
||||||
//! audio files next to their tomls) mirror a library subtree into a folder.
|
//! with its target directory and listing index), enforcing the size
|
||||||
//! The walk runs in two phases: **enumerate** first (every directory and
|
//! [`Caps`]; [`Downloader::download_to`] streams one stream URL into a file
|
||||||
//! track, enforcing the size caps — this makes the total known before the
|
//! with the player's bounded windowing; [`Progress`] reports a capture's
|
||||||
//! first download), then **fetch** track by track, reporting [`Progress`]
|
//! settling counters to clients. The `CrabidyStore` (`crabidy_store.rs`)
|
||||||
//! after each one.
|
//! drives these into the content store, de-duplicating per track and
|
||||||
//!
|
//! swapping the finished save into place.
|
||||||
//! The per-track [`Sink`] decides the write mode: bookmarks build the whole
|
|
||||||
//! capture in a hidden tmp sibling and swap it into place (all-or-nothing,
|
|
||||||
//! overwrite = refresh), download captures write **incrementally** into the
|
|
||||||
//! final folder — entries that are already satisfied are reused, tracks
|
|
||||||
//! whose source cannot be captured are recorded as *skipped* tomls, and a
|
|
||||||
//! real download failure aborts the run but keeps everything written so
|
|
||||||
//! far, so re-running the same name resumes where it stopped.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
use crabidy_core::proto::crabidy::{CaptureProgress, Track};
|
||||||
use crabidy_core::ProviderClient;
|
use crabidy_core::ProviderClient;
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
/// Connect timeout for download requests.
|
/// Connect timeout for download requests.
|
||||||
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
@ -50,8 +41,8 @@ pub struct Caps {
|
||||||
pub max_dirs: usize,
|
pub max_dirs: usize,
|
||||||
/// Maximum track files.
|
/// Maximum track files.
|
||||||
pub max_tracks: usize,
|
pub max_tracks: usize,
|
||||||
/// Maximum total bytes downloaded in one run (ignored by
|
/// Maximum total bytes downloaded in one run (ignored by link saves).
|
||||||
/// [`Sink::Link`]). Reused entries of a resumed capture do not count.
|
/// De-duplicated tracks (provider-id or hash hits) do not count.
|
||||||
pub max_bytes: u64,
|
pub max_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +75,8 @@ pub enum CaptureError {
|
||||||
Disabled,
|
Disabled,
|
||||||
#[error("the source path cannot be captured: {0}")]
|
#[error("the source path cannot be captured: {0}")]
|
||||||
BadSource(String),
|
BadSource(String),
|
||||||
|
#[error("a save named \"{0}\" already exists")]
|
||||||
|
Conflict(String),
|
||||||
#[error("the source does not allow downloads")]
|
#[error("the source does not allow downloads")]
|
||||||
Unsupported,
|
Unsupported,
|
||||||
#[error("the subtree is too large to capture ({0})")]
|
#[error("the subtree is too large to capture ({0})")]
|
||||||
|
|
@ -94,6 +87,8 @@ pub enum CaptureError {
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
TrackFile(#[from] fsdy::TrackFileError),
|
TrackFile(#[from] fsdy::TrackFileError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Store(#[from] crate::crabidy_store::StoreError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress reporting for one capture run
|
/// Progress reporting for one capture run
|
||||||
|
|
@ -155,21 +150,21 @@ impl Progress {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_total(&self, total: usize) {
|
pub fn set_total(&self, total: usize) {
|
||||||
self.total
|
self.total
|
||||||
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
|
.store(total.min(u32::MAX as usize) as u32, Ordering::Relaxed);
|
||||||
self.publish();
|
self.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One track settled with playable data (reused, downloaded, linked).
|
/// One track settled with playable data (reused, downloaded, linked).
|
||||||
fn track_done(&self) {
|
pub fn track_done(&self) {
|
||||||
self.done.fetch_add(1, Ordering::Relaxed);
|
self.done.fetch_add(1, Ordering::Relaxed);
|
||||||
self.publish();
|
self.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One track settled as skipped (counts toward done — the ratio must
|
/// One track settled as skipped (counts toward done — the ratio must
|
||||||
/// reach total on success).
|
/// reach total on success).
|
||||||
fn track_skipped(&self) {
|
pub fn track_skipped(&self) {
|
||||||
self.skipped.fetch_add(1, Ordering::Relaxed);
|
self.skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
self.done.fetch_add(1, Ordering::Relaxed);
|
self.done.fetch_add(1, Ordering::Relaxed);
|
||||||
self.publish();
|
self.publish();
|
||||||
|
|
@ -187,32 +182,12 @@ impl Progress {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What happens to each track the walk visits.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Sink {
|
|
||||||
/// Write an order-prefixed link file ([`fsdy::TrackFile::from_track`])
|
|
||||||
/// — the bookmark behavior: tmp-and-swap, all-or-nothing.
|
|
||||||
Link,
|
|
||||||
/// Download the track's audio next to an order-prefixed toml that
|
|
||||||
/// points at it — the captures behavior: incremental into the final
|
|
||||||
/// folder, resumable by name.
|
|
||||||
Download(Downloader),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How one visited track settled during the fetch phase.
|
|
||||||
enum TrackOutcome {
|
|
||||||
/// Playable data is in place (downloaded now, or already there).
|
|
||||||
Captured,
|
|
||||||
/// The source cannot be captured; a skipped toml records the gap.
|
|
||||||
Skipped,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One track discovered by the enumeration phase: what to fetch, where to
|
/// One track discovered by the enumeration phase: what to fetch, where to
|
||||||
/// put it, and its listing position (the order prefix).
|
/// put it, and its listing position (the order prefix).
|
||||||
struct TrackEntry {
|
pub(crate) struct TrackEntry {
|
||||||
track: Track,
|
pub(crate) track: Track,
|
||||||
dir: PathBuf,
|
pub(crate) dir: PathBuf,
|
||||||
index: usize,
|
pub(crate) index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads one track's audio via the provider's stream URL.
|
/// Downloads one track's audio via the provider's stream URL.
|
||||||
|
|
@ -244,89 +219,45 @@ impl Downloader {
|
||||||
Ok(Self { http, window })
|
Ok(Self { http, window })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settles one track: audio file first, then the toml pointing at
|
/// Streams the stream URL `url` for `track_path` into the file `dest`,
|
||||||
/// it — a toml never exists without its audio. The whole operation is
|
/// returning the audio extension chosen from the response
|
||||||
/// bounded by [`DOWNLOAD_TRACK_TIMEOUT`].
|
/// `Content-Type`/URL. Bounded end to end by [`DOWNLOAD_TRACK_TIMEOUT`].
|
||||||
///
|
///
|
||||||
/// Error messages carry the track's library path, never the stream
|
/// Error messages carry `track_path`, never the URL (it may embed a
|
||||||
/// URL (it may embed a token) — reqwest errors are stripped with
|
/// token) — reqwest errors are stripped with
|
||||||
/// [`reqwest::Error::without_url`].
|
/// [`reqwest::Error::without_url`]. Writes no toml: the content store
|
||||||
async fn download_track<C>(
|
/// hashes `dest` and decides where it lands
|
||||||
|
/// (architecture/crabidy-store.md D4).
|
||||||
|
pub async fn download_to(
|
||||||
&self,
|
&self,
|
||||||
client: &C,
|
track_path: &str,
|
||||||
track: &Track,
|
url: &str,
|
||||||
dir: &Path,
|
dest: &Path,
|
||||||
index: usize,
|
|
||||||
bytes_left: &mut u64,
|
bytes_left: &mut u64,
|
||||||
) -> Result<TrackOutcome, CaptureError>
|
) -> Result<String, CaptureError> {
|
||||||
where
|
match tokio::time::timeout(
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
let fetched = tokio::time::timeout(
|
|
||||||
DOWNLOAD_TRACK_TIMEOUT,
|
DOWNLOAD_TRACK_TIMEOUT,
|
||||||
self.fetch_track(client, track, dir, index, bytes_left),
|
self.download_windowed(track_path, url, dest, bytes_left),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
match fetched {
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(_) => Err(CaptureError::Download(format!(
|
Err(_) => Err(CaptureError::Download(format!(
|
||||||
"{}: timed out after {}s",
|
"{track_path}: timed out after {}s",
|
||||||
track.path,
|
|
||||||
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
DOWNLOAD_TRACK_TIMEOUT.as_secs()
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unbounded body of [`Self::download_track`]: resolve the stream
|
/// The unbounded body of [`Self::download_to`]: the windowed range
|
||||||
/// URL, stream the response to disk against the byte budget, then
|
/// download, streaming the response to `dest` against the byte budget.
|
||||||
/// write the toml.
|
async fn download_windowed(
|
||||||
///
|
|
||||||
/// A track whose source cannot be captured — it is itself skipped, its
|
|
||||||
/// stream fails to resolve, or it resolves to something other than an
|
|
||||||
/// http(s) URL (e.g. a local file playable) — is recorded as a
|
|
||||||
/// **skipped toml** instead of aborting the capture
|
|
||||||
/// (architecture/incremental-captures.md D2): queue and bookmark
|
|
||||||
/// captures mix providers, and one local track must not kill the rest.
|
|
||||||
/// Actual download failures stay fatal for the run.
|
|
||||||
async fn fetch_track<C>(
|
|
||||||
&self,
|
&self,
|
||||||
client: &C,
|
track_path: &str,
|
||||||
track: &Track,
|
url: &str,
|
||||||
dir: &Path,
|
dest: &Path,
|
||||||
index: usize,
|
|
||||||
bytes_left: &mut u64,
|
bytes_left: &mut u64,
|
||||||
) -> Result<TrackOutcome, CaptureError>
|
) -> Result<String, CaptureError> {
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
let track_path = track.path.as_str();
|
|
||||||
if track.is_skipped {
|
|
||||||
warn!(path = track_path, "recording an already-skipped track");
|
|
||||||
return write_skipped(track, dir, index).await;
|
|
||||||
}
|
|
||||||
let urls = match client.get_urls_for_track(track_path).await {
|
|
||||||
Ok(urls) => urls,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
path = track_path,
|
|
||||||
"recording uncapturable track as skipped: {err}"
|
|
||||||
);
|
|
||||||
return write_skipped(track, dir, index).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(url) = urls.first() else {
|
|
||||||
warn!(
|
|
||||||
path = track_path,
|
|
||||||
"recording track without a stream url as skipped"
|
|
||||||
);
|
|
||||||
return write_skipped(track, dir, index).await;
|
|
||||||
};
|
|
||||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
|
||||||
// A local file playable (an fs track, or a re-captured
|
|
||||||
// capture): the audio is already on disk, so copy it into the
|
|
||||||
// capture instead of skipping it.
|
|
||||||
return self.copy_local(track, url, dir, index, bytes_left).await;
|
|
||||||
}
|
|
||||||
let download_err = |err: reqwest::Error| {
|
let download_err = |err: reqwest::Error| {
|
||||||
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
||||||
};
|
};
|
||||||
|
|
@ -360,8 +291,7 @@ impl Downloader {
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
let ext = extension_for(content_type.as_deref(), url);
|
let ext = extension_for(content_type.as_deref(), url);
|
||||||
let audio_name = audio_file_name(index, &track.title, &ext);
|
let mut audio = tokio::fs::File::create(dest).await?;
|
||||||
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
|
|
||||||
loop {
|
loop {
|
||||||
// The window's extent and the resource total, from
|
// The window's extent and the resource total, from
|
||||||
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
||||||
|
|
@ -423,56 +353,7 @@ impl Downloader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
||||||
drop(audio);
|
Ok(ext)
|
||||||
|
|
||||||
let text =
|
|
||||||
fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name)).to_toml()?;
|
|
||||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
|
||||||
Ok(TrackOutcome::Captured)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copies an already-local audio file (an fs playable, or a track
|
|
||||||
/// from an existing capture) into the capture folder next to its
|
|
||||||
/// toml. A source that is missing or not a regular file is recorded
|
|
||||||
/// as skipped rather than aborting the run; the copy counts against
|
|
||||||
/// the same byte budget as a download so a capture cannot run away.
|
|
||||||
async fn copy_local(
|
|
||||||
&self,
|
|
||||||
track: &Track,
|
|
||||||
source: &str,
|
|
||||||
dir: &Path,
|
|
||||||
index: usize,
|
|
||||||
bytes_left: &mut u64,
|
|
||||||
) -> Result<TrackOutcome, CaptureError> {
|
|
||||||
let track_path = track.path.as_str();
|
|
||||||
let source_path = Path::new(source);
|
|
||||||
match tokio::fs::metadata(source_path).await {
|
|
||||||
Ok(meta) if meta.is_file() => {
|
|
||||||
let len = meta.len();
|
|
||||||
if len > *bytes_left {
|
|
||||||
return Err(CaptureError::TooLarge("download budget exhausted"));
|
|
||||||
}
|
|
||||||
let ext = source_path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("bin");
|
|
||||||
let audio_name = audio_file_name(index, &track.title, ext);
|
|
||||||
tokio::fs::copy(source_path, dir.join(&audio_name)).await?;
|
|
||||||
*bytes_left -= len;
|
|
||||||
let text = fsdy::TrackFile::from_track_with_file(track, Path::new(&audio_name))
|
|
||||||
.to_toml()?;
|
|
||||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text)
|
|
||||||
.await?;
|
|
||||||
Ok(TrackOutcome::Captured)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
path = track_path,
|
|
||||||
"local playable is not a readable file; recording as skipped"
|
|
||||||
);
|
|
||||||
write_skipped(track, dir, index).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,127 +369,12 @@ fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
|
||||||
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the skipped toml for `track` at listing position `index`,
|
/// Mirrors the directory structure under `root` and collects every track
|
||||||
/// overwriting whatever was there.
|
/// with its target directory and listing index. Iterative pre-order over
|
||||||
async fn write_skipped(
|
/// [`ProviderClient::get_lib_node`] (a deep tree must not overflow the
|
||||||
track: &Track,
|
/// stack). A `source_path` that is a track enumerates as a single entry.
|
||||||
dir: &Path,
|
/// Enforces `max_dirs`/`max_tracks`.
|
||||||
index: usize,
|
pub(crate) async fn enumerate<C>(
|
||||||
) -> Result<TrackOutcome, CaptureError> {
|
|
||||||
let text = fsdy::TrackFile::from_track_skipped(track).to_toml()?;
|
|
||||||
tokio::fs::write(dir.join(fsdy::track_file_name(index, &track.title)), text).await?;
|
|
||||||
Ok(TrackOutcome::Skipped)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Captures the subtree at `source_path` as `dir/<name>/`.
|
|
||||||
///
|
|
||||||
/// Validates `name` ([`fsdy::validate_folder_name`], nothing reserved),
|
|
||||||
/// then enumerates the subtree (caps enforced, total reported) and fetches
|
|
||||||
/// track by track. [`Sink::Link`] builds the whole capture in a hidden
|
|
||||||
/// `.tmp-<name>` sibling and swaps it into place, removing the temp folder
|
|
||||||
/// on any failure — the bookmark all-or-nothing. [`Sink::Download`] writes
|
|
||||||
/// incrementally into `dir/<name>` itself: satisfied entries are reused,
|
|
||||||
/// uncapturable tracks become skipped tomls, and failures keep everything
|
|
||||||
/// already written (re-run the same name to resume).
|
|
||||||
pub async fn capture_into<C>(
|
|
||||||
dir: &Path,
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
name: &str,
|
|
||||||
caps: Caps,
|
|
||||||
sink: &Sink,
|
|
||||||
progress: &Progress,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
let name = fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
|
||||||
match sink {
|
|
||||||
Sink::Link => {
|
|
||||||
let tmp = dir.join(format!(".tmp-{name}"));
|
|
||||||
let written = write_links(client, source_path, &tmp, caps, progress).await;
|
|
||||||
if let Err(err) = written {
|
|
||||||
// Every failure path removes the temp folder: nothing
|
|
||||||
// half-written survives, not even hidden.
|
|
||||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
let target = dir.join(name);
|
|
||||||
if tokio::fs::try_exists(&target).await? {
|
|
||||||
tokio::fs::remove_dir_all(&target).await?;
|
|
||||||
}
|
|
||||||
tokio::fs::rename(&tmp, &target).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Sink::Download(downloader) => {
|
|
||||||
let target = dir.join(name);
|
|
||||||
tokio::fs::create_dir_all(&target).await?;
|
|
||||||
let entries = enumerate(client, source_path, &target, caps).await?;
|
|
||||||
progress.set_total(entries.len());
|
|
||||||
let mut bytes_left = caps.max_bytes;
|
|
||||||
for entry in &entries {
|
|
||||||
if existing_is_satisfied(&entry.dir, entry.index, &entry.track.title).await {
|
|
||||||
progress.track_done();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let outcome = downloader
|
|
||||||
.download_track(
|
|
||||||
client,
|
|
||||||
&entry.track,
|
|
||||||
&entry.dir,
|
|
||||||
entry.index,
|
|
||||||
&mut bytes_left,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
match outcome {
|
|
||||||
TrackOutcome::Captured => progress.track_done(),
|
|
||||||
TrackOutcome::Skipped => progress.track_skipped(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bookmark walk body: fresh tmp folder, enumerate, one link file per
|
|
||||||
/// track ([`fsdy::TrackFile::from_track`] — skipped source tracks write
|
|
||||||
/// skipped tomls). The caller owns cleanup on error.
|
|
||||||
async fn write_links<C>(
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
tmp: &Path,
|
|
||||||
caps: Caps,
|
|
||||||
progress: &Progress,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
// A leftover temp folder from a crashed or racing capture is stale.
|
|
||||||
if tokio::fs::try_exists(tmp).await? {
|
|
||||||
tokio::fs::remove_dir_all(tmp).await?;
|
|
||||||
}
|
|
||||||
tokio::fs::create_dir_all(tmp).await?;
|
|
||||||
let entries = enumerate(client, source_path, tmp, caps).await?;
|
|
||||||
progress.set_total(entries.len());
|
|
||||||
for entry in &entries {
|
|
||||||
let text = fsdy::TrackFile::from_track(&entry.track).to_toml()?;
|
|
||||||
let file = entry
|
|
||||||
.dir
|
|
||||||
.join(fsdy::track_file_name(entry.index, &entry.track.title));
|
|
||||||
tokio::fs::write(file, text).await?;
|
|
||||||
progress.track_done();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 1: mirrors the directory structure under `root` and collects
|
|
||||||
/// every track with its target directory and listing index. Iterative
|
|
||||||
/// pre-order over [`ProviderClient::get_lib_node`] (a deep tree must not
|
|
||||||
/// overflow the stack); existing directories are reused
|
|
||||||
/// (`create_dir_all`), which is what makes download captures resumable. A
|
|
||||||
/// `source_path` that is a track enumerates as a single entry. Enforces
|
|
||||||
/// `max_dirs`/`max_tracks`.
|
|
||||||
async fn enumerate<C>(
|
|
||||||
client: &C,
|
client: &C,
|
||||||
source_path: &str,
|
source_path: &str,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
|
|
@ -661,35 +427,6 @@ where
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the entry for track `title` at listing position `index` in
|
|
||||||
/// `dir` already has playable data: a parseable toml whose playable is not
|
|
||||||
/// skipped, and — for a `file` playable — whose audio file exists.
|
|
||||||
/// Anything else (missing, broken, skipped, audio gone) is re-captured.
|
|
||||||
async fn existing_is_satisfied(dir: &Path, index: usize, title: &str) -> bool {
|
|
||||||
let toml_path = dir.join(fsdy::track_file_name(index, title));
|
|
||||||
let Ok(text) = tokio::fs::read_to_string(&toml_path).await else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Ok(file) = fsdy::TrackFile::parse(&text) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
match file.playable() {
|
|
||||||
Ok(fsdy::Playable::File(target)) => {
|
|
||||||
let absolute = if target.is_absolute() {
|
|
||||||
target
|
|
||||||
} else {
|
|
||||||
dir.join(target)
|
|
||||||
};
|
|
||||||
tokio::fs::try_exists(absolute).await.unwrap_or(false)
|
|
||||||
}
|
|
||||||
Ok(fsdy::Playable::Skipped) => false,
|
|
||||||
// A url/link playable was not written by this store, but whoever
|
|
||||||
// put it there gave the entry playable data — keep it.
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Picks the audio file extension: the response `Content-Type` first
|
/// Picks the audio file extension: the response `Content-Type` first
|
||||||
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
|
/// (`audio/flac` → `flac`, `audio/mp4`/`audio/m4a` → `m4a`, `audio/mpeg` →
|
||||||
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
|
/// `mp3`, `audio/ogg` → `ogg`, `audio/wav` → `wav`), then the URL path's
|
||||||
|
|
@ -729,14 +466,6 @@ fn url_extension(url: &str) -> Option<&str> {
|
||||||
plausible.then_some(ext)
|
plausible.then_some(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The audio file name for track `index` titled `title` with `ext`, next
|
|
||||||
/// to its toml: `NNNN <title>.<ext>` through the same sanitizer as the
|
|
||||||
/// toml name, so the pair sorts together — and what the toml's relative
|
|
||||||
/// `file` playable points at.
|
|
||||||
fn audio_file_name(index: usize, title: &str, ext: &str) -> String {
|
|
||||||
format!("{}.{ext}", fsdy::dir_name(index, title))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -783,17 +512,6 @@ mod tests {
|
||||||
assert_eq!(parse_content_range("garbage"), None);
|
assert_eq!(parse_content_range("garbage"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn audio_files_pair_with_their_toml_names() {
|
|
||||||
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
|
|
||||||
// audio file sorts right next to its toml.
|
|
||||||
assert_eq!(
|
|
||||||
audio_file_name(0, "We Will Rock You", "flac"),
|
|
||||||
"0001 We Will Rock You.flac"
|
|
||||||
);
|
|
||||||
assert_eq!(audio_file_name(11, "a/b", "mp3"), "0012 a_b.mp3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn progress_counts_skipped_toward_done_and_finishes_once() {
|
fn progress_counts_skipped_toward_done_and_finishes_once() {
|
||||||
let (tx, rx) = flume::bounded(16);
|
let (tx, rx) = flume::bounded(16);
|
||||||
|
|
|
||||||
|
|
@ -1,783 +0,0 @@
|
||||||
//! Downloaded library subtrees ("captures") on disk
|
|
||||||
//! (see `architecture/captures.md` and
|
|
||||||
//! `architecture/incremental-captures.md`).
|
|
||||||
//!
|
|
||||||
//! Every capture is a folder under `<config>/crabidy/captures/` that
|
|
||||||
//! mirrors the captured subtree like a bookmark, except each track's audio
|
|
||||||
//! is **downloaded** next to its order-prefixed `*.cbd-track.toml`, and the
|
|
||||||
//! toml's playable is a relative `file` pointing at it — replaying a
|
|
||||||
//! capture needs no provider round trip. Tracks whose source cannot be
|
|
||||||
//! captured are recorded as *skipped* tomls. The same directory is mounted
|
|
||||||
//! read-only into the library as `/captures` by an `fsdy` instance (with
|
|
||||||
//! editable top-level folders); this module is the only writer.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crabidy_core::ProviderClient;
|
|
||||||
|
|
||||||
use crate::capture::{Caps, CaptureError, Progress};
|
|
||||||
|
|
||||||
/// The library mount point of the captures directory.
|
|
||||||
pub const CAPTURES_PROVIDER_ROOT: &str = "/captures";
|
|
||||||
|
|
||||||
/// The captures directory: `captures/` inside the crabidy config
|
|
||||||
/// directory. `None` when the platform has no config directory.
|
|
||||||
pub fn captures_dir() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|d| d.join("crabidy").join("captures"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes download captures. All audio is fetched through one shared HTTP
|
|
||||||
/// client. Captures are **incremental**: the walk writes into the final
|
|
||||||
/// folder, reuses entries that already have their audio, and keeps
|
|
||||||
/// everything written so far when a download fails — capturing the same
|
|
||||||
/// name again resumes (and completes previously skipped entries where the
|
|
||||||
/// source became capturable).
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct CaptureStore {
|
|
||||||
dir: PathBuf,
|
|
||||||
sink: crate::capture::Sink,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CaptureStore {
|
|
||||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
|
||||||
/// missing, and builds the shared HTTP client.
|
|
||||||
pub async fn open(dir: PathBuf) -> Result<Self, CaptureError> {
|
|
||||||
tokio::fs::create_dir_all(&dir).await?;
|
|
||||||
let downloader = crate::capture::Downloader::new()
|
|
||||||
.map_err(|err| CaptureError::Download(format!("cannot build http client: {err}")))?;
|
|
||||||
Ok(Self {
|
|
||||||
dir,
|
|
||||||
sink: crate::capture::Sink::Download(downloader),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The store directory (what the `/captures` provider instance
|
|
||||||
/// mounts).
|
|
||||||
pub fn dir(&self) -> &Path {
|
|
||||||
&self.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates a capture request without writing anything: the name must
|
|
||||||
/// be a legal folder name and the capture **root** must opt in
|
|
||||||
/// (`architecture/captures.md` D4 — a directory source must report
|
|
||||||
/// `is_downloadable`, a track source's parent node must). The
|
|
||||||
/// accept-then-stream RPC replies after this and runs
|
|
||||||
/// [`Self::capture`] detached (architecture/incremental-captures.md
|
|
||||||
/// D4).
|
|
||||||
pub async fn validate<C>(
|
|
||||||
&self,
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
fsdy::validate_folder_name(name, &[]).map_err(CaptureError::InvalidName)?;
|
|
||||||
source_allows_download(client, source_path).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Captures the subtree at `source_path` as the capture `name`,
|
|
||||||
/// downloading every track's audio — **incrementally**: an existing
|
|
||||||
/// capture of that name is resumed, not overwritten (satisfied entries
|
|
||||||
/// are reused, skipped and broken ones re-attempted). Reports through
|
|
||||||
/// `progress` (non-terminal events only; the caller sends the terminal
|
|
||||||
/// one).
|
|
||||||
///
|
|
||||||
/// The source must pass [`Self::validate`]. The walk, caps
|
|
||||||
/// ([`crate::capture::DOWNLOAD_CAPS`]), reuse rule, and skipped tomls
|
|
||||||
/// are [`crate::capture::capture_into`]'s.
|
|
||||||
pub async fn capture<C>(
|
|
||||||
&self,
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
name: &str,
|
|
||||||
progress: &Progress,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
self.capture_with_caps(
|
|
||||||
client,
|
|
||||||
source_path,
|
|
||||||
name,
|
|
||||||
crate::capture::DOWNLOAD_CAPS,
|
|
||||||
progress,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Self::capture`] with explicit caps — the seam the cap tests use.
|
|
||||||
async fn capture_with_caps<C>(
|
|
||||||
&self,
|
|
||||||
client: &C,
|
|
||||||
source_path: &str,
|
|
||||||
name: &str,
|
|
||||||
caps: Caps,
|
|
||||||
progress: &Progress,
|
|
||||||
) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
source_allows_download(client, source_path).await?;
|
|
||||||
crate::capture::capture_into(
|
|
||||||
&self.dir,
|
|
||||||
client,
|
|
||||||
source_path,
|
|
||||||
name,
|
|
||||||
caps,
|
|
||||||
&self.sink,
|
|
||||||
progress,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks that the capture root allows downloads: the node at
|
|
||||||
/// `source_path` (or, for a track path, its parent node) must set
|
|
||||||
/// `is_downloadable`. An unreadable root is [`CaptureError::BadSource`].
|
|
||||||
async fn source_allows_download<C>(client: &C, source_path: &str) -> Result<(), CaptureError>
|
|
||||||
where
|
|
||||||
C: ProviderClient + Sync,
|
|
||||||
{
|
|
||||||
let node_path = if client.is_track_path(source_path) {
|
|
||||||
crabidy_core::parent_path(source_path).unwrap_or(source_path)
|
|
||||||
} else {
|
|
||||||
source_path
|
|
||||||
};
|
|
||||||
let node = client
|
|
||||||
.get_lib_node(node_path)
|
|
||||||
.await
|
|
||||||
.map_err(|err| CaptureError::BadSource(format!("{node_path}: {err}")))?;
|
|
||||||
if node.is_downloadable {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(CaptureError::Unsupported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::capture::{Caps, DOWNLOAD_CAPS};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
|
||||||
use crabidy_core::ProviderError;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
|
|
||||||
/// Serves every request with one fixed response and returns a URL for
|
|
||||||
/// it. Minimal HTTP/1.1 on a loopback socket — enough for reqwest.
|
|
||||||
async fn serve(status: &'static str, content_type: &'static str, body: Vec<u8>) -> String {
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.expect("bind test server");
|
|
||||||
let addr = listener.local_addr().expect("test server addr");
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Ok((mut sock, _)) = listener.accept().await {
|
|
||||||
let body = body.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// Read until the header terminator; the request itself
|
|
||||||
// is irrelevant.
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut chunk = [0u8; 1024];
|
|
||||||
loop {
|
|
||||||
match sock.read(&mut chunk).await {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
buf.extend_from_slice(&chunk[..n]);
|
|
||||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let head = format!(
|
|
||||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\n\
|
|
||||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
let _ = sock.write_all(head.as_bytes()).await;
|
|
||||||
let _ = sock.write_all(&body).await;
|
|
||||||
let _ = sock.shutdown().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
format!("http://{addr}/stream")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`serve`], but the server enforces bounded ranges the way
|
|
||||||
/// googlevideo does: ranged requests up to `max_window` bytes get
|
|
||||||
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
|
|
||||||
/// oversized) gets `403`.
|
|
||||||
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.expect("bind test server");
|
|
||||||
let addr = listener.local_addr().expect("test server addr");
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Ok((mut sock, _)) = listener.accept().await {
|
|
||||||
let body = body.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut chunk = [0u8; 1024];
|
|
||||||
loop {
|
|
||||||
match sock.read(&mut chunk).await {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
buf.extend_from_slice(&chunk[..n]);
|
|
||||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let request = String::from_utf8_lossy(&buf).to_lowercase();
|
|
||||||
let range = request
|
|
||||||
.lines()
|
|
||||||
.find_map(|line| line.strip_prefix("range: bytes="))
|
|
||||||
.and_then(|spec| {
|
|
||||||
let (start, end) = spec.trim().split_once('-')?;
|
|
||||||
let start: u64 = start.parse().ok()?;
|
|
||||||
let end: u64 = end.parse().ok()?;
|
|
||||||
Some((start, end))
|
|
||||||
});
|
|
||||||
let total = body.len() as u64;
|
|
||||||
let response = match range {
|
|
||||||
Some((start, end)) if start < total && end - start < max_window => {
|
|
||||||
let end = end.min(total - 1);
|
|
||||||
let slice = &body[start as usize..=end as usize];
|
|
||||||
let mut head = format!(
|
|
||||||
"HTTP/1.1 206 Partial Content\r\nContent-Type: {content_type}\r\n\
|
|
||||||
Content-Range: bytes {start}-{end}/{total}\r\n\
|
|
||||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
|
||||||
slice.len()
|
|
||||||
)
|
|
||||||
.into_bytes();
|
|
||||||
head.extend_from_slice(slice);
|
|
||||||
head
|
|
||||||
}
|
|
||||||
Some((start, _)) if start >= total => format!(
|
|
||||||
"HTTP/1.1 416 Range Not Satisfiable\r\n\
|
|
||||||
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
|
|
||||||
Connection: close\r\n\r\n"
|
|
||||||
)
|
|
||||||
.into_bytes(),
|
|
||||||
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
|
||||||
.to_vec(),
|
|
||||||
};
|
|
||||||
let _ = sock.write_all(&response).await;
|
|
||||||
let _ = sock.shutdown().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
format!("http://{addr}/stream")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
|
|
||||||
/// stream URLs are the test server's, and a node-level download
|
|
||||||
/// blessing toggle.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct MockProvider {
|
|
||||||
urls: HashMap<String, String>,
|
|
||||||
downloadable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockProvider {
|
|
||||||
fn new(urls: &[(&str, &str)], downloadable: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
urls: urls
|
|
||||||
.iter()
|
|
||||||
.map(|(p, u)| (p.to_string(), u.to_string()))
|
|
||||||
.collect(),
|
|
||||||
downloadable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn track(&self, path: &str) -> Track {
|
|
||||||
let title = match path {
|
|
||||||
"/mock/a/1" => "one",
|
|
||||||
"/mock/a/2" => "two",
|
|
||||||
other => other,
|
|
||||||
};
|
|
||||||
Track {
|
|
||||||
path: path.to_string(),
|
|
||||||
artist: "mock".to_string(),
|
|
||||||
title: title.to_string(),
|
|
||||||
duration: Some(10),
|
|
||||||
album: None,
|
|
||||||
is_skipped: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ProviderClient for MockProvider {
|
|
||||||
async fn init(_s: &str) -> Result<Self, ProviderError> {
|
|
||||||
Err(ProviderError::NotSupported)
|
|
||||||
}
|
|
||||||
fn settings(&self) -> String {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
fn is_track_path(&self, path: &str) -> bool {
|
|
||||||
path.starts_with("/mock/a/") && path.len() > "/mock/a/".len()
|
|
||||||
}
|
|
||||||
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
|
||||||
self.urls
|
|
||||||
.get(track_path)
|
|
||||||
.map(|u| vec![u.clone()])
|
|
||||||
.ok_or(ProviderError::FetchError)
|
|
||||||
}
|
|
||||||
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
|
||||||
if !self.is_track_path(track_path) {
|
|
||||||
return Err(ProviderError::MalformedPath);
|
|
||||||
}
|
|
||||||
Ok(self.track(track_path))
|
|
||||||
}
|
|
||||||
fn get_lib_root(&self) -> LibraryNode {
|
|
||||||
LibraryNode::new()
|
|
||||||
}
|
|
||||||
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
|
||||||
let mut node = LibraryNode::new();
|
|
||||||
node.path = path.to_string();
|
|
||||||
node.is_downloadable = self.downloadable;
|
|
||||||
match path {
|
|
||||||
"/mock" => {
|
|
||||||
node.title = "mock".to_string();
|
|
||||||
node.children = vec![LibraryNodeChild {
|
|
||||||
is_downloadable: self.downloadable,
|
|
||||||
..LibraryNodeChild::new("/mock/a".to_string(), "a".to_string(), true)
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
"/mock/a" => {
|
|
||||||
node.title = "a".to_string();
|
|
||||||
node.is_queable = true;
|
|
||||||
node.tracks = vec![self.track("/mock/a/1"), self.track("/mock/a/2")];
|
|
||||||
}
|
|
||||||
_ => return Err(ProviderError::MalformedPath),
|
|
||||||
}
|
|
||||||
Ok(node)
|
|
||||||
}
|
|
||||||
async fn create_lib_node(
|
|
||||||
&self,
|
|
||||||
_parent_path: &str,
|
|
||||||
_title: &str,
|
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
|
||||||
Err(ProviderError::NotSupported)
|
|
||||||
}
|
|
||||||
async fn rename_lib_node(
|
|
||||||
&self,
|
|
||||||
_path: &str,
|
|
||||||
_new_title: &str,
|
|
||||||
) -> Result<LibraryNode, ProviderError> {
|
|
||||||
Err(ProviderError::NotSupported)
|
|
||||||
}
|
|
||||||
async fn delete_lib_node(&self, _path: &str) -> Result<LibraryNode, ProviderError> {
|
|
||||||
Err(ProviderError::NotSupported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn store() -> (CaptureStore, TempDir) {
|
|
||||||
let dir = TempDir::new().expect("store tempdir");
|
|
||||||
let store = CaptureStore::open(dir.path().join("captures"))
|
|
||||||
.await
|
|
||||||
.expect("open creates the directory");
|
|
||||||
(store, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visible(dir: &Path) -> Vec<String> {
|
|
||||||
let mut names: Vec<String> = fs::read_dir(dir)
|
|
||||||
.expect("dir")
|
|
||||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
|
||||||
.filter(|n| !n.starts_with('.'))
|
|
||||||
.collect();
|
|
||||||
names.sort_by_key(|n| n.to_lowercase());
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A silent progress reporter for tests that do not assert on events.
|
|
||||||
fn silent() -> Progress {
|
|
||||||
Progress::silent("test", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the capture entry's toml and returns its playable.
|
|
||||||
fn playable_of(dir: &Path, name: &str) -> fsdy::Playable {
|
|
||||||
let text = fs::read_to_string(dir.join(name)).expect("toml");
|
|
||||||
fsdy::TrackFile::parse(&text)
|
|
||||||
.expect("parses")
|
|
||||||
.playable()
|
|
||||||
.expect("playable")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn download_capture_writes_audio_next_to_pointing_tomls() {
|
|
||||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture");
|
|
||||||
|
|
||||||
let root = store.dir().join("faves");
|
|
||||||
assert_eq!(
|
|
||||||
visible(&root),
|
|
||||||
vec![
|
|
||||||
"0001 one.cbd-track.toml".to_string(),
|
|
||||||
"0001 one.flac".into(),
|
|
||||||
"0002 two.cbd-track.toml".into(),
|
|
||||||
"0002 two.flac".into(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
|
||||||
b"flacbytes"
|
|
||||||
);
|
|
||||||
// The toml points at its sibling with a *relative* file playable.
|
|
||||||
assert_eq!(
|
|
||||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
|
||||||
fsdy::Playable::File("0002 two.flac".into())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Replay: the capture resolves through a /captures instance and the
|
|
||||||
// audio resolves to the absolute sibling path — no provider round
|
|
||||||
// trip left.
|
|
||||||
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
|
||||||
.expect("captures instance");
|
|
||||||
let (chunk_tx, chunk_rx) = flume::bounded(8);
|
|
||||||
captures
|
|
||||||
.resolve_tracks_into("/captures/faves", chunk_tx)
|
|
||||||
.await
|
|
||||||
.expect("resolve");
|
|
||||||
let tracks: Vec<Track> = chunk_rx.into_iter().flatten().collect();
|
|
||||||
assert_eq!(tracks.len(), 2);
|
|
||||||
let urls = captures
|
|
||||||
.get_urls_for_track(&tracks[0].path)
|
|
||||||
.await
|
|
||||||
.expect("urls");
|
|
||||||
assert_eq!(urls, vec![root.join("0001 one.flac").display().to_string()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_requires_the_root_download_blessing() {
|
|
||||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], false);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
// validate() is what the accept-then-stream RPC checks up front...
|
|
||||||
assert!(matches!(
|
|
||||||
store.validate(&mock, "/mock/a", "faves").await,
|
|
||||||
Err(CaptureError::Unsupported)
|
|
||||||
));
|
|
||||||
// ...and the walk itself re-checks, for both node and track roots.
|
|
||||||
assert!(matches!(
|
|
||||||
store.capture(&mock, "/mock/a", "faves", &silent()).await,
|
|
||||||
Err(CaptureError::Unsupported)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.capture(&mock, "/mock/a/1", "faves", &silent()).await,
|
|
||||||
Err(CaptureError::Unsupported)
|
|
||||||
));
|
|
||||||
assert_eq!(fs::read_dir(store.dir()).expect("store dir").count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capturing_a_single_blessed_track_writes_one_pair() {
|
|
||||||
let url = serve("200 OK", "audio/mpeg", b"mp3bytes".to_vec()).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a/1", "just one", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture track");
|
|
||||||
assert_eq!(
|
|
||||||
visible(&store.dir().join("just one")),
|
|
||||||
vec!["0001 one.cbd-track.toml".to_string(), "0001 one.mp3".into()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn download_failure_keeps_progress_and_resuming_completes() {
|
|
||||||
let ok = serve("200 OK", "audio/flac", b"first".to_vec()).await;
|
|
||||||
let gone = serve("404 Not Found", "text/plain", Vec::new()).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &gone)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
let err = store
|
|
||||||
.capture(&mock, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect_err("a failed download aborts the run");
|
|
||||||
assert!(matches!(err, CaptureError::Download(_)), "got {err:?}");
|
|
||||||
// Incremental: what downloaded before the failure survives
|
|
||||||
// (architecture/incremental-captures.md D2).
|
|
||||||
let root = store.dir().join("faves");
|
|
||||||
assert!(root.join("0001 one.cbd-track.toml").exists());
|
|
||||||
assert!(root.join("0001 one.flac").exists());
|
|
||||||
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
|
||||||
|
|
||||||
// Resume with a healthy source: the satisfied entry is reused (the
|
|
||||||
// sentinel content is not re-downloaded), the missing one arrives.
|
|
||||||
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
|
||||||
let fixed = MockProvider::new(&[("/mock/a/1", &ok), ("/mock/a/2", &ok)], true);
|
|
||||||
store
|
|
||||||
.capture(&fixed, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("resume completes");
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
|
||||||
b"sentinel",
|
|
||||||
"satisfied entries must not be re-downloaded"
|
|
||||||
);
|
|
||||||
assert!(root.join("0002 two.cbd-track.toml").exists());
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(root.join("0002 two.flac")).expect("audio"),
|
|
||||||
b"first"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn download_capture_enforces_its_caps() {
|
|
||||||
let url = serve("200 OK", "audio/flac", b"0123456789".to_vec()).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
// Byte budget: two 10-byte bodies against a 15-byte budget. The
|
|
||||||
// first track fits, the second trips the budget — and survives as
|
|
||||||
// partial progress (no toml, so a resume re-downloads it).
|
|
||||||
let caps = Caps {
|
|
||||||
max_bytes: 15,
|
|
||||||
..DOWNLOAD_CAPS
|
|
||||||
};
|
|
||||||
let err = store
|
|
||||||
.capture_with_caps(&mock, "/mock/a", "big", caps, &silent())
|
|
||||||
.await
|
|
||||||
.expect_err("over the byte budget");
|
|
||||||
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
|
||||||
let root = store.dir().join("big");
|
|
||||||
assert!(root.join("0001 one.cbd-track.toml").exists());
|
|
||||||
assert!(!root.join("0002 two.cbd-track.toml").exists());
|
|
||||||
// Track cap: enumeration fails before anything is fetched.
|
|
||||||
let caps = Caps {
|
|
||||||
max_tracks: 1,
|
|
||||||
..DOWNLOAD_CAPS
|
|
||||||
};
|
|
||||||
let err = store
|
|
||||||
.capture_with_caps(&mock, "/mock/a", "big2", caps, &silent())
|
|
||||||
.await
|
|
||||||
.expect_err("over the track cap");
|
|
||||||
assert!(matches!(err, CaptureError::TooLarge(_)), "got {err:?}");
|
|
||||||
assert_eq!(visible(&store.dir().join("big2")), Vec::<String>::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capturing_a_mixed_queue_downloads_web_and_copies_local() {
|
|
||||||
// A queue-like source: a downloadable fsdy instance whose folder
|
|
||||||
// mixes an http track with a local-file track — exactly a
|
|
||||||
// persisted queue linking tidal and fs entries. Both must end up
|
|
||||||
// with local audio: the web one downloaded, the fs one copied.
|
|
||||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
|
||||||
let src = TempDir::new().expect("source tempdir");
|
|
||||||
let mix = src.path().join("mix");
|
|
||||||
fs::create_dir_all(&mix).expect("mkdir");
|
|
||||||
fs::write(
|
|
||||||
mix.join("01 web.cbd-track.toml"),
|
|
||||||
format!("title = \"web\"\n[playable]\nurl = {url:?}\n"),
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
fs::write(mix.join("audio.flac"), b"local").expect("write");
|
|
||||||
fs::write(
|
|
||||||
mix.join("02 local.cbd-track.toml"),
|
|
||||||
"title = \"local\"\n[playable]\nfile = \"audio.flac\"\n",
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
let source = fsdy::Client::new("/queues", src.path().to_path_buf())
|
|
||||||
.expect("source instance")
|
|
||||||
.with_downloadable_nodes();
|
|
||||||
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&source, "/queues/mix", "mixed", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture");
|
|
||||||
// Both tracks captured with audio next to their toml: the web
|
|
||||||
// track downloaded, the local track copied in.
|
|
||||||
let root = store.dir().join("mixed");
|
|
||||||
assert_eq!(
|
|
||||||
visible(&root),
|
|
||||||
vec![
|
|
||||||
"0001 web.cbd-track.toml".to_string(),
|
|
||||||
"0001 web.flac".into(),
|
|
||||||
"0002 local.cbd-track.toml".into(),
|
|
||||||
"0002 local.flac".into(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
match playable_of(&root, "0002 local.cbd-track.toml") {
|
|
||||||
fsdy::Playable::File(rel) => {
|
|
||||||
assert_eq!(fs::read(root.join(&rel)).expect("copied audio"), b"local");
|
|
||||||
}
|
|
||||||
other => panic!("expected the local track copied, got {other:?}"),
|
|
||||||
}
|
|
||||||
// Neither lists as skipped.
|
|
||||||
let captures = fsdy::Client::new(CAPTURES_PROVIDER_ROOT, store.dir().to_path_buf())
|
|
||||||
.expect("captures instance");
|
|
||||||
let node = captures
|
|
||||||
.get_lib_node("/captures/mixed")
|
|
||||||
.await
|
|
||||||
.expect("node");
|
|
||||||
assert_eq!(node.tracks.len(), 2);
|
|
||||||
assert!(node.tracks.iter().all(|t| !t.is_skipped));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn skipped_entries_are_reattempted_on_resume() {
|
|
||||||
let url = serve("200 OK", "audio/flac", b"flacbytes".to_vec()).await;
|
|
||||||
// Track two has no stream URL: recorded as skipped.
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture with a skipped entry");
|
|
||||||
let root = store.dir().join("faves");
|
|
||||||
assert_eq!(
|
|
||||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
|
||||||
fsdy::Playable::Skipped
|
|
||||||
);
|
|
||||||
|
|
||||||
// The source became capturable: re-capturing the same name
|
|
||||||
// completes the skipped entry and reuses the satisfied one.
|
|
||||||
fs::write(root.join("0001 one.flac"), b"sentinel").expect("stamp");
|
|
||||||
let fixed = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
|
||||||
store
|
|
||||||
.capture(&fixed, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("resume");
|
|
||||||
assert_eq!(
|
|
||||||
playable_of(&root, "0002 two.cbd-track.toml"),
|
|
||||||
fsdy::Playable::File("0002 two.flac".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(root.join("0001 one.flac")).expect("audio"),
|
|
||||||
b"sentinel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_validates_names_and_merges_into_existing() {
|
|
||||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url), ("/mock/a/2", &url)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
assert!(matches!(
|
|
||||||
store.validate(&mock, "/mock/a", "a/b").await,
|
|
||||||
Err(CaptureError::InvalidName(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.capture(&mock, "/mock/a", "a/b", &silent()).await,
|
|
||||||
Err(CaptureError::InvalidName(_))
|
|
||||||
));
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("first capture");
|
|
||||||
// Capturing a smaller source into the same name merges: the
|
|
||||||
// single-track capture reuses its entry, the rest stays.
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a/1", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("merge");
|
|
||||||
assert_eq!(
|
|
||||||
visible(&store.dir().join("faves")),
|
|
||||||
vec![
|
|
||||||
"0001 one.cbd-track.toml".to_string(),
|
|
||||||
"0001 one.flac".into(),
|
|
||||||
"0002 two.cbd-track.toml".into(),
|
|
||||||
"0002 two.flac".into(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn downloads_chain_bounded_windows_on_strict_cdns() {
|
|
||||||
// A googlevideo-style server: only bounded ranges under 16 bytes
|
|
||||||
// are served; plain or oversized requests are 403. A downloader
|
|
||||||
// with a 10-byte window must fetch the 100-byte body completely.
|
|
||||||
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
|
||||||
let url = serve_ranged("audio/flac", body.clone(), 16).await;
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
|
||||||
let dir = TempDir::new().expect("store tempdir");
|
|
||||||
let store_dir = dir.path().join("captures");
|
|
||||||
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
|
|
||||||
let store = CaptureStore {
|
|
||||||
dir: store_dir,
|
|
||||||
sink: crate::capture::Sink::Download(
|
|
||||||
crate::capture::Downloader::with_window(10).expect("downloader"),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a/1", "windowed", &silent())
|
|
||||||
.await
|
|
||||||
.expect("windowed capture");
|
|
||||||
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
|
|
||||||
assert_eq!(audio, body, "all windows stitched in order");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn capture_reports_progress_totals_and_skips() {
|
|
||||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
|
||||||
// Track two is uncapturable: one done, one skipped, total two.
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
let (tx, rx) = flume::bounded(64);
|
|
||||||
let progress = Progress::new("faves", true, tx);
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a", "faves", &progress)
|
|
||||||
.await
|
|
||||||
.expect("capture");
|
|
||||||
progress.finish(None).await;
|
|
||||||
let events: Vec<_> = rx.drain().collect();
|
|
||||||
assert!(events.iter().any(|e| e.tracks_total == 2 && !e.finished));
|
|
||||||
let last = events.last().expect("terminal event");
|
|
||||||
assert!(last.finished);
|
|
||||||
assert!(last.error.is_empty());
|
|
||||||
assert_eq!(last.tracks_done, 2, "skipped counts toward done");
|
|
||||||
assert_eq!(last.tracks_skipped, 1);
|
|
||||||
assert!(last.download);
|
|
||||||
assert_eq!(last.name, "faves");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn local_file_playables_are_copied_not_skipped() {
|
|
||||||
// An fs-style playable: the provider resolves the track to a
|
|
||||||
// local file path (not an http URL). The capture must copy that
|
|
||||||
// audio in, not record the track as skipped.
|
|
||||||
let src = TempDir::new().expect("source dir");
|
|
||||||
let audio = src.path().join("song.flac");
|
|
||||||
fs::write(&audio, b"flacdata").expect("write source audio");
|
|
||||||
let mock = MockProvider::new(&[("/mock/a/1", audio.to_str().unwrap())], true);
|
|
||||||
let (store, dir) = store().await;
|
|
||||||
store
|
|
||||||
.capture(&mock, "/mock/a", "faves", &silent())
|
|
||||||
.await
|
|
||||||
.expect("capture");
|
|
||||||
|
|
||||||
let capture_dir = dir.path().join("captures").join("faves");
|
|
||||||
// Track one ("one"): a File playable whose audio was copied in
|
|
||||||
// next to the toml, with the source extension and contents.
|
|
||||||
let one_toml = fsdy::track_file_name(0, "one");
|
|
||||||
match playable_of(&capture_dir, &one_toml) {
|
|
||||||
fsdy::Playable::File(rel) => {
|
|
||||||
let copied = capture_dir.join(&rel);
|
|
||||||
assert!(copied.exists(), "audio copied next to the toml");
|
|
||||||
assert_eq!(fs::read(&copied).expect("read copy"), b"flacdata");
|
|
||||||
assert_eq!(rel.extension().and_then(|e| e.to_str()), Some("flac"));
|
|
||||||
}
|
|
||||||
other => panic!("expected a copied File playable, got {other:?}"),
|
|
||||||
}
|
|
||||||
// Track two ("two") has no source and is still recorded skipped.
|
|
||||||
let two_toml = fsdy::track_file_name(1, "two");
|
|
||||||
assert!(matches!(
|
|
||||||
playable_of(&capture_dir, &two_toml),
|
|
||||||
fsdy::Playable::Skipped
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,568 @@
|
||||||
|
//! Execution of the server-owned CLI subcommands (`guard`, `scan`) and the
|
||||||
|
//! shared helpers the bundled `cbd` binary reuses (architecture/cli.md D4/D5).
|
||||||
|
//!
|
||||||
|
//! The clap *definitions* live in `cbd-cli`; their *execution* lives here
|
||||||
|
//! because it needs the server's config writer ([`ServerSettings::store`]) and
|
||||||
|
//! the content store ([`CrabidyStore`]), which `cbd-cli` must not depend on.
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::io::{IsTerminal, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use cbd_cli::{GuardArgs, Role, ScanArgs};
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
use crate::crabidy_store::CrabidyStore;
|
||||||
|
use crate::settings::ServerSettings;
|
||||||
|
|
||||||
|
/// Default server address for the remote `library`/`queue`/`global` commands.
|
||||||
|
pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051";
|
||||||
|
|
||||||
|
/// File-name extensions treated as playable audio by `scan` (lowercased).
|
||||||
|
/// `opus` is here only when this build can decode it, so `scan` never indexes
|
||||||
|
/// a file the player would reject (architecture/build-features.md D6).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
const AUDIO_EXTENSIONS: &[&str] = &[
|
||||||
|
"flac",
|
||||||
|
"mp3",
|
||||||
|
"m4a",
|
||||||
|
"aac",
|
||||||
|
"ogg",
|
||||||
|
#[cfg(feature = "opus")]
|
||||||
|
"opus",
|
||||||
|
"wav",
|
||||||
|
"webm",
|
||||||
|
"wma",
|
||||||
|
"aiff",
|
||||||
|
"aif",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The crabidy config directory (`dirs::config_dir()/crabidy`).
|
||||||
|
fn config_dir() -> Result<PathBuf, Box<dyn Error>> {
|
||||||
|
dirs::config_dir()
|
||||||
|
.map(|dir| dir.join("crabidy"))
|
||||||
|
.ok_or_else(|| "no config directory available".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a resolved [`cbd_cli::Connection`] from the shared remote flags,
|
||||||
|
/// falling back to the client defaults (localhost, no credentials).
|
||||||
|
pub fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection {
|
||||||
|
cbd_cli::Connection {
|
||||||
|
address: remote
|
||||||
|
.address
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DEFAULT_ADDRESS.to_string()),
|
||||||
|
user: remote.user.clone().unwrap_or_default(),
|
||||||
|
password: remote.password.clone().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the password from the CLI argument, or reads it from stdin when
|
||||||
|
/// omitted. Never echoed or logged. Rejects an empty password.
|
||||||
|
fn resolve_password(arg: Option<String>) -> Result<String, Box<dyn Error>> {
|
||||||
|
let password = match arg {
|
||||||
|
Some(password) => password,
|
||||||
|
None => {
|
||||||
|
let mut stdin = std::io::stdin();
|
||||||
|
if stdin.is_terminal() {
|
||||||
|
eprintln!("Enter password (input is not hidden):");
|
||||||
|
}
|
||||||
|
let mut buf = String::new();
|
||||||
|
stdin.read_to_string(&mut buf)?;
|
||||||
|
buf.trim_end_matches(['\r', '\n']).to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("empty password".into());
|
||||||
|
}
|
||||||
|
Ok(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `hash` into the role's `[auth]` field of `crabidy-server.toml` in
|
||||||
|
/// `config_dir`, preserving the other roles (the testable core of `guard`'s
|
||||||
|
/// config write).
|
||||||
|
pub fn write_role_hash(config_dir: &Path, role: Role, hash: String) -> Result<(), String> {
|
||||||
|
let mut settings = ServerSettings::load(config_dir)?;
|
||||||
|
match role {
|
||||||
|
Role::Owner => settings.auth.owner = Some(hash),
|
||||||
|
Role::QueueOwner => settings.auth.queue_owner = Some(hash),
|
||||||
|
Role::Appender => settings.auth.queue_appender = Some(hash),
|
||||||
|
}
|
||||||
|
// Refuse to persist a config the server would reject on load: roles
|
||||||
|
// must be guarded from the top down (architecture/roles-auth.md). This
|
||||||
|
// catches `guard queue-owner` before `guard owner`.
|
||||||
|
settings.auth.validate()?;
|
||||||
|
settings.store(config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `guard <role> [password] [--no-config]`: hash a role password (argon2id),
|
||||||
|
/// print the PHC string to stdout, and — unless `--no-config` — write it into
|
||||||
|
/// `crabidy-server.toml`'s `[auth]` (architecture/cli.md D4). Only the hash is
|
||||||
|
/// printed to stdout, so the command stays pipeable; the confirmation goes to
|
||||||
|
/// stderr and the password is never printed or logged.
|
||||||
|
pub async fn guard(args: GuardArgs) -> Result<(), Box<dyn Error>> {
|
||||||
|
let password = resolve_password(args.password)?;
|
||||||
|
let hash = crate::auth::hash_password(&password)?;
|
||||||
|
println!("{hash}");
|
||||||
|
if !args.no_config {
|
||||||
|
let dir = config_dir()?;
|
||||||
|
write_role_hash(&dir, args.role, hash)?;
|
||||||
|
eprintln!(
|
||||||
|
"wrote the {} hash to {}",
|
||||||
|
args.role.user_name(),
|
||||||
|
dir.join(crate::settings::SETTINGS_FILE).display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `audio-devices [device]`: with no argument, list the available audio output
|
||||||
|
/// devices so the user can choose one for `[audio] device` in
|
||||||
|
/// `crabidy-server.toml`, marking the device the current config selects (same
|
||||||
|
/// case-insensitive substring match the server applies at startup). With a
|
||||||
|
/// `device` argument, write it into `[audio] device` and then list — so one
|
||||||
|
/// command both configures and confirms. Fixes the common Raspberry Pi case
|
||||||
|
/// where the default device is HDMI and audio plays but is silent on the
|
||||||
|
/// jack/DAC.
|
||||||
|
pub fn audio_devices(select: Option<String>) -> Result<(), Box<dyn Error>> {
|
||||||
|
// Setting a device needs a writable config dir; listing tolerates its
|
||||||
|
// absence.
|
||||||
|
if let Some(device) = select {
|
||||||
|
let dir = config_dir()?;
|
||||||
|
let mut settings = ServerSettings::load(&dir)?;
|
||||||
|
settings.audio.device = Some(device.clone());
|
||||||
|
settings.store(&dir)?;
|
||||||
|
println!(
|
||||||
|
"Set [audio] device = \"{device}\" in {}",
|
||||||
|
dir.join(crate::settings::SETTINGS_FILE).display()
|
||||||
|
);
|
||||||
|
// The server matches the same way; warn on a fragment that currently
|
||||||
|
// resolves to nothing so a typo is caught here, not as silent output.
|
||||||
|
let needle = device.to_lowercase();
|
||||||
|
let names = audio_player::output_device_names();
|
||||||
|
if !names.is_empty()
|
||||||
|
&& !names
|
||||||
|
.iter()
|
||||||
|
.any(|name| name.to_lowercase().contains(&needle))
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"warning: no current output device name contains \"{device}\"; the server \
|
||||||
|
will fall back to the system default until one matches"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("Restart the server for it to take effect.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = config_dir().ok();
|
||||||
|
let configured = dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|dir| ServerSettings::load(dir).ok())
|
||||||
|
.and_then(|settings| settings.audio.device);
|
||||||
|
|
||||||
|
let names = audio_player::output_device_names();
|
||||||
|
if names.is_empty() {
|
||||||
|
println!("No audio output devices found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let needle = configured.as_ref().map(|device| device.to_lowercase());
|
||||||
|
println!("Audio output devices (* = selected by the current config):");
|
||||||
|
for name in &names {
|
||||||
|
let selected = needle
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|needle| name.to_lowercase().contains(needle));
|
||||||
|
println!(" {}{name}", if selected { "* " } else { " " });
|
||||||
|
}
|
||||||
|
|
||||||
|
match configured {
|
||||||
|
Some(device) => println!("\n[audio] device = \"{device}\""),
|
||||||
|
None => {
|
||||||
|
let path = dir
|
||||||
|
.map(|dir| {
|
||||||
|
dir.join(crate::settings::SETTINGS_FILE)
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| crate::settings::SETTINGS_FILE.to_string());
|
||||||
|
println!(
|
||||||
|
"\nNo [audio] device set (using the system default). To pin one, add to {path}:\n\n[audio]\ndevice = \"Headphones\" # a name or fragment from the list above"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `scan` without the `fs` feature: the command exists (the clap surface is
|
||||||
|
/// feature-independent, so completions and the man page never vary) but this
|
||||||
|
/// build has no sidecar writer or content store
|
||||||
|
/// (architecture/build-features.md D5/D9).
|
||||||
|
#[cfg(not(feature = "fs"))]
|
||||||
|
pub async fn scan(_args: ScanArgs) -> Result<(), Box<dyn Error>> {
|
||||||
|
Err("this binary was built without the `fs` feature, so it cannot index a music folder".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The build features this binary was compiled with, in a stable order:
|
||||||
|
/// the providers it can mount ([`crate::settings::BUILT_IN_PROVIDERS`])
|
||||||
|
/// followed by the non-provider features (`opus`, `spectrum`, `web-ui`).
|
||||||
|
///
|
||||||
|
/// Used by the `features` subcommand and by the startup log line, so "why is
|
||||||
|
/// `/tidal` missing?" is answerable from the binary itself
|
||||||
|
/// (architecture/build-features.md D4).
|
||||||
|
pub fn build_features() -> Vec<&'static str> {
|
||||||
|
let mut features: Vec<&'static str> = crate::settings::BUILT_IN_PROVIDERS.to_vec();
|
||||||
|
if cfg!(feature = "opus") {
|
||||||
|
features.push("opus");
|
||||||
|
}
|
||||||
|
if cfg!(feature = "spectrum") {
|
||||||
|
features.push("spectrum");
|
||||||
|
}
|
||||||
|
if cfg!(feature = "web-ui") {
|
||||||
|
features.push("web-ui");
|
||||||
|
}
|
||||||
|
features
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `features`: print what this build can do — one feature per line, so it is
|
||||||
|
/// greppable — and where the runtime `providers` list that prunes it further
|
||||||
|
/// lives (architecture/build-features.md D4).
|
||||||
|
pub fn features() -> Result<(), Box<dyn Error>> {
|
||||||
|
for feature in build_features() {
|
||||||
|
println!("{feature}");
|
||||||
|
}
|
||||||
|
let path = config_dir()
|
||||||
|
.map(|dir| {
|
||||||
|
dir.join(crate::settings::SETTINGS_FILE)
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| crate::settings::SETTINGS_FILE.to_string());
|
||||||
|
eprintln!(
|
||||||
|
"\nProviders can be pruned further at runtime with the `providers` list in {path}; \
|
||||||
|
names missing above are not in this binary and cannot be enabled there."
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a `scan` walk, for a concise summary and for tests.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ScanSummary {
|
||||||
|
/// Sidecar `.cbd-track.toml` files written this run.
|
||||||
|
pub written: usize,
|
||||||
|
/// Audio files skipped because a sidecar already existed.
|
||||||
|
pub skipped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `scan <path> [--capture|--move]`: index a music folder (architecture/cli.md
|
||||||
|
/// D5). Opens the content store only when a capture/move is requested.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
pub async fn scan(args: ScanArgs) -> Result<(), Box<dyn Error>> {
|
||||||
|
if !args.path.is_dir() {
|
||||||
|
return Err(format!("not a directory: {}", args.path.display()).into());
|
||||||
|
}
|
||||||
|
let store = if args.capture || args.move_ {
|
||||||
|
let tree_root = CrabidyStore::default_tree_root()
|
||||||
|
.ok_or("--capture/--move need a state directory for the content store")?;
|
||||||
|
let store_root = CrabidyStore::default_store_root()
|
||||||
|
.ok_or("--capture/--move need a data directory for the content store")?;
|
||||||
|
Some(CrabidyStore::open(tree_root, store_root).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let summary = scan_dir(&args.path, store.as_ref(), args.move_).await?;
|
||||||
|
println!(
|
||||||
|
"scan complete: {} written, {} skipped",
|
||||||
|
summary.written, summary.skipped
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks `root` (bounded, hidden entries and symlinks skipped), writing a
|
||||||
|
/// `<stem>.cbd-track.toml` beside each audio file. With `store` set, each file
|
||||||
|
/// is ingested (copied, or moved when `move_it`) into the content store and
|
||||||
|
/// the sidecar points at the store entry; otherwise the sidecar's playable is
|
||||||
|
/// a relative [`fsdy::Playable::File`]. An existing sidecar is never
|
||||||
|
/// clobbered. Unreadable entries are warnings, not failures.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
pub async fn scan_dir(
|
||||||
|
root: &Path,
|
||||||
|
store: Option<&CrabidyStore>,
|
||||||
|
move_it: bool,
|
||||||
|
) -> Result<ScanSummary, Box<dyn Error>> {
|
||||||
|
let mut summary = ScanSummary::default();
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(read_dir) => read_dir,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(dir = %dir.display(), "cannot read directory: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let entry = match read_dir.next_entry().await {
|
||||||
|
Ok(Some(entry)) => entry,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(dir = %dir.display(), "error listing directory: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Ok(file_type) = entry.file_type().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if file_type.is_dir() {
|
||||||
|
stack.push(path);
|
||||||
|
} else if is_audio_file(&path) {
|
||||||
|
match scan_file(&dir, &path, store, move_it).await {
|
||||||
|
Ok(true) => summary.written += 1,
|
||||||
|
Ok(false) => summary.skipped += 1,
|
||||||
|
Err(err) => warn!(file = %path.display(), "cannot index file: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indexes one audio file. Returns `true` when a sidecar was written, `false`
|
||||||
|
/// when one already existed (left untouched).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
async fn scan_file(
|
||||||
|
dir: &Path,
|
||||||
|
file: &Path,
|
||||||
|
store: Option<&CrabidyStore>,
|
||||||
|
move_it: bool,
|
||||||
|
) -> Result<bool, Box<dyn Error>> {
|
||||||
|
let stem = file
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or("audio file has no usable name")?
|
||||||
|
.to_string();
|
||||||
|
let file_name = file
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or("audio file has no usable name")?
|
||||||
|
.to_string();
|
||||||
|
let sidecar = dir.join(format!("{stem}{}", fsdy::TRACK_FILE_SUFFIX));
|
||||||
|
if tokio::fs::try_exists(&sidecar).await.unwrap_or(false) {
|
||||||
|
warn!(sidecar = %sidecar.display(), "leaving existing track toml untouched");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let playable = match store {
|
||||||
|
Some(store) => {
|
||||||
|
let name = store.ingest_file(file, move_it).await?;
|
||||||
|
fsdy::PlayableSpec {
|
||||||
|
file: None,
|
||||||
|
url: None,
|
||||||
|
link: None,
|
||||||
|
store: Some(name),
|
||||||
|
skipped: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => fsdy::PlayableSpec {
|
||||||
|
file: Some(PathBuf::from(&file_name)),
|
||||||
|
url: None,
|
||||||
|
link: None,
|
||||||
|
store: None,
|
||||||
|
skipped: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let track_file = fsdy::TrackFile {
|
||||||
|
title: stem,
|
||||||
|
artist: String::new(),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
// A scanned local file has no provider-internal id.
|
||||||
|
provider_item_id: None,
|
||||||
|
playable,
|
||||||
|
};
|
||||||
|
tokio::fs::write(&sidecar, track_file.to_toml()?).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `path`'s extension is a known audio extension (case-insensitive).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
fn is_audio_file(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
.map(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_writes_the_role_hash_and_preserves_others() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
// Guarding must run top-down; owner first, then queue-owner.
|
||||||
|
write_role_hash(dir.path(), Role::Owner, "$argon2id$owner".to_string()).expect("owner");
|
||||||
|
write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string()).expect("qo");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("reload");
|
||||||
|
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner"));
|
||||||
|
assert_eq!(settings.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
|
||||||
|
assert!(settings.auth.queue_appender.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_refuses_to_write_a_role_below_an_unguarded_one() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
// queue-owner without owner would let anonymous callers outrank it.
|
||||||
|
let err = write_role_hash(dir.path(), Role::QueueOwner, "$argon2id$qo".to_string())
|
||||||
|
.expect_err("must refuse");
|
||||||
|
assert!(err.contains("owner"), "{err}");
|
||||||
|
// Nothing was written, so the config still loads clean.
|
||||||
|
assert!(ServerSettings::load(dir.path()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_features_lists_providers_then_extras() {
|
||||||
|
let features = build_features();
|
||||||
|
// Providers first, in BUILT_IN_PROVIDERS order, then the extras.
|
||||||
|
let providers = crate::settings::BUILT_IN_PROVIDERS;
|
||||||
|
assert_eq!(&features[..providers.len()], providers);
|
||||||
|
let extras = &features[providers.len()..];
|
||||||
|
for extra in extras {
|
||||||
|
assert!(
|
||||||
|
["opus", "spectrum", "web-ui"].contains(extra),
|
||||||
|
"unexpected extra {extra}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(extras.contains(&"opus"), cfg!(feature = "opus"));
|
||||||
|
assert_eq!(extras.contains(&"spectrum"), cfg!(feature = "spectrum"));
|
||||||
|
assert_eq!(extras.contains(&"web-ui"), cfg!(feature = "web-ui"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `scan` must index `.opus` exactly when this build can decode it
|
||||||
|
/// (architecture/build-features.md D6) — and never change its verdict on
|
||||||
|
/// the other extensions.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[test]
|
||||||
|
fn opus_is_scannable_only_with_the_decoder() {
|
||||||
|
assert_eq!(
|
||||||
|
is_audio_file(Path::new("/music/a.opus")),
|
||||||
|
cfg!(feature = "opus")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
is_audio_file(Path::new("/music/A.OPUS")),
|
||||||
|
cfg!(feature = "opus")
|
||||||
|
);
|
||||||
|
for name in ["a.flac", "a.mp3", "a.m4a", "a.ogg", "a.wav"] {
|
||||||
|
assert!(is_audio_file(&PathBuf::from(name)), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["a.jpg", "a.cbd-track.toml", "a"] {
|
||||||
|
assert!(!is_audio_file(&PathBuf::from(name)), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_walks_past_opus_files_it_cannot_play() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(dir.path().join("song.opus"), b"AUDIO").expect("write");
|
||||||
|
std::fs::write(dir.path().join("other.flac"), b"AUDIO").expect("write");
|
||||||
|
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
|
||||||
|
let expected = if cfg!(feature = "opus") { 2 } else { 1 };
|
||||||
|
assert_eq!(summary.written, expected);
|
||||||
|
assert_eq!(
|
||||||
|
dir.path().join("song.cbd-track.toml").is_file(),
|
||||||
|
cfg!(feature = "opus")
|
||||||
|
);
|
||||||
|
assert!(dir.path().join("other.cbd-track.toml").is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_writes_file_playables_and_skips_existing_tomls() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let sub = dir.path().join("album");
|
||||||
|
std::fs::create_dir(&sub).expect("mkdir");
|
||||||
|
std::fs::write(sub.join("song.flac"), b"AUDIO").expect("write");
|
||||||
|
std::fs::write(dir.path().join("cover.jpg"), b"jpg").expect("write");
|
||||||
|
std::fs::write(dir.path().join(".hidden.mp3"), b"x").expect("write");
|
||||||
|
|
||||||
|
let summary = scan_dir(dir.path(), None, false).await.expect("scan");
|
||||||
|
assert_eq!(summary.written, 1);
|
||||||
|
assert_eq!(summary.skipped, 0);
|
||||||
|
|
||||||
|
let sidecar = sub.join("song.cbd-track.toml");
|
||||||
|
assert!(sidecar.is_file());
|
||||||
|
let text = std::fs::read_to_string(&sidecar).expect("read");
|
||||||
|
let track = fsdy::TrackFile::parse(&text).expect("parse");
|
||||||
|
assert_eq!(track.title, "song");
|
||||||
|
assert_eq!(
|
||||||
|
track.playable().expect("playable"),
|
||||||
|
fsdy::Playable::File(PathBuf::from("song.flac"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// A re-scan leaves the hand-written toml untouched.
|
||||||
|
let again = scan_dir(dir.path(), None, false).await.expect("rescan");
|
||||||
|
assert_eq!(again.written, 0);
|
||||||
|
assert_eq!(again.skipped, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() {
|
||||||
|
let src = TempDir::new().expect("srcdir");
|
||||||
|
std::fs::write(src.path().join("a.flac"), b"AUDIO").expect("write");
|
||||||
|
let store_dir = TempDir::new().expect("storedir");
|
||||||
|
let store = CrabidyStore::open(
|
||||||
|
store_dir.path().join("state"),
|
||||||
|
store_dir.path().join("store"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("open store");
|
||||||
|
|
||||||
|
let summary = scan_dir(src.path(), Some(&store), false)
|
||||||
|
.await
|
||||||
|
.expect("capture scan");
|
||||||
|
assert_eq!(summary.written, 1);
|
||||||
|
// Source stays (copy), the store has the audio, the toml points there.
|
||||||
|
assert!(src.path().join("a.flac").is_file());
|
||||||
|
let text = std::fs::read_to_string(src.path().join("a.cbd-track.toml")).expect("read");
|
||||||
|
let track = fsdy::TrackFile::parse(&text).expect("parse");
|
||||||
|
match track.playable().expect("playable") {
|
||||||
|
fsdy::Playable::Store(name) => assert!(store.store_dir().join(&name).is_file()),
|
||||||
|
other => panic!("expected a store playable, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_move_removes_the_source_audio() {
|
||||||
|
let src = TempDir::new().expect("srcdir");
|
||||||
|
std::fs::write(src.path().join("b.mp3"), b"BYTES").expect("write");
|
||||||
|
let store_dir = TempDir::new().expect("storedir");
|
||||||
|
let store = CrabidyStore::open(
|
||||||
|
store_dir.path().join("state"),
|
||||||
|
store_dir.path().join("store"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("open store");
|
||||||
|
|
||||||
|
scan_dir(src.path(), Some(&store), true)
|
||||||
|
.await
|
||||||
|
.expect("move scan");
|
||||||
|
// The original audio is gone; only the toml remains beside it.
|
||||||
|
assert!(!src.path().join("b.mp3").exists());
|
||||||
|
assert!(src.path().join("b.cbd-track.toml").is_file());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,15 +1,23 @@
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod bookmark_store;
|
|
||||||
#[cfg(feature = "web-ui")]
|
#[cfg(feature = "web-ui")]
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
|
// Local files and persistent state: the content store behind `/crabidy` and
|
||||||
|
// `/orphans`, bookmarks/captures, and queue persistence are all written in
|
||||||
|
// terms of `fsdy`, so they share the `fs` feature
|
||||||
|
// (architecture/build-features.md D5).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod capture_store;
|
pub mod cli;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
pub mod crabidy_store;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
pub mod orphans;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod queue_store;
|
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
#[cfg(feature = "spectrum")]
|
||||||
pub mod spectrum;
|
pub mod spectrum;
|
||||||
|
|
||||||
use audio_player::PlayerMessage;
|
use audio_player::PlayerMessage;
|
||||||
|
|
@ -17,7 +25,7 @@ use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Queue,
|
||||||
Track,
|
Track,
|
||||||
};
|
};
|
||||||
use crabidy_core::{ProviderClient, ProviderError};
|
use crabidy_core::ProviderError;
|
||||||
use rand::{rng, seq::SliceRandom};
|
use rand::{rng, seq::SliceRandom};
|
||||||
use std::sync::{atomic::AtomicBool, Arc};
|
use std::sync::{atomic::AtomicBool, Arc};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
@ -45,43 +53,55 @@ pub async fn serve(
|
||||||
let config_dir = dirs::config_dir()
|
let config_dir = dirs::config_dir()
|
||||||
.map(|d| d.join("crabidy"))
|
.map(|d| d.join("crabidy"))
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||||
|
// Seed a default config (all providers, no auth) on first run so users
|
||||||
|
// have the full provider list to prune. Best-effort — a write failure just
|
||||||
|
// means we fall back to the all-enabled default below.
|
||||||
|
if let Err(err) = settings::ServerSettings::ensure_default(&config_dir) {
|
||||||
|
warn!("could not write a default crabidy-server.toml: {err}");
|
||||||
|
}
|
||||||
let server_settings = settings::ServerSettings::load(&config_dir)?;
|
let server_settings = settings::ServerSettings::load(&config_dir)?;
|
||||||
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
|
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
|
||||||
if authenticator.enabled() {
|
if authenticator.enabled() {
|
||||||
info!("role authorization enabled");
|
info!("role authorization enabled");
|
||||||
}
|
}
|
||||||
|
// What this binary can do at all (architecture/build-features.md D4), so
|
||||||
|
// a tailored build is self-describing in its own log.
|
||||||
|
info!(features = %cli::build_features().join(" "), "build features");
|
||||||
|
// A `providers` entry this build cannot mount is a warning, not a startup
|
||||||
|
// error: the library layer is fail-open, unlike `[auth]`.
|
||||||
|
for name in server_settings.unavailable_providers() {
|
||||||
|
warn!(
|
||||||
|
provider = name,
|
||||||
|
"crabidy-server.toml lists a provider this binary was not built with; ignoring it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let toggles = server_settings.provider_toggles();
|
||||||
|
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||||
let orchestrator = provider::ProviderOrchestrator::init("")
|
let orchestrator = provider::ProviderOrchestrator::build(toggles)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
error!("failed to init provider orchestrator: {err}");
|
error!("failed to init provider orchestrator: {err}");
|
||||||
err
|
err
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Queue persistence is optional: without a usable queues directory the
|
// Queue persistence rides on the /crabidy store (its `current` folder);
|
||||||
// server runs with an in-memory queue only.
|
// the orchestrator built it, so playback shares the same Arc. Without a
|
||||||
let queue_store = match queue_store::queues_dir() {
|
// state/data directory it is `None` and the queue lives in memory only —
|
||||||
Some(dir) => match queue_store::QueueStore::open(dir).await {
|
// as it always does in a build without the `fs` feature.
|
||||||
Ok(store) => Some(Arc::new(store)),
|
#[cfg(feature = "fs")]
|
||||||
Err(err) => {
|
let crabidy_store = orchestrator.crabidy_store();
|
||||||
warn!("queue persistence disabled: {err}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
warn!("queue persistence disabled: no config directory");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let playback = playback::Playback::new(
|
let playback = playback::Playback::new(
|
||||||
update_tx.clone(),
|
update_tx.clone(),
|
||||||
orchestrator.provider_tx.clone(),
|
orchestrator.provider_tx.clone(),
|
||||||
queue_store,
|
#[cfg(feature = "fs")]
|
||||||
|
crabidy_store,
|
||||||
|
server_settings.audio.device.clone(),
|
||||||
);
|
);
|
||||||
// Reload the persisted current queue before anything can observe or
|
// Reload the persisted current queue before anything can observe or
|
||||||
// mutate state; never starts playback.
|
// mutate state; never starts playback.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
playback.restore_current().await;
|
playback.restore_current().await;
|
||||||
|
|
||||||
let playback_tx = playback.playback_tx.clone();
|
let playback_tx = playback.playback_tx.clone();
|
||||||
|
|
@ -92,12 +112,14 @@ pub async fn serve(
|
||||||
});
|
});
|
||||||
info!("player message forwarder started");
|
info!("player message forwarder started");
|
||||||
|
|
||||||
|
#[cfg(feature = "spectrum")]
|
||||||
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
spawn_spectrum_task(playback.player.spectrum_tap(), update_tx.clone());
|
||||||
|
|
||||||
let crabidy_service = rpc::RpcService::new(
|
let crabidy_service = rpc::RpcService::new(
|
||||||
update_tx,
|
update_tx,
|
||||||
playback.playback_tx.clone(),
|
playback.playback_tx.clone(),
|
||||||
orchestrator.provider_tx.clone(),
|
orchestrator.provider_tx.clone(),
|
||||||
|
authenticator.enabled(),
|
||||||
);
|
);
|
||||||
orchestrator.run();
|
orchestrator.run();
|
||||||
info!("provider orchestrator started");
|
info!("provider orchestrator started");
|
||||||
|
|
@ -149,6 +171,7 @@ pub fn build_router(
|
||||||
/// subscribers, and only recomputes when the tap advanced since the
|
/// subscribers, and only recomputes when the tap advanced since the
|
||||||
/// last tick (audio is flowing), emitting a single zero frame when
|
/// last tick (audio is flowing), emitting a single zero frame when
|
||||||
/// playback goes idle so the bars fall rather than freeze.
|
/// playback goes idle so the bars fall rather than freeze.
|
||||||
|
#[cfg(feature = "spectrum")]
|
||||||
fn spawn_spectrum_task(
|
fn spawn_spectrum_task(
|
||||||
tap: std::sync::Arc<audio_player::SpectrumTap>,
|
tap: std::sync::Arc<audio_player::SpectrumTap>,
|
||||||
update_tx: tokio::sync::broadcast::Sender<
|
update_tx: tokio::sync::broadcast::Sender<
|
||||||
|
|
@ -249,6 +272,13 @@ pub struct PendingResolve {
|
||||||
kind: ResolveKind,
|
kind: ResolveKind,
|
||||||
/// Tracks applied so far; an op finishing at zero is worth a warning.
|
/// Tracks applied so far; an op finishing at zero is worth a warning.
|
||||||
applied: usize,
|
applied: usize,
|
||||||
|
/// Set once a chunk of this op makes a track current — the op should be
|
||||||
|
/// driving the initial playback start — and cleared once playback is
|
||||||
|
/// confirmed started ([`Self::mark_started`]). While set, the playback
|
||||||
|
/// loop (re)starts an idle player as later chunks arrive, so an unplayable
|
||||||
|
/// or very short head track that ran the player dry before more tracks
|
||||||
|
/// resolved does not strand the queue stopped.
|
||||||
|
wants_start: bool,
|
||||||
/// Shared with the op's forwarder task: set on cancellation so the
|
/// Shared with the op's forwarder task: set on cancellation so the
|
||||||
/// forwarder drops the chunk receiver, which stops the provider fetch.
|
/// forwarder drops the chunk receiver, which stops the provider fetch.
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
|
|
@ -259,10 +289,23 @@ impl PendingResolve {
|
||||||
Self {
|
Self {
|
||||||
kind,
|
kind,
|
||||||
applied: 0,
|
applied: 0,
|
||||||
|
wants_start: false,
|
||||||
cancelled: Arc::new(AtomicBool::new(false)),
|
cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this op should (re)start playback: a chunk made a track current
|
||||||
|
/// but playback has not been confirmed started yet (see [`Self::apply_chunk`]).
|
||||||
|
pub fn wants_start(&self) -> bool {
|
||||||
|
self.wants_start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that playback started for this op, so later chunks only extend
|
||||||
|
/// the queue and never restart the playing track.
|
||||||
|
pub fn mark_started(&mut self) {
|
||||||
|
self.wants_start = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// The cancellation flag to hand to this op's forwarder task.
|
/// The cancellation flag to hand to this op's forwarder task.
|
||||||
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
|
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
|
||||||
Arc::clone(&self.cancelled)
|
Arc::clone(&self.cancelled)
|
||||||
|
|
@ -282,10 +325,14 @@ impl PendingResolve {
|
||||||
/// Applies one resolved chunk to the queue and advances this op's
|
/// Applies one resolved chunk to the queue and advances this op's
|
||||||
/// cursor. Returns the track that should start playing, if this chunk
|
/// cursor. Returns the track that should start playing, if this chunk
|
||||||
/// made one current (first chunk of a replace, or any chunk landing in
|
/// made one current (first chunk of a replace, or any chunk landing in
|
||||||
/// an empty queue) — later chunks of the same op never restart playback.
|
/// an empty queue) — later chunks of the same op never restart a playing
|
||||||
|
/// track. A chunk that makes a track current also sets [`Self::wants_start`],
|
||||||
|
/// so if that track (or the ones after it) turns out unplayable and runs
|
||||||
|
/// the player dry before more tracks resolve, the loop retries the start
|
||||||
|
/// on later chunks instead of leaving the queue stopped.
|
||||||
pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> {
|
pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> {
|
||||||
self.applied += tracks.len();
|
self.applied += tracks.len();
|
||||||
match self.kind {
|
let started = match self.kind {
|
||||||
ResolveKind::Replace => {
|
ResolveKind::Replace => {
|
||||||
// Only the first chunk replaces; the rest of this op
|
// Only the first chunk replaces; the rest of this op
|
||||||
// extends the fresh queue.
|
// extends the fresh queue.
|
||||||
|
|
@ -300,7 +347,11 @@ impl PendingResolve {
|
||||||
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
|
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
|
||||||
queue.insert_tracks(position, tracks)
|
queue.insert_tracks(position, tracks)
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if started.is_some() {
|
||||||
|
self.wants_start = true;
|
||||||
}
|
}
|
||||||
|
started
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -626,6 +677,8 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -769,6 +822,33 @@ mod tests {
|
||||||
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
|
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_op_wants_start_until_marked_started() {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
let mut op = PendingResolve::new(ResolveKind::Replace);
|
||||||
|
// Nothing applied yet: no start intent.
|
||||||
|
assert!(!op.wants_start());
|
||||||
|
// The first chunk makes a track current, so the op now wants to drive
|
||||||
|
// the initial start — and keeps wanting it across later chunks until
|
||||||
|
// playback is confirmed, so a skipped/short head that runs the player
|
||||||
|
// dry before more resolve is retried rather than stranding the queue.
|
||||||
|
op.apply_chunk(&mut q, &[track(10)]);
|
||||||
|
assert!(op.wants_start());
|
||||||
|
op.apply_chunk(&mut q, &[track(11)]);
|
||||||
|
assert!(op.wants_start(), "still wants start until confirmed");
|
||||||
|
op.mark_started();
|
||||||
|
assert!(!op.wants_start());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_into_playing_queue_never_wants_start() {
|
||||||
|
let mut q = queue_with(2); // already playing track 0
|
||||||
|
let mut op = PendingResolve::new(ResolveKind::Append);
|
||||||
|
op.apply_chunk(&mut q, &[track(10)]);
|
||||||
|
// Appending behind a playing queue must not (re)start playback.
|
||||||
|
assert!(!op.wants_start());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shuffle_insert_keeps_order_unique() {
|
fn shuffle_insert_keeps_order_unique() {
|
||||||
let mut q = queue_with(5);
|
let mut q = queue_with(5);
|
||||||
|
|
@ -851,6 +931,10 @@ pub enum ProviderCommand {
|
||||||
/// on `progress_tx`, ending in exactly one `finished` event (with
|
/// on `progress_tx`, ending in exactly one `finished` event (with
|
||||||
/// `error` set on failure). A rejected capture answers with the error
|
/// `error` set on failure). A rejected capture answers with the error
|
||||||
/// and sends no progress events.
|
/// and sends no progress events.
|
||||||
|
///
|
||||||
|
/// Only exists with the `fs` feature: captures live in the content store,
|
||||||
|
/// which that feature brings (architecture/build-features.md D5).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
CaptureLibraryNode {
|
CaptureLibraryNode {
|
||||||
path: String,
|
path: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -869,6 +953,7 @@ impl ProviderCommand {
|
||||||
Self::CreateLibraryNode { .. } => "create_library_node",
|
Self::CreateLibraryNode { .. } => "create_library_node",
|
||||||
Self::RenameLibraryNode { .. } => "rename_library_node",
|
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||||||
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -934,9 +1019,13 @@ pub enum PlaybackCommand {
|
||||||
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||||||
/// snapshot is consistent; the disk write happens on a spawned task and
|
/// snapshot is consistent; the disk write happens on a spawned task and
|
||||||
/// reports through `result_tx`.
|
/// reports through `result_tx`.
|
||||||
|
///
|
||||||
|
/// Only exists with the `fs` feature — a saved queue is a store write
|
||||||
|
/// (architecture/build-features.md D5).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
SaveQueue {
|
SaveQueue {
|
||||||
name: String,
|
name: String,
|
||||||
result_tx: flume::Sender<Result<(), crate::queue_store::SaveQueueError>>,
|
result_tx: flume::Sender<Result<(), crate::capture::CaptureError>>,
|
||||||
},
|
},
|
||||||
ToggleShuffle,
|
ToggleShuffle,
|
||||||
ToggleRepeat,
|
ToggleRepeat,
|
||||||
|
|
@ -977,6 +1066,7 @@ impl PlaybackCommand {
|
||||||
Self::ResolveFinished { .. } => "resolve_finished",
|
Self::ResolveFinished { .. } => "resolve_finished",
|
||||||
Self::Clear { .. } => "clear",
|
Self::Clear { .. } => "clear",
|
||||||
Self::SetCurrent { .. } => "set_current",
|
Self::SetCurrent { .. } => "set_current",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
Self::SaveQueue { .. } => "save_queue",
|
Self::SaveQueue { .. } => "save_queue",
|
||||||
Self::ToggleShuffle => "toggle_shuffle",
|
Self::ToggleShuffle => "toggle_shuffle",
|
||||||
Self::ToggleRepeat => "toggle_repeat",
|
Self::ToggleRepeat => "toggle_repeat",
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,63 @@
|
||||||
//! The standalone server binary: stderr tracing plus
|
//! The standalone server binary: a clap-derive CLI
|
||||||
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack
|
//! ([`cbd_cli::ServerCli`]). With no subcommand it runs the server (stderr
|
||||||
//! lives in the library so the bundled `cbd` binary can host it too
|
//! tracing plus [`crabidy_server::serve`] on the fixed listen address),
|
||||||
//! (architecture/cbd-bundle.md D1).
|
//! exactly as before; subcommands cover role setup (`guard`), folder
|
||||||
|
//! indexing (`scan`), remote control (`library`/`queue`/`global`), and shell
|
||||||
|
//! completions. The whole server stack lives in the library so the bundled
|
||||||
|
//! `cbd` binary can host it too (architecture/cbd-bundle.md D1).
|
||||||
|
|
||||||
use clap::Parser;
|
use cbd_cli::{RemoteCmd, ServerCli, ServerCommand};
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
use crabidy_server::cli;
|
||||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(author, version, about)]
|
|
||||||
struct Cli {
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Option<Command>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(clap::Subcommand)]
|
|
||||||
enum Command {
|
|
||||||
/// Hash a password for the `[auth]` section of crabidy-server.toml
|
|
||||||
/// (architecture/roles-auth.md). Reads the password as one line
|
|
||||||
/// from stdin and prints the PHC string — nothing else, so output
|
|
||||||
/// can be piped. The password itself is never printed or logged.
|
|
||||||
HashPassword,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let cli = Cli::parse();
|
let cli = ServerCli::parse();
|
||||||
if let Some(Command::HashPassword) = cli.command {
|
match cli.command {
|
||||||
return hash_password();
|
// No subcommand: run the server, exactly as before.
|
||||||
}
|
None => {
|
||||||
let _log_guard = init_tracing();
|
let _log_guard = init_tracing();
|
||||||
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
// Subcommands are one-shot CLI actions: a failure prints a short
|
||||||
/// Reads one line from stdin and prints its argon2 PHC hash.
|
// message and exits non-zero, never a color-eyre report chain.
|
||||||
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
Some(command) => {
|
||||||
let mut password = String::new();
|
if let Err(err) = run_command(&cli.remote, command).await {
|
||||||
std::io::stdin().read_line(&mut password)?;
|
eprintln!("error: {err}");
|
||||||
let password = password.trim_end_matches(['\r', '\n']);
|
std::process::exit(1);
|
||||||
if password.is_empty() {
|
|
||||||
return Err("empty password".into());
|
|
||||||
}
|
}
|
||||||
println!("{}", crabidy_server::auth::hash_password(password)?);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches a server subcommand.
|
||||||
|
async fn run_command(
|
||||||
|
remote: &cbd_cli::RemoteArgs,
|
||||||
|
command: ServerCommand,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match command {
|
||||||
|
ServerCommand::Guard(args) => cli::guard(args).await,
|
||||||
|
ServerCommand::Scan(args) => cli::scan(args).await,
|
||||||
|
ServerCommand::AudioDevices(args) => cli::audio_devices(args.device),
|
||||||
|
ServerCommand::Features => cli::features(),
|
||||||
|
ServerCommand::Library(cmd) => {
|
||||||
|
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await
|
||||||
|
}
|
||||||
|
ServerCommand::Queue(cmd) => {
|
||||||
|
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Queue(cmd)).await
|
||||||
|
}
|
||||||
|
ServerCommand::Global(cmd) => {
|
||||||
|
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Global(cmd)).await
|
||||||
|
}
|
||||||
|
ServerCommand::Completions(args) => {
|
||||||
|
cbd_cli::print_completions(args.shell, &mut ServerCli::command(), "crabidy-server");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Installs the global tracing subscriber.
|
/// Installs the global tracing subscriber.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
//! The `/orphans` provider: a store garbage-collection view.
|
||||||
|
//!
|
||||||
|
//! `/orphans` surfaces content-store entries that no mounted local file
|
||||||
|
//! provider references any more (see `architecture/orphans.md`). It is a
|
||||||
|
//! read-mostly management subtree: each orphan is presented as an editable,
|
||||||
|
//! deletable, queueable child node, so every client's existing `e`/`d`/queue
|
||||||
|
//! gestures work unchanged (no proto surface is added).
|
||||||
|
//!
|
||||||
|
//! The heavy lifting lives on [`CrabidyStore`] (it owns the store root and the
|
||||||
|
//! derived index); this provider only supplies the reference roots to walk and
|
||||||
|
//! translates between library paths and store names.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crabidy_core::{
|
||||||
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
||||||
|
ProviderClient, ProviderError,
|
||||||
|
};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::crabidy_store::{CrabidyStore, StoreError};
|
||||||
|
|
||||||
|
/// The single library segment this provider owns.
|
||||||
|
pub const ORPHANS_PROVIDER_ROOT: &str = "/orphans";
|
||||||
|
|
||||||
|
/// Maps a [`StoreError`] to the provider-boundary error: a missing entry or a
|
||||||
|
/// malformed name is a bad path, a taken/invalid rename target is bad input,
|
||||||
|
/// everything else is internal.
|
||||||
|
fn to_provider_error(err: StoreError) -> ProviderError {
|
||||||
|
match err {
|
||||||
|
StoreError::NotFound(_) => ProviderError::MalformedPath,
|
||||||
|
StoreError::NameTaken(_) | StoreError::InvalidName(_) => ProviderError::InvalidInput,
|
||||||
|
other => {
|
||||||
|
warn!("orphans store error: {other}");
|
||||||
|
ProviderError::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/orphans` provider.
|
||||||
|
///
|
||||||
|
/// Holds the store (for enumeration and mutation) and the disk roots of every
|
||||||
|
/// mounted local file provider (for the reference scan). Constructed in
|
||||||
|
/// [`crate::provider::ProviderOrchestrator::init`] only when the store is
|
||||||
|
/// present.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OrphansProvider {
|
||||||
|
/// The content store: enumerates orphans and performs rename/delete.
|
||||||
|
store: Arc<CrabidyStore>,
|
||||||
|
/// Disk roots to walk for `Playable::Store` references — the `/crabidy`
|
||||||
|
/// toml tree and (when enabled) the `/fs` root. "Referenced" is defined as
|
||||||
|
/// reachable through one of these (architecture/orphans.md).
|
||||||
|
ref_roots: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrphansProvider {
|
||||||
|
/// Builds the provider over `store`, scanning `ref_roots` for references.
|
||||||
|
pub fn new(store: Arc<CrabidyStore>, ref_roots: Vec<PathBuf>) -> Self {
|
||||||
|
Self { store, ref_roots }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bare store name addressed by an `/orphans/<seg>` path (its single
|
||||||
|
/// decoded segment); `None` for the bare root, a deeper path, or a segment
|
||||||
|
/// that is not a legal bare name (empty, `.`, `..`, or containing a
|
||||||
|
/// separator) — which keeps a crafted path from escaping the store root.
|
||||||
|
fn entry_name(&self, path: &str) -> Option<String> {
|
||||||
|
let rest = path
|
||||||
|
.strip_prefix(ORPHANS_PROVIDER_ROOT)?
|
||||||
|
.strip_prefix('/')?;
|
||||||
|
if rest.is_empty() || rest.contains('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = crabidy_core::decode_segment(rest);
|
||||||
|
if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library path of the orphan named `name`.
|
||||||
|
fn entry_path(name: &str) -> String {
|
||||||
|
crabidy_core::join_path(ORPHANS_PROVIDER_ROOT, &crabidy_core::encode_segment(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/orphans` root node, its children recomputed from the current
|
||||||
|
/// orphan set.
|
||||||
|
async fn root_node(&self) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let orphans = self
|
||||||
|
.store
|
||||||
|
.list_orphans(&self.ref_roots)
|
||||||
|
.await
|
||||||
|
.map_err(to_provider_error)?;
|
||||||
|
let children = orphans
|
||||||
|
.into_iter()
|
||||||
|
.map(|orphan| {
|
||||||
|
let mut child =
|
||||||
|
LibraryNodeChild::new(Self::entry_path(&orphan.name), orphan.name, true);
|
||||||
|
// Reuse the existing rename/delete/queue gestures: an orphan is
|
||||||
|
// an editable, deletable, queueable, already-captured child.
|
||||||
|
child.is_editable = true;
|
||||||
|
child.is_deletable = true;
|
||||||
|
child.is_downloadable = false;
|
||||||
|
child.is_captured = true;
|
||||||
|
child
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(LibraryNode {
|
||||||
|
path: ORPHANS_PROVIDER_ROOT.to_string(),
|
||||||
|
title: "orphans".to_string(),
|
||||||
|
children,
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single orphan's node: childless, queueable, carrying its one track.
|
||||||
|
async fn entry_node(&self, path: &str, name: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let track = self
|
||||||
|
.store
|
||||||
|
.orphan_track(name, path)
|
||||||
|
.await
|
||||||
|
.map_err(to_provider_error)?;
|
||||||
|
Ok(LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: name.to_string(),
|
||||||
|
children: Vec::new(),
|
||||||
|
parent: Some(ORPHANS_PROVIDER_ROOT.to_string()),
|
||||||
|
tracks: vec![track],
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderClient for OrphansProvider {
|
||||||
|
/// Not constructed from a config string — built by the orchestrator with a
|
||||||
|
/// store handle. Present only to satisfy the trait; never called.
|
||||||
|
async fn init(_raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(&self) -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always false: orphans are addressed as nodes. The one track per orphan
|
||||||
|
/// is reached by resolving the node (the default `resolve_tracks_into`
|
||||||
|
/// walk), not by a track path.
|
||||||
|
fn is_track_path(&self, _path: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an orphan's audio to its store file path (a local file, exactly
|
||||||
|
/// like a resolved `Playable::Store`), so the walked-out track still plays.
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
|
let name = self
|
||||||
|
.entry_name(track_path)
|
||||||
|
.ok_or(ProviderError::MalformedPath)?;
|
||||||
|
let url = self
|
||||||
|
.store
|
||||||
|
.orphan_url(&name)
|
||||||
|
.await
|
||||||
|
.map_err(to_provider_error)?;
|
||||||
|
Ok(vec![url])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track metadata for a single orphan (from the sidecar's first provider
|
||||||
|
/// entry); `is_captured` is true.
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
|
let name = self
|
||||||
|
.entry_name(track_path)
|
||||||
|
.ok_or(ProviderError::MalformedPath)?;
|
||||||
|
self.store
|
||||||
|
.orphan_track(&name, track_path)
|
||||||
|
.await
|
||||||
|
.map_err(to_provider_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal `/orphans` root; children are discovered via
|
||||||
|
/// [`Self::get_lib_node`] (listing needs async store access).
|
||||||
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
|
LibraryNode {
|
||||||
|
path: ORPHANS_PROVIDER_ROOT.to_string(),
|
||||||
|
title: "orphans".to_string(),
|
||||||
|
children: Vec::new(),
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/orphans` root lists one child per current orphan; an
|
||||||
|
/// `/orphans/<seg>` path returns that orphan's single-track node.
|
||||||
|
/// Recomputes the orphan set on every call (no cache).
|
||||||
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
if path == ORPHANS_PROVIDER_ROOT {
|
||||||
|
return self.root_node().await;
|
||||||
|
}
|
||||||
|
let name = self.entry_name(path).ok_or(ProviderError::MalformedPath)?;
|
||||||
|
self.entry_node(path, &name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root is not creatable.
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
_parent_path: &str,
|
||||||
|
_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
Err(ProviderError::NotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames an orphan's store files (audio + sidecar), keeping the index in
|
||||||
|
/// sync; returns the node at its new `/orphans/<encode(new)>` path.
|
||||||
|
async fn rename_lib_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let old = self.entry_name(path).ok_or(ProviderError::NotSupported)?;
|
||||||
|
self.store
|
||||||
|
.rename_orphan(&old, new_title)
|
||||||
|
.await
|
||||||
|
.map_err(to_provider_error)?;
|
||||||
|
let new_path = Self::entry_path(new_title.trim());
|
||||||
|
let name = self
|
||||||
|
.entry_name(&new_path)
|
||||||
|
.ok_or(ProviderError::InvalidInput)?;
|
||||||
|
self.entry_node(&new_path, &name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes an orphan's audio file and sidecar; returns the refreshed
|
||||||
|
/// `/orphans` root. Idempotent.
|
||||||
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let name = self.entry_name(path).ok_or(ProviderError::NotSupported)?;
|
||||||
|
self.store
|
||||||
|
.delete_orphan(&name)
|
||||||
|
.await
|
||||||
|
.map_err(to_provider_error)?;
|
||||||
|
self.root_node().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crabidy_store::{ProviderEntry, StoreSidecar};
|
||||||
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
async fn open_store() -> (Arc<CrabidyStore>, TempDir) {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let store = CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
|
||||||
|
.await
|
||||||
|
.expect("open store");
|
||||||
|
(Arc::new(store), dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a store entry (audio + sidecar) directly, bypassing capture.
|
||||||
|
async fn write_entry(store: &CrabidyStore, name: &str, hash: &str, id: &str, title: &str) {
|
||||||
|
let root = store.store_dir();
|
||||||
|
tokio::fs::write(root.join(name), b"AUDIO")
|
||||||
|
.await
|
||||||
|
.expect("audio");
|
||||||
|
let sidecar = StoreSidecar {
|
||||||
|
hash: hash.to_string(),
|
||||||
|
providers: vec![ProviderEntry {
|
||||||
|
provider: "tidal".to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
duration: Some(123),
|
||||||
|
album: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let text = toml::to_string_pretty(&sidecar).expect("ser");
|
||||||
|
tokio::fs::write(root.join(format!("{name}.cbd-store.toml")), text)
|
||||||
|
.await
|
||||||
|
.expect("sidecar");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a `.cbd-track.toml` under `dir` that references store `name`.
|
||||||
|
async fn write_store_reference(dir: &Path, file: &str, name: &str) {
|
||||||
|
tokio::fs::create_dir_all(dir).await.expect("mkdir");
|
||||||
|
let toml = format!("title = \"ref\"\n\n[playable]\nstore = \"{name}\"\n");
|
||||||
|
tokio::fs::write(dir.join(file), toml)
|
||||||
|
.await
|
||||||
|
.expect("ref toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn orphans_are_store_entries_no_reference_reaches() {
|
||||||
|
let (store, dir) = open_store().await;
|
||||||
|
write_entry(&store, "a.flac", "blake3:a", "1", "A").await;
|
||||||
|
write_entry(&store, "b.flac", "blake3:b", "2", "B").await;
|
||||||
|
// A reference under a scanned root rescues b.flac only.
|
||||||
|
let ref_root = dir.path().join("tree");
|
||||||
|
write_store_reference(&ref_root, "0001 b.cbd-track.toml", "b.flac").await;
|
||||||
|
|
||||||
|
let provider = OrphansProvider::new(store, vec![ref_root]);
|
||||||
|
let root = provider.get_lib_node("/orphans").await.expect("root");
|
||||||
|
let names: Vec<&str> = root.children.iter().map(|c| c.title.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["a.flac"], "only the unreferenced entry orphans");
|
||||||
|
let child = &root.children[0];
|
||||||
|
assert!(child.is_editable && child.is_deletable && child.is_queable && child.is_captured);
|
||||||
|
assert!(!child.is_downloadable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_leaf_carries_one_captured_track_that_resolves_to_store_audio() {
|
||||||
|
let (store, _dir) = open_store().await;
|
||||||
|
write_entry(&store, "song.flac", "blake3:s", "9", "Song").await;
|
||||||
|
let provider = OrphansProvider::new(store.clone(), vec![]);
|
||||||
|
|
||||||
|
let path = OrphansProvider::entry_path("song.flac");
|
||||||
|
let node = provider.get_lib_node(&path).await.expect("leaf");
|
||||||
|
assert!(node.children.is_empty());
|
||||||
|
assert_eq!(node.tracks.len(), 1);
|
||||||
|
assert!(node.tracks[0].is_captured);
|
||||||
|
assert_eq!(node.tracks[0].title, "Song");
|
||||||
|
let urls = provider.get_urls_for_track(&path).await.expect("urls");
|
||||||
|
let expected = store.store_dir().join("song.flac");
|
||||||
|
assert_eq!(urls, vec![expected.to_str().unwrap().to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_moves_both_files_and_delete_removes_them() {
|
||||||
|
let (store, _dir) = open_store().await;
|
||||||
|
write_entry(&store, "old.flac", "blake3:x", "7", "Old").await;
|
||||||
|
let provider = OrphansProvider::new(store.clone(), vec![]);
|
||||||
|
let path = OrphansProvider::entry_path("old.flac");
|
||||||
|
|
||||||
|
let renamed = provider
|
||||||
|
.rename_lib_node(&path, "new.flac")
|
||||||
|
.await
|
||||||
|
.expect("rename");
|
||||||
|
assert_eq!(renamed.title, "new.flac");
|
||||||
|
assert!(store.store_dir().join("new.flac").is_file());
|
||||||
|
assert!(!store.store_dir().join("old.flac").exists());
|
||||||
|
assert!(store.store_dir().join("new.flac.cbd-store.toml").is_file());
|
||||||
|
assert!(!store.store_dir().join("old.flac.cbd-store.toml").exists());
|
||||||
|
|
||||||
|
let new_path = OrphansProvider::entry_path("new.flac");
|
||||||
|
provider.delete_lib_node(&new_path).await.expect("delete");
|
||||||
|
assert!(!store.store_dir().join("new.flac").exists());
|
||||||
|
assert!(!store.store_dir().join("new.flac.cbd-store.toml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_and_traversal_paths_are_malformed() {
|
||||||
|
let (store, _dir) = open_store().await;
|
||||||
|
let provider = OrphansProvider::new(store, vec![]);
|
||||||
|
// A never-created entry.
|
||||||
|
assert!(matches!(
|
||||||
|
provider.get_lib_node("/orphans/ghost.flac").await,
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
));
|
||||||
|
// A traversal attempt decodes to a rejected segment.
|
||||||
|
assert!(provider.entry_name("/orphans/..%2Fetc").is_none());
|
||||||
|
assert!(provider.entry_name("/orphans/a/b").is_none());
|
||||||
|
assert!(provider.entry_name("/orphans").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
use crate::queue_store::{self, QueueSnapshot, QueueStore, SaveQueueError};
|
// Queue persistence and saved queues live in the content store, which the
|
||||||
|
// `fs` feature brings (architecture/build-features.md D5). Without it the
|
||||||
|
// queue is in memory only.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
use crate::capture::CaptureError;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
use crate::crabidy_store::{self, CrabidyStore, QueueSnapshot};
|
||||||
use crate::{PendingResolve, QueueManager, ResolveKind};
|
use crate::{PendingResolve, QueueManager, ResolveKind};
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use audio_player::Player;
|
use audio_player::Player;
|
||||||
|
|
@ -8,10 +14,14 @@ use crabidy_core::proto::crabidy::{
|
||||||
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
Queue as ProtoQueue, QueueTrack, Track, TrackPosition,
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
#[cfg(feature = "fs")]
|
||||||
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Instrument};
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
use tracing::info;
|
||||||
|
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
|
||||||
|
|
||||||
pub struct Playback {
|
pub struct Playback {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
|
|
@ -25,11 +35,13 @@ pub struct Playback {
|
||||||
/// touches this map (same single-writer discipline as `queue`).
|
/// touches this map (same single-writer discipline as `queue`).
|
||||||
pending: Mutex<HashMap<u64, PendingResolve>>,
|
pending: Mutex<HashMap<u64, PendingResolve>>,
|
||||||
next_op_id: AtomicU64,
|
next_op_id: AtomicU64,
|
||||||
/// `None` when queue persistence is disabled (no usable queues
|
/// `None` when queue persistence is disabled (no usable state
|
||||||
/// directory) — the queue then lives in memory only.
|
/// directory) — the queue then lives in memory only.
|
||||||
store: Option<Arc<QueueStore>>,
|
#[cfg(feature = "fs")]
|
||||||
|
store: Option<Arc<CrabidyStore>>,
|
||||||
/// Feeds the persister task; latest snapshot wins, so the loop never
|
/// Feeds the persister task; latest snapshot wins, so the loop never
|
||||||
/// waits on disk (architecture/queue-persistence.md D4).
|
/// waits on disk (architecture/queue-persistence.md D4).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
persist_tx: tokio::sync::watch::Sender<Option<QueueSnapshot>>,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
}
|
}
|
||||||
|
|
@ -38,13 +50,15 @@ impl Playback {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
store: Option<Arc<QueueStore>>,
|
#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>,
|
||||||
|
audio_device: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (playback_tx, playback_rx) = flume::bounded(64);
|
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||||
let queue = Mutex::new(QueueManager::new());
|
let queue = Mutex::new(QueueManager::new());
|
||||||
let state = Mutex::new(PlayState::Stopped);
|
let state = Mutex::new(PlayState::Stopped);
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
let (persist_tx, _) = tokio::sync::watch::channel(None);
|
||||||
let player = Player::default();
|
let player = Player::new(audio_device);
|
||||||
Self {
|
Self {
|
||||||
update_tx,
|
update_tx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
|
|
@ -54,7 +68,9 @@ impl Playback {
|
||||||
state,
|
state,
|
||||||
pending: Mutex::new(HashMap::new()),
|
pending: Mutex::new(HashMap::new()),
|
||||||
next_op_id: AtomicU64::new(0),
|
next_op_id: AtomicU64::new(0),
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
store,
|
store,
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
persist_tx,
|
persist_tx,
|
||||||
player,
|
player,
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +80,7 @@ impl Playback {
|
||||||
/// shuffle/repeat. Never starts playback — a restarted server stays
|
/// shuffle/repeat. Never starts playback — a restarted server stays
|
||||||
/// silent. Call before [`Self::run`] so nothing observes the empty
|
/// silent. Call before [`Self::run`] so nothing observes the empty
|
||||||
/// queue first.
|
/// queue first.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
pub async fn restore_current(&self) {
|
pub async fn restore_current(&self) {
|
||||||
let Some(store) = &self.store else {
|
let Some(store) = &self.store else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -95,8 +112,9 @@ impl Playback {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
if let Some(store) = &self.store {
|
if let Some(store) = &self.store {
|
||||||
queue_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
crabidy_store::spawn_persister(Arc::clone(store), self.persist_tx.subscribe());
|
||||||
}
|
}
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
||||||
|
|
@ -147,6 +165,9 @@ impl Playback {
|
||||||
repeat: queue.repeat,
|
repeat: queue.repeat,
|
||||||
shuffle: queue.shuffle,
|
shuffle: queue.shuffle,
|
||||||
}),
|
}),
|
||||||
|
// Stamped by the RPC handler, which owns the auth
|
||||||
|
// switch; the playback loop only knows queue state.
|
||||||
|
auth_enabled: false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
trace!(?response, "sending init response");
|
trace!(?response, "sending init response");
|
||||||
|
|
@ -252,6 +273,7 @@ impl Playback {
|
||||||
self.play(track).await;
|
self.play(track).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
PlaybackCommand::SaveQueue { name, result_tx } => {
|
PlaybackCommand::SaveQueue { name, result_tx } => {
|
||||||
debug!(name, "saving the queue");
|
debug!(name, "saving the queue");
|
||||||
// Snapshot on the loop (single-writer discipline), write on
|
// Snapshot on the loop (single-writer discipline), write on
|
||||||
|
|
@ -273,8 +295,8 @@ impl Playback {
|
||||||
tokio::spawn(
|
tokio::spawn(
|
||||||
async move {
|
async move {
|
||||||
let result = match &store {
|
let result = match &store {
|
||||||
Some(store) => store.save(&name, &snapshot).await,
|
Some(store) => store.save_snapshot(&name, &snapshot.tracks).await,
|
||||||
None => Err(SaveQueueError::Disabled),
|
None => Err(CaptureError::Disabled),
|
||||||
};
|
};
|
||||||
if let Err(err) = &result {
|
if let Err(err) = &result {
|
||||||
warn!(name, "cannot save queue: {err}");
|
warn!(name, "cannot save queue: {err}");
|
||||||
|
|
@ -326,14 +348,27 @@ impl Playback {
|
||||||
*state
|
*state
|
||||||
};
|
};
|
||||||
debug!(?state, "toggling play");
|
debug!(?state, "toggling play");
|
||||||
if state == PlayState::Playing {
|
match state {
|
||||||
|
PlayState::Playing => {
|
||||||
if let Err(err) = self.player.pause().await {
|
if let Err(err) = self.player.pause().await {
|
||||||
warn!("pause failed: {err:?}");
|
warn!("pause failed: {err:?}");
|
||||||
}
|
}
|
||||||
} else if let Err(err) = self.player.unpause().await {
|
}
|
||||||
|
PlayState::Paused => {
|
||||||
|
if let Err(err) = self.player.unpause().await {
|
||||||
warn!("unpause failed: {err:?}");
|
warn!("unpause failed: {err:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stopped/idle: nothing is loaded in the player (e.g. right
|
||||||
|
// after a restart restored the queue without autoplay), so
|
||||||
|
// there is nothing to unpause. Load and play the current
|
||||||
|
// queue track, exactly as `SetCurrent` does — a no-op when
|
||||||
|
// the queue is empty.
|
||||||
|
_ => {
|
||||||
|
self.play(self.current_track()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackCommand::Stop => {
|
PlaybackCommand::Stop => {
|
||||||
debug!("stopping playback");
|
debug!("stopping playback");
|
||||||
|
|
@ -405,9 +440,24 @@ impl Playback {
|
||||||
|
|
||||||
PlaybackCommand::RestartTrack => {
|
PlaybackCommand::RestartTrack => {
|
||||||
debug!("restarting current track");
|
debug!("restarting current track");
|
||||||
|
let state = {
|
||||||
|
let Ok(state) = self.state.lock() else {
|
||||||
|
error!("play state lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
*state
|
||||||
|
};
|
||||||
|
// With a track loaded (playing or paused) restart it from the
|
||||||
|
// top. When nothing is loaded (queue restored without autoplay)
|
||||||
|
// there is no source to restart, so start the current queue
|
||||||
|
// track instead.
|
||||||
|
if matches!(state, PlayState::Playing | PlayState::Paused) {
|
||||||
if let Err(err) = self.player.restart().await {
|
if let Err(err) = self.player.restart().await {
|
||||||
warn!("restart failed: {err:?}");
|
warn!("restart failed: {err:?}");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.play(self.current_track()).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackCommand::VolumeChanged { volume } => {
|
PlaybackCommand::VolumeChanged { volume } => {
|
||||||
|
|
@ -437,17 +487,25 @@ impl Playback {
|
||||||
|
|
||||||
/// Registers a pending resolve operation and spawns its forwarder task.
|
/// Registers a pending resolve operation and spawns its forwarder task.
|
||||||
///
|
///
|
||||||
/// The forwarder resolves `paths` one after the other (preserving the
|
/// The forwarder resolves `paths` with an *exponential read-ahead*: a
|
||||||
/// request's path order): for each path it sends
|
/// concurrency window that starts at 1 and doubles (1, 2, 4, 8, 16, then
|
||||||
/// `ProviderCommand::ResolveTracks` with a fresh bounded chunk channel
|
/// steady 16) after each path completes. It keeps up to `window` paths
|
||||||
/// and forwards every chunk to the playback loop as
|
/// resolving at once (each via `ProviderCommand::ResolveTracks` on its own
|
||||||
/// `PlaybackCommand::ApplyResolvedChunk`; after the last path it sends
|
/// bounded chunk channel) but forwards chunks to the playback loop as
|
||||||
/// `ResolveFinished`. When the op's cancellation flag is set, the
|
/// `PlaybackCommand::ApplyResolvedChunk` in strict path order, so the queue
|
||||||
/// forwarder drops the chunk receiver instead — the provider's next
|
/// keeps the requested order and the first chunk still lands — and starts
|
||||||
/// send fails and the fetch stops. Queue state is never touched here:
|
/// playback — as fast as a single resolve. The first track therefore plays
|
||||||
/// mutations happen only when the loop processes the forwarded
|
/// as soon as one resolve yields it, while the window fills the queue far
|
||||||
/// commands. An immediate `Queue` broadcast (unchanged tracks,
|
/// ahead of playback: a very short or skipped leading track cannot outrun
|
||||||
/// `resolving = true`) gives clients instant feedback.
|
/// resolution and cause a silent stall (architecture/progressive-queueing.md D8).
|
||||||
|
///
|
||||||
|
/// After the last path it sends `ResolveFinished`. When the op's
|
||||||
|
/// cancellation flag is set, the forwarder stops launching resolves and
|
||||||
|
/// drops its chunk receivers — each provider's next send fails and the
|
||||||
|
/// fetch stops. Queue state is never touched here: mutations happen only
|
||||||
|
/// when the loop processes the forwarded commands. An immediate `Queue`
|
||||||
|
/// broadcast (unchanged tracks, `resolving = true`) gives clients instant
|
||||||
|
/// feedback.
|
||||||
fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) {
|
fn start_resolve(&self, kind: ResolveKind, paths: Vec<String>) {
|
||||||
let op = PendingResolve::new(kind);
|
let op = PendingResolve::new(kind);
|
||||||
let cancelled = op.cancel_flag();
|
let cancelled = op.cancel_flag();
|
||||||
|
|
@ -473,10 +531,21 @@ impl Playback {
|
||||||
let playback_tx = self.playback_tx.clone();
|
let playback_tx = self.playback_tx.clone();
|
||||||
tokio::spawn(
|
tokio::spawn(
|
||||||
async move {
|
async move {
|
||||||
for path in &paths {
|
// Exponential read-ahead window: 1, 2, 4, 8, 16, then steady.
|
||||||
|
const MAX_WINDOW: usize = 16;
|
||||||
|
let mut window = 1usize;
|
||||||
|
let mut next = 0usize;
|
||||||
|
let mut inflight: VecDeque<(String, flume::Receiver<Vec<Track>>)> = VecDeque::new();
|
||||||
|
'resolve: loop {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Top the window up with fresh concurrent resolves. Each
|
||||||
|
// resolves in the background into its own bounded channel
|
||||||
|
// while we drain the oldest one below.
|
||||||
|
while inflight.len() < window && next < paths.len() {
|
||||||
|
let path = paths[next].clone();
|
||||||
|
next += 1;
|
||||||
let (chunk_tx, chunk_rx) = flume::bounded(4);
|
let (chunk_tx, chunk_rx) = flume::bounded(4);
|
||||||
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
|
@ -484,15 +553,23 @@ impl Playback {
|
||||||
});
|
});
|
||||||
if provider_tx.send_async(message).await.is_err() {
|
if provider_tx.send_async(message).await.is_err() {
|
||||||
error!("provider channel closed");
|
error!("provider channel closed");
|
||||||
break;
|
break 'resolve;
|
||||||
}
|
}
|
||||||
|
inflight.push_back((path, chunk_rx));
|
||||||
|
}
|
||||||
|
// Drain the oldest (lowest path index) resolve to
|
||||||
|
// completion before the next, so chunks reach the loop in
|
||||||
|
// request order even though resolution ran concurrently.
|
||||||
|
let Some((path, chunk_rx)) = inflight.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
let mut forwarded = 0usize;
|
let mut forwarded = 0usize;
|
||||||
while let Ok(tracks) = chunk_rx.recv_async().await {
|
while let Ok(tracks) = chunk_rx.recv_async().await {
|
||||||
// On cancellation this loop exits and drops
|
// On cancellation this drops chunk_rx (and, on the next
|
||||||
// chunk_rx; the provider's next send fails and the
|
// iteration, the rest of `inflight`); each provider's
|
||||||
// fetch stops.
|
// next send then fails and its fetch stops.
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
break;
|
break 'resolve;
|
||||||
}
|
}
|
||||||
forwarded += tracks.len();
|
forwarded += tracks.len();
|
||||||
let apply = PlaybackCommand::ApplyResolvedChunk { op_id, tracks };
|
let apply = PlaybackCommand::ApplyResolvedChunk { op_id, tracks };
|
||||||
|
|
@ -508,6 +585,7 @@ impl Playback {
|
||||||
if forwarded == 0 && !cancelled.load(Ordering::Relaxed) {
|
if forwarded == 0 && !cancelled.load(Ordering::Relaxed) {
|
||||||
warn!(path, "path resolved to no playable tracks");
|
warn!(path, "path resolved to no playable tracks");
|
||||||
}
|
}
|
||||||
|
window = (window * 2).min(MAX_WINDOW);
|
||||||
}
|
}
|
||||||
// Always reported — also for cancelled or empty ops — so
|
// Always reported — also for cancelled or empty ops — so
|
||||||
// the pending map can never leak a stuck resolving flag.
|
// the pending map can never leak a stuck resolving flag.
|
||||||
|
|
@ -520,15 +598,17 @@ impl Playback {
|
||||||
|
|
||||||
/// Applies one chunk to the queue for pending op `op_id`, broadcasts
|
/// Applies one chunk to the queue for pending op `op_id`, broadcasts
|
||||||
/// the grown queue, and starts playback when the chunk made a track
|
/// the grown queue, and starts playback when the chunk made a track
|
||||||
/// current. Chunks for an unknown op id (finished or cancelled) are
|
/// current — or when this op still wants to start but its earlier attempt
|
||||||
/// dropped silently.
|
/// ran the player dry on skipped/short head tracks before more resolved.
|
||||||
|
/// Chunks for an unknown op id (finished or cancelled) are dropped
|
||||||
|
/// silently.
|
||||||
async fn apply_resolved_chunk(&self, op_id: u64, tracks: Vec<Track>) {
|
async fn apply_resolved_chunk(&self, op_id: u64, tracks: Vec<Track>) {
|
||||||
let track = {
|
let attempt = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
error!("queue lock poisoned");
|
error!("queue lock poisoned");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let track = {
|
let (designated, wants_start) = {
|
||||||
let Ok(mut pending) = self.pending.lock() else {
|
let Ok(mut pending) = self.pending.lock() else {
|
||||||
error!("pending ops lock poisoned");
|
error!("pending ops lock poisoned");
|
||||||
return;
|
return;
|
||||||
|
|
@ -537,12 +617,32 @@ impl Playback {
|
||||||
trace!(op_id, "dropping chunk for a finished or cancelled op");
|
trace!(op_id, "dropping chunk for a finished or cancelled op");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
op.apply_chunk(&mut queue, &tracks)
|
let designated = op.apply_chunk(&mut queue, &tracks);
|
||||||
|
(designated, op.wants_start())
|
||||||
};
|
};
|
||||||
self.broadcast_queue(&queue);
|
self.broadcast_queue(&queue);
|
||||||
track
|
// Start on the track this chunk made current; failing that, if the
|
||||||
|
// op still wants to start (a prior attempt found nothing playable
|
||||||
|
// yet), retry from the current position — `next_playable_urls`
|
||||||
|
// advances past skipped/unplayable heads to the first track that
|
||||||
|
// has now resolved.
|
||||||
|
designated.or_else(|| {
|
||||||
|
if wants_start {
|
||||||
|
queue.current_track()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
};
|
};
|
||||||
self.play_if_some(track).await;
|
// Confirming the start clears the op's `wants_start`, so later chunks
|
||||||
|
// only extend the queue and never restart the playing track.
|
||||||
|
if self.play(attempt).await {
|
||||||
|
if let Ok(mut pending) = self.pending.lock() {
|
||||||
|
if let Some(op) = pending.get_mut(&op_id) {
|
||||||
|
op.mark_started();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the finished op and broadcasts the final `Queue` snapshot
|
/// Removes the finished op and broadcasts the final `Queue` snapshot
|
||||||
|
|
@ -610,6 +710,12 @@ impl Playback {
|
||||||
/// when persistence is disabled. Latest snapshot wins, so calling this
|
/// when persistence is disabled. Latest snapshot wins, so calling this
|
||||||
/// on every mutation is free of backpressure (the persister skips
|
/// on every mutation is free of backpressure (the persister skips
|
||||||
/// writes for unchanged snapshots).
|
/// writes for unchanged snapshots).
|
||||||
|
#[cfg(not(feature = "fs"))]
|
||||||
|
fn send_persist_snapshot(&self, _queue: &QueueManager) {}
|
||||||
|
|
||||||
|
/// Hands the queue's persistable state to the persister task; a no-op
|
||||||
|
/// when persistence is disabled.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
fn send_persist_snapshot(&self, queue: &QueueManager) {
|
fn send_persist_snapshot(&self, queue: &QueueManager) {
|
||||||
if self.store.is_none() {
|
if self.store.is_none() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -646,6 +752,19 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The queue's current track, or `None` when the queue is empty (or its
|
||||||
|
/// lock is poisoned). The resume-style controls (`TogglePlay`,
|
||||||
|
/// `RestartTrack`) use it to load what a restored/idle queue points at.
|
||||||
|
fn current_track(&self) -> Option<Track> {
|
||||||
|
match self.queue.lock() {
|
||||||
|
Ok(queue) => queue.current_track(),
|
||||||
|
Err(_) => {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Plays the given track if there is one, otherwise stops the player.
|
/// Plays the given track if there is one, otherwise stops the player.
|
||||||
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
||||||
async fn play_or_stop(&self, track: Option<Track>) {
|
async fn play_or_stop(&self, track: Option<Track>) {
|
||||||
|
|
@ -656,13 +775,6 @@ impl Playback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays the given track if there is one; does nothing otherwise.
|
|
||||||
async fn play_if_some(&self, track: Option<Track>) {
|
|
||||||
if track.is_some() {
|
|
||||||
self.play(track).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds the stream URLs of the first playable track, starting at
|
/// Finds the stream URLs of the first playable track, starting at
|
||||||
/// `track` and advancing the queue past unplayable ones. Tracks marked
|
/// `track` and advancing the queue past unplayable ones. Tracks marked
|
||||||
/// `is_skipped` (captures recorded their source as uncapturable) are
|
/// `is_skipped` (captures recorded their source as uncapturable) are
|
||||||
|
|
@ -715,20 +827,26 @@ impl Playback {
|
||||||
/// Starts playback of the given track, skipping past unplayable ones
|
/// Starts playback of the given track, skipping past unplayable ones
|
||||||
/// (see [`Self::next_playable_urls`]); stops the player when nothing
|
/// (see [`Self::next_playable_urls`]); stops the player when nothing
|
||||||
/// in the queue is playable.
|
/// in the queue is playable.
|
||||||
|
///
|
||||||
|
/// Returns `true` when a playable track was found and handed to the
|
||||||
|
/// player. A device error while starting is logged but still counts as
|
||||||
|
/// started — retrying the same track would not help. Returns `false` when
|
||||||
|
/// there was nothing to play or nothing playable remained (the player is
|
||||||
|
/// then stopped), so the caller can retry once more tracks resolve.
|
||||||
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
||||||
async fn play(&self, track: Option<Track>) {
|
async fn play(&self, track: Option<Track>) -> bool {
|
||||||
let Some(track) = track else {
|
let Some(track) = track else {
|
||||||
debug!("nothing to play");
|
debug!("nothing to play");
|
||||||
return;
|
return false;
|
||||||
};
|
};
|
||||||
let Some(urls) = self.next_playable_urls(track).await else {
|
let Some(urls) = self.next_playable_urls(track).await else {
|
||||||
self.stop_player().await;
|
self.stop_player().await;
|
||||||
return;
|
return false;
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let Ok(queue) = self.queue.lock() else {
|
let Ok(queue) = self.queue.lock() else {
|
||||||
error!("queue lock poisoned");
|
error!("queue lock poisoned");
|
||||||
return;
|
return false;
|
||||||
};
|
};
|
||||||
// Current-track moves (Next/Prev/SetCurrent/skips) change the
|
// Current-track moves (Next/Prev/SetCurrent/skips) change the
|
||||||
// persisted position without a queue broadcast.
|
// persisted position without a queue broadcast.
|
||||||
|
|
@ -742,12 +860,14 @@ impl Playback {
|
||||||
if let Err(err) = self.player.play(&urls[0]).await {
|
if let Err(err) = self.player.play(&urls[0]).await {
|
||||||
error!("player failed to start track: {err:?}");
|
error!("player failed to start track: {err:?}");
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn track(i: usize) -> Track {
|
fn track(i: usize) -> Track {
|
||||||
|
|
@ -758,29 +878,40 @@ mod tests {
|
||||||
duration: None,
|
duration: None,
|
||||||
album: None,
|
album: None,
|
||||||
is_skipped: false,
|
is_skipped: false,
|
||||||
|
provider_item_id: String::new(),
|
||||||
|
is_captured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn store_in(dir: &TempDir) -> Arc<QueueStore> {
|
#[cfg(feature = "fs")]
|
||||||
|
async fn store_in(dir: &TempDir) -> Arc<CrabidyStore> {
|
||||||
Arc::new(
|
Arc::new(
|
||||||
QueueStore::open(dir.path().join("queues"))
|
CrabidyStore::open(dir.path().join("state"), dir.path().join("store"))
|
||||||
.await
|
.await
|
||||||
.expect("open store"),
|
.expect("open store"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_with(store: Option<Arc<QueueStore>>) -> Playback {
|
fn playback_with(#[cfg(feature = "fs")] store: Option<Arc<CrabidyStore>>) -> Playback {
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(64);
|
let (update_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
let (provider_tx, _provider_rx) = flume::bounded(16);
|
let (provider_tx, _provider_rx) = flume::bounded(16);
|
||||||
Playback::new(update_tx, provider_tx, store)
|
Playback::new(
|
||||||
|
update_tx,
|
||||||
|
provider_tx,
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
store,
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
fn fill_queue(playback: &Playback, n: usize) {
|
fn fill_queue(playback: &Playback, n: usize) {
|
||||||
let tracks: Vec<Track> = (0..n).map(track).collect();
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
let mut queue = playback.queue.lock().expect("queue lock");
|
let mut queue = playback.queue.lock().expect("queue lock");
|
||||||
let _ = queue.replace_with_tracks(&tracks);
|
let _ = queue.replace_with_tracks(&tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restore_fills_the_queue_without_starting_playback() {
|
async fn restore_fills_the_queue_without_starting_playback() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -810,6 +941,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restore_survives_an_out_of_range_position() {
|
async fn restore_survives_an_out_of_range_position() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -829,6 +961,7 @@ mod tests {
|
||||||
assert_eq!(queue.current_position(), 0);
|
assert_eq!(queue.current_position(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn save_queue_command_snapshots_the_live_queue() {
|
async fn save_queue_command_snapshots_the_live_queue() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -849,7 +982,7 @@ mod tests {
|
||||||
.expect("reply")
|
.expect("reply")
|
||||||
.expect("save succeeds");
|
.expect("save succeeds");
|
||||||
|
|
||||||
let entries = std::fs::read_dir(store.dir().join("road trip"))
|
let entries = std::fs::read_dir(store.tree_dir().join("road trip"))
|
||||||
.expect("saved queue folder")
|
.expect("saved queue folder")
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
!e.as_ref()
|
!e.as_ref()
|
||||||
|
|
@ -862,6 +995,7 @@ mod tests {
|
||||||
assert_eq!(entries, 2);
|
assert_eq!(entries, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn save_queue_rejects_an_empty_queue() {
|
async fn save_queue_rejects_an_empty_queue() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
@ -874,7 +1008,7 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let result = result_rx.recv_async().await.expect("reply");
|
let result = result_rx.recv_async().await.expect("reply");
|
||||||
assert!(matches!(result, Err(SaveQueueError::EmptyQueue)));
|
assert!(matches!(result, Err(CaptureError::BadSource(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -884,7 +1018,10 @@ mod tests {
|
||||||
// with `None`. The marked tracks are skipped without any provider
|
// with `None`. The marked tracks are skipped without any provider
|
||||||
// round trip (the provider channel is closed — a call would fail,
|
// round trip (the provider channel is closed — a call would fail,
|
||||||
// not hang).
|
// not hang).
|
||||||
let playback = playback_with(None);
|
let playback = playback_with(
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
None,
|
||||||
|
);
|
||||||
let tracks: Vec<Track> = (0..3)
|
let tracks: Vec<Track> = (0..3)
|
||||||
.map(|i| Track {
|
.map(|i| Track {
|
||||||
is_skipped: true,
|
is_skipped: true,
|
||||||
|
|
@ -903,6 +1040,7 @@ mod tests {
|
||||||
assert!(urls.is_none(), "an all-skipped queue has nothing playable");
|
assert!(urls.is_none(), "an all-skipped queue has nothing playable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn queue_mutations_reach_the_persist_channel() {
|
async fn queue_mutations_reach_the_persist_channel() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,472 +0,0 @@
|
||||||
//! Persisted queues on disk (see `architecture/queue-persistence.md`).
|
|
||||||
//!
|
|
||||||
//! Every queue is a folder under the store directory
|
|
||||||
//! (`<config>/crabidy/queues/`) holding one order-prefixed
|
|
||||||
//! `*.cbd-track.toml` **link** file per entry, plus a hidden
|
|
||||||
//! [`STATE_FILE_NAME`] sidecar. The automatically maintained queue lives in
|
|
||||||
//! [`CURRENT_QUEUE_NAME`]; every other folder is a named save. The same
|
|
||||||
//! directory is mounted read-only into the library as `/queues` by a second
|
|
||||||
//! `fsdy` instance — this module is the only writer.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::Track;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::{debug, warn};
|
|
||||||
|
|
||||||
/// The library mount point of the queues directory (second `fsdy`
|
|
||||||
/// instance, see `architecture/queue-persistence.md` D1).
|
|
||||||
pub const QUEUES_PROVIDER_ROOT: &str = "/queues";
|
|
||||||
|
|
||||||
/// Reserved folder name of the automatically maintained queue.
|
|
||||||
pub const CURRENT_QUEUE_NAME: &str = "current";
|
|
||||||
|
|
||||||
/// Hidden per-queue sidecar carrying [`QueueState`]. Dot-prefixed, so
|
|
||||||
/// library listings never show it.
|
|
||||||
pub const STATE_FILE_NAME: &str = ".queue-state.toml";
|
|
||||||
|
|
||||||
/// The queues directory: `queues/` inside the crabidy config directory.
|
|
||||||
/// `None` when the platform has no config directory.
|
|
||||||
pub fn queues_dir() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|d| d.join("crabidy").join("queues"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the playback loop knows about the queue that is worth
|
|
||||||
/// persisting. Sent through the persister's `watch` channel (latest wins)
|
|
||||||
/// and written by [`QueueStore`].
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct QueueSnapshot {
|
|
||||||
/// Queue entries in track order (not play order — shuffle order is
|
|
||||||
/// deliberately not persisted).
|
|
||||||
pub tracks: Vec<Track>,
|
|
||||||
/// Index of the current track in `tracks`.
|
|
||||||
pub current_position: u32,
|
|
||||||
pub repeat: bool,
|
|
||||||
pub shuffle: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The on-disk schema of the [`STATE_FILE_NAME`] sidecar.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
|
||||||
pub struct QueueState {
|
|
||||||
pub current_position: u32,
|
|
||||||
pub repeat: bool,
|
|
||||||
pub shuffle: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors from validating or writing a persisted queue.
|
|
||||||
///
|
|
||||||
/// At the RPC boundary: `InvalidName` → `invalid_argument`, `EmptyQueue` →
|
|
||||||
/// `failed_precondition`, the rest → `internal`. Messages carry names and
|
|
||||||
/// paths, never file contents.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum SaveQueueError {
|
|
||||||
#[error("invalid queue name: {0}")]
|
|
||||||
InvalidName(&'static str),
|
|
||||||
#[error("the queue is empty")]
|
|
||||||
EmptyQueue,
|
|
||||||
#[error("queue persistence is disabled")]
|
|
||||||
Disabled,
|
|
||||||
#[error("cannot write queue: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
TrackFile(#[from] fsdy::TrackFileError),
|
|
||||||
#[error("cannot serialize queue state: {0}")]
|
|
||||||
State(#[from] toml::ser::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads and writes persisted queue folders. Cheap to clone behind an
|
|
||||||
/// `Arc`; all I/O is `tokio::fs`.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct QueueStore {
|
|
||||||
dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl QueueStore {
|
|
||||||
/// Opens the store at `dir`, creating the directory (and parents) if
|
|
||||||
/// missing.
|
|
||||||
pub async fn open(dir: PathBuf) -> Result<Self, std::io::Error> {
|
|
||||||
tokio::fs::create_dir_all(&dir).await?;
|
|
||||||
Ok(Self { dir })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The store directory (what the `/queues` provider instance mounts).
|
|
||||||
pub fn dir(&self) -> &Path {
|
|
||||||
&self.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates a user-supplied queue name, returning the trimmed name.
|
|
||||||
///
|
|
||||||
/// Rejected: empty after trimming, containing `/`, `\` or NUL, starting
|
|
||||||
/// with a dot (hidden folders are invisible to listings), and the
|
|
||||||
/// reserved [`CURRENT_QUEUE_NAME`].
|
|
||||||
pub fn validate_name(name: &str) -> Result<&str, SaveQueueError> {
|
|
||||||
// The shared fs-provider naming rules, with the auto-persisted
|
|
||||||
// queue's folder reserved.
|
|
||||||
fsdy::validate_folder_name(name, &[CURRENT_QUEUE_NAME]).map_err(SaveQueueError::InvalidName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saves `snapshot` as the named queue, overwriting an existing one.
|
|
||||||
///
|
|
||||||
/// Validates `name` per [`Self::validate_name`] and rejects an empty
|
|
||||||
/// snapshot with [`SaveQueueError::EmptyQueue`]. The folder is written
|
|
||||||
/// to a hidden temp sibling first, then swapped into place (remove old,
|
|
||||||
/// rename) — a crash can lose the folder, never corrupt it half-written
|
|
||||||
/// next to intact files.
|
|
||||||
pub async fn save(&self, name: &str, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
|
||||||
let name = Self::validate_name(name)?;
|
|
||||||
if snapshot.tracks.is_empty() {
|
|
||||||
return Err(SaveQueueError::EmptyQueue);
|
|
||||||
}
|
|
||||||
self.write_queue_dir(name, snapshot).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persists `snapshot` as the current queue ([`CURRENT_QUEUE_NAME`]).
|
|
||||||
///
|
|
||||||
/// Same write path as [`Self::save`] but without name validation and
|
|
||||||
/// with an empty snapshot allowed — clearing the queue must persist as
|
|
||||||
/// cleared.
|
|
||||||
pub async fn persist_current(&self, snapshot: &QueueSnapshot) -> Result<(), SaveQueueError> {
|
|
||||||
self.write_queue_dir(CURRENT_QUEUE_NAME, snapshot).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shared write path: build the whole folder as a hidden temp
|
|
||||||
/// sibling, then swap it into place (remove old, rename). A crash can
|
|
||||||
/// lose the folder, never leave it half-written next to intact files
|
|
||||||
/// (architecture/queue-persistence.md D3).
|
|
||||||
async fn write_queue_dir(
|
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
snapshot: &QueueSnapshot,
|
|
||||||
) -> Result<(), SaveQueueError> {
|
|
||||||
let tmp = self.dir.join(format!(".tmp-{name}"));
|
|
||||||
// A leftover temp folder from a crashed or racing write is stale.
|
|
||||||
if tokio::fs::try_exists(&tmp).await? {
|
|
||||||
tokio::fs::remove_dir_all(&tmp).await?;
|
|
||||||
}
|
|
||||||
tokio::fs::create_dir_all(&tmp).await?;
|
|
||||||
for (index, track) in snapshot.tracks.iter().enumerate() {
|
|
||||||
let text = fsdy::TrackFile::from_track(track).to_toml()?;
|
|
||||||
let file = tmp.join(fsdy::track_file_name(index, &track.title));
|
|
||||||
tokio::fs::write(file, text).await?;
|
|
||||||
}
|
|
||||||
let state = QueueState {
|
|
||||||
current_position: snapshot.current_position,
|
|
||||||
repeat: snapshot.repeat,
|
|
||||||
shuffle: snapshot.shuffle,
|
|
||||||
};
|
|
||||||
tokio::fs::write(tmp.join(STATE_FILE_NAME), toml::to_string_pretty(&state)?).await?;
|
|
||||||
|
|
||||||
let target = self.dir.join(name);
|
|
||||||
if tokio::fs::try_exists(&target).await? {
|
|
||||||
tokio::fs::remove_dir_all(&target).await?;
|
|
||||||
}
|
|
||||||
tokio::fs::rename(&tmp, &target).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the persisted current queue for the startup restore.
|
|
||||||
///
|
|
||||||
/// Reads the folder like a library listing (sorted case-insensitively,
|
|
||||||
/// broken/hidden/foreign entries skipped with warnings) plus the
|
|
||||||
/// [`QueueState`] sidecar (missing or broken sidecar → default state).
|
|
||||||
/// `None` when the folder does not exist — a fresh start. Never fails
|
|
||||||
/// the server; every defect is a warning and degrades to less state.
|
|
||||||
pub async fn load_current(&self) -> Option<QueueSnapshot> {
|
|
||||||
let dir = self.dir.join(CURRENT_QUEUE_NAME);
|
|
||||||
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
|
||||||
Ok(read_dir) => read_dir,
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(dir = %dir.display(), "cannot read the persisted queue: {err}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mirror the provider listing: visible regular `*.cbd-track.toml`
|
|
||||||
// files, sorted case-insensitively — restore order == listing order.
|
|
||||||
let mut names: Vec<String> = Vec::new();
|
|
||||||
loop {
|
|
||||||
let entry = match read_dir.next_entry().await {
|
|
||||||
Ok(Some(entry)) => entry,
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(dir = %dir.display(), "error while reading the persisted queue: {err}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let is_file = entry
|
|
||||||
.file_type()
|
|
||||||
.await
|
|
||||||
.is_ok_and(|file_type| file_type.is_file());
|
|
||||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
|
||||||
warn!(dir = %dir.display(), "skipping queue entry with non-UTF-8 name");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if is_file && !name.starts_with('.') && name.ends_with(fsdy::TRACK_FILE_SUFFIX) {
|
|
||||||
names.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
names.sort_by_key(|name| name.to_lowercase());
|
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
|
||||||
for name in names {
|
|
||||||
let file = dir.join(&name);
|
|
||||||
let text = match tokio::fs::read_to_string(&file).await {
|
|
||||||
Ok(text) => text,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(file = %file.display(), "cannot read queue entry: {err}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match fsdy::TrackFile::parse(&text) {
|
|
||||||
Ok(track_file) => {
|
|
||||||
// The same library path the /queues listing would give
|
|
||||||
// the entry, so non-link playables behave identically.
|
|
||||||
let lib_path = crabidy_core::join_path(
|
|
||||||
&crabidy_core::join_path(QUEUES_PROVIDER_ROOT, CURRENT_QUEUE_NAME),
|
|
||||||
&crabidy_core::encode_segment(&name),
|
|
||||||
);
|
|
||||||
tracks.push(track_file.to_track(&lib_path));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(file = %file.display(), "skipping invalid queue entry: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let state_file = dir.join(STATE_FILE_NAME);
|
|
||||||
let state = match tokio::fs::read_to_string(&state_file).await {
|
|
||||||
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
|
|
||||||
warn!(file = %state_file.display(), "broken queue state, using defaults: {err}");
|
|
||||||
QueueState::default()
|
|
||||||
}),
|
|
||||||
Err(err) => {
|
|
||||||
debug!(file = %state_file.display(), "no queue state, using defaults: {err}");
|
|
||||||
QueueState::default()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(QueueSnapshot {
|
|
||||||
tracks,
|
|
||||||
current_position: state.current_position,
|
|
||||||
repeat: state.repeat,
|
|
||||||
shuffle: state.shuffle,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the auto-persist task: awaits snapshot changes on `rx`, debounces
|
|
||||||
/// briefly to coalesce bursts (resolve chunks), skips writes whose snapshot
|
|
||||||
/// equals the last one written, and rewrites the current queue folder.
|
|
||||||
/// Write failures are warnings; the task never affects playback. Exits when
|
|
||||||
/// the sender side is dropped.
|
|
||||||
pub fn spawn_persister(
|
|
||||||
store: Arc<QueueStore>,
|
|
||||||
mut rx: tokio::sync::watch::Receiver<Option<QueueSnapshot>>,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut last_written: Option<QueueSnapshot> = None;
|
|
||||||
while rx.changed().await.is_ok() {
|
|
||||||
// Debounce: a resolve streams many chunks in quick succession;
|
|
||||||
// the watch channel keeps only the newest snapshot, so waiting
|
|
||||||
// briefly coalesces the burst into one write.
|
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
||||||
let Some(snapshot) = rx.borrow_and_update().clone() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Broadcasts that only toggled the `resolving` flag carry an
|
|
||||||
// unchanged snapshot — skip the write.
|
|
||||||
if last_written.as_ref() == Some(&snapshot) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match store.persist_current(&snapshot).await {
|
|
||||||
Ok(()) => last_written = Some(snapshot),
|
|
||||||
Err(err) => warn!("cannot persist the current queue: {err}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug!("queue snapshot channel closed, persister exiting");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crabidy_core::proto::crabidy::Album;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn track(i: usize) -> Track {
|
|
||||||
Track {
|
|
||||||
path: format!("/tidal/playlists/p/{i}"),
|
|
||||||
artist: "artist".to_string(),
|
|
||||||
title: format!("track {i}"),
|
|
||||||
duration: Some(60 + i as u32),
|
|
||||||
album: Some(Album {
|
|
||||||
title: "album".to_string(),
|
|
||||||
release_date: None,
|
|
||||||
}),
|
|
||||||
is_skipped: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot(n: usize) -> QueueSnapshot {
|
|
||||||
QueueSnapshot {
|
|
||||||
tracks: (0..n).map(track).collect(),
|
|
||||||
current_position: 0,
|
|
||||||
repeat: false,
|
|
||||||
shuffle: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn store() -> (QueueStore, TempDir) {
|
|
||||||
let dir = TempDir::new().expect("tempdir");
|
|
||||||
let store = QueueStore::open(dir.path().join("queues"))
|
|
||||||
.await
|
|
||||||
.expect("open creates the directory");
|
|
||||||
(store, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sorted visible file names of a queue folder.
|
|
||||||
fn visible_files(dir: &Path) -> Vec<String> {
|
|
||||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
|
||||||
.expect("queue folder")
|
|
||||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
|
||||||
.filter(|n| !n.starts_with('.'))
|
|
||||||
.collect();
|
|
||||||
names.sort_by_key(|n| n.to_lowercase());
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_writes_ordered_link_files_and_the_sidecar() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
let mut snap = snapshot(3);
|
|
||||||
// A queue may hold fs tracks; persisting must link to them too.
|
|
||||||
snap.tracks[1].path = "/fs/mix/song.cbd-track.toml".to_string();
|
|
||||||
store.save("road trip", &snap).await.expect("save");
|
|
||||||
|
|
||||||
let queue_dir = store.dir().join("road trip");
|
|
||||||
let names = visible_files(&queue_dir);
|
|
||||||
assert_eq!(names.len(), 3);
|
|
||||||
for (i, name) in names.iter().enumerate() {
|
|
||||||
assert!(name.starts_with(&format!("{:04} ", i + 1)), "{name}");
|
|
||||||
let text = std::fs::read_to_string(queue_dir.join(name)).expect("read entry");
|
|
||||||
let file = fsdy::TrackFile::parse(&text).expect("entry parses");
|
|
||||||
// The listing rewrite restores the original track exactly.
|
|
||||||
assert_eq!(file.to_track("/queues/irrelevant"), snap.tracks[i]);
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
queue_dir.join(STATE_FILE_NAME).exists(),
|
|
||||||
"sidecar written (hidden from listings by its dot prefix)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_validates_names_and_rejects_an_empty_queue() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
for bad in ["", " ", "a/b", "a\\b", ".hidden", CURRENT_QUEUE_NAME] {
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
store.save(bad, &snapshot(1)).await,
|
|
||||||
Err(SaveQueueError::InvalidName(_))
|
|
||||||
),
|
|
||||||
"name {bad:?} must be rejected"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(matches!(
|
|
||||||
store.save("fine", &snapshot(0)).await,
|
|
||||||
Err(SaveQueueError::EmptyQueue)
|
|
||||||
));
|
|
||||||
// A valid name is used trimmed.
|
|
||||||
store
|
|
||||||
.save(" padded ", &snapshot(1))
|
|
||||||
.await
|
|
||||||
.expect("trimmed name saves");
|
|
||||||
assert!(store.dir().join("padded").is_dir());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_overwrites_an_existing_queue_completely() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store.save("mix", &snapshot(3)).await.expect("first save");
|
|
||||||
store.save("mix", &snapshot(1)).await.expect("overwrite");
|
|
||||||
// No stale entries from the longer first save survive.
|
|
||||||
assert_eq!(visible_files(&store.dir().join("mix")).len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn persist_current_and_load_round_trip() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
let snap = QueueSnapshot {
|
|
||||||
current_position: 2,
|
|
||||||
repeat: true,
|
|
||||||
shuffle: true,
|
|
||||||
..snapshot(4)
|
|
||||||
};
|
|
||||||
store.persist_current(&snap).await.expect("persist");
|
|
||||||
let loaded = store.load_current().await.expect("load");
|
|
||||||
assert_eq!(loaded, snap);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn persist_current_accepts_an_empty_queue() {
|
|
||||||
// Clearing the queue must persist as cleared, not keep yesterday's
|
|
||||||
// tracks for the next restart.
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store
|
|
||||||
.persist_current(&snapshot(2))
|
|
||||||
.await
|
|
||||||
.expect("non-empty");
|
|
||||||
store.persist_current(&snapshot(0)).await.expect("empty");
|
|
||||||
let loaded = store.load_current().await.expect("load");
|
|
||||||
assert!(loaded.tracks.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn load_current_without_a_folder_is_a_fresh_start() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
assert!(store.load_current().await.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn load_current_skips_broken_entries_and_survives_a_broken_sidecar() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
store.persist_current(&snapshot(2)).await.expect("persist");
|
|
||||||
let current = store.dir().join(CURRENT_QUEUE_NAME);
|
|
||||||
std::fs::write(current.join("0000 broken.cbd-track.toml"), "not [ toml")
|
|
||||||
.expect("write broken entry");
|
|
||||||
std::fs::write(current.join(STATE_FILE_NAME), "also not [ toml")
|
|
||||||
.expect("break the sidecar");
|
|
||||||
let loaded = store.load_current().await.expect("load");
|
|
||||||
// The two good tracks load; the broken entry is skipped and the
|
|
||||||
// broken sidecar degrades to default state instead of failing.
|
|
||||||
assert_eq!(loaded.tracks.len(), 2);
|
|
||||||
assert_eq!(loaded.current_position, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn persister_writes_the_latest_snapshot() {
|
|
||||||
let (store, _dir) = store().await;
|
|
||||||
let store = Arc::new(store);
|
|
||||||
let (tx, rx) = tokio::sync::watch::channel(None);
|
|
||||||
spawn_persister(Arc::clone(&store), rx);
|
|
||||||
// A burst: only the newest snapshot matters (latest-wins channel).
|
|
||||||
tx.send(Some(snapshot(5))).expect("send");
|
|
||||||
tx.send(Some(snapshot(3))).expect("send");
|
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
if let Some(loaded) = store.load_current().await {
|
|
||||||
if loaded.tracks.len() == 3 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
tokio::time::Instant::now() < deadline,
|
|
||||||
"persister never wrote the latest snapshot"
|
|
||||||
);
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::bookmark_store::CaptureError;
|
#[cfg(feature = "fs")]
|
||||||
use crate::queue_store::SaveQueueError;
|
use crate::capture::CaptureError;
|
||||||
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
|
|
@ -18,6 +18,34 @@ use crabidy_core::proto::crabidy::{
|
||||||
};
|
};
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
/// The one message both store-backed RPCs answer with in a build that has no
|
||||||
|
/// content store (architecture/build-features.md D5). `Unimplemented` is the
|
||||||
|
/// honest status: the method exists on the wire, this build cannot serve it.
|
||||||
|
#[cfg(not(feature = "fs"))]
|
||||||
|
const NO_STORE: &str = "this server was built without the `fs` feature, so it has no content \
|
||||||
|
store: bookmarks, captures, and saved queues are unavailable";
|
||||||
|
|
||||||
|
/// Maps a capture/store error to a gRPC status (shared by the capture and
|
||||||
|
/// queue-save RPCs). Bad input → `invalid_argument`; an existing save name →
|
||||||
|
/// `already_exists`; precondition failures → `failed_precondition`;
|
||||||
|
/// everything else → `internal` (logged, message not leaked).
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
fn capture_error_status(err: CaptureError, internal_msg: &'static str) -> Status {
|
||||||
|
match err {
|
||||||
|
CaptureError::InvalidName(_) | CaptureError::BadSource(_) => {
|
||||||
|
Status::invalid_argument(err.to_string())
|
||||||
|
}
|
||||||
|
CaptureError::Conflict(_) => Status::already_exists(err.to_string()),
|
||||||
|
CaptureError::TooLarge(_) | CaptureError::Disabled | CaptureError::Unsupported => {
|
||||||
|
Status::failed_precondition(err.to_string())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error!("{internal_msg}: {err}");
|
||||||
|
Status::internal(internal_msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
use tracing::{debug, error, instrument, trace};
|
use tracing::{debug, error, instrument, trace};
|
||||||
|
|
@ -27,6 +55,10 @@ pub struct RpcService {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
playback_tx: flume::Sender<PlaybackMessage>,
|
playback_tx: flume::Sender<PlaybackMessage>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
|
/// Whether role authorization is configured. Surfaced verbatim on the
|
||||||
|
/// anonymously-reachable `Init` response so the web client can offer a
|
||||||
|
/// login even when it connected as the unauthenticated fallback role.
|
||||||
|
auth_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcService {
|
impl RpcService {
|
||||||
|
|
@ -34,11 +66,13 @@ impl RpcService {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
playback_tx: flume::Sender<PlaybackMessage>,
|
playback_tx: flume::Sender<PlaybackMessage>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
|
auth_enabled: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
update_tx,
|
update_tx,
|
||||||
playback_tx,
|
playback_tx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
|
auth_enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,10 +101,14 @@ impl CrabidyService for RpcService {
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
self.send_playback(PlaybackCommand::Init { result_tx })
|
self.send_playback(PlaybackCommand::Init { result_tx })
|
||||||
.await?;
|
.await?;
|
||||||
let response = result_rx.recv_async().await.map_err(|err| {
|
let mut response = result_rx.recv_async().await.map_err(|err| {
|
||||||
error!("no reply from playback loop: {err}");
|
error!("no reply from playback loop: {err}");
|
||||||
Status::internal("playback loop did not reply")
|
Status::internal("playback loop did not reply")
|
||||||
})?;
|
})?;
|
||||||
|
// The playback loop owns the queue/player state; the auth switch
|
||||||
|
// is the server's, so it is stamped here rather than threaded
|
||||||
|
// through the playback command.
|
||||||
|
response.auth_enabled = self.auth_enabled;
|
||||||
Ok(Response::new(response))
|
Ok(Response::new(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,6 +434,14 @@ impl CrabidyService for RpcService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<CaptureLibraryNodeRequest>,
|
request: Request<CaptureLibraryNodeRequest>,
|
||||||
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
) -> Result<Response<CaptureLibraryNodeResponse>, Status> {
|
||||||
|
#[cfg(not(feature = "fs"))]
|
||||||
|
{
|
||||||
|
let _ = request;
|
||||||
|
debug!("received capture_library_node request on a store-less build");
|
||||||
|
return Err(Status::unimplemented(NO_STORE));
|
||||||
|
}
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
{
|
||||||
let CaptureLibraryNodeRequest {
|
let CaptureLibraryNodeRequest {
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
|
|
@ -436,32 +482,33 @@ impl CrabidyService for RpcService {
|
||||||
})?;
|
})?;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
Ok(()) => Ok(Response::new(CaptureLibraryNodeResponse {})),
|
||||||
Err(err @ (CaptureError::InvalidName(_) | CaptureError::BadSource(_))) => {
|
Err(err) => Err(capture_error_status(err, "cannot capture the subtree")),
|
||||||
Err(Status::invalid_argument(err.to_string()))
|
|
||||||
}
|
|
||||||
Err(
|
|
||||||
err @ (CaptureError::TooLarge(_)
|
|
||||||
| CaptureError::Disabled
|
|
||||||
| CaptureError::Unsupported),
|
|
||||||
) => Err(Status::failed_precondition(err.to_string())),
|
|
||||||
Err(err) => {
|
|
||||||
error!("capture_library_node failed: {err}");
|
|
||||||
Err(Status::internal("cannot capture the subtree"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the current queue under a name (persisted queues, visible as
|
/// Link-saves the current queue as `/crabidy/<name>` — the queue `w`
|
||||||
/// `/queues/<name>` in the library).
|
/// gesture (architecture/crabidy-store.md D5). A bookmark of the live
|
||||||
|
/// queue; capturing its audio is the queue `W` gesture, which goes
|
||||||
|
/// through `capture_library_node` on `/crabidy/current`.
|
||||||
///
|
///
|
||||||
/// Error mapping is part of the contract: an invalid name →
|
/// Error mapping is part of the contract: an invalid name →
|
||||||
/// `invalid_argument`; an empty queue or disabled persistence →
|
/// `invalid_argument`; an existing name → `already_exists`; an empty
|
||||||
/// `failed_precondition`; I/O and serialization failures → `internal`.
|
/// queue or disabled persistence → `failed_precondition`; I/O failures →
|
||||||
|
/// `internal`.
|
||||||
#[instrument(skip(self, request), fields(name))]
|
#[instrument(skip(self, request), fields(name))]
|
||||||
async fn save_queue(
|
async fn save_queue(
|
||||||
&self,
|
&self,
|
||||||
request: Request<SaveQueueRequest>,
|
request: Request<SaveQueueRequest>,
|
||||||
) -> Result<Response<SaveQueueResponse>, Status> {
|
) -> Result<Response<SaveQueueResponse>, Status> {
|
||||||
|
#[cfg(not(feature = "fs"))]
|
||||||
|
{
|
||||||
|
let _ = request;
|
||||||
|
debug!("received save_queue request on a store-less build");
|
||||||
|
Err(Status::unimplemented(NO_STORE))
|
||||||
|
}
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
{
|
||||||
let name = request.into_inner().name;
|
let name = request.into_inner().name;
|
||||||
tracing::Span::current().record("name", name.as_str());
|
tracing::Span::current().record("name", name.as_str());
|
||||||
debug!("received save_queue request");
|
debug!("received save_queue request");
|
||||||
|
|
@ -474,15 +521,7 @@ impl CrabidyService for RpcService {
|
||||||
})?;
|
})?;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
Ok(()) => Ok(Response::new(SaveQueueResponse {})),
|
||||||
Err(err @ SaveQueueError::InvalidName(_)) => {
|
Err(err) => Err(capture_error_status(err, "cannot save the queue")),
|
||||||
Err(Status::invalid_argument(err.to_string()))
|
|
||||||
}
|
|
||||||
Err(err @ (SaveQueueError::EmptyQueue | SaveQueueError::Disabled)) => {
|
|
||||||
Err(Status::failed_precondition(err.to_string()))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!("save_queue failed: {err}");
|
|
||||||
Err(Status::internal("cannot save the queue"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,153 @@
|
||||||
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
|
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
|
||||||
//!
|
//!
|
||||||
//! Today this only carries the `[auth]` role hashes
|
//! Carries the enabled-providers list ([`ServerSettings::providers`]) and the
|
||||||
//! (architecture/roles-auth.md). The file is optional — a missing file
|
//! `[auth]` role hashes (architecture/roles-auth.md). The server writes a
|
||||||
//! runs the server open, exactly as before the feature — but a file
|
//! default (all providers, no auth) on first start via
|
||||||
//! that exists and does not parse aborts startup: silently ignoring a
|
//! [`ServerSettings::ensure_default`]. The file is optional — a missing file
|
||||||
//! broken auth config would run an intended-to-be-locked server open
|
//! runs the server open with every provider — but a file that exists and does
|
||||||
//! (fail-closed, quality/roles-auth.md).
|
//! not parse aborts startup: silently ignoring a broken auth config would run
|
||||||
|
//! an intended-to-be-locked server open (fail-closed, quality/roles-auth.md).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The server config file name inside the crabidy config directory.
|
/// The server config file name inside the crabidy config directory.
|
||||||
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||||
|
|
||||||
|
/// Every provider name the runtime toggle understands, in the order the
|
||||||
|
/// default config lists them. Each name is a library root (`/tidal`,
|
||||||
|
/// `/youtube`, `/fyyd`, `/abs`, `/soundcloud`, `/jamendo`, `/fs`, `/crabidy`,
|
||||||
|
/// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`,
|
||||||
|
/// and both need the `fs` build feature (architecture/build-features.md D5).
|
||||||
|
///
|
||||||
|
/// This is the *vocabulary*, not what this binary can mount — see
|
||||||
|
/// [`BUILT_IN_PROVIDERS`].
|
||||||
|
pub const ALL_PROVIDERS: [&str; 9] = [
|
||||||
|
"tidal",
|
||||||
|
"youtube",
|
||||||
|
"fyyd",
|
||||||
|
"abs",
|
||||||
|
"soundcloud",
|
||||||
|
"jamendo",
|
||||||
|
"fs",
|
||||||
|
"crabidy",
|
||||||
|
"orphans",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The providers **this binary** was built with: [`ALL_PROVIDERS`] filtered by
|
||||||
|
/// the compile-time features (architecture/build-features.md D4). The default
|
||||||
|
/// config we write lists exactly these, and a `providers` entry outside this
|
||||||
|
/// set can never be mounted — it earns a startup warning
|
||||||
|
/// ([`ServerSettings::unavailable_providers`]).
|
||||||
|
pub const BUILT_IN_PROVIDERS: &[&str] = &[
|
||||||
|
#[cfg(feature = "tidal")]
|
||||||
|
"tidal",
|
||||||
|
#[cfg(feature = "youtube")]
|
||||||
|
"youtube",
|
||||||
|
#[cfg(feature = "fyyd")]
|
||||||
|
"fyyd",
|
||||||
|
#[cfg(feature = "abs")]
|
||||||
|
"abs",
|
||||||
|
#[cfg(feature = "soundcloud")]
|
||||||
|
"soundcloud",
|
||||||
|
#[cfg(feature = "jamendo")]
|
||||||
|
"jamendo",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
"fs",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
"crabidy",
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
"orphans",
|
||||||
|
];
|
||||||
|
|
||||||
/// Contents of `crabidy-server.toml`.
|
/// Contents of `crabidy-server.toml`.
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ServerSettings {
|
pub struct ServerSettings {
|
||||||
|
/// Enabled providers. The default config we write lists all of them
|
||||||
|
/// ([`ALL_PROVIDERS`]); remove a name to disable that provider. Absent —
|
||||||
|
/// the key deleted, or a fresh install with no file — enables all of them,
|
||||||
|
/// so a server never silently loses every provider (fail-open for the
|
||||||
|
/// library, unlike auth).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub providers: Option<Vec<String>>,
|
||||||
/// Role credentials; absent (or empty) means the server runs open.
|
/// Role credentials; absent (or empty) means the server runs open.
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "AuthSettings::is_default")]
|
||||||
pub auth: AuthSettings,
|
pub auth: AuthSettings,
|
||||||
|
/// Audio output selection; absent means the system default device.
|
||||||
|
#[serde(default, skip_serializing_if = "AudioSettings::is_default")]
|
||||||
|
pub audio: AudioSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio output configuration.
|
||||||
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AudioSettings {
|
||||||
|
/// A substring of the output device's name (case-insensitive) to send
|
||||||
|
/// audio to. Absent uses the system default device — which on a Raspberry
|
||||||
|
/// Pi is often HDMI, so audio plays but is silent on the jack/DAC. List
|
||||||
|
/// the available names with `crabidy-server audio-devices`, then set e.g.
|
||||||
|
/// `device = "Headphones"`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub device: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioSettings {
|
||||||
|
/// Whether this is the empty default (no device pinned), so the
|
||||||
|
/// auto-written config omits the `[audio]` table entirely.
|
||||||
|
fn is_default(&self) -> bool {
|
||||||
|
self.device.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which providers to mount, resolved from [`ServerSettings::providers`].
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ProviderToggles {
|
||||||
|
pub tidal: bool,
|
||||||
|
pub youtube: bool,
|
||||||
|
pub fyyd: bool,
|
||||||
|
pub abs: bool,
|
||||||
|
pub soundcloud: bool,
|
||||||
|
pub jamendo: bool,
|
||||||
|
pub fs: bool,
|
||||||
|
pub crabidy: bool,
|
||||||
|
pub orphans: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderToggles {
|
||||||
|
/// Every provider this binary has on — the default, and what a
|
||||||
|
/// missing/keyless config yields. Providers built out are off: a feature
|
||||||
|
/// that is not compiled in cannot be toggled on
|
||||||
|
/// (architecture/build-features.md D4).
|
||||||
|
pub fn all() -> Self {
|
||||||
|
Self {
|
||||||
|
tidal: cfg!(feature = "tidal"),
|
||||||
|
youtube: cfg!(feature = "youtube"),
|
||||||
|
fyyd: cfg!(feature = "fyyd"),
|
||||||
|
abs: cfg!(feature = "abs"),
|
||||||
|
soundcloud: cfg!(feature = "soundcloud"),
|
||||||
|
jamendo: cfg!(feature = "jamendo"),
|
||||||
|
fs: cfg!(feature = "fs"),
|
||||||
|
// Both live on the content store, which the `fs` feature brings
|
||||||
|
// (D5).
|
||||||
|
crabidy: cfg!(feature = "fs"),
|
||||||
|
orphans: cfg!(feature = "fs"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One PHC password hash per role; a role without a hash cannot
|
/// One PHC password hash per role; a role without a hash cannot
|
||||||
/// authenticate. Generate hashes with `crabidy-server hash-password`.
|
/// authenticate. Generate hashes with `crabidy-server guard <role>`.
|
||||||
/// Hashes are not passwords, but the file should stay private anyway.
|
/// Hashes are not passwords, but the file should stay private anyway.
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct AuthSettings {
|
pub struct AuthSettings {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub owner: Option<String>,
|
pub owner: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub queue_owner: Option<String>,
|
pub queue_owner: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub queue_appender: Option<String>,
|
pub queue_appender: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +157,41 @@ impl AuthSettings {
|
||||||
pub fn enabled(&self) -> bool {
|
pub fn enabled(&self) -> bool {
|
||||||
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
|
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this is the empty default (no roles) — so the auto-written
|
||||||
|
/// default config omits the `[auth]` table entirely.
|
||||||
|
fn is_default(&self) -> bool {
|
||||||
|
self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces the guarding order: roles must be locked from the most
|
||||||
|
/// privileged down. Anonymous callers inherit the highest *unguarded*
|
||||||
|
/// role (architecture/roles-auth.md), so guarding a lower role while a
|
||||||
|
/// higher one is open is meaningless — the anonymous role would still
|
||||||
|
/// outrank it. Such a config is a mistake, not a subtle preference, so
|
||||||
|
/// it aborts startup rather than running with a surprising posture.
|
||||||
|
///
|
||||||
|
/// Valid guarded sets are prefixes of `[owner, queue_owner,
|
||||||
|
/// queue_appender]`: nothing, `owner`, `owner`+`queue_owner`, or all
|
||||||
|
/// three. `queue_owner` without `owner`, or `queue_appender` without
|
||||||
|
/// `queue_owner`, is rejected.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.queue_owner.is_some() && self.owner.is_none() {
|
||||||
|
return Err(
|
||||||
|
"[auth] queue_owner is guarded but owner is not; guard roles from most to \
|
||||||
|
least privileged (owner, then queue_owner, then queue_appender)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.queue_appender.is_some() && self.queue_owner.is_none() {
|
||||||
|
return Err(
|
||||||
|
"[auth] queue_appender is guarded but queue_owner is not; guard roles from most \
|
||||||
|
to least privileged (owner, then queue_owner, then queue_appender)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerSettings {
|
impl ServerSettings {
|
||||||
|
|
@ -57,7 +209,97 @@ impl ServerSettings {
|
||||||
}
|
}
|
||||||
Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
|
Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
|
||||||
};
|
};
|
||||||
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))
|
let settings: ServerSettings =
|
||||||
|
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))?;
|
||||||
|
// A broken guarding order is fail-closed like any other parse error:
|
||||||
|
// abort startup rather than run with a surprising auth posture.
|
||||||
|
settings
|
||||||
|
.auth
|
||||||
|
.validate()
|
||||||
|
.map_err(|err| format!("invalid {}: {err}", file.display()))?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the named provider is enabled: every *built-in* provider when
|
||||||
|
/// the `providers` key is absent, otherwise only the names the list holds.
|
||||||
|
///
|
||||||
|
/// A provider this binary was not built with is never enabled, whatever
|
||||||
|
/// the config says (architecture/build-features.md D4) — the compile-time
|
||||||
|
/// set is the outer bound of the runtime one.
|
||||||
|
pub fn provider_enabled(&self, name: &str) -> bool {
|
||||||
|
BUILT_IN_PROVIDERS.contains(&name)
|
||||||
|
&& self
|
||||||
|
.providers
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|list| list.iter().any(|p| p == name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names the `providers` list asks for that this binary cannot mount:
|
||||||
|
/// providers built out (`tidal` in a build without the `tidal` feature)
|
||||||
|
/// and outright unknown names (a typo). The caller logs one warning per
|
||||||
|
/// name at startup — the library layer is fail-open, so this never aborts
|
||||||
|
/// (architecture/build-features.md D4).
|
||||||
|
pub fn unavailable_providers(&self) -> Vec<&str> {
|
||||||
|
self.providers
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.filter(|name| !BUILT_IN_PROVIDERS.contains(name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-provider mount decisions for the orchestrator. `orphans` also
|
||||||
|
/// requires `crabidy` (it is a view over the store); that dependency is
|
||||||
|
/// enforced where the store is built, so it is not folded in here.
|
||||||
|
pub fn provider_toggles(&self) -> ProviderToggles {
|
||||||
|
ProviderToggles {
|
||||||
|
tidal: self.provider_enabled("tidal"),
|
||||||
|
youtube: self.provider_enabled("youtube"),
|
||||||
|
fyyd: self.provider_enabled("fyyd"),
|
||||||
|
abs: self.provider_enabled("abs"),
|
||||||
|
soundcloud: self.provider_enabled("soundcloud"),
|
||||||
|
jamendo: self.provider_enabled("jamendo"),
|
||||||
|
fs: self.provider_enabled("fs"),
|
||||||
|
crabidy: self.provider_enabled("crabidy"),
|
||||||
|
orphans: self.provider_enabled("orphans"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a default `crabidy-server.toml` — every provider this binary has
|
||||||
|
/// ([`BUILT_IN_PROVIDERS`]), no auth — when none exists yet, so users have
|
||||||
|
/// a full list to prune. A file that already exists (even a pruned one) is
|
||||||
|
/// left untouched. Best-effort: the caller treats a write failure as a
|
||||||
|
/// warning, not a startup error.
|
||||||
|
pub fn ensure_default(config_dir: &Path) -> Result<(), String> {
|
||||||
|
let file = config_dir.join(SETTINGS_FILE);
|
||||||
|
match std::fs::metadata(&file) {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(err) => return Err(format!("cannot check {}: {err}", file.display())),
|
||||||
|
}
|
||||||
|
let settings = ServerSettings {
|
||||||
|
providers: Some(BUILT_IN_PROVIDERS.iter().map(|s| s.to_string()).collect()),
|
||||||
|
auth: AuthSettings::default(),
|
||||||
|
audio: AudioSettings::default(),
|
||||||
|
};
|
||||||
|
settings.store(config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the current settings back to `crabidy-server.toml` in
|
||||||
|
/// `config_dir`, creating the directory if missing.
|
||||||
|
///
|
||||||
|
/// The flat `[auth]` shape is preserved (each role a top-level key), so a
|
||||||
|
/// reload under `#[serde(deny_unknown_fields)]` still parses. Roles left
|
||||||
|
/// `None` are simply omitted — they cannot authenticate, exactly as a
|
||||||
|
/// missing key. The password never appears here (only the PHC hash).
|
||||||
|
pub fn store(&self, config_dir: &Path) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(config_dir)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", config_dir.display()))?;
|
||||||
|
let text = toml::to_string_pretty(self)
|
||||||
|
.map_err(|err| format!("cannot serialize server settings: {err}"))?;
|
||||||
|
let file = config_dir.join(SETTINGS_FILE);
|
||||||
|
std::fs::write(&file, text).map_err(|err| format!("cannot write {}: {err}", file.display()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,16 +320,261 @@ mod tests {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.path().join(SETTINGS_FILE),
|
dir.path().join(SETTINGS_FILE),
|
||||||
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
|
"[auth]\nowner = \"$argon2id$fake\"\n",
|
||||||
)
|
)
|
||||||
.expect("write");
|
.expect("write");
|
||||||
let settings = ServerSettings::load(dir.path()).expect("parse");
|
let settings = ServerSettings::load(dir.path()).expect("parse");
|
||||||
assert!(settings.auth.enabled());
|
assert!(settings.auth.enabled());
|
||||||
|
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$fake"));
|
||||||
|
assert!(settings.auth.queue_owner.is_none());
|
||||||
|
assert!(settings.auth.queue_appender.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_accepts_top_down_guarded_prefixes() {
|
||||||
|
// Every valid guarded set is a prefix of owner → queue_owner →
|
||||||
|
// queue_appender.
|
||||||
|
let owner = "$argon2id$o".to_string();
|
||||||
|
let qo = "$argon2id$qo".to_string();
|
||||||
|
let qa = "$argon2id$qa".to_string();
|
||||||
|
let cases = [
|
||||||
|
AuthSettings::default(),
|
||||||
|
AuthSettings {
|
||||||
|
owner: Some(owner.clone()),
|
||||||
|
queue_owner: None,
|
||||||
|
queue_appender: None,
|
||||||
|
},
|
||||||
|
AuthSettings {
|
||||||
|
owner: Some(owner.clone()),
|
||||||
|
queue_owner: Some(qo.clone()),
|
||||||
|
queue_appender: None,
|
||||||
|
},
|
||||||
|
AuthSettings {
|
||||||
|
owner: Some(owner),
|
||||||
|
queue_owner: Some(qo),
|
||||||
|
queue_appender: Some(qa),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for case in cases {
|
||||||
|
assert!(case.validate().is_ok(), "{case:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_a_role_guarded_below_an_open_one() {
|
||||||
|
// queue_owner without owner.
|
||||||
|
let broken = AuthSettings {
|
||||||
|
owner: None,
|
||||||
|
queue_owner: Some("$argon2id$qo".to_string()),
|
||||||
|
queue_appender: None,
|
||||||
|
};
|
||||||
|
assert!(broken.validate().is_err());
|
||||||
|
// queue_appender without queue_owner.
|
||||||
|
let broken = AuthSettings {
|
||||||
|
owner: Some("$argon2id$o".to_string()),
|
||||||
|
queue_owner: None,
|
||||||
|
queue_appender: Some("$argon2id$qa".to_string()),
|
||||||
|
};
|
||||||
|
assert!(broken.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_guarding_order_aborts_load() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join(SETTINGS_FILE),
|
||||||
|
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let err = ServerSettings::load(dir.path()).expect_err("must reject");
|
||||||
|
assert!(err.contains("crabidy-server.toml"), "{err}");
|
||||||
|
assert!(err.contains("queue_appender"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_sets_one_role_and_preserves_the_others() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
// Seed a file with two roles already set.
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join(SETTINGS_FILE),
|
||||||
|
"[auth]\nowner = \"$argon2id$owner\"\nqueue_owner = \"$argon2id$qo\"\n",
|
||||||
|
)
|
||||||
|
.expect("seed");
|
||||||
|
let mut settings = ServerSettings::load(dir.path()).expect("load");
|
||||||
|
// Set the third role and write it back.
|
||||||
|
settings.auth.queue_appender = Some("$argon2id$appender".to_string());
|
||||||
|
settings.store(dir.path()).expect("store");
|
||||||
|
|
||||||
|
// Reload: all three roles present, still parses under deny_unknown.
|
||||||
|
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
||||||
|
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$owner"));
|
||||||
|
assert_eq!(reloaded.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.auth.queue_appender.as_deref(),
|
reloaded.auth.queue_appender.as_deref(),
|
||||||
Some("$argon2id$fake")
|
Some("$argon2id$appender")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_creates_the_config_dir_when_missing() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let nested = dir.path().join("config").join("crabidy");
|
||||||
|
let mut settings = ServerSettings::default();
|
||||||
|
settings.auth.owner = Some("$argon2id$x".to_string());
|
||||||
|
settings.store(&nested).expect("store creates dir");
|
||||||
|
assert!(nested.join(SETTINGS_FILE).is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_providers_key_enables_every_built_in_provider() {
|
||||||
|
let settings = ServerSettings::default();
|
||||||
|
for provider in BUILT_IN_PROVIDERS {
|
||||||
|
assert!(settings.provider_enabled(provider), "{provider}");
|
||||||
|
}
|
||||||
|
// …and nothing this binary was built without.
|
||||||
|
for provider in ALL_PROVIDERS {
|
||||||
|
if !BUILT_IN_PROVIDERS.contains(&provider) {
|
||||||
|
assert!(!settings.provider_enabled(provider), "{provider}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A provider the binary lacks cannot be turned on from the config: the
|
||||||
|
/// compile-time set bounds the runtime one
|
||||||
|
/// (architecture/build-features.md D4).
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(feature = "tidal"))]
|
||||||
|
fn a_built_out_provider_cannot_be_enabled() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"tidal\"]\n").expect("write");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("load");
|
||||||
|
assert!(!settings.provider_enabled("tidal"));
|
||||||
|
assert!(!settings.provider_toggles().tidal);
|
||||||
|
assert_eq!(settings.unavailable_providers(), vec!["tidal"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unavailable_providers_reports_built_out_and_unknown_names() {
|
||||||
|
// An unknown name (a typo) is always unavailable, whatever the build.
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join(SETTINGS_FILE),
|
||||||
|
"providers = [\"fs\", \"tidl\", \"\"]\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("load");
|
||||||
|
let unavailable = settings.unavailable_providers();
|
||||||
|
assert!(unavailable.contains(&"tidl"), "{unavailable:?}");
|
||||||
|
assert!(unavailable.contains(&""), "{unavailable:?}");
|
||||||
|
// `fs` is only available when it was built in.
|
||||||
|
assert_eq!(
|
||||||
|
unavailable.contains(&"fs"),
|
||||||
|
!cfg!(feature = "fs"),
|
||||||
|
"{unavailable:?}"
|
||||||
|
);
|
||||||
|
// An absent key asks for nothing, so nothing is unavailable.
|
||||||
|
assert!(ServerSettings::default().unavailable_providers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_default_lists_only_built_in_providers() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
ServerSettings::ensure_default(dir.path()).expect("write default");
|
||||||
|
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
||||||
|
let listed = reloaded.providers.expect("providers key");
|
||||||
|
assert_eq!(listed, BUILT_IN_PROVIDERS);
|
||||||
|
// Nothing it writes can then warn at startup.
|
||||||
|
assert!(reloaded_unavailable(dir.path()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reloaded_unavailable(dir: &Path) -> Vec<String> {
|
||||||
|
ServerSettings::load(dir)
|
||||||
|
.expect("reload")
|
||||||
|
.unavailable_providers()
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserts on `fs`/`crabidy`, so it needs a build that has them.
|
||||||
|
#[cfg(feature = "fs")]
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_list_enables_only_its_names() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join(SETTINGS_FILE),
|
||||||
|
"providers = [\"fs\", \"crabidy\"]\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("load");
|
||||||
|
assert!(settings.provider_enabled("fs") && settings.provider_enabled("crabidy"));
|
||||||
|
assert!(!settings.provider_enabled("tidal"));
|
||||||
|
assert!(!settings.provider_enabled("youtube"));
|
||||||
|
assert!(!settings.provider_enabled("orphans"));
|
||||||
|
let toggles = settings.provider_toggles();
|
||||||
|
assert!(toggles.fs && toggles.crabidy);
|
||||||
|
assert!(!toggles.tidal && !toggles.youtube && !toggles.orphans);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_default_writes_all_providers_and_never_clobbers() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
ServerSettings::ensure_default(dir.path()).expect("write default");
|
||||||
|
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
|
||||||
|
for provider in BUILT_IN_PROVIDERS {
|
||||||
|
assert!(text.contains(provider), "default lists {provider}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!text.contains("[auth]"),
|
||||||
|
"no empty auth table in the default"
|
||||||
|
);
|
||||||
|
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.providers.as_ref().map(Vec::len),
|
||||||
|
Some(BUILT_IN_PROVIDERS.len())
|
||||||
|
);
|
||||||
|
// A second call must not overwrite a file the user has since pruned.
|
||||||
|
std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune");
|
||||||
|
ServerSettings::ensure_default(dir.path()).expect("no-op");
|
||||||
|
let after = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
|
||||||
|
assert_eq!(after.trim(), "providers = [\"fs\"]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn providers_and_auth_round_trip() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join(SETTINGS_FILE),
|
||||||
|
"providers = [\"tidal\", \"fs\"]\n[auth]\nowner = \"$argon2id$x\"\n",
|
||||||
|
)
|
||||||
|
.expect("seed");
|
||||||
|
let settings = ServerSettings::load(dir.path()).expect("load");
|
||||||
|
assert!(settings.auth.enabled());
|
||||||
|
settings.store(dir.path()).expect("store");
|
||||||
|
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.providers,
|
||||||
|
Some(vec!["tidal".to_string(), "fs".to_string()])
|
||||||
|
);
|
||||||
|
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_device_round_trips_and_omits_when_unset() {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
// Unset: the [audio] table is omitted entirely.
|
||||||
|
ServerSettings::default().store(dir.path()).expect("store");
|
||||||
|
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
|
||||||
|
assert!(!text.contains("[audio]"), "no empty audio table: {text}");
|
||||||
|
// Set: it round-trips.
|
||||||
|
let mut settings = ServerSettings::default();
|
||||||
|
settings.audio.device = Some("plughw:CARD=sndrpihifiberry".to_string());
|
||||||
|
settings.store(dir.path()).expect("store");
|
||||||
|
let reloaded = ServerSettings::load(dir.path()).expect("reload");
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.audio.device.as_deref(),
|
||||||
|
Some("plughw:CARD=sndrpihifiberry")
|
||||||
);
|
);
|
||||||
assert!(settings.auth.owner.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ fn service() -> RpcService {
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(4);
|
let (update_tx, _) = tokio::sync::broadcast::channel(4);
|
||||||
let (playback_tx, _playback_rx) = flume::unbounded();
|
let (playback_tx, _playback_rx) = flume::unbounded();
|
||||||
let (provider_tx, _provider_rx) = flume::unbounded();
|
let (provider_tx, _provider_rx) = flume::unbounded();
|
||||||
RpcService::new(update_tx, playback_tx, provider_tx)
|
RpcService::new(update_tx, playback_tx, provider_tx, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash(password: &str) -> String {
|
fn hash(password: &str) -> String {
|
||||||
|
|
@ -88,13 +88,14 @@ async fn unknown_get_paths_fall_back_to_the_shell() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grpc_web_calls_route_through_the_auth_layer() {
|
async fn grpc_web_calls_route_through_the_auth_layer() {
|
||||||
// A credentialed server: an unauthenticated gRPC-web POST must be
|
// A fully-locked server (every role guarded, so anonymous callers get
|
||||||
// rejected by the layer (gRPC status UNAUTHENTICATED = 16) *before*
|
// nothing): an unauthenticated gRPC-web POST must be rejected by the
|
||||||
// reaching a handler — so the dead channels never matter.
|
// layer (gRPC status UNAUTHENTICATED = 16) *before* reaching a handler
|
||||||
|
// — so the dead channels never matter.
|
||||||
let auth = Authenticator::new(&AuthSettings {
|
let auth = Authenticator::new(&AuthSettings {
|
||||||
owner: Some(hash("pw")),
|
owner: Some(hash("pw")),
|
||||||
queue_owner: None,
|
queue_owner: Some(hash("qo")),
|
||||||
queue_appender: None,
|
queue_appender: Some(hash("qa")),
|
||||||
});
|
});
|
||||||
let router = crabidy_server::build_router(service(), Arc::new(auth));
|
let router = crabidy_server::build_router(service(), Arc::new(auth));
|
||||||
let response = router
|
let response = router
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
{ pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
mdbook-admonish = pkgs.mdbook-admonish.overrideAttrs (old: rec {
|
||||||
|
version = "1.20.0-unstable-pr235";
|
||||||
|
src = pkgs.fetchFromGitHub {
|
||||||
|
owner = "tommilligan";
|
||||||
|
repo = "mdbook-admonish";
|
||||||
|
rev = "f67dc47c24bc48dada3ae4decf055fdd6ba4a4ed";
|
||||||
|
hash = "sha256-vD71Hhjn43NnqUg+sluybC2wejjNsY6cvj/PsPvIUnw=";
|
||||||
|
};
|
||||||
|
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
||||||
|
inherit src;
|
||||||
|
name = "mdbook-admonish-${version}-vendor";
|
||||||
|
hash = "sha256-FQo58eT9SyO5bhuoRQOAfBcAi1acBOPjYH6WUtiJPIE=";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = with pkgs; [
|
||||||
|
mdbook
|
||||||
|
mdbook-admonish
|
||||||
|
mdbook-d2
|
||||||
|
mdbook-footnote
|
||||||
|
mdbook-toc
|
||||||
|
d2
|
||||||
|
];
|
||||||
|
|
||||||
|
scripts.docs.exec = ''
|
||||||
|
export D2_NO_XML_TAG="''${D2_NO_XML_TAG:-true}"
|
||||||
|
mdbook serve docs --hostname "''${MDBOOK_HOST:-127.0.0.1}" --port "''${MDBOOK_PORT:-3000}" "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
|
scripts.docs-build.exec = ''
|
||||||
|
export D2_NO_XML_TAG="''${D2_NO_XML_TAG:-true}"
|
||||||
|
mdbook build docs "$@"
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,14 @@
|
||||||
vulkan-tools
|
vulkan-tools
|
||||||
];
|
];
|
||||||
env.RUSTC_WRAPPER = "sccache";
|
env.RUSTC_WRAPPER = "sccache";
|
||||||
|
# Per-user sccache server socket. On the default shared TCP port (4226)
|
||||||
|
# the first user's server performs every rustc write for every user, so a
|
||||||
|
# second user's builds die with EACCES in their own target/ dir.
|
||||||
|
enterShell = ''
|
||||||
|
sccache_dir="''${XDG_RUNTIME_DIR:-/tmp/sccache-$(id -u)}"
|
||||||
|
mkdir -p "$sccache_dir"
|
||||||
|
export SCCACHE_SERVER_UDS="$sccache_dir/sccache.sock"
|
||||||
|
'';
|
||||||
# https://devenv.sh/languages/
|
# https://devenv.sh/languages/
|
||||||
languages.rust = {
|
languages.rust = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
|
||||||
66
devenv.nix
66
devenv.nix
|
|
@ -15,6 +15,13 @@ let
|
||||||
pkg-config
|
pkg-config
|
||||||
protobuf
|
protobuf
|
||||||
cargo-cross
|
cargo-cross
|
||||||
|
# opusic-sys (via symphonia-adapter-libopus) compiles libopus from source
|
||||||
|
# with CMake, using the gnumake generator from stdenv. Do NOT add ninja:
|
||||||
|
# its mere presence flips cmake's generator (Make -> Ninja), which then
|
||||||
|
# conflicts with any build dir already cached under the other generator
|
||||||
|
# ("Does not match the generator used previously"). Keeping only cmake
|
||||||
|
# makes the generator deterministic.
|
||||||
|
cmake
|
||||||
# Stream-URL sidecar for the ytdy provider: YouTube caps tokenless
|
# Stream-URL sidecar for the ytdy provider: YouTube caps tokenless
|
||||||
# stream URLs at ~1 MiB and yt-dlp is the only maintained cipher
|
# stream URLs at ~1 MiB and yt-dlp is the only maintained cipher
|
||||||
# solver (architecture/youtube-rustypipe.md, D2-revised).
|
# solver (architecture/youtube-rustypipe.md, D2-revised).
|
||||||
|
|
@ -28,7 +35,10 @@ let
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
imports = [ ./devenv-rust.nix ];
|
imports = [
|
||||||
|
./devenv-rust.nix
|
||||||
|
./devenv-docs.nix
|
||||||
|
];
|
||||||
|
|
||||||
# The wasm target for cbd-web; merges with the languages.rust
|
# The wasm target for cbd-web; merges with the languages.rust
|
||||||
# settings in devenv-rust.nix.
|
# settings in devenv-rust.nix.
|
||||||
|
|
@ -64,6 +74,60 @@ in
|
||||||
scripts.serve-web.exec = ''
|
scripts.serve-web.exec = ''
|
||||||
cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@"
|
cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@"
|
||||||
'';
|
'';
|
||||||
|
# Generates the CLI shell completions and man pages into dist/ by building
|
||||||
|
# the native binaries with CBD_ASSET_DIR set (architecture/cli.md D7). Each
|
||||||
|
# binary's build.rs copies its assets into $CBD_ASSET_DIR when present, so
|
||||||
|
# dist/completions/** and dist/man/*.1 appear after this runs. Unlike
|
||||||
|
# build-web (a wasm build) this is a native build, so RUSTFLAGS is left
|
||||||
|
# alone — the mold linker flag is correct for the native toolchain.
|
||||||
|
scripts.gen-cli-assets.exec = ''
|
||||||
|
cd "$DEVENV_ROOT" && CBD_ASSET_DIR="$DEVENV_ROOT/dist" cargo build "$@"
|
||||||
|
'';
|
||||||
|
# The build-feature matrix (architecture/build-features.md D11,
|
||||||
|
# quality/build-features.md G6/G7). Feature combinations are where cfg rot
|
||||||
|
# hides: a curated set catches what matters without a powerset sweep. Every
|
||||||
|
# entry must be clippy-clean under -D warnings; the two extremes (defaults
|
||||||
|
# and nothing) also run their tests.
|
||||||
|
scripts.check-features.exec = ''
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEVENV_ROOT"
|
||||||
|
clippy() {
|
||||||
|
echo "==> clippy $*"
|
||||||
|
cargo clippy --all-targets "$@" -- -D warnings
|
||||||
|
}
|
||||||
|
test_it() {
|
||||||
|
echo "==> test $*"
|
||||||
|
cargo test "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The two extremes, tests included.
|
||||||
|
clippy -p crabidy-server
|
||||||
|
test_it -p crabidy-server
|
||||||
|
clippy -p crabidy-server --no-default-features
|
||||||
|
test_it -p crabidy-server --no-default-features
|
||||||
|
|
||||||
|
# Each provider on its own: nothing else may be needed to compile it.
|
||||||
|
for feature in tidal youtube fyyd abs soundcloud jamendo fs; do
|
||||||
|
clippy -p crabidy-server --no-default-features --features "$feature"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Each non-provider axis dropped from an otherwise full build.
|
||||||
|
clippy -p crabidy-server --no-default-features --features all-providers,spectrum,web-ui
|
||||||
|
clippy -p crabidy-server --no-default-features --features all-providers,opus,web-ui
|
||||||
|
clippy -p crabidy-server --no-default-features --features all-providers,opus,spectrum
|
||||||
|
# The local-files appliance and the streaming box from the docs.
|
||||||
|
clippy -p crabidy-server --no-default-features --features fs,opus
|
||||||
|
clippy -p crabidy-server --no-default-features --features tidal,web-ui,opus,spectrum
|
||||||
|
|
||||||
|
# The other feature-carrying crates.
|
||||||
|
clippy -p audio-player --no-default-features
|
||||||
|
clippy -p cbd-tui --no-default-features
|
||||||
|
test_it -p cbd-tui --no-default-features
|
||||||
|
clippy -p cbd --no-default-features
|
||||||
|
clippy -p cbd --no-default-features --features fs,opus,notifications
|
||||||
|
|
||||||
|
echo "all feature combinations are clean"
|
||||||
|
'';
|
||||||
|
|
||||||
enterShell = "";
|
enterShell = "";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,24 @@
|
||||||
|
# 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)
|
||||||
|
- [SoundCloud — /soundcloud](./providers/soundcloud.md)
|
||||||
|
- [Jamendo — /jamendo](./providers/jamendo.md)
|
||||||
|
- [audiobookshelf — /abs](./providers/abs.md)
|
||||||
|
- [fyyd — /fyyd](./providers/fyyd.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)
|
||||||
|
- [Tailored builds](./build-features.md)
|
||||||
|
- [Roles and authorization](./auth.md)
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
# 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**.
|
||||||
|
|
||||||
|
It holds one **mount** per provider: the library root it owns (`/tidal`,
|
||||||
|
`/fs`, …), the name shown in the root listing, and the client itself behind a
|
||||||
|
trait object. Every routed call — `get_lib_node`, `resolve_tracks_into`,
|
||||||
|
`get_urls_for_track`, `get_metadata_for_track`, and the
|
||||||
|
create/rename/delete/capture calls — looks up the one mount that owns the
|
||||||
|
path and forwards to it. Ownership stops at a path separator, so `/fsx` is
|
||||||
|
not `/fs`. A path no mount owns is rejected as malformed (or, for editing
|
||||||
|
calls, unsupported) rather than guessed at. The mounts are sorted once, so
|
||||||
|
the root always lists `crabidy` first, `orphans` last, and the rest
|
||||||
|
alphabetically.
|
||||||
|
|
||||||
|
Mounting is the only place a provider is named, which is what makes a
|
||||||
|
provider droppable in three independent ways: not compiled in (see [Tailored
|
||||||
|
builds](./build-features.md)), not enabled in `crabidy-server.toml`, or
|
||||||
|
failed to initialize. A failure to initialize logs a warning and omits that
|
||||||
|
child; the server runs without it. Tidal is the one exception — a Tidal
|
||||||
|
config that exists but cannot be loaded aborts startup. 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,122 @@
|
||||||
|
# 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 password hashes to the `[auth]` section of `crabidy-server.toml`
|
||||||
|
locks the server down **from the top**. Each password you set lowers
|
||||||
|
what a caller with **no credentials** may do; a caller *with* a matching
|
||||||
|
password is elevated to that role. You lock the powerful roles first and
|
||||||
|
leave the weaker ones open for anyone on your network — see
|
||||||
|
[What anonymous callers get](#what-anonymous-callers-get) below.
|
||||||
|
|
||||||
|
## 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. Guard from the top down.
|
||||||
|
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
queue_owner = "$argon2id$v=19$..."
|
||||||
|
queue_appender = "$argon2id$v=19$..."
|
||||||
|
```
|
||||||
|
|
||||||
|
A role's PHC hash — not its password — is what lives in the file. Omit a
|
||||||
|
role's key to leave that role **open**: no password is needed to act as
|
||||||
|
it, and no one can log in as it.
|
||||||
|
|
||||||
|
## What anonymous callers get
|
||||||
|
|
||||||
|
A request with no credentials is not rejected — it is granted the
|
||||||
|
**most powerful role you did *not* guard**. Setting a password walks
|
||||||
|
that floor down one step:
|
||||||
|
|
||||||
|
| Guarded roles | A no-credential caller is… |
|
||||||
|
| --- | --- |
|
||||||
|
| *(none — no `[auth]`)* | **owner** — the open default |
|
||||||
|
| `owner` | **queue-owner** |
|
||||||
|
| `owner` + `queue_owner` | **queue-appender** |
|
||||||
|
| all three | *nothing* — credentials required |
|
||||||
|
|
||||||
|
So to run a server where guests may append but a password is needed to
|
||||||
|
run the queue, guard `owner` **and** `queue_owner` and leave
|
||||||
|
`queue_appender` open.
|
||||||
|
|
||||||
|
Because the anonymous caller always gets the highest open role, guarding
|
||||||
|
a weaker role while a stronger one is still open would be pointless — the
|
||||||
|
anonymous role would simply outrank it. Such a config is treated as a
|
||||||
|
mistake: setting `queue_owner` without `owner`, or `queue_appender`
|
||||||
|
without `queue_owner`, **aborts startup**, and `crabidy-server guard`
|
||||||
|
refuses to write it. Guard the roles from the top down.
|
||||||
|
|
||||||
|
## 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 — or that guards the roles out of order — aborts server
|
||||||
|
startup rather than silently running open: a broken auth config never
|
||||||
|
downgrades to a weaker posture than intended.
|
||||||
|
- **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 and is
|
||||||
|
treated as the anonymous role — which keeps the zero-config local
|
||||||
|
setup working against an open server.
|
||||||
|
- A caller below a method's required role — whether anonymous or logged
|
||||||
|
in — gets gRPC `PERMISSION_DENIED`. A wrong password (and an anonymous
|
||||||
|
request to a fully-locked server) gets `UNAUTHENTICATED`, with every
|
||||||
|
authentication failure answering identically so a caller cannot probe
|
||||||
|
which part was wrong. A wrong password is never quietly downgraded to
|
||||||
|
the anonymous role.
|
||||||
|
|
||||||
|
```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,135 @@
|
||||||
|
# Tailored builds
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Every provider, Opus decoding, the spectrum bars, the embedded web UI and
|
||||||
|
the TUI's desktop notifications sit behind a Cargo **feature**. All of them
|
||||||
|
are **on by default**, so a plain `cargo build` gives you the full player.
|
||||||
|
Turning some off gives you a smaller binary that pulls fewer dependencies —
|
||||||
|
useful for a single-purpose box (a Raspberry Pi playing a local flac
|
||||||
|
collection) or a build environment you want to keep lean.
|
||||||
|
|
||||||
|
This is the *compile-time* half of provider selection. The runtime half —
|
||||||
|
the `providers` list in `crabidy-server.toml` — still works exactly as
|
||||||
|
before; see [Configuration](./config.md#enabling-and-disabling-providers).
|
||||||
|
The two compose in one direction: **a provider that is not compiled in
|
||||||
|
cannot be enabled in the config**, and if you name one anyway the server
|
||||||
|
logs a warning and carries on.
|
||||||
|
|
||||||
|
## What this build has
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crabidy-server features # or: cbd features
|
||||||
|
```
|
||||||
|
|
||||||
|
prints one feature per line — the providers it can mount, then the extras.
|
||||||
|
The same list goes into the server's startup log, so a support question
|
||||||
|
("why is `/tidal` missing?") is answerable from the binary and its log.
|
||||||
|
|
||||||
|
## The features
|
||||||
|
|
||||||
|
| Feature | Turning it off drops |
|
||||||
|
| ------------ | ----------------------------------------------------------- |
|
||||||
|
| `tidal` | the `/tidal` provider (`tidaldy`) |
|
||||||
|
| `youtube` | the `/youtube` provider (`ytdy`, and with it `rustypipe`) |
|
||||||
|
| `fyyd` | the `/fyyd` podcast provider |
|
||||||
|
| `abs` | the `/abs` audiobookshelf provider |
|
||||||
|
| `soundcloud` | the `/soundcloud` provider |
|
||||||
|
| `jamendo` | the `/jamendo` provider |
|
||||||
|
| `fs` | local files **and persistent state** — see below |
|
||||||
|
| `opus` | Ogg-Opus decoding (`symphonia` + a bundled libopus C build) |
|
||||||
|
| `spectrum` | the server-side FFT feeding clients' spectrum bars |
|
||||||
|
| `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) |
|
||||||
|
|
||||||
|
Plus two conveniences: `all-providers` enables the seven provider features
|
||||||
|
at once, and `cbd` (the bundle) mirrors every feature above and adds
|
||||||
|
`notifications` for the TUI's desktop "now playing" popups (`notify-rust`,
|
||||||
|
which on Linux pulls a D-Bus stack).
|
||||||
|
|
||||||
|
### `fs` is more than `/fs`
|
||||||
|
|
||||||
|
The `/fs` provider, the content store, and everything built on it are all
|
||||||
|
written against the same crate, so they share one feature. With `fs` off you
|
||||||
|
lose:
|
||||||
|
|
||||||
|
- the `/fs` mount (your local music folder);
|
||||||
|
- `/crabidy` — saved queues, bookmarks (`w`) and captures (`W`);
|
||||||
|
- `/orphans`, which is a view over the store;
|
||||||
|
- **queue persistence**: the queue lives in memory, so a restart starts
|
||||||
|
empty;
|
||||||
|
- the `scan` command (it fails with a message naming the feature);
|
||||||
|
- captured-track markers in library listings.
|
||||||
|
|
||||||
|
The capture and save-queue RPCs answer `Unimplemented` on such a server, so
|
||||||
|
clients report an ordinary error instead of hanging.
|
||||||
|
|
||||||
|
If you want `/fs` but not `/crabidy`, keep the feature and prune the
|
||||||
|
`providers` list at runtime instead — that is what it is for.
|
||||||
|
|
||||||
|
### `opus` and the libopus build
|
||||||
|
|
||||||
|
`symphonia` (and so rodio) has no Opus decoder, so Ogg-Opus files are
|
||||||
|
decoded through a bundled libopus, which needs `cmake` at build time.
|
||||||
|
Turning `opus` off removes both the crates and that build requirement.
|
||||||
|
|
||||||
|
The feature also decides whether `scan` treats `.opus` files as playable at
|
||||||
|
all, so a build that cannot decode Opus will not index Opus files either. An
|
||||||
|
Opus file that reaches such a build fails to decode with a message naming
|
||||||
|
the missing feature and is skipped, exactly like any unplayable file.
|
||||||
|
|
||||||
|
### `spectrum` and `web-ui`
|
||||||
|
|
||||||
|
`spectrum` only affects the *server*: with it off no frames are computed or
|
||||||
|
broadcast, and clients simply show no bars — nothing else changes, and no
|
||||||
|
client needs rebuilding. `web-ui` drops the embedded browser client; the
|
||||||
|
gRPC service on port 50051 still serves `cbd-tui` and the CLI.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
A local-files appliance — no network providers, no web UI, no FFT:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p crabidy-server \
|
||||||
|
--no-default-features --features fs,opus
|
||||||
|
```
|
||||||
|
|
||||||
|
A streaming box with the browser client and the bars, no local library:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p crabidy-server \
|
||||||
|
--no-default-features --features tidal,web-ui,opus,spectrum
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundle, tailored the same way (its features forward to the server):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p cbd --no-default-features --features fs,opus
|
||||||
|
```
|
||||||
|
|
||||||
|
A terminal client with no D-Bus dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p cbd-tui --no-default-features
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-default-features` on its own is legal and compiles: you get a server
|
||||||
|
that starts, serves an empty library and plays nothing. It is the base case
|
||||||
|
the feature matrix checks, not a useful deployment.
|
||||||
|
|
||||||
|
## What is *not* behind a feature
|
||||||
|
|
||||||
|
- **Authorization.** `[auth]` and its password hashing always ship. A build
|
||||||
|
that ignored configured role hashes would silently run an
|
||||||
|
intended-to-be-locked server open — a fail-open hole not worth a small
|
||||||
|
dependency.
|
||||||
|
- **Audio output.** The server is the player; a server without audio has no
|
||||||
|
purpose.
|
||||||
|
- **HLS streaming and the spectrum tap.** They bring no dependencies of
|
||||||
|
their own, so gating them would add build complexity and save nothing.
|
||||||
|
|
||||||
|
## Checking combinations
|
||||||
|
|
||||||
|
`devenv shell -- check-features` runs the curated matrix — defaults, no
|
||||||
|
features, each provider alone, each extra dropped, both examples above, and
|
||||||
|
the client crates — and requires every one to be clippy-clean. Run it after
|
||||||
|
changing anything that sits behind a feature.
|
||||||
|
|
@ -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,136 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`audio-devices [device]` lists the audio output devices the server can see,
|
||||||
|
marking the one the current config selects. Given a name (or a fragment of
|
||||||
|
one) it writes `[audio] device` into `crabidy-server.toml` and then lists, so
|
||||||
|
one command both configures and confirms — see [The audio output
|
||||||
|
device](../config.md#the-audio-output-device).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crabidy-server audio-devices # list
|
||||||
|
crabidy-server audio-devices Headphones # pin one, then list
|
||||||
|
```
|
||||||
|
|
||||||
|
`features` prints the build features this binary was compiled with — the
|
||||||
|
providers it can mount, then the extras (`opus`, `spectrum`, `web-ui`) — one
|
||||||
|
per line. It answers "why is `/tidal` missing?" without guesswork; see
|
||||||
|
[Tailored builds](../build-features.md).
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ crabidy-server features
|
||||||
|
fs
|
||||||
|
crabidy
|
||||||
|
orphans
|
||||||
|
opus
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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,164 @@
|
||||||
|
# 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.
|
||||||
|
- `v` (or `V`) enters **visual mode** in the library: movement then marks
|
||||||
|
or unmarks the rows you sweep over, like vim's visual selection. The
|
||||||
|
selection is anchored where you entered it, so moving back over a row
|
||||||
|
reverses it. `Esc` leaves visual mode (keeping the marks and any `/`
|
||||||
|
filter), and so does any non-movement action. The pane title shows
|
||||||
|
`— VISUAL` while it is on.
|
||||||
|
- `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 trailing `↓` (down-arrow) at the end of
|
||||||
|
the row, 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.
|
||||||
|
|
||||||
|
Press `f` to show or hide them at runtime; `spectrum` in the client
|
||||||
|
config sets the startup default (`spectrum = true` is the default;
|
||||||
|
`false` starts hidden). Either way it is only 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 |
|
||||||
|
| ------- | ----------------- | ----------------------------------------- |
|
||||||
|
| 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 |
|
||||||
|
| Global | `f` | Toggle the frequency spectrum |
|
||||||
|
| 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 | `v` / `V` | Visual mode: movement toggles marks |
|
||||||
|
| 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,185 @@
|
||||||
|
# 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 config fails to load
|
||||||
|
simply does not mount; it does not stop the server.
|
||||||
|
|
||||||
|
Which providers mount at all is controlled by `crabidy-server.toml` (see
|
||||||
|
[Enabling and disabling providers](#enabling-and-disabling-providers)),
|
||||||
|
which the server now also writes on first start.
|
||||||
|
|
||||||
|
## The config files
|
||||||
|
|
||||||
|
| File | Component | Credentials needed |
|
||||||
|
| --------------------- | ---------------- | --------------------------- |
|
||||||
|
| `abs.toml` | audiobookshelf | server URL + API key |
|
||||||
|
| `fsdy.toml` | local files | none |
|
||||||
|
| `fyyd.toml` | podcasts | none |
|
||||||
|
| `jamendo.toml` | Jamendo | none (a key is shipped) |
|
||||||
|
| `soundcloud.toml` | SoundCloud | none (token optional) |
|
||||||
|
| `tidaly.toml` | Tidal | device login (interactive) |
|
||||||
|
| `ytdy.toml` | YouTube | none (cookies optional) |
|
||||||
|
| `cbd-tui.toml` | `cbd-tui` | server role + password |
|
||||||
|
| `cbd.toml` | `cbd` | server role + password |
|
||||||
|
| `crabidy-server.toml` | server | its own `[auth]` hashes |
|
||||||
|
|
||||||
|
Every file is auto-created with defaults on first start.
|
||||||
|
|
||||||
|
- The provider files each get a full option reference — **including how to log
|
||||||
|
in** — on that provider's page under [Providers](./providers.md).
|
||||||
|
- `cbd-tui.toml` and `cbd.toml` are client configs (below).
|
||||||
|
- `crabidy-server.toml` holds the enabled-providers list (below), the audio
|
||||||
|
output device (below), and server auth (see [Roles and
|
||||||
|
authorization](./auth.md)).
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
Provider credentials and client passwords are stored in **cleartext** — the
|
||||||
|
files are configuration, not a keyring. Keep `~/.config/crabidy/` private
|
||||||
|
(`chmod 700` is a reasonable habit). Crabidy redacts secrets from its logs,
|
||||||
|
error messages, and config dumps.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enabling and disabling providers
|
||||||
|
|
||||||
|
On first start the server writes `crabidy-server.toml` with every provider
|
||||||
|
enabled:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
providers = [
|
||||||
|
"tidal",
|
||||||
|
"youtube",
|
||||||
|
"fyyd",
|
||||||
|
"abs",
|
||||||
|
"soundcloud",
|
||||||
|
"jamendo",
|
||||||
|
"fs",
|
||||||
|
"crabidy",
|
||||||
|
"orphans",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove a name to disable that provider** — it no longer mounts and drops
|
||||||
|
out of the library tree; its own config file (e.g. `tidaly.toml`) is then
|
||||||
|
left unread. Deleting the whole `providers` line re-enables everything (the
|
||||||
|
same as a fresh install with no file). Because `orphans` is a view over the
|
||||||
|
store, disabling `crabidy` disables `orphans` too.
|
||||||
|
|
||||||
|
The list can only offer what the binary was **built** with: providers are
|
||||||
|
also selectable at compile time (see [Tailored
|
||||||
|
builds](./build-features.md)). The default list written on first start
|
||||||
|
therefore names only the providers this build has, and if you add one it
|
||||||
|
does not have, the server logs a warning at startup and ignores it. Run
|
||||||
|
`crabidy-server features` to see what a binary contains.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```admonish note
|
||||||
|
A binary built without the `fs` feature has no content store at all: no
|
||||||
|
`/crabidy`, no bookmarks or captures, and the queue is not persisted across
|
||||||
|
restarts. See [Tailored builds](./build-features.md).
|
||||||
|
```
|
||||||
|
|
||||||
|
## The audio output device
|
||||||
|
|
||||||
|
By default the server plays to the system default output device. On a
|
||||||
|
Raspberry Pi that is often HDMI, so playback runs but nothing comes out of the
|
||||||
|
headphone jack or a USB DAC. Ask the server what it can see:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ crabidy-server audio-devices
|
||||||
|
Audio output devices (* = selected by the current config):
|
||||||
|
hdmi:CARD=vc4hdmi,DEV=0
|
||||||
|
sysdefault:CARD=Headphones
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pin one by passing it to the same command, and restart the server:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ crabidy-server audio-devices Headphones
|
||||||
|
Set [audio] device = "Headphones" in .../crabidy-server.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is matched case-insensitively as a **substring** of the device
|
||||||
|
name, so a memorable fragment is enough. It writes:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[audio]
|
||||||
|
device = "Headphones"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the fragment matches nothing, both the command and the server warn, and
|
||||||
|
the server falls back to the system default.
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
# 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, the behaviour you can rely on, and every
|
||||||
|
configuration option. It is reference documentation — it describes the system
|
||||||
|
as it is, not the history of how it got there.
|
||||||
|
|
||||||
|
```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 {
|
||||||
|
streaming: "Tidal · YouTube\nSoundCloud · Jamendo"
|
||||||
|
spoken: "audiobookshelf · fyyd"
|
||||||
|
fs: "Local files\n+ the crabidy store"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/
|
||||||
|
├── crabidy your saves: queues, bookmarks, and captures
|
||||||
|
├── abs audiobookshelf audiobooks
|
||||||
|
├── fs a local music folder
|
||||||
|
├── fyyd podcast search
|
||||||
|
├── jamendo Creative-Commons music
|
||||||
|
├── soundcloud SoundCloud search, likes, and playlists
|
||||||
|
├── tidal Tidal streaming
|
||||||
|
├── youtube YouTube search & playlists
|
||||||
|
└── orphans store audio no save references any more
|
||||||
|
```
|
||||||
|
|
||||||
|
Which of these you see depends on what your binary was built with, what you
|
||||||
|
enabled, and which providers came up — see [Providers](./providers.md).
|
||||||
|
|
||||||
|
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) — every config file and option, including
|
||||||
|
how to log in to each provider.
|
||||||
|
- [Tailored builds](./build-features.md) — dropping providers and features
|
||||||
|
you do not need at compile time.
|
||||||
|
- [Roles and authorization](./auth.md) — locking a server down.
|
||||||
|
|
@ -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,111 @@
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
crabidy: "/crabidy — your saves & captures"
|
||||||
|
abs: "/abs — audiobookshelf audiobooks"
|
||||||
|
fs: "/fs — a local music folder"
|
||||||
|
fyyd: "/fyyd — podcast search"
|
||||||
|
jamendo: "/jamendo — Creative-Commons catalogue"
|
||||||
|
soundcloud: "/soundcloud — SoundCloud"
|
||||||
|
tidal: "/tidal — Tidal streaming"
|
||||||
|
youtube: "/youtube — YouTube search & playlists"
|
||||||
|
orphans: "/orphans — unreferenced store audio"
|
||||||
|
|
||||||
|
root -> crabidy: "route /crabidy/*"
|
||||||
|
root -> abs: "route /abs/*"
|
||||||
|
root -> fs: "route /fs/*"
|
||||||
|
root -> fyyd: "route /fyyd/*"
|
||||||
|
root -> jamendo: "route /jamendo/*"
|
||||||
|
root -> soundcloud: "route /soundcloud/*"
|
||||||
|
root -> tidal: "route /tidal/*"
|
||||||
|
root -> youtube: "route /youtube/*"
|
||||||
|
root -> orphans: "route /orphans/*"
|
||||||
|
```
|
||||||
|
|
||||||
|
The root lists its children in a fixed order: `crabidy` first, `orphans`
|
||||||
|
last, and everything else alphabetically in between.
|
||||||
|
|
||||||
|
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 appears in the library only if it got that far, and three
|
||||||
|
independent things decide it:
|
||||||
|
|
||||||
|
1. **Was it built in?** Every provider sits behind a Cargo feature (all on by
|
||||||
|
default). A binary built without one can never mount it — see [Tailored
|
||||||
|
builds](../build-features.md), and `crabidy-server features` to see what a
|
||||||
|
binary has.
|
||||||
|
2. **Is it enabled?** The `providers` list in `crabidy-server.toml` turns
|
||||||
|
compiled-in providers on and off without a rebuild — see
|
||||||
|
[Configuration](../config.md#enabling-and-disabling-providers).
|
||||||
|
3. **Did it initialize?** Only a client that came up successfully is mounted.
|
||||||
|
|
||||||
|
```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 only
|
||||||
|
exception is Tidal: a Tidal config that exists but cannot be loaded aborts
|
||||||
|
startup rather than silently dropping your account.
|
||||||
|
```
|
||||||
|
|
||||||
|
So the exact set of top-level entries depends on your build *and* your
|
||||||
|
configuration: `/tidal` needs a login, `/abs` needs
|
||||||
|
credentials, `/youtube` needs its client to build, `/fs` needs a readable
|
||||||
|
root directory, and `/crabidy` needs a writable state directory.
|
||||||
|
|
||||||
|
## Today's providers
|
||||||
|
|
||||||
|
Listed in the order the library root serves them.
|
||||||
|
|
||||||
|
- **`/crabidy`** — where the server writes everything you save: saved queues,
|
||||||
|
bookmarks, and downloaded captures, backed by a content-addressed audio
|
||||||
|
store. It has its own page: [The crabidy store](./store.md).
|
||||||
|
- **[`/abs`](./providers/abs.md)** — audiobooks from a self-hosted
|
||||||
|
audiobookshelf server. Needs a server URL and an API key (`abs.toml`).
|
||||||
|
- **[`/fs`](./providers/fs.md)** — a local music folder: directories become
|
||||||
|
nodes, `*.cbd-track.toml` files become tracks. No login (`fsdy.toml`).
|
||||||
|
- **[`/fyyd`](./providers/fyyd.md)** — podcast search over fyyd's public
|
||||||
|
API: find a podcast, drill into its episodes, play them. No account of any
|
||||||
|
kind (`fyyd.toml`).
|
||||||
|
- **[`/jamendo`](./providers/jamendo.md)** — Jamendo's catalogue of
|
||||||
|
Creative-Commons music, by search or album. Works out of the box on a
|
||||||
|
shipped app key; bring your own for your own rate limit (`jamendo.toml`).
|
||||||
|
- **[`/soundcloud`](./providers/soundcloud.md)** — SoundCloud search, link
|
||||||
|
resolving, and playback with no credentials at all; an optional token adds
|
||||||
|
your likes and playlists (`soundcloud.toml`).
|
||||||
|
- **[`/tidal`](./providers/tidal.md)** — a Tidal account: playlists,
|
||||||
|
favorite artists and their albums, mixes, and search. Interactive device
|
||||||
|
login on first start (`tidaly.toml`).
|
||||||
|
- **[`/youtube`](./providers/youtube.md)** — YouTube search, plus an
|
||||||
|
account's saved playlists when cookies are configured (`ytdy.toml`).
|
||||||
|
- **`/orphans`** — store audio that no save references any more, surfaced so
|
||||||
|
you can reclaim it (rename it into a save, queue it, or delete it). It is a
|
||||||
|
view over the store, not a separate source, so it comes and goes with
|
||||||
|
`/crabidy`.
|
||||||
|
|
||||||
|
Each provider page documents its tree, how to log in where that applies, and
|
||||||
|
every option in its config file.
|
||||||
|
|
||||||
|
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,79 @@
|
||||||
|
# audiobookshelf — /abs
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Mounts a self-hosted [audiobookshelf](https://www.audiobookshelf.org/)
|
||||||
|
server at `/abs`, so your audiobooks browse, queue, and play like any other
|
||||||
|
part of the library. Audiobooks only for now — podcast libraries on the ABS
|
||||||
|
server are not listed.
|
||||||
|
|
||||||
|
## Logging in
|
||||||
|
|
||||||
|
The provider needs two things in `abs.toml`: your server's base URL and an
|
||||||
|
**API key**. Without both, `/abs` does not mount and the rest of the server
|
||||||
|
carries on.
|
||||||
|
|
||||||
|
1. Open your audiobookshelf web UI as the user whose libraries you want.
|
||||||
|
2. Go to **Settings → Users → (your user) → API Keys** (on older versions:
|
||||||
|
**Settings → API Keys**) and create a key.
|
||||||
|
3. Put it in `~/.config/crabidy/abs.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
base_url = "https://audiobookshelf.example.com"
|
||||||
|
api_key = "<the key you just created>"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the server. `/abs` appears with one child per book library.
|
||||||
|
|
||||||
|
There is no interactive login and no token to refresh: the API key is a
|
||||||
|
long-lived bearer token. Revoke it in the same screen to cut access.
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
The API key grants access to your audiobookshelf account. Crabidy redacts it
|
||||||
|
from logs and never prints it, but keep `abs.toml` private — it is stored in
|
||||||
|
cleartext, like every other provider credential.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
/abs
|
||||||
|
└── <library> one node per "book" library
|
||||||
|
├── search create a term with `%`
|
||||||
|
│ └── <term> books matching the term
|
||||||
|
│ └── <book> the book's audio files as tracks
|
||||||
|
└── <book> an audiobook
|
||||||
|
└── <file> one audio file = one track
|
||||||
|
```
|
||||||
|
|
||||||
|
- A **library** lists its books plus a creatable `search` node. Search is
|
||||||
|
scoped per library: a term created under one library is not visible under
|
||||||
|
another. See [Search](./search.md).
|
||||||
|
- A **book** lists its audio files as tracks, in the server's order. Queue or
|
||||||
|
capture the whole book, or a single file.
|
||||||
|
- An item with no audio (an ebook-only entry) is listed but not queueable.
|
||||||
|
|
||||||
|
## Playback
|
||||||
|
|
||||||
|
Each file streams directly from audiobookshelf over HTTP with range
|
||||||
|
requests — no transcoding session is opened, and the audio is whatever the
|
||||||
|
server stores (commonly Opus or MP3; Opus needs the `opus` build feature, see
|
||||||
|
[Tailored builds](../build-features.md)). Captures (`W`) download the same
|
||||||
|
files into the content store.
|
||||||
|
|
||||||
|
## Configuration — `abs.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Required — without both of these the provider does not mount.
|
||||||
|
base_url = "https://audiobookshelf.example.com"
|
||||||
|
api_key = "<your audiobookshelf API key>"
|
||||||
|
|
||||||
|
# Optional, defaults shown.
|
||||||
|
items_per_library = 200 # books listed per library
|
||||||
|
search_results = 50 # books listed per search term
|
||||||
|
call_timeout_secs = 30 # per-request timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
Listings are fetched fresh on every visit and capped by the values above.
|
||||||
|
Only your typed search terms are remembered, and only in memory until the
|
||||||
|
server restarts.
|
||||||
|
|
@ -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,78 @@
|
||||||
|
# fyyd — /fyyd
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
The fyyd provider (crate `fyyd`) mounts podcasts under `/fyyd`, backed by
|
||||||
|
[fyyd](https://fyyd.de)'s public podcast search engine
|
||||||
|
(`api.fyyd.de`). It needs no account or API key: you search for a
|
||||||
|
podcast, drill into its episodes, and play them.
|
||||||
|
|
||||||
|
## How it differs from the music providers
|
||||||
|
|
||||||
|
A music search returns tracks. A *podcast* search returns **podcasts**,
|
||||||
|
and each podcast is a container of **episodes**. So `/fyyd` carries one
|
||||||
|
level more than `/youtube`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/fyyd/search/<term> → matching podcasts
|
||||||
|
/fyyd/search/<term>/<podcast> → that podcast's episodes (tracks)
|
||||||
|
```
|
||||||
|
|
||||||
|
An episode is an ordinary track whose audio is the `enclosure` URL from
|
||||||
|
the podcast's feed — a plain media file the built-in player streams
|
||||||
|
directly. There is no sidecar, cipher solving, or byte-proxying.
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
- `/fyyd/search` — always present. Press `%` to create a search term
|
||||||
|
(see [Search](./search.md)); the term node lists matching podcasts.
|
||||||
|
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.
|
||||||
|
- `/fyyd/hot` — always present. A fixed browse of fyyd's currently
|
||||||
|
featured ("hot") podcasts, so the provider is useful with no typing.
|
||||||
|
- `/fyyd/<branch>/<podcast>` — one podcast's episodes as tracks;
|
||||||
|
queueable and downloadable, so you can queue or capture (`W`) a whole
|
||||||
|
podcast at once (bounded, see below).
|
||||||
|
|
||||||
|
Track paths end in the fyyd episode id, which is also the wire track's
|
||||||
|
`provider_item_id` — a stable identity independent of the path, so the
|
||||||
|
same episode de-duplicates in the crabidy store (see [The crabidy
|
||||||
|
store](../store.md)).
|
||||||
|
|
||||||
|
## Configuration — `~/.config/crabidy/fyyd.toml`
|
||||||
|
|
||||||
|
fyyd's API needs no credentials, so the file is optional and every
|
||||||
|
option has a default:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Podcasts listed per search term. Default: 20.
|
||||||
|
# search_results = 20
|
||||||
|
|
||||||
|
# Featured podcasts under /fyyd/hot. Default: 20.
|
||||||
|
# hot_count = 20
|
||||||
|
|
||||||
|
# Episodes listed per podcast (also a hard cap so a huge back catalogue
|
||||||
|
# cannot stall the library). Default: 100.
|
||||||
|
# episodes_per_podcast = 100
|
||||||
|
|
||||||
|
# Per-request timeout in seconds. Default: 30.
|
||||||
|
# call_timeout_secs = 30
|
||||||
|
|
||||||
|
# API base URL override (a mirror, or a test server). Default:
|
||||||
|
# "https://api.fyyd.de/0.2".
|
||||||
|
# base_url = "https://api.fyyd.de/0.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Every listing is fetched fresh per request; only your search *terms* are
|
||||||
|
kept, in memory. A client that fails to build disables only `/fyyd` with
|
||||||
|
a warning — never the server.
|
||||||
|
|
||||||
|
## Captures
|
||||||
|
|
||||||
|
Podcasts and their episode lists are downloadable: `W` captures a
|
||||||
|
podcast's episodes into `/crabidy`, the same way it works for any other
|
||||||
|
provider.
|
||||||
|
|
||||||
|
Everything the provider needs is public: fyyd requires no account, no key,
|
||||||
|
and no login of any kind.
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Jamendo — /jamendo
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Mounts [Jamendo](https://www.jamendo.com) at `/jamendo`: search a large
|
||||||
|
catalogue of Creative-Commons licensed music, play it, and browse the album a
|
||||||
|
track belongs to.
|
||||||
|
|
||||||
|
## Logging in
|
||||||
|
|
||||||
|
There is nothing to do. Jamendo has no user login — its public catalogue
|
||||||
|
needs only an **app key** (`client_id`) that identifies the *application*, not
|
||||||
|
you, and crabidy ships a working default. `/jamendo` mounts out of the box,
|
||||||
|
and the key it used is written into `jamendo.toml` so you can always see which
|
||||||
|
one is in force.
|
||||||
|
|
||||||
|
You may still want your own key, because Jamendo's rate limit is **per key**:
|
||||||
|
a shipped default is a shared budget, and a heavy user is better off with
|
||||||
|
their own.
|
||||||
|
|
||||||
|
1. Register at [devportal.jamendo.com](https://devportal.jamendo.com) and
|
||||||
|
create an application.
|
||||||
|
2. Copy its **Client ID**.
|
||||||
|
3. Put it in `~/.config/crabidy/jamendo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
client_id = "<your jamendo client id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the server. A configured key always wins over the default.
|
||||||
|
|
||||||
|
No token refresh, no expiry, nothing to maintain. The key is treated as a
|
||||||
|
semi-secret: it is redacted from logs, but keep the file private.
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
/jamendo
|
||||||
|
├── search create a search term with `%`
|
||||||
|
│ └── <term> matching tracks
|
||||||
|
├── track/<id> a single track (canonical address)
|
||||||
|
└── album/<id> an album and its tracks (canonical address)
|
||||||
|
```
|
||||||
|
|
||||||
|
`search` is a creatable node: press `%` and type a term, and the term becomes
|
||||||
|
a child node holding its results. Rename it (`e`) to re-run the search,
|
||||||
|
delete it (`d`) to drop it. Terms live in memory only, so a restart forgets
|
||||||
|
the list. See [Search](./search.md).
|
||||||
|
|
||||||
|
Tracks and albums are addressed by id whatever browse node you reached them
|
||||||
|
through, so a queued track keeps working after the search term that found it
|
||||||
|
is gone. From a track you can navigate to its album to hear the rest of it.
|
||||||
|
|
||||||
|
## Playback
|
||||||
|
|
||||||
|
Jamendo hands out a direct MP3 URL per track, which the player streams over
|
||||||
|
its ordinary HTTP path — no HLS, no helper binary, and seeking works.
|
||||||
|
Captures (`W`) download those files into the content store.
|
||||||
|
|
||||||
|
The default `audioformat` is `mp31`, Jamendo's freely streamable MP3. `mp32`
|
||||||
|
(higher bitrate) is not reliably provisioned for streaming and comes back
|
||||||
|
empty for many tracks, so it is a poor default; a Pro account can set it
|
||||||
|
explicitly. If a track has no audio in the requested format the provider
|
||||||
|
falls back rather than failing the listing.
|
||||||
|
|
||||||
|
## Configuration — `jamendo.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# The app key. Unset (or blank) uses the key crabidy ships with, which is
|
||||||
|
# written back here on first start so you can see what is in use. Replace it
|
||||||
|
# with your own from devportal.jamendo.com to get your own rate limit.
|
||||||
|
# client_id = "..."
|
||||||
|
|
||||||
|
# Optional, defaults shown.
|
||||||
|
audioformat = "mp31" # "mp31" (default) or "mp32" (see above)
|
||||||
|
search_results = 50 # tracks per search term (Jamendo caps at 200)
|
||||||
|
album_tracks = 200 # tracks listed per album
|
||||||
|
call_timeout_secs = 30 # per-request timeout
|
||||||
|
```
|
||||||
|
|
@ -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,92 @@
|
||||||
|
# SoundCloud — /soundcloud
|
||||||
|
|
||||||
|
<!-- toc -->
|
||||||
|
|
||||||
|
Mounts SoundCloud at `/soundcloud`: search the public catalogue, paste a
|
||||||
|
track or playlist link to resolve it, and — if you configure a token — browse
|
||||||
|
your own likes and playlists.
|
||||||
|
|
||||||
|
## Logging in (optional)
|
||||||
|
|
||||||
|
SoundCloud is the one provider that works with **no credentials at all**.
|
||||||
|
Public browsing and playback need only a `client_id`, which the provider
|
||||||
|
scrapes from SoundCloud's own web app on first start and then writes back
|
||||||
|
into `soundcloud.toml` so later starts reuse it. Nothing to do.
|
||||||
|
|
||||||
|
A login only adds two nodes — `likes` and `playlists`. To enable them, put an
|
||||||
|
OAuth token in `~/.config/crabidy/soundcloud.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
oauth_token = "<your soundcloud oauth token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The token is the value SoundCloud's own web app uses for your session; read
|
||||||
|
it out of a logged-in browser (the `oauth_token` cookie on `soundcloud.com`,
|
||||||
|
or the `Authorization: OAuth …` header on any API request in the network
|
||||||
|
tab). There is no device-code flow — SoundCloud does not offer public API
|
||||||
|
registration any more, so this is the available route.
|
||||||
|
|
||||||
|
Restart the server after adding it: `likes` and `playlists` appear at the
|
||||||
|
provider root. If the token is rejected or expires, those two nodes are the
|
||||||
|
only thing lost — search, resolve, and playback keep working.
|
||||||
|
|
||||||
|
```admonish warning
|
||||||
|
The token is a live session credential for your account. It is redacted from
|
||||||
|
logs and from every debug dump, but `soundcloud.toml` holds it in cleartext —
|
||||||
|
keep the file private, and revoke the session in SoundCloud's settings if it
|
||||||
|
leaks.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
/soundcloud
|
||||||
|
├── search create a search term with `%`
|
||||||
|
│ └── <term> matching tracks
|
||||||
|
├── resolve create an entry from a soundcloud.com URL
|
||||||
|
│ └── <url> the track or playlist that URL points at
|
||||||
|
├── likes your liked tracks (only with a token)
|
||||||
|
└── playlists your playlists (only with a token)
|
||||||
|
└── <playlist> its tracks
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `search` and `resolve` are creatable nodes: press `%` and type a search
|
||||||
|
term or paste a URL. The entry becomes a child node you can rename (`e`,
|
||||||
|
which re-runs it) or delete (`d`). Entries live in memory, so a server
|
||||||
|
restart forgets the list — navigating back to a remembered path recreates it.
|
||||||
|
See [Search](./search.md).
|
||||||
|
|
||||||
|
Tracks and playlists are addressed canonically by id
|
||||||
|
(`/soundcloud/track/<id>`, `/soundcloud/playlist/<id>`), whichever browse
|
||||||
|
node you reached them through, so a queued track keeps working after you
|
||||||
|
delete the search term that found it.
|
||||||
|
|
||||||
|
## Playback
|
||||||
|
|
||||||
|
SoundCloud serves audio as **HLS**: the provider resolves a signed `.m3u8`
|
||||||
|
media URL and the player streams its MP3 segments in order as one continuous
|
||||||
|
stream. That URL is a short-lived secret and never appears in logs. Seeking
|
||||||
|
within an HLS track is not supported (the stream is forward-only); everything
|
||||||
|
else — play, pause, next, capture — behaves normally.
|
||||||
|
|
||||||
|
## Configuration — `soundcloud.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Scraped and written back by the provider; you normally never touch these.
|
||||||
|
# Delete them to force a fresh scrape on the next start.
|
||||||
|
# client_id = "..."
|
||||||
|
# app_version = "..."
|
||||||
|
|
||||||
|
# Optional login: adds the `likes` and `playlists` nodes.
|
||||||
|
# oauth_token = "..."
|
||||||
|
|
||||||
|
# Optional, defaults shown.
|
||||||
|
search_results = 50 # tracks per search term
|
||||||
|
playlist_tracks = 500 # tracks hydrated per playlist
|
||||||
|
call_timeout_secs = 30 # per-request timeout
|
||||||
|
hls_deadline_secs = 300 # total deadline for fetching one HLS stream
|
||||||
|
```
|
||||||
|
|
||||||
|
If SoundCloud rotates its web app and the stored `client_id` stops working,
|
||||||
|
the provider re-scrapes on the next start; if it cannot obtain one at all,
|
||||||
|
`/soundcloud` is dropped with a warning and the server runs on.
|
||||||
|
|
@ -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,89 @@
|
||||||
|
# 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 crate-level notes on the rustypipe/`yt-dlp` split, see
|
||||||
|
`ytdy/README.md`.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue