Skip to main content

sonos_cli/tui/
image_loader.rs

1//! Background image fetcher with in-memory cache.
2//!
3//! Fetches album art from Sonos speakers over HTTP in a background thread,
4//! decodes images via the `image` crate, and caches decoded `DynamicImage`s
5//! by URI. The main thread polls for completed loads each event loop tick.
6//!
7//! Design: single-threaded access from the TUI event loop. `request()` uses
8//! `RefCell` for the pending set so it can be called from render functions
9//! that only have `&self` access.
10
11use 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
19/// Maximum number of cached images before evicting oldest entries.
20const MAX_CACHE_SIZE: usize = 20;
21
22/// HTTP fetch timeout for album art requests.
23const 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
35/// Background image loader with request/poll/get API.
36///
37/// - `request()` — enqueue a fetch (callable from render with `&self`)
38/// - `poll()` — drain completed fetches into cache (callable from event loop with `&mut self`)
39/// - `get()` — read from cache (callable from render with `&self`)
40pub struct ImageLoader {
41    cache: HashMap<String, DynamicImage>,
42    /// Insertion order for LRU eviction.
43    insertion_order: VecDeque<String>,
44    /// URIs currently being fetched (RefCell for &self access from render).
45    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        // Spawn a single worker thread for fetching images
62        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    /// Request an image fetch if not already cached or pending.
79    ///
80    /// Callable from render functions with `&self`. The fetch happens in a
81    /// background thread; call `poll()` from the event loop to collect results.
82    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    /// Drain completed fetches into the cache. Call from event loop each tick.
104    ///
105    /// Returns `true` if any new images were loaded (should mark app dirty).
106    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    /// Get a cached image by URI.
121    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
136/// Build a full URL from an album art URI and speaker IP.
137///
138/// Sonos speakers return `album_art_uri` as either:
139/// - A relative path: `/getaa?s=1&u=...` → prepend `http://{ip}:1400`
140/// - An absolute URL: `http://...` → use as-is
141fn 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
149/// Worker thread: receives fetch requests, downloads + decodes images, sends results back.
150fn 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; // main thread dropped, exit
164        }
165    }
166}
167
168/// Fetch an image over HTTP and decode it.
169fn 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    // Read body into memory (limit to 5MB to prevent OOM)
179    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}