binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
mod parsers;
mod spotlight;
mod types;
pub(crate) mod ui;

use crate::preview::doc::{format_file_size, format_unix_timestamp};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use std::fs;
use std::path::Path;

pub use self::types::MediaKind;
use self::types::{MediaMetadata, MediaPreviewPayload};

pub fn detect_media_kind(path: &Path) -> Option<MediaKind> {
    let ext = path.extension()?.to_str()?;
    if is_audio_extension(ext) {
        return Some(MediaKind::Audio);
    }
    if is_video_extension(ext) {
        return Some(MediaKind::Video);
    }
    None
}

pub fn generate_preview(path: &Path, kind: MediaKind) -> MediaPreviewPayload {
    let mut lines = Vec::new();

    append_file_info(path, &mut lines);

    let mut metadata = MediaMetadata::default();
    if let Some(spotlight) = spotlight::read_spotlight_metadata(path, &kind) {
        metadata.merge(spotlight);
    }
    if matches!(kind, MediaKind::Audio) {
        if let Some(id3) = parsers::read_mp3_id3(path) {
            metadata.merge(id3);
        }
        if let Some(flac) = parsers::read_flac_metadata(path) {
            metadata.merge(flac);
        }
    }

    append_metadata(path, &kind, &metadata, &mut lines);
    MediaPreviewPayload {
        text: Text::from(lines),
        artwork_bytes: metadata.artwork.as_ref().map(|a| a.data.clone()),
    }
}

fn append_file_info(path: &Path, lines: &mut Vec<Line<'static>>) {
    lines.push(styled_line("File Info", Color::Yellow, true));
    if let Ok(meta) = fs::metadata(path) {
        push_field(
            lines,
            "File",
            path.file_name().and_then(|n| n.to_str()).unwrap_or("-"),
        );
        push_field(lines, "Size", &format_file_size(meta.len()));
        if let Ok(modified) = meta.modified() {
            if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
                push_field(
                    lines,
                    "Modified",
                    &format_unix_timestamp(duration.as_secs()),
                );
            }
        }
    } else {
        push_field(lines, "File", "Unable to read file metadata");
    }
    lines.push(Line::from(""));
}

fn append_metadata(
    path: &Path,
    kind: &MediaKind,
    m: &MediaMetadata,
    lines: &mut Vec<Line<'static>>,
) {
    lines.push(styled_line("Metadata", Color::Yellow, true));

    let fallback_name = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("Unknown");

    push_field(lines, "Name", m.title.as_deref().unwrap_or(fallback_name));
    push_field(lines, "Artist", optional_text(&m.artist));

    match kind {
        MediaKind::Audio => {
            push_field(lines, "Album", optional_text(&m.album));
            push_field(lines, "Genre", optional_text(&m.genre));
            push_field(lines, "Composer", optional_text(&m.composer));
            push_field(lines, "Track", optional_text(&m.track));
        }
        MediaKind::Video => {
            push_field(lines, "Resolution", optional_text(&m.resolution));
            push_field(lines, "Codec", optional_text(&m.codec));
            push_field(lines, "Frame Rate", optional_text(&m.frame_rate));
        }
    }

    push_field(lines, "Duration", optional_text(&m.duration));
    push_field(lines, "Bitrate", optional_text(&m.bitrate));
    push_field(lines, "Sample Rate", optional_text(&m.sample_rate));
    push_field(lines, "Channels", optional_text(&m.channels));
    push_field(lines, "Year", optional_text(&m.year));

    lines.push(Line::from(""));
    lines.push(styled_line("Artwork / Images", Color::Yellow, true));
    if let Some(art) = &m.artwork {
        let mut summary = format!("Embedded image ({}, {} bytes)", art.mime, art.size_bytes);
        if let Some((w, h)) = art.dimensions {
            summary = format!("{summary}, {w}x{h}");
        }
        push_field(lines, "Artwork", &summary);
    } else {
        push_field(lines, "Artwork", "Not available");
    }
}

fn push_field(lines: &mut Vec<Line<'static>>, key: &str, value: &str) {
    lines.push(Line::from(vec![
        Span::styled(
            format!("   {}: ", key),
            Style::default().fg(Color::DarkGray),
        ),
        Span::styled(value.to_string(), Style::default().fg(Color::White)),
    ]));
}

fn styled_line(text: &'static str, color: Color, bold: bool) -> Line<'static> {
    let mut style = Style::default().fg(color);
    if bold {
        style = style.add_modifier(Modifier::BOLD);
    }
    Line::from(vec![Span::styled(text, style)])
}

fn optional_text(v: &Option<String>) -> &str {
    v.as_deref()
        .filter(|s| !s.is_empty())
        .unwrap_or("Not available")
}

fn is_audio_extension(ext: &str) -> bool {
    matches!(
        ext.to_ascii_lowercase().as_str(),
        "mp3" | "m4a" | "aac" | "flac" | "wav" | "ogg" | "opus" | "aiff" | "alac"
    )
}

fn is_video_extension(ext: &str) -> bool {
    matches!(
        ext.to_ascii_lowercase().as_str(),
        "mp4" | "m4v" | "mov" | "mkv" | "webm" | "avi" | "wmv" | "flv" | "mpeg" | "mpg"
    )
}