lastui 0.3.0

Command line tool to access Last.fm api and commands
use crate::config::Config;
use anyhow::Result;
use image::{DynamicImage, imageops::FilterType};
use ratatui::{
    style::{Color, Style},
    text::{Line, Span},
};
use serde_json::Value;

const ASCII_CHARS: &[char] = &['@', '%', '#', '*', '+', '=', '-', ':', '.'];

pub struct DashboardStats {
    pub username: String,
    pub weekly_scrobbles: u32,
    pub top_track: String,
    pub top_artist: String,
    pub top_album: String,
    pub now_playing: String,
    pub total_scrobbles: String,
    pub cover_image_url: Option<String>,
}

pub fn fetch(cfg: &Config) -> Result<Option<DashboardStats>> {
    let user_info_url = format!(
        "https://ws.audioscrobbler.com/2.0/?method=user.getinfo&user={}&api_key={}&format=json",
        cfg.username, cfg.api_key
    );

    let top_tracks_url = format!(
        "https://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user={}&api_key={}&format=json&period=7day&limit=1",
        cfg.username, cfg.api_key
    );

    let top_artists_url = format!(
        "https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user={}&api_key={}&format=json&period=7day&limit=1",
        cfg.username, cfg.api_key
    );

    let top_albums_url = format!(
        "https://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user={}&api_key={}&format=json&period=7day&limit=1",
        cfg.username, cfg.api_key
    );

    let recent_url = format!(
        "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={}&api_key={}&format=json&limit=1",
        cfg.username, cfg.api_key
    );

    let weekly_url = format!(
        "https://ws.audioscrobbler.com/2.0/?method=user.getweeklytrackchart&user={}&api_key={}&format=json",
        cfg.username, cfg.api_key
    );

    let user_info: Value = reqwest::blocking::get(&user_info_url)?.json()?;
    let top_tracks: Value = reqwest::blocking::get(&top_tracks_url)?.json()?;
    let top_artists: Value = reqwest::blocking::get(&top_artists_url)?.json()?;
    let top_albums: Value = reqwest::blocking::get(&top_albums_url)?.json()?;
    let recent: Value = reqwest::blocking::get(&recent_url)?.json()?;
    let weekly: Value = reqwest::blocking::get(&weekly_url)?.json()?;

    let username = user_info["user"]["name"]
        .as_str()
        .unwrap_or(&cfg.username)
        .to_string();

    let total_scrobbles = user_info["user"]["playcount"]
        .as_str()
        .unwrap_or("?")
        .to_string();

    let top_track = top_tracks["toptracks"]["track"]
        .as_array()
        .and_then(|arr| arr.first())
        .map(|track| {
            let name = track["name"].as_str().unwrap_or("?");
            let artist = track["artist"]["name"].as_str().unwrap_or("?");
            format!("{name} - {artist}")
        })
        .unwrap_or_else(|| String::from("No user stats"));

    let top_artist = top_artists["topartists"]["artist"]
        .as_array()
        .and_then(|arr| arr.first())
        .map(|artist| artist["name"].as_str().unwrap_or("?").to_string())
        .unwrap_or_else(|| String::from("No user stats"));

    let top_album_value = top_albums["topalbums"]["album"]
        .as_array()
        .and_then(|arr| arr.first());

    let top_album = top_album_value
        .map(|album| {
            let name = album["name"].as_str().unwrap_or("?");
            let artist = album["artist"]["name"].as_str().unwrap_or("?");
            format!("{name} - {artist}")
        })
        .unwrap_or_else(|| String::from("No user stats"));

    let cover_image_url = top_album_value
        .and_then(|album| album["image"].as_array())
        .and_then(|images| {
            images
                .iter()
                .rev()
                .find_map(|img| img["#text"].as_str())
                .filter(|url| !url.is_empty())
        })
        .map(String::from);

    let now_playing = recent["recenttracks"]["track"]
        .as_array()
        .and_then(|arr| arr.first())
        .map(|track| {
            let name = track["name"].as_str().unwrap_or("?");
            let artist = track["artist"]["#text"].as_str().unwrap_or("?");
            let is_now_playing = track["@attr"]["nowplaying"].as_str() == Some("true");

            if is_now_playing {
                format!("{name} - {artist}")
            } else {
                String::from("Nothing playing")
            }
        })
        .unwrap_or_else(|| String::from("Nothing playing"));

    let weekly_scrobbles: u32 = weekly["weeklytrackchart"]["track"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|track| track["playcount"].as_str())
                .filter_map(|count| count.parse::<u32>().ok())
                .sum()
        })
        .unwrap_or(0);

    if weekly_scrobbles == 0 && top_track == "No user stats" && top_artist == "No user stats" && top_album == "No user stats" {
        return Ok(None);
    }

    Ok(Some(DashboardStats {
        username,
        weekly_scrobbles,
        top_track,
        top_artist,
        top_album,
        now_playing,
        total_scrobbles,
        cover_image_url,
    }))
}

pub fn cover_to_ascii(url: &str, width: u32) -> Result<Vec<Line<'static>>> {
    let bytes = reqwest::blocking::get(url)?.bytes()?;
    let image = image::load_from_memory(&bytes)?;
    Ok(image_to_ascii(image, width))
}

fn image_to_ascii(image: DynamicImage, width: u32) -> Vec<Line<'static>> {
    let rgb = image.to_rgb8();

    let (src_w, src_h) = rgb.dimensions();
    if src_w == 0 || src_h == 0 {
        return vec![Line::from("No user stats")];
    }

    let aspect = src_h as f32 / src_w as f32;
    let height = ((width as f32 * aspect) * 0.55).max(1.0) as u32;
    let resized = image.resize_exact(width, height, FilterType::Triangle).to_rgb8();

    let mut lines = Vec::new();

    for y in 0..resized.height() {
        let mut spans = Vec::new();

        for x in 0..resized.width() {
            let pixel = resized.get_pixel(x, y);
            let [r, g, b] = pixel.0;
            let [fg_r, fg_g, fg_b] = boost_color([r, g, b]);
            let [bg_r, bg_g, bg_b] = darken_color([r, g, b]);

            let brightness =
                (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0;
            let normalized = brightness.powf(0.82);
            let index = ((ASCII_CHARS.len() - 1) as f32 * normalized).round() as usize;
            let ch = ASCII_CHARS[index];

            spans.push(Span::styled(
                ch.to_string(),
                Style::default()
                    .fg(Color::Rgb(fg_r, fg_g, fg_b))
                    .bg(Color::Rgb(bg_r, bg_g, bg_b)),
            ));
        }

        lines.push(Line::from(spans));
    }

    lines
}

fn boost_color([r, g, b]: [u8; 3]) -> [u8; 3] {
    let [r, g, b] = saturate([r, g, b], 1.22);
    let [r, g, b] = brighten([r, g, b], 1.08);
    [r, g, b]
}

fn darken_color([r, g, b]: [u8; 3]) -> [u8; 3] {
    [
        scale_channel(r, 0.45),
        scale_channel(g, 0.45),
        scale_channel(b, 0.45),
    ]
}

fn saturate([r, g, b]: [u8; 3], amount: f32) -> [u8; 3] {
    let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;

    [
        clamp_channel(luma + (r as f32 - luma) * amount),
        clamp_channel(luma + (g as f32 - luma) * amount),
        clamp_channel(luma + (b as f32 - luma) * amount),
    ]
}

fn brighten([r, g, b]: [u8; 3], amount: f32) -> [u8; 3] {
    [
        scale_channel(r, amount),
        scale_channel(g, amount),
        scale_channel(b, amount),
    ]
}

fn scale_channel(value: u8, amount: f32) -> u8 {
    clamp_channel(value as f32 * amount)
}

fn clamp_channel(value: f32) -> u8 {
    value.clamp(0.0, 255.0).round() as u8
}