feat(orbit): add shared library foundations and gated host tools

Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.

Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.

Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.

Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.

cx
This commit is contained in:
vicrodh committed 2026-09-12 13:02:29 -06:00
1 parent 6394ef176c
commit 74e1610433
75 files changed
+6437 -1470

No files matched your search

+12 -1
View File
@@ -34,6 +34,8 @@
# pre-release # pre-release
# integration so regressions land before a release cut. # integration so regressions land before a release cut.
# Shared cargo-test.sh also gates Orbit HTTP host isolation, library profiles
# and the SSE public-identity projection. No separate test command to drift.
name: test-crates name: test-crates
on: on:
@@ -52,6 +54,10 @@ on:
- "scripts/packaging/smoke_release.py" - "scripts/packaging/smoke_release.py"
- "scripts/packaging/test_smoke_release.py" - "scripts/packaging/test_smoke_release.py"
- "scripts/qml-audits/**" - "scripts/qml-audits/**"
- "scripts/qml-tests/**"
- "scripts/test-search-local-qml.sh"
- "scripts/test-orbit-qml.sh"
- "scripts/test-orbit-daemon.py"
- ".github/workflows/test-crates.yml" - ".github/workflows/test-crates.yml"
push: push:
# Integration branch only — never main (a push to main is a release merge). # Integration branch only — never main (a push to main is a release merge).
@@ -67,6 +73,10 @@ on:
- "scripts/packaging/smoke_release.py" - "scripts/packaging/smoke_release.py"
- "scripts/packaging/test_smoke_release.py" - "scripts/packaging/test_smoke_release.py"
- "scripts/qml-audits/**" - "scripts/qml-audits/**"
- "scripts/qml-tests/**"
- "scripts/test-search-local-qml.sh"
- "scripts/test-orbit-qml.sh"
- "scripts/test-orbit-daemon.py"
- ".github/workflows/test-crates.yml" - ".github/workflows/test-crates.yml"
workflow_dispatch: workflow_dispatch:
@@ -214,7 +224,8 @@ jobs:
# The exact QT=1 half of scripts/cargo-test.sh, minus its `test` job # The exact QT=1 half of scripts/cargo-test.sh, minus its `test` job
# (already run above) and its shader gate (the shader-gate job, so qsb # (already run above) and its shader gate (the shader-gate job, so qsb
# is never on PATH while cargo runs here): dep-graph gate, cargo test # is never on PATH while cargo runs here): dep-graph gate, cargo test
# -p qbz-qt, Local Library/Kiosk artwork/navigation regressions, debug/release # -p qbz-qt, Local Library/Kiosk artwork/navigation and federated-search
# view/activation regressions, debug/release
# offscreen boot. One shared runtime script. # offscreen boot. One shared runtime script.
- name: qt gates (scripts/cargo-test.sh QT=1, without the workspace test) - name: qt gates (scripts/cargo-test.sh QT=1, without the workspace test)
env: env:
+22
View File
@@ -4027,6 +4027,25 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
[[package]]
name = "qbz-control"
version = "2.1.1"
dependencies = [
"log",
"qbz-core",
"qbz-library",
"qbz-models",
"qbz-qobuz",
"qbz-reco",
"serde",
"serde_json",
"tempfile",
"tiny_http",
"tokio",
"urlencoding",
"uuid 1.26.0",
]
[[package]] [[package]]
name = "qbz-core" name = "qbz-core"
version = "2.1.1" version = "2.1.1"
@@ -4418,6 +4437,7 @@ dependencies = [
"qbz-audio", "qbz-audio",
"qbz-cache", "qbz-cache",
"qbz-cast", "qbz-cast",
"qbz-control",
"qbz-core", "qbz-core",
"qbz-credentials", "qbz-credentials",
"qbz-dac-wizard-core", "qbz-dac-wizard-core",
@@ -4585,9 +4605,11 @@ dependencies = [
"open", "open",
"qbz-app", "qbz-app",
"qbz-audio", "qbz-audio",
"qbz-control",
"qbz-core", "qbz-core",
"qbz-credentials", "qbz-credentials",
"qbz-integrations", "qbz-integrations",
"qbz-library",
"qbz-log", "qbz-log",
"qbz-media-controls", "qbz-media-controls",
"qbz-models", "qbz-models",
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"qbz-app", "qbz-app",
"qbz-control",
"qbz-theme", "qbz-theme",
"qbz-models", "qbz-models",
"qbz-audio", "qbz-audio",
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "qbz-control"
version.workspace = true
edition = "2021"
license = "MIT"
description = "Shared HTTP API infrastructure for QBZ hosts"
[dependencies]
uuid = { version = "1", features = ["v4"] }
qbz-library = { path = "../qbz-library" }
qbz-models = { path = "../qbz-models" }
qbz-core = { path = "../qbz-core" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-reco = { path = "../qbz-reco" }
tokio.workspace = true
serde_json.workspace = true
serde.workspace = true
log.workspace = true
tiny_http = "0.12"
urlencoding = "2"
[dev-dependencies]
tempfile = "3"
@@ -1,3 +1,4 @@
use qbz_models::FrontendAdapter;
// crates/qbzd/src/api/artwork.rs — GET /api/artwork/current (CONSOLE ext). // crates/qbzd/src/api/artwork.rs — GET /api/artwork/current (CONSOLE ext).
// //
// A stable redirect to the CURRENT track's cover art: a daemon-only client (a // A stable redirect to the CURRENT track's cover art: a daemon-only client (a
@@ -10,12 +11,14 @@ use std::io::Cursor;
use tiny_http::{Header, Response}; use tiny_http::{Header, Response};
use super::{err_json, ApiState}; use super::{err_json, CatalogContext};
/// `GET /api/artwork/current` → `302` to the current cover, or `404` when /// `GET /api/artwork/current` → `302` to the current cover, or `404` when
/// nothing is playing / the track carries no art. /// nothing is playing / the track carries no art.
pub fn current(state: &ApiState) -> Response<Cursor<Vec<u8>>> { pub fn current(
let queue = state.rt.block_on(state.runtime.core().get_queue_state()); state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
) -> Response<Cursor<Vec<u8>>> {
let queue = state.rt.block_on(state.core.get_queue_state());
let url = queue let url = queue
.current_track .current_track
.as_ref() .as_ref()
@@ -24,9 +27,16 @@ pub fn current(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
match url { match url {
Some(u) => match Header::from_bytes(&b"Location"[..], u.as_bytes()) { Some(u) => match Header::from_bytes(&b"Location"[..], u.as_bytes()) {
Ok(location) => Response::from_data(Vec::new()).with_status_code(302).with_header(location), Ok(location) => Response::from_data(Vec::new())
.with_status_code(302)
.with_header(location),
// A non-header-safe URL (control bytes) — never expected from the CDN. // A non-header-safe URL (control bytes) — never expected from the CDN.
Err(_) => err_json(500, "internal", "artwork url is not a valid redirect target", "check: qbzd now"), Err(_) => err_json(
500,
"internal",
"artwork url is not a valid redirect target",
"check: qbzd now",
),
}, },
None => err_json( None => err_json(
404, 404,
@@ -13,9 +13,9 @@ use std::io::Cursor;
use serde_json::Value; use serde_json::Value;
use tiny_http::Response; use tiny_http::Response;
use crate::state::AuthState; use qbz_models::FrontendAdapter;
use super::{err_json, json, ApiState}; use super::{err_json, json, CatalogContext};
const DEFAULT_LIMIT: u32 = 20; const DEFAULT_LIMIT: u32 = 20;
const MAX_LIMIT: u32 = 100; const MAX_LIMIT: u32 = 100;
@@ -25,22 +25,32 @@ const QUEUE_SEED_CAP: usize = 20;
/// `GET /api/album?id=<ALBUM_ID>&suggest=<0|1>`. The full album envelope /// `GET /api/album?id=<ALBUM_ID>&suggest=<0|1>`. The full album envelope
/// (tracklist, ImageSet artwork, description, awards) via `core.get_album`; /// (tracklist, ImageSet artwork, description, awards) via `core.get_album`;
/// `suggest=1` also includes similar albums (`get_album_suggest`). /// `suggest=1` also includes similar albums (`get_album_suggest`).
pub fn album(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn album(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
let p = parse(query); let p = parse(query);
let id = match p.get("id") { let id = match p.get("id") {
Some(v) if !v.is_empty() => v.clone(), Some(v) if !v.is_empty() => v.clone(),
_ => return err_json(400, "bad_request", "album requires an id", "usage: qbzd album <ALBUM_ID>"), _ => {
return err_json(
400,
"bad_request",
"album requires an id",
"usage: qbzd album <ALBUM_ID>",
)
}
}; };
let album = match state.rt.block_on(state.runtime.core().get_album(&id)) { let album = match state.rt.block_on(state.core.get_album(&id)) {
Ok(a) => serde_json::to_value(a).unwrap_or(Value::Null), Ok(a) => serde_json::to_value(a).unwrap_or(Value::Null),
Err(_) => return not_found("album", &id), Err(_) => return not_found("album", &id),
}; };
let similar = if wants(&p, "suggest") { let similar = if wants(&p, "suggest") {
match state.rt.block_on(state.runtime.core().get_album_suggest(&id)) { match state.rt.block_on(state.core.get_album_suggest(&id)) {
Ok(s) => serde_json::to_value(s).unwrap_or(Value::Null), Ok(s) => serde_json::to_value(s).unwrap_or(Value::Null),
Err(_) => Value::Null, Err(_) => Value::Null,
} }
@@ -54,42 +64,72 @@ pub fn album(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
/// `GET /api/artist?id=<ID>&view=page|top|albums&limit=&offset=&release_type=`. /// `GET /api/artist?id=<ID>&view=page|top|albums&limit=&offset=&release_type=`.
/// `page` (default) = the artist page (bio, top tracks, similar); `top` = the /// `page` (default) = the artist page (bio, top tracks, similar); `top` = the
/// full top-tracks list; `albums` = the paged releases grid. /// full top-tracks list; `albums` = the paged releases grid.
pub fn artist(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn artist(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
let p = parse(query); let p = parse(query);
let id = match p.get("id").and_then(|v| v.parse::<u64>().ok()) { let id = match p.get("id").and_then(|v| v.parse::<u64>().ok()) {
Some(id) => id, Some(id) => id,
None => return err_json(400, "bad_request", "artist requires a numeric id", "usage: qbzd artist <ARTIST_ID>"), None => {
return err_json(
400,
"bad_request",
"artist requires a numeric id",
"usage: qbzd artist <ARTIST_ID>",
)
}
}; };
let (limit, offset) = limit_offset(&p); let (limit, offset) = limit_offset(&p);
let view = p.get("view").map(String::as_str).unwrap_or("page"); let view = p.get("view").map(String::as_str).unwrap_or("page");
let core = state.runtime.core(); let core = state.core;
match view { match view {
"page" => match state.rt.block_on(core.get_artist_page(id, None)) { "page" => match state.rt.block_on(core.get_artist_page(id, None)) {
Ok(r) => json(200, serde_json::json!({"view": "page", "page": serde_json::to_value(r).unwrap_or(Value::Null)})), Ok(r) => json(
200,
serde_json::json!({"view": "page", "page": serde_json::to_value(r).unwrap_or(Value::Null)}),
),
Err(_) => not_found("artist", &id.to_string()), Err(_) => not_found("artist", &id.to_string()),
}, },
"top" => match state.rt.block_on(core.get_artist_tracks(id, limit, offset)) { "top" => match state.rt.block_on(core.get_artist_tracks(id, limit, offset)) {
Ok(tc) => json(200, serde_json::json!({"view": "top", "tracks": serde_json::to_value(tc).unwrap_or(Value::Null)})), Ok(tc) => json(
200,
serde_json::json!({"view": "top", "tracks": serde_json::to_value(tc).unwrap_or(Value::Null)}),
),
Err(_) => not_found("artist", &id.to_string()), Err(_) => not_found("artist", &id.to_string()),
}, },
"albums" => { "albums" => {
let release_type = p.get("release_type").map(String::as_str).unwrap_or("album"); let release_type = p.get("release_type").map(String::as_str).unwrap_or("album");
match state.rt.block_on(core.get_releases_grid(id, release_type, limit, offset, None)) { match state
Ok(r) => json(200, serde_json::json!({"view": "albums", "releases": serde_json::to_value(r).unwrap_or(Value::Null)})), .rt
.block_on(core.get_releases_grid(id, release_type, limit, offset, None))
{
Ok(r) => json(
200,
serde_json::json!({"view": "albums", "releases": serde_json::to_value(r).unwrap_or(Value::Null)}),
),
Err(_) => not_found("artist", &id.to_string()), Err(_) => not_found("artist", &id.to_string()),
} }
} }
other => err_json(400, "bad_request", &format!("unknown view '{other}'"), "view: page | top | albums"), other => err_json(
400,
"bad_request",
&format!("unknown view '{other}'"),
"view: page | top | albums",
),
} }
} }
/// `GET /api/similar?artist=<ID>` (similar artists) or `?album=<ID>` (similar /// `GET /api/similar?artist=<ID>` (similar artists) or `?album=<ID>` (similar
/// albums), `&limit=&offset=`. Exactly one selector. /// albums), `&limit=&offset=`. Exactly one selector.
pub fn similar(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn similar(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -97,18 +137,32 @@ pub fn similar(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
let (limit, offset) = limit_offset(&p); let (limit, offset) = limit_offset(&p);
if let Some(artist_id) = p.get("artist").and_then(|v| v.parse::<u64>().ok()) { if let Some(artist_id) = p.get("artist").and_then(|v| v.parse::<u64>().ok()) {
return match state.rt.block_on(state.runtime.core().get_similar_artists(artist_id, limit, offset)) { return match state
Ok(page) => json(200, serde_json::json!({"artists": serde_json::to_value(page).unwrap_or(Value::Null)})), .rt
.block_on(state.core.get_similar_artists(artist_id, limit, offset))
{
Ok(page) => json(
200,
serde_json::json!({"artists": serde_json::to_value(page).unwrap_or(Value::Null)}),
),
Err(_) => not_found("artist", &artist_id.to_string()), Err(_) => not_found("artist", &artist_id.to_string()),
}; };
} }
if let Some(album_id) = p.get("album").filter(|v| !v.is_empty()) { if let Some(album_id) = p.get("album").filter(|v| !v.is_empty()) {
return match state.rt.block_on(state.runtime.core().get_album_suggest(album_id)) { return match state.rt.block_on(state.core.get_album_suggest(album_id)) {
Ok(sug) => json(200, serde_json::json!({"albums": serde_json::to_value(sug).unwrap_or(Value::Null)})), Ok(sug) => json(
200,
serde_json::json!({"albums": serde_json::to_value(sug).unwrap_or(Value::Null)}),
),
Err(_) => not_found("album", album_id), Err(_) => not_found("album", album_id),
}; };
} }
err_json(400, "bad_request", "similar requires artist=<ID> or album=<ID>", "usage: qbzd similar artist:<ID> | album:<ID>") err_json(
400,
"bad_request",
"similar requires artist=<ID> or album=<ID>",
"usage: qbzd similar artist:<ID> | album:<ID>",
)
} }
/// `GET /api/suggest?seed=<ID,ID,...>&limit=`. Dynamic For-You suggestions /// `GET /api/suggest?seed=<ID,ID,...>&limit=`. Dynamic For-You suggestions
@@ -116,7 +170,10 @@ pub fn similar(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
/// the current queue's track ids (current + upcoming, capped) — so the daemon /// the current queue's track ids (current + upcoming, capped) — so the daemon
/// tracks no listening history, honoring the UNIX-honest seeding the CONSOLE /// tracks no listening history, honoring the UNIX-honest seeding the CONSOLE
/// brief specifies. /// brief specifies.
pub fn suggest(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn suggest(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -124,9 +181,12 @@ pub fn suggest(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
let (limit, _offset) = limit_offset(&p); let (limit, _offset) = limit_offset(&p);
let seeds: Vec<u64> = match p.get("seed") { let seeds: Vec<u64> = match p.get("seed") {
Some(s) if !s.is_empty() => s.split(',').filter_map(|x| x.trim().parse::<u64>().ok()).collect(), Some(s) if !s.is_empty() => s
.split(',')
.filter_map(|x| x.trim().parse::<u64>().ok())
.collect(),
_ => { _ => {
let q = state.rt.block_on(state.runtime.core().get_queue_state()); let q = state.rt.block_on(state.core.get_queue_state());
let mut ids: Vec<u64> = Vec::new(); let mut ids: Vec<u64> = Vec::new();
if let Some(t) = &q.current_track { if let Some(t) = &q.current_track {
ids.push(t.id); ids.push(t.id);
@@ -146,29 +206,48 @@ pub fn suggest(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
); );
} }
match state.rt.block_on(state.runtime.core().get_dynamic_suggest(&seeds, limit)) { match state
Ok(tracks) => json(200, serde_json::json!({"tracks": serde_json::to_value(tracks).unwrap_or(Value::Null)})), .rt
Err(_) => err_json(502, "suggest_failed", "suggestion request to Qobuz failed", "try again in a moment"), .block_on(state.core.get_dynamic_suggest(&seeds, limit))
{
Ok(tracks) => json(
200,
serde_json::json!({"tracks": serde_json::to_value(tracks).unwrap_or(Value::Null)}),
),
Err(_) => err_json(
502,
"suggest_failed",
"suggestion request to Qobuz failed",
"try again in a moment",
),
} }
} }
// ============================ internals ============================ // ============================ internals ============================
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> { fn auth_gate(
let needs_auth = state state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
.shared ) -> Option<Response<Cursor<Vec<u8>>>> {
.lock() let needs_auth = state.needs_auth;
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth { if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login")) Some(err_json(
409,
"needs_auth",
"not logged in to Qobuz",
"run: qbzd login",
))
} else { } else {
None None
} }
} }
fn not_found(kind: &str, id: &str) -> Response<Cursor<Vec<u8>>> { fn not_found(kind: &str, id: &str) -> Response<Cursor<Vec<u8>>> {
err_json(404, "not_found", &format!("{kind} {id} not found"), "check the id: qbzd search <QUERY>") err_json(
404,
"not_found",
&format!("{kind} {id} not found"),
"check the id: qbzd search <QUERY>",
)
} }
/// Percent-decoded query-string map (values only; keys are plain ascii). /// Percent-decoded query-string map (values only; keys are plain ascii).
@@ -196,7 +275,10 @@ fn limit_offset(p: &HashMap<String, String>) -> (u32, u32) {
.and_then(|v| v.parse::<u32>().ok()) .and_then(|v| v.parse::<u32>().ok())
.map(|n| n.clamp(1, MAX_LIMIT)) .map(|n| n.clamp(1, MAX_LIMIT))
.unwrap_or(DEFAULT_LIMIT); .unwrap_or(DEFAULT_LIMIT);
let offset = p.get("offset").and_then(|v| v.parse::<u32>().ok()).unwrap_or(0); let offset = p
.get("offset")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(0);
(limit, offset) (limit, offset)
} }
@@ -24,14 +24,17 @@ use std::io::Cursor;
use serde_json::Value; use serde_json::Value;
use tiny_http::Response; use tiny_http::Response;
use crate::state::AuthState; use qbz_models::FrontendAdapter;
use super::{err_json, json, ApiState}; use super::{err_json, json, CatalogContext};
const DEFAULT_LIMIT: u32 = 20; const DEFAULT_LIMIT: u32 = 20;
const MAX_LIMIT: u32 = 100; const MAX_LIMIT: u32 = 100;
pub fn discover(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn discover(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -39,7 +42,7 @@ pub fn discover(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
let section = get(&p, "section").unwrap_or("index").to_string(); let section = get(&p, "section").unwrap_or("index").to_string();
let genre = parse_genre(get(&p, "genre")); let genre = parse_genre(get(&p, "genre"));
let (limit, offset) = limit_offset(&p); let (limit, offset) = limit_offset(&p);
let core = state.runtime.core(); let core = state.core;
let data: Result<Value, Response<Cursor<Vec<u8>>>> = match section.as_str() { let data: Result<Value, Response<Cursor<Vec<u8>>>> = match section.as_str() {
"index" => serialize(state.rt.block_on(core.get_discover_index(genre))), "index" => serialize(state.rt.block_on(core.get_discover_index(genre))),
@@ -84,21 +87,31 @@ pub fn discover(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
// ============================ internals ============================ // ============================ internals ============================
/// Serialize an already-awaited core result, or map an upstream error to 502. /// Serialize an already-awaited core result, or map an upstream error to 502.
fn serialize<T: serde::Serialize>(r: Result<T, qbz_core::CoreError>) -> Result<Value, Response<Cursor<Vec<u8>>>> { fn serialize<T: serde::Serialize>(
r: Result<T, qbz_core::CoreError>,
) -> Result<Value, Response<Cursor<Vec<u8>>>> {
match r { match r {
Ok(v) => Ok(serde_json::to_value(v).unwrap_or(Value::Null)), Ok(v) => Ok(serde_json::to_value(v).unwrap_or(Value::Null)),
Err(_) => Err(err_json(502, "discover_failed", "discover request to Qobuz failed", "try again in a moment")), Err(_) => Err(err_json(
502,
"discover_failed",
"discover request to Qobuz failed",
"try again in a moment",
)),
} }
} }
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> { fn auth_gate(
let needs_auth = state state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
.shared ) -> Option<Response<Cursor<Vec<u8>>>> {
.lock() let needs_auth = state.needs_auth;
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth { if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login")) Some(err_json(
409,
"needs_auth",
"not logged in to Qobuz",
"run: qbzd login",
))
} else { } else {
None None
} }
@@ -112,18 +125,26 @@ fn pairs(query: &str) -> Vec<(String, String)> {
let mut kv = p.splitn(2, '='); let mut kv = p.splitn(2, '=');
let k = kv.next().unwrap_or("").to_string(); let k = kv.next().unwrap_or("").to_string();
let raw = kv.next().unwrap_or(""); let raw = kv.next().unwrap_or("");
let v = urlencoding::decode(raw).map(|c| c.into_owned()).unwrap_or_else(|_| raw.to_string()); let v = urlencoding::decode(raw)
.map(|c| c.into_owned())
.unwrap_or_else(|_| raw.to_string());
(k, v) (k, v)
}) })
.collect() .collect()
} }
fn get<'a>(p: &'a [(String, String)], key: &str) -> Option<&'a str> { fn get<'a>(p: &'a [(String, String)], key: &str) -> Option<&'a str> {
p.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()).filter(|v| !v.is_empty()) p.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
.filter(|v| !v.is_empty())
} }
fn parse_genre(v: Option<&str>) -> Option<Vec<u64>> { fn parse_genre(v: Option<&str>) -> Option<Vec<u64>> {
let ids: Vec<u64> = v?.split(',').filter_map(|s| s.trim().parse::<u64>().ok()).collect(); let ids: Vec<u64> = v?
.split(',')
.filter_map(|s| s.trim().parse::<u64>().ok())
.collect();
if ids.is_empty() { if ids.is_empty() {
None None
} else { } else {
@@ -132,8 +153,13 @@ fn parse_genre(v: Option<&str>) -> Option<Vec<u64>> {
} }
fn limit_offset(p: &[(String, String)]) -> (u32, u32) { fn limit_offset(p: &[(String, String)]) -> (u32, u32) {
let limit = get(p, "limit").and_then(|v| v.parse::<u32>().ok()).map(|n| n.clamp(1, MAX_LIMIT)).unwrap_or(DEFAULT_LIMIT); let limit = get(p, "limit")
let offset = get(p, "offset").and_then(|v| v.parse::<u32>().ok()).unwrap_or(0); .and_then(|v| v.parse::<u32>().ok())
.map(|n| n.clamp(1, MAX_LIMIT))
.unwrap_or(DEFAULT_LIMIT);
let offset = get(p, "offset")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(0);
(limit, offset) (limit, offset)
} }
@@ -10,9 +10,9 @@ use std::io::Cursor;
use serde_json::Value; use serde_json::Value;
use tiny_http::Response; use tiny_http::Response;
use crate::state::AuthState; use qbz_models::FrontendAdapter;
use super::{err_json, json, ApiState}; use super::{err_json, json, CatalogContext};
const DEFAULT_LIMIT: u32 = 50; const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 500; const MAX_LIMIT: u32 = 500;
@@ -20,7 +20,10 @@ const MAX_LIMIT: u32 = 500;
/// `GET /api/favorites?type=track|album|artist&limit=&offset=`. Returns the /// `GET /api/favorites?type=track|album|artist&limit=&offset=`. Returns the
/// raw favorites payload under `favorites` (its `items` arrays are the same /// raw favorites payload under `favorites` (its `items` arrays are the same
/// shape the CLI's generic renderer walks). `type` defaults to `track`. /// shape the CLI's generic renderer walks). `type` defaults to `track`.
pub fn list(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn list(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -47,59 +50,131 @@ pub fn list(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
} }
} }
match state.rt.block_on(state.runtime.core().get_favorites(ftype.plural(), limit, offset)) { match state
Ok(v) => json(200, serde_json::json!({"type": ftype.singular(), "favorites": v})), .rt
Err(_) => err_json(502, "favorites_failed", "favorites request to Qobuz failed", "try again in a moment"), .block_on(state.core.get_favorites(ftype.plural(), limit, offset))
{
Ok(v) => json(
200,
serde_json::json!({"type": ftype.singular(), "favorites": v}),
),
Err(_) => err_json(
502,
"favorites_failed",
"favorites request to Qobuz failed",
"try again in a moment",
),
} }
} }
/// `POST /api/favorites/add`. Body `{"fav_type": "track|album|artist", /// `POST /api/favorites/add`. Body `{"fav_type": "track|album|artist",
/// "item_id": "..."}` or `{"fav_type": "track", "current": true}` (favorite the /// "item_id": "..."}` or `{"fav_type": "track", "current": true}` (favorite the
/// now-playing track). Idempotent from the caller's view (Qobuz de-dupes). /// now-playing track). Idempotent from the caller's view (Qobuz de-dupes).
pub fn add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> { pub fn add(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
mutate(state, body, true) mutate(state, body, true)
} }
/// `POST /api/favorites/remove`. Same body shape (minus `current`). /// `POST /api/favorites/remove`. Same body shape (minus `current`).
pub fn remove(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> { pub fn remove(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
mutate(state, body, false) mutate(state, body, false)
} }
// ============================ internals ============================ // ============================ internals ============================
fn mutate(state: &ApiState, body: &Value, adding: bool) -> Response<Cursor<Vec<u8>>> { fn mutate(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
adding: bool,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
let ftype = match body.get("fav_type").and_then(|v| v.as_str()).and_then(FavType::parse) { let ftype = match body
.get("fav_type")
.and_then(|v| v.as_str())
.and_then(FavType::parse)
{
Some(t) => t, Some(t) => t,
None => return err_json(400, "bad_request", "requires fav_type: track|album|artist", "body: {\"fav_type\":\"track\",\"item_id\":\"...\"}"), None => {
return err_json(
400,
"bad_request",
"requires fav_type: track|album|artist",
"body: {\"fav_type\":\"track\",\"item_id\":\"...\"}",
)
}
}; };
// `current: true` (add only) favorites the now-playing track. // `current: true` (add only) favorites the now-playing track.
let item_id = if adding && body.get("current").and_then(|v| v.as_bool()).unwrap_or(false) { let item_id = if adding
&& body
.get("current")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
if ftype != FavType::Track { if ftype != FavType::Track {
return err_json(400, "bad_request", "current only applies to fav_type track", "use: fav add track --current"); return err_json(
400,
"bad_request",
"current only applies to fav_type track",
"use: fav add track --current",
);
} }
match state.rt.block_on(state.runtime.core().get_queue_state()).current_track { match state
.rt
.block_on(state.core.get_queue_state())
.current_track
{
Some(t) => t.id.to_string(), Some(t) => t.id.to_string(),
None => return err_json(404, "not_found", "nothing is playing", "queue a track first"), None => {
return err_json(
404,
"not_found",
"nothing is playing",
"queue a track first",
)
}
} }
} else { } else {
match body.get("item_id").and_then(|v| v.as_str()) { match body.get("item_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id.to_string(), Some(id) if !id.is_empty() => id.to_string(),
_ => return err_json(400, "bad_request", "requires an item_id", "body: {\"fav_type\":\"track\",\"item_id\":\"176544871\"}"), _ => {
return err_json(
400,
"bad_request",
"requires an item_id",
"body: {\"fav_type\":\"track\",\"item_id\":\"176544871\"}",
)
}
} }
}; };
let result = if adding { let result = if adding {
state.rt.block_on(state.runtime.core().add_favorite(ftype.singular(), &item_id)) state
.rt
.block_on(state.core.add_favorite(ftype.singular(), &item_id))
} else { } else {
state.rt.block_on(state.runtime.core().remove_favorite(ftype.singular(), &item_id)) state
.rt
.block_on(state.core.remove_favorite(ftype.singular(), &item_id))
}; };
match result { match result {
Ok(()) => json(200, serde_json::json!({"ok": true, "type": ftype.singular(), "item_id": item_id})), Ok(()) => json(
Err(_) => err_json(502, "favorites_failed", "favorites update failed", "try again in a moment"), 200,
serde_json::json!({"ok": true, "type": ftype.singular(), "item_id": item_id}),
),
Err(_) => err_json(
502,
"favorites_failed",
"favorites update failed",
"try again in a moment",
),
} }
} }
@@ -139,17 +214,25 @@ impl FavType {
} }
fn bad_type(got: &str) -> Response<Cursor<Vec<u8>>> { fn bad_type(got: &str) -> Response<Cursor<Vec<u8>>> {
err_json(400, "bad_request", &format!("unknown fav type '{got}'"), "type: track | album | artist") err_json(
400,
"bad_request",
&format!("unknown fav type '{got}'"),
"type: track | album | artist",
)
} }
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> { fn auth_gate(
let needs_auth = state state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
.shared ) -> Option<Response<Cursor<Vec<u8>>>> {
.lock() let needs_auth = state.needs_auth;
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth { if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login")) Some(err_json(
409,
"needs_auth",
"not logged in to Qobuz",
"run: qbzd login",
))
} else { } else {
None None
} }
@@ -164,7 +247,9 @@ fn pairs(query: &str) -> Vec<(String, String)> {
let mut kv = p.splitn(2, '='); let mut kv = p.splitn(2, '=');
let k = kv.next().unwrap_or("").to_string(); let k = kv.next().unwrap_or("").to_string();
let raw = kv.next().unwrap_or(""); let raw = kv.next().unwrap_or("");
let v = urlencoding::decode(raw).map(|c| c.into_owned()).unwrap_or_else(|_| raw.to_string()); let v = urlencoding::decode(raw)
.map(|c| c.into_owned())
.unwrap_or_else(|_| raw.to_string());
(k, v) (k, v)
}) })
.collect() .collect()
@@ -190,6 +275,12 @@ mod tests {
#[test] #[test]
fn pairs_decodes_values() { fn pairs_decodes_values() {
let p = pairs("type=track&limit=50"); let p = pairs("type=track&limit=50");
assert_eq!(p, vec![("type".into(), "track".into()), ("limit".into(), "50".into())]); assert_eq!(
p,
vec![
("type".into(), "track".into()),
("limit".into(), "50".into())
]
);
} }
} }
+31
View File
@@ -0,0 +1,31 @@
//! Shared HTTP infrastructure and catalog handlers for QBZ hosts.
//!
//! The host owns playback, settings and session authority. This crate owns
//! HTTP access control, wire framing and operations over an explicit core.
//! No desktop globals or daemon state enter this boundary.
pub mod artwork;
pub mod browse;
pub mod discover;
pub mod fav;
pub mod library;
pub mod lyrics;
pub mod playlist;
pub mod reco;
mod routes;
pub mod search;
mod server;
mod sse;
mod wire;
pub use routes::{P0_ROUTES, P1_ROUTES};
pub use server::{bind, serve, ApiHandle, BindError, BoundServer, HttpHost};
pub use wire::{canon_volume, err_json, error_body, json};
/// Borrowed, request-scoped catalog view. The host snapshots its session gate
/// before routing; no global current-profile lookup occurs inside a handler.
pub struct CatalogContext<'a, A: qbz_models::FrontendAdapter> {
pub core: &'a qbz_core::QbzCore<A>,
pub rt: &'a tokio::runtime::Handle,
pub needs_auth: bool,
}
+481
View File
@@ -0,0 +1,481 @@
//! Experimental Orbit library inspection. This host advertises only the
//! capabilities it serves; inspection never grants playback/audio authority.
//! Track DTOs contain metadata and an instance-scoped identity, never host paths
//! or media-server credentials. The existing daemon catalog API is unchanged.
use std::io::Cursor;
use std::sync::Arc;
use qbz_library::service::LibraryService;
use serde::{Deserialize, Serialize};
use tiny_http::{Request, Response};
use tokio::sync::broadcast;
use crate::{err_json, json, HttpHost};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LibraryInfo {
pub protocol: u32,
/// Rotates whenever the listener starts or the host profile changes.
pub instance: String,
pub name: String,
pub capabilities: Vec<String>,
pub sources: Vec<String>,
pub tracks: u64,
pub folders: usize,
pub revision: u64,
pub scanning: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LibraryTrack {
pub id: String,
pub title: String,
pub artist: String,
pub album: String,
pub duration_secs: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LibraryPage {
pub instance: String,
pub revision: u64,
pub tracks: Vec<LibraryTrack>,
pub has_more: bool,
}
pub struct LibraryEndpoint {
service: Arc<LibraryService>,
name: String,
instance: String,
management: bool,
}
impl LibraryEndpoint {
/// Host supplies a service and explicitly chooses whether to offer administration.
/// The enclosing HttpHost applies its existing Origin/bearer policy first.
pub fn new(service: Arc<LibraryService>, name: String, management: bool) -> Self {
Self {
service,
name,
management,
instance: uuid::Uuid::new_v4().to_string(),
}
}
fn info(&self) -> Response<Cursor<Vec<u8>>> {
let result = self
.service
.store()
.read(|db| Ok((db.get_stats(true)?, db.get_folders_with_metadata()?.len())));
let (tracks, folders) = match result {
Ok(Some((stats, folders))) => (stats.track_count, folders),
Ok(None) => (0, 0),
Err(error) => return failed(error),
};
let snapshot = self.service.snapshot();
json(
200,
serde_json::to_value(LibraryInfo {
protocol: PROTOCOL_VERSION,
instance: self.instance.clone(),
name: self.name.clone(),
capabilities: {
let mut caps = vec![
"library.files.inspect".into(),
"library.files.search".into(),
];
if self.management {
caps.push("library.files.manage".into());
}
caps
},
sources: vec!["files".into()],
tracks: tracks as u64,
folders,
revision: snapshot.revision,
scanning: snapshot.progress.running,
})
.expect("library info is JSON"),
)
}
fn search(&self, query: &str) -> Response<Cursor<Vec<u8>>> {
let params: std::collections::HashMap<_, _> = query
.split('&')
.filter_map(|part| {
let (key, value) = part.split_once('=')?;
Some((
key,
urlencoding::decode(&value.replace('+', " "))
.ok()?
.into_owned(),
))
})
.collect();
if params.get("instance") != Some(&self.instance) {
return err_json(
409,
"host_changed",
"the library host changed",
"verify the host again",
);
}
let limit = match params.get("limit").map(|v| v.parse::<u64>()) {
None => 25,
Some(Ok(n @ 1..=100)) => n,
_ => {
return err_json(
400,
"invalid_limit",
"limit must be between 1 and 100",
"use a bounded page",
)
}
};
let offset = match params.get("offset").map(|v| v.parse::<u64>()) {
None => 0,
Some(Ok(n @ 0..=100_000)) => n,
_ => {
return err_json(
400,
"invalid_offset",
"invalid library offset",
"start a new search",
)
}
};
let q = params.get("q").map(String::as_str).unwrap_or("").trim();
if q.len() > 1024 {
return err_json(
400,
"invalid_query",
"query is too long",
"shorten the search",
);
}
let rows =
if q.chars().count() < 2 {
Vec::new()
} else {
match self.service.store().read(|db| {
db.search_with_filter_page(q, offset, limit + 1, true, false, "default")
}) {
Ok(rows) => rows.unwrap_or_default(),
Err(error) => return failed(error),
}
};
let has_more = rows.len() > limit as usize;
let tracks = rows
.into_iter()
.take(limit as usize)
.map(|t| LibraryTrack {
id: t.id.to_string(),
title: t.title,
artist: t.artist,
album: t.album,
duration_secs: t.duration_secs,
})
.collect();
json(
200,
serde_json::to_value(LibraryPage {
instance: self.instance.clone(),
revision: self.service.snapshot().revision,
tracks,
has_more,
})
.expect("library page is JSON"),
)
}
}
impl LibraryEndpoint {
pub fn route(&self, request: &mut Request) -> Response<Cursor<Vec<u8>>> {
if self.service.snapshot().closed {
return err_json(
409,
"host_closed",
"this library is no longer active",
"verify the host again",
);
}
let url = request.url().to_owned();
let (path, query) = url.split_once('?').unwrap_or((&url, ""));
match (request.method().as_str(), path) {
("GET", "/api/orbit/library") => self.info(),
("GET", "/api/orbit/library/search") => self.search(query),
(
_,
"/api/orbit/library/folders"
| "/api/orbit/library/scan"
| "/api/orbit/library/cancel"
| "/api/orbit/library/jobs",
) if self.management => self.manage(request, path, query),
_ => err_json(
404,
"not_supported",
"this host does not offer that operation",
"check its advertised capabilities",
),
}
}
}
fn failed(error: qbz_library::LibraryError) -> Response<Cursor<Vec<u8>>> {
log::warn!("Orbit library query failed: {error}");
err_json(
500,
"library_unavailable",
"the host library could not be read",
"check the host's library settings",
)
}
/// Standalone opt-in Qt laboratory host. A daemon embeds LibraryEndpoint in
/// its existing HTTP host instead of opening another server or runtime.
pub struct LibraryHost {
endpoint: LibraryEndpoint,
token: String,
events: broadcast::Sender<qbz_models::CoreEvent>,
}
impl LibraryHost {
pub fn new(service: Arc<LibraryService>, name: String, token: String) -> Self {
Self::with_management(service, name, token, false)
}
pub fn with_management(
service: Arc<LibraryService>,
name: String,
token: String,
management: bool,
) -> Self {
Self {
endpoint: LibraryEndpoint::new(service, name, management),
token,
events: broadcast::channel(16).0,
}
}
}
impl HttpHost for LibraryHost {
fn token(&self) -> Option<&str> {
Some(&self.token)
}
fn events(&self) -> &broadcast::Sender<qbz_models::CoreEvent> {
&self.events
}
fn route(&self, request: &mut Request) -> Response<Cursor<Vec<u8>>> {
self.endpoint.route(request)
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LibraryCommand {
instance: String,
revision: Option<u64>,
path: Option<String>,
folder_id: Option<i64>,
job: Option<u64>,
}
impl LibraryEndpoint {
fn manage(&self, request: &mut Request, path: &str, query: &str) -> Response<Cursor<Vec<u8>>> {
let method = request.method().as_str();
if method == "GET"
&& matches!(
path,
"/api/orbit/library/folders" | "/api/orbit/library/jobs"
)
{
let instance = query.split('&').find_map(|p| p.strip_prefix("instance="));
if instance != Some(self.instance.as_str()) {
return changed();
}
return if path.ends_with("/jobs") {
self.jobs(200)
} else {
self.folders()
};
}
if method != "POST"
|| !matches!(
path,
"/api/orbit/library/folders"
| "/api/orbit/library/scan"
| "/api/orbit/library/cancel"
)
{
return err_json(
405,
"method_not_allowed",
"unsupported library method",
"check the library contract",
);
}
use std::io::Read;
let mut bytes = Vec::new();
if request
.as_reader()
.take(8193)
.read_to_end(&mut bytes)
.is_err()
|| bytes.len() > 8192
{
return err_json(
400,
"invalid_body",
"invalid library command",
"send a bounded JSON object",
);
}
let Ok(command) = serde_json::from_slice::<LibraryCommand>(&bytes) else {
return err_json(
400,
"invalid_body",
"invalid library command",
"send a valid JSON object",
);
};
if command.instance != self.instance {
return changed();
}
match path {
"/api/orbit/library/folders" => {
let (Some(path), Some(revision)) = (command.path, command.revision) else {
return err_json(
400,
"invalid_body",
"path and revision are required",
"read the host library first",
);
};
match self
.service
.register_folder(std::path::Path::new(&path), revision)
{
Ok(_) => self.folders(),
Err(qbz_library::service::FolderError::Storage(e)) => failed(e),
Err(qbz_library::service::FolderError::InvalidPath) => err_json(
400,
"invalid_path",
"expected an existing absolute directory on the host",
"use the host's path, not the controller's path",
),
Err(qbz_library::service::FolderError::Busy) => err_json(
409,
"library_busy",
"a library scan is running",
"wait for the scan or cancel it",
),
Err(qbz_library::service::FolderError::Conflict) => err_json(
409,
"folder_conflict",
"folder overlaps an existing library root",
"use the existing root",
),
Err(qbz_library::service::FolderError::Disabled) => err_json(
409,
"folder_disabled",
"folder is disabled",
"enable it in the host library settings",
),
Err(qbz_library::service::FolderError::Closed) => changed(),
Err(qbz_library::service::FolderError::RevisionChanged) => err_json(
409,
"revision_changed",
"the library changed",
"refresh before trying again",
),
}
}
"/api/orbit/library/scan" => {
if let Some(id) = command.folder_id {
match self.service.store().read(|db| db.get_folder_by_id(id)) {
Ok(Some(Some(folder))) if folder.enabled => {}
Ok(Some(Some(_))) => {
return err_json(
409,
"folder_disabled",
"folder is disabled",
"enable it in the host library settings",
)
}
Ok(_) => {
return err_json(
404,
"folder_not_found",
"folder does not exist on this host",
"refresh the folder list",
)
}
Err(e) => return failed(e),
}
}
match self.service.scan(command.folder_id) {
Ok(true) => self.jobs(202),
Ok(false) => changed(),
Err(e) => failed(e),
}
}
_ => {
let Some(job) = command.job else {
return err_json(
400,
"invalid_body",
"job is required",
"read the active scan first",
);
};
if self.service.cancel_job(job) {
self.jobs(202)
} else {
err_json(
409,
"job_changed",
"that scan is no longer active",
"refresh the scan status",
)
}
}
}
}
fn jobs(&self, status: u16) -> Response<Cursor<Vec<u8>>> {
json(
status,
serde_json::json!({"instance": self.instance, "library": self.service.snapshot()}),
)
}
fn folders(&self) -> Response<Cursor<Vec<u8>>> {
match self
.service
.store()
.read(|db| db.get_folders_with_metadata())
{
Ok(rows) => {
// Paths are deliberate administrative data, never part of search
// metadata. Serialize only what the folder settings need.
let folders: Vec<_> = rows
.unwrap_or_default()
.into_iter()
.map(|f| serde_json::json!({"id":f.id, "path":f.path, "enabled":f.enabled}))
.collect();
json(
200,
serde_json::json!({"instance": self.instance, "revision": self.service.snapshot().revision, "folders": folders}),
)
}
Err(e) => failed(e),
}
}
}
fn changed() -> Response<Cursor<Vec<u8>>> {
err_json(
409,
"host_changed",
"the library host changed",
"verify the host again",
)
}
@@ -12,11 +12,14 @@ use tiny_http::Response;
use qbz_qobuz::lyrics::{QobuzLyricsContent, QobuzLyricsDocument}; use qbz_qobuz::lyrics::{QobuzLyricsContent, QobuzLyricsDocument};
use crate::state::AuthState; use qbz_models::FrontendAdapter;
use super::{err_json, json, ApiState}; use super::{err_json, json, CatalogContext};
pub fn lyrics(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn lyrics(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -26,10 +29,18 @@ pub fn lyrics(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
Err(resp) => return resp, Err(resp) => return resp,
}; };
match state.rt.block_on(state.runtime.core().get_lyrics(id)) { match state.rt.block_on(state.core.get_lyrics(id)) {
Ok(Some(doc)) => json(200, normalize(id, &doc)), Ok(Some(doc)) => json(200, normalize(id, &doc)),
Ok(None) => json(200, serde_json::json!({"track_id": id, "synced": false, "lines": []})), Ok(None) => json(
Err(_) => err_json(502, "lyrics_failed", "lyrics request to Qobuz failed", "try again in a moment"), 200,
serde_json::json!({"track_id": id, "synced": false, "lines": []}),
),
Err(_) => err_json(
502,
"lyrics_failed",
"lyrics request to Qobuz failed",
"try again in a moment",
),
} }
} }
@@ -50,7 +61,10 @@ fn normalize(id: u64, doc: &QobuzLyricsDocument) -> Value {
), ),
Some(QobuzLyricsContent::Plain { lines, .. }) => ( Some(QobuzLyricsContent::Plain { lines, .. }) => (
false, false,
lines.iter().map(|l| serde_json::json!({"text": l.line})).collect(), lines
.iter()
.map(|l| serde_json::json!({"text": l.line}))
.collect(),
), ),
None => (false, Vec::new()), None => (false, Vec::new()),
}; };
@@ -58,7 +72,10 @@ fn normalize(id: u64, doc: &QobuzLyricsDocument) -> Value {
} }
/// `?id=<u64>` or `?id=current` (the queue cursor); default `current`. /// `?id=<u64>` or `?id=current` (the queue cursor); default `current`.
fn resolve_id(state: &ApiState, query: &str) -> Result<u64, Response<Cursor<Vec<u8>>>> { fn resolve_id(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Result<u64, Response<Cursor<Vec<u8>>>> {
let raw = query let raw = query
.split('&') .split('&')
.filter_map(|p| { .filter_map(|p| {
@@ -69,23 +86,41 @@ fn resolve_id(state: &ApiState, query: &str) -> Result<u64, Response<Cursor<Vec<
.unwrap_or("current"); .unwrap_or("current");
if raw.is_empty() || raw == "current" { if raw.is_empty() || raw == "current" {
return match state.rt.block_on(state.runtime.core().get_queue_state()).current_track { return match state
.rt
.block_on(state.core.get_queue_state())
.current_track
{
Some(t) => Ok(t.id), Some(t) => Ok(t.id),
None => Err(err_json(404, "not_found", "nothing is playing", "give a track id: qbzd lyrics <TRACK_ID>")), None => Err(err_json(
404,
"not_found",
"nothing is playing",
"give a track id: qbzd lyrics <TRACK_ID>",
)),
}; };
} }
raw.parse::<u64>() raw.parse::<u64>().map_err(|_| {
.map_err(|_| err_json(400, "bad_request", "lyrics id must be a track id or 'current'", "usage: qbzd lyrics [TRACK_ID]")) err_json(
400,
"bad_request",
"lyrics id must be a track id or 'current'",
"usage: qbzd lyrics [TRACK_ID]",
)
})
} }
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> { fn auth_gate(
let needs_auth = state state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
.shared ) -> Option<Response<Cursor<Vec<u8>>>> {
.lock() let needs_auth = state.needs_auth;
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth { if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login")) Some(err_json(
409,
"needs_auth",
"not logged in to Qobuz",
"run: qbzd login",
))
} else { } else {
None None
} }
+362
View File
@@ -0,0 +1,362 @@
// crates/qbzd/src/api/playlist.rs — playlists (02 §2.3, §3.4 row 24). GET
// /api/playlists (the user's collection) and GET /api/playlist?id= (one
// playlist with its COMPLETE track list — get_playlist auto-pages server-side).
// Reads only in this slice; playlist CRUD (create/update/delete/tracks) is a
// later batch. Auth-gated; typed serde shapes verbatim.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use qbz_models::FrontendAdapter;
use super::{err_json, json, CatalogContext};
/// `GET /api/playlists` — the user's playlist collection.
pub fn list(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
match state.rt.block_on(state.core.get_user_playlists()) {
Ok(pls) => json(
200,
serde_json::json!({"playlists": serde_json::to_value(pls).unwrap_or(Value::Null)}),
),
Err(_) => err_json(
502,
"playlists_failed",
"playlists request to Qobuz failed",
"try again in a moment",
),
}
}
/// `GET /api/playlist?id=<ID>` — one playlist with its full track list.
pub fn show(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match id_param(query) {
Some(id) => id,
None => {
return err_json(
400,
"bad_request",
"playlist requires a numeric id",
"usage: qbzd playlist show <ID>",
)
}
};
match state.rt.block_on(state.core.get_playlist(id)) {
Ok(pl) => json(
200,
serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)}),
),
Err(_) => err_json(
404,
"not_found",
&format!("playlist {id} not found"),
"check: qbzd playlist list",
),
}
}
/// `POST /api/playlist/create`. Body `{"name": "...", "description"?: "...",
/// "public"?: bool}`. Returns the created playlist.
pub fn create(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let name = match body
.get("name")
.and_then(|v| v.as_str())
.filter(|n| !n.trim().is_empty())
{
Some(n) => n,
None => {
return err_json(
400,
"bad_request",
"create requires a name",
"body: {\"name\": \"My Playlist\"}",
)
}
};
let desc = body.get("description").and_then(|v| v.as_str());
let public = body
.get("public")
.and_then(|v| v.as_bool())
.unwrap_or(false);
match state
.rt
.block_on(state.core.create_playlist(name, desc, public))
{
Ok(pl) => json(
200,
serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)}),
),
Err(_) => err_json(
502,
"playlists_failed",
"playlist create failed",
"try again in a moment",
),
}
}
/// `POST /api/playlist/update`. Body `{"id": N, "name"?, "description"?,
/// "public"?}`. Only the present fields change.
pub fn update(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => {
return err_json(
400,
"bad_request",
"update requires an id",
"body: {\"id\": 987, \"name\": \"...\"}",
)
}
};
let name = body.get("name").and_then(|v| v.as_str());
let desc = body.get("description").and_then(|v| v.as_str());
let public = body.get("public").and_then(|v| v.as_bool());
match state
.rt
.block_on(state.core.update_playlist(id, name, desc, public))
{
Ok(pl) => json(
200,
serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)}),
),
Err(_) => err_json(
502,
"playlists_failed",
"playlist update failed",
"try again in a moment",
),
}
}
/// `POST /api/playlist/delete`. Body `{"id": N}`. Deletes an owned playlist.
pub fn delete(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => {
return err_json(
400,
"bad_request",
"delete requires an id",
"body: {\"id\": 987}",
)
}
};
match state.rt.block_on(state.core.delete_playlist(id)) {
Ok(()) => json(200, serde_json::json!({"ok": true, "deleted": id})),
Err(_) => err_json(
502,
"playlists_failed",
"playlist delete failed",
"try again in a moment",
),
}
}
/// `POST /api/playlist/tracks/add`. Body `{"id": N, "track_ids": [...]}`.
pub fn tracks_add(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => {
return err_json(
400,
"bad_request",
"add requires a playlist id",
"body: {\"id\": 987, \"track_ids\": [...]}",
)
}
};
let track_ids = match parse_ids(body) {
Ok(ids) => ids,
Err((m, h)) => return err_json(400, "bad_request", &m, &h),
};
match state
.rt
.block_on(state.core.add_tracks_to_playlist(id, &track_ids))
{
Ok(()) => json(
200,
serde_json::json!({"ok": true, "added": track_ids.len()}),
),
Err(_) => err_json(
502,
"playlists_failed",
"add to playlist failed",
"try again in a moment",
),
}
}
/// `POST /api/playlist/tracks/remove`. Body `{"id": N, "track_ids": [...]}` —
/// PLAIN track ids. The daemon resolves them to per-playlist `playlist_track_id`
/// row ids (the row-id trap, qbz-models Track.playlist_track_id) before calling
/// `remove_tracks_from_playlist`, so clients never touch row ids.
pub fn tracks_remove(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => {
return err_json(
400,
"bad_request",
"remove requires a playlist id",
"body: {\"id\": 987, \"track_ids\": [...]}",
)
}
};
let track_ids = match parse_ids(body) {
Ok(ids) => ids,
Err((m, h)) => return err_json(400, "bad_request", &m, &h),
};
let pl = match state.rt.block_on(state.core.get_playlist(id)) {
Ok(p) => p,
Err(_) => {
return err_json(
404,
"not_found",
&format!("playlist {id} not found"),
"check: qbzd playlist list",
)
}
};
let wanted: std::collections::HashSet<u64> = track_ids.iter().copied().collect();
let items = pl.tracks.map(|t| t.items).unwrap_or_default();
let row_ids: Vec<u64> = items
.iter()
.filter(|t| wanted.contains(&t.id))
.filter_map(|t| t.playlist_track_id)
.collect();
if row_ids.is_empty() {
return err_json(
404,
"not_found",
"none of those tracks are in the playlist",
"check: qbzd playlist show <ID>",
);
}
match state
.rt
.block_on(state.core.remove_tracks_from_playlist(id, &row_ids))
{
Ok(()) => json(
200,
serde_json::json!({"ok": true, "removed": row_ids.len()}),
),
Err(_) => err_json(
502,
"playlists_failed",
"remove from playlist failed",
"try again in a moment",
),
}
}
// ============================ internals ============================
/// Strict `track_ids` array parse (any non-u64 element is a 400).
fn parse_ids(body: &Value) -> Result<Vec<u64>, (String, String)> {
let hint = "body: {\"id\": 987, \"track_ids\": [176544871]}".to_string();
let arr = match body.get("track_ids").and_then(|v| v.as_array()) {
Some(a) if !a.is_empty() => a,
Some(_) => return Err(("'track_ids' must not be empty".into(), hint)),
None => return Err(("requires a 'track_ids' array".into(), hint)),
};
let mut ids = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
match v.as_u64() {
Some(id) => ids.push(id),
None => return Err((format!("track_ids[{i}] is not an unsigned integer"), hint)),
}
}
Ok(ids)
}
fn id_param(query: &str) -> Option<u64> {
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next() == Some("id") {
return kv.next().and_then(|v| v.parse::<u64>().ok());
}
}
None
}
fn auth_gate(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
) -> Option<Response<Cursor<Vec<u8>>>> {
let needs_auth = state.needs_auth;
if needs_auth {
Some(err_json(
409,
"needs_auth",
"not logged in to Qobuz",
"run: qbzd login",
))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_param_reads_numeric_id() {
assert_eq!(id_param("id=987654"), Some(987654));
assert_eq!(id_param("foo=1&id=42"), Some(42));
assert_eq!(id_param("id=abc"), None);
assert_eq!(id_param(""), None);
}
#[test]
fn parse_ids_accepts_valid_and_rejects_bad() {
assert_eq!(
parse_ids(&serde_json::json!({"track_ids": [1, 2, 3]})),
Ok(vec![1, 2, 3])
);
assert!(parse_ids(&serde_json::json!({"track_ids": []})).is_err());
assert!(parse_ids(&serde_json::json!({"track_ids": [1, "x"]})).is_err());
assert!(parse_ids(&serde_json::json!({})).is_err());
}
}
@@ -22,11 +22,14 @@ use std::io::Cursor;
use serde_json::Value; use serde_json::Value;
use tiny_http::Response; use tiny_http::Response;
use crate::state::AuthState; use qbz_models::FrontendAdapter;
use super::{err_json, json, ApiState}; use super::{err_json, json, CatalogContext};
pub fn playlist(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> { pub fn playlist(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
body: &Value,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -53,7 +56,12 @@ pub fn playlist(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
}; };
if artists.is_empty() { if artists.is_empty() {
return err_json(400, "bad_request", "no seed artists", "the playlist has no resolvable artists"); return err_json(
400,
"bad_request",
"no seed artists",
"the playlist has no resolvable artists",
);
} }
let mut config = qbz_reco::SuggestionConfig::default(); let mut config = qbz_reco::SuggestionConfig::default();
@@ -61,9 +69,19 @@ pub fn playlist(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
config.max_pool_size = (n as usize).clamp(1, 200); config.max_pool_size = (n as usize).clamp(1, 200);
} }
match state.rt.block_on(state.runtime.core().generate_playlist_suggestions(artists, exclude, true, Some(config))) { match state.rt.block_on(state.core.generate_playlist_suggestions(
artists,
exclude,
true,
Some(config),
)) {
Ok(res) => json(200, serde_json::to_value(res).unwrap_or(Value::Null)), Ok(res) => json(200, serde_json::to_value(res).unwrap_or(Value::Null)),
Err(_) => err_json(502, "reco_failed", "suggestion engine failed", "try again in a moment; check: qbzd status"), Err(_) => err_json(
502,
"reco_failed",
"suggestion engine failed",
"try again in a moment; check: qbzd status",
),
} }
} }
@@ -74,12 +92,19 @@ pub fn playlist(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
/// (qbz/src/playlist_suggestions.rs) but re-derived Slint-free from `Playlist`. /// (qbz/src/playlist_suggestions.rs) but re-derived Slint-free from `Playlist`.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn seed_from_playlist( fn seed_from_playlist(
state: &ApiState, state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
pid: u64, pid: u64,
) -> Result<(Vec<(Option<u64>, String)>, Vec<u64>), Response<Cursor<Vec<u8>>>> { ) -> Result<(Vec<(Option<u64>, String)>, Vec<u64>), Response<Cursor<Vec<u8>>>> {
let pl = match state.rt.block_on(state.runtime.core().get_playlist(pid)) { let pl = match state.rt.block_on(state.core.get_playlist(pid)) {
Ok(p) => p, Ok(p) => p,
Err(_) => return Err(err_json(404, "not_found", &format!("playlist {pid} not found"), "check: qbzd playlist list")), Err(_) => {
return Err(err_json(
404,
"not_found",
&format!("playlist {pid} not found"),
"check: qbzd playlist list",
))
}
}; };
let items = pl.tracks.map(|t| t.items).unwrap_or_default(); let items = pl.tracks.map(|t| t.items).unwrap_or_default();
let mut artists: Vec<(Option<u64>, String)> = Vec::new(); let mut artists: Vec<(Option<u64>, String)> = Vec::new();
@@ -99,21 +124,28 @@ fn seed_from_playlist(
fn parse_artists(arr: &[Value]) -> Vec<(Option<u64>, String)> { fn parse_artists(arr: &[Value]) -> Vec<(Option<u64>, String)> {
arr.iter() arr.iter()
.filter_map(|a| { .filter_map(|a| {
let name = a.get("name").and_then(|v| v.as_str()).filter(|n| !n.is_empty())?.to_string(); let name = a
.get("name")
.and_then(|v| v.as_str())
.filter(|n| !n.is_empty())?
.to_string();
let id = a.get("id").and_then(|v| v.as_u64()); let id = a.get("id").and_then(|v| v.as_u64());
Some((id, name)) Some((id, name))
}) })
.collect() .collect()
} }
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> { fn auth_gate(
let needs_auth = state state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
.shared ) -> Option<Response<Cursor<Vec<u8>>>> {
.lock() let needs_auth = state.needs_auth;
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth { if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login")) Some(err_json(
409,
"needs_auth",
"not logged in to Qobuz",
"run: qbzd login",
))
} else { } else {
None None
} }
+63
View File
@@ -0,0 +1,63 @@
/// The counted P0 route table (02 §3.2) — 17, FINAL, this test now guards the
/// budget forever. A route exists only iff a shipped client calls it (§3.1.4).
/// T1-T6 landed the first 3; T7 added the 9 playback + now-playing routes
/// (rows 4-12); T8 added the 4 queue routes (rows 13-16); T11 (this task)
/// adds `/api/settings/reload` (row 17) — the inline
/// `route_table_matches_spec_count` test pins the number so the 68-routes
/// failure shape (a route with no caller) cannot creep back in.
pub const P0_ROUTES: &[(&str, &str)] = &[
("GET", "/api/ping"),
("GET", "/api/info"),
("GET", "/api/status"),
("GET", "/api/now-playing"),
("POST", "/api/playback/play"),
("POST", "/api/playback/pause"),
("POST", "/api/playback/toggle"),
("POST", "/api/playback/stop"),
("POST", "/api/playback/next"),
("POST", "/api/playback/previous"),
("POST", "/api/playback/seek"),
("POST", "/api/playback/volume"),
("GET", "/api/queue"),
("POST", "/api/queue/add"),
("POST", "/api/queue/remove"),
("POST", "/api/queue/clear"),
("POST", "/api/settings/reload"),
];
/// The P1 route table (02 §3.4) — the content-verb surface. SAME HARD RULE as
/// P0 (§3.1.4): a route exists only iff a shipped CLI/TUI client calls it, and
/// this table grows one row per verb+route pair in the SAME change as the verb.
/// Row 19 (this change): `GET /api/search` — caller: `qbzd search`. The
/// `p1_route_table_grows_only_with_a_shipped_caller` test pins the count and
/// forbids overlap with P0, so the 68-routes failure shape cannot creep in
/// through the P1 door either.
pub const P1_ROUTES: &[(&str, &str)] = &[
("GET", "/api/search"),
("POST", "/api/play"),
("GET", "/api/album"),
("GET", "/api/artist"),
("GET", "/api/similar"),
("GET", "/api/suggest"),
("GET", "/api/discover"),
("GET", "/api/lyrics"),
("POST", "/api/radio"),
("POST", "/api/reco/playlist"),
("POST", "/api/playback/shuffle"),
("POST", "/api/playback/repeat"),
("POST", "/api/queue/move"),
("POST", "/api/queue/jump"),
("POST", "/api/queue/stop-after"),
("GET", "/api/favorites"),
("POST", "/api/favorites/add"),
("POST", "/api/favorites/remove"),
("GET", "/api/playlists"),
("GET", "/api/playlist"),
("POST", "/api/playlist/create"),
("POST", "/api/playlist/update"),
("POST", "/api/playlist/delete"),
("POST", "/api/playlist/tracks/add"),
("POST", "/api/playlist/tracks/remove"),
("GET", "/api/events"),
("GET", "/api/artwork/current"),
];
@@ -20,9 +20,9 @@ use std::io::Cursor;
use serde_json::Value; use serde_json::Value;
use tiny_http::Response; use tiny_http::Response;
use crate::state::AuthState; use qbz_models::FrontendAdapter;
use super::{err_json, json, ApiState}; use super::{err_json, json, CatalogContext};
/// Default result count per category when the caller gives no `limit`. /// Default result count per category when the caller gives no `limit`.
const DEFAULT_LIMIT: u32 = 20; const DEFAULT_LIMIT: u32 = 20;
@@ -34,7 +34,10 @@ const MAX_LIMIT: u32 = 100;
/// `query` is the raw query string (no leading `?`); `route()` strips it off the /// `query` is the raw query string (no leading `?`); `route()` strips it off the
/// path before dispatch. Errors: 409 `needs_auth`, 400 `bad_request` (missing /// path before dispatch. Errors: 409 `needs_auth`, 400 `bad_request` (missing
/// query / unknown type), 502 `search_failed` (upstream Qobuz error). /// query / unknown type), 502 `search_failed` (upstream Qobuz error).
pub fn search(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> { pub fn search(
state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
query: &str,
) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) { if let Some(resp) = auth_gate(state) {
return resp; return resp;
} }
@@ -60,7 +63,7 @@ pub fn search(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
let mut playlists = Value::Null; let mut playlists = Value::Null;
if do_albums { if do_albums {
match state.rt.block_on(state.runtime.core().search_albums( match state.rt.block_on(state.core.search_albums(
&params.q, &params.q,
params.limit, params.limit,
params.offset, params.offset,
@@ -71,7 +74,7 @@ pub fn search(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
} }
} }
if do_tracks { if do_tracks {
match state.rt.block_on(state.runtime.core().search_tracks( match state.rt.block_on(state.core.search_tracks(
&params.q, &params.q,
params.limit, params.limit,
params.offset, params.offset,
@@ -82,7 +85,7 @@ pub fn search(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
} }
} }
if do_artists { if do_artists {
match state.rt.block_on(state.runtime.core().search_artists( match state.rt.block_on(state.core.search_artists(
&params.q, &params.q,
params.limit, params.limit,
params.offset, params.offset,
@@ -93,11 +96,11 @@ pub fn search(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
} }
} }
if do_playlists { if do_playlists {
match state.rt.block_on(state.runtime.core().search_playlists( match state.rt.block_on(
&params.q, state
params.limit, .core
params.offset, .search_playlists(&params.q, params.limit, params.offset),
)) { ) {
Ok(page) => playlists = serde_json::to_value(page).unwrap_or(Value::Null), Ok(page) => playlists = serde_json::to_value(page).unwrap_or(Value::Null),
Err(_) => return upstream_error(), Err(_) => return upstream_error(),
} }
@@ -122,12 +125,10 @@ pub fn search(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
/// 409 `needs_auth` — search needs a live Qobuz session. Mirrors /// 409 `needs_auth` — search needs a live Qobuz session. Mirrors
/// `queue::add`'s gate (this file's self-contained-helpers convention). /// `queue::add`'s gate (this file's self-contained-helpers convention).
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> { fn auth_gate(
let needs_auth = state state: &CatalogContext<'_, impl FrontendAdapter + 'static>,
.shared ) -> Option<Response<Cursor<Vec<u8>>>> {
.lock() let needs_auth = state.needs_auth;
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth { if needs_auth {
Some(err_json( Some(err_json(
409, 409,
+145
View File
@@ -0,0 +1,145 @@
use crate::{sse, wire::access_gate};
use qbz_models::CoreEvent;
use std::io::Cursor;
use std::net::SocketAddr;
use std::sync::Arc;
use tiny_http::{Method, Request, Response};
use tokio::sync::broadcast;
/// Host state lives on the single serving thread, so only Send is required.
/// Routing is called after the shared Origin/bearer gate. Host command handlers
/// remain responsible for playback authority and catalog-session admission.
pub trait HttpHost: Send + 'static {
fn token(&self) -> Option<&str>;
fn events(&self) -> &broadcast::Sender<CoreEvent>;
fn route(&self, request: &mut Request) -> Response<Cursor<Vec<u8>>>;
}
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
/// in an `Arc` so the serving thread and the shutdown handle can both hold it
/// (`unblock` from the handle terminates the thread's `incoming_requests`).
pub struct BoundServer {
server: Arc<tiny_http::Server>,
}
/// Live serving handle. [`ApiHandle::shutdown`] unblocks the serving thread and
/// joins it — dropping the thread's `ApiState` (and with it the `Arc<AppRuntime>`
/// clone) BEFORE the daemon drops the runtime, preserving the §8.2 audio
/// clock-release ordering (the API thread is one more `Arc<AppRuntime>` holder,
/// exactly like the driver and auth-retry tasks).
pub struct ApiHandle {
server: Arc<tiny_http::Server>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl BoundServer {
/// Actual address, useful when binding an ephemeral port for a fixture.
pub fn local_addr(&self) -> SocketAddr {
self.server.server_addr().to_ip().expect("TCP listener")
}
}
impl ApiHandle {
pub fn shutdown(mut self) {
self.server.unblock();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Why a bind failed — `AddrInUse` is the case the boot step-5 diagnosis probes
/// (foreign qbzd vs another process); everything else is a generic fatal.
#[derive(Debug)]
pub enum BindError {
AddrInUse(SocketAddr),
Other(String),
}
/// Boot step 5 (01 §8.1): bind only — stateless, so the foreign-occupant
/// diagnosis (in `daemon.rs`) runs BEFORE stores (6) and runtime composition (7).
pub fn bind(addr: SocketAddr) -> Result<BoundServer, BindError> {
match tiny_http::Server::http(addr) {
Ok(server) => Ok(BoundServer {
server: Arc::new(server),
}),
Err(e) => Err(classify_bind_error(e, addr)),
}
}
fn classify_bind_error(
e: Box<dyn std::error::Error + Send + Sync + 'static>,
addr: SocketAddr,
) -> BindError {
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::AddrInUse {
return BindError::AddrInUse(addr);
}
}
BindError::Other(e.to_string())
}
/// Boot step 11 (01 §8.1): start serving on the already-bound socket. Requests
/// are handled inline on one thread (serialized). `unblock` (from the handle)
/// ends `incoming_requests` for graceful shutdown.
pub fn serve(server: BoundServer, state: impl HttpHost) -> ApiHandle {
log::info!("shared API serving");
let srv = server.server;
let srv_handle = srv.clone();
let thread = std::thread::Builder::new()
.name("qbz-control".into())
.spawn(move || {
for mut req in srv.incoming_requests() {
// `/api/events` is a long-lived SSE stream: it would block this
// single serving thread forever. Move it onto its OWN thread
// (Request is Send) so the control plane keeps answering. The
// origin/token gate is applied first, identically to `route`.
let is_events = *req.method() == Method::Get
&& req.url().split('?').next() == Some("/api/events");
if is_events {
let has_origin = req.headers().iter().any(|h| h.field.equiv("Origin"));
let auth = req
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"))
.map(|h| h.value.as_str().to_owned());
if let Some(reject) = access_gate(
has_origin,
"GET",
"/api/events",
auth.as_deref(),
state.token(),
) {
let _ = req.respond(reject.response());
continue;
}
let rx = state.events().subscribe();
std::thread::Builder::new()
.name("qbz-control-sse".into())
.spawn(move || sse::stream(req, rx))
.ok();
continue;
}
let has_origin = req.headers().iter().any(|h| h.field.equiv("Origin"));
let auth = req
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"))
.map(|h| h.value.as_str());
let path = req.url().split('?').next().unwrap_or("");
let resp = if let Some(reject) =
access_gate(has_origin, req.method().as_str(), path, auth, state.token())
{
reject.response()
} else {
state.route(&mut req)
};
let _ = req.respond(resp);
}
})
.expect("failed to spawn shared API thread");
ApiHandle {
server: srv_handle,
thread: Some(thread),
}
}
+219
View File
@@ -0,0 +1,219 @@
// Shared API — the `GET /api/events` Server-Sent Events stream
// (CONSOLE ext). A live push feed of CoreEvents so a client (a plasmoid, a bar
// applet, `qbzd watch`) reacts to playback/queue/library changes without
// polling.
//
// Concurrency: the control-plane serve loop is single-threaded, so an open SSE
// stream would starve every other request. `serve()` therefore moves this onto
// its OWN thread (Request is Send); `stream` blocks there until the client
// disconnects (a write fails) or the bus closes. The rusqlite-free bus is a
// tokio broadcast, drained here with `blocking_recv()` from the plain thread.
//
// Wire format: one SSE frame per emitted event —
// event: <CoreEvent type>\n
// data: {"type":"…","data":{…}}\n\n
// The `data` line preserves the tagged CoreEvent JSON envelope, with
// LoggedIn projected to public identity only. A leading comment primes the
// stream; a dropped-lag notice reports lost events.
use qbz_models::CoreEvent;
use std::io::{self, Write};
use tiny_http::Request;
use tokio::sync::broadcast;
/// Send each frame immediately. tiny_http's Response reader path uses a
/// chunked-transfer encoder with an 8 KiB buffer and cannot flush between
/// events. Taking the response writer keeps its connection lifecycle while
/// letting us flush complete HTTP chunks before blocking for the next event.
pub fn stream(req: Request, rx: broadcast::Receiver<CoreEvent>) {
let chunked = (req.http_version().0, req.http_version().1) >= (1, 1);
let mut writer = req.into_writer();
let _ = write_stream(&mut writer, rx, chunked);
}
fn write_stream(
writer: &mut dyn Write,
rx: broadcast::Receiver<CoreEvent>,
chunked: bool,
) -> io::Result<()> {
writer.write_all(if chunked {
b"HTTP/1.1 200 OK\r\n"
} else {
b"HTTP/1.0 200 OK\r\n"
})?;
writer.write_all(b"Content-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\nX-Accel-Buffering: no\r\n")?;
if chunked {
writer.write_all(b"Transfer-Encoding: chunked\r\n")?;
}
writer.write_all(b"\r\n")?;
let mut events = EventFrames { rx, primed: false };
while let Some(frame) = events.next_frame() {
if chunked {
write!(writer, "{:x}\r\n", frame.len())?;
}
writer.write_all(&frame)?;
if chunked {
writer.write_all(b"\r\n")?;
}
writer.flush()?;
}
if chunked {
writer.write_all(b"0\r\n\r\n")?;
}
writer.flush()
}
struct EventFrames {
rx: broadcast::Receiver<CoreEvent>,
primed: bool,
}
impl EventFrames {
fn next_frame(&mut self) -> Option<Vec<u8>> {
if !self.primed {
self.primed = true;
return Some(b": qbzd event stream\n\n".to_vec());
}
loop {
match self.rx.blocking_recv() {
Ok(ev) => {
if let Some(frame) = format_event(&ev) {
return Some(frame.into_bytes());
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
return Some(format!(": lagged {n} event(s)\n\n").into_bytes());
}
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}
}
/// Render one CoreEvent as an SSE frame, or `None` for events not worth pushing
/// to a UI client (bulky search payloads, internal loading/download/navigation
/// hints, diagnostics). Everything else — playback, queue, volume, auth,
/// favorites, playlists, errors, device changes — is emitted.
fn format_event(ev: &CoreEvent) -> Option<String> {
if !emit(ev) {
return None;
}
// Project from an allowlist instead of serializing credentials and trying
// to strip known secret fields afterwards. New session fields stay private.
let value = match ev {
CoreEvent::LoggedIn { session } => serde_json::json!({
"type": "LoggedIn",
"data": {"session": {
"user_id": session.user_id,
"display_name": session.display_name,
}},
}),
_ => serde_json::to_value(ev).ok()?,
};
let typ = value
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("event")
.to_string();
let data = serde_json::to_string(&value).ok()?;
Some(format!("event: {typ}\ndata: {data}\n\n"))
}
fn emit(ev: &CoreEvent) -> bool {
use CoreEvent::*;
!matches!(
ev,
SearchResultsReceived { .. }
| LoadingStarted { .. }
| LoadingCompleted { .. }
| DownloadProgress { .. }
| DownloadCompleted { .. }
| NavigateToAlbum { .. }
| NavigateToArtist { .. }
| NavigateToPlaylist { .. }
| AudioDiagnostic { .. }
)
}
#[cfg(test)]
mod tests {
use super::*;
use qbz_models::PlaybackState;
#[test]
fn stream_finishes_chunks_when_bus_closes_and_supports_http_10() {
for chunked in [true, false] {
let (tx, rx) = broadcast::channel(4);
tx.send(CoreEvent::VolumeChanged { volume: 0.25 }).unwrap();
drop(tx);
let mut output = Vec::new();
write_stream(&mut output, rx, chunked).unwrap();
let wire = String::from_utf8(output).unwrap();
assert!(wire.contains("event: VolumeChanged"));
if chunked {
assert!(wire.contains("Transfer-Encoding: chunked"));
assert!(wire.ends_with("0\r\n\r\n"));
} else {
assert!(wire.starts_with("HTTP/1.0 200 OK"));
assert!(!wire.contains("Transfer-Encoding"));
assert!(wire.ends_with("\n\n"));
}
}
}
#[test]
fn logged_in_frame_contains_public_identity_only() {
let frame = format_event(&CoreEvent::LoggedIn {
session: qbz_models::UserSession {
user_auth_token: "fixture-private-token".into(),
user_id: 7,
display_name: "Listener".into(),
email: "private@example.invalid".into(),
..Default::default()
},
})
.unwrap();
assert!(!frame.contains("fixture-private-token"));
assert!(!frame.contains("user_auth_token"));
assert!(!frame.contains("private@example.invalid"));
let line = frame
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap();
let value: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
value,
serde_json::json!({
"type": "LoggedIn",
"data": {"session": {"user_id": 7, "display_name": "Listener"}}
})
);
}
#[test]
fn playback_event_becomes_a_typed_sse_frame() {
let frame = format_event(&CoreEvent::PlaybackStateChanged {
state: PlaybackState::Playing,
})
.expect("playback event is emitted");
assert!(frame.starts_with("event: PlaybackStateChanged\n"));
assert!(frame.contains("data: {"));
assert!(frame.ends_with("\n\n"));
// The data line carries the tagged CoreEvent JSON.
assert!(frame.contains("\"type\":\"PlaybackStateChanged\""));
}
#[test]
fn bulky_and_internal_events_are_not_emitted() {
assert!(format_event(&CoreEvent::LoadingStarted {
operation: "x".into()
})
.is_none());
assert!(format_event(&CoreEvent::DownloadCompleted { track_id: 1 }).is_none());
assert!(format_event(&CoreEvent::NavigateToArtist { artist_id: 1 }).is_none());
}
#[test]
fn volume_and_queue_events_are_emitted() {
assert!(format_event(&CoreEvent::VolumeChanged { volume: 0.5 }).is_some());
assert!(format_event(&CoreEvent::ShuffleChanged { enabled: true }).is_some());
}
}
+272
View File
@@ -0,0 +1,272 @@
use std::io::Cursor;
use tiny_http::{Header, Response};
/// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a
/// pure decision (unit-testable without a tiny_http `Request`, which has no
/// public constructor) while `route` renders the normative envelope.
pub(crate) enum GateReject {
OriginForbidden,
InvalidToken,
}
impl GateReject {
pub(crate) fn response(&self) -> Response<Cursor<Vec<u8>>> {
match self {
GateReject::OriginForbidden => err_json(
403,
"origin_forbidden",
"requests with an Origin header are refused",
"the control plane is not a browser API",
),
GateReject::InvalidToken => err_json(
401,
"invalid_token",
"missing or wrong bearer token",
"set QBZD_TOKEN or check [server] token in qbzd.toml",
),
}
}
}
/// The pre-routing access decision (02 §3.1.2): Origin shield always on; the
/// opt-in Bearer required on every route except `GET /api/ping` when `token`
/// is `Some`. `None` = open (no auth machinery). Returns `Some(_)` to reject.
pub(crate) fn access_gate(
has_origin: bool,
method: &str,
path: &str,
auth_header: Option<&str>,
token: Option<&str>,
) -> Option<GateReject> {
if has_origin {
return Some(GateReject::OriginForbidden);
}
if let Some(secret) = token {
let is_ping = method == "GET" && path == "/api/ping";
let expected = format!("Bearer {secret}");
let ok = auth_header
.map(|v| constant_time_eq(v.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
if !is_ping && !ok {
return Some(GateReject::InvalidToken);
}
}
None
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// Canonical JSON number for a volume level (0.0-1.0). `serde_json::Value`
/// backs f32 via `Number::from_f32`, which stores the value as `f as f64` —
/// the f32→f64 widening turns `0.8f32` into `0.800000011920929` on the wire.
/// 02-cli-and-api.md §2.2/§3.3.4 document plain `0.8`, and `--json` is the
/// frozen machine contract ("scripts parse this"), so every volume-bearing
/// response routes through this instead of a bare `json!(v)`. 3 decimals is
/// plenty of precision for a 0.0-1.0 level.
pub fn canon_volume(v: f32) -> serde_json::Value {
let rounded = (v as f64 * 1000.0).round() / 1000.0;
serde_json::json!(rounded)
}
/// A 2xx JSON response. `pub(crate)` so the per-route handlers in `status.rs`
/// share the exact same envelope framing.
pub fn json(status: u16, body: serde_json::Value) -> Response<Cursor<Vec<u8>>> {
let bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
let header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
.expect("static content-type header");
Response::from_data(bytes)
.with_status_code(status)
.with_header(header)
}
/// The normative error envelope (02 §3.1.3): `{"error":{"code","message","hint"}}`.
/// The CLI keys its exit code off `code` (never raw HTTP status), and every hint
/// names the fix (§1.4 error voice). The G0 addendum's shorthand
/// `{"error":"origin_forbidden"}` is this same nested envelope — the uniform
/// §3.1.3 shape the CLI's `error_from_envelope` reads via `error.code`.
pub fn err_json(status: u16, code: &str, message: &str, hint: &str) -> Response<Cursor<Vec<u8>>> {
json(status, error_body(code, message, hint))
}
pub fn error_body(code: &str, message: &str, hint: &str) -> serde_json::Value {
serde_json::json!({"error": {"code": code, "message": message, "hint": hint}})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{P0_ROUTES, P1_ROUTES};
#[test]
fn route_table_matches_spec_count() {
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes, FINAL; grows ONLY
// with a shipped client. T1-T6 landed 3; T7 +9 = 12; T8 +4 = 16; T11
// (this task) +1 = 17.
assert_eq!(P0_ROUTES.len(), 17);
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
assert!(P0_ROUTES.contains(&("GET", "/api/now-playing")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/play")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/pause")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/toggle")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/stop")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/next")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/previous")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/seek")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/volume")));
assert!(P0_ROUTES.contains(&("GET", "/api/queue")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/add")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/remove")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/clear")));
assert!(P0_ROUTES.contains(&("POST", "/api/settings/reload")));
}
#[test]
fn p1_route_table_grows_only_with_a_shipped_caller() {
// 02-cli-and-api.md §3.4 — each P1 route lands with its CLI verb (the
// §3.1.4 HARD RULE, applied to the content-verb door). Row 19:
// GET /api/search — caller: `qbzd search`. Count is pinned so a route
// with no caller cannot creep in; P1 must never overlap P0.
assert_eq!(P1_ROUTES.len(), 27);
assert!(P1_ROUTES.contains(&("GET", "/api/events"))); // caller: `qbzd watch`
assert!(P1_ROUTES.contains(&("GET", "/api/artwork/current"))); // caller: `qbzd art`
assert!(P1_ROUTES.contains(&("GET", "/api/discover")));
assert!(P1_ROUTES.contains(&("GET", "/api/lyrics")));
assert!(P1_ROUTES.contains(&("POST", "/api/reco/playlist")));
assert!(P1_ROUTES.contains(&("GET", "/api/favorites")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/add")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/remove")));
assert!(P1_ROUTES.contains(&("GET", "/api/playlists")));
assert!(P1_ROUTES.contains(&("GET", "/api/playlist")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/create")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/update")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/delete")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/tracks/add")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/tracks/remove")));
assert!(P1_ROUTES.contains(&("GET", "/api/search")));
assert!(P1_ROUTES.contains(&("POST", "/api/play")));
assert!(P1_ROUTES.contains(&("GET", "/api/album")));
assert!(P1_ROUTES.contains(&("GET", "/api/artist")));
assert!(P1_ROUTES.contains(&("GET", "/api/similar")));
assert!(P1_ROUTES.contains(&("GET", "/api/suggest")));
assert!(P1_ROUTES.contains(&("POST", "/api/radio")));
assert!(P1_ROUTES.contains(&("POST", "/api/playback/shuffle")));
assert!(P1_ROUTES.contains(&("POST", "/api/playback/repeat")));
assert!(P1_ROUTES.contains(&("POST", "/api/queue/move")));
assert!(P1_ROUTES.contains(&("POST", "/api/queue/jump")));
assert!(P1_ROUTES.contains(&("POST", "/api/queue/stop-after")));
for r in P1_ROUTES {
assert!(
!P0_ROUTES.contains(r),
"{r:?} is duplicated across P0 and P1"
);
}
}
#[test]
fn constant_time_eq_matches_only_identical_slices() {
assert!(constant_time_eq(b"Bearer s3cret", b"Bearer s3cret"));
assert!(!constant_time_eq(b"Bearer s3cret", b"Bearer wrong"));
assert!(!constant_time_eq(b"Bearer s3cret", b"Bearer s3cret-extra"));
assert!(!constant_time_eq(b"", b"x"));
}
fn code(r: Option<GateReject>) -> Option<&'static str> {
r.map(|r| match r {
GateReject::OriginForbidden => "origin_forbidden",
GateReject::InvalidToken => "invalid_token",
})
}
#[test]
fn origin_header_is_refused_on_every_route_including_ping() {
// Step 4(a): an Origin header → 403 origin_forbidden everywhere, even
// /api/ping, and even in the open (token=None) default.
for (m, p) in P0_ROUTES {
assert_eq!(
code(access_gate(true, m, p, None, None)),
Some("origin_forbidden"),
"{m} {p} with Origin must be refused"
);
}
// ...and the Origin shield wins even when a valid Bearer is present.
assert_eq!(
code(access_gate(
true,
"GET",
"/api/ping",
Some("Bearer s3cret"),
Some("s3cret")
)),
Some("origin_forbidden")
);
}
#[test]
fn open_mode_answers_every_route_without_auth() {
// Step 4(b): token=None → no auth machinery; nothing is rejected.
for (m, p) in P0_ROUTES {
assert!(
access_gate(false, m, p, None, None).is_none(),
"{m} {p} must be open"
);
}
}
#[test]
fn opt_in_token_rejects_missing_or_wrong_bearer_but_never_ping() {
// Step 4(c): token=Some → missing/wrong bearer is 401 on non-ping routes;
// /api/ping stays 200 (exempt); the correct bearer passes.
let tok = Some("s3cret");
assert_eq!(
code(access_gate(false, "GET", "/api/status", None, tok)),
Some("invalid_token")
);
assert_eq!(
code(access_gate(
false,
"GET",
"/api/status",
Some("Bearer nope"),
tok
)),
Some("invalid_token")
);
assert!(access_gate(false, "GET", "/api/status", Some("Bearer s3cret"), tok).is_none());
// /api/ping answers even with no/ wrong bearer.
assert!(access_gate(false, "GET", "/api/ping", None, tok).is_none());
assert!(access_gate(false, "GET", "/api/ping", Some("Bearer nope"), tok).is_none());
}
#[test]
fn canon_volume_pins_0_8_exactly_no_f32_widening() {
// `serde_json::Number::from_f32` widens f32→f64 (`0.8f32` would
// serialize raw as `0.800000011920929`); `canon_volume` must not.
assert_eq!(serde_json::to_string(&canon_volume(0.8f32)).unwrap(), "0.8");
assert_eq!(serde_json::to_string(&canon_volume(1.0f32)).unwrap(), "1.0");
assert_eq!(serde_json::to_string(&canon_volume(0.0f32)).unwrap(), "0.0");
assert_eq!(
serde_json::to_string(&canon_volume(0.75f32)).unwrap(),
"0.75"
);
}
#[test]
fn error_envelope_is_the_nested_shape_with_code() {
// 02 §3.1.3 — the on-the-wire shape the CLI reads via `error.code`.
let body = error_body("origin_forbidden", "refused", "not a browser API");
assert_eq!(body["error"]["code"], "origin_forbidden");
assert_eq!(body["error"]["message"], "refused");
assert_eq!(body["error"]["hint"], "not a browser API");
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Real loopback HTTP against two independent host fixtures. No audio/runtime,
//! account, machine profile or external service is used.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use std::time::Duration;
use qbz_control::{bind, serve, ApiHandle, HttpHost};
use qbz_models::CoreEvent;
use tiny_http::{Request, Response};
use tokio::sync::broadcast;
struct Host {
name: &'static str,
token: Option<String>,
bus: broadcast::Sender<CoreEvent>,
calls: Arc<AtomicUsize>,
dropped: std::sync::mpsc::Sender<()>,
}
impl Drop for Host {
fn drop(&mut self) {
let _ = self.dropped.send(());
}
}
impl HttpHost for Host {
fn token(&self) -> Option<&str> {
self.token.as_deref()
}
fn events(&self) -> &broadcast::Sender<CoreEvent> {
&self.bus
}
fn route(&self, req: &mut Request) -> Response<std::io::Cursor<Vec<u8>>> {
self.calls.fetch_add(1, Ordering::SeqCst);
match req.url().split('?').next().unwrap() {
"/api/ping" => qbz_control::json(200, serde_json::json!({"app":self.name})),
"/api/echo" => {
let mut data = String::new();
req.as_reader().read_to_string(&mut data).unwrap();
qbz_control::json(200, serde_json::json!({"app":self.name,"data":data}))
}
_ => qbz_control::err_json(404, "not_found", "unknown route", "fixture"),
}
}
}
struct Fixture {
addr: SocketAddr,
handle: Option<ApiHandle>,
bus: broadcast::Sender<CoreEvent>,
calls: Arc<AtomicUsize>,
dropped: std::sync::mpsc::Receiver<()>,
}
impl Fixture {
fn start(name: &'static str, token: Option<&str>) -> Self {
let bound = bind("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = bound.local_addr();
let (bus, _) = broadcast::channel(16);
let calls = Arc::new(AtomicUsize::new(0));
let (tx, dropped) = std::sync::mpsc::channel();
let handle = serve(
bound,
Host {
name,
token: token.map(str::to_owned),
bus: bus.clone(),
calls: calls.clone(),
dropped: tx,
},
);
Self {
addr,
handle: Some(handle),
bus,
calls,
dropped,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
handle.shutdown();
}
}
}
fn connect(addr: SocketAddr, method: &str, path: &str, headers: &str, body: &str) -> TcpStream {
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3)).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(3)))
.unwrap();
write!(stream, "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n{headers}Content-Length: {}\r\n\r\n{body}", body.len()).unwrap();
stream
}
fn request(
addr: SocketAddr,
method: &str,
path: &str,
headers: &str,
body: &str,
) -> (u16, serde_json::Value) {
let mut stream = connect(addr, method, path, headers, body);
let mut text = String::new();
stream.read_to_string(&mut text).unwrap();
let status = text.split_whitespace().nth(1).unwrap().parse().unwrap();
let body = text.split_once("\r\n\r\n").unwrap().1;
(status, serde_json::from_str(body).unwrap())
}
#[test]
fn two_hosts_route_to_their_own_state_and_gate_before_dispatch() {
let a = Fixture::start("first", Some("alpha"));
let b = Fixture::start("second", Some("beta"));
assert_eq!(
request(a.addr, "GET", "/api/ping?probe=1", "", "").1["app"],
"first"
);
assert_eq!(
request(b.addr, "GET", "/api/ping", "", "").1["app"],
"second"
);
let before = a.calls.load(Ordering::SeqCst);
for path in ["/api/echo", "/api/events", "/missing"] {
let (status, body) = request(a.addr, "GET", path, "Authorization: Bearer beta\r\n", "");
assert_eq!(status, 401);
assert_eq!(body["error"]["code"], "invalid_token");
}
for path in ["/api/ping", "/api/echo", "/api/events"] {
let (status, body) = request(
a.addr,
"GET",
path,
"Origin: https://example.invalid\r\nAuthorization: Bearer alpha\r\n",
"",
);
assert_eq!(status, 403);
assert_eq!(body["error"]["code"], "origin_forbidden");
}
assert_eq!(a.calls.load(Ordering::SeqCst), before);
assert_eq!(a.bus.receiver_count(), 0);
let (status, echo) = request(
a.addr,
"POST",
"/api/echo",
"Authorization: Bearer alpha\r\n",
"payload",
);
assert_eq!(status, 200);
assert_eq!(echo, serde_json::json!({"app":"first","data":"payload"}));
assert_eq!(
request(
b.addr,
"GET",
"/missing",
"Authorization: Bearer beta\r\n",
""
)
.0,
404
);
}
#[test]
fn sse_uses_selected_host_bus_without_blocking_regular_requests() {
let a = Fixture::start("first", None);
let b = Fixture::start("second", None);
let stream = connect(a.addr, "GET", "/api/events", "", "");
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert!(line.starts_with("HTTP/1.1 200"));
let mut headers = String::new();
loop {
line.clear();
reader.read_line(&mut line).unwrap();
if line == "\r\n" {
break;
}
assert!(!line.is_empty(), "SSE closed before headers finished");
headers.push_str(&line);
}
assert!(headers
.to_ascii_lowercase()
.contains("transfer-encoding: chunked"));
// The priming comment must reach the socket even before the first event.
assert_eq!(read_chunk(&mut reader), ": qbzd event stream\n\n");
assert_eq!(a.bus.receiver_count(), 1);
assert_eq!(b.bus.receiver_count(), 0);
assert_eq!(request(a.addr, "GET", "/api/ping", "", "").0, 200);
a.bus
.send(CoreEvent::VolumeChanged { volume: 0.25 })
.unwrap();
let volume_frame = read_chunk(&mut reader);
assert!(volume_frame.starts_with("event: VolumeChanged\n"));
assert!(volume_frame.ends_with("\n\n"));
let data = volume_frame
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(data).unwrap(),
serde_json::json!({"type": "VolumeChanged", "data": {"volume": 0.25}})
);
a.bus
.send(CoreEvent::LoggedIn {
session: qbz_models::UserSession {
user_id: 5,
display_name: "Música".into(),
user_auth_token: "private-fixture-token".into(),
..Default::default()
},
})
.unwrap();
let frame = read_chunk(&mut reader);
assert!(frame.contains("Música"));
assert!(!frame.contains("private-fixture-token"));
assert!(!frame.contains("user_auth_token"));
}
fn read_chunk(reader: &mut impl BufRead) -> String {
let mut size = String::new();
reader.read_line(&mut size).unwrap();
let len = usize::from_str_radix(size.trim(), 16).unwrap();
let mut bytes = vec![0; len];
reader.read_exact(&mut bytes).unwrap();
let mut end = [0u8; 2];
reader.read_exact(&mut end).unwrap();
assert_eq!(&end, b"\r\n");
String::from_utf8(bytes).unwrap()
}
#[test]
fn shutdown_releases_host_state_and_listener() {
let mut fixture = Fixture::start("first", None);
assert_eq!(request(fixture.addr, "GET", "/api/ping", "", "").0, 200);
fixture.handle.take().unwrap().shutdown();
fixture
.dropped
.recv_timeout(Duration::from_secs(3))
.unwrap();
// tiny_http wakes its background accept thread on Drop but does not join
// it. Host state must be gone above; allow that documented asynchronous
// socket close to finish, without an unconditional sleep or unbounded retry.
let deadline = std::time::Instant::now() + Duration::from_secs(3);
loop {
match bind(fixture.addr) {
Ok(rebound) => {
drop(rebound);
break;
}
Err(qbz_control::BindError::AddrInUse(_)) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("listener not released after shutdown: {error:?}"),
}
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Real HTTP + real scanner: admission, host ownership and playable-file metadata.
use qbz_control::library::LibraryHost;
use qbz_library::{service::LibraryService, LibraryStore};
use serde_json::{json, Value};
use std::{
io::{Read, Write},
net::{SocketAddr, TcpStream},
sync::Arc,
time::Duration,
};
fn request(addr: SocketAddr, method: &str, path: &str, token: &str, body: Value) -> (u16, Value) {
let mut socket = TcpStream::connect(addr).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let body = body.to_string();
write!(socket, "{method} {path} HTTP/1.0\r\nHost: localhost\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
let mut response = String::new();
socket.read_to_string(&mut response).unwrap();
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
(
headers.split_whitespace().nth(1).unwrap().parse().unwrap(),
serde_json::from_str(body).unwrap(),
)
}
fn wav(path: &std::path::Path) {
let data = vec![0u8; 88200];
let mut bytes = Vec::new();
bytes.extend(b"RIFF");
bytes.extend((36 + data.len() as u32).to_le_bytes());
bytes.extend(b"WAVEfmt ");
bytes.extend(16u32.to_le_bytes());
bytes.extend(1u16.to_le_bytes());
bytes.extend(1u16.to_le_bytes());
bytes.extend(44100u32.to_le_bytes());
bytes.extend(88200u32.to_le_bytes());
bytes.extend(2u16.to_le_bytes());
bytes.extend(16u16.to_le_bytes());
bytes.extend(b"data");
bytes.extend((data.len() as u32).to_le_bytes());
bytes.extend(data);
std::fs::write(path, bytes).unwrap();
}
#[test]
fn administration_scans_only_the_named_host_and_rejects_stale_commands() {
let temp = tempfile::tempdir().unwrap();
let mut hosts = Vec::new();
for name in ["a", "b"] {
let root = temp.path().join(name);
let service = Arc::new(LibraryService::new(
LibraryStore::new(root.join("library.db")),
root.join("artwork"),
));
let bound = qbz_control::bind("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = bound.local_addr();
let server = qbz_control::serve(
bound,
LibraryHost::with_management(service.clone(), name.into(), name.into(), true),
);
let (code, info) = request(addr, "GET", "/api/orbit/library", name, Value::Null);
assert_eq!(code, 200);
assert!(info["capabilities"]
.as_array()
.unwrap()
.contains(&json!("library.files.manage")));
hosts.push((server, service, addr, info));
}
let (_, service, addr, info) = &hosts[0];
let music = temp.path().join("Music");
std::fs::create_dir(&music).unwrap();
wav(&music.join("Orbit sample.wav"));
let add = json!({"instance":info["instance"], "revision":0, "path":music.to_str().unwrap()});
assert_eq!(
request(
*addr,
"POST",
"/api/orbit/library/folders",
"wrong",
add.clone()
)
.0,
401
);
let mut wrong = add.clone();
wrong["instance"] = hosts[1].3["instance"].clone();
assert_eq!(
request(*addr, "POST", "/api/orbit/library/folders", "a", wrong).0,
409
);
assert!(!service.store().database_path().exists());
let mut invalid = add.clone();
invalid["path"] = json!("relative");
assert_eq!(
request(*addr, "POST", "/api/orbit/library/folders", "a", invalid).0,
400
);
let (code, folders) = request(
*addr,
"POST",
"/api/orbit/library/folders",
"a",
add.clone(),
);
assert_eq!(code, 200, "{folders}");
assert_eq!(folders["revision"], 1);
assert_eq!(folders["folders"].as_array().unwrap().len(), 1);
assert_eq!(
request(*addr, "POST", "/api/orbit/library/folders", "a", add).0,
409
);
assert!(!hosts[1].1.store().database_path().exists());
let scan = json!({"instance":info["instance"], "folder_id":folders["folders"][0]["id"]});
let mut missing = scan.clone();
missing["folder_id"] = json!(9999);
assert_eq!(
request(*addr, "POST", "/api/orbit/library/scan", "a", missing).0,
404
);
assert_eq!(
request(*addr, "POST", "/api/orbit/library/scan", "a", scan).0,
202
);
assert!(service.wait_idle(Duration::from_secs(10)));
let (_, after) = request(*addr, "GET", "/api/orbit/library", "a", Value::Null);
assert_eq!(after["tracks"], 1, "{after}");
assert_eq!(after["revision"], 2);
let instance = info["instance"].as_str().unwrap();
let (_, page) = request(
*addr,
"GET",
&format!("/api/orbit/library/search?instance={instance}&q=Orbit"),
"a",
Value::Null,
);
assert_eq!(page["tracks"].as_array().unwrap().len(), 1);
assert!(!page.to_string().contains(music.to_str().unwrap()));
let (_, jobs) = request(
*addr,
"GET",
&format!("/api/orbit/library/jobs?instance={instance}"),
"a",
Value::Null,
);
assert_eq!(jobs["library"]["last_scan"]["outcome"], "complete");
assert_eq!(
request(
*addr,
"POST",
"/api/orbit/library/cancel",
"a",
json!({"instance":instance,"job":1})
)
.0,
409
);
for (server, service, _, _) in hosts {
server.shutdown();
service.shutdown();
}
}
#[test]
fn inspection_does_not_grant_administration_and_bad_bodies_never_create_a_profile() {
let temp = tempfile::tempdir().unwrap();
for management in [false, true] {
let root = temp.path().join(management.to_string());
let service = Arc::new(LibraryService::new(
LibraryStore::new(root.join("library.db")),
root.join("artwork"),
));
let bound = qbz_control::bind("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = bound.local_addr();
let server = qbz_control::serve(
bound,
LibraryHost::with_management(
service.clone(),
"fixture".into(),
"key".into(),
management,
),
);
let (_, info) = request(addr, "GET", "/api/orbit/library", "key", Value::Null);
for body in [
Value::Null,
json!({"instance":info["instance"],"unknown":true}),
json!({"instance":info["instance"],"path":"x".repeat(9000),"revision":0}),
] {
assert_eq!(
request(addr, "POST", "/api/orbit/library/folders", "key", body).0,
if management { 400 } else { 404 }
);
}
assert!(!root.exists());
server.shutdown();
service.shutdown();
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Orbit inspection against real independent profile databases and sockets.
use qbz_control::library::LibraryHost;
use qbz_library::{service::LibraryService, LibraryStore, LocalTrack};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::Duration;
fn request(addr: SocketAddr, path: &str, token: &str) -> (u16, serde_json::Value) {
let mut socket = TcpStream::connect(addr).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
write!(
socket,
"GET {path} HTTP/1.0\r\nHost: localhost\r\nAuthorization: Bearer {token}\r\n\r\n"
)
.unwrap();
let mut response = String::new();
socket.read_to_string(&mut response).unwrap();
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
(
headers.split_whitespace().nth(1).unwrap().parse().unwrap(),
serde_json::from_str(body).unwrap(),
)
}
#[test]
fn two_hosts_keep_search_identity_paths_and_access_keys_separate() {
let temp = tempfile::tempdir().unwrap();
let mut hosts = Vec::new();
for name in ["desktop", "daemon"] {
let root = temp.path().join(name);
let service = Arc::new(LibraryService::new(
LibraryStore::new(root.join("library.db")),
root.join("artwork"),
));
service
.store()
.write(|db| {
for n in 0..3 {
db.insert_track(&LocalTrack {
file_path: format!("/private/{name}/secret-{n}.flac"),
title: format!("Shared title {n}"),
artist: name.into(),
album: "Fixture".into(),
artwork_path: Some("/private/artwork.jpg".into()),
..Default::default()
})?;
}
Ok(())
})
.unwrap();
let bound = qbz_control::bind("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = bound.local_addr();
let token = format!("{name}-private-access-key");
let handle = qbz_control::serve(
bound,
LibraryHost::new(service.clone(), name.into(), token.clone()),
);
let (code, info) = request(addr, "/api/orbit/library", &token);
assert_eq!(code, 200);
assert_eq!(info["tracks"], 3);
assert_eq!(
info["capabilities"],
serde_json::json!(["library.files.inspect", "library.files.search"])
);
hosts.push((handle, service, addr, token, info));
}
assert_ne!(hosts[0].4["instance"], hosts[1].4["instance"]);
for (index, name) in ["desktop", "daemon"].into_iter().enumerate() {
let (_, service, addr, token, info) = &hosts[index];
let instance = info["instance"].as_str().unwrap();
let path = format!("/api/orbit/library/search?q=Shared+title&limit=2&instance={instance}");
assert_eq!(request(*addr, &path, &hosts[1 - index].3).0, 401);
let (code, page) = request(*addr, &path, token);
assert_eq!(code, 200);
assert_eq!(page["tracks"].as_array().unwrap().len(), 2);
assert_eq!(page["tracks"][0]["artist"], name);
assert_eq!(page["has_more"], true);
let raw = page.to_string();
assert!(!raw.contains("/private"));
assert!(!raw.contains("access-key"));
assert_eq!(
request(*addr, &path.replace("limit=2", "limit=100000"), token).0,
400
);
assert_eq!(
request(
*addr,
&path.replace(instance, hosts[1 - index].4["instance"].as_str().unwrap()),
token
)
.0,
409
);
service.close();
assert_eq!(request(*addr, &path, token).0, 409);
}
for (handle, service, _, _, _) in hosts {
handle.shutdown();
service.shutdown();
}
}
#[test]
fn reading_an_empty_host_needs_no_qobuz_session_and_creates_no_profile() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("uncreated");
let service = Arc::new(LibraryService::new(
LibraryStore::new(root.join("library.db")),
root.join("artwork"),
));
let bound = qbz_control::bind("127.0.0.1:0".parse().unwrap()).unwrap();
let addr = bound.local_addr();
let server = qbz_control::serve(
bound,
LibraryHost::new(service.clone(), "Guest".into(), "test-key".into()),
);
let (code, info) = request(addr, "/api/orbit/library", "test-key");
assert_eq!(code, 200);
assert_eq!(info["tracks"], 0);
assert!(!root.exists());
server.shutdown();
service.shutdown();
}
@@ -7072,3 +7072,71 @@ msgstr "Name (Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "My QBZ durchsuchen" msgstr "My QBZ durchsuchen"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Zugriffsschlüssel"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Verbindung verloren. Die Fernsteuerung ist nicht verfügbar."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "{} wird gesteuert"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Zugriffsschlüssel kopieren"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "Der Orbit-Server konnte nicht gestartet werden."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "Der Host konnte nicht überprüft werden."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Gib eine gültige Hostadresse ein."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Hostadresse"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Host überprüft"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Bibliothek nicht verfügbar"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Empfangsadresse"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Lokale Dateien"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbit ist experimentell. Das Überprüfen eines Hosts ändert weder Wiedergabe noch Einstellungen."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Diese Bibliothek freigeben"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "Der Host hat sich geändert. Überprüfe ihn erneut."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "Dieser Host unterstützt keine Bibliotheksabfragen."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Host überprüfen"
@@ -2672,3 +2672,71 @@ msgstr "Name (Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "Search My QBZ" msgstr "Search My QBZ"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Access key"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Connection lost. Remote controls are unavailable."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "Controlling {}"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Copy access key"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "Could not start the Orbit listener."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "Could not verify the host."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Enter a valid host address."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Host address"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Host verified"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Library unavailable"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Listening address"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Local files"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbit is experimental. Verifying a host does not switch playback or settings."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Share this library"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "The host changed. Verify it again."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "This host does not support library inspection."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Verify host"
@@ -7078,3 +7078,71 @@ msgstr "Nombre (Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "Buscar en My QBZ" msgstr "Buscar en My QBZ"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Clave de acceso"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Se perdió la conexión. Los controles remotos no están disponibles."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "Controlando {}"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Copiar clave de acceso"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "No se pudo iniciar el servidor de Orbit."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "No se pudo verificar el equipo."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Ingresa una dirección válida del equipo."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Dirección del equipo"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Equipo verificado"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Biblioteca no disponible"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Dirección de escucha"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Archivos locales"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbit es experimental. Verificar un equipo no cambia la reproducción ni la configuración."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Compartir esta biblioteca"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "El equipo cambió. Verifícalo de nuevo."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "Este equipo no permite consultar su biblioteca."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Verificar equipo"
@@ -7070,3 +7070,71 @@ msgstr "Nom (Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "Rechercher dans My QBZ" msgstr "Rechercher dans My QBZ"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Clé d’accès"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Connexion perdue. Les commandes à distance sont indisponibles."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "Contrôle de {}"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Copier la clé d’accès"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "Impossible de démarrer le serveur Orbit."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "Impossible de vérifier l’hôte."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Saisissez une adresse d’hôte valide."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Adresse de l’hôte"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Hôte vérifié"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Bibliothèque indisponible"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Adresse d’écoute"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Fichiers locaux"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbit est expérimental. Vérifier un hôte ne change ni la lecture ni les paramètres."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Partager cette bibliothèque"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "L’hôte a changé. Vérifiez-le à nouveau."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "Cet hôte ne permet pas de consulter sa bibliothèque."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Vérifier l’hôte"
@@ -7053,3 +7053,71 @@ msgstr "名前(Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "My QBZ を検索" msgstr "My QBZ を検索"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "アクセスキー"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "接続が失われました。リモート操作は利用できません。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "{} を操作中"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "アクセスキーをコピー"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "Orbitサーバーを起動できませんでした。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "ホストを確認できませんでした。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "有効なホストアドレスを入力してください。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "ホストアドレス"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "ホストを確認しました"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "ライブラリを利用できません"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "待ち受けアドレス"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "ローカルファイル"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbitは実験的な機能です。ホストを確認しても、再生や設定の対象は切り替わりません。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "このライブラリを共有"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "ホストが変更されました。もう一度確認してください。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "このホストはライブラリの参照に対応していません。"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "ホストを確認"
@@ -7060,3 +7060,71 @@ msgstr "Naam (Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "My QBZ doorzoeken" msgstr "My QBZ doorzoeken"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Toegangssleutel"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Verbinding verbroken. Bediening op afstand is niet beschikbaar."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "{} bedienen"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Toegangssleutel kopiëren"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "De Orbit-server kon niet worden gestart."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "De host kon niet worden gecontroleerd."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Voer een geldig hostadres in."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Hostadres"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Host gecontroleerd"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Bibliotheek niet beschikbaar"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Luisteradres"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Lokale bestanden"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbit is experimenteel. Het controleren van een host verandert de afspeelbediening of instellingen niet."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Deze bibliotheek delen"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "De host is gewijzigd. Controleer deze opnieuw."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "Deze host ondersteunt geen bibliotheekinzage."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Host controleren"
@@ -7070,3 +7070,71 @@ msgstr "Nome (Z-A)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "Pesquisar em My QBZ" msgstr "Pesquisar em My QBZ"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Chave de acesso"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Conexão perdida. Os controles remotos estão indisponíveis."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "Controlando {}"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Copiar chave de acesso"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "Não foi possível iniciar o servidor Orbit."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "Não foi possível verificar o host."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Digite um endereço de host válido."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Endereço do host"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Host verificado"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Biblioteca indisponível"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Endereço de escuta"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Arquivos locais"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "O Orbit é experimental. Verificar um host não altera a reprodução nem as configurações."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Compartilhar esta biblioteca"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "O host mudou. Verifique-o novamente."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "Este host não permite consultar sua biblioteca."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Verificar host"
@@ -7091,3 +7091,71 @@ msgstr "Название (Я-А)"
msgid "Search My QBZ" msgid "Search My QBZ"
msgstr "Поиск в My QBZ" msgstr "Поиск в My QBZ"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Access key"
msgstr "Ключ доступа"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Connection lost. Remote controls are unavailable."
msgstr "Соединение потеряно. Удалённое управление недоступно."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Controlling {}"
msgstr "Управление {}"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Copy access key"
msgstr "Копировать ключ доступа"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not start the Orbit listener."
msgstr "Не удалось запустить сервер Orbit."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Could not verify the host."
msgstr "Не удалось проверить хост."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Enter a valid host address."
msgstr "Введите допустимый адрес хоста."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host address"
msgstr "Адрес хоста"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Host verified"
msgstr "Хост проверен"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Library unavailable"
msgstr "Библиотека недоступна"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Listening address"
msgstr "Адрес прослушивания"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Local files"
msgstr "Локальные файлы"
#. Experimental Orbit host inspection and persistent remote context.
msgid "Orbit is experimental. Verifying a host does not switch playback or settings."
msgstr "Orbit — экспериментальная функция. Проверка хоста не меняет воспроизведение или настройки."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Share this library"
msgstr "Открыть доступ к этой библиотеке"
#. Experimental Orbit host inspection and persistent remote context.
msgid "The host changed. Verify it again."
msgstr "Хост изменился. Проверьте его снова."
#. Experimental Orbit host inspection and persistent remote context.
msgid "This host does not support library inspection."
msgstr "Этот хост не поддерживает просмотр библиотеки."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Проверить хост"
+5 -1
View File
@@ -38,8 +38,8 @@ pub mod local_playlists;
mod metadata; mod metadata;
mod models; mod models;
mod mount_info; mod mount_info;
mod purchase_copies;
pub mod playlist_membership; pub mod playlist_membership;
mod purchase_copies;
pub mod qobuz_playlist_snapshot; pub mod qobuz_playlist_snapshot;
pub mod reachability; pub mod reachability;
mod remote_tag_sidecar; mod remote_tag_sidecar;
@@ -47,6 +47,9 @@ mod sacd;
pub mod sacd_scan; pub mod sacd_scan;
mod scan; mod scan;
mod scanner; mod scanner;
pub mod search;
pub mod service;
mod store;
mod tag_sidecar; mod tag_sidecar;
mod tag_writer; mod tag_writer;
mod thumbnails; mod thumbnails;
@@ -78,6 +81,7 @@ pub use scan::{scan_with_progress, ScanEvent};
pub use scanner::{ pub use scanner::{
LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy, LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy,
}; };
pub use store::LibraryStore;
pub use tag_writer::{ pub use tag_writer::{
compute_track_artist_match, inspect_album_tag_layers, read_editor_tag_snapshots, compute_track_artist_match, inspect_album_tag_layers, read_editor_tag_snapshots,
write_album_tags_to_files, write_album_tags_to_files_extended, write_album_tags_to_files, write_album_tags_to_files_extended,
+235
View File
@@ -0,0 +1,235 @@
//! Search the sources registered by one library host.
//!
//! "Local Library" is a scope, not a file-only provider. Hosts supply their
//! enabled source adapters; adding a provider does not change this merger.
//! This is a blocking service (cached/indexed data only), independent of Qt,
//! Qobuz authentication and the network API. LocalTrack remains an internal
//! playback record, not an Orbit wire DTO: it can contain host filesystem paths.
use crate::{LibraryError, LibraryStore, LocalTrack};
type Query = dyn Fn(&str, u64) -> Result<Vec<LocalTrack>, LibraryError> + Send + Sync;
struct Provider {
key: String,
query: Box<Query>,
}
#[derive(Default)]
pub struct LibrarySearch {
providers: Vec<Provider>,
}
pub struct SearchFailure {
pub source: String,
pub error: LibraryError,
}
#[derive(Default)]
pub struct LibrarySearchResult {
pub tracks: Vec<LocalTrack>,
/// One failed source must not discard successful sources' results.
pub failures: Vec<SearchFailure>,
/// Candidate window only: this is not an exact count of album/artist hits.
pub has_more: bool,
}
impl LibrarySearch {
/// The key identifies a configured source instance within this host.
/// Native track identity and metadata are left untouched by the merger.
pub fn add_source(
&mut self,
key: impl Into<String>,
query: impl Fn(&str, u64) -> Result<Vec<LocalTrack>, LibraryError> + Send + Sync + 'static,
) -> Result<(), LibraryError> {
let key = key.into();
if key.trim().is_empty() || self.providers.iter().any(|p| p.key == key) {
return Err(LibraryError::Other(
"duplicate or empty search source".into(),
));
}
self.providers.push(Provider {
key,
query: Box::new(query),
});
Ok(())
}
/// Capture the selected profile before scheduling work, never while it runs.
pub fn add_files(
&mut self,
store: LibraryStore,
exclude_network: bool,
) -> Result<(), LibraryError> {
self.add_source("files", move |q, limit| {
store
.read(|db| {
db.search_with_filter_page(q, 0, limit, true, exclude_network, "default")
})
.map(Option::unwrap_or_default)
})
}
/// Query a bounded window per provider and interleave it fairly. A large
/// media-server mirror cannot push all file hits out of the visible preview.
/// The limit is global as well as per-provider, and capped at 500 candidates.
pub fn search(&self, query: &str, limit: u64) -> LibrarySearchResult {
let query = query.trim();
let limit = limit.min(500) as usize;
let mut result = LibrarySearchResult::default();
if query.chars().count() < 2 || limit == 0 {
return result;
}
let mut batches = Vec::new();
for provider in &self.providers {
match (provider.query)(query, limit as u64) {
Ok(mut tracks) => {
result.has_more |= tracks.len() >= limit;
// Enforce the bound even if a provider returns too many rows.
tracks.truncate(limit);
batches.push(tracks.into_iter());
}
Err(error) => result.failures.push(SearchFailure {
source: provider.key.clone(),
error,
}),
}
}
while result.tracks.len() < limit {
let before = result.tracks.len();
for batch in &mut batches {
if result.tracks.len() == limit {
break;
}
if let Some(track) = batch.next() {
result.tracks.push(track);
}
}
if result.tracks.len() == before {
break;
}
}
result.has_more |= batches.iter().any(|b| b.len() > 0);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_registered_source_participates_without_identity_deduplication() {
let mut search = LibrarySearch::default();
// A fifth source requires registration only, not another merger branch.
for source in ["files", "plex", "jellyfin", "subsonic", "future"] {
search
.add_source(source, move |q, limit| {
assert_eq!(q, "曲名");
assert_eq!(limit, 6);
Ok((0..20)
.map(|i| LocalTrack {
id: i,
source: Some(source.into()),
title: "Same recording".into(),
..Default::default()
})
.collect())
})
.unwrap();
}
let result = search.search(" 曲名 ", 6);
assert_eq!(result.tracks.len(), 6);
assert!(result.has_more);
assert_eq!(
result
.tracks
.iter()
.map(|t| t.source.as_deref().unwrap())
.collect::<Vec<_>>(),
["files", "plex", "jellyfin", "subsonic", "future", "files"]
);
assert_eq!(
result.tracks[..5].iter().map(|t| t.id).collect::<Vec<_>>(),
vec![0; 5]
);
}
#[test]
fn failed_provider_does_not_hide_another_library_source() {
let mut search = LibrarySearch::default();
search
.add_source("unavailable", |_, _| {
Err(LibraryError::Other("offline".into()))
})
.unwrap();
search
.add_source("working", |_, _| Ok(vec![LocalTrack::default()]))
.unwrap();
let result = search.search("song", 10);
assert_eq!(result.tracks.len(), 1);
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0].source, "unavailable");
assert!(!result.has_more);
}
#[test]
fn profiles_are_captured_and_empty_queries_never_open_sources() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a/library.db");
let b = tmp.path().join("b/library.db");
let mut host_a = LibrarySearch::default();
let mut host_b = LibrarySearch::default();
host_a
.add_files(LibraryStore::new(a.clone()), false)
.unwrap();
host_b
.add_files(LibraryStore::new(b.clone()), false)
.unwrap();
assert!(host_a.search("song", 10).tracks.is_empty());
assert!(host_b.search("song", 10).tracks.is_empty());
assert!(!a.exists() && !b.exists());
host_a
.add_source("unused", |_, _| {
panic!("empty queries must not query providers")
})
.unwrap();
assert!(host_a.search(" 曲 ", 10).tracks.is_empty());
assert!(host_a.search("song", 0).tracks.is_empty());
assert!(host_a.add_source("files", |_, _| Ok(vec![])).is_err());
}
#[test]
fn files_search_uses_the_selected_hosts_database() {
let tmp = tempfile::tempdir().unwrap();
let mut hosts = Vec::new();
for name in ["desktop", "daemon"] {
let store = LibraryStore::new(tmp.path().join(name).join("library.db"));
store
.write(|db| {
db.insert_scanned_track(
&LocalTrack {
file_path: "/music/song.flac".into(),
title: format!("Song on {name}"),
artist: "Artist".into(),
album: "Album".into(),
..Default::default()
},
false,
)
})
.unwrap();
let mut search = LibrarySearch::default();
search.add_files(store, false).unwrap();
hosts.push(search);
}
let a = hosts[0].search("Song", 20);
let b = hosts[1].search("Song", 20);
assert!(a.failures.is_empty() && b.failures.is_empty());
assert_eq!(a.tracks.len(), 1);
assert_eq!(b.tracks.len(), 1);
assert_eq!(a.tracks[0].id, b.tracks[0].id);
assert_eq!(a.tracks[0].title, "Song on desktop");
assert_eq!(b.tracks[0].title, "Song on daemon");
}
}
+739
View File
@@ -0,0 +1,739 @@
//! Host-owned library work. No desktop globals, runtime or Qobuz session.
//!
//! A scan captures the database and artwork roots for the lifetime of this
//! service. Requests coalesce behind one worker; cancellation clears the queue.
//! Hosts poll progress/revision to invalidate their own derived views. Shutdown
//! is cooperative: call `close` immediately, then `shutdown` on a blocking thread
//! to join (filesystem calls on an unavailable mount may take time to return).
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use crate::{LibraryError, LibraryStore, ScanEvent, ScanStatus};
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct ScanProgress {
pub running: bool,
pub job: u64,
pub processed: u32,
pub total: u32,
pub source_processed: u32,
pub source_total: u32,
pub current_root_id: i64,
pub source_index: u32,
pub source_count: u32,
/// Basename only; full host paths are not needed by a progress indicator.
pub file: String,
pub cleaning: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ScanOutcome {
Complete,
Cancelled,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct ScanCompletion {
pub job: u64,
pub outcome: ScanOutcome,
pub skipped: usize,
}
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct LibrarySnapshot {
pub progress: ScanProgress,
pub last_scan: Option<ScanCompletion>,
/// Changes after each scan, even a partial/cancelled one: committed batches
/// remain authoritative. This is an invalidation signal, not a DB cursor.
pub revision: u64,
pub queued: usize,
pub closed: bool,
}
#[derive(Default)]
struct PendingScans {
all: bool,
folders: BTreeSet<i64>,
}
impl PendingScans {
fn push(&mut self, id: Option<i64>) {
match id {
None => {
self.all = true;
self.folders.clear();
}
Some(id) if !self.all => {
self.folders.insert(id);
}
Some(_) => {}
}
}
fn pop(&mut self) -> Option<Option<i64>> {
if self.all {
self.all = false;
Some(None)
} else {
self.folders.pop_first().map(Some)
}
}
fn len(&self) -> usize {
usize::from(self.all) + self.folders.len()
}
fn clear(&mut self) {
*self = Self::default();
}
}
#[derive(Default)]
struct State {
snapshot: LibrarySnapshot,
pending: PendingScans,
source_base: u32,
}
struct Shared {
store: LibraryStore,
artwork: PathBuf,
state: Mutex<State>,
idle: Condvar,
cancel: AtomicBool,
#[cfg(test)]
observer: Mutex<Option<Box<dyn Fn(&ScanEvent) + Send + Sync>>>,
}
/// Administration failures are distinct from an empty catalog or accepted job.
#[derive(Debug, thiserror::Error)]
pub enum FolderError {
#[error("library host is closed")]
Closed,
#[error("library scan is running")]
Busy,
#[error("library revision changed")]
RevisionChanged,
#[error("expected an existing absolute directory on the host")]
InvalidPath,
#[error("folder overlaps an existing library root")]
Conflict,
#[error("folder is disabled")]
Disabled,
#[error(transparent)]
Storage(#[from] LibraryError),
}
pub struct LibraryService {
shared: Arc<Shared>,
// Serializes worker handoff with requests and shutdown. Never taken by the
// worker or while publishing progress; joining cannot deadlock on it.
worker: Mutex<Option<JoinHandle<()>>>,
}
impl LibraryService {
/// Construction is inert: no directories, database or worker are created.
pub fn new(store: LibraryStore, artwork: PathBuf) -> Self {
Self {
shared: Arc::new(Shared {
store,
artwork,
state: Mutex::new(State::default()),
idle: Condvar::new(),
cancel: AtomicBool::new(false),
#[cfg(test)]
observer: Mutex::new(None),
}),
worker: Mutex::new(None),
}
}
pub fn store(&self) -> &LibraryStore {
&self.shared.store
}
pub fn artwork_directory(&self) -> &Path {
&self.shared.artwork
}
pub fn snapshot(&self) -> LibrarySnapshot {
let state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let mut snapshot = state.snapshot.clone();
snapshot.queued = state.pending.len();
snapshot
}
/// Returns false after close. The queue and running flag change under the
/// same lock, so a request arriving at worker completion cannot be lost.
pub fn scan(&self, folder: Option<i64>) -> Result<bool, LibraryError> {
let mut worker = self.worker.lock().unwrap_or_else(|e| e.into_inner());
let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
if state.snapshot.closed {
return Ok(false);
}
state.pending.push(folder);
if state.snapshot.progress.running {
return Ok(true);
}
state.snapshot.progress.running = true;
drop(state);
if let Some(previous) = worker.take() {
let _ = previous.join();
}
let shared = self.shared.clone();
match std::thread::Builder::new()
.name("library-scan".into())
.spawn(move || {
// Always release running, including an unexpected engine panic.
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&shared)));
if result.is_err() {
let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let job = state.snapshot.progress.job;
state.snapshot.last_scan = Some(ScanCompletion {
job,
outcome: ScanOutcome::Failed,
skipped: 0,
});
state.snapshot.revision += 1;
state.snapshot.progress.running = false;
state.pending.clear();
shared.idle.notify_all();
log::error!("library scan worker panicked");
}
}) {
Ok(handle) => {
*worker = Some(handle);
Ok(true)
}
Err(error) => {
let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
state.snapshot.progress.running = false;
state.pending.clear();
self.shared.idle.notify_all();
Err(error.into())
}
}
}
/// Register a directory on THIS host, with optimistic revision admission.
/// The worker handoff lock also makes shutdown join in-flight registration
/// before a host can move/drop its profile. Never call on the UI thread.
pub fn register_folder(
&self,
path: &Path,
revision: u64,
) -> Result<crate::LibraryFolder, FolderError> {
let _worker = self.worker.lock().unwrap_or_else(|e| e.into_inner());
{
let state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
if state.snapshot.closed {
return Err(FolderError::Closed);
}
if state.snapshot.revision != revision {
return Err(FolderError::RevisionChanged);
}
if state.snapshot.progress.running {
return Err(FolderError::Busy);
}
}
if !path.is_absolute() {
return Err(FolderError::InvalidPath);
}
let path = path.canonicalize().map_err(|_| FolderError::InvalidPath)?;
if !path.is_dir() {
return Err(FolderError::InvalidPath);
}
path.to_str().ok_or(FolderError::InvalidPath)?;
let db = self.shared.store.open_or_create()?;
use crate::RegisterFolderOutcome;
let (folder_id, added) = match db.register_or_refresh_folder(&path) {
RegisterFolderOutcome::Added { folder_id } => (folder_id, true),
RegisterFolderOutcome::Refreshed { folder_id }
| RegisterFolderOutcome::Covered { folder_id } => (folder_id, false),
RegisterFolderOutcome::RegisteredDisabled { .. } => return Err(FolderError::Disabled),
RegisterFolderOutcome::Conflict => return Err(FolderError::Conflict),
RegisterFolderOutcome::Failed => {
return Err(FolderError::Storage(LibraryError::Other(
"folder registration failed".into(),
)))
}
};
let folder = db
.get_folder_by_id(folder_id)?
.ok_or_else(|| LibraryError::Other("registered folder missing".into()))?;
if added {
self.shared
.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.snapshot
.revision += 1;
}
Ok(folder)
}
/// An old Cancel click must never cancel a subsequent scan's job.
pub fn cancel_job(&self, job: u64) -> bool {
let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
if state.snapshot.closed
|| !state.snapshot.progress.running
|| state.snapshot.progress.job != job
{
return false;
}
state.pending.clear();
self.shared.cancel.store(true, Ordering::Release);
true
}
/// Stop the current scan and discard requests already queued. A NEW request
/// after this call is accepted, and runs after the cancelled scan unwinds.
pub fn cancel_scan(&self) {
let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
state.pending.clear();
self.shared.cancel.store(true, Ordering::Release);
}
/// Reject future requests and cooperatively stop this host's worker.
pub fn close(&self) {
let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
state.snapshot.closed = true;
state.pending.clear();
self.shared.cancel.store(true, Ordering::Release);
}
/// Blocking join. Never call this from an event/UI thread.
pub fn shutdown(&self) {
self.close();
if let Some(worker) = self.worker.lock().unwrap_or_else(|e| e.into_inner()).take() {
let _ = worker.join();
}
}
pub fn wait_idle(&self, timeout: Duration) -> bool {
let state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let (state, _) = self
.shared
.idle
.wait_timeout_while(state, timeout, |state| state.snapshot.progress.running)
.unwrap_or_else(|e| e.into_inner());
!state.snapshot.progress.running
}
}
impl Drop for LibraryService {
fn drop(&mut self) {
// Do not block the UI's last Arc drop. The detached worker owns only its
// captured old host roots and closed Shared, never a new host's state.
self.close();
}
}
fn run(shared: &Shared) {
loop {
let folder = {
let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let next = if state.snapshot.closed {
None
} else {
state.pending.pop()
};
let Some(next) = next else {
state.snapshot.progress.running = false;
state.snapshot.progress.file.clear();
shared.idle.notify_all();
return;
};
let job = state.snapshot.progress.job + 1;
state.snapshot.progress = ScanProgress {
running: true,
job,
..Default::default()
};
state.source_base = 0;
// Same lock as cancellation: no request can reset a cancellation
// intended for the already-started job.
shared.cancel.store(false, Ordering::Release);
next
};
let result = shared.store.open_or_create().and_then(|db| {
let ids = folder.map(|id| vec![id]);
let count = db
.get_folders_with_metadata()?
.iter()
.filter(|f| f.enabled && folder.is_none_or(|id| f.id == id))
.count();
shared
.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.snapshot
.progress
.source_count = count.min(u32::MAX as usize) as u32;
std::fs::create_dir_all(&shared.artwork)?;
crate::scan_with_progress(
&db,
ids.as_deref(),
&shared.artwork,
&shared.cancel,
&|event| on_event(shared, event),
)
});
let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner());
if let Err(error) = result {
log::error!("library scan failed: {error}");
let job = state.snapshot.progress.job;
state.snapshot.last_scan = Some(ScanCompletion {
job,
outcome: ScanOutcome::Failed,
skipped: 0,
});
}
state.snapshot.revision += 1;
}
}
fn on_event(shared: &Shared, event: ScanEvent) {
#[cfg(test)]
if let Some(observer) = shared.observer.lock().unwrap().as_ref() {
observer(&event);
}
let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let base = state.source_base;
let progress = &mut state.snapshot.progress;
match event {
ScanEvent::TotalsAdded { total } => {
progress.total = total;
progress.source_total = total.saturating_sub(base);
}
ScanEvent::FileStarted { path } => {
progress.file = Path::new(&path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
}
ScanEvent::FileDone { processed, total } => {
progress.processed = processed;
progress.total = total;
progress.source_processed = processed.saturating_sub(base);
progress.source_total = total.saturating_sub(base);
}
ScanEvent::RootStarted { root_id, .. } => {
progress.current_root_id = root_id;
progress.source_processed = 0;
progress.source_total = 0;
progress.source_index += 1;
state.source_base = progress.total;
}
ScanEvent::RootFinished {
root_id,
discovered,
..
} => {
let total = progress.total;
if progress.current_root_id != root_id {
progress.current_root_id = root_id;
progress.source_index += 1;
}
progress.source_processed = discovered.min(u32::MAX as u64) as u32;
progress.source_total = progress.source_processed;
state.source_base = total;
}
ScanEvent::Cleanup => {
progress.cleaning = true;
progress.file.clear();
}
ScanEvent::Finished { status, errors } => {
progress.cleaning = false;
progress.file.clear();
let job = progress.job;
state.snapshot.last_scan = Some(ScanCompletion {
job,
outcome: match status {
ScanStatus::Complete => ScanOutcome::Complete,
ScanStatus::Cancelled => ScanOutcome::Cancelled,
_ => ScanOutcome::Failed,
},
skipped: errors.len(),
});
}
ScanEvent::Started => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn host(dir: &Path) -> LibraryService {
LibraryService::new(
LibraryStore::new(dir.join("library.db")),
dir.join("artwork"),
)
}
#[test]
fn folder_registration_is_revisioned_idempotent_and_profile_owned() {
let tmp = tempfile::tempdir().unwrap();
let a = host(&tmp.path().join("a"));
let b = host(&tmp.path().join("b"));
let music = tmp.path().join("music");
std::fs::create_dir(&music).unwrap();
assert!(matches!(
a.register_folder(Path::new("relative"), 0),
Err(FolderError::InvalidPath)
));
assert!(!a.store().database_path().exists());
let folder = a.register_folder(&music, 0).unwrap();
assert_eq!(a.snapshot().revision, 1);
assert!(matches!(
a.register_folder(&music, 0),
Err(FolderError::RevisionChanged)
));
assert_eq!(
a.register_folder(&music.join("."), 1).unwrap().id,
folder.id
);
assert_eq!(a.snapshot().revision, 1);
assert_eq!(
a.store()
.read(|db| db.get_folders())
.unwrap()
.unwrap()
.len(),
1
);
let nested = music.join("album");
std::fs::create_dir(&nested).unwrap();
assert_eq!(a.register_folder(&nested, 1).unwrap().id, folder.id);
assert!(matches!(
a.register_folder(tmp.path(), 1),
Err(FolderError::Conflict)
));
a.store()
.write(|db| db.set_folder_enabled(folder.id, false))
.unwrap();
assert!(matches!(
a.register_folder(&nested, 1),
Err(FolderError::Disabled)
));
assert!(
!a.store()
.read(|db| db.get_folder_by_id(folder.id))
.unwrap()
.unwrap()
.unwrap()
.enabled
);
assert!(!b.store().database_path().exists());
assert_eq!(b.register_folder(&music, 0).unwrap().id, folder.id);
a.shutdown();
assert!(matches!(
a.register_folder(&music, 1),
Err(FolderError::Closed)
));
assert!(!b.snapshot().closed);
b.shutdown();
}
#[test]
fn queued_scans_coalesce_without_losing_directed_roots() {
let mut queue = PendingScans::default();
for id in [9, 4, 9] {
queue.push(Some(id));
}
assert_eq!(queue.pop(), Some(Some(4)));
assert_eq!(queue.pop(), Some(Some(9)));
queue.push(Some(4));
queue.push(None);
queue.push(Some(9));
assert_eq!(queue.pop(), Some(None));
assert_eq!(queue.pop(), None);
}
#[test]
fn separate_hosts_scan_real_roots_and_keep_cancellation_local() {
let tmp = tempfile::tempdir().unwrap();
let a = host(&tmp.path().join("a"));
let b = host(&tmp.path().join("b"));
assert!(!tmp.path().join("a").exists());
for (service, name) in [(&a, "a"), (&b, "b")] {
let music = tmp.path().join(format!("music-{name}"));
std::fs::create_dir_all(&music).unwrap();
service
.store()
.write(|db| {
assert!(matches!(
db.register_or_refresh_folder(&music),
crate::RegisterFolderOutcome::Added { .. }
));
Ok(())
})
.unwrap();
}
a.close();
assert!(!a.scan(None).unwrap());
assert!(b.scan(None).unwrap());
assert!(b.wait_idle(Duration::from_secs(10)));
assert_eq!(
b.snapshot().last_scan.unwrap().outcome,
ScanOutcome::Complete
);
assert_eq!(b.snapshot().revision, 1);
assert_eq!(a.snapshot().revision, 0);
assert!(!a.artwork_directory().exists());
assert!(b.artwork_directory().exists());
a.shutdown();
b.shutdown();
}
#[test]
fn failed_open_is_terminal_and_worker_can_accept_another_request() {
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("parent");
std::fs::write(&parent, b"file").unwrap();
let service = host(&parent);
for job in 1..=2 {
assert!(service.scan(None).unwrap());
assert!(service.wait_idle(Duration::from_secs(5)));
let snapshot = service.snapshot();
assert_eq!(
snapshot.last_scan.unwrap(),
ScanCompletion {
job,
outcome: ScanOutcome::Failed,
skipped: 0
}
);
assert_eq!(snapshot.revision, job);
}
service.shutdown();
assert!(!service.scan(None).unwrap());
}
#[test]
fn cancel_clears_only_existing_queue_and_progress_does_not_expose_paths() {
let tmp = tempfile::tempdir().unwrap();
let service = host(tmp.path());
{
let mut state = service.shared.state.lock().unwrap();
state.pending.push(Some(42));
}
on_event(
&service.shared,
ScanEvent::FileStarted {
path: "/private/host/music/song.flac".into(),
},
);
assert_eq!(service.snapshot().progress.file, "song.flac");
service.cancel_scan();
assert_eq!(service.snapshot().queued, 0);
assert!(service.shared.cancel.load(Ordering::Acquire));
assert!(!service.snapshot().closed);
}
#[test]
fn cancel_active_scan_discards_old_queue_but_keeps_new_request() {
use std::sync::mpsc;
let tmp = tempfile::tempdir().unwrap();
let service = host(&tmp.path().join("profile"));
let music = tmp.path().join("music");
std::fs::create_dir_all(&music).unwrap();
service
.store()
.write(|db| {
db.register_or_refresh_folder(&music);
Ok(())
})
.unwrap();
let (entered_tx, entered_rx) = mpsc::sync_channel(1);
let (release_tx, release_rx) = mpsc::sync_channel(1);
let release_rx = Mutex::new(release_rx);
let first = AtomicBool::new(true);
*service.shared.observer.lock().unwrap() = Some(Box::new(move |event| {
if matches!(event, ScanEvent::Started) && first.swap(false, Ordering::SeqCst) {
entered_tx.send(()).unwrap();
release_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(5))
.unwrap();
}
}));
service.scan(None).unwrap();
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
service.scan(Some(99)).unwrap();
let job = service.snapshot().progress.job;
assert!(!service.cancel_job(job + 1));
assert_eq!(service.snapshot().queued, 1);
assert!(matches!(
service.register_folder(&music, 0),
Err(FolderError::Busy)
));
assert!(service.cancel_job(job));
assert_eq!(service.snapshot().queued, 0);
service.scan(None).unwrap();
release_tx.send(()).unwrap();
assert!(service.wait_idle(Duration::from_secs(10)));
let snapshot = service.snapshot();
assert_eq!(snapshot.revision, 2);
assert_eq!(
snapshot.last_scan.unwrap(),
ScanCompletion {
job: 2,
outcome: ScanOutcome::Complete,
skipped: 0,
}
);
service.shutdown();
}
#[test]
fn shutdown_joins_old_worker_and_never_runs_queued_scans() {
use std::sync::mpsc;
let tmp = tempfile::tempdir().unwrap();
let service = Arc::new(host(&tmp.path().join("old")));
let music = tmp.path().join("music");
std::fs::create_dir_all(&music).unwrap();
service
.store()
.write(|db| {
db.register_or_refresh_folder(&music);
Ok(())
})
.unwrap();
let (entered_tx, entered_rx) = mpsc::sync_channel(1);
let (release_tx, release_rx) = mpsc::sync_channel(1);
let release_rx = Mutex::new(release_rx);
*service.shared.observer.lock().unwrap() = Some(Box::new(move |event| {
if matches!(event, ScanEvent::Started) {
entered_tx.send(()).unwrap();
release_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(5))
.unwrap();
}
}));
service.scan(None).unwrap();
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
service.scan(None).unwrap();
service.close();
let joining = service.clone();
let join = std::thread::spawn(move || joining.shutdown());
assert!(!service.scan(Some(1)).unwrap());
release_tx.send(()).unwrap();
join.join().unwrap();
let snapshot = service.snapshot();
assert!(!snapshot.progress.running);
assert_eq!(snapshot.revision, 1);
assert_eq!(snapshot.queued, 0);
assert_eq!(snapshot.last_scan.unwrap().outcome, ScanOutcome::Cancelled);
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Profile-scoped access to a host's library database.
//!
//! The host selects the path once. This object never consults desktop globals,
//! last-user markers or environment defaults. Connections stay on the calling
//! blocking thread; reads of an absent library do not create a profile.
use std::path::{Path, PathBuf};
use crate::{LibraryDatabase, LibraryError};
#[derive(Debug, Clone)]
pub struct LibraryStore {
database_path: PathBuf,
}
impl LibraryStore {
pub fn new(database_path: PathBuf) -> Self {
Self { database_path }
}
pub fn database_path(&self) -> &Path {
&self.database_path
}
/// Open an existing library, including its normal schema migrations.
/// This is not a SQLite read-only connection: callers choose read operations.
pub fn open_existing(&self) -> Result<Option<LibraryDatabase>, LibraryError> {
if !self.database_path.try_exists()? {
return Ok(None);
}
LibraryDatabase::open(&self.database_path).map(Some)
}
/// Open for an operation that may create this profile's library.
pub fn open_or_create(&self) -> Result<LibraryDatabase, LibraryError> {
if let Some(parent) = self
.database_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
LibraryDatabase::open(&self.database_path)
}
pub fn read<T>(
&self,
operation: impl FnOnce(&LibraryDatabase) -> Result<T, LibraryError>,
) -> Result<Option<T>, LibraryError> {
self.open_existing()?.as_ref().map(operation).transpose()
}
pub fn write<T>(
&self,
operation: impl FnOnce(&LibraryDatabase) -> Result<T, LibraryError>,
) -> Result<T, LibraryError> {
operation(&self.open_or_create()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_read_does_not_create_profile_or_run_query() {
let tmp = tempfile::tempdir().unwrap();
let profile = tmp.path().join("guest");
let store = LibraryStore::new(profile.join("library.db"));
let result: Option<()> = store
.read(|_| panic!("missing library must not run query"))
.unwrap();
assert!(result.is_none());
assert!(!profile.exists());
}
#[test]
fn profiles_keep_identical_ids_and_mutations_separate() {
let tmp = tempfile::tempdir().unwrap();
let desktop = LibraryStore::new(tmp.path().join("desktop/users/0/library.db"));
let daemon = LibraryStore::new(tmp.path().join("daemon/library.db"));
desktop
.write(|db| db.set_playlist_favorite(42, true))
.unwrap();
daemon
.write(|db| db.set_playlist_favorite(7, true))
.unwrap();
assert_eq!(
desktop.read(|db| db.get_favorite_playlist_ids()).unwrap(),
Some(vec![42])
);
assert_eq!(
daemon.read(|db| db.get_favorite_playlist_ids()).unwrap(),
Some(vec![7])
);
let queued_operation = desktop.clone();
daemon
.write(|db| db.set_playlist_favorite(42, true))
.unwrap();
queued_operation
.write(|db| db.set_playlist_favorite(42, false))
.unwrap();
assert!(desktop
.read(|db| db.get_favorite_playlist_ids())
.unwrap()
.unwrap()
.is_empty());
let mut daemon_ids = daemon
.read(|db| db.get_favorite_playlist_ids())
.unwrap()
.unwrap();
daemon_ids.sort();
assert_eq!(daemon_ids, vec![7, 42]);
}
#[test]
fn failed_operation_is_not_reported_as_empty_library() {
let tmp = tempfile::tempdir().unwrap();
let store = LibraryStore::new(tmp.path().join("library.db"));
store.open_or_create().unwrap();
let result = store.read::<()>(|_| Err(LibraryError::Other("query failed".into())));
assert!(matches!(result, Err(LibraryError::Other(_))));
let invalid = tmp.path().join("file");
std::fs::write(&invalid, b"not a directory").unwrap();
assert!(LibraryStore::new(invalid.join("library.db"))
.open_or_create()
.is_err());
}
}
+1
View File
@@ -12,6 +12,7 @@ name = "qbz"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
qbz-control = { path = "../qbz-control" }
cxx = "1" cxx = "1"
cxx-qt = "0.7" cxx-qt = "0.7"
cxx-qt-lib = { version = "0.7", features = ["qt_full"] } cxx-qt-lib = { version = "0.7", features = ["qt_full"] }
+5
View File
@@ -522,6 +522,7 @@ fn main() {
"src/lyrics_qt.rs", "src/lyrics_qt.rs",
"src/icon_tint_qt.rs", "src/icon_tint_qt.rs",
"src/cast_bridge.rs", "src/cast_bridge.rs",
"src/orbit_bridge.rs",
"src/myqbz_bridge.rs", "src/myqbz_bridge.rs",
"src/myqbz_add_bridge.rs", "src/myqbz_add_bridge.rs",
"src/disco_bridge.rs", "src/disco_bridge.rs",
@@ -644,8 +645,11 @@ fn main() {
"qml/settings/CustomThemeEditor.qml", "qml/settings/CustomThemeEditor.qml",
"qml/settings/IntegrationsSettings.qml", "qml/settings/IntegrationsSettings.qml",
"qml/settings/SettingsView.qml", "qml/settings/SettingsView.qml",
"qml/settings/OrbitSettings.qml",
"qml/settings/OrbitSettingsPanel.qml",
"qml/shell/AmbientField.qml", "qml/shell/AmbientField.qml",
"qml/shell/AppShell.qml", "qml/shell/AppShell.qml",
"qml/shell/OrbitBanner.qml",
"qml/shell/ArtPreviewOverlay.qml", "qml/shell/ArtPreviewOverlay.qml",
"qml/shell/Cortinilla.qml", "qml/shell/Cortinilla.qml",
"qml/shell/HeaderBar.qml", "qml/shell/HeaderBar.qml",
@@ -792,6 +796,7 @@ fn main() {
"qml/views/PlaylistView.qml", "qml/views/PlaylistView.qml",
"qml/views/QueueView.qml", "qml/views/QueueView.qml",
"qml/views/SearchView.qml", "qml/views/SearchView.qml",
"qml/views/SearchLocalResults.qml",
"qml/views/SectionRail.qml", "qml/views/SectionRail.qml",
"qml/shell/CastPicker.qml", "qml/shell/CastPicker.qml",
"qml/controls/BrowseGenreButton.qml", "qml/controls/BrowseGenreButton.qml",
+1
View File
@@ -377,6 +377,7 @@ ApplicationWindow {
QbzMusician.boot() QbzMusician.boot()
QbzLyrics.boot() QbzLyrics.boot()
QbzCast.boot() QbzCast.boot()
QbzOrbit.boot()
QbzBridge.boot() QbzBridge.boot()
// MyQBZ (two grids + detail + modals), the app-wide Add picker, the // MyQBZ (two grids + detail + modals), the app-wide Add picker, the
// Artist-Collection builder, and the Blacklist manager. // Artist-Collection builder, and the Blacklist manager.
@@ -0,0 +1,170 @@
// Experimental host inspection, constructed only when --orbit is present.
pragma ComponentBehavior: Bound
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
id: root
property bool kioskHost: false
property var doc: ({})
property string listenAddress: "127.0.0.1:17290"
property string peerAddress: "http://127.0.0.1:17290"
property string accessKey: ""
property string query: ""
spacing: 4
QbzTheme { id: theme }
function reload() {
try { doc = JSON.parse(QbzOrbit.stateJson) } catch (e) { doc = ({}) }
}
Component.onCompleted: reload()
Connections {
target: QbzOrbit
function onStateJsonChanged() { root.reload() }
}
function statusText() {
switch (doc.status || "") {
case "verified": return QbzSession.tr("Host verified", QbzSession.trRev)
case "not-supported": return QbzSession.tr("This host does not support library inspection.", QbzSession.trRev)
case "invalid-address": return QbzSession.tr("Enter a valid host address.", QbzSession.trRev)
case "listen-failed": return QbzSession.tr("Could not start the Orbit listener.", QbzSession.trRev)
case "host-changed": return QbzSession.tr("The host changed. Verify it again.", QbzSession.trRev)
case "host-unavailable": return QbzSession.tr("Library unavailable", QbzSession.trRev)
case "verify-failed": return QbzSession.tr("Could not verify the host.", QbzSession.trRev)
default: return ""
}
}
GroupHeader { kioskHost: root.kioskHost; text: "ORBIT" }
Text {
width: parent.width
text: QbzSession.tr("Orbit is experimental. Verifying a host does not switch playback or settings.", QbzSession.trRev)
wrapMode: Text.WordWrap
color: theme.textMuted
font.pixelSize: theme.fontBody
}
SettingsSpacer {}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Listening address", QbzSession.trRev)
QbzLineEdit {
kioskHost: root.kioskHost
enabled: !root.doc.listening && !root.doc.starting
text: root.listenAddress
onEdited: function(value) { root.listenAddress = value }
onCommitted: function(value) { root.listenAddress = value }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Share this library", QbzSession.trRev)
description: root.doc.address || ""
QbzToggle {
kioskHost: root.kioskHost
checked: root.doc.listening === true
enabled: !root.doc.starting
onToggled: function(value) {
if (value) QbzOrbit.startHost(root.listenAddress)
else QbzOrbit.stopHost()
}
}
}
SettingRow {
kioskHost: root.kioskHost
visible: root.doc.listening === true
label: QbzSession.tr("Access key", QbzSession.trRev)
SettingsButton {
kioskHost: root.kioskHost
text: QbzSession.tr("Copy access key", QbzSession.trRev)
onClicked: QbzOrbit.copyAccessKey()
}
}
SettingsDivider {}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Host address", QbzSession.trRev)
QbzLineEdit {
kioskHost: root.kioskHost
text: root.peerAddress
onEdited: function(value) { root.peerAddress = value }
onCommitted: function(value) { root.peerAddress = value }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Access key", QbzSession.trRev)
QbzLineEdit {
kioskHost: root.kioskHost
text: root.accessKey
isPassword: true
onEdited: function(value) { root.accessKey = value }
onCommitted: function(value) { root.accessKey = value }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Verify host", QbzSession.trRev)
SettingsButton {
kioskHost: root.kioskHost
text: root.doc.probing ? QbzSession.tr("Checking server…", QbzSession.trRev)
: QbzSession.tr("Verify host", QbzSession.trRev)
enabled: !root.doc.probing
onClicked: QbzOrbit.verifyHost(root.peerAddress, root.accessKey)
}
}
Text {
width: parent.width
visible: text !== ""
text: root.statusText()
wrapMode: Text.WordWrap
color: root.doc.status === "verified" ? theme.success : theme.danger
font.pixelSize: theme.fontLegal
}
Text {
width: parent.width
visible: !!root.doc.peer
text: root.doc.peer ? root.doc.peer.name + " · " + root.doc.peer.tracks + " " + QbzSession.tr("Tracks", QbzSession.trRev) : ""
textFormat: Text.PlainText
wrapMode: Text.WordWrap
color: theme.textPrimary
font.pixelSize: theme.fontBody
}
SettingRow {
kioskHost: root.kioskHost
visible: !!root.doc.peer
label: QbzSession.tr("Local files", QbzSession.trRev)
Row {
spacing: 8
QbzLineEdit {
kioskHost: root.kioskHost
text: root.query
onEdited: function(value) { root.query = value }
onCommitted: function(value) { root.query = value }
onAccepted: QbzOrbit.searchLibrary(root.query)
}
SettingsButton {
kioskHost: root.kioskHost
text: root.doc.searching ? QbzSession.tr("Searching…", QbzSession.trRev)
: QbzSession.tr("Search", QbzSession.trRev)
enabled: !root.doc.searching && root.query.trim().length >= 2
onClicked: QbzOrbit.searchLibrary(root.query)
}
}
}
Repeater {
model: root.doc.page ? root.doc.page.tracks : []
delegate: Text {
required property var modelData
width: root.width
text: modelData.title + " · " + modelData.artist + " · " + modelData.album
textFormat: Text.PlainText
elide: Text.ElideRight
color: theme.textSecondary
font.pixelSize: theme.fontBody
height: 32
verticalAlignment: Text.AlignVCenter
}
}
}
@@ -0,0 +1,15 @@
// The gate is on construction, not merely visibility. Nothing in the Orbit
// panel is created in a normal launch or while another section is selected.
pragma ComponentBehavior: Bound
import QtQuick
import com.blitzfc.qbz
Loader {
id: root
property bool selected: false
property bool kioskHost: false
active: QbzOrbit.enabled && selected
visible: active
sourceComponent: Component {
OrbitSettings { width: root.width; kioskHost: root.kioskHost }
}
}
+14 -2
View File
@@ -77,9 +77,9 @@ Item {
{ label: QbzSession.tr("Integrations", QbzSession.trRev), section: 6 }, { label: QbzSession.tr("Integrations", QbzSession.trRev), section: 6 },
{ label: QbzSession.tr("Import / Export", QbzSession.trRev), section: 9 }, { label: QbzSession.tr("Import / Export", QbzSession.trRev), section: 9 },
{ label: QbzSession.tr("Developer", QbzSession.trRev), section: 7 } { label: QbzSession.tr("Developer", QbzSession.trRev), section: 7 }
].concat(root.sandboxed ? [{ label: (doc.dev || ({})).installMethod === "snap" ].concat(QbzOrbit.enabled ? [{ label: "Orbit", section: 11 }] : []).concat(root.sandboxed ? [{ label: (doc.dev || ({})).installMethod === "snap"
? QbzSession.tr("Snap", QbzSession.trRev) : QbzSession.tr("Flatpak", QbzSession.trRev), section: 8 }] : []) ? QbzSession.tr("Snap", QbzSession.trRev) : QbzSession.tr("Flatpak", QbzSession.trRev), section: 8 }] : [])
readonly property int section: QbzBridge.settingsSection readonly property int section: QbzBridge.settingsSection === 11 && !QbzOrbit.enabled ? 0 : QbzBridge.settingsSection
readonly property bool migrationRunning: readonly property bool migrationRunning:
(doc.importExport || ({})).migrationRunning === true (doc.importExport || ({})).migrationRunning === true
@@ -287,6 +287,13 @@ Item {
active: root.section === 7 active: root.section === 7
onClicked: QbzBridge.settingsSetSection(7) onClicked: QbzBridge.settingsSetSection(7)
} }
SubNavItem {
visible: QbzOrbit.enabled
name: "compass"
label: "Orbit"
active: root.section === 11
onClicked: QbzBridge.settingsSetSection(11)
}
// Sandboxed installs only (Flatpak / Snap permissions). // Sandboxed installs only (Flatpak / Snap permissions).
SubNavItem { SubNavItem {
visible: root.sandboxed visible: root.sandboxed
@@ -406,6 +413,11 @@ Item {
width: parent ? parent.width : 0 width: parent ? parent.width : 0
} }
OrbitSettingsPanel {
width: parent.width
selected: root.section === 11
kioskHost: root.kioskHost
}
Panel { Panel {
panelIndex: 0 panelIndex: 0
sourceComponent: Component { sourceComponent: Component {
+15 -5
View File
@@ -239,6 +239,16 @@ Rectangle {
// the compositor's business). // the compositor's business).
} }
OrbitBanner {
id: orbitBanner
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
remoteActive: QbzOrbit.enabled && QbzOrbit.controllingRemote
connectionLost: QbzOrbit.connectionLost
hostName: QbzOrbit.hostName
}
NowPlayingBar { NowPlayingBar {
id: npb id: npb
anchors.left: parent.left anchors.left: parent.left
@@ -256,7 +266,7 @@ Rectangle {
Sidebar { Sidebar {
id: sidebar id: sidebar
anchors.left: parent.left anchors.left: parent.left
anchors.top: header.bottom anchors.top: orbitBanner.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
// The shared hover-tooltip overlay (declared last, below). The sidebar // The shared hover-tooltip overlay (declared last, below). The sidebar
// clips its own overflow, so the collapsed rail's name bubble HAS to be // clips its own overflow, so the collapsed rail's name bubble HAS to be
@@ -281,7 +291,7 @@ Rectangle {
Rectangle { Rectangle {
id: queueColumn id: queueColumn
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: orbitBanner.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
// #771: the open width is the user's (300..600, persisted). // #771: the open width is the user's (300..600, persisted).
width: (root.queueSidebarVisible || QbzShell.lyricsOpen) width: (root.queueSidebarVisible || QbzShell.lyricsOpen)
@@ -344,7 +354,7 @@ Rectangle {
visible: QbzShell.sidebarState !== 2 visible: QbzShell.sidebarState !== 2
// Pane edge at sidebar.width + 8; strip 245..249 at 240, centre 247. // Pane edge at sidebar.width + 8; strip 245..249 at 240, centre 247.
x: sidebar.width + 5 x: sidebar.width + 5
anchors.top: header.bottom anchors.top: orbitBanner.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
z: 5 z: 5
onDragged: function (px) { QbzShell.sidebarDrag(px) } onDragged: function (px) { QbzShell.sidebarDrag(px) }
@@ -355,7 +365,7 @@ Rectangle {
visible: queueColumn.width > 0 visible: queueColumn.width > 0
// Pane edge at queueColumn.x - 8; strip x-9..x-4, centre x-6.5. // Pane edge at queueColumn.x - 8; strip x-9..x-4, centre x-6.5.
x: queueColumn.x - 9 x: queueColumn.x - 9
anchors.top: header.bottom anchors.top: orbitBanner.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
z: 5 z: 5
// The column hangs off the window's RIGHT edge: a pointer at x means // The column hangs off the window's RIGHT edge: a pointer at x means
@@ -414,7 +424,7 @@ Rectangle {
id: contentFrame id: contentFrame
anchors.left: sidebar.right anchors.left: sidebar.right
anchors.right: queueColumn.left anchors.right: queueColumn.left
anchors.top: header.bottom anchors.top: orbitBanner.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
+9 -1
View File
@@ -458,10 +458,18 @@ Rectangle {
// on that view, so its 42px must NOT be subtracted — otherwise the // on that view, so its 42px must NOT be subtracted — otherwise the
// content stays short and the NavRail rides up, leaving dead space // content stays short and the NavRail rides up, leaving dead space
// below the footer. // below the footer.
OrbitBanner {
id: orbitBanner
width: shellColumn.width
remoteActive: QbzOrbit.enabled && QbzOrbit.controllingRemote
connectionLost: QbzOrbit.connectionLost
hostName: QbzOrbit.hostName
}
Rectangle { Rectangle {
id: contentFrame id: contentFrame
width: shellColumn.width width: shellColumn.width
height: root.height - backBar.height height: root.height - backBar.height - orbitBanner.height
- (QbzShell.currentView === "nowplaying" ? 0 : root.transportHeight) - (QbzShell.currentView === "nowplaying" ? 0 : root.transportHeight)
- root.navRailHeight - root.navRailHeight
color: theme.surfaceCard color: theme.surfaceCard
+36
View File
@@ -0,0 +1,36 @@
// Persistent context identity. No dismiss button or mutable visibility state.
// A lost connection keeps the selected host visible; only ending remote
// control may remove the banner. Host verification does not activate it.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool remoteActive: false
property bool connectionLost: false
property string hostName: ""
visible: remoteActive
height: remoteActive ? Math.max(38, label.implicitHeight + 16) : 0
color: theme.surfaceElevated
border.width: 1
border.color: theme.accent
Accessible.role: Accessible.StaticText
Accessible.name: label.text
QbzTheme { id: theme }
Text {
id: label
objectName: "orbitContextLabel"
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 16
anchors.verticalCenter: parent.verticalCenter
text: "Orbit · " + QbzSession.tr("Controlling {}", QbzSession.trRev).replace("{}", root.hostName)
+ (root.connectionLost ? " · " + QbzSession.tr("Connection lost. Remote controls are unavailable.", QbzSession.trRev) : "")
textFormat: Text.PlainText
wrapMode: Text.WordWrap
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
}
@@ -0,0 +1,159 @@
// Local Library previews shared by the All and category search tabs.
// Result actions carry the page snapshot revision, never a Qobuz/native DB ID.
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
id: root
property var sections: []
property string revision: ""
property int tab: 0
readonly property bool hasResults: sections.some(function(section) { return root.accepts(section.kind) })
signal activated(string revision, int index, string action)
signal viewMore(string revision, string kind)
spacing: 24
QbzTheme { id: theme }
function accepts(kind) {
return tab === 0 || (tab === 1 && kind === "local-album")
|| (tab === 2 && kind === "local") || (tab === 3 && kind === "local-artist")
}
function titleFor(kind) {
if (kind === "local-album") return QbzSession.tr("Albums on Local Library", QbzSession.trRev)
if (kind === "local-artist") return QbzSession.tr("Artists on Local Library", QbzSession.trRev)
return QbzSession.tr("On Local Library", QbzSession.trRev)
}
function menuFor(row) {
var t = QbzSession.tr
var r = QbzSession.trRev
if (row.kind === "artist")
return [{label:t("Open artist", r), icon:"user", action:"open"}]
var entries = []
if (row.kind === "album")
entries.push({label:t("Open album", r), icon:"library-big", action:"open"})
entries.push({label:t("Play", r), icon:"play-fill", action:"play"})
entries.push({label:t("Play next", r), icon:"list-start", action:"next"})
entries.push({label:t("Play later", r), icon:"list-plus", action:"later"})
entries.push({label:t("Add to queue", r), icon:"list-end", action:"queue"})
if (row.kind === "track") {
entries.push({label:t("Add to playlist", r), icon:"list-music", action:"add-to-playlist"})
if ((row.albumId || "") !== "")
entries.push({label:t("Open containing album", r), icon:"disc-3", action:"open-album"})
}
return entries
}
Repeater {
model: root.sections
delegate: Column {
id: section
required property var modelData
objectName: "localSearchSection-" + section.modelData.kind
visible: root.accepts(section.modelData.kind)
width: root.width
spacing: 8
QbzSectionHeader {
title: root.titleFor(section.modelData.kind)
showChevrons: false
showViewAll: section.modelData.hasMore === true
viewAllLabel: QbzSession.tr("View more", QbzSession.trRev)
onViewAllClicked: root.viewMore(root.revision, section.modelData.kind)
}
Repeater {
model: section.visible ? section.modelData.rows : []
delegate: Rectangle {
id: resultRow
required property var modelData
objectName: "localSearchRow-" + resultRow.modelData.flatIndex
width: section.width
height: 68
radius: theme.radiusSm
color: hit.containsMouse ? theme.surfaceHover : "transparent"
RoundedImage {
x: 8; width: 48; height: 48
anchors.verticalCenter: parent.verticalCenter
source: resultRow.modelData.artPath || ""
radius: resultRow.modelData.kind === "artist" ? 24 : 5
}
Column {
anchors.left: parent.left; anchors.leftMargin: 70
anchors.right: menuButton.left; anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: resultRow.modelData.title || ""
color: theme.textPrimary; font.pixelSize: 14
elide: Text.ElideRight
}
Text {
width: parent.width
text: resultRow.modelData.subtitle || ""
color: theme.textMuted; font.pixelSize: 12
elide: Text.ElideRight
}
Text {
width: parent.width
visible: text !== ""
text: resultRow.modelData.qualityDetail || ""
color: theme.textSecondary; font.pixelSize: 10
elide: Text.ElideRight
}
}
MouseArea {
id: hit
objectName: "localSearchHit-" + resultRow.modelData.flatIndex
anchors.fill: parent
anchors.rightMargin: 44
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
menuLoader.active = true
var menu = menuLoader.item as CardMenu
if (menu) menu.openAtCursor(hit, mouse.x, mouse.y)
} else {
root.activated(root.revision, resultRow.modelData.flatIndex,
resultRow.modelData.kind === "track" ? "play" : "open")
}
}
}
QbzIconButton {
id: menuButton
objectName: "localSearchMenu-" + resultRow.modelData.flatIndex
anchors.right: parent.right; anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
name: "ellipsis"; btnSize: 28; iconSize: 14
onClicked: {
menuLoader.active = true
var menu = menuLoader.item as CardMenu
if (menu) menu.openBelowRight(menuButton)
}
HoverHandler { id: menuHover }
ToolTip.visible: menuHover.hovered
ToolTip.text: QbzSession.tr("More options", QbzSession.trRev)
ToolTip.delay: 350
}
Loader {
id: menuLoader
objectName: "localSearchMenuLoader-" + resultRow.modelData.flatIndex
active: false
sourceComponent: CardMenu {
objectName: "localSearchMenuPopup-" + resultRow.modelData.flatIndex
entries: root.menuFor(resultRow.modelData)
onPicked: function(action) {
root.activated(root.revision, resultRow.modelData.flatIndex, action)
}
}
}
}
}
}
}
}
+24 -2
View File
@@ -76,6 +76,7 @@ Rectangle {
readonly property var artists: doc.artists || [] readonly property var artists: doc.artists || []
readonly property var artistsCarousel: doc.artistsCarousel || [] readonly property var artistsCarousel: doc.artistsCarousel || []
readonly property var playlists: doc.playlists || [] readonly property var playlists: doc.playlists || []
readonly property var localSections: doc.localSections || []
readonly property var mp: doc.mostPopular || ({}) readonly property var mp: doc.mostPopular || ({})
readonly property bool loading: doc.loading === true readonly property bool loading: doc.loading === true
readonly property int tab: doc.tab || 0 readonly property int tab: doc.tab || 0
@@ -84,7 +85,8 @@ Rectangle {
// IDENTITY: when it changes, the page on screen is a different result set // IDENTITY: when it changes, the page on screen is a different result set
// and any pending "fade the tail" threshold is stale (see clearFade). // and any pending "fade the tail" threshold is stale (see clearFade).
readonly property string query: doc.query || "" readonly property string query: doc.query || ""
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0 readonly property bool hasQobuzResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property bool hasResults: hasQobuzResults || localSections.length > 0
readonly property int previewCap: 6 readonly property int previewCap: 6
// The per-type result tabs live inside the page Flickable, so a GridView // The per-type result tabs live inside the page Flickable, so a GridView
@@ -665,11 +667,17 @@ Rectangle {
font.weight: theme.weightBold font.weight: theme.weightBold
} }
Row { Row {
visible: root.hasResults visible: root.hasQobuzResults || root.filterIndex !== 0
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 32 anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 16 spacing: 16
Text {
text: QbzSession.tr("Qobuz", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzSearch.searchFilterChanged(1) } FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzSearch.searchFilterChanged(1) }
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzSearch.searchFilterChanged(2) } FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzSearch.searchFilterChanged(2) }
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzSearch.searchFilterChanged(3) } FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzSearch.searchFilterChanged(3) }
@@ -1213,6 +1221,20 @@ Rectangle {
cellH: 270 cellH: 270
onClicked: root.armLoadMore(4) onClicked: root.armLoadMore(4)
} }
SearchLocalResults {
id: localSearchResults
width: parent.width
sections: root.localSections
revision: root.doc.localRevision || ""
tab: root.tab
visible: localSearchResults.hasResults
onActivated: function(revision, index, action) {
QbzSearch.searchLocalAction(revision, index, action)
}
onViewMore: function(revision, kind) {
QbzSearch.searchLocalMore(revision, kind)
}
}
} }
// ---- Loading skeleton --------------------------------------- // ---- Loading skeleton ---------------------------------------
+8
View File
@@ -128,6 +128,7 @@ where
qbz_log::register_secret(token.clone()); qbz_log::register_secret(token.clone());
// Emit LoggedIn through the core (idempotent set_session). // Emit LoggedIn through the core (idempotent set_session).
crate::local_service_qt::quiesce().await;
core.set_session(session).await.map_err(|e| e.to_string())?; core.set_session(session).await.map_err(|e| e.to_string())?;
// Activate the per-user session (creates dirs, opens the session store). // Activate the per-user session (creates dirs, opens the session store).
@@ -136,6 +137,7 @@ where
// Offline-MODE per-user binding, then the D4 valid verdict and the D2 // Offline-MODE per-user binding, then the D4 valid verdict and the D2
// recovery: a successful login ends any unauthenticated offline session. // recovery: a successful login ends any unauthenticated offline session.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) { if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::local_service_qt::bind(&dir);
crate::offline_fwd::init_for_user(&dir); crate::offline_fwd::init_for_user(&dir);
// Plex settings live per-user (plex_settings.db); bind the store to // Plex settings live per-user (plex_settings.db); bind the store to
// this session so the first Local Library read does not have to. // this session so the first Local Library read does not have to.
@@ -197,6 +199,7 @@ where
/// logs on failure, never blocks shell entry) — one entry point, so the caller /// logs on failure, never blocks shell entry) — one entry point, so the caller
/// cannot bind the uid and forget the schema. /// cannot bind the uid and forget the schema.
async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) { async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
crate::local_service_qt::bind(dir);
// Independent Last.fm/ListenBrainz credentials + queues. This binding is // Independent Last.fm/ListenBrainz credentials + queues. This binding is
// retained across Qobuz logout by design (opt-out lives in that store), // retained across Qobuz logout by design (opt-out lives in that store),
// and is replaced atomically when another profile activates. // and is replaced atomically when another profile activates.
@@ -358,11 +361,13 @@ where
let user_id = session.user_id; let user_id = session.user_id;
let display_name = session.display_name.clone(); let display_name = session.display_name.clone();
let subscription = session.subscription_label.clone(); let subscription = session.subscription_label.clone();
crate::local_service_qt::quiesce().await;
core.set_session(session).await.map_err(|e| e.to_string())?; core.set_session(session).await.map_err(|e| e.to_string())?;
runtime.activate(user_id).await?; runtime.activate(user_id).await?;
// Per-user stores the shell depends on — the same binding the // Per-user stores the shell depends on — the same binding the
// fresh-login path uses. // fresh-login path uses.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) { if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::local_service_qt::bind(&dir);
crate::offline_fwd::init_for_user(&dir); crate::offline_fwd::init_for_user(&dir);
// Plex settings live per-user (plex_settings.db); bind the // Plex settings live per-user (plex_settings.db); bind the
// store to this session so the first Local Library read does // store to this session so the first Local Library read does
@@ -415,11 +420,13 @@ where
} }
// Session scaffolding at the last user (session store, runtime state). // Session scaffolding at the last user (session store, runtime state).
crate::local_service_qt::quiesce().await;
runtime.activate_offline().await?; runtime.activate_offline().await?;
// Offline-MODE engine: bind the per-user stores and flag the // Offline-MODE engine: bind the per-user stores and flag the
// unauthenticated offline session. // unauthenticated offline session.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) { if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::local_service_qt::bind(&dir);
crate::offline_fwd::init_for_user(&dir); crate::offline_fwd::init_for_user(&dir);
// Plex settings live per-user (plex_settings.db); bind the store to // Plex settings live per-user (plex_settings.db); bind the store to
// this session so the first Local Library read does not have to. // this session so the first Local Library read does not have to.
@@ -469,6 +476,7 @@ pub async fn logout<A>(runtime: &Arc<AppRuntime<A>>) -> Result<(), String>
where where
A: FrontendAdapter + Send + Sync + 'static, A: FrontendAdapter + Send + Sync + 'static,
{ {
crate::local_service_qt::reset();
crate::media_sync_qt::cancel_all(); crate::media_sync_qt::cancel_all();
let _ = qbz_credentials::clear_oauth_token(); let _ = qbz_credentials::clear_oauth_token();
let _ = runtime.core().logout().await; let _ = runtime.core().logout().await;
+1
View File
@@ -297,6 +297,7 @@ impl qbz_bridge::QbzBridge {
/// Slint global is not persisted either: Settings always opens on Audio, /// Slint global is not persisted either: Settings always opens on Audio,
/// the section only has to survive a Loader unmount WITHIN a session. /// the section only has to survive a Loader unmount WITHIN a session.
pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) { pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) {
if index == 11 && !crate::orbit_qt::enabled() { return; }
self.as_mut().set_settings_section(index); self.as_mut().set_settings_section(index);
} }
+9 -26
View File
@@ -16,7 +16,6 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use qbz_app::user_data::UserDataPaths;
use qbz_library::{LibraryDatabase, LibraryError}; use qbz_library::{LibraryDatabase, LibraryError};
/// `<data_dir>/qbz/users/<uid>/library.db` — the same per-user path the /// `<data_dir>/qbz/users/<uid>/library.db` — the same per-user path the
@@ -44,14 +43,7 @@ use qbz_library::{LibraryDatabase, LibraryError};
/// Reads still pass `create: false`, so nothing is conjured by listing; only a /// Reads still pass `create: false`, so nothing is conjured by listing; only a
/// write brings `users/0/library.db` into existence. /// write brings `users/0/library.db` into existence.
fn db_path() -> Option<PathBuf> { fn db_path() -> Option<PathBuf> {
let uid = UserDataPaths::load_last_user_id().unwrap_or(0); crate::local_state::db_path()
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(uid.to_string())
.join("library.db"),
)
} }
/// Run `f` against the per-user database. `create` mirrors the reference's /// Run `f` against the per-user database. `create` mirrors the reference's
@@ -73,25 +65,16 @@ pub(crate) fn with_db<F, R>(create: bool, f: F) -> Option<R>
where where
F: FnOnce(&LibraryDatabase) -> Result<R, LibraryError>, F: FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
{ {
let path = db_path()?; let store = qbz_library::LibraryStore::new(db_path()?);
if create { let result = if create {
if let Some(parent) = path.parent() { store.write(f).map(Some)
let _ = std::fs::create_dir_all(parent); } else {
} store.read(f)
} else if !path.exists() {
return None;
}
let db = match LibraryDatabase::open(&path) {
Ok(db) => db,
Err(e) => {
log::warn!("[qbz-qt] library.db open failed: {e}");
return None;
}
}; };
match f(&db) { match result {
Ok(r) => Some(r), Ok(value) => value,
Err(e) => { Err(e) => {
log::error!("[qbz-qt] library.db op failed: {e}"); log::error!("[qbz-qt] library.db operation failed: {e}");
None None
} }
} }
+38 -9
View File
@@ -8,7 +8,7 @@ use std::collections::BTreeMap;
use std::sync::Once; use std::sync::Once;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use qbz_library::{LibraryDatabase, LibraryFolder, LocalRootWatcher, RootWatchEvent}; use qbz_library::{LibraryFolder, LocalRootWatcher, RootWatchEvent};
const WATCH_POLL: Duration = Duration::from_secs(1); const WATCH_POLL: Duration = Duration::from_secs(1);
const WATCH_DEBOUNCE: Duration = Duration::from_secs(2); const WATCH_DEBOUNCE: Duration = Duration::from_secs(2);
@@ -27,17 +27,36 @@ pub(crate) fn start() {
} }
fn run() { fn run() {
let Ok(db) = LibraryDatabase::open(&qbz_library::get_db_path()) else { loop {
log::warn!("[local-scan] maintenance disabled: library database unavailable"); if let Some(host) =
return; crate::local_service_qt::current().filter(crate::local_service_qt::is_current)
}; {
let mut folders = db.get_folders_with_metadata().unwrap_or_default(); // A missing profile is not a reason to create the legacy global
// library.db. Wait for the first real library write instead.
match host
.service
.store()
.read(|db| db.get_folders_with_metadata())
{
Ok(Some(folders)) => watch_profile(host, folders),
Ok(None) => {}
Err(error) => log::warn!("[local-scan] profile library unavailable: {error}"),
}
}
std::thread::sleep(WATCH_POLL);
}
}
fn watch_profile(
host: std::sync::Arc<crate::local_service_qt::DesktopLibrary>,
mut folders: Vec<LibraryFolder>,
) {
let mut watcher = LocalRootWatcher::new(&folders).ok(); let mut watcher = LocalRootWatcher::new(&folders).ok();
let mut pending = BTreeMap::<i64, Instant>::new(); let mut pending = BTreeMap::<i64, Instant>::new();
queue_periodic_due(&folders, unix_now(), Instant::now(), &mut pending); queue_periodic_due(&folders, unix_now(), Instant::now(), &mut pending);
let mut refreshed = Instant::now(); let mut refreshed = Instant::now();
loop { while crate::local_service_qt::is_current(&host) {
let event = watcher let event = watcher
.as_ref() .as_ref()
.map(|watcher| watcher.recv_timeout(WATCH_POLL)) .map(|watcher| watcher.recv_timeout(WATCH_POLL))
@@ -45,6 +64,9 @@ fn run() {
std::thread::sleep(WATCH_POLL); std::thread::sleep(WATCH_POLL);
RootWatchEvent::Timeout RootWatchEvent::Timeout
}); });
if !crate::local_service_qt::is_current(&host) {
return;
}
let now = Instant::now(); let now = Instant::now();
match event { match event {
RootWatchEvent::Changed(root_ids) => { RootWatchEvent::Changed(root_ids) => {
@@ -66,7 +88,14 @@ fn run() {
} }
if now.duration_since(refreshed) >= ROOT_REFRESH { if now.duration_since(refreshed) >= ROOT_REFRESH {
folders = db.get_folders_with_metadata().unwrap_or_default(); let Ok(Some(current)) = host
.service
.store()
.read(|db| db.get_folders_with_metadata())
else {
return;
};
folders = current;
match watcher.as_mut() { match watcher.as_mut() {
Some(active_watcher) => { Some(active_watcher) => {
if active_watcher.rebuild(&folders).is_err() { if active_watcher.rebuild(&folders).is_err() {
@@ -86,7 +115,7 @@ fn run() {
let Some(root_id) = ready else { let Some(root_id) = ready else {
continue; continue;
}; };
if crate::settings_qt::library::scan(Some(root_id)) { if crate::settings_qt::library::scan_for(host.clone(), Some(root_id)) {
pending.remove(&root_id); pending.remove(&root_id);
} else { } else {
pending.insert(root_id, now + BUSY_RETRY); pending.insert(root_id, now + BUSY_RETRY);
+124
View File
@@ -0,0 +1,124 @@
//! Desktop binding of the shared host-owned library service. Workers retain the
//! captured binding; only this boundary selects desktop profile defaults.
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use qbz_library::service::LibraryService;
use qbz_library::LibraryStore;
pub struct DesktopLibrary {
pub service: Arc<LibraryService>,
observing: AtomicBool,
}
impl DesktopLibrary {
pub fn begin_observing(&self) -> bool {
!self.observing.swap(true, Ordering::AcqRel)
}
pub fn stop_observing(&self) {
self.observing.store(false, Ordering::Release);
}
}
static HOST: Mutex<Option<Arc<DesktopLibrary>>> = Mutex::new(None);
// Accessed while holding HOST. Once a transition starts, background observers
// cannot create a new guest binding between quiesce and explicit activation.
static ALLOW_LAZY_BIND: AtomicBool = AtomicBool::new(true);
fn create(dir: &Path) -> Arc<DesktopLibrary> {
Arc::new(DesktopLibrary {
service: Arc::new(LibraryService::new(
LibraryStore::new(dir.join("library.db")),
// Retain the desktop's stable artwork cache across guest-profile
// adoption: moving users/0 must not invalidate indexed absolute
// cover paths. Other hosts supply their own cache to the service.
dirs::cache_dir()
.map(|p| p.join("qbz/artwork"))
.unwrap_or_else(|| {
dir.parent()
.and_then(Path::parent)
.unwrap_or(dir)
.join("artwork")
}),
)),
observing: AtomicBool::new(false),
})
}
pub fn current() -> Option<Arc<DesktopLibrary>> {
let mut host = HOST.lock().unwrap_or_else(|e| e.into_inner());
if host.is_none() {
if !ALLOW_LAZY_BIND.load(Ordering::Relaxed) {
return None;
}
let uid = qbz_app::user_data::UserDataPaths::load_last_user_id().unwrap_or(0);
let dir = dirs::data_dir()?.join("qbz/users").join(uid.to_string());
*host = Some(create(&dir));
}
host.clone()
}
pub fn is_current(host: &Arc<DesktopLibrary>) -> bool {
HOST.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.is_some_and(|current| Arc::ptr_eq(host, current) && !current.service.snapshot().closed)
}
pub fn bind(dir: &Path) {
let old = {
let mut host = HOST.lock().unwrap_or_else(|e| e.into_inner());
ALLOW_LAZY_BIND.store(true, Ordering::Relaxed);
if host.as_ref().is_some_and(|h| {
!h.service.snapshot().closed
&& h.service.store().database_path() == dir.join("library.db")
}) {
return;
}
host.replace(create(dir))
};
crate::orbit_qt::reset();
retire(old);
crate::settings_qt::library::reset_profile_state();
}
pub fn reset() {
// Retain the closed binding until activation: a following login must
// still be able to join its worker before adopting/renaming guest data.
let old = {
let host = HOST.lock().unwrap_or_else(|e| e.into_inner());
ALLOW_LAZY_BIND.store(false, Ordering::Relaxed);
host.clone()
};
crate::orbit_qt::reset();
retire(old);
crate::settings_qt::library::reset_profile_state();
}
fn retire(old: Option<Arc<DesktopLibrary>>) {
if let Some(old) = old {
old.service.close();
// Never wait for a network mount on the UI thread. The old worker can
// only finish against its captured paths; it cannot publish to new UI.
crate::spawn(async move {
let _ = tokio::task::spawn_blocking(move || old.service.shutdown()).await;
});
}
}
/// Join before AppRuntime can rename a guest profile. This wait runs on the
/// blocking pool; a slow filesystem must not turn into a scan writing through
/// a directory while that directory is being adopted by another account.
pub async fn quiesce() {
let host = {
let host = HOST.lock().unwrap_or_else(|e| e.into_inner());
ALLOW_LAZY_BIND.store(false, Ordering::Relaxed);
host.clone()
};
if let Some(host) = host {
host.service.close();
crate::orbit_qt::reset();
let _ = tokio::task::spawn_blocking(move || host.service.shutdown()).await;
}
}
+10 -23
View File
@@ -10,7 +10,6 @@ use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use qbz_app::user_data::UserDataPaths;
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack}; use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
use qbz_source::SourceId; use qbz_source::SourceId;
@@ -62,13 +61,12 @@ pub struct TracksLoadRequest {
/// The two helpers open the SAME file and must never disagree about where it /// The two helpers open the SAME file and must never disagree about where it
/// is. /// is.
pub fn db_path() -> Option<PathBuf> { pub fn db_path() -> Option<PathBuf> {
let uid = UserDataPaths::load_last_user_id().unwrap_or(0);
Some( Some(
dirs::data_dir()? crate::local_service_qt::current()?
.join("qbz") .service
.join("users") .store()
.join(uid.to_string()) .database_path()
.join("library.db"), .to_path_buf(),
) )
} }
@@ -76,21 +74,10 @@ pub fn with_db<F, R>(f: F) -> Option<R>
where where
F: FnOnce(&LibraryDatabase) -> Result<R, LibraryError>, F: FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
{ {
let path = db_path()?; match qbz_library::LibraryStore::new(db_path()?).read(f) {
if !path.exists() { Ok(result) => result,
return None;
}
let db = match LibraryDatabase::open(&path) {
Ok(db) => db,
Err(e) => { Err(e) => {
log::error!("[qbz-qt] local library open failed: {e}"); log::error!("[qbz-qt] local library operation failed: {e}");
return None;
}
};
match f(&db) {
Ok(r) => Some(r),
Err(e) => {
log::error!("[qbz-qt] local library query failed: {e}");
None None
} }
} }
@@ -596,8 +583,8 @@ mod phase_a_tests {
use qbz_source::SourceId; use qbz_source::SourceId;
use super::{ use super::{
library_sources_available, normalize_explorer_columns, tracks_request_is_current, library_sources_available, normalize_explorer_columns, tracks_request_is_current, with_art,
with_art, Prefs, TrackSourceOffsets, TracksLoadRequest, Prefs, TrackSourceOffsets, TracksLoadRequest,
}; };
#[test] #[test]
+4
View File
@@ -208,6 +208,9 @@ mod local_filter;
mod local_library_qt; mod local_library_qt;
mod local_rows; mod local_rows;
mod local_state; mod local_state;
mod local_service_qt;
mod orbit_bridge;
mod orbit_qt;
mod recommendations_qt; mod recommendations_qt;
mod whats_new_qt; mod whats_new_qt;
// Watcher hints + periodic root reconciliation for the incremental scanner. // Watcher hints + periodic root reconciliation for the incremental scanner.
@@ -4200,6 +4203,7 @@ fn main() {
// and the same reasoning as the session flush above (one SQLite // and the same reasoning as the session flush above (one SQLite
// write on the main thread, behind the watchdog). // write on the main thread, behind the watchdog).
listen_log_qt::shutdown_blocking(); listen_log_qt::shutdown_blocking();
local_service_qt::reset();
log::info!("[qbz-qt] shutdown complete"); log::info!("[qbz-qt] shutdown complete");
} }
// Explicit, INSTRUMENTED drops (2026-08-04 quit incident). Rust would run // Explicit, INSTRUMENTED drops (2026-08-04 quit incident). Rust would run
-26
View File
@@ -699,32 +699,6 @@ pub fn search_tracks_page(
rows.into_iter().map(cached_to_local_track).collect() rows.into_iter().map(cached_to_local_track).collect()
} }
/// Substring search across one remote source, in the `LocalTrack` shape.
pub fn search_tracks(query: &str, limit: Option<u32>) -> Vec<qbz_library::LocalTrack> {
let mut out = Vec::new();
for kind in MediaServerKind::ALL {
if !get(kind).is_configured(kind) {
continue;
}
let (source, handle) = match kind {
MediaServerKind::Jellyfin => (
qbz_media_cache::RemoteSource::Jellyfin,
qbz_source::registry().jellyfin().cache(),
),
MediaServerKind::Subsonic => (
qbz_media_cache::RemoteSource::Subsonic,
qbz_source::registry().subsonic().cache(),
),
};
if let Some(rows) =
handle.with(|c| qbz_media_cache::search(c, source, query, limit).unwrap_or_default())
{
out.extend(rows.into_iter().map(cached_to_local_track));
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+90
View File
@@ -0,0 +1,90 @@
//! Launch-gated Orbit laboratory. No operation is admitted without --orbit.
use cxx_qt::{CxxQtThread, Threading as _};
use cxx_qt_lib::QString;
use std::pin::Pin;
use std::sync::OnceLock;
#[cxx_qt::bridge]
pub mod qbz_orbit_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
#[qproperty(bool, enabled)]
#[qproperty(QString, state_json)]
// Reserved for the integral context switch, NOT set by a host probe.
// The shell banner already consumes these; verifying a host leaves
// the current local context and all of its controls explicitly local.
#[qproperty(bool, controlling_remote)]
#[qproperty(bool, connection_lost)]
#[qproperty(QString, host_name)]
type QbzOrbit = super::QbzOrbitRust;
#[qinvokable]
fn boot(self: Pin<&mut QbzOrbit>);
#[qinvokable]
fn start_host(self: Pin<&mut QbzOrbit>, address: QString);
#[qinvokable]
fn stop_host(self: Pin<&mut QbzOrbit>);
#[qinvokable]
fn copy_access_key(self: Pin<&mut QbzOrbit>);
#[qinvokable]
fn verify_host(self: Pin<&mut QbzOrbit>, url: QString, token: QString);
#[qinvokable]
fn search_library(self: Pin<&mut QbzOrbit>, query: QString);
}
impl cxx_qt::Threading for QbzOrbit {}
}
use qbz_orbit_bridge::QbzOrbit;
pub struct QbzOrbitRust {
enabled: bool,
state_json: QString,
controlling_remote: bool,
connection_lost: bool,
host_name: QString,
}
impl Default for QbzOrbitRust {
fn default() -> Self {
Self {
enabled: crate::orbit_qt::enabled(),
state_json: QString::from("{}"),
controlling_remote: false,
connection_lost: false,
host_name: QString::default(),
}
}
}
static THREAD: OnceLock<CxxQtThread<QbzOrbit>> = OnceLock::new();
pub fn ui(f: impl FnOnce(Pin<&mut QbzOrbit>) + Send + 'static) {
if let Some(thread) = THREAD.get() {
let _ = thread.queue(f);
}
}
impl QbzOrbit {
pub fn boot(self: Pin<&mut Self>) {
let _ = THREAD.set(self.qt_thread());
if crate::orbit_qt::enabled() {
crate::orbit_qt::publish();
}
}
pub fn start_host(self: Pin<&mut Self>, address: QString) {
crate::orbit_qt::start_host(address.to_string());
}
pub fn stop_host(self: Pin<&mut Self>) {
crate::orbit_qt::stop_host();
}
pub fn copy_access_key(self: Pin<&mut Self>) {
crate::orbit_qt::copy_access_key();
}
pub fn verify_host(self: Pin<&mut Self>, url: QString, token: QString) {
crate::orbit_qt::verify_host(url.to_string(), token.to_string());
}
pub fn search_library(self: Pin<&mut Self>, query: QString) {
crate::orbit_qt::search_library(query.to_string());
}
}
+398
View File
@@ -0,0 +1,398 @@
//! Opt-in inspection host/client. A successful probe does NOT select a remote
//! playback context. Capabilities are checked before each laboratory operation.
use qbz_control::library::{LibraryHost, LibraryInfo, LibraryPage, PROTOCOL_VERSION};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::Duration;
pub fn enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| has_flag(std::env::args()))
}
fn has_flag(args: impl IntoIterator<Item = String>) -> bool {
args.into_iter().any(|arg| arg == "--orbit")
}
struct Listener {
handle: qbz_control::ApiHandle,
address: String,
token: String,
}
#[derive(Clone)]
struct Peer {
url: reqwest::Url,
token: String,
info: LibraryInfo,
}
#[derive(Default)]
struct State {
listener: Option<Listener>,
peer: Option<Peer>,
page: Option<LibraryPage>,
host_generation: u64,
peer_generation: u64,
revision: u64,
starting: bool,
probing: bool,
searching: bool,
status: &'static str,
}
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| Mutex::new(State::default()));
pub fn publish() {
if !enabled() {
return;
}
let (revision, doc) = {
let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
(
state.revision,
serde_json::json!({
"listening": state.listener.is_some(), "starting": state.starting,
"address": state.listener.as_ref().map(|s| s.address.as_str()),
// Access key never enters JSON/QML. Copy requires a deliberate click.
"peer": state.peer.as_ref().map(|p| &p.info),
"page": state.page, "probing": state.probing, "searching": state.searching,
"status": state.status,
})
.to_string(),
)
};
crate::orbit_bridge::ui(move |mut bridge| {
if STATE.lock().unwrap_or_else(|e| e.into_inner()).revision == revision {
bridge
.as_mut()
.set_state_json(cxx_qt_lib::QString::from(doc));
}
});
}
pub fn start_host(address: String) {
if !enabled() {
return;
}
let Ok(address) = address.trim().parse::<std::net::SocketAddr>() else {
set_status("invalid-address");
return;
};
let Some(host) = crate::local_service_qt::current() else {
set_status("host-unavailable");
return;
};
let generation = {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
if state.starting || state.listener.is_some() {
return;
}
state.host_generation += 1;
state.revision += 1;
state.starting = true;
state.status = "";
state.host_generation
};
publish();
crate::spawn(async move {
let binding = host.clone();
let result = tokio::task::spawn_blocking(move || {
let bound = qbz_control::bind(address).map_err(|_| ())?;
let address = bound.local_addr().to_string();
let token = uuid::Uuid::new_v4().simple().to_string();
qbz_log::register_secret(token.clone());
let name = std::env::var("HOSTNAME")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "QBZ".into());
let endpoint = LibraryHost::new(host.service.clone(), name, token.clone());
Ok::<_, ()>(Listener {
handle: qbz_control::serve(bound, endpoint),
address,
token,
})
})
.await;
let mut discard = None;
{
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
let current = state.host_generation == generation
&& crate::local_service_qt::is_current(&binding);
match result {
Ok(Ok(listener)) if current => {
state.listener = Some(listener);
}
Ok(Ok(listener)) => discard = Some(listener),
_ if current => state.status = "listen-failed",
_ => {}
}
if current {
state.starting = false;
state.revision += 1;
}
}
if let Some(listener) = discard {
let _ = tokio::task::spawn_blocking(move || listener.handle.shutdown()).await;
}
publish();
});
}
pub fn stop_host() {
if !enabled() {
return;
}
let listener = {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
state.host_generation += 1;
state.revision += 1;
state.starting = false;
state.listener.take()
};
if let Some(listener) = listener {
crate::spawn(async move {
let _ = tokio::task::spawn_blocking(move || listener.handle.shutdown()).await;
});
}
publish();
}
/// Session teardown also invalidates delayed client replies and drops its key.
pub fn reset() {
if !enabled() {
return;
}
stop_host();
{
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
state.peer_generation += 1;
state.revision += 1;
state.peer = None;
state.page = None;
state.probing = false;
state.searching = false;
state.status = "";
}
publish();
}
pub fn copy_access_key() {
if !enabled() {
return;
}
let token = STATE
.lock()
.unwrap_or_else(|e| e.into_inner())
.listener
.as_ref()
.map(|s| s.token.clone());
if let Some(token) = token {
crate::share_qt::copy_to_clipboard(token);
}
}
fn peer_url(input: &str) -> Result<reqwest::Url, ()> {
let mut url = reqwest::Url::parse(input.trim()).map_err(|_| ())?;
if !matches!(url.scheme(), "http" | "https")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| !matches!(url.path(), "" | "/")
{
return Err(());
}
url.set_path("/");
Ok(url)
}
async fn get<T: serde::de::DeserializeOwned>(
url: reqwest::Url,
token: &str,
) -> Result<T, &'static str> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(6))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "verify-failed")?;
let mut response = client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|_| "verify-failed")?;
if !response.status().is_success() {
return Err(if response.status().as_u16() == 409 {
"host-changed"
} else {
"verify-failed"
});
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "verify-failed")? {
if bytes.len() + chunk.len() > 1_048_576 {
return Err("verify-failed");
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).map_err(|_| "verify-failed")
}
pub fn verify_host(url: String, token: String) {
if !enabled() {
return;
}
let Ok(url) = peer_url(&url) else {
{
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
state.peer_generation += 1;
state.revision += 1;
state.peer = None;
state.page = None;
state.probing = false;
state.searching = false;
state.status = "invalid-address";
}
publish();
return;
};
let generation = {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
state.peer_generation += 1;
state.revision += 1;
state.peer = None;
state.page = None;
state.probing = true;
state.searching = false;
state.status = "";
state.peer_generation
};
publish();
crate::spawn(async move {
let result = get::<LibraryInfo>(
url.join("api/orbit/library")
.expect("constant relative URL"),
&token,
)
.await;
{
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
if state.peer_generation != generation {
return;
}
state.probing = false;
state.revision += 1;
match result {
Ok(info)
if info.protocol == PROTOCOL_VERSION
&& !info.instance.is_empty()
&& info
.capabilities
.iter()
.any(|c| c == "library.files.inspect") =>
{
state.peer = Some(Peer { url, token, info });
state.status = "verified";
}
Ok(_) => state.status = "not-supported",
Err(error) => state.status = error,
}
}
publish();
});
}
pub fn search_library(query: String) {
if !enabled() {
return;
}
let (peer, generation) = {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
let Some(peer) = state
.peer
.as_ref()
.filter(|p| {
p.info
.capabilities
.iter()
.any(|c| c == "library.files.search")
})
.cloned()
else {
return;
};
state.peer_generation += 1;
state.revision += 1;
state.searching = true;
state.page = None;
state.status = "";
(peer, state.peer_generation)
};
publish();
crate::spawn(async move {
let mut url = peer
.url
.join("api/orbit/library/search")
.expect("constant relative URL");
url.query_pairs_mut()
.append_pair("instance", &peer.info.instance)
.append_pair("q", &query)
.append_pair("limit", "25");
let result = get::<LibraryPage>(url, &peer.token).await;
{
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
if state.peer_generation != generation {
return;
}
state.searching = false;
state.revision += 1;
match result {
Ok(page) if page.instance == peer.info.instance => state.page = Some(page),
Ok(_) => {
state.peer = None;
state.status = "host-changed";
}
Err(error) => {
state.peer = None;
state.status = error;
}
}
}
publish();
});
}
fn set_status(status: &'static str) {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
state.status = status;
state.revision += 1;
drop(state);
publish();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn orbit_requires_the_exact_launch_flag() {
assert!(!has_flag(
["qbz", "--no-orbit", "--orbit=false"].map(String::from)
));
assert!(has_flag(["qbz", "--orbit"].map(String::from)));
}
#[test]
fn peer_address_cannot_smuggle_credentials_or_a_different_route() {
for bad in [
"file:///tmp/qbz",
"http://user:pass@host/",
"http://host/api",
"http://host/?token=secret",
"http://host/#key",
] {
assert!(peer_url(bad).is_err(), "{bad}");
}
for good in [
"http://127.0.0.1:17290",
"http://[::1]:17290/",
"https://music.example/",
] {
assert!(peer_url(good).is_ok(), "{good}");
}
}
}
+23
View File
@@ -118,6 +118,16 @@ pub mod qbz_search {
/// searchType filter radios (0 = none, 1..5 = the chips). /// searchType filter radios (0 = none, 1..5 = the chips).
#[qinvokable] #[qinvokable]
fn search_filter_changed(self: Pin<&mut QbzSearch>, index: i32); fn search_filter_changed(self: Pin<&mut QbzSearch>, index: i32);
/// Local page results use their own versioned snapshot, not the dropdown's.
#[qinvokable]
fn search_local_action(
self: Pin<&mut QbzSearch>,
revision: QString,
index: i32,
action: QString,
);
#[qinvokable]
fn search_local_more(self: Pin<&mut QbzSearch>, revision: QString, kind: QString);
} }
impl cxx_qt::Threading for QbzSearch {} impl cxx_qt::Threading for QbzSearch {}
@@ -261,4 +271,17 @@ impl qbz_search::QbzSearch {
pub fn search_filter_changed(self: Pin<&mut Self>, index: i32) { pub fn search_filter_changed(self: Pin<&mut Self>, index: i32) {
crate::search_filter_changed(index); crate::search_filter_changed(index);
} }
pub fn search_local_action(
self: Pin<&mut Self>,
revision: QString,
index: i32,
action: QString,
) {
crate::search_qt::local_page_action(&revision.to_string(), index, &action.to_string());
}
pub fn search_local_more(self: Pin<&mut Self>, revision: QString, kind: QString) {
crate::search_qt::local_page_more(&revision.to_string(), &kind.to_string());
}
} }
+130 -57
View File
@@ -15,19 +15,14 @@
//! Pure + blocking helpers only: no Qt types, no bridge, no `ui()`. The //! Pure + blocking helpers only: no Qt types, no bridge, no `ui()`. The
//! publishing side stays in `search_qt.rs`. //! publishing side stays in `search_qt.rs`.
//! //!
//! ## What the reference does that this does NOT change //! ## Search presentation and candidate policy
//!
//! Several oddities are reproduced 1:1 on purpose, because the reference is
//! the spec and a "fix" here would be a silent divergence:
//! //!
//! - **No dedupe between the Qobuz half and the local half.** An album owned //! - **No dedupe between the Qobuz half and the local half.** An album owned
//! locally AND in the catalog appears twice, in two sections, with two ids. //! locally AND in the catalog appears twice, in two sections, with two ids.
//! - **`has_more` is window-relative, not library-relative.** No `COUNT(*)` is //! - **`has_more` is window-relative, not library-relative.** No `COUNT(*)` is
//! ever issued; it answers "were there more distinct groups inside the rows //! ever issued; it answers "were there more distinct groups inside the rows
//! I fetched", which under-reports on a large library. //! I fetched", which under-reports on a large library.
//! - **Plex rows are PREPENDED and the track section is a plain `take(cap)`,** //! - Candidate reads use qbz-library's bounded, interleaved source search.
//! so three Plex matches can starve the local-file tracks out of the section
//! entirely.
//! - **Artwork is resolved before mapping.** Current scans persist //! - **Artwork is resolved before mapping.** Current scans persist
//! embedded/disc/collection art in that order; this bounded search window //! embedded/disc/collection art in that order; this bounded search window
//! also runs the queue-time folder resolver so rows indexed by an older //! also runs the queue-time folder resolver so rows indexed by an older
@@ -37,6 +32,50 @@ use std::collections::HashSet;
use crate::search_qt::{CortRow, CortSection, CortinillaData}; use crate::search_qt::{CortRow, CortSection, CortinillaData};
/// The page owns a separate snapshot: typing in the dropdown must never change
/// what a page click plays. Track row keys are snapshot offsets, not DB IDs;
/// different source instances are allowed to reuse the same native integer ID.
#[derive(Default)]
pub(crate) struct PageSnapshot {
pub data: CortinillaData,
pub tracks: Vec<qbz_library::LocalTrack>,
}
impl PageSnapshot {
pub fn new(query: &str, tracks: Vec<qbz_library::LocalTrack>) -> Self {
let mut data = CortinillaData {
query: query.into(),
..Default::default()
};
append_local_sections(
&mut data,
&tracks,
LocalCaps {
albums: 8,
artists: 4,
tracks: 8,
},
query,
);
for section in &mut data.sections {
if section.kind == "local" {
for (index, row) in section.rows.iter_mut().enumerate() {
row.id = index.to_string();
}
}
}
Self { data, tracks }
}
pub fn row(&self, index: i32) -> Option<&CortRow> {
self.data
.sections
.iter()
.flat_map(|s| &s.rows)
.find(|r| r.flat_index == index)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Caps // Caps
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -195,8 +234,7 @@ pub(crate) fn derive_local_album_rows(
if out.len() >= cap { if out.len() >= cap {
continue; // keep counting for an honest has_more continue; // keep counting for an honest has_more
} }
let (art_url, art_path) = let (art_url, art_path) = local_art_split(t, crate::local_rows::ArtworkScope::Album);
local_art_split(t, crate::local_rows::ArtworkScope::Album);
out.push(CortRow { out.push(CortRow {
kind: "album".into(), kind: "album".into(),
id: key, id: key,
@@ -249,8 +287,7 @@ pub(crate) fn derive_local_artist_rows(
if out.len() >= cap { if out.len() >= cap {
continue; continue;
} }
let (art_url, art_path) = let (art_url, art_path) = local_art_split(t, crate::local_rows::ArtworkScope::Album);
local_art_split(t, crate::local_rows::ArtworkScope::Album);
out.push(CortRow { out.push(CortRow {
kind: "artist".into(), kind: "artist".into(),
id: String::new(), id: String::new(),
@@ -275,8 +312,7 @@ pub(crate) fn derive_local_artist_rows(
/// media action. `id` is the library row id; the router resolves the concrete /// media action. `id` is the library row id; the router resolves the concrete
/// `LocalTrack` back from the per-query snapshot, NOT from this id. /// `LocalTrack` back from the per-query snapshot, NOT from this id.
pub(crate) fn map_local_track_to_cort_row(t: &qbz_library::LocalTrack) -> CortRow { pub(crate) fn map_local_track_to_cort_row(t: &qbz_library::LocalTrack) -> CortRow {
let (art_url, art_path) = let (art_url, art_path) = local_art_split(t, crate::local_rows::ArtworkScope::Track);
local_art_split(t, crate::local_rows::ArtworkScope::Track);
// "artist · album" when both exist, else whichever does (U+00B7). // "artist · album" when both exist, else whichever does (U+00B7).
let subtitle = match (t.artist.is_empty(), t.album.is_empty()) { let subtitle = match (t.artist.is_empty(), t.album.is_empty()) {
(false, false) => format!("{} · {}", t.artist, t.album), (false, false) => format!("{} · {}", t.artist, t.album),
@@ -395,9 +431,9 @@ pub(crate) fn append_immersive_local_albums(
/// Fetch up to `limit` local-library tracks matching `query`, off the calling /// Fetch up to `limit` local-library tracks matching `query`, off the calling
/// thread. /// thread.
/// ///
/// Independent of the Qobuz search: callers `tokio::join!` this with /// Callers run this alongside `core.search_all` with `tokio::join!`.
/// `core.search_all`, so a slow or offline Qobuz never blocks the on-device /// Local lookup has no Qobuz-auth prerequisite; publication still waits for
/// results and vice versa. /// both queries, and a Qobuz failure must preserve the local result.
/// ///
/// `gated` is the intelligent-search kill switch. The MAIN cortinilla passes /// `gated` is the intelligent-search kill switch. The MAIN cortinilla passes
/// `true` — the module being off means no local search either, which is the /// `true` — the module being off means no local search either, which is the
@@ -417,55 +453,64 @@ pub(crate) async fn load_cortinilla_local(
return Vec::new(); return Vec::new();
} }
let exclude_network = crate::offline_fwd::exclude_network_folders_now(); let exclude_network = crate::offline_fwd::exclude_network_folders_now();
// Plex is part of the user's Local Library — the Artists/Tracks tabs union // Resolve the files profile on the caller before scheduling work. Remote
// it — so the cortinilla must include it too. The DB search only hits // adapters retain their existing cache/config ownership until the per-host
// `local_tracks`; the Plex cache is a separate bounded set merged here. // source registry is extracted; the shared merger has no Qt globals.
let plex_enabled = crate::local_plex::is_configured(); let mut search = qbz_library::search::LibrarySearch::default();
let q_log = q.clone(); if let Some(path) = crate::local_state::db_path() {
search
.add_files(qbz_library::LibraryStore::new(path), exclude_network)
.expect("files source registered once");
}
if crate::local_plex::is_configured() {
search
.add_source("plex", |q, limit| {
Ok(crate::local_plex::search_tracks_page(
q,
0,
limit,
"default",
&[],
false,
&[],
))
})
.expect("Plex source registered once");
}
for source in crate::media_servers_qt::configured_words() {
search
.add_source(source, move |q, limit| {
Ok(crate::media_servers_qt::search_tracks_page(
source,
q,
0,
limit,
"default",
&[],
false,
&[],
))
})
.expect("media source registered once");
}
let t = std::time::Instant::now(); let t = std::time::Instant::now();
let rows: Vec<qbz_library::LocalTrack> = tokio::task::spawn_blocking(move || { let rows = tokio::task::spawn_blocking(move || {
let mut rows = crate::local_state::with_db(|db| { let result = search.search(&q, limit);
// "default" sort: the cortinilla has no sort control, so keep the for failure in result.failures {
// historical album-grouped order. log::warn!(
db.search_with_filter_page(q.trim(), 0, limit, true, exclude_network, "default") "[qbz-qt] local search source {}: {}",
}) failure.source,
.unwrap_or_default(); failure.error
// PREPEND so remote content is visible without scrolling past a full );
// local page. See the module header: this can starve the local-file
// tracks out of the track section, and that is the reference's
// behaviour, reproduced — now for every remote source, not just Plex.
let mut merged = if plex_enabled {
crate::local_plex::search_tracks(q.trim())
} else {
Vec::new()
};
// BOUNDED, unlike the Plex arm. The Plex cache is read whole because
// it always was; a media-server mirror can hold 50k rows and this runs
// on every keystroke of the cortinilla, so it takes the same limit the
// caller asked the local query for.
merged.extend(crate::media_servers_qt::search_tracks(
q.trim(),
Some(limit as u32),
));
if !merged.is_empty() {
merged.append(&mut rows);
rows = merged;
} }
// Search is an artwork-bearing surface too. Keep the result consistent let mut rows = result.tracks;
// with Library Explorer and playback for pre-migration rows: a cover
// in the track's disc directory wins over a stale collection cover,
// while collection art remains the fallback. The window is bounded by
// `limit`, and this closure is already off the async/UI threads.
crate::local_playback::fill_missing_covers(&mut rows); crate::local_playback::fill_missing_covers(&mut rows);
rows rows
}) })
.await .await
.unwrap_or_default(); .unwrap_or_default();
// The port had NO perf log on any local path, which is why the cost of
// this query on a large library was unmeasured. It is measured now.
log::info!( log::info!(
"[qbz-qt][perf] cortinilla local: query={q_log:?} limit={limit} plex={plex_enabled} \ "[qbz-qt][perf] local search limit={limit} -> {} rows in {:?}",
exclude_network={exclude_network} -> {} rows in {:?}",
rows.len(), rows.len(),
t.elapsed() t.elapsed()
); );
@@ -486,6 +531,34 @@ mod tests {
t t
} }
#[test]
fn page_snapshot_preserves_colliding_source_ids_and_is_independent_of_dropdown() {
let mut files = track("Song", "Artist", "Album");
files.id = 7;
let mut remote = files.clone();
remote.source = Some("jellyfin".into());
remote.file_path = "server-item-7".into();
let page = PageSnapshot::new("Song", vec![files, remote]);
let dropdown = PageSnapshot::new("Other", vec![track("Other", "Other", "Other")]);
let section = page
.data
.sections
.iter()
.find(|s| s.kind == "local")
.unwrap();
assert_eq!(section.rows.len(), 2);
for (offset, expected_source) in [None, Some("jellyfin")].iter().enumerate() {
let displayed = page.row(section.rows[offset].flat_index).unwrap();
let resolved = &page.tracks[displayed.id.parse::<usize>().unwrap()];
assert_eq!(resolved.id, 7);
assert_eq!(&resolved.source.as_deref(), expected_source);
assert_eq!(resolved.title, "Song");
}
assert_eq!(dropdown.tracks[0].title, "Other");
assert!(page.row(-1).is_none());
assert!(page.row(1000).is_none());
}
/// The owner's real report: searching "Iro" showed Cynic and Die Toten /// The owner's real report: searching "Iro" showed Cynic and Die Toten
/// Hosen under "Artists on Local Library", and their records under /// Hosen under "Artists on Local Library", and their records under
/// "Albums on Local Library". Neither name contains "iro" - a TRACK of /// "Albums on Local Library". Neither name contains "iro" - a TRACK of
+163 -39
View File
@@ -26,7 +26,7 @@
//! - The LOCAL "on this device" sections ARE ported (`search_local.rs`): //! - The LOCAL "on this device" sections ARE ported (`search_local.rs`):
//! albums / artists / tracks, appended last, Plex unioned in, fetched //! albums / artists / tracks, appended last, Plex unioned in, fetched
//! CONCURRENTLY with the Qobuz half so an offline or slow Qobuz still //! CONCURRENTLY with the Qobuz half so an offline or slow Qobuz still
//! yields a local-only dropdown. The results PAGE is still Qobuz-only. //! yields local results. The results page also carries the same local sections.
//! - Artist "following" flags are resolved from `fav_cache_qt` (the ported //! - Artist "following" flags are resolved from `fav_cache_qt` (the ported
//! favourite-id cache) — they used to be hard-`false`, which made a search //! favourite-id cache) — they used to be hard-`false`, which made a search
//! hit on a followed artist draw "Follow" and un-follow them on click. //! hit on a followed artist draw "Follow" and un-follow them on click.
@@ -43,9 +43,9 @@ use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::album_qt::format_album_title;
use cxx_qt_lib::QString; use cxx_qt_lib::QString;
pub(crate) use qbz_app::settings::search_service::InteractionAction; pub(crate) use qbz_app::settings::search_service::InteractionAction;
use crate::album_qt::format_album_title;
use qbz_app::settings::search_service::SearchService; use qbz_app::settings::search_service::SearchService;
use qbz_app::shell::AppRuntime; use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter; use qbz_core::LoggingAdapter;
@@ -103,6 +103,13 @@ pub fn teardown() {
*guard = None; *guard = None;
} }
crate::search_cache_qt::teardown(); crate::search_cache_qt::teardown();
next_page_version();
*PAGE.lock().unwrap_or_else(|e| e.into_inner()) = None;
crate::search_bridge::ui(|mut b| {
if PAGE.lock().unwrap_or_else(|e| e.into_inner()).is_none() {
b.as_mut().set_search_json(QString::from("{}"));
}
});
next_cort_version(); next_cort_version();
*LAST_CORT.lock().unwrap_or_else(|e| e.into_inner()) = None; *LAST_CORT.lock().unwrap_or_else(|e| e.into_inner()) = None;
*LAST_CORT_LOCAL.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new(); *LAST_CORT_LOCAL.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new();
@@ -321,7 +328,6 @@ fn year_of(date: Option<&str>) -> String {
date.and_then(|d| d.get(0..4)).unwrap_or("").to_string() date.and_then(|d| d.get(0..4)).unwrap_or("").to_string()
} }
fn map_album(album: &Album) -> CardRow { fn map_album(album: &Album) -> CardRow {
CardRow { CardRow {
// Pin badge state from the per-user store (search.rs stamps it the // Pin badge state from the per-user store (search.rs stamps it the
@@ -2170,6 +2176,10 @@ pub struct MostPopularDoc {
#[derive(Clone, Default, Serialize)] #[derive(Clone, Default, Serialize)]
pub struct SearchPageDoc { pub struct SearchPageDoc {
#[serde(rename = "localRevision")]
pub local_revision: String,
#[serde(rename = "localSections")]
pub local_sections: Vec<CortSection>,
pub query: String, pub query: String,
pub tab: i32, pub tab: i32,
pub loading: bool, pub loading: bool,
@@ -2196,6 +2206,7 @@ pub struct SearchPageDoc {
#[derive(Default)] #[derive(Default)]
struct PageState { struct PageState {
doc: SearchPageDoc, doc: SearchPageDoc,
local: crate::search_local::PageSnapshot,
} }
static PAGE: Mutex<Option<PageState>> = Mutex::new(None); static PAGE: Mutex<Option<PageState>> = Mutex::new(None);
@@ -2339,8 +2350,16 @@ pub(crate) fn apply_favorite_change(kind: &str, id: &str, favorite: bool) {
fn publish_page(doc: &SearchPageDoc) { fn publish_page(doc: &SearchPageDoc) {
let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into()); let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into());
let revision = doc.local_revision.clone();
crate::search_bridge::ui(move |mut b| { crate::search_bridge::ui(move |mut b| {
b.as_mut().set_search_json(QString::from(json.as_str())); let current = PAGE
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.is_some_and(|p| p.doc.local_revision == revision);
if current {
b.as_mut().set_search_json(QString::from(json.as_str()));
}
}); });
} }
@@ -2387,52 +2406,50 @@ async fn submit_page(
} }
{ {
let mut guard = PAGE.lock().unwrap(); let mut guard = PAGE.lock().unwrap();
let doc = &mut guard.get_or_insert_with(PageState::default).doc; if !is_current_page_version(version) {
if crate::kiosk_profile_qt::active() && doc.query != q { return;
// A different query must not expose the previous result set while loading.
doc.albums.clear();
doc.tracks.clear();
doc.artists.clear();
doc.artists_carousel.clear();
doc.playlists.clear();
doc.albums_total = 0;
doc.tracks_total = 0;
doc.artists_total = 0;
doc.playlists_total = 0;
doc.most_popular = MostPopularDoc::default();
} }
doc.query = q.clone(); let page = guard.get_or_insert_with(PageState::default);
doc.tab = tab.unwrap_or(0); *page = PageState::default();
doc.loading = true; page.doc.query = q.clone();
publish_page(doc); page.doc.tab = tab.unwrap_or(0);
page.doc.loading = true;
page.doc.local_revision = version.to_string();
publish_page(&page.doc);
} }
let t = std::time::Instant::now(); let t = std::time::Instant::now();
// Blacklist filtering happens INSIDE search_all (`search.rs:1056-1059`). // Blacklist filtering happens INSIDE search_all (`search.rs:1056-1059`).
let (bl, abl) = blacklist_snapshots(); let (bl, abl) = blacklist_snapshots();
let results = runtime.core().search_all(&q, &bl, &abl).await; let (results, local_rows) = tokio::join!(
runtime.core().search_all(&q, &bl, &abl),
crate::search_local::load_cortinilla_local(q.clone(), 136, false),
);
if !is_current_page_version(version) { if !is_current_page_version(version) {
return; return;
} }
let local = crate::search_local::PageSnapshot::new(&q, local_rows);
// Local search has no Qobuz-auth prerequisite. A catalog error must not
// clear successful local results or their exact playback snapshot.
let results = match results { let results = match results {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
log::warn!("[qbz-qt] search failed: {e}"); log::warn!("[qbz-qt] Qobuz search failed; keeping local results: {e}");
let mut guard = PAGE.lock().unwrap(); fn empty<T>() -> qbz_models::SearchResultsPage<T> {
let doc = &mut guard.get_or_insert_with(PageState::default).doc; qbz_models::SearchResultsPage {
doc.loading = false; items: vec![],
doc.albums.clear(); total: 0,
doc.tracks.clear(); offset: 0,
doc.artists.clear(); limit: 0,
doc.artists_carousel.clear(); }
doc.playlists.clear(); }
doc.albums_total = 0; SearchAllResults {
doc.tracks_total = 0; albums: empty(),
doc.artists_total = 0; tracks: empty(),
doc.playlists_total = 0; artists: empty(),
doc.most_popular = MostPopularDoc::default(); playlists: empty(),
publish_page(doc); most_popular: None,
return; }
} }
}; };
log::info!( log::info!(
@@ -2524,7 +2541,20 @@ async fn submit_page(
let doc = { let doc = {
let mut guard = PAGE.lock().unwrap(); let mut guard = PAGE.lock().unwrap();
let doc = &mut guard.get_or_insert_with(PageState::default).doc; if !is_current_page_version(version) {
return;
}
let page = guard.get_or_insert_with(PageState::default);
page.local = local;
let doc = &mut page.doc;
doc.local_sections = page.local.data.sections.clone();
missing.extend(attach_urls(
doc.local_sections
.iter_mut()
.flat_map(|s| &mut s.rows)
.map(|r| (r.art_url.clone(), &mut r.art_path))
.collect(),
));
doc.loading = false; doc.loading = false;
doc.albums = albums; doc.albums = albums;
doc.tracks = tracks; doc.tracks = tracks;
@@ -2554,6 +2584,9 @@ async fn submit_page(
} }
let doc = { let doc = {
let mut guard = PAGE.lock().unwrap(); let mut guard = PAGE.lock().unwrap();
if !is_current_page_version(version) {
return;
}
let doc = &mut guard.get_or_insert_with(PageState::default).doc; let doc = &mut guard.get_or_insert_with(PageState::default).doc;
let mut missing2 = Vec::new(); let mut missing2 = Vec::new();
missing2.extend(attach_urls( missing2.extend(attach_urls(
@@ -2603,6 +2636,13 @@ async fn submit_page(
if let Some(t) = &mut doc.most_popular.track { if let Some(t) = &mut doc.most_popular.track {
let _ = attach_urls(vec![(t.art_url.clone(), &mut t.art_path)]); let _ = attach_urls(vec![(t.art_url.clone(), &mut t.art_path)]);
} }
let _ = attach_urls(
doc.local_sections
.iter_mut()
.flat_map(|s| &mut s.rows)
.map(|r| (r.art_url.clone(), &mut r.art_path))
.collect(),
);
let _ = missing2; let _ = missing2;
doc.clone() doc.clone()
}; };
@@ -2611,6 +2651,90 @@ async fn submit_page(
} }
} }
/// Resolve only the local snapshot that produced the visible page.
pub fn local_page_action(revision: &str, index: i32, action: &str) {
let (row, tracks) = {
let guard = PAGE.lock().unwrap_or_else(|e| e.into_inner());
let Some(page) = guard.as_ref().filter(|p| p.doc.local_revision == revision) else {
return;
};
let Some(row) = page.local.row(index) else {
return;
};
(row.clone(), page.local.tracks.clone())
};
if action == "open" {
match row.kind.as_str() {
"album" => crate::open_album(row.id),
"artist" => {
crate::navigate_to("local");
crate::local_album_actions::open_artist_by_name(row.title);
}
_ => {}
}
return;
}
if action == "open-album" && row.kind == "track" && !row.album_id.is_empty() {
crate::open_album(row.album_id);
return;
}
let start = row.id.parse::<usize>().ok().filter(|i| *i < tracks.len());
if action == "add-to-playlist" && row.kind == "track" {
if let Some(start) = start {
crate::local_album_actions::open_picker_for_rows(&tracks[start..start + 1]);
}
return;
}
if !matches!(action, "play" | "next" | "later" | "queue") {
return;
}
let runtime = crate::app();
let action = action.to_string();
crate::spawn(async move {
match row.kind.as_str() {
"album" if action == "play" => {
crate::local_playback::play_album(&runtime, row.id, None, false).await;
}
"album" => {
crate::local_playback::enqueue(&runtime, "album".into(), row.id, action).await;
}
"track" => {
if let Some(start) = start {
if action == "play" {
crate::local_playback::play_rows(&runtime, tracks, start, false).await;
} else {
crate::local_playback::enqueue_rows(
&runtime,
vec![tracks[start].clone()],
action,
)
.await;
}
}
}
_ => {}
}
});
}
pub fn local_page_more(revision: &str, kind: &str) {
let query = {
let guard = PAGE.lock().unwrap_or_else(|e| e.into_inner());
let Some(page) = guard.as_ref().filter(|p| p.doc.local_revision == revision) else {
return;
};
page.doc.query.clone()
};
let tab = match kind {
"local-album" => "albums",
"local-artist" => "artists",
"local" => "tracks",
_ => return,
};
crate::local_album_actions::set_pending_route(tab, if tab == "tracks" { &query } else { "" });
crate::navigate_to("local");
}
/// Tab strip: pure view state (search_all already loaded everything). /// Tab strip: pure view state (search_all already loaded everything).
pub fn tab_changed(tab: i32) { pub fn tab_changed(tab: i32) {
let mut guard = PAGE.lock().unwrap(); let mut guard = PAGE.lock().unwrap();
+111 -281
View File
@@ -20,10 +20,10 @@
//! ([`pick_and_add_folder`]). Kept as a record because the note was load //! ([`pick_and_add_folder`]). Kept as a record because the note was load
//! bearing: it justified a downgrade for weeks after its premise expired. //! bearing: it justified a downgrade for weeks after its premise expired.
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex};
use qbz_library::{LibraryDatabase, ScanEvent}; use qbz_library::LibraryDatabase;
use serde::Serialize; use serde::Serialize;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -212,66 +212,11 @@ fn fs_type_index(label: Option<&str>) -> i32 {
// Live state // Live state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static SCANNING: AtomicBool = AtomicBool::new(false);
static CANCEL: AtomicBool = AtomicBool::new(false);
static PROCESSED: AtomicU32 = AtomicU32::new(0);
static TOTAL: AtomicU32 = AtomicU32::new(0);
static SOURCE_PROCESSED: AtomicU32 = AtomicU32::new(0);
static SOURCE_TOTAL: AtomicU32 = AtomicU32::new(0);
static SOURCE_BASE: AtomicU32 = AtomicU32::new(0);
static CURRENT_ROOT_ID: AtomicI64 = AtomicI64::new(0);
static SOURCE_INDEX: AtomicU32 = AtomicU32::new(0);
static SOURCE_COUNT: AtomicU32 = AtomicU32::new(0);
static CURRENT_FILE: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static CLEANING: AtomicBool = AtomicBool::new(false); static CLEANING: AtomicBool = AtomicBool::new(false);
static CLEANUP_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new())); static CLEANUP_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static CLEARING: AtomicBool = AtomicBool::new(false); static CLEARING: AtomicBool = AtomicBool::new(false);
static STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new())); static STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
#[derive(Default)]
struct PendingScans {
all: bool,
folder_ids: std::collections::BTreeSet<i64>,
}
enum PendingScan {
All,
Folder(i64),
}
impl PendingScans {
fn push(&mut self, folder_id: Option<i64>) {
match folder_id {
None => {
self.all = true;
self.folder_ids.clear();
}
Some(id) if !self.all => {
self.folder_ids.insert(id);
}
Some(_) => {}
}
}
fn pop(&mut self) -> Option<PendingScan> {
if self.all {
self.all = false;
self.folder_ids.clear();
Some(PendingScan::All)
} else {
self.folder_ids.pop_first().map(PendingScan::Folder)
}
}
fn clear(&mut self) {
self.all = false;
self.folder_ids.clear();
}
}
static PENDING_SCANS: LazyLock<Mutex<PendingScans>> =
LazyLock::new(|| Mutex::new(PendingScans::default()));
/// Last known accessibility per NETWORK folder id, filled by /// Last known accessibility per NETWORK folder id, filled by
/// [`spawn_accessibility_probes`]. /// [`spawn_accessibility_probes`].
/// ///
@@ -297,11 +242,8 @@ fn set_status(text: String) {
/// FIRST folder is exactly the case that has to create it, so this module /// FIRST folder is exactly the case that has to create it, so this module
/// owns a creating opener. /// owns a creating opener.
fn open_db() -> Option<LibraryDatabase> { fn open_db() -> Option<LibraryDatabase> {
let path = crate::local_state::db_path()?; let store = qbz_library::LibraryStore::new(crate::local_state::db_path()?);
if let Some(parent) = path.parent() { match store.open_or_create() {
let _ = std::fs::create_dir_all(parent);
}
match LibraryDatabase::open(&path) {
Ok(db) => Some(db), Ok(db) => Some(db),
Err(e) => { Err(e) => {
log::error!("[qbz-qt] library db open failed: {e}"); log::error!("[qbz-qt] library db open failed: {e}");
@@ -355,21 +297,25 @@ pub fn snapshot() -> Snapshot {
}) })
.collect(); .collect();
let progress = crate::local_service_qt::current()
.map(|h| h.service.snapshot().progress)
.unwrap_or_default();
let plex_cfg = crate::local_plex::settings(); let plex_cfg = crate::local_plex::settings();
Snapshot { Snapshot {
folders, folders,
scanning: SCANNING.load(Ordering::SeqCst), scanning: progress.running,
processed: PROCESSED.load(Ordering::SeqCst) as i32, processed: progress.processed as i32,
total: TOTAL.load(Ordering::SeqCst) as i32, total: progress.total as i32,
source_processed: SOURCE_PROCESSED.load(Ordering::SeqCst) as i32, source_processed: progress.source_processed as i32,
source_total: SOURCE_TOTAL.load(Ordering::SeqCst) as i32, source_total: progress.source_total as i32,
current_root_id: CURRENT_ROOT_ID.load(Ordering::SeqCst), current_root_id: progress.current_root_id,
source_index: SOURCE_INDEX.load(Ordering::SeqCst) as i32, source_index: progress.source_index as i32,
source_count: SOURCE_COUNT.load(Ordering::SeqCst) as i32, source_count: progress.source_count as i32,
file: CURRENT_FILE file: if progress.cleaning {
.lock() qbz_i18n::t("Cleaning up missing files...")
.unwrap_or_else(|e| e.into_inner()) } else {
.clone(), progress.file
},
cleaning: CLEANING.load(Ordering::SeqCst), cleaning: CLEANING.load(Ordering::SeqCst),
cleanup_status: CLEANUP_STATUS cleanup_status: CLEANUP_STATUS
.lock() .lock()
@@ -756,230 +702,114 @@ pub async fn toggle_folder_enabled(id: i64) {
// Scan // Scan
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Scan every enabled folder (`None`) or exactly one (`Some(id)`). A request /// Queue work on the captured host. Qt only translates/publishes its snapshot;
/// arriving during an active scan is coalesced and run afterwards. /// all scheduling, cancellation and progress now belong to qbz-library.
///
/// Progress rides the statics; a short ticker republishes the settings document
/// while the scan runs (the document is the only transport this port has, and
/// republishing per FILE would rebuild the whole snapshot thousands of times).
pub fn scan(folder_id: Option<i64>) -> bool { pub fn scan(folder_id: Option<i64>) -> bool {
if SCANNING.swap(true, Ordering::SeqCst) { let Some(host) = crate::local_service_qt::current() else {
PENDING_SCANS return false;
.lock() };
.unwrap_or_else(|e| e.into_inner()) scan_for(host, folder_id)
.push(folder_id); }
/// Background observers carry the same captured binding as their root IDs.
/// A profile switch can close this host, but can never retarget the request.
pub(crate) fn scan_for(
host: std::sync::Arc<crate::local_service_qt::DesktopLibrary>,
folder_id: Option<i64>,
) -> bool {
if !crate::local_service_qt::is_current(&host) {
return false;
}
let before = host.service.snapshot();
match host.service.scan(folder_id) {
Ok(true) => {}
Ok(false) => return false,
Err(error) => {
log::error!("[qbz-qt] library scan start failed: {error}");
set_status(qbz_i18n::t("Scan failed."));
return false;
}
}
if !host.begin_observing() {
return true; return true;
} }
CANCEL.store(false, Ordering::SeqCst);
PROCESSED.store(0, Ordering::SeqCst);
TOTAL.store(0, Ordering::SeqCst);
SOURCE_PROCESSED.store(0, Ordering::SeqCst);
SOURCE_TOTAL.store(0, Ordering::SeqCst);
SOURCE_BASE.store(0, Ordering::SeqCst);
CURRENT_ROOT_ID.store(0, Ordering::SeqCst);
SOURCE_INDEX.store(0, Ordering::SeqCst);
SOURCE_COUNT.store(0, Ordering::SeqCst);
*CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = String::new();
crate::spawn(async move { crate::spawn(async move {
// Paint the scanning state immediately. The ticker deliberately stays let mut revision = before.revision;
// independent of per-file events so a large library never turns QML let mut completed = before.last_scan.map(|s| s.job).unwrap_or(0);
// publication into work proportional to its track count. loop {
super::publish_snapshot().await; if !crate::local_service_qt::is_current(&host) {
// Progress ticker. break;
crate::spawn(async {
while SCANNING.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
super::publish_snapshot().await;
} }
}); let snapshot = host.service.snapshot();
if let Some(result) = snapshot.last_scan.as_ref().filter(|s| s.job > completed) {
let _ = tokio::task::spawn_blocking(move || { completed = result.job;
let Some(db) = open_db() else { scan_finished(result);
return;
};
let cache = qbz_library::get_artwork_cache_dir();
let ids = folder_id.map(|id| vec![id]);
let source_count = db
.get_folders_with_metadata()
.map(|folders| {
folders
.into_iter()
.filter(|folder| {
folder.enabled
&& ids
.as_ref()
.map_or(true, |wanted| wanted.contains(&folder.id))
})
.count()
})
.unwrap_or(0);
SOURCE_COUNT.store(source_count.min(u32::MAX as usize) as u32, Ordering::SeqCst);
let on_event = move |event: ScanEvent| match event {
ScanEvent::TotalsAdded { total } => {
TOTAL.store(total, Ordering::SeqCst);
SOURCE_TOTAL.store(
total.saturating_sub(SOURCE_BASE.load(Ordering::SeqCst)),
Ordering::SeqCst,
);
}
ScanEvent::FileStarted { path } => {
let name = basename(&path);
*CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = name;
}
ScanEvent::FileDone { processed, total } => {
PROCESSED.store(processed, Ordering::SeqCst);
TOTAL.store(total, Ordering::SeqCst);
let base = SOURCE_BASE.load(Ordering::SeqCst);
SOURCE_PROCESSED.store(processed.saturating_sub(base), Ordering::SeqCst);
SOURCE_TOTAL.store(total.saturating_sub(base), Ordering::SeqCst);
}
ScanEvent::RootStarted { root_id, .. } => {
CURRENT_ROOT_ID.store(root_id, Ordering::SeqCst);
// `TOTAL`, rather than processed, is the next root's
// streaming offset even when a malformed file in the
// previous root was discovered but could not be emitted.
SOURCE_BASE.store(TOTAL.load(Ordering::SeqCst), Ordering::SeqCst);
SOURCE_PROCESSED.store(0, Ordering::SeqCst);
SOURCE_TOTAL.store(0, Ordering::SeqCst);
SOURCE_INDEX.fetch_add(1, Ordering::SeqCst);
}
ScanEvent::RootFinished {
root_id,
discovered,
..
} => {
// An unavailable root finishes without `RootStarted`
// (the scanner cannot prepare a generation for it). Keep
// the source ordinal/name honest instead of leaving the
// previous folder painted for this slot.
if CURRENT_ROOT_ID.load(Ordering::SeqCst) != root_id {
CURRENT_ROOT_ID.store(root_id, Ordering::SeqCst);
SOURCE_BASE.store(TOTAL.load(Ordering::SeqCst), Ordering::SeqCst);
SOURCE_INDEX.fetch_add(1, Ordering::SeqCst);
}
let value = discovered.min(u32::MAX as u64) as u32;
SOURCE_PROCESSED.store(value, Ordering::SeqCst);
SOURCE_TOTAL.store(value, Ordering::SeqCst);
}
// The missing-file cleanup phase. The reference puts it in the
// SAME slot the per-file name occupies, so the progress line
// keeps saying something while no file name is flowing
// (local_library_settings.rs:738-743). This port dropped the
// variant, so the scan looked stalled on its last filename
// for the whole cleanup.
ScanEvent::Cleanup => {
*CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) =
qbz_i18n::t("Cleaning up missing files...");
}
// Terminal. Until 2026-08-04 this only logged, so a scan that
// failed or was cancelled looked exactly like one that worked.
// Same three outcomes and the same strings as the reference
// (:744-775).
ScanEvent::Finished { status, errors } => {
let n = errors.len();
if CLEANING.load(Ordering::SeqCst) {
*CLEANUP_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = match &status {
qbz_library::ScanStatus::Complete => qbz_i18n::t("Scan complete"),
qbz_library::ScanStatus::Cancelled => qbz_i18n::t("Scan cancelled"),
_ => qbz_i18n::t("Cleanup failed."),
};
}
match status {
qbz_library::ScanStatus::Complete if n > 0 => {
log::warn!("[qbz-qt] library scan finished with {n} errors");
crate::toast_qt::success(qbz_i18n::tf(
"Scan complete ({} file skipped)",
"Scan complete ({} files skipped)",
n as i64,
&[&n.to_string()],
));
}
qbz_library::ScanStatus::Complete => {
crate::toast_qt::success(qbz_i18n::t("Scan complete"));
}
qbz_library::ScanStatus::Cancelled => {
crate::toast_qt::success(qbz_i18n::t("Scan cancelled"));
}
_ => {
log::error!("[qbz-qt] library scan failed ({n} errors)");
crate::toast_qt::error(qbz_i18n::t("Scan failed"));
}
}
*CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = String::new();
}
_ => {}
};
if let Err(e) =
qbz_library::scan_with_progress(&db, ids.as_deref(), &cache, &CANCEL, &on_event)
{
log::error!("[qbz-qt] library scan failed: {e}");
if CLEANING.load(Ordering::SeqCst) {
*CLEANUP_STATUS.lock().unwrap_or_else(|e| e.into_inner()) =
qbz_i18n::t("Cleanup failed.");
}
set_status(qbz_i18n::t("Scan failed."));
} }
}) if snapshot.revision != revision {
.await; revision = snapshot.revision;
crate::local_catalog_qt::request_catch_up();
SCANNING.store(false, Ordering::SeqCst); refresh_browse();
CLEANING.store(false, Ordering::SeqCst);
*CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = String::new();
crate::local_catalog_qt::request_catch_up();
refresh_browse();
super::publish_snapshot().await;
let pending = PENDING_SCANS
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop();
match pending {
Some(PendingScan::All) => {
scan(None);
} }
Some(PendingScan::Folder(id)) => { if !snapshot.progress.running {
scan(Some(id)); CLEANING.store(false, Ordering::SeqCst);
} }
None => {} super::publish_snapshot().await;
if !snapshot.progress.running {
host.stop_observing();
// A request may arrive as this observer exits. Either it owns
// the next ticker or this ticker resumes; no progress is lost.
if !host.service.snapshot().progress.running || !host.begin_observing() {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
} }
host.stop_observing();
}); });
true true
} }
/// "Stop" — checked at every file boundary by the shared engine. fn scan_finished(result: &qbz_library::service::ScanCompletion) {
use qbz_library::service::ScanOutcome;
if CLEANING.load(Ordering::SeqCst) {
*CLEANUP_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = match result.outcome {
ScanOutcome::Complete => qbz_i18n::t("Scan complete"),
ScanOutcome::Cancelled => qbz_i18n::t("Scan cancelled"),
ScanOutcome::Failed => qbz_i18n::t("Cleanup failed."),
};
}
match result.outcome {
ScanOutcome::Complete if result.skipped > 0 => crate::toast_qt::success(qbz_i18n::tf(
"Scan complete ({} file skipped)",
"Scan complete ({} files skipped)",
result.skipped as i64,
&[&result.skipped.to_string()],
)),
ScanOutcome::Complete => crate::toast_qt::success(qbz_i18n::t("Scan complete")),
ScanOutcome::Cancelled => crate::toast_qt::success(qbz_i18n::t("Scan cancelled")),
ScanOutcome::Failed => {
set_status(qbz_i18n::t("Scan failed."));
crate::toast_qt::error(qbz_i18n::t("Scan failed"));
}
}
}
pub fn stop_scan() { pub fn stop_scan() {
CANCEL.store(true, Ordering::SeqCst); if let Some(host) = crate::local_service_qt::current() {
PENDING_SCANS host.service.cancel_scan();
}
}
pub fn reset_profile_state() {
CLEANING.store(false, Ordering::SeqCst);
CLEARING.store(false, Ordering::SeqCst);
CLEANUP_STATUS
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.clear(); .clear();
} STATUS.lock().unwrap_or_else(|e| e.into_inner()).clear();
ACCESSIBLE.lock().unwrap_or_else(|e| e.into_inner()).clear();
#[cfg(test)] *EDIT.lock().unwrap_or_else(|e| e.into_inner()) = FolderEdit::default();
mod pending_scan_tests {
use super::{PendingScan, PendingScans};
#[test]
fn directed_scans_are_deduplicated_and_kept() {
let mut queue = PendingScans::default();
queue.push(Some(9));
queue.push(Some(9));
queue.push(Some(4));
assert!(matches!(queue.pop(), Some(PendingScan::Folder(4))));
assert!(matches!(queue.pop(), Some(PendingScan::Folder(9))));
assert!(queue.pop().is_none());
}
#[test]
fn full_scan_coalesces_every_directed_refresh() {
let mut queue = PendingScans::default();
queue.push(Some(1));
queue.push(None);
queue.push(Some(2));
assert!(matches!(queue.pop(), Some(PendingScan::All)));
assert!(queue.pop().is_none());
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2
View File
@@ -13,6 +13,8 @@ path = "src/main.rs"
# workspace crates shared by the daemon and the Qt application # workspace crates shared by the daemon and the Qt application
qbz-models = { path = "../qbz-models" } qbz-models = { path = "../qbz-models" }
qbz-app = { path = "../qbz-app" } qbz-app = { path = "../qbz-app" }
qbz-library = { path = "../qbz-library" }
qbz-control = { path = "../qbz-control" }
qbz-core = { path = "../qbz-core" } qbz-core = { path = "../qbz-core" }
qbz-player = { path = "../qbz-player" } qbz-player = { path = "../qbz-player" }
qbz-audio = { path = "../qbz-audio" } # PROTECTED — read-only consumer qbz-audio = { path = "../qbz-audio" } # PROTECTED — read-only consumer
+42 -478
View File
@@ -1,120 +1,27 @@
// crates/qbzd/src/api/mod.rs — the HTTP control plane (02-cli-and-api.md §3). //! Daemon host for the shared QBZ API. Playback, queue, settings and authority
// //! remain here; HTTP access control, serving and catalog handlers are shared.
// tiny_http 0.12, single listener, thread-per-connection collapsed to ONE
// serving thread (requests are handled inline, serialized — a control plane
// sees a handful of clients, never a load). Two mechanisms qualify the
// otherwise-open surface (§3.1.2):
// - Origin shield (ALWAYS on): any request carrying an `Origin` header is
// refused `403 origin_forbidden` before routing — CLI/curl/scripts send no
// `Origin`, browsers do (CSRF / DNS-rebinding guard at zero UX cost).
// - Opt-in `[server] token`: when set, `Authorization: Bearer <token>` is
// required on every route EXCEPT `GET /api/ping`; a mismatch is
// `401 invalid_token`. Unset = no auth machinery exists at runtime.
//
// The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant
// diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is
// what keeps the 01-architecture.md §8.1 boot order honest.
pub mod browse;
pub mod discover;
pub mod fav;
pub mod lyrics;
pub mod play; pub mod play;
pub mod playback; pub mod playback;
pub mod playlist;
pub mod queue; pub mod queue;
pub mod radio; pub mod radio;
pub mod reco;
pub mod search;
pub mod artwork;
pub mod settings; pub mod settings;
pub mod sse;
pub mod status; pub mod status;
use std::io::Cursor; use std::io::Cursor;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant; use std::time::Instant;
use tiny_http::{Request, Response};
use tiny_http::{Header, Method, Request, Response};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::adapter::DaemonAdapter; use crate::adapter::DaemonAdapter;
use crate::paths::ProfileRoots; use crate::paths::ProfileRoots;
use crate::state::DaemonShared; use crate::state::DaemonShared;
use qbz_app::shell::AppRuntime; use qbz_app::shell::AppRuntime;
use qbz_audio::settings::AudioSettingsStore; use qbz_audio::settings::AudioSettingsStore;
use qbz_models::CoreEvent; use qbz_models::CoreEvent;
pub use qbz_control::{bind, serve, BindError};
/// The counted P0 route table (02 §3.2) — 17, FINAL, this test now guards the pub(crate) use qbz_control::{canon_volume, err_json, error_body, json};
/// budget forever. A route exists only iff a shipped client calls it (§3.1.4). use qbz_control::{artwork, browse, discover, fav, lyrics, playlist, reco, search};
/// T1-T6 landed the first 3; T7 added the 9 playback + now-playing routes
/// (rows 4-12); T8 added the 4 queue routes (rows 13-16); T11 (this task)
/// adds `/api/settings/reload` (row 17) — the inline
/// `route_table_matches_spec_count` test pins the number so the 68-routes
/// failure shape (a route with no caller) cannot creep back in.
pub const P0_ROUTES: &[(&str, &str)] = &[
("GET", "/api/ping"),
("GET", "/api/info"),
("GET", "/api/status"),
("GET", "/api/now-playing"),
("POST", "/api/playback/play"),
("POST", "/api/playback/pause"),
("POST", "/api/playback/toggle"),
("POST", "/api/playback/stop"),
("POST", "/api/playback/next"),
("POST", "/api/playback/previous"),
("POST", "/api/playback/seek"),
("POST", "/api/playback/volume"),
("GET", "/api/queue"),
("POST", "/api/queue/add"),
("POST", "/api/queue/remove"),
("POST", "/api/queue/clear"),
("POST", "/api/settings/reload"),
];
/// The P1 route table (02 §3.4) — the content-verb surface. SAME HARD RULE as
/// P0 (§3.1.4): a route exists only iff a shipped CLI/TUI client calls it, and
/// this table grows one row per verb+route pair in the SAME change as the verb.
/// Row 19 (this change): `GET /api/search` — caller: `qbzd search`. The
/// `p1_route_table_grows_only_with_a_shipped_caller` test pins the count and
/// forbids overlap with P0, so the 68-routes failure shape cannot creep in
/// through the P1 door either.
pub const P1_ROUTES: &[(&str, &str)] = &[
("GET", "/api/search"),
("POST", "/api/play"),
("GET", "/api/album"),
("GET", "/api/artist"),
("GET", "/api/similar"),
("GET", "/api/suggest"),
("GET", "/api/discover"),
("GET", "/api/lyrics"),
("POST", "/api/radio"),
("POST", "/api/reco/playlist"),
("POST", "/api/playback/shuffle"),
("POST", "/api/playback/repeat"),
("POST", "/api/queue/move"),
("POST", "/api/queue/jump"),
("POST", "/api/queue/stop-after"),
("GET", "/api/favorites"),
("POST", "/api/favorites/add"),
("POST", "/api/favorites/remove"),
("GET", "/api/playlists"),
("GET", "/api/playlist"),
("POST", "/api/playlist/create"),
("POST", "/api/playlist/update"),
("POST", "/api/playlist/delete"),
("POST", "/api/playlist/tracks/add"),
("POST", "/api/playlist/tracks/remove"),
("GET", "/api/events"),
("GET", "/api/artwork/current"),
];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
/// in an `Arc` so the serving thread and the shutdown handle can both hold it
/// (`unblock` from the handle terminates the thread's `incoming_requests`).
pub struct BoundServer {
server: Arc<tiny_http::Server>,
}
/// Everything the route handlers read. Owned by the single serving thread /// Everything the route handlers read. Owned by the single serving thread
/// (moved into it by [`serve`]), so it only needs `Send`, never `Sync` — which /// (moved into it by [`serve`]), so it only needs `Send`, never `Sync` — which
@@ -122,6 +29,8 @@ pub struct BoundServer {
/// can live here directly. `token` is the opt-in `[server] token`, read once at /// can live here directly. `token` is the opt-in `[server] token`, read once at
/// boot (`None` = open). /// boot (`None` = open).
pub struct ApiState { pub struct ApiState {
/// Absent unless run --orbit. Uses daemon roots and this server's access gate.
pub library: Option<qbz_control::library::LibraryEndpoint>,
pub runtime: Arc<AppRuntime<DaemonAdapter>>, pub runtime: Arc<AppRuntime<DaemonAdapter>>,
pub shared: Arc<Mutex<DaemonShared>>, pub shared: Arc<Mutex<DaemonShared>>,
/// The CoreEvent bus (DaemonAdapter sender). `/api/events` subscribes a /// The CoreEvent bus (DaemonAdapter sender). `/api/events` subscribes a
@@ -164,56 +73,14 @@ pub struct DeviceCache {
pub names: Vec<String>, pub names: Vec<String>,
} }
/// Live serving handle. [`ApiHandle::shutdown`] unblocks the serving thread and impl qbz_control::HttpHost for ApiState {
/// joins it — dropping the thread's `ApiState` (and with it the `Arc<AppRuntime>` fn token(&self) -> Option<&str> { self.token.as_deref() }
/// clone) BEFORE the daemon drops the runtime, preserving the §8.2 audio fn events(&self) -> &broadcast::Sender<CoreEvent> { &self.bus }
/// clock-release ordering (the API thread is one more `Arc<AppRuntime>` holder, fn route(&self, request: &mut Request) -> Response<Cursor<Vec<u8>>> {
/// exactly like the driver and auth-retry tasks). route(self, request)
pub struct ApiHandle {
server: Arc<tiny_http::Server>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl ApiHandle {
pub fn shutdown(mut self) {
self.server.unblock();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
} }
} }
/// Why a bind failed — `AddrInUse` is the case the boot step-5 diagnosis probes
/// (foreign qbzd vs another process); everything else is a generic fatal.
#[derive(Debug)]
pub enum BindError {
AddrInUse(SocketAddr),
Other(String),
}
/// Boot step 5 (01 §8.1): bind only — stateless, so the foreign-occupant
/// diagnosis (in `daemon.rs`) runs BEFORE stores (6) and runtime composition (7).
pub fn bind(addr: SocketAddr) -> Result<BoundServer, BindError> {
match tiny_http::Server::http(addr) {
Ok(server) => Ok(BoundServer {
server: Arc::new(server),
}),
Err(e) => Err(classify_bind_error(e, addr)),
}
}
fn classify_bind_error(
e: Box<dyn std::error::Error + Send + Sync + 'static>,
addr: SocketAddr,
) -> BindError {
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::AddrInUse {
return BindError::AddrInUse(addr);
}
}
BindError::Other(e.to_string())
}
/// Best-effort occupant probe for the step-5 diagnosis: `GET /api/ping` and /// Best-effort occupant probe for the step-5 diagnosis: `GET /api/ping` and
/// check the response identifies as qbzd (`"app":"qbzd"`). Loopback, short /// check the response identifies as qbzd (`"app":"qbzd"`). Loopback, short
/// timeout, no dependency on reqwest (the CLI's async client is not built here). /// timeout, no dependency on reqwest (the CLI's async client is not built here).
@@ -240,80 +107,23 @@ pub fn probe_is_qbzd(addr: SocketAddr) -> bool {
text.contains("\"app\":\"qbzd\"") text.contains("\"app\":\"qbzd\"")
} }
/// Boot step 11 (01 §8.1): start serving on the already-bound socket. Requests
/// are handled inline on one thread (serialized). `unblock` (from the handle)
/// ends `incoming_requests` for graceful shutdown.
pub fn serve(server: BoundServer, state: ApiState) -> ApiHandle {
// The counted P0 surface — logged at boot so an operator sees exactly how
// many routes this build exposes (and it anchors the const to production).
log::info!(
"control plane serving {} route(s) ({} P0 + {} P1)",
P0_ROUTES.len() + P1_ROUTES.len(),
P0_ROUTES.len(),
P1_ROUTES.len()
);
let srv = server.server;
let srv_handle = srv.clone();
let thread = std::thread::Builder::new()
.name("qbzd-api".into())
.spawn(move || {
for mut req in srv.incoming_requests() {
// `/api/events` is a long-lived SSE stream: it would block this
// single serving thread forever. Move it onto its OWN thread
// (Request is Send) so the control plane keeps answering. The
// origin/token gate is applied first, identically to `route`.
let is_events = *req.method() == Method::Get
&& req.url().split('?').next() == Some("/api/events");
if is_events {
let has_origin = req.headers().iter().any(|h| h.field.equiv("Origin"));
let auth = req
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"))
.map(|h| h.value.as_str().to_owned());
if let Some(reject) =
access_gate(has_origin, "GET", "/api/events", auth.as_deref(), state.token.as_deref())
{
let _ = req.respond(reject.response());
continue;
}
let rx = state.bus.subscribe();
std::thread::Builder::new()
.name("qbzd-sse".into())
.spawn(move || sse::stream(req, rx))
.ok();
continue;
}
let resp = route(&state, &mut req);
let _ = req.respond(resp);
}
})
.expect("failed to spawn qbzd api thread");
ApiHandle {
server: srv_handle,
thread: Some(thread),
}
}
fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> { fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let method = req.method().as_str().to_owned(); let method = req.method().as_str().to_owned();
let url = req.url().to_owned(); let url = req.url().to_owned();
let mut url_parts = url.splitn(2, '?'); let mut url_parts = url.splitn(2, '?');
let path = url_parts.next().unwrap_or("").to_owned(); let path = url_parts.next().unwrap_or("").to_owned();
let query = url_parts.next().unwrap_or("").to_owned(); let query = url_parts.next().unwrap_or("").to_owned();
let has_origin = req.headers().iter().any(|h| h.field.equiv("Origin")); if path == "/api/orbit/library" || path.starts_with("/api/orbit/library/") {
let auth_header = req return match &state.library {
.headers() Some(library) => library.route(req),
.iter() None => err_json(404, "not_supported", "Orbit library is not enabled", "start the host with run --orbit"),
.find(|h| h.field.equiv("Authorization")) };
.map(|h| h.value.as_str());
// Origin shield (ALWAYS on) + opt-in [server] token — one pre-routing gate,
// /api/ping exempt from the token only (§3.1.2).
if let Some(reject) = access_gate(has_origin, &method, &path, auth_header, state.token.as_deref())
{
return reject.response();
} }
let catalog = qbz_control::CatalogContext {
core: state.runtime.core(),
rt: &state.rt,
needs_auth: state.shared.lock().map(|s| s.auth == crate::state::AuthState::NeedsAuth).unwrap_or(false),
};
match (method.as_str(), path.as_str()) { match (method.as_str(), path.as_str()) {
("GET", "/api/ping") => json( ("GET", "/api/ping") => json(
@@ -345,56 +155,56 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let body = read_json_body(req); let body = read_json_body(req);
playback::repeat(state, &body) playback::repeat(state, &body)
} }
("GET", "/api/search") => search::search(state, &query), ("GET", "/api/search") => search::search(&catalog, &query),
("POST", "/api/play") => { ("POST", "/api/play") => {
let body = read_json_body(req); let body = read_json_body(req);
play::play(state, &body) play::play(state, &body)
} }
("GET", "/api/album") => browse::album(state, &query), ("GET", "/api/album") => browse::album(&catalog, &query),
("GET", "/api/artist") => browse::artist(state, &query), ("GET", "/api/artist") => browse::artist(&catalog, &query),
("GET", "/api/similar") => browse::similar(state, &query), ("GET", "/api/similar") => browse::similar(&catalog, &query),
("GET", "/api/suggest") => browse::suggest(state, &query), ("GET", "/api/suggest") => browse::suggest(&catalog, &query),
("GET", "/api/discover") => discover::discover(state, &query), ("GET", "/api/discover") => discover::discover(&catalog, &query),
("GET", "/api/lyrics") => lyrics::lyrics(state, &query), ("GET", "/api/lyrics") => lyrics::lyrics(&catalog, &query),
("GET", "/api/artwork/current") => artwork::current(state), ("GET", "/api/artwork/current") => artwork::current(&catalog),
("POST", "/api/radio") => { ("POST", "/api/radio") => {
let body = read_json_body(req); let body = read_json_body(req);
radio::radio(state, &body) radio::radio(state, &body)
} }
("POST", "/api/reco/playlist") => { ("POST", "/api/reco/playlist") => {
let body = read_json_body(req); let body = read_json_body(req);
reco::playlist(state, &body) reco::playlist(&catalog, &body)
} }
("GET", "/api/favorites") => fav::list(state, &query), ("GET", "/api/favorites") => fav::list(&catalog, &query),
("POST", "/api/favorites/add") => { ("POST", "/api/favorites/add") => {
let body = read_json_body(req); let body = read_json_body(req);
fav::add(state, &body) fav::add(&catalog, &body)
} }
("POST", "/api/favorites/remove") => { ("POST", "/api/favorites/remove") => {
let body = read_json_body(req); let body = read_json_body(req);
fav::remove(state, &body) fav::remove(&catalog, &body)
} }
("GET", "/api/playlists") => playlist::list(state), ("GET", "/api/playlists") => playlist::list(&catalog),
("GET", "/api/playlist") => playlist::show(state, &query), ("GET", "/api/playlist") => playlist::show(&catalog, &query),
("POST", "/api/playlist/create") => { ("POST", "/api/playlist/create") => {
let body = read_json_body(req); let body = read_json_body(req);
playlist::create(state, &body) playlist::create(&catalog, &body)
} }
("POST", "/api/playlist/update") => { ("POST", "/api/playlist/update") => {
let body = read_json_body(req); let body = read_json_body(req);
playlist::update(state, &body) playlist::update(&catalog, &body)
} }
("POST", "/api/playlist/delete") => { ("POST", "/api/playlist/delete") => {
let body = read_json_body(req); let body = read_json_body(req);
playlist::delete(state, &body) playlist::delete(&catalog, &body)
} }
("POST", "/api/playlist/tracks/add") => { ("POST", "/api/playlist/tracks/add") => {
let body = read_json_body(req); let body = read_json_body(req);
playlist::tracks_add(state, &body) playlist::tracks_add(&catalog, &body)
} }
("POST", "/api/playlist/tracks/remove") => { ("POST", "/api/playlist/tracks/remove") => {
let body = read_json_body(req); let body = read_json_body(req);
playlist::tracks_remove(state, &body) playlist::tracks_remove(&catalog, &body)
} }
("GET", "/api/queue") => queue::list(state, &query), ("GET", "/api/queue") => queue::list(state, &query),
("POST", "/api/queue/add") => { ("POST", "/api/queue/add") => {
@@ -435,107 +245,6 @@ fn read_json_body(req: &mut Request) -> serde_json::Value {
serde_json::from_str(&buf).unwrap_or(serde_json::Value::Null) serde_json::from_str(&buf).unwrap_or(serde_json::Value::Null)
} }
/// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a
/// pure decision (unit-testable without a tiny_http `Request`, which has no
/// public constructor) while `route` renders the normative envelope.
enum GateReject {
OriginForbidden,
InvalidToken,
}
impl GateReject {
fn response(&self) -> Response<Cursor<Vec<u8>>> {
match self {
GateReject::OriginForbidden => err_json(
403,
"origin_forbidden",
"requests with an Origin header are refused",
"the control plane is not a browser API",
),
GateReject::InvalidToken => err_json(
401,
"invalid_token",
"missing or wrong bearer token",
"set QBZD_TOKEN or check [server] token in qbzd.toml",
),
}
}
}
/// The pre-routing access decision (02 §3.1.2): Origin shield always on; the
/// opt-in Bearer required on every route except `GET /api/ping` when `token`
/// is `Some`. `None` = open (no auth machinery). Returns `Some(_)` to reject.
fn access_gate(
has_origin: bool,
method: &str,
path: &str,
auth_header: Option<&str>,
token: Option<&str>,
) -> Option<GateReject> {
if has_origin {
return Some(GateReject::OriginForbidden);
}
if let Some(secret) = token {
let is_ping = method == "GET" && path == "/api/ping";
let expected = format!("Bearer {secret}");
let ok = auth_header
.map(|v| constant_time_eq(v.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
if !is_ping && !ok {
return Some(GateReject::InvalidToken);
}
}
None
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// Canonical JSON number for a volume level (0.0-1.0). `serde_json::Value`
/// backs f32 via `Number::from_f32`, which stores the value as `f as f64` —
/// the f32→f64 widening turns `0.8f32` into `0.800000011920929` on the wire.
/// 02-cli-and-api.md §2.2/§3.3.4 document plain `0.8`, and `--json` is the
/// frozen machine contract ("scripts parse this"), so every volume-bearing
/// response routes through this instead of a bare `json!(v)`. 3 decimals is
/// plenty of precision for a 0.0-1.0 level.
pub(crate) fn canon_volume(v: f32) -> serde_json::Value {
let rounded = (v as f64 * 1000.0).round() / 1000.0;
serde_json::json!(rounded)
}
/// A 2xx JSON response. `pub(crate)` so the per-route handlers in `status.rs`
/// share the exact same envelope framing.
pub(crate) fn json(status: u16, body: serde_json::Value) -> Response<Cursor<Vec<u8>>> {
let bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
let header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
.expect("static content-type header");
Response::from_data(bytes)
.with_status_code(status)
.with_header(header)
}
/// The normative error envelope (02 §3.1.3): `{"error":{"code","message","hint"}}`.
/// The CLI keys its exit code off `code` (never raw HTTP status), and every hint
/// names the fix (§1.4 error voice). The G0 addendum's shorthand
/// `{"error":"origin_forbidden"}` is this same nested envelope — the uniform
/// §3.1.3 shape the CLI's `error_from_envelope` reads via `error.code`.
pub(crate) fn err_json(
status: u16,
code: &str,
message: &str,
hint: &str,
) -> Response<Cursor<Vec<u8>>> {
json(status, error_body(code, message, hint))
}
/// Refuse owner-only queue and track-selection actions while the QConnect /// Refuse owner-only queue and track-selection actions while the QConnect
/// authority cell belongs to a delegated runtime (or is fenced during an /// authority cell belongs to a delegated runtime (or is fenced during an
/// authority transition). Transport controls that are safe for either origin /// authority transition). Transport controls that are safe for either origin
@@ -605,154 +314,9 @@ fn transport_action_conflict_body() -> serde_json::Value {
) )
} }
pub(crate) fn error_body(code: &str, message: &str, hint: &str) -> serde_json::Value {
serde_json::json!({"error": {"code": code, "message": message, "hint": hint}})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn route_table_matches_spec_count() {
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes, FINAL; grows ONLY
// with a shipped client. T1-T6 landed 3; T7 +9 = 12; T8 +4 = 16; T11
// (this task) +1 = 17.
assert_eq!(P0_ROUTES.len(), 17);
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
assert!(P0_ROUTES.contains(&("GET", "/api/now-playing")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/play")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/pause")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/toggle")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/stop")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/next")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/previous")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/seek")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/volume")));
assert!(P0_ROUTES.contains(&("GET", "/api/queue")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/add")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/remove")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/clear")));
assert!(P0_ROUTES.contains(&("POST", "/api/settings/reload")));
}
#[test]
fn p1_route_table_grows_only_with_a_shipped_caller() {
// 02-cli-and-api.md §3.4 — each P1 route lands with its CLI verb (the
// §3.1.4 HARD RULE, applied to the content-verb door). Row 19:
// GET /api/search — caller: `qbzd search`. Count is pinned so a route
// with no caller cannot creep in; P1 must never overlap P0.
assert_eq!(P1_ROUTES.len(), 27);
assert!(P1_ROUTES.contains(&("GET", "/api/events"))); // caller: `qbzd watch`
assert!(P1_ROUTES.contains(&("GET", "/api/artwork/current"))); // caller: `qbzd art`
assert!(P1_ROUTES.contains(&("GET", "/api/discover")));
assert!(P1_ROUTES.contains(&("GET", "/api/lyrics")));
assert!(P1_ROUTES.contains(&("POST", "/api/reco/playlist")));
assert!(P1_ROUTES.contains(&("GET", "/api/favorites")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/add")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/remove")));
assert!(P1_ROUTES.contains(&("GET", "/api/playlists")));
assert!(P1_ROUTES.contains(&("GET", "/api/playlist")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/create")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/update")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/delete")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/tracks/add")));
assert!(P1_ROUTES.contains(&("POST", "/api/playlist/tracks/remove")));
assert!(P1_ROUTES.contains(&("GET", "/api/search")));
assert!(P1_ROUTES.contains(&("POST", "/api/play")));
assert!(P1_ROUTES.contains(&("GET", "/api/album")));
assert!(P1_ROUTES.contains(&("GET", "/api/artist")));
assert!(P1_ROUTES.contains(&("GET", "/api/similar")));
assert!(P1_ROUTES.contains(&("GET", "/api/suggest")));
assert!(P1_ROUTES.contains(&("POST", "/api/radio")));
assert!(P1_ROUTES.contains(&("POST", "/api/playback/shuffle")));
assert!(P1_ROUTES.contains(&("POST", "/api/playback/repeat")));
assert!(P1_ROUTES.contains(&("POST", "/api/queue/move")));
assert!(P1_ROUTES.contains(&("POST", "/api/queue/jump")));
assert!(P1_ROUTES.contains(&("POST", "/api/queue/stop-after")));
for r in P1_ROUTES {
assert!(!P0_ROUTES.contains(r), "{r:?} is duplicated across P0 and P1");
}
}
#[test]
fn constant_time_eq_matches_only_identical_slices() {
assert!(constant_time_eq(b"Bearer s3cret", b"Bearer s3cret"));
assert!(!constant_time_eq(b"Bearer s3cret", b"Bearer wrong"));
assert!(!constant_time_eq(b"Bearer s3cret", b"Bearer s3cret-extra"));
assert!(!constant_time_eq(b"", b"x"));
}
fn code(r: Option<GateReject>) -> Option<&'static str> {
r.map(|r| match r {
GateReject::OriginForbidden => "origin_forbidden",
GateReject::InvalidToken => "invalid_token",
})
}
#[test]
fn origin_header_is_refused_on_every_route_including_ping() {
// Step 4(a): an Origin header → 403 origin_forbidden everywhere, even
// /api/ping, and even in the open (token=None) default.
for (m, p) in P0_ROUTES {
assert_eq!(
code(access_gate(true, m, p, None, None)),
Some("origin_forbidden"),
"{m} {p} with Origin must be refused"
);
}
// ...and the Origin shield wins even when a valid Bearer is present.
assert_eq!(
code(access_gate(true, "GET", "/api/ping", Some("Bearer s3cret"), Some("s3cret"))),
Some("origin_forbidden")
);
}
#[test]
fn open_mode_answers_every_route_without_auth() {
// Step 4(b): token=None → no auth machinery; nothing is rejected.
for (m, p) in P0_ROUTES {
assert!(access_gate(false, m, p, None, None).is_none(), "{m} {p} must be open");
}
}
#[test]
fn opt_in_token_rejects_missing_or_wrong_bearer_but_never_ping() {
// Step 4(c): token=Some → missing/wrong bearer is 401 on non-ping routes;
// /api/ping stays 200 (exempt); the correct bearer passes.
let tok = Some("s3cret");
assert_eq!(code(access_gate(false, "GET", "/api/status", None, tok)), Some("invalid_token"));
assert_eq!(
code(access_gate(false, "GET", "/api/status", Some("Bearer nope"), tok)),
Some("invalid_token")
);
assert!(access_gate(false, "GET", "/api/status", Some("Bearer s3cret"), tok).is_none());
// /api/ping answers even with no/ wrong bearer.
assert!(access_gate(false, "GET", "/api/ping", None, tok).is_none());
assert!(access_gate(false, "GET", "/api/ping", Some("Bearer nope"), tok).is_none());
}
#[test]
fn canon_volume_pins_0_8_exactly_no_f32_widening() {
// `serde_json::Number::from_f32` widens f32→f64 (`0.8f32` would
// serialize raw as `0.800000011920929`); `canon_volume` must not.
assert_eq!(serde_json::to_string(&canon_volume(0.8f32)).unwrap(), "0.8");
assert_eq!(serde_json::to_string(&canon_volume(1.0f32)).unwrap(), "1.0");
assert_eq!(serde_json::to_string(&canon_volume(0.0f32)).unwrap(), "0.0");
assert_eq!(serde_json::to_string(&canon_volume(0.75f32)).unwrap(), "0.75");
}
#[test]
fn error_envelope_is_the_nested_shape_with_code() {
// 02 §3.1.3 — the on-the-wire shape the CLI reads via `error.code`.
let body = error_body("origin_forbidden", "refused", "not a browser API");
assert_eq!(body["error"]["code"], "origin_forbidden");
assert_eq!(body["error"]["message"], "refused");
assert_eq!(body["error"]["hint"], "not a browser API");
}
#[test] #[test]
fn owner_action_conflict_is_stable_and_contains_no_handoff_details() { fn owner_action_conflict_is_stable_and_contains_no_handoff_details() {
let body = owner_action_conflict_body(); let body = owner_action_conflict_body();
-216
View File
@@ -1,216 +0,0 @@
// crates/qbzd/src/api/playlist.rs — playlists (02 §2.3, §3.4 row 24). GET
// /api/playlists (the user's collection) and GET /api/playlist?id= (one
// playlist with its COMPLETE track list — get_playlist auto-pages server-side).
// Reads only in this slice; playlist CRUD (create/update/delete/tracks) is a
// later batch. Auth-gated; typed serde shapes verbatim.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use crate::state::AuthState;
use super::{err_json, json, ApiState};
/// `GET /api/playlists` — the user's playlist collection.
pub fn list(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
match state.rt.block_on(state.runtime.core().get_user_playlists()) {
Ok(pls) => json(
200,
serde_json::json!({"playlists": serde_json::to_value(pls).unwrap_or(Value::Null)}),
),
Err(_) => err_json(502, "playlists_failed", "playlists request to Qobuz failed", "try again in a moment"),
}
}
/// `GET /api/playlist?id=<ID>` — one playlist with its full track list.
pub fn show(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match id_param(query) {
Some(id) => id,
None => return err_json(400, "bad_request", "playlist requires a numeric id", "usage: qbzd playlist show <ID>"),
};
match state.rt.block_on(state.runtime.core().get_playlist(id)) {
Ok(pl) => json(
200,
serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)}),
),
Err(_) => err_json(404, "not_found", &format!("playlist {id} not found"), "check: qbzd playlist list"),
}
}
/// `POST /api/playlist/create`. Body `{"name": "...", "description"?: "...",
/// "public"?: bool}`. Returns the created playlist.
pub fn create(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let name = match body.get("name").and_then(|v| v.as_str()).filter(|n| !n.trim().is_empty()) {
Some(n) => n,
None => return err_json(400, "bad_request", "create requires a name", "body: {\"name\": \"My Playlist\"}"),
};
let desc = body.get("description").and_then(|v| v.as_str());
let public = body.get("public").and_then(|v| v.as_bool()).unwrap_or(false);
match state.rt.block_on(state.runtime.core().create_playlist(name, desc, public)) {
Ok(pl) => json(200, serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)})),
Err(_) => err_json(502, "playlists_failed", "playlist create failed", "try again in a moment"),
}
}
/// `POST /api/playlist/update`. Body `{"id": N, "name"?, "description"?,
/// "public"?}`. Only the present fields change.
pub fn update(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => return err_json(400, "bad_request", "update requires an id", "body: {\"id\": 987, \"name\": \"...\"}"),
};
let name = body.get("name").and_then(|v| v.as_str());
let desc = body.get("description").and_then(|v| v.as_str());
let public = body.get("public").and_then(|v| v.as_bool());
match state.rt.block_on(state.runtime.core().update_playlist(id, name, desc, public)) {
Ok(pl) => json(200, serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)})),
Err(_) => err_json(502, "playlists_failed", "playlist update failed", "try again in a moment"),
}
}
/// `POST /api/playlist/delete`. Body `{"id": N}`. Deletes an owned playlist.
pub fn delete(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => return err_json(400, "bad_request", "delete requires an id", "body: {\"id\": 987}"),
};
match state.rt.block_on(state.runtime.core().delete_playlist(id)) {
Ok(()) => json(200, serde_json::json!({"ok": true, "deleted": id})),
Err(_) => err_json(502, "playlists_failed", "playlist delete failed", "try again in a moment"),
}
}
/// `POST /api/playlist/tracks/add`. Body `{"id": N, "track_ids": [...]}`.
pub fn tracks_add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => return err_json(400, "bad_request", "add requires a playlist id", "body: {\"id\": 987, \"track_ids\": [...]}"),
};
let track_ids = match parse_ids(body) {
Ok(ids) => ids,
Err((m, h)) => return err_json(400, "bad_request", &m, &h),
};
match state.rt.block_on(state.runtime.core().add_tracks_to_playlist(id, &track_ids)) {
Ok(()) => json(200, serde_json::json!({"ok": true, "added": track_ids.len()})),
Err(_) => err_json(502, "playlists_failed", "add to playlist failed", "try again in a moment"),
}
}
/// `POST /api/playlist/tracks/remove`. Body `{"id": N, "track_ids": [...]}` —
/// PLAIN track ids. The daemon resolves them to per-playlist `playlist_track_id`
/// row ids (the row-id trap, qbz-models Track.playlist_track_id) before calling
/// `remove_tracks_from_playlist`, so clients never touch row ids.
pub fn tracks_remove(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match body.get("id").and_then(|v| v.as_u64()) {
Some(id) => id,
None => return err_json(400, "bad_request", "remove requires a playlist id", "body: {\"id\": 987, \"track_ids\": [...]}"),
};
let track_ids = match parse_ids(body) {
Ok(ids) => ids,
Err((m, h)) => return err_json(400, "bad_request", &m, &h),
};
let pl = match state.rt.block_on(state.runtime.core().get_playlist(id)) {
Ok(p) => p,
Err(_) => return err_json(404, "not_found", &format!("playlist {id} not found"), "check: qbzd playlist list"),
};
let wanted: std::collections::HashSet<u64> = track_ids.iter().copied().collect();
let items = pl.tracks.map(|t| t.items).unwrap_or_default();
let row_ids: Vec<u64> = items
.iter()
.filter(|t| wanted.contains(&t.id))
.filter_map(|t| t.playlist_track_id)
.collect();
if row_ids.is_empty() {
return err_json(404, "not_found", "none of those tracks are in the playlist", "check: qbzd playlist show <ID>");
}
match state.rt.block_on(state.runtime.core().remove_tracks_from_playlist(id, &row_ids)) {
Ok(()) => json(200, serde_json::json!({"ok": true, "removed": row_ids.len()})),
Err(_) => err_json(502, "playlists_failed", "remove from playlist failed", "try again in a moment"),
}
}
// ============================ internals ============================
/// Strict `track_ids` array parse (any non-u64 element is a 400).
fn parse_ids(body: &Value) -> Result<Vec<u64>, (String, String)> {
let hint = "body: {\"id\": 987, \"track_ids\": [176544871]}".to_string();
let arr = match body.get("track_ids").and_then(|v| v.as_array()) {
Some(a) if !a.is_empty() => a,
Some(_) => return Err(("'track_ids' must not be empty".into(), hint)),
None => return Err(("requires a 'track_ids' array".into(), hint)),
};
let mut ids = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
match v.as_u64() {
Some(id) => ids.push(id),
None => return Err((format!("track_ids[{i}] is not an unsigned integer"), hint)),
}
}
Ok(ids)
}
fn id_param(query: &str) -> Option<u64> {
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next() == Some("id") {
return kv.next().and_then(|v| v.parse::<u64>().ok());
}
}
None
}
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> {
let needs_auth = state
.shared
.lock()
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login"))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_param_reads_numeric_id() {
assert_eq!(id_param("id=987654"), Some(987654));
assert_eq!(id_param("foo=1&id=42"), Some(42));
assert_eq!(id_param("id=abc"), None);
assert_eq!(id_param(""), None);
}
#[test]
fn parse_ids_accepts_valid_and_rejects_bad() {
assert_eq!(parse_ids(&serde_json::json!({"track_ids": [1, 2, 3]})), Ok(vec![1, 2, 3]));
assert!(parse_ids(&serde_json::json!({"track_ids": []})).is_err());
assert!(parse_ids(&serde_json::json!({"track_ids": [1, "x"]})).is_err());
assert!(parse_ids(&serde_json::json!({})).is_err());
}
}
-156
View File
@@ -1,156 +0,0 @@
// crates/qbzd/src/api/sse.rs — the `GET /api/events` Server-Sent Events stream
// (CONSOLE ext). A live push feed of CoreEvents so a client (a plasmoid, a bar
// applet, `qbzd watch`) reacts to playback/queue/library changes without
// polling.
//
// Concurrency: the control-plane serve loop is single-threaded, so an open SSE
// stream would starve every other request. `serve()` therefore moves this onto
// its OWN thread (Request is Send); `stream` blocks there until the client
// disconnects (respond() errors) or the bus closes. The rusqlite-free bus is a
// tokio broadcast, drained here with `blocking_recv()` from the plain thread.
//
// Wire format: one SSE frame per emitted event —
// event: <CoreEvent type>\n
// data: {"type":"…","data":{…}}\n\n
// The `data` line is the CoreEvent's own `#[serde(tag="type",content="data")]`
// JSON (single-line). A leading `: …` comment primes the stream; a dropped-lag
// notice is sent as a comment rather than silently losing ordering.
use std::io::{Cursor, Read};
use tiny_http::{Header, Request, Response, StatusCode};
use tokio::sync::broadcast;
use qbz_models::CoreEvent;
/// Stream CoreEvents to one client until it disconnects. Runs on a dedicated
/// thread; `req.respond` blocks here, writing chunked as `SseReader` yields.
pub fn stream(req: Request, rx: broadcast::Receiver<CoreEvent>) {
let headers = vec![
header("Content-Type", "text/event-stream"),
header("Cache-Control", "no-cache"),
header("Connection", "keep-alive"),
// Defeat proxy/response buffering so frames flush immediately.
header("X-Accel-Buffering", "no"),
];
let response = Response::new(StatusCode(200), headers, SseReader::new(rx), None, None);
let _ = req.respond(response);
}
fn header(name: &str, value: &str) -> Header {
// Both are static ASCII — construction cannot fail.
Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("static header")
}
/// A blocking `Read` over the CoreEvent bus: each `read` serves the current
/// frame's bytes, and when a frame is exhausted it blocks for the next event.
/// `Ok(0)` (EOF) only when the bus closes (daemon shutting down).
struct SseReader {
rx: broadcast::Receiver<CoreEvent>,
frame: Cursor<Vec<u8>>,
primed: bool,
}
impl SseReader {
fn new(rx: broadcast::Receiver<CoreEvent>) -> Self {
SseReader { rx, frame: Cursor::new(Vec::new()), primed: false }
}
/// The next frame to send, blocking for a bus event. `None` = bus closed.
fn next_frame(&mut self) -> Option<Vec<u8>> {
if !self.primed {
self.primed = true;
return Some(b": qbzd event stream\n\n".to_vec());
}
loop {
match self.rx.blocking_recv() {
Ok(ev) => {
if let Some(frame) = format_event(&ev) {
return Some(frame.into_bytes());
}
// Not an emitted event — keep waiting.
}
Err(broadcast::error::RecvError::Lagged(n)) => {
return Some(format!(": lagged {n} event(s)\n\n").into_bytes());
}
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}
}
impl Read for SseReader {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
loop {
let n = self.frame.read(out)?;
if n > 0 {
return Ok(n);
}
match self.next_frame() {
Some(bytes) => self.frame = Cursor::new(bytes),
None => return Ok(0), // bus closed → end the stream
}
}
}
}
/// Render one CoreEvent as an SSE frame, or `None` for events not worth pushing
/// to a UI client (bulky search payloads, internal loading/download/navigation
/// hints, diagnostics). Everything else — playback, queue, volume, auth,
/// favorites, playlists, errors, device changes — is emitted.
fn format_event(ev: &CoreEvent) -> Option<String> {
if !emit(ev) {
return None;
}
let value = serde_json::to_value(ev).ok()?;
let typ = value.get("type").and_then(|v| v.as_str()).unwrap_or("event").to_string();
let data = serde_json::to_string(&value).ok()?;
Some(format!("event: {typ}\ndata: {data}\n\n"))
}
fn emit(ev: &CoreEvent) -> bool {
use CoreEvent::*;
!matches!(
ev,
SearchResultsReceived { .. }
| LoadingStarted { .. }
| LoadingCompleted { .. }
| DownloadProgress { .. }
| DownloadCompleted { .. }
| NavigateToAlbum { .. }
| NavigateToArtist { .. }
| NavigateToPlaylist { .. }
| AudioDiagnostic { .. }
)
}
#[cfg(test)]
mod tests {
use super::*;
use qbz_models::PlaybackState;
#[test]
fn playback_event_becomes_a_typed_sse_frame() {
let frame = format_event(&CoreEvent::PlaybackStateChanged {
state: PlaybackState::Playing,
})
.expect("playback event is emitted");
assert!(frame.starts_with("event: PlaybackStateChanged\n"));
assert!(frame.contains("data: {"));
assert!(frame.ends_with("\n\n"));
// The data line carries the tagged CoreEvent JSON.
assert!(frame.contains("\"type\":\"PlaybackStateChanged\""));
}
#[test]
fn bulky_and_internal_events_are_not_emitted() {
assert!(format_event(&CoreEvent::LoadingStarted { operation: "x".into() }).is_none());
assert!(format_event(&CoreEvent::DownloadCompleted { track_id: 1 }).is_none());
assert!(format_event(&CoreEvent::NavigateToArtist { artist_id: 1 }).is_none());
}
#[test]
fn volume_and_queue_events_are_emitted() {
assert!(format_event(&CoreEvent::VolumeChanged { volume: 0.5 }).is_some());
assert!(format_event(&CoreEvent::ShuffleChanged { enabled: true }).is_some());
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Experimental library administration on the selected host. These commands
//! first read its identity, then submit an instance-scoped operation. They never
//! open a desktop library or resolve the supplied directory on the controller.
use crate::{
cli::client::{ApiClient, CliError},
paths::ProfileRoots,
LibraryCmd,
};
use qbz_control::library::{LibraryInfo, PROTOCOL_VERSION};
use serde_json::{json, Value};
pub async fn run(host: Option<String>, roots: &ProfileRoots, command: LibraryCmd) -> i32 {
match execute(&ApiClient::new(host, roots), command).await {
Ok(value) => {
println!("{value}");
0
}
Err(error) => {
eprintln!("{error}");
error.exit_code()
}
}
}
async fn execute(client: &ApiClient, command: LibraryCmd) -> Result<Value, CliError> {
let value = client.get("/api/orbit/library").await?;
let info: LibraryInfo = serde_json::from_value(value.clone())
.map_err(|_| CliError::Runtime("host returned an invalid library identity".into()))?;
if info.protocol != PROTOCOL_VERSION || info.instance.is_empty() {
return Err(CliError::Runtime(
"host uses an unsupported Orbit library protocol".into(),
));
}
if matches!(command, LibraryCmd::Info) {
return Ok(value);
}
if let LibraryCmd::Search { query, limit } = command {
if !(1..=100).contains(&limit) {
return Err(CliError::Runtime(
"library limit must be between 1 and 100".into(),
));
}
return client
.get(&format!(
"/api/orbit/library/search?instance={}&q={}&limit={limit}",
urlencoding::encode(&info.instance),
urlencoding::encode(&query)
))
.await;
}
if !info
.capabilities
.iter()
.any(|c| c == "library.files.manage")
{
return Err(CliError::Runtime(
"host does not offer library administration".into(),
));
}
let instance = urlencoding::encode(&info.instance);
match command {
LibraryCmd::Folders => {
client
.get(&format!("/api/orbit/library/folders?instance={instance}"))
.await
}
LibraryCmd::Jobs => {
client
.get(&format!("/api/orbit/library/jobs?instance={instance}"))
.await
}
LibraryCmd::Add { path } => {
client
.post(
"/api/orbit/library/folders",
json!({"instance":info.instance, "revision":info.revision, "path":path}),
)
.await
}
LibraryCmd::Scan { folder } => {
client
.post(
"/api/orbit/library/scan",
json!({"instance":info.instance, "folder_id":folder}),
)
.await
}
LibraryCmd::Cancel { job } => {
client
.post(
"/api/orbit/library/cancel",
json!({"instance":info.instance, "job":job}),
)
.await
}
LibraryCmd::Info | LibraryCmd::Search { .. } => unreachable!(),
}
}
+2
View File
@@ -24,3 +24,5 @@ pub mod settings;
pub mod status; pub mod status;
pub mod transport; pub mod transport;
pub mod watch; pub mod watch;
pub mod library;
+16 -1
View File
@@ -39,7 +39,7 @@ pub struct BootedRuntime {
/// `qbzd run` — boot the daemon in the foreground, park on signals, shut down /// `qbzd run` — boot the daemon in the foreground, park on signals, shut down
/// gracefully. Returns the process exit code (0 = clean shutdown). `warns` are /// gracefully. Returns the process exit code (0 = clean shutdown). `warns` are
/// the unknown-key warnings surfaced by [`QbzdConfig::load`] in `main`. /// the unknown-key warnings surfaced by [`QbzdConfig::load`] in `main`.
pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Result<i32, String> { pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>, orbit: bool) -> Result<i32, String> {
// 1. argv parse happened in main(). 2. logging: // 1. argv parse happened in main(). 2. logging:
qbz_log::install(&cfg.log.level); qbz_log::install(&cfg.log.level);
// A headless daemon that dies of a signal leaves even less behind than // A headless daemon that dies of a signal leaves even less behind than
@@ -240,9 +240,19 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// the vanishingly small window before that. // the vanishingly small window before that.
let qconnect_control: Arc<std::sync::OnceLock<crate::qconnect::QconnectControl>> = let qconnect_control: Arc<std::sync::OnceLock<crate::qconnect::QconnectControl>> =
Arc::new(std::sync::OnceLock::new()); Arc::new(std::sync::OnceLock::new());
// Orbit uses the daemon's OWN roots. Construction is inert and local
// library access never depends on Qobuz authentication. No second port.
let library = orbit.then(|| Arc::new(qbz_library::service::LibraryService::new(
qbz_library::LibraryStore::new(roots.data.join("library.db")),
roots.cache.join("artwork"),
)));
let library_endpoint = library.as_ref().map(|service| qbz_control::library::LibraryEndpoint::new(
service.clone(), std::env::var("HOSTNAME").ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "qbzd".into()), true,
));
let api = crate::api::serve( let api = crate::api::serve(
bound, bound,
crate::api::ApiState { crate::api::ApiState {
library: library_endpoint,
runtime: booted.runtime.clone(), runtime: booted.runtime.clone(),
shared: booted.shared.clone(), shared: booted.shared.clone(),
bus: booted.bus.clone(), bus: booted.bus.clone(),
@@ -284,6 +294,8 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// fns are verified no-ops from a fresh process and re-adding them is the // fns are verified no-ops from a fresh process and re-adding them is the
// documented skeptic-correction #1 trap (§8.1). // documented skeptic-correction #1 trap (§8.1).
wait_for_signal().await; wait_for_signal().await;
// Reject new library work immediately; join after the API stops serving.
if let Some(library) = &library { library.close(); }
// ── Shutdown (§8.2, ordered). Step 1: disconnect the QConnect session (and // ── Shutdown (§8.2, ordered). Step 1: disconnect the QConnect session (and
// stop its auto-connect watcher) BEFORE playback is stopped, then drop the // stop its auto-connect watcher) BEFORE playback is stopped, then drop the
@@ -341,6 +353,9 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// ahead of the #521 pair — the same ordering constraint as the driver and // ahead of the #521 pair — the same ordering constraint as the driver and
// auth-retry tasks (§8.2). // auth-retry tasks (§8.2).
api.shutdown(); api.shutdown();
if let Some(library) = library {
let _ = tokio::task::spawn_blocking(move || library.shutdown()).await;
}
// The reload route's OnceLock handle also clones `QconnectControl`, which // The reload route's OnceLock handle also clones `QconnectControl`, which
// holds an `Arc<AppRuntime>` (via `DaemonQconnectService.runtime`) — drop // holds an `Arc<AppRuntime>` (via `DaemonQconnectService.runtime`) — drop
// it before `drop(booted)` too, same #521/§8.2 ordering as the driver, // it before `drop(booted)` too, same #521/§8.2 ordering as the driver,
+32 -3
View File
@@ -35,7 +35,14 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Run the daemon in the foreground (systemd ExecStart) /// Run the daemon in the foreground (systemd ExecStart)
Run, Run {
/// Enable the experimental Orbit library API
#[arg(long, hide = true)]
orbit: bool,
},
/// Experimental host-owned local library (requires run --orbit)
#[command(hide = true)]
Library { #[command(subcommand)] cmd: LibraryCmd },
/// Log in to Qobuz (one-shot browser listener; --paste; --token) /// Log in to Qobuz (one-shot browser listener; --paste; --token)
Login { Login {
#[arg(long)] callback_host: Option<String>, #[arg(long)] callback_host: Option<String>,
@@ -154,6 +161,24 @@ enum Cmd {
Completions { shell: clap_complete::Shell }, Completions { shell: clap_complete::Shell },
} }
#[derive(Subcommand)]
enum LibraryCmd {
/// Inspect this host's file library
Info,
/// List directories registered on the target host
Folders,
/// Register an existing absolute directory on the target host
Add { path: String },
/// Queue a scan of one directory or the whole library
Scan { #[arg(long)] folder: Option<i64> },
/// Read scan progress and its current job id
Jobs,
/// Cancel this specific job (never a newer one)
Cancel { job: u64 },
/// Search the target host's indexed files
Search { query: String, #[arg(long, default_value_t = 25)] limit: u32 },
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum QueueCmd { enum QueueCmd {
List { #[arg(long)] json: bool }, List { #[arg(long)] json: bool },
@@ -282,7 +307,7 @@ async fn main() {
&mut std::io::stdout()); &mut std::io::stdout());
0 0
} }
Cmd::Run => { Cmd::Run { orbit } => {
// Phase 1: resolve the config root and load qbzd.toml. The config's // Phase 1: resolve the config root and load qbzd.toml. The config's
// `data_root` (a container override) can redirect the data/cache // `data_root` (a container override) can redirect the data/cache
// roots, so resolve those in phase 2 once it is known. // roots, so resolve those in phase 2 once it is known.
@@ -302,7 +327,7 @@ async fn main() {
None, None,
data_root.as_deref().map(std::path::Path::new), data_root.as_deref().map(std::path::Path::new),
); );
match daemon::run(roots, cfg, warns).await { match daemon::run(roots, cfg, warns, orbit).await {
Ok(code) => code, Ok(code) => code,
Err(e) => { Err(e) => {
eprintln!("{e}"); eprintln!("{e}");
@@ -310,6 +335,10 @@ async fn main() {
} }
} }
} }
Cmd::Library { cmd } => {
let roots = login_roots();
cli::library::run(cli.host, &roots, cmd).await
}
Cmd::Login { Cmd::Login {
callback_host, callback_host,
paste, paste,
+33 -2
View File
@@ -21,12 +21,12 @@
# + the fatal-signal reporter check (qbz-log): a real child process is # + the fatal-signal reporter check (qbz-log): a real child process is
# segfaulted on purpose; the handler must name the signal and the process # segfaulted on purpose; the handler must name the signal and the process
# must still die OF that signal. # must still die OF that signal.
# --workspace today = the 42 members of crates/Cargo.toml minus qbz-qt: the # --workspace = members of crates/Cargo.toml minus qbz-qt: the
# audio/player/cache/DSD/disc/rip core, qbz-app/core/models/theme/i18n, # audio/player/cache/DSD/disc/rip core, qbz-app/core/models/theme/i18n,
# the Qobuz client and the source seam, Plex/Jellyfin/Subsonic + media # the Qobuz client and the source seam, Plex/Jellyfin/Subsonic + media
# cache, local library + catalog, integrations/reco/lyrics/radio/mixtape/ # cache, local library + catalog, integrations/reco/lyrics/radio/mixtape/
# playlist-import/media-controls/cast, credentials/secrets/offline-cache, # playlist-import/media-controls/cast, credentials/secrets/offline-cache,
# the four qconnect-* crates, the HiFi wizard core, and qbzd. Nothing here # the qconnect-* crates, the HiFi wizard core, qbz-control and qbzd. Nothing here
# needs Qt; wayland-sys enters the graph only through qbz-qt. # needs Qt; wayland-sys enters the graph only through qbz-qt.
# The Slint crates (qbz, qbz-ui, qbz-dac-wizard, qbz-slint-common) are gone # The Slint crates (qbz, qbz-ui, qbz-dac-wizard, qbz-slint-common) are gone
# from the workspace: no exclusions for them, and never bring them back. # from the workspace: no exclusions for them, and never bring them back.
@@ -75,6 +75,37 @@ cargo test \
--no-fail-fast \ --no-fail-fast \
"$@" "$@"
say "gate: Orbit HTTP host isolation and library profile regressions present"
# The workspace run executes these. Keep actual socket/gate/shutdown tests and
# profile isolation from disappearing during the next extraction phases.
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-control --test http_hosts -- --list 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 3 )) || { echo "Orbit HTTP host suite has $n tests (expected >= 3)"; exit 1; }
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-library --lib -- --list store::tests:: 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 3 )) || { echo "Library profile suite has $n tests (expected >= 3)"; exit 1; }
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-library --lib -- --list search::tests:: 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 4 )) || { echo "Library source search suite has $n tests (expected >= 4)"; exit 1; }
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-library --lib -- --list service::tests:: 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 7 )) || { echo "Library service suite has $n tests (expected >= 7)"; exit 1; }
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-control --test library_hosts -- --list 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 2 )) || { echo "Orbit library HTTP suite has $n tests (expected >= 2)"; exit 1; }
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-control --test library_admin -- --list 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 2 )) || { echo "Orbit library administration suite has $n tests (expected >= 2)"; exit 1; }
if [[ "$(uname -s)" == "Linux" ]]; then
say "gate: real Orbit daemon instances (isolated roots, no audio or Qobuz session)"
orbit_target_dir=$(cargo metadata --manifest-path crates/Cargo.toml --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
python3 scripts/test-orbit-daemon.py --bin "$orbit_target_dir/debug/qbzd"
fi
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-control --lib -- --list logged_in_frame_contains_public_identity_only 2>/dev/null \
| grep -c ': test$' || true)
(( n >= 1 )) || { echo "SSE public-identity regression missing"; exit 1; }
say "gate: SACD physical-sector and scanner regressions present" say "gate: SACD physical-sector and scanner regressions present"
# Already executed by the workspace run: keep the container/seek equivalence # Already executed by the workspace run: keep the container/seek equivalence
# and failed-scan preservation checks from silently disappearing. # and failed-scan preservation checks from silently disappearing.
@@ -0,0 +1,15 @@
pragma Singleton
import QtQuick
QtObject {
property bool enabled: false
property bool controllingRemote: false
property bool connectionLost: false
property string hostName: ""
property string stateJson: "{}"
signal verificationRequested(string url, string token)
function startHost(address) {}
function stopHost() {}
function copyAccessKey() {}
function verifyHost(url, token) { verificationRequested(url, token) }
function searchLibrary(query) {}
}
@@ -6,3 +6,4 @@ singleton QbzSession 1.0 QbzSession.qml
singleton QbzBridge 1.0 QbzBridge.qml singleton QbzBridge 1.0 QbzBridge.qml
singleton QbzLibrary 1.0 QbzLibrary.qml singleton QbzLibrary 1.0 QbzLibrary.qml
singleton QbzPurchases 1.0 QbzPurchases.qml singleton QbzPurchases 1.0 QbzPurchases.qml
singleton QbzOrbit 1.0 QbzOrbit.qml
+57
View File
@@ -0,0 +1,57 @@
import QtQuick
import QtTest
import com.blitzfc.qbz
import "../../crates/qbz-qt/qml/settings" as Settings
import "../../crates/qbz-qt/qml/shell" as Shell
Item {
width: 1000; height: 1000
Settings.OrbitSettingsPanel { id: panel; width: 900; selected: true }
Shell.OrbitBanner { id: banner; width: 900 }
TestCase {
name: "OrbitLaboratory"; when: windowShown
function init() {
QbzOrbit.enabled = false
QbzOrbit.stateJson = "{}"
panel.selected = true
banner.remoteActive = false
banner.connectionLost = false
banner.hostName = ""
}
function test_panel_is_not_constructed_without_launch_flag() {
compare(panel.item, null)
verify(!panel.visible)
QbzOrbit.enabled = true
tryVerify(function() { return panel.item !== null })
panel.selected = false
compare(panel.item, null)
panel.selected = true
tryVerify(function() { return panel.item !== null })
QbzOrbit.enabled = false
compare(panel.item, null)
}
function test_verified_host_does_not_claim_remote_control() {
QbzOrbit.enabled = true
QbzOrbit.stateJson = JSON.stringify({peer:{name:"Other computer", tracks:12}, status:"verified"})
tryVerify(function() { return panel.item !== null })
compare(panel.item.doc.peer.name, "Other computer")
verify(!banner.visible)
compare(banner.height, 0)
}
function test_remote_banner_survives_clicks_and_connection_loss() {
banner.hostName = "Studio QBZ"
banner.remoteActive = true
verify(banner.visible)
verify(banner.height >= 38)
var label = findChild(banner, "orbitContextLabel")
verify(label.text.indexOf("Studio QBZ") >= 0)
mouseClick(banner, 800, 15)
verify(banner.visible)
banner.connectionLost = true
verify(banner.visible)
verify(label.text.indexOf("Remote controls are unavailable") >= 0)
banner.remoteActive = false
compare(banner.height, 0)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import QtQuick
import QtTest
import "../../crates/qbz-qt/qml/views" as Views
Item {
width: 900; height: 900
Views.SearchLocalResults {
id: results
width: 850
revision: "host-a-query-12"
sections: [
{kind:"local-album", hasMore:true, rows:[{kind:"album", title:"Album", flatIndex:0}]},
{kind:"local-artist", hasMore:false, rows:[{kind:"artist", title:"Artist", flatIndex:1}]},
{kind:"local", hasMore:true, rows:[
{kind:"track", title:"Files", flatIndex:2},
{kind:"track", title:"Plex", flatIndex:3},
{kind:"track", title:"Jellyfin", flatIndex:4},
{kind:"track", title:"Subsonic", flatIndex:5},
{kind:"track", title:"Future source", flatIndex:6}
]}
]
}
SignalSpy { id: activated; target: results; signalName: "activated" }
TestCase {
name: "LocalSearchResults"; when: windowShown
function init() { results.tab = 0; activated.clear(); verify(waitForRendering(results)) }
function test_local_only_results_render_without_qobuz_rows() {
verify(findChild(results, "localSearchSection-local-album").visible)
verify(findChild(results, "localSearchSection-local-artist").visible)
verify(findChild(results, "localSearchSection-local").visible)
var future = findChild(results, "localSearchHit-6")
verify(future !== null)
mouseClick(future)
compare(activated.count, 1)
compare(activated.signalArguments[0][0], "host-a-query-12")
compare(activated.signalArguments[0][1], 6)
compare(activated.signalArguments[0][2], "play")
}
function test_category_tabs_keep_the_local_kind() {
results.tab = 1
verify(findChild(results, "localSearchSection-local-album").visible)
verify(!findChild(results, "localSearchSection-local").visible)
mouseClick(findChild(results, "localSearchHit-0"))
compare(activated.signalArguments[0][0], "host-a-query-12")
compare(activated.signalArguments[0][1], 0)
compare(activated.signalArguments[0][2], "open")
results.tab = 3
verify(findChild(results, "localSearchSection-local-artist").visible)
verify(!findChild(results, "localSearchSection-local-album").visible)
results.tab = 4
verify(!findChild(results, "localSearchSection-local-artist").visible)
tryCompare(results, "height", 0)
}
function test_menu_actions_keep_the_same_source_snapshot() {
mouseClick(findChild(results, "localSearchMenu-6"))
var menu = findChild(results, "localSearchMenuLoader-6").item
verify(menu !== null)
tryCompare(menu, "opened", true)
compare(menu.entries[1].action, "next")
mouseClick(menu.contentItem, 80, 49)
compare(activated.count, 1)
compare(activated.signalArguments[0][0], "host-a-query-12")
compare(activated.signalArguments[0][1], 6)
compare(activated.signalArguments[0][2], "next")
mouseClick(findChild(results, "localSearchHit-6"), 40, 30, Qt.RightButton)
tryCompare(menu, "opened", true)
compare(activated.count, 1, "right-click opens the menu without playing")
menu.close()
}
}
}
+2
View File
@@ -3,6 +3,8 @@
set -euo pipefail set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
export QBZ_PREBUILT_SHADERS=1 export QBZ_PREBUILT_SHADERS=1
bash scripts/test-search-local-qml.sh
bash scripts/test-orbit-qml.sh
node scripts/test_qt_local_views.mjs node scripts/test_qt_local_views.mjs
node scripts/test_qt_kiosk_art.mjs node scripts/test_qt_kiosk_art.mjs
node scripts/test_qt_kiosk_navigation.mjs node scripts/test_qt_kiosk_navigation.mjs
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Orbit against real qbzd processes, isolated profiles and no audio/session bus.
Uses a prebuilt binary; never compiles, authenticates to Qobuz or plays audio.
Linux XDG fixture only. Run with --bin /path/to/qbzd after the Rust test build.
"""
import argparse
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import wave
def check(condition, message):
if not condition:
raise AssertionError(message)
class Host:
def __init__(self, base, name, binary):
self.root = base / name
self.binary = binary
self.token = name + "-fixture-key"
self.env = os.environ.copy()
for key, folder in [("XDG_CONFIG_HOME", "config"), ("XDG_DATA_HOME", "data"),
("XDG_CACHE_HOME", "cache"), ("XDG_RUNTIME_DIR", "run")]:
path = self.root / folder
path.mkdir(parents=True, mode=0o700)
self.env[key] = str(path)
self.env.update({
"DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(self.root / "absent-bus"),
"PIPEWIRE_RUNTIME_DIR": str(self.root / "run"),
"PIPEWIRE_REMOTE": "orbit-fixture-absent",
"PULSE_SERVER": "unix:" + str(self.root / "absent-pulse"),
"QBZD_MPRIS": "0", "QBZD_TOKEN": self.token,
"PATH": str(base / "fake-tools") + os.pathsep + self.env.get("PATH", ""),
})
self.env.pop("QBZD_HOST", None)
self.env.pop("QBZD_HOOK", None)
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
self.port = sock.getsockname()[1]
config = self.root / "config/qbzd"
config.mkdir()
(config / "qbzd.toml").write_text(
f'[server]\nbind = "127.0.0.1"\nport = {self.port}\ntoken = "{self.token}"\n')
self.music = self.root / "music"
self.music.mkdir()
with wave.open(str(self.music / f"Orbit {name}.wav"), "wb") as audio:
audio.setnchannels(1)
audio.setsampwidth(2)
audio.setframerate(44100)
audio.writeframes(b"\0\0" * 44100)
self.process = None
self.log = None
def start(self, orbit):
self.log = (self.root / "daemon.log").open("ab")
self.process = subprocess.Popen([self.binary, "run"] + (["--orbit"] if orbit else []),
env=self.env, stdout=self.log, stderr=subprocess.STDOUT)
end = time.monotonic() + 20
while time.monotonic() < end:
check(self.process.poll() is None, f"daemon exited: {self.root / 'daemon.log'}")
try:
if self.http("/api/ping")[0] == 200:
# The API starts before run() installs its signal waiter.
# Do not SIGTERM a booting process in that small window.
status = Path(f"/proc/{self.process.pid}/status").read_text()
caught = next(line.split()[1] for line in status.splitlines() if line.startswith("SigCgt:"))
if int(caught, 16) & (1 << (signal.SIGTERM - 1)):
return
except (OSError, urllib.error.URLError):
pass
time.sleep(0.05)
raise AssertionError("daemon startup timed out")
def stop(self):
if self.process is not None:
self.process.send_signal(signal.SIGTERM)
try:
code = self.process.wait(timeout=15)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
raise AssertionError("daemon failed to shut down")
finally:
self.process = None
self.log.close()
check(code == 0, f"unclean shutdown: {code}")
def http(self, path, body=None, token=None):
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}",
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": "Bearer " + (self.token if token is None else token),
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, json.load(error)
def cli(self, *args):
result = subprocess.run([self.binary, "--host", f"127.0.0.1:{self.port}", "library", *args],
env=self.env, capture_output=True, text=True, timeout=15)
check(result.returncode == 0, result.stderr)
return json.loads(result.stdout)
def indexed(self):
end = time.monotonic() + 15
while time.monotonic() < end:
state = self.cli("jobs")["library"]
if not state["progress"]["running"]:
check(state["last_scan"]["outcome"] == "complete", state)
return
time.sleep(0.05)
raise AssertionError("scan did not complete")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--bin", required=True)
args = parser.parse_args()
binary = str(Path(args.bin).resolve(strict=True))
if not os.sys.platform.startswith("linux"):
raise SystemExit("this fixture requires Linux XDG isolation")
with tempfile.TemporaryDirectory(prefix="orbit-daemon-") as scratch:
base = Path(scratch)
fake = base / "fake-tools"
fake.mkdir()
# Daemon quit's existing clock-reset subprocesses must not reach the
# user's PipeWire session, even if its environment changes in future.
for name in ("pw-metadata", "pw-cli"):
executable = fake / name
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o700)
a, b = Host(base, "Alpha", binary), Host(base, "Beta", binary)
try:
a.start(False)
check(a.http("/api/orbit/library")[0] == 404, "Orbit visible without flag")
check(not (a.root / "data/qbzd/library.db").exists(), "disabled Orbit created a DB")
a.stop()
a.start(True)
b.start(True)
for host in (a, b):
code, state = host.http("/api/status")
check(code == 200 and state["auth"]["state"] == "needs_auth", state)
check(host.cli("info")["tracks"] == 0, "nonempty new library")
folders = host.cli("add", str(host.music))
check(len(folders["folders"]) == 1, folders)
host.cli("scan")
host.indexed()
check(host.cli("info")["tracks"] == 1, "scan missing indexed WAV")
ia, ib = a.cli("info"), b.cli("info")
check(ia["instance"] != ib["instance"], "shared host instance")
pa, pb = a.cli("search", "Orbit"), b.cli("search", "Orbit")
check(pa["tracks"][0]["id"] == pb["tracks"][0]["id"], "fixture needs colliding native IDs")
check("Alpha" in pa["tracks"][0]["title"] and "Beta" in pb["tracks"][0]["title"], "crossed host metadata")
check(a.http("/api/orbit/library/scan", {"instance":ib["instance"]})[0] == 409, "crossed instance admitted")
check(a.http("/api/orbit/library", token=b.token)[0] == 401, "crossed token admitted")
a.stop()
a.start(True)
check(a.cli("info")["tracks"] == 1, "library did not survive restart")
check(a.http("/api/orbit/library/scan", {"instance":ia["instance"]})[0] == 409, "old instance admitted after restart")
check(b.cli("info")["instance"] == ib["instance"], "other host changed during restart")
print("PASS: flag gate, two daemon roots, no Qobuz auth, folder/scan/search CLI, access/instance isolation, restart persistence")
except Exception:
for host in (a, b):
log = host.root / "daemon.log"
if log.exists():
print(log.read_text(errors="replace")[-6000:], file=os.sys.stderr)
raise
finally:
try:
a.stop()
finally:
b.stop()
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# The same bounded view/activation regression runs in CI and locally.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
if [[ -n "${QT_ROOT_DIR:-}" ]]; then
qt_orbit_bins="$QT_ROOT_DIR/bin"
else
qt_orbit_qmake="${QMAKE:-}"
if [[ -z "$qt_orbit_qmake" ]]; then
qt_orbit_qmake="$(command -v qmake6 || command -v qmake)"
fi
qt_orbit_bins="$("$qt_orbit_qmake" -query QT_INSTALL_BINS)"
fi
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
"$qt_orbit_bins/qmltestrunner" \
-input scripts/qml-tests/tst_orbit.qml \
-import scripts/qml-tests/imports
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# The same bounded view/activation regression runs in CI and locally.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
if [[ -n "${QT_ROOT_DIR:-}" ]]; then
qt_search_bins="$QT_ROOT_DIR/bin"
else
qt_search_qmake="${QMAKE:-}"
if [[ -z "$qt_search_qmake" ]]; then
qt_search_qmake="$(command -v qmake6 || command -v qmake)"
fi
qt_search_bins="$("$qt_search_qmake" -query QT_INSTALL_BINS)"
fi
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
"$qt_search_bins/qmltestrunner" \
-input scripts/qml-tests/tst_search_local.qml \
-import scripts/qml-tests/imports