gled-core 2.31.2

The engine of gled without a user interface: inputs, timing, rendering, Art-Net/DMX output and storage
Documentation
//! Pictures from the asset repository for the interfaces: each asks for the
//! ones it shows and gets them, to itself alone. A thread reads them, so a
//! large photo does not hold up a tick. Pictures uploaded while the engine
//! runs are answered from memory: another interface may ask before the
//! storage thread has written one.

use std::{
    collections::HashMap,
    path::{Path, PathBuf},
    sync::Arc,
    thread,
};

use gled_model::asset::picture::is_picture_path;
use gled_protocol::{bytes::SharedBytes, state::EngineEvent};
use kanal::{Sender, unbounded};
use tracing::warn;

use crate::storage::{STORAGE_DIR, action::StorageAction};

/// The largest picture handed out or stored, well within a message.
const MAX_BYTES: u64 = 32 * 1024 * 1024;

/// A request for the reader thread: the picture, and where the answer goes.
type Request = (PathBuf, Sender<EngineEvent>);

#[derive(Debug)]
pub(super) struct Pictures {
    uploads: HashMap<PathBuf, SharedBytes>,
    reader: Option<Sender<Request>>,
}

impl Default for Pictures {
    fn default() -> Self {
        let (reader, requests) = unbounded::<Request>();
        let started = thread::Builder::new()
            .name("gled:pictures".to_owned())
            .spawn(move || {
                while let Ok((path, reply)) = requests.recv() {
                    let picture = read(&STORAGE_DIR, &path);
                    let _ = reply.send(EngineEvent::Picture { path, picture });
                }
            });
        if let Err(err) = &started {
            warn!("Could not start reading pictures: {err}");
        }
        Self {
            uploads: HashMap::new(),
            reader: started.ok().map(|_| reader),
        }
    }
}

impl Pictures {
    /// Answers the request for the picture at `path` through `reply`.
    pub(super) fn request(&self, path: PathBuf, reply: &Sender<EngineEvent>) {
        let answer = |picture| {
            let _ = reply.send(EngineEvent::Picture {
                path: path.clone(),
                picture,
            });
        };
        if !is_picture_path(&path) {
            return answer(Err("no picture in the asset repository".to_owned()));
        }
        if let Some(bytes) = self.uploads.get(&path) {
            return answer(Ok(bytes.clone()));
        }
        match &self.reader {
            Some(reader) => {
                let _ = reader.send((path, reply.clone()));
            }
            None => answer(Err("the engine reads no pictures".to_owned())),
        }
    }

    /// Stores a picture an interface chose at `path` in the repository.
    pub(super) fn save(&mut self, path: PathBuf, bytes: SharedBytes) {
        if !is_picture_path(&path) || bytes.0.len() as u64 > MAX_BYTES {
            warn!("Refused to store {} as a picture", path.display());
            return;
        }
        self.uploads.insert(path.clone(), bytes.clone());
        StorageAction::SavePicture { path, bytes }.enqueue();
    }
}

/// The picture at `path` in the repository `repository`.
fn read(repository: &Path, path: &Path) -> Result<SharedBytes, String> {
    let file = repository.join(path);
    let size = std::fs::metadata(&file)
        .map_err(|err| format!("{}: {err}", path.display()))?
        .len();
    if size > MAX_BYTES {
        return Err(format!("{} is too large to show", path.display()));
    }
    std::fs::read(&file)
        .map(|bytes| SharedBytes(Arc::new(bytes)))
        .map_err(|err| format!("{}: {err}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_picture_is_read_from_the_repository() {
        let repository =
            std::env::temp_dir().join(format!("gled-pictures-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(repository.join("pictures")).expect("creates the repository");
        std::fs::write(repository.join("pictures/stage.png"), b"png").expect("writes");
        let bytes = read(&repository, Path::new("pictures/stage.png")).expect("reads it");
        assert_eq!(*bytes.0, b"png");
        assert!(read(&repository, Path::new("pictures/gone.png")).is_err());
    }

    #[test]
    fn a_path_leaving_the_repository_is_refused() {
        let pictures = Pictures {
            uploads: HashMap::new(),
            reader: None,
        };
        let (reply, answers) = unbounded();
        pictures.request(PathBuf::from("../secret.png"), &reply);
        let Ok(Some(EngineEvent::Picture { picture, .. })) = answers.try_recv() else {
            panic!("an answer");
        };
        assert!(picture.is_err());
    }

    #[test]
    fn an_upload_is_answered_from_memory() {
        let mut pictures = Pictures {
            uploads: HashMap::new(),
            reader: None,
        };
        let path = PathBuf::from("pictures/stage.png");
        let bytes = SharedBytes(Arc::new(b"png".to_vec()));
        pictures.uploads.insert(path.clone(), bytes.clone());
        let (reply, answers) = unbounded();
        pictures.request(path, &reply);
        let Ok(Some(EngineEvent::Picture { picture, .. })) = answers.try_recv() else {
            panic!("an answer");
        };
        assert_eq!(picture, Ok(bytes));
    }
}