use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::tungstenite::Message;
use crate::atproto::Atproto;
const CONNECT_LXM: &str = "fm.atradio.connect";
const DEFAULT_SERVICE_AUD: &str = "did:web:api.atradio.fm#atradio_appview";
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct StationLite {
pub id: String,
pub name: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub favicon: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct WireState {
pub playing: bool,
#[serde(default)]
pub station: Option<StationLite>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub volume: f32,
pub muted: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Device {
pub id: String,
pub name: String,
#[serde(default)]
pub platform: String,
#[serde(default, rename = "self")]
pub is_self: bool,
#[serde(default)]
pub state: WireState,
}
#[derive(Clone, Debug)]
pub enum RemoteCmd {
PlayPause,
Play,
Pause,
Stop,
SetVolume(f32),
ToggleMute,
LoadStation(StationLite),
}
#[derive(Clone, Debug)]
#[allow(dead_code)] pub enum RemoteEvent {
Status(bool),
Welcome(String),
Devices(Vec<Device>),
Presence { any_playing: bool, cleanup: bool },
}
#[derive(Clone)]
pub struct RemoteControl {
tx: mpsc::UnboundedSender<Outbound>,
}
enum Outbound {
Command { target: String, cmd: RemoteCmd },
}
impl RemoteControl {
pub fn command(&self, target: impl Into<String>, cmd: RemoteCmd) {
let _ = self.tx.send(Outbound::Command {
target: target.into(),
cmd,
});
}
}
pub struct RemoteConfig {
pub base_url: String,
pub device_id: String,
pub device_name: String,
pub atproto: Arc<Atproto>,
}
pub struct Remote {
pub cmd_rx: mpsc::UnboundedReceiver<RemoteCmd>,
pub evt_rx: mpsc::UnboundedReceiver<RemoteEvent>,
pub control: RemoteControl,
}
pub fn spawn(cfg: RemoteConfig, state: watch::Receiver<WireState>) -> Remote {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (evt_tx, evt_rx) = mpsc::unbounded_channel();
let (out_tx, out_rx) = mpsc::unbounded_channel();
tokio::spawn(run(cfg, state, cmd_tx, evt_tx, out_rx));
Remote {
cmd_rx,
evt_rx,
control: RemoteControl { tx: out_tx },
}
}
async fn run(
cfg: RemoteConfig,
mut state: watch::Receiver<WireState>,
cmd_tx: mpsc::UnboundedSender<RemoteCmd>,
evt_tx: mpsc::UnboundedSender<RemoteEvent>,
mut out_rx: mpsc::UnboundedReceiver<Outbound>,
) {
let mut backoff = 1000u64;
loop {
let service_aud = discover_service_aud(&cfg.base_url).await;
match connect_once(
&cfg,
&service_aud,
&mut state,
&cmd_tx,
&evt_tx,
&mut out_rx,
)
.await
{
Ok(true) => return, Ok(false) | Err(_) => {}
}
let _ = evt_tx.send(RemoteEvent::Status(false));
tokio::time::sleep(Duration::from_millis(backoff)).await;
backoff = (backoff * 2).min(15000);
}
}
async fn connect_once(
cfg: &RemoteConfig,
service_aud: &str,
state: &mut watch::Receiver<WireState>,
cmd_tx: &mpsc::UnboundedSender<RemoteCmd>,
evt_tx: &mpsc::UnboundedSender<RemoteEvent>,
out_rx: &mut mpsc::UnboundedReceiver<Outbound>,
) -> Result<bool> {
let token = cfg
.atproto
.mint_service_auth(service_aud, CONNECT_LXM)
.await?;
let (ws, _resp) = tokio_tungstenite::connect_async(ws_url(&cfg.base_url)).await?;
let (mut sink, mut stream) = ws.split();
let hello = json!({
"t": "hello",
"token": token,
"device": {
"id": cfg.device_id,
"name": cfg.device_name,
"platform": "cli",
"state": state.borrow().clone(),
}
});
sink.send(Message::Text(hello.to_string().into())).await?;
loop {
tokio::select! {
msg = stream.next() => {
let Some(msg) = msg else { return Ok(false) };
match msg? {
Message::Text(txt) => handle_server_text(txt.as_str(), cmd_tx, evt_tx),
Message::Ping(p) => sink.send(Message::Pong(p)).await?,
Message::Close(_) => return Ok(false),
_ => {}
}
}
changed = state.changed() => {
if changed.is_err() { return Ok(true); } let cur = state.borrow().clone();
let frame = json!({ "t": "state", "state": cur });
sink.send(Message::Text(frame.to_string().into())).await?;
}
out = out_rx.recv() => {
match out {
Some(Outbound::Command { target, cmd }) => {
let frame = json!({
"t": "command",
"target": target,
"cmd": cmd_to_json(&cmd),
});
sink.send(Message::Text(frame.to_string().into())).await?;
}
None => return Ok(true),
}
}
}
}
}
fn handle_server_text(
txt: &str,
cmd_tx: &mpsc::UnboundedSender<RemoteCmd>,
evt_tx: &mpsc::UnboundedSender<RemoteEvent>,
) {
let Ok(v) = serde_json::from_str::<Value>(txt) else {
return;
};
match v.get("t").and_then(Value::as_str) {
Some("welcome") => {
if let Some(id) = v.get("deviceId").and_then(Value::as_str) {
let _ = evt_tx.send(RemoteEvent::Welcome(id.to_string()));
}
let _ = evt_tx.send(RemoteEvent::Status(true));
}
Some("devices") => {
if let Some(devs) = v
.get("devices")
.and_then(|d| serde_json::from_value::<Vec<Device>>(d.clone()).ok())
{
let _ = evt_tx.send(RemoteEvent::Devices(devs));
}
}
Some("command") => {
if let Some(cmd) = v.get("cmd").and_then(json_to_cmd) {
let _ = cmd_tx.send(cmd);
}
}
Some("presence") => {
let any_playing = v
.get("anyPlaying")
.and_then(Value::as_bool)
.unwrap_or(false);
let cleanup = v.get("cleanup").and_then(Value::as_bool).unwrap_or(false);
let _ = evt_tx.send(RemoteEvent::Presence {
any_playing,
cleanup,
});
}
_ => {}
}
}
fn cmd_to_json(cmd: &RemoteCmd) -> Value {
match cmd {
RemoteCmd::PlayPause => json!({ "action": "playPause" }),
RemoteCmd::Play => json!({ "action": "play" }),
RemoteCmd::Pause => json!({ "action": "pause" }),
RemoteCmd::Stop => json!({ "action": "stop" }),
RemoteCmd::SetVolume(v) => json!({ "action": "setVolume", "value": v }),
RemoteCmd::ToggleMute => json!({ "action": "toggleMute" }),
RemoteCmd::LoadStation(s) => json!({ "action": "playStation", "station": s }),
}
}
fn json_to_cmd(v: &Value) -> Option<RemoteCmd> {
match v.get("action").and_then(Value::as_str)? {
"playPause" => Some(RemoteCmd::PlayPause),
"play" => Some(RemoteCmd::Play),
"pause" => Some(RemoteCmd::Pause),
"stop" => Some(RemoteCmd::Stop),
"toggleMute" => Some(RemoteCmd::ToggleMute),
"setVolume" => v
.get("value")
.and_then(Value::as_f64)
.map(|f| RemoteCmd::SetVolume(f as f32)),
"playStation" => v
.get("station")
.and_then(|s| serde_json::from_value::<StationLite>(s.clone()).ok())
.map(RemoteCmd::LoadStation),
_ => None,
}
}
async fn discover_service_aud(base: &str) -> String {
#[derive(Deserialize)]
struct Health {
#[serde(rename = "connectAud")]
connect_aud: Option<String>,
}
match reqwest::get(format!("{}/health", base.trim_end_matches('/'))).await {
Ok(r) => match r.json::<Health>().await {
Ok(h) => h
.connect_aud
.unwrap_or_else(|| DEFAULT_SERVICE_AUD.to_string()),
Err(_) => DEFAULT_SERVICE_AUD.to_string(),
},
Err(_) => DEFAULT_SERVICE_AUD.to_string(),
}
}
fn ws_url(base: &str) -> String {
let b = base.trim_end_matches('/');
let b = if let Some(rest) = b.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = b.strip_prefix("http://") {
format!("ws://{rest}")
} else {
b.to_string()
};
format!("{b}/connect")
}
pub fn load_or_create_device_id(session_path: &Path) -> String {
let path = session_path.with_file_name("device_id");
if let Ok(existing) = std::fs::read_to_string(&path) {
let t = existing.trim();
if !t.is_empty() {
return t.to_string();
}
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let id = format!("cli-{:x}-{:x}", std::process::id(), nanos);
let _ = std::fs::write(&path, &id);
id
}
pub fn default_device_name() -> String {
std::env::var("HOSTNAME")
.ok()
.or_else(|| std::env::var("COMPUTERNAME").ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|h| format!("atradio CLI · {h}"))
.unwrap_or_else(|| "atradio CLI".to_string())
}