sonos_cli/tui/
image_loader.rs1use std::cell::RefCell;
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::net::IpAddr;
14use std::sync::mpsc;
15use std::time::Duration;
16
17use image::DynamicImage;
18
19const MAX_CACHE_SIZE: usize = 20;
21
22const FETCH_TIMEOUT: Duration = Duration::from_secs(3);
24
25struct LoadRequest {
26 uri: String,
27 full_url: String,
28}
29
30struct LoadResult {
31 uri: String,
32 image: Option<DynamicImage>,
33}
34
35pub struct ImageLoader {
41 cache: HashMap<String, DynamicImage>,
42 insertion_order: VecDeque<String>,
44 pending: RefCell<HashSet<String>>,
46 result_rx: mpsc::Receiver<LoadResult>,
47 request_tx: mpsc::Sender<LoadRequest>,
48}
49
50impl Default for ImageLoader {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl ImageLoader {
57 pub fn new() -> Self {
58 let (request_tx, request_rx) = mpsc::channel::<LoadRequest>();
59 let (result_tx, result_rx) = mpsc::channel::<LoadResult>();
60
61 std::thread::Builder::new()
63 .name("album-art-loader".into())
64 .spawn(move || {
65 worker_loop(request_rx, result_tx);
66 })
67 .expect("failed to spawn album art loader thread");
68
69 Self {
70 cache: HashMap::new(),
71 insertion_order: VecDeque::new(),
72 pending: RefCell::new(HashSet::new()),
73 result_rx,
74 request_tx,
75 }
76 }
77
78 pub fn request(&self, uri: &str, speaker_ip: IpAddr) {
83 if self.cache.contains_key(uri) {
84 return;
85 }
86
87 let mut pending = self.pending.borrow_mut();
88 if pending.contains(uri) {
89 return;
90 }
91
92 let full_url = build_url(uri, speaker_ip);
93 let req = LoadRequest {
94 uri: uri.to_string(),
95 full_url,
96 };
97
98 if self.request_tx.send(req).is_ok() {
99 pending.insert(uri.to_string());
100 }
101 }
102
103 pub fn poll(&mut self) -> bool {
107 let mut loaded = false;
108 while let Ok(result) = self.result_rx.try_recv() {
109 self.pending.borrow_mut().remove(&result.uri);
110 if let Some(img) = result.image {
111 self.evict_if_full();
112 self.insertion_order.push_back(result.uri.clone());
113 self.cache.insert(result.uri, img);
114 loaded = true;
115 }
116 }
117 loaded
118 }
119
120 pub fn get(&self, uri: &str) -> Option<&DynamicImage> {
122 self.cache.get(uri)
123 }
124
125 fn evict_if_full(&mut self) {
126 while self.cache.len() >= MAX_CACHE_SIZE {
127 if let Some(oldest) = self.insertion_order.pop_front() {
128 self.cache.remove(&oldest);
129 } else {
130 break;
131 }
132 }
133 }
134}
135
136fn build_url(uri: &str, speaker_ip: IpAddr) -> String {
142 if uri.starts_with("http://") || uri.starts_with("https://") {
143 uri.to_string()
144 } else {
145 format!("http://{speaker_ip}:1400{uri}")
146 }
147}
148
149fn worker_loop(rx: mpsc::Receiver<LoadRequest>, tx: mpsc::Sender<LoadResult>) {
151 let agent = ureq::Agent::config_builder()
152 .timeout_global(Some(FETCH_TIMEOUT))
153 .build()
154 .new_agent();
155
156 for req in rx {
157 let image = fetch_and_decode(&agent, &req.full_url);
158 let result = LoadResult {
159 uri: req.uri,
160 image,
161 };
162 if tx.send(result).is_err() {
163 break; }
165 }
166}
167
168fn fetch_and_decode(agent: &ureq::Agent, url: &str) -> Option<DynamicImage> {
170 let response = agent.get(url).call().ok()?;
171
172 let status = response.status();
173 if status != 200 {
174 tracing::debug!("Album art fetch returned {status} for {url}");
175 return None;
176 }
177
178 let body = response
180 .into_body()
181 .with_config()
182 .limit(5 * 1024 * 1024)
183 .read_to_vec()
184 .ok()?;
185
186 image::load_from_memory(&body)
187 .map_err(|e| {
188 tracing::debug!("Album art decode failed for {url}: {e}");
189 e
190 })
191 .ok()
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn build_url_relative_path() {
200 let ip: IpAddr = "192.168.1.100".parse().unwrap();
201 assert_eq!(
202 build_url("/getaa?s=1&u=test", ip),
203 "http://192.168.1.100:1400/getaa?s=1&u=test"
204 );
205 }
206
207 #[test]
208 fn build_url_absolute_http() {
209 let ip: IpAddr = "192.168.1.100".parse().unwrap();
210 let url = "http://example.com/art.jpg";
211 assert_eq!(build_url(url, ip), url);
212 }
213
214 #[test]
215 fn build_url_absolute_https() {
216 let ip: IpAddr = "192.168.1.100".parse().unwrap();
217 let url = "https://example.com/art.jpg";
218 assert_eq!(build_url(url, ip), url);
219 }
220}