use std::path::PathBuf; use std::process::Stdio; use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::Serialize; use tokio::process::Command; use crate::auth; use crate::error::ApiError; use crate::state::{AppState, FirefoxLoginSession}; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/ytauth") .route("/status", web::get().to(status)) .route("/login-start", web::post().to(login_start)) .route("/login-stop", web::post().to(login_stop)) .route("/refresh", web::post().to(refresh)) .route("/cookies", web::delete().to(clear_cookies)), ); } #[derive(Serialize)] struct AuthStatus { authenticated: bool, cookie_age_hours: Option, cookie_count: Option, refresh_enabled: bool, login_session_active: bool, vnc_url: Option, ytdlp_version: Option, ytdlp_latest: Option, ytdlp_update_available: bool, lastfm_api_key_set: bool, fanart_api_key_set: bool, } /// GET /api/ytauth/status — check YouTube auth state. async fn status(state: web::Data, session: Session) -> Result { auth::require_auth(&session)?; let config = state.config.read().await; let profile_dir = shanty_config::data_dir().join("firefox-profile"); // Check login session let login = state.firefox_login.lock().await; let login_active = login.is_some(); let vnc_url = login.as_ref().map(|s| s.vnc_url.clone()); drop(login); // Run status check via Python script let (authenticated, cookie_age, cookie_count) = if let Ok(result) = run_cookie_manager(&["status", &profile_dir.to_string_lossy()]).await { if let Ok(v) = serde_json::from_str::(&result) { ( v.get("authenticated") .and_then(|v| v.as_bool()) .unwrap_or(false), v.get("cookie_age_hours").and_then(|v| v.as_f64()), v.get("cookie_count").and_then(|v| v.as_i64()), ) } else { (false, None, None) } } else { (false, None, None) }; // Check yt-dlp version (local + latest from PyPI) let (ytdlp_version, ytdlp_latest, ytdlp_update_available) = check_ytdlp_version().await; Ok(HttpResponse::Ok().json(AuthStatus { authenticated, cookie_age_hours: cookie_age, cookie_count, refresh_enabled: config.download.cookie_refresh_enabled, login_session_active: login_active, vnc_url, ytdlp_version, ytdlp_latest, ytdlp_update_available, lastfm_api_key_set: config.metadata.lastfm_api_key.is_some(), fanart_api_key_set: config.metadata.fanart_api_key.is_some(), })) } /// POST /api/ytauth/login-start — launch Firefox + noVNC for interactive login. async fn login_start( state: web::Data, session: Session, ) -> Result { auth::require_admin(&session)?; let mut login = state.firefox_login.lock().await; if login.is_some() { return Err(ApiError::BadRequest("login session already active".into())); } let config = state.config.read().await; let profile_dir = shanty_config::data_dir().join("firefox-profile"); let vnc_port = config.download.vnc_port; drop(config); let result = run_cookie_manager(&[ "login-start", &profile_dir.to_string_lossy(), &vnc_port.to_string(), ]) .await .map_err(|e| ApiError::Internal(format!("failed to start login: {e}")))?; let v: serde_json::Value = serde_json::from_str(&result) .map_err(|e| ApiError::Internal(format!("bad response from cookie_manager: {e}")))?; if v.get("status").and_then(|s| s.as_str()) == Some("error") { let err = v .get("error") .and_then(|e| e.as_str()) .unwrap_or("unknown error"); return Err(ApiError::Internal(format!("login start failed: {err}"))); } let vnc_url = v .get("vnc_url") .and_then(|u| u.as_str()) .unwrap_or("") .to_string(); *login = Some(FirefoxLoginSession { vnc_url: vnc_url.clone(), }); Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "running", "vnc_url": vnc_url, }))) } /// POST /api/ytauth/login-stop — stop Firefox, export cookies, enable refresh. async fn login_stop( state: web::Data, session: Session, ) -> Result { auth::require_admin(&session)?; let mut login = state.firefox_login.lock().await; if login.is_none() { return Err(ApiError::BadRequest("no active login session".into())); } let profile_dir = shanty_config::data_dir().join("firefox-profile"); let cookies_path = shanty_config::data_dir().join("cookies.txt"); let result = run_cookie_manager(&[ "login-stop", "--profile-dir", &profile_dir.to_string_lossy(), "--cookies-output", &cookies_path.to_string_lossy(), ]) .await .map_err(|e| ApiError::Internal(format!("failed to stop login: {e}")))?; *login = None; let v: serde_json::Value = serde_json::from_str(&result).unwrap_or_default(); // Update config to use the cookies and enable refresh let mut config = state.config.write().await; config.download.cookies_path = Some(cookies_path.clone()); config.download.cookie_refresh_enabled = true; config .save(state.config_path.as_deref()) .map_err(ApiError::Internal)?; Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "stopped", "cookies_exported": v.get("cookies_count").is_some(), "cookies_path": cookies_path.to_string_lossy(), }))) } /// POST /api/ytauth/refresh — trigger immediate headless cookie refresh. async fn refresh(state: web::Data, session: Session) -> Result { auth::require_admin(&session)?; let profile_dir = shanty_config::data_dir().join("firefox-profile"); let cookies_path = shanty_config::data_dir().join("cookies.txt"); if !profile_dir.exists() { return Err(ApiError::BadRequest( "no Firefox profile found — log in first".into(), )); } let result = run_cookie_manager(&[ "refresh", &profile_dir.to_string_lossy(), &cookies_path.to_string_lossy(), ]) .await .map_err(|e| ApiError::Internal(format!("refresh failed: {e}")))?; let v: serde_json::Value = serde_json::from_str(&result).unwrap_or_default(); if v.get("status").and_then(|s| s.as_str()) == Some("error") { let err = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); return Err(ApiError::Internal(format!("refresh failed: {err}"))); } // Ensure config points to cookies let mut config = state.config.write().await; if config.download.cookies_path.is_none() { config.download.cookies_path = Some(cookies_path); config .save(state.config_path.as_deref()) .map_err(ApiError::Internal)?; } Ok(HttpResponse::Ok().json(v)) } /// DELETE /api/ytauth/cookies — clear cookies and Firefox profile. async fn clear_cookies( state: web::Data, session: Session, ) -> Result { auth::require_admin(&session)?; let profile_dir = shanty_config::data_dir().join("firefox-profile"); let cookies_path = shanty_config::data_dir().join("cookies.txt"); // Remove cookies file if cookies_path.exists() { std::fs::remove_file(&cookies_path).ok(); } // Remove Firefox profile if profile_dir.exists() { std::fs::remove_dir_all(&profile_dir).ok(); } // Update config let mut config = state.config.write().await; config.download.cookies_path = None; config.download.cookie_refresh_enabled = false; config .save(state.config_path.as_deref()) .map_err(ApiError::Internal)?; Ok(HttpResponse::Ok().json(serde_json::json!({"status": "cleared"}))) } // --- Helper --- /// Find the cookie_manager.py script (same search logic as ytmusic_search.py). fn find_cookie_manager_script() -> Result { let candidates = [ // Next to the binary std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join("cookie_manager.py"))), // Standard install locations Some(PathBuf::from("/usr/share/shanty/cookie_manager.py")), Some(PathBuf::from("/usr/local/share/shanty/cookie_manager.py")), // Development: crate root Some(PathBuf::from("shanty-dl/scripts/cookie_manager.py")), ]; for candidate in candidates.into_iter().flatten() { if candidate.exists() { return Ok(candidate); } } Err(ApiError::Internal("cookie_manager.py not found".into())) } /// Run cookie_manager.py with the given arguments and return stdout. async fn run_cookie_manager(args: &[&str]) -> Result { let script = find_cookie_manager_script().map_err(|e| e.to_string())?; let output = Command::new("python3") .arg(&script) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .map_err(|e| format!("failed to run cookie_manager.py: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("cookie_manager.py failed: {stderr}")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// Check installed yt-dlp version and compare to latest on PyPI. async fn check_ytdlp_version() -> (Option, Option, bool) { // Get installed version let installed = Command::new("yt-dlp") .arg("--version") .stdout(Stdio::piped()) .stderr(Stdio::null()) .output() .await .ok() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); // Get latest version from PyPI let latest = async { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() .ok()?; let resp: serde_json::Value = client .get("https://pypi.org/pypi/yt-dlp/json") .send() .await .ok()? .json() .await .ok()?; resp.get("info")?.get("version")?.as_str().map(String::from) } .await; // Normalize version strings for comparison: "2026.03.17" and "2026.3.17" should match. let normalize = |v: &str| -> String { v.split('.') .map(|part| part.trim_start_matches('0')) .map(|p| if p.is_empty() { "0" } else { p }) .collect::>() .join(".") }; let update_available = match (&installed, &latest) { (Some(i), Some(l)) => normalize(i) != normalize(l), _ => false, }; (installed, latest, update_available) }