use koan_relay_client::{ActivityState, Client, Error, Push as Relayed};
#[derive(Debug, Clone, PartialEq)]
pub enum Push {
Wake,
WakeNow,
Summon,
Notify {
claim: String,
},
Activity(ActivityState),
}
#[derive(Debug, PartialEq)]
pub enum Outcome {
Sent,
Gone,
Failed(String),
}
pub struct Pusher {
client: Client,
}
impl Pusher {
pub fn send(&self, handle: &str, push: &Push) -> Outcome {
let sent = match push {
Push::Wake => self.client.push(handle, Relayed::Wake),
Push::WakeNow => self.client.push(handle, Relayed::WakeNow),
Push::Summon => self.client.push(handle, Relayed::Summon { blob: None }),
Push::Notify { claim } => self.client.push(
handle,
Relayed::Notify {
blob: Some(claim.as_str()),
},
),
Push::Activity(state) => self.client.update_activity(handle, state),
};
match sent {
Ok(()) => Outcome::Sent,
Err(Error::Gone) => Outcome::Gone,
Err(e) => Outcome::Failed(e.to_string()),
}
}
}
pub fn activity_state(info: &crate::clients::ClientInfo) -> ActivityState {
ActivityState {
device: info.name.clone(),
linked: true,
title: info.state.title.clone(),
artist: info.state.artist.clone(),
album: info.state.album.clone(),
playing: info.state.playing,
position_ms: info.position_ms(),
duration_ms: info.state.duration_ms,
at: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
art: info
.state
.queue
.iter()
.find(|e| e.current)
.and_then(|e| e.track_id.as_deref())
.and_then(activity_art),
}
}
pub fn differs(state: &ActivityState, sent: &ActivityState) -> bool {
let strip = |s: &ActivityState| ActivityState {
position_ms: 0,
at: 0.0,
..s.clone()
};
if strip(state) != strip(sent) {
return true;
}
let expected = if sent.playing {
sent.position_ms + ((state.at - sent.at).max(0.0) * 1000.0) as u64
} else {
sent.position_ms
};
state.position_ms.abs_diff(expected) > 3000
}
const ART_BUDGET: usize = 2600;
fn activity_art(track: &str) -> Option<String> {
use base64::Engine as _;
static LAST: parking_lot::Mutex<Option<(String, Option<String>)>> =
parking_lot::Mutex::new(None);
static COVERS: std::sync::LazyLock<crate::covers::Covers> =
std::sync::LazyLock::new(crate::covers::Covers::in_config_dir);
if cfg!(test) {
return None;
}
if let Some((held, art)) = LAST.lock().as_ref()
&& held == track
{
return art.clone();
}
let art = (|| {
let db = koan_core::db::pool::shared().get().ok()?;
let id = koan_core::db::queries::resolve_id(
&db.conn,
koan_core::db::queries::UidKind::Track,
track,
)
.ok()??;
let row = koan_core::db::queries::get_track_row(&db.conn, id).ok()??;
let cover = COVERS.cover(std::slice::from_ref(&row), crate::covers::SIZES[0])?;
let img = image::load_from_memory(&cover).ok()?;
[(72, 60), (60, 50), (48, 40)]
.into_iter()
.find_map(|(side, quality)| {
let mut out = Vec::new();
let small = img.thumbnail(side, side).to_rgb8();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality)
.encode_image(&small)
.ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
(b64.len() <= ART_BUDGET).then_some(b64)
})
})();
*LAST.lock() = Some((track.to_string(), art.clone()));
art
}
pub fn pusher() -> Option<&'static Pusher> {
static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
let cfg = koan_core::config::Config::load().unwrap_or_default();
if !cfg.push.enabled {
log::info!("push: off");
return None;
}
let relay = koan_core::remote::relay::RELAY;
match Client::new(relay) {
Ok(client) => {
log::info!("push: through {relay}");
Some(Pusher { client })
}
Err(e) => {
log::warn!("push: the relay {relay} cannot be used: {e}");
None
}
}
});
PUSHER.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
fn state(position_ms: u64, at: f64, playing: bool) -> ActivityState {
ActivityState {
device: "Desk".into(),
linked: true,
title: Some("Blue in Green".into()),
artist: None,
album: None,
playing,
position_ms,
duration_ms: 337_000,
at,
art: None,
}
}
#[test]
fn a_playhead_moving_on_time_is_no_change() {
let sent = state(10_000, 100.0, true);
assert!(!differs(&state(15_000, 105.0, true), &sent));
assert!(differs(&state(60_000, 105.0, true), &sent), "a seek");
assert!(differs(&state(15_000, 105.0, false), &sent), "a pause");
}
#[test]
fn a_paused_playhead_stays_put() {
let sent = state(10_000, 100.0, false);
assert!(!differs(&state(10_000, 500.0, false), &sent));
}
}