1use koan_relay_client::{ActivityState, Client, Error, Push as Relayed};
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum Push {
22 Wake,
23 WakeNow,
24 Summon,
26 Notify {
29 claim: String,
30 },
31 Activity(ActivityState),
32}
33
34#[derive(Debug, PartialEq)]
36pub enum Outcome {
37 Sent,
38 Gone,
40 Failed(String),
41}
42
43pub struct Pusher {
44 client: Client,
45}
46
47impl Pusher {
48 pub fn send(&self, handle: &str, push: &Push) -> Outcome {
51 let sent = match push {
52 Push::Wake => self.client.push(handle, Relayed::Wake),
53 Push::WakeNow => self.client.push(handle, Relayed::WakeNow),
54 Push::Summon => self.client.push(handle, Relayed::Summon { blob: None }),
55 Push::Notify { claim } => self.client.push(
56 handle,
57 Relayed::Notify {
58 blob: Some(claim.as_str()),
59 },
60 ),
61 Push::Activity(state) => self.client.update_activity(handle, state),
62 };
63 match sent {
64 Ok(()) => Outcome::Sent,
65 Err(Error::Gone) => Outcome::Gone,
66 Err(e) => Outcome::Failed(e.to_string()),
67 }
68 }
69}
70
71pub fn activity_state(info: &crate::clients::ClientInfo) -> ActivityState {
73 ActivityState {
74 device: info.name.clone(),
75 linked: true,
76 title: info.state.title.clone(),
77 artist: info.state.artist.clone(),
78 album: info.state.album.clone(),
79 playing: info.state.playing,
80 position_ms: info.position_ms(),
81 duration_ms: info.state.duration_ms,
82 at: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
83 art: info
84 .state
85 .queue
86 .iter()
87 .find(|e| e.current)
88 .and_then(|e| e.track_id.as_deref())
89 .and_then(activity_art),
90 }
91}
92
93pub fn differs(state: &ActivityState, sent: &ActivityState) -> bool {
96 let strip = |s: &ActivityState| ActivityState {
97 position_ms: 0,
98 at: 0.0,
99 ..s.clone()
100 };
101 if strip(state) != strip(sent) {
102 return true;
103 }
104 let expected = if sent.playing {
105 sent.position_ms + ((state.at - sent.at).max(0.0) * 1000.0) as u64
106 } else {
107 sent.position_ms
108 };
109 state.position_ms.abs_diff(expected) > 3000
110}
111
112const ART_BUDGET: usize = 2600;
115
116fn activity_art(track: &str) -> Option<String> {
120 use base64::Engine as _;
121 static LAST: parking_lot::Mutex<Option<(String, Option<String>)>> =
122 parking_lot::Mutex::new(None);
123 static COVERS: std::sync::LazyLock<crate::covers::Covers> =
124 std::sync::LazyLock::new(crate::covers::Covers::in_config_dir);
125 if cfg!(test) {
126 return None;
127 }
128 if let Some((held, art)) = LAST.lock().as_ref()
129 && held == track
130 {
131 return art.clone();
132 }
133 let art = (|| {
134 let db = koan_core::db::pool::shared().get().ok()?;
135 let id = koan_core::db::queries::resolve_id(
136 &db.conn,
137 koan_core::db::queries::UidKind::Track,
138 track,
139 )
140 .ok()??;
141 let row = koan_core::db::queries::get_track_row(&db.conn, id).ok()??;
142 let cover = COVERS.cover(std::slice::from_ref(&row), crate::covers::SIZES[0])?;
143 let img = image::load_from_memory(&cover).ok()?;
144 [(72, 60), (60, 50), (48, 40)]
146 .into_iter()
147 .find_map(|(side, quality)| {
148 let mut out = Vec::new();
149 let small = img.thumbnail(side, side).to_rgb8();
150 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality)
151 .encode_image(&small)
152 .ok()?;
153 let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
154 (b64.len() <= ART_BUDGET).then_some(b64)
155 })
156 })();
157 *LAST.lock() = Some((track.to_string(), art.clone()));
158 art
159}
160
161pub fn pusher() -> Option<&'static Pusher> {
164 static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
165 let cfg = koan_core::config::Config::load().unwrap_or_default();
166 if !cfg.push.enabled {
167 log::info!("push: off");
168 return None;
169 }
170 let relay = koan_core::remote::relay::RELAY;
171 match Client::new(relay) {
172 Ok(client) => {
173 log::info!("push: through {relay}");
174 Some(Pusher { client })
175 }
176 Err(e) => {
177 log::warn!("push: the relay {relay} cannot be used: {e}");
178 None
179 }
180 }
181 });
182 PUSHER.as_ref()
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn state(position_ms: u64, at: f64, playing: bool) -> ActivityState {
190 ActivityState {
191 device: "Desk".into(),
192 linked: true,
193 title: Some("Blue in Green".into()),
194 artist: None,
195 album: None,
196 playing,
197 position_ms,
198 duration_ms: 337_000,
199 at,
200 art: None,
201 }
202 }
203
204 #[test]
205 fn a_playhead_moving_on_time_is_no_change() {
206 let sent = state(10_000, 100.0, true);
207 assert!(!differs(&state(15_000, 105.0, true), &sent));
208 assert!(differs(&state(60_000, 105.0, true), &sent), "a seek");
209 assert!(differs(&state(15_000, 105.0, false), &sent), "a pause");
210 }
211
212 #[test]
213 fn a_paused_playhead_stays_put() {
214 let sent = state(10_000, 100.0, false);
215 assert!(!differs(&state(10_000, 500.0, false), &sent));
216 }
217}