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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user