add SPA config UI, wire media/rss adapters, event-driven layout push

- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder,
  presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000
- wire media + rss adapters into polling loop, remove xtb source type
- media adapter: read username/password from headers, proper subsonic auth
- event handler: subscribe to LayoutChanged, push screen update to clients
- fix clippy warnings across workspace (Default impls, collapsible ifs,
  redundant closures, is_none_or, unused imports)
This commit is contained in:
2026-06-19 00:12:42 +02:00
parent 21c08911df
commit 26ebfad3a2
175 changed files with 12338 additions and 801 deletions

View File

@@ -8,6 +8,8 @@ domain.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
md5 = "0.7"
fastrand = "2"
[dev-dependencies]
tokio.workspace = true

View File

@@ -4,6 +4,8 @@ pub enum MediaError {
Request(#[from] reqwest::Error),
#[error("no url configured")]
NoUrl,
#[error("missing field in headers: {0}")]
MissingField(&'static str),
#[error("parse: {0}")]
Parse(String),
}

View File

@@ -2,33 +2,61 @@ mod error;
pub use error::MediaError;
use std::collections::BTreeMap;
use domain::{DataSource, DataSourcePort, Value};
use std::collections::BTreeMap;
pub struct MediaAdapter {
client: reqwest::Client,
}
impl MediaAdapter {
pub fn new() -> Self {
impl Default for MediaAdapter {
fn default() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl MediaAdapter {
pub fn new() -> Self {
Self::default()
}
}
fn find_header<'a>(headers: &'a [(String, String)], key: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
fn subsonic_token(password: &str, salt: &str) -> String {
format!("{:x}", md5::compute(format!("{password}{salt}")))
}
impl DataSourcePort for MediaAdapter {
type Error = MediaError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?;
let api_key = source.config.api_key.as_deref().unwrap_or("");
let username = find_header(&source.config.headers, "username")
.ok_or(MediaError::MissingField("username"))?;
let password = find_header(&source.config.headers, "password")
.ok_or(MediaError::MissingField("password"))?;
let salt: String = (0..12).map(|_| fastrand::alphanumeric()).collect();
let token = subsonic_token(password, &salt);
let url = format!(
"{base_url}/rest/getNowPlaying.view?u=kframe&t={api_key}&s=salt&v=1.16.1&c=kframe&f=json"
"{base_url}/rest/getNowPlaying.view?u={username}&t={token}&s={salt}&v=1.16.1&c=kframe&f=json"
);
let resp = self.client.get(&url).send().await.map_err(MediaError::Request)?;
let resp = self
.client
.get(&url)
.send()
.await
.map_err(MediaError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?;
let entries = json["subsonic-response"]["nowPlaying"]["entry"]
@@ -45,15 +73,18 @@ impl DataSourcePort for MediaAdapter {
let entry = &entries[0];
let mut result = BTreeMap::new();
result.insert("playing".into(), Value::Bool(true));
result.insert("title".into(), Value::String(
entry["title"].as_str().unwrap_or("Unknown").into()
));
result.insert("artist".into(), Value::String(
entry["artist"].as_str().unwrap_or("Unknown").into()
));
result.insert("album".into(), Value::String(
entry["album"].as_str().unwrap_or("Unknown").into()
));
result.insert(
"title".into(),
Value::String(entry["title"].as_str().unwrap_or("Unknown").into()),
);
result.insert(
"artist".into(),
Value::String(entry["artist"].as_str().unwrap_or("Unknown").into()),
);
result.insert(
"album".into(),
Value::String(entry["album"].as_str().unwrap_or("Unknown").into()),
);
if let Some(duration) = entry["duration"].as_u64() {
result.insert("duration".into(), Value::Number(duration as f64));

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
use media_adapter::MediaAdapter;
use std::time::Duration;
fn subsonic_response(playing: bool) -> serde_json::Value {
if playing {
@@ -28,10 +28,10 @@ fn subsonic_response(playing: bool) -> serde_json::Value {
}
async fn start_fake_subsonic(playing: bool) -> String {
let app = axum::Router::new()
.route("/rest/getNowPlaying.view", axum::routing::get(move || async move {
axum::response::Json(subsonic_response(playing))
}));
let app = axum::Router::new().route(
"/rest/getNowPlaying.view",
axum::routing::get(move || async move { axum::response::Json(subsonic_response(playing)) }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
@@ -47,8 +47,11 @@ fn make_source(url: String) -> DataSource {
poll_interval: Duration::from_secs(5),
config: DataSourceConfig {
url: Some(url),
headers: vec![],
api_key: Some("testtoken".into()),
headers: vec![
("username".into(), "test".into()),
("password".into(), "testpass".into()),
],
api_key: None,
},
}
}
@@ -62,8 +65,14 @@ async fn returns_now_playing_info() {
let result = adapter.poll(&source).await.unwrap();
assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true)));
assert_eq!(result.get_path("$.title"), Some(&Value::String("Believer".into())));
assert_eq!(result.get_path("$.artist"), Some(&Value::String("Imagine Dragons".into())));
assert_eq!(
result.get_path("$.title"),
Some(&Value::String("Believer".into()))
);
assert_eq!(
result.get_path("$.artist"),
Some(&Value::String("Imagine Dragons".into()))
);
}
#[tokio::test]