plavno 0.1.1

A lightweight Linux terminal music player with service waves and library shuffle
use image::{ImageReader, RgbImage, imageops::FilterType};
use ratatui::{buffer::Buffer, layout::Rect, style::Color};
use std::{
    collections::VecDeque,
    io::Cursor,
    sync::{
        Arc, Condvar, Mutex,
        mpsc::{self, Receiver},
    },
    time::Duration,
};

type Cover = Arc<RgbImage>;
type Reply = (String, Option<Cover>);

#[derive(Default)]
struct Pending {
    url: Option<String>,
    closed: bool,
}

#[derive(Default)]
struct Requests {
    pending: Mutex<Pending>,
    ready: Condvar,
}

pub struct Artwork {
    wanted: Option<String>,
    submitted: Option<String>,
    requests: Arc<Requests>,
    replies: Receiver<Reply>,
    cache: VecDeque<(String, Cover)>,
    image: Option<Cover>,
    raster: Option<RgbImage>,
}

impl Artwork {
    pub fn new() -> std::io::Result<Self> {
        let requests = Arc::new(Requests::default());
        let worker_requests = requests.clone();
        let (sender, replies) = mpsc::sync_channel(1);
        std::thread::Builder::new()
            .name("artwork".into())
            .spawn(move || {
                let http: ureq::Agent = ureq::Agent::config_builder()
                    .timeout_global(Some(Duration::from_secs(8)))
                    .build()
                    .into();
                loop {
                    let url = {
                        let mut pending = worker_requests.pending.lock().unwrap();
                        while pending.url.is_none() && !pending.closed {
                            pending = worker_requests.ready.wait(pending).unwrap();
                        }
                        if pending.closed {
                            break;
                        }
                        pending.url.take().unwrap()
                    };
                    let image = fetch(&http, &url).map(Arc::new);
                    if sender.send((url, image)).is_err() {
                        break;
                    }
                }
            })?;
        Ok(Self {
            wanted: None,
            submitted: None,
            requests,
            replies,
            cache: VecDeque::new(),
            image: None,
            raster: None,
        })
    }

    pub fn update(&mut self, url: Option<&str>) -> bool {
        let mut changed = false;
        if self.wanted.as_deref() != url {
            self.wanted = url.map(str::to_owned);
            self.submitted = None;
            self.requests.pending.lock().unwrap().url = None;
            self.image = self
                .cache
                .iter()
                .find(|(key, _)| Some(key.as_str()) == url)
                .map(|(_, image)| image.clone());
            self.raster = None;
            changed = true;
        }
        while let Ok((key, image)) = self.replies.try_recv() {
            if let Some(image) = image {
                self.cache.retain(|(cached, _)| *cached != key);
                self.cache.push_back((key.clone(), image.clone()));
                while self.cache.len() > 3 {
                    self.cache.pop_front();
                }
                if self.wanted.as_deref() == Some(&key) {
                    self.image = Some(image);
                    self.raster = None;
                    changed = true;
                }
            }
        }
        if self.image.is_none()
            && self.wanted != self.submitted
            && let Some(url) = &self.wanted
        {
            self.requests.pending.lock().unwrap().url = Some(url.clone());
            self.submitted = self.wanted.clone();
            self.requests.ready.notify_one();
        }
        changed
    }

    pub fn render(&mut self, area: Rect, buffer: &mut Buffer) {
        let Some(image) = &self.image else { return };
        if area.is_empty() {
            return;
        }
        // Two vertical pixels per cell compensate for the terminal's tall glyphs.
        let size = (u32::from(area.width), u32::from(area.height) * 2);
        if self.raster.as_ref().map(RgbImage::dimensions) != Some(size) {
            let (width, height) = image.dimensions();
            let (crop_width, crop_height) = if width * size.1 > height * size.0 {
                ((height * size.0 / size.1).max(1), height)
            } else {
                (width, (width * size.1 / size.0).max(1))
            };
            let cropped = image::imageops::crop_imm(
                &**image,
                (width - crop_width) / 2,
                (height - crop_height) / 2,
                crop_width,
                crop_height,
            )
            .to_image();
            let mut raster =
                image::imageops::resize(&cropped, size.0, size.1, FilterType::Triangle);
            for pixel in raster.pixels_mut() {
                for channel in &mut pixel.0 {
                    *channel = (u16::from(*channel) * 22 / 100) as u8;
                }
            }
            self.raster = Some(raster);
        }
        let raster = self.raster.as_ref().unwrap();
        for y in 0..area.height {
            for x in 0..area.width {
                let top = raster.get_pixel(u32::from(x), u32::from(y) * 2).0;
                let bottom = raster.get_pixel(u32::from(x), u32::from(y) * 2 + 1).0;
                buffer[(area.x + x, area.y + y)]
                    .set_char('â–€')
                    .set_fg(Color::Rgb(top[0], top[1], top[2]))
                    .set_bg(Color::Rgb(bottom[0], bottom[1], bottom[2]));
            }
        }
    }
}

impl Drop for Artwork {
    fn drop(&mut self) {
        let mut pending = self.requests.pending.lock().unwrap();
        pending.closed = true;
        pending.url = None;
        self.requests.ready.notify_one();
    }
}

fn fetch(http: &ureq::Agent, url: &str) -> Option<RgbImage> {
    let url = url::Url::parse(url).ok()?;
    if !matches!(url.scheme(), "http" | "https") {
        return None;
    }
    let bytes = http
        .get(url.as_str())
        .call()
        .ok()?
        .body_mut()
        .with_config()
        .limit(4 * 1024 * 1024)
        .read_to_vec()
        .ok()?;
    decode(&bytes)
}

fn decode(bytes: &[u8]) -> Option<RgbImage> {
    let mut reader = ImageReader::new(Cursor::new(bytes))
        .with_guessed_format()
        .ok()?;
    let mut limits = image::Limits::default();
    limits.max_image_width = Some(2048);
    limits.max_image_height = Some(2048);
    limits.max_alloc = Some(32 * 1024 * 1024);
    reader.limits(limits);
    Some(reader.decode().ok()?.thumbnail(192, 192).to_rgb8())
}

#[cfg(test)]
#[path = "artwork_regression.rs"]
mod regression_tests;

#[cfg(test)]
mod tests {
    use super::*;
    use crate::wave::Wave;
    use ratatui::widgets::Widget;

    fn artwork(image: RgbImage) -> Artwork {
        let requests = Arc::new(Requests::default());
        let (_, replies) = mpsc::sync_channel(1);
        Artwork {
            wanted: Some("cover".into()),
            submitted: Some("cover".into()),
            requests,
            replies,
            cache: VecDeque::new(),
            image: Some(Arc::new(image)),
            raster: None,
        }
    }

    #[test]
    fn wave_overlays_artwork_without_erasing_its_background() {
        let area = Rect::new(0, 0, 20, 8);
        let mut buffer = Buffer::empty(area);
        let mut artwork = artwork(RgbImage::from_pixel(12, 12, image::Rgb([200, 100, 50])));
        let inner = Rect::new(1, 1, 18, 6);
        artwork.render(inner, &mut buffer);
        (&Wave::default()).render(inner, &mut buffer);
        let cells = buffer
            .content
            .iter()
            .filter(|cell| cell.bg == Color::Rgb(44, 22, 11));
        assert_eq!(cells.clone().count(), 108);
        assert!(cells.clone().any(|cell| cell.symbol() == "â–€"));
        assert!(cells.clone().any(|cell| {
            cell.symbol()
                .chars()
                .any(|c| ('\u{2801}'..='\u{28ff}').contains(&c))
        }));
        let raster = artwork.raster.as_ref().unwrap().as_ptr();
        artwork.render(inner, &mut buffer);
        assert_eq!(artwork.raster.as_ref().unwrap().as_ptr(), raster);
        artwork.render(Rect::new(1, 1, 10, 3), &mut buffer);
        assert_eq!(artwork.raster.as_ref().unwrap().dimensions(), (10, 6));
    }

    #[test]
    fn late_previous_cover_does_not_replace_the_current_album() {
        let mut artwork = artwork(RgbImage::new(2, 2));
        let (sender, receiver) = mpsc::sync_channel(1);
        artwork.replies = receiver;
        sender
            .send(("old-cover".into(), Some(Arc::new(RgbImage::new(3, 3)))))
            .unwrap();
        artwork.update(Some("new-cover"));
        assert!(artwork.image.is_none());
        assert!(artwork.raster.is_none());
        artwork.update(Some("old-cover"));
        assert_eq!(artwork.image.as_ref().unwrap().dimensions(), (3, 3));
        artwork.update(None);
        assert!(artwork.image.is_none());
    }

    #[test]
    fn decode_rejects_invalid_and_oversized_images_and_keeps_small_thumbnails() {
        assert!(decode(b"not an image").is_none());
        for (width, expected) in [(600, Some((192, 1))), (2049, None)] {
            let image = RgbImage::new(width, 1);
            let mut bytes = Cursor::new(Vec::new());
            image::DynamicImage::ImageRgb8(image)
                .write_to(&mut bytes, image::ImageFormat::Png)
                .unwrap();
            assert_eq!(
                decode(bytes.get_ref()).map(|image| image.dimensions()),
                expected
            );
        }
    }

    #[test]
    fn worker_loads_http_artwork_once_and_reuses_it_after_hiding() {
        use std::{
            io::{BufRead, BufReader, Write},
            net::TcpListener,
            time::Instant,
        };
        let mut encoded = Cursor::new(Vec::new());
        image::DynamicImage::ImageRgb8(RgbImage::from_pixel(4, 4, image::Rgb([20, 40, 60])))
            .write_to(&mut encoded, image::ImageFormat::Png)
            .unwrap();
        let server = TcpListener::bind("127.0.0.1:0").unwrap();
        let url = format!("http://{}/cover.png", server.local_addr().unwrap());
        let serving = std::thread::spawn(move || {
            let (mut stream, _) = server.accept().unwrap();
            stream
                .set_read_timeout(Some(Duration::from_secs(3)))
                .unwrap();
            let mut reader = BufReader::new(&mut stream);
            let mut request = String::new();
            loop {
                let start = request.len();
                assert!(reader.read_line(&mut request).unwrap() > 0);
                assert!(request.len() < 4096);
                if &request[start..] == "\r\n" {
                    break;
                }
            }
            assert!(request.starts_with("GET /cover.png HTTP/1.1\r\n"));
            write!(
                stream,
                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                encoded.get_ref().len()
            )
            .unwrap();
            stream.write_all(encoded.get_ref()).unwrap();
        });
        let mut artwork = Artwork::new().unwrap();
        let start = Instant::now();
        while artwork.image.is_none() {
            artwork.update(Some(&url));
            assert!(
                start.elapsed() < Duration::from_secs(3),
                "Cover loading timed out"
            );
            std::thread::sleep(Duration::from_millis(5));
        }
        serving.join().unwrap();
        let image = artwork.image.as_ref().unwrap().clone();
        artwork.update(None);
        artwork.update(Some(&url));
        assert!(Arc::ptr_eq(&image, artwork.image.as_ref().unwrap()));
        assert!(
            artwork.submitted.is_none(),
            "A cached cover triggered another HTTP request"
        );
        assert_eq!(artwork.cache.len(), 1);
    }
}