showreet 0.5.2

A GPU-accelerated video thumbnail browser with inline playback
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use md5::Digest;

/// Supported thumbnail sizes and their cache directories (FreeDesktop spec)
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ThumbnailSize {
    Small = 128,
    Normal = 256,
    Large = 512,
    Huge = 1024,
}

impl ThumbnailSize {
    pub fn dir_name(&self) -> &str {
        match self {
            ThumbnailSize::Small => "normal",
            ThumbnailSize::Normal => "large",
            ThumbnailSize::Large => "x-large",
            ThumbnailSize::Huge => "xx-large",
        }
    }

    pub fn pixel_size(&self) -> u32 {
        *self as u32
    }

    /// Pick the best cache size for a given display size
    pub fn for_display(display_size: u32) -> Self {
        match display_size {
            0..=192 => ThumbnailSize::Small,
            193..=384 => ThumbnailSize::Normal,
            385..=768 => ThumbnailSize::Large,
            _ => ThumbnailSize::Huge,
        }
    }
}

/// Find or generate a thumbnail for a video file
#[derive(Clone)]
pub struct Thumbnailer {
    cache_base: PathBuf,
}

impl Thumbnailer {
    pub fn new() -> Self {
        // Allow env var override on all platforms
        let cache_base = if let Ok(dir) = std::env::var("SHOWREET_THUMBNAIL_DIR") {
            PathBuf::from(dir)
        } else if cfg!(target_os = "windows") {
            // %LOCALAPPDATA%\showreet\thumbnails
            let local = std::env::var("LOCALAPPDATA")
                .unwrap_or_else(|_| std::env::var("APPDATA").unwrap_or_default());
            PathBuf::from(local).join("showreet").join("thumbnails")
        } else if cfg!(target_os = "macos") {
            // ~/Library/Caches/showreet/thumbnails
            let home = std::env::var("HOME").unwrap_or_default();
            PathBuf::from(home).join("Library").join("Caches").join("showreet").join("thumbnails")
        } else {
            // Linux/BSD: $XDG_CACHE_HOME/thumbnails or ~/.cache/thumbnails
            std::env::var("XDG_CACHE_HOME")
                .ok()
                .map(PathBuf::from)
                .unwrap_or_else(|| {
                    let home = std::env::var("HOME").unwrap_or_default();
                    PathBuf::from(home).join(".cache")
                })
                .join("thumbnails")
        };

        Self { cache_base }
    }

    /// Get thumbnail path for a video file, generating if needed
    pub fn get_thumbnail(
        &self,
        video_path: &Path,
        display_size: u32,
    ) -> Option<PathBuf> {
        let size = ThumbnailSize::for_display(display_size);
        let md5 = file_uri_md5(video_path);

        // Check cache: try requested size first, then smaller sizes as fallback
        let sizes_to_try = match size {
            ThumbnailSize::Huge => vec![
                ThumbnailSize::Huge,
                ThumbnailSize::Large,
                ThumbnailSize::Normal,
                ThumbnailSize::Small,
            ],
            ThumbnailSize::Large => vec![
                ThumbnailSize::Large,
                ThumbnailSize::Normal,
                ThumbnailSize::Small,
            ],
            ThumbnailSize::Normal => vec![ThumbnailSize::Normal, ThumbnailSize::Small],
            ThumbnailSize::Small => vec![ThumbnailSize::Small],
        };

        for s in &sizes_to_try {
            let path = self.cache_path(&md5, s);
            if path.exists() {
                return Some(path);
            }
        }

        // Generate if not found
        self.generate(&md5, video_path, size)
    }

    fn cache_path(&self, md5: &str, size: &ThumbnailSize) -> PathBuf {
        self.cache_base.join(size.dir_name()).join(format!("{}.png", md5))
    }

    fn generate(
        &self,
        md5: &str,
        video_path: &Path,
        size: ThumbnailSize,
    ) -> Option<PathBuf> {
        let target_dir = self.cache_base.join(size.dir_name());
        let _ = std::fs::create_dir_all(&target_dir);

        let output_path = target_dir.join(format!("{}.png", md5));
        let temp_path = target_dir.join(format!("{}.tmp.png", md5));

        // Try ffmpegthumbnailer first (fast, dedicated tool)
        if try_ffmpegthumbnailer(video_path, &temp_path, size.pixel_size()) {
            let _ = std::fs::rename(&temp_path, &output_path);
            return Some(output_path);
        }

        // Fallback: GStreamer pipeline (cross-platform, no external binary needed)
        if try_gstreamer(video_path, &temp_path, size.pixel_size()) {
            let _ = std::fs::rename(&temp_path, &output_path);
            return Some(output_path);
        }

        let _ = std::fs::remove_file(&temp_path);
        None
    }
}

/// Try generating thumbnail with ffmpegthumbnailer (Linux/FreeDesktop).
fn try_ffmpegthumbnailer(video_path: &Path, output: &Path, size: u32) -> bool {
    Command::new("ffmpegthumbnailer")
        .args([
            "-i",
            video_path.to_str().unwrap_or(""),
            "-o",
            output.to_str().unwrap_or(""),
            "-s",
            &size.to_string(),
            "-q",
            "8",
            "-m",
        ])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Fallback thumbnail generator using GStreamer (cross-platform).
fn try_gstreamer(video_path: &Path, output: &Path, size: u32) -> bool {
    use gstreamer::prelude::*;

    let _ = gstreamer::init();
    let height = size * 9 / 16;

    // Build proper file:// URI with forward slashes (required on Windows)
    let uri = format!(
        "file:///{}",
        video_path.to_string_lossy().replace('\\', "/")
    );
    let out_path = output.to_string_lossy().replace('\\', "\\\\");

    let pipeline_str = format!(
        "playbin uri=\"{}\" flags=0x2 video-sink=\"videoscale ! videoconvert ! video/x-raw,format=RGB,width={},height={} ! pngenc ! filesink location=\\\"{}\\\"\"",
        uri, size, height, out_path,
    );

    let pipeline = match gstreamer::parse::launch(&pipeline_str) {
        Ok(p) => p,
        Err(_) => return false,
    };
    let pipeline = match pipeline.downcast::<gstreamer::Pipeline>() {
        Ok(p) => p,
        Err(_) => return false,
    };
    let bus = match pipeline.bus() {
        Some(b) => b,
        None => return false,
    };

    if pipeline.set_state(gstreamer::State::Paused).is_err() {
        return false;
    }
    // Seek to ~5s to skip black intros
    if pipeline.seek_simple(
        gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::KEY_UNIT,
        gstreamer::ClockTime::from_seconds(5),
    ).is_err() {
        // Seek may fail on short videos; continue anyway
    }
    if pipeline.set_state(gstreamer::State::Playing).is_err() {
        return false;
    }

    let timeout = gstreamer::ClockTime::from_seconds(8);
    let started = Instant::now();
    loop {
        let elapsed = gstreamer::ClockTime::from_nseconds(
            started.elapsed().as_nanos() as u64,
        );
        let remaining = if elapsed < timeout {
            timeout - elapsed
        } else {
            gstreamer::ClockTime::ZERO
        };
        match bus.timed_pop(remaining) {
            Some(msg) => {
                use gstreamer::MessageView;
                match msg.view() {
                    MessageView::Error(_) => {
                        let _ = pipeline.set_state(gstreamer::State::Null);
                        return false;
                    }
                    MessageView::Eos(_) => break,
                    _ => {}
                }
            }
            None => {
                let _ = pipeline.set_state(gstreamer::State::Null);
                return false;
            }
        }
    }

    let _ = pipeline.set_state(gstreamer::State::Null);
    output.exists()
}

/// Compute MD5 of file:// URI as per FreeDesktop thumbnail spec
fn file_uri_md5(path: &Path) -> String {
    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    let uri = format!("file://{}", canonical.display());
    let mut hasher = md5::Md5::new();
    hasher.update(uri.as_bytes());
    let digest = hasher.finalize();
    format!("{:x}", digest)
}

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

    #[test]
    fn test_thumbnail_size_for_display() {
        assert_eq!(ThumbnailSize::for_display(100).pixel_size(), 128);
        assert_eq!(ThumbnailSize::for_display(200).pixel_size(), 256);
        assert_eq!(ThumbnailSize::for_display(400).pixel_size(), 512);
        assert_eq!(ThumbnailSize::for_display(800).pixel_size(), 1024);
    }
}