kvasir-cli 0.1.2

Command-line client for the kvasir music library.
use std::time::{Duration, Instant};

use tao::event::{Event, StartCause, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop};
use tao::platform::run_return::EventLoopExtRunReturn;
use tao::window::WindowBuilder;
use wry::http::Request;
use wry::{WebContext, WebViewBuilder};

use crate::config::{self, Config};
use crate::jobs;

const APP_HTML: &str = include_str!("gui.html");
const BOOT: &str = r#"
window.kvasir = window.kvasir || {};
window.kvasir.send = (msg) => window.ipc.postMessage(JSON.stringify(msg));
"#;

pub fn run() -> Result<(), Box<dyn std::error::Error>> {
    let mut event_loop = EventLoop::new();
    let window = WindowBuilder::new()
        .with_title("Kvasir")
        .with_inner_size(tao::dpi::LogicalSize::new(980.0, 760.0))
        .build(&event_loop)?;
    let login_window = WindowBuilder::new()
        .with_title("Sign in to Deezer")
        .with_inner_size(tao::dpi::LogicalSize::new(1100.0, 820.0))
        .with_visible(false)
        .build(&event_loop)?;

    let (tx, rx) = std::sync::mpsc::channel::<String>();
    let ipc_tx = tx.clone();
    let mut context = web_context();
    let webview = WebViewBuilder::new_with_web_context(&mut context)
        .with_html(APP_HTML)
        .with_initialization_script(BOOT)
        .with_ipc_handler(move |request: Request<String>| {
            let _ = ipc_tx.send(request.into_body());
        })
        .build(&window)?;
    let login_view = WebViewBuilder::new_with_web_context(&mut context)
        .with_url("https://www.deezer.com/login")
        .build(&login_window)?;

    let main_id = window.id();
    let login_id = login_window.id();
    let mut signing_in = false;
    let mut config = Config::load();

    event_loop.run_return(move |event, _, control_flow| {
        *control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(400));
        if matches!(event, Event::NewEvents(StartCause::Init)) {
            emit(&webview, &state_message(&config));
        }
        while let Ok(body) = rx.try_recv() {
            dispatch(&webview, &login_window, &login_view, &tx, &mut config, &mut signing_in, &body);
        }
        if signing_in {
            if let Some(arl) = read_arl(&login_view) {
                config.arl = arl;
                let _ = config.save();
                signing_in = false;
                login_window.set_visible(false);
                emit(&webview, &state_message(&config));
                emit(
                    &webview,
                    &serde_json::json!({"op":"status","level":"ok","text":"Signed in. You can search or paste a link."}),
                );
            }
        }
        if let Event::WindowEvent { window_id, event, .. } = event {
            if let WindowEvent::CloseRequested = event {
                if window_id == login_id {
                    signing_in = false;
                    login_window.set_visible(false);
                } else if window_id == main_id {
                    *control_flow = ControlFlow::Exit;
                }
            }
        }
    });
    Ok(())
}

pub fn login() -> Result<(), Box<dyn std::error::Error>> {
    let mut event_loop = EventLoop::new();
    let window = WindowBuilder::new()
        .with_title("Sign in to Deezer")
        .with_inner_size(tao::dpi::LogicalSize::new(1100.0, 820.0))
        .build(&event_loop)?;
    let mut context = web_context();
    let webview = WebViewBuilder::new_with_web_context(&mut context)
        .with_url("https://www.deezer.com/login")
        .build(&window)?;
    let window_id = window.id();
    let saved = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let saved_flag = saved.clone();
    event_loop.run_return(move |event, _, control_flow| {
        *control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(400));
        if let Some(arl) = read_arl(&webview) {
            let mut config = Config::load();
            config.arl = arl;
            match config.save() {
                Ok(path) => {
                    println!("Saved Deezer login to {}", path.display());
                    saved_flag.store(true, std::sync::atomic::Ordering::Relaxed);
                    *control_flow = ControlFlow::Exit;
                }
                Err(err) => eprintln!("Could not save login: {err}"),
            }
        }
        if let Event::WindowEvent { window_id: id, event: WindowEvent::CloseRequested, .. } = event {
            if id == window_id {
                *control_flow = ControlFlow::Exit;
            }
        }
    });
    if saved.load(std::sync::atomic::Ordering::Relaxed) {
        Ok(())
    } else {
        Err("The sign-in window closed before Deezer stored a session.".into())
    }
}

fn web_context() -> WebContext {
    let dir = config::config_dir().join("webview");
    let _ = std::fs::create_dir_all(&dir);
    WebContext::new(Some(dir))
}

fn dispatch(
    webview: &wry::WebView,
    login_window: &tao::window::Window,
    login_view: &wry::WebView,
    tx: &std::sync::mpsc::Sender<String>,
    config: &mut Config,
    signing_in: &mut bool,
    body: &str,
) {
    let op = serde_json::from_str::<serde_json::Value>(body)
        .ok()
        .and_then(|value| value.get("op").and_then(|value| value.as_str()).map(str::to_string));
    if matches!(op.as_deref(), Some("results" | "downloaded" | "status" | "busy")) {
        if let Ok(message) = serde_json::from_str::<serde_json::Value>(body) {
            emit(webview, &message);
        }
        return;
    }
    handle_ipc(webview, login_window, login_view, tx, config, signing_in, body);
}

fn handle_ipc(
    webview: &wry::WebView,
    login_window: &tao::window::Window,
    login_view: &wry::WebView,
    tx: &std::sync::mpsc::Sender<String>,
    config: &mut Config,
    signing_in: &mut bool,
    body: &str,
) {
    let Ok(message) = serde_json::from_str::<serde_json::Value>(body) else {
        return;
    };
    match message.get("op").and_then(|value| value.as_str()).unwrap_or("") {
        "ready" | "refresh" => emit(webview, &state_message(config)),
        "login" => {
            *signing_in = true;
            let _ = login_view.load_url("https://www.deezer.com/login");
            login_window.set_visible(true);
            login_window.set_focus();
            emit(
                webview,
                &serde_json::json!({"op":"status","level":"info","text":"Sign in in the Deezer window. Kvasir reads the session cookie itself."}),
            );
        }
        "logout" => {
            config.arl.clear();
            let _ = config.save();
            emit(webview, &state_message(config));
        }
        "reset" => match config::reset() {
            Ok(dir) => {
                *config = Config::default();
                emit(webview, &state_message(config));
                emit(
                    webview,
                    &serde_json::json!({"op":"status","level":"ok","text":format!("Removed {}", dir.display())}),
                );
            }
            Err(err) => emit(
                webview,
                &serde_json::json!({"op":"status","level":"err","text":err.to_string()}),
            ),
        },
        "browse" => {
            let mut dialog = rfd::FileDialog::new();
            if config.download_dir.is_dir() {
                dialog = dialog.set_directory(&config.download_dir);
            }
            if let Some(path) = dialog.pick_folder() {
                config.download_dir = path;
                let _ = config.save();
                emit(webview, &state_message(config));
            }
        }
        "settings" => {
            if let Some(quality) = message.get("quality").and_then(|value| value.as_str()) {
                config.quality = quality.to_string();
            }
            if let Some(dir) = message.get("download_dir").and_then(|value| value.as_str()) {
                if !dir.trim().is_empty() {
                    config.download_dir = dir.trim().into();
                }
            }
            let _ = config.save();
            emit(webview, &state_message(config));
        }
        "go" => {
            let query = message.get("query").and_then(|value| value.as_str()).unwrap_or("").trim().to_string();
            if query.is_empty() {
                return;
            }
            let quality = config.quality.clone();
            let directory = config.download_dir.clone();
            let direct = jobs::looks_like_target(&query);
            emit(webview, &serde_json::json!({"op":"busy","on":true}));
            let tx = tx.clone();
            std::thread::spawn(move || {
                let outcome = jobs::runtime().block_on(async {
                    let session = jobs::open_session().await?;
                    if direct {
                        let paths = jobs::download(&session, &query, &quality, &directory).await?;
                        Ok::<_, String>(serde_json::json!({
                            "op": "downloaded",
                            "paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
                        }))
                    } else {
                        let hits = jobs::search(&session, &query).await?;
                        Ok(serde_json::json!({"op":"results","tracks":hits}))
                    }
                });
                report(&tx, outcome);
            });
        }
        "download" => {
            let id = message.get("id").and_then(|value| value.as_str()).unwrap_or("").to_string();
            if id.is_empty() {
                return;
            }
            let quality = config.quality.clone();
            let directory = config.download_dir.clone();
            emit(webview, &serde_json::json!({"op":"busy","on":true}));
            let tx = tx.clone();
            std::thread::spawn(move || {
                let outcome = jobs::runtime().block_on(async {
                    let session = jobs::open_session().await?;
                    let paths = jobs::download(&session, &id, &quality, &directory).await?;
                    Ok(serde_json::json!({
                        "op": "downloaded",
                        "paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
                    }))
                });
                report(&tx, outcome);
            });
        }
        _ => {}
    }
}

fn report(tx: &std::sync::mpsc::Sender<String>, outcome: Result<serde_json::Value, String>) {
    let message = match outcome {
        Ok(message) => message,
        Err(err) => serde_json::json!({"op":"status","level":"err","text":err}),
    };
    if let Ok(text) = serde_json::to_string(&message) {
        let _ = tx.send(text);
    }
    let _ = tx.send(r#"{"op":"busy","on":false}"#.to_string());
}

fn state_message(config: &Config) -> serde_json::Value {
    let name = if config.signed_in() {
        jobs::runtime().block_on(async {
            let session = jobs::open_session().await.ok()?;
            let user = session.get_user().await.ok()?;
            Some(jobs::account_name(&user))
        })
    } else {
        None
    };
    serde_json::json!({
        "op": "state",
        "signed_in": config.signed_in(),
        "name": name,
        "quality": config.quality,
        "download_dir": config.download_dir.display().to_string(),
        "config_dir": config::config_dir().display().to_string(),
    })
}

fn read_arl(webview: &wry::WebView) -> Option<String> {
    let cookies = webview.cookies_for_url("https://www.deezer.com").ok()?;
    cookies.into_iter().find_map(|cookie| {
        if cookie.name() == "arl" && cookie.value().len() == 192 {
            Some(cookie.value().to_string())
        } else {
            None
        }
    })
}

fn emit(webview: &wry::WebView, message: &serde_json::Value) {
    let payload = serde_json::to_string(message).unwrap_or_else(|_| "{}".into());
    let _ = webview.evaluate_script(&format!("window.kvasir && window.kvasir.recv && window.kvasir.recv({payload})"));
}