use color_eyre::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::{DefaultTerminal, Frame};
use std::path::PathBuf;
use std::time::{Duration, UNIX_EPOCH};
use std::{fs, process::Stdio};
use tracing::{info, warn, error};
use regex::Regex;
const MAX_INPUT_LENGTH: usize = 500;
const MAX_PASTE_LENGTH: usize = 10000;
#[derive(Debug)]
pub struct App {
pub running: bool,
pub input: String,
pub status_message: String,
pub download_status: DownloadStatus,
pub focus: Focus,
pub batch_mode: bool,
pub batch_urls: Vec<String>,
pub batch_progress: BatchProgress,
pub should_clear: bool,
pub download_history: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BatchProgress {
pub current: usize,
pub total: usize,
pub completed: Vec<String>,
pub failed: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum DownloadStatus {
Idle,
Downloading,
Success(String),
Error(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Focus {
Input,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
Self {
running: true,
input: String::new(),
status_message: "Paste a YouTube URL and press Enter to download MP3".to_string(),
download_status: DownloadStatus::Idle,
focus: Focus::Input,
batch_mode: false,
batch_urls: Vec::new(),
batch_progress: BatchProgress {
current: 0,
total: 0,
completed: Vec::new(),
failed: Vec::new(),
},
should_clear: false,
download_history: Vec::new(),
}
}
pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
info!("Starting main app loop");
while self.running {
if self.should_clear {
terminal.clear()?;
self.should_clear = false;
}
terminal.draw(|frame| self.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
self.handle_key_event(key).await?;
}
}
}
info!("App loop finished");
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
crate::ui::render(frame, self);
}
fn sanitize_input(&mut self, input: &str) -> String {
let truncated = if input.len() > MAX_PASTE_LENGTH {
warn!("Input truncated from {} to {} characters", input.len(), MAX_PASTE_LENGTH);
&input[..MAX_PASTE_LENGTH]
} else {
input
};
if let Some(url) = self.extract_youtube_url(truncated) {
info!("Extracted YouTube URL: {}", url);
return url;
}
let cleaned = self.clean_text(truncated);
if cleaned.len() > MAX_INPUT_LENGTH {
warn!("Cleaned input truncated from {} to {} characters", cleaned.len(), MAX_INPUT_LENGTH);
cleaned[..MAX_INPUT_LENGTH].to_string()
} else {
cleaned
}
}
fn extract_youtube_url(&self, text: &str) -> Option<String> {
let patterns = [
r"https://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]+)(?:[&\w=]*)?",
r"https://youtu\.be/([a-zA-Z0-9_-]+)(?:\?[&\w=]*)?",
r"(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]+)(?:[&\w=]*)?",
r"youtu\.be/([a-zA-Z0-9_-]+)(?:\?[&\w=]*)?",
r"watch\?v=([a-zA-Z0-9_-]+)(?:[&\w=]*)?",
];
for pattern in &patterns {
if let Ok(regex) = Regex::new(pattern) {
if let Some(captures) = regex.captures(text) {
if let Some(video_id) = captures.get(1) {
let url = format!("https://www.youtube.com/watch?v={}", video_id.as_str());
info!("Extracted YouTube URL from pattern '{}': {}", pattern, url);
return Some(url);
}
}
}
}
None
}
fn clean_text(&self, text: &str) -> String {
text.chars()
.filter(|c| {
c.is_ascii_graphic() || c.is_ascii_whitespace() || (*c as u32) > 127
})
.collect::<String>()
.split_whitespace() .collect::<Vec<_>>()
.join(" ")
}
fn handle_char_input(&mut self, c: char) {
if self.input.len() >= MAX_INPUT_LENGTH {
warn!("Input at maximum length ({}), ignoring character", MAX_INPUT_LENGTH);
self.status_message = format!("Input limit reached ({} characters)", MAX_INPUT_LENGTH);
return;
}
if c.is_control() && c != '\t' {
warn!("Ignoring control character: {:?}", c);
return;
}
self.input.push(c);
if self.status_message.starts_with("Input limit reached") ||
self.status_message.starts_with("Large input sanitized") {
self.status_message.clear();
}
}
fn handle_paste(&mut self, pasted_text: &str) {
let original_len = pasted_text.len();
let sanitized = self.sanitize_input(pasted_text);
if sanitized != pasted_text {
if original_len > MAX_PASTE_LENGTH {
self.status_message = format!(
"Large input sanitized: {} → {} chars (extracted URL or cleaned text)",
original_len, sanitized.len()
);
} else {
self.status_message = "Input cleaned and URL extracted".to_string();
}
info!("Input sanitized: original {} chars → {} chars", original_len, sanitized.len());
}
self.input = sanitized;
}
async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
info!("User quit with Ctrl+C");
self.running = false;
return Ok(());
}
if let Err(e) = self.handle_key_event_safe(key).await {
error!("Error handling key event: {}", e);
self.status_message = format!("Error: {}", e);
}
Ok(())
}
async fn handle_key_event_safe(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.running = false;
}
KeyCode::Enter => {
if !self.input.trim().is_empty() {
if self.batch_mode {
self.batch_urls.push(self.input.clone());
self.status_message = format!("Added URL {} to batch (total: {})",
self.batch_urls.len(), self.batch_urls.len());
self.input.clear();
} else {
self.start_download(128).await?;
}
}
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Delete => {
self.input.clear();
}
KeyCode::Tab => {
}
KeyCode::F(5) => {
if !self.input.is_empty() {
let original = self.input.clone();
self.handle_paste(&original);
}
}
KeyCode::Char(c @ ('b' | 'B')) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
self.batch_mode = !self.batch_mode;
self.should_clear = true;
if self.batch_mode {
self.status_message = "🎯 Batch mode ON - Add URLs with Enter, download with Ctrl+D".to_string();
self.batch_urls.clear();
self.batch_progress = BatchProgress {
current: 0,
total: 0,
completed: Vec::new(),
failed: Vec::new(),
};
} else {
self.status_message = "Single URL mode - Paste a YouTube URL and press Enter to download".to_string();
self.batch_progress = BatchProgress {
current: 0,
total: 0,
completed: Vec::new(),
failed: Vec::new(),
};
}
} else {
self.handle_char_input(c);
}
}
KeyCode::Char(c @ ('d' | 'D')) => {
if key.modifiers.contains(KeyModifiers::CONTROL) && self.batch_mode {
self.start_batch_download(128).await?;
} else {
self.handle_char_input(c);
}
}
KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !self.input.trim().is_empty() {
self.start_download(128).await?;
}
}
KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !self.input.trim().is_empty() {
self.start_download(256).await?;
}
}
KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.status_message = "💡 Paste detected! Press F5 to clean and extract URL from pasted content".to_string();
info!("Ctrl+V detected - user should use F5 for URL extraction");
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
info!("Ctrl+A detected - clearing input");
}
KeyCode::Char(c) => {
self.handle_char_input(c);
}
_ => {}
}
Ok(())
}
async fn start_download(&mut self, bitrate: u32) -> Result<()> {
let url = self.input.trim();
if url.is_empty() {
self.status_message = "Please enter a YouTube URL".to_string();
warn!("Empty URL provided");
return Ok(());
}
if !url.contains("youtube.com") && !url.contains("youtu.be") {
self.status_message = "Please enter a valid YouTube URL".to_string();
warn!("Invalid URL provided: {}", url);
return Ok(());
}
if let Err(e) = self.perform_download(url.to_string(), bitrate).await {
error!("Download failed: {}", e);
self.download_status = DownloadStatus::Error(e.to_string());
self.status_message = format!("❌ Download failed: {}", e);
}
Ok(())
}
async fn perform_download(&mut self, url: String, bitrate: u32) -> Result<()> {
self.download_status = DownloadStatus::Downloading;
self.status_message = format!("🎵 Downloading MP3 at {}kbps... Please wait", bitrate);
self.input.clear();
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
let output_dir = PathBuf::from(home).join("Downloads");
let file_path = self.download_mp3(url, output_dir, bitrate).await
.map_err(|e| color_eyre::eyre::eyre!("Download failed: {}", e))?;
self.download_status = DownloadStatus::Success(file_path.clone());
self.status_message = format!("✅ Successfully downloaded: {}", file_path);
if let Some(filename) = file_path.strip_prefix("✅ Downloaded: ") {
self.download_history.push(filename.to_string());
} else {
self.download_history.push(file_path.clone());
}
Ok(())
}
async fn start_batch_download(&mut self, bitrate: u32) -> Result<()> {
if self.batch_urls.is_empty() {
self.status_message = "❌ No URLs in batch. Add URLs with Enter first.".to_string();
return Ok(());
}
info!("Starting batch download for {} URLs at {}kbps", self.batch_urls.len(), bitrate);
self.download_status = DownloadStatus::Downloading;
self.batch_progress = BatchProgress {
current: 0,
total: self.batch_urls.len(),
completed: Vec::new(),
failed: Vec::new(),
};
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
let output_dir = PathBuf::from(home).join("Downloads");
for (index, url) in self.batch_urls.iter().enumerate() {
self.batch_progress.current = index + 1;
self.status_message = format!("📥 Downloading {}/{}: {}", index + 1, self.batch_urls.len(), url);
match self.download_mp3(url.clone(), output_dir.clone(), bitrate).await {
Ok(file_path) => {
self.batch_progress.completed.push(url.clone());
if let Some(filename) = file_path.strip_prefix("✅ Downloaded: ") {
self.download_history.push(filename.to_string());
} else {
self.download_history.push(file_path);
}
}
Err(e) => {
error!("Download failed for {}: {}", url, e);
self.batch_progress.failed.push(url.clone());
}
}
}
let success_count = self.batch_progress.completed.len();
let failed_count = self.batch_progress.failed.len();
if failed_count == 0 {
self.status_message = format!("✅ Batch complete! All {} downloads successful", success_count);
self.download_status = DownloadStatus::Success(format!("{} files downloaded", success_count));
} else {
self.status_message = format!("⚠️ Batch complete: {} successful, {} failed", success_count, failed_count);
self.download_status = DownloadStatus::Success(format!("{} successful, {} failed", success_count, failed_count));
}
Ok(())
}
async fn download_mp3(
&self,
url: String,
output_dir: PathBuf,
bitrate: u32,
) -> Result<String, Box<dyn std::error::Error>> {
let existing_mp3s = self.get_mp3_files(&output_dir).await.unwrap_or_default();
let output_template = output_dir.join("%(title)s.%(ext)s");
let mut cmd = tokio::process::Command::new("yt-dlp");
cmd.args(&[
"--format", "bestaudio", "--extract-audio", "--audio-format", "mp3", "--audio-quality", &format!("{}K", bitrate), "--output", &output_template.to_string_lossy(), "--no-playlist", "--prefer-ffmpeg", "--embed-thumbnail", "--add-metadata", "--no-warnings", "--quiet", &url ]);
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null());
let output = cmd.output().await
.map_err(|_| format!("yt-dlp not found. Please install: brew install yt-dlp"))?;
if !output.status.success() {
return Err("Download failed. Check if the YouTube URL is valid and accessible.".into());
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let new_mp3s = self.get_mp3_files(&output_dir).await.unwrap_or_default();
let new_file = new_mp3s.iter()
.find(|file| !existing_mp3s.contains(file))
.cloned()
.unwrap_or_else(|| {
fs::read_dir(&output_dir)
.ok()
.and_then(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "mp3"))
.max_by_key(|e| e.metadata().and_then(|m| m.modified()).unwrap_or(UNIX_EPOCH))
.and_then(|e| e.file_name().to_str().map(|s| s.to_string()))
})
.unwrap_or_else(|| "Unknown.mp3".to_string())
});
Ok(format!("✅ Downloaded: {}", new_file))
}
async fn get_mp3_files(&self, dir: &PathBuf) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut mp3_files = Vec::new();
if !dir.exists() {
return Ok(mp3_files);
}
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
if let Some(extension) = path.extension() {
if extension == "mp3" {
if let Some(filename) = path.file_name() {
if let Some(filename_str) = filename.to_str() {
if !filename_str.is_empty() && filename_str.len() > 4 {
mp3_files.push(filename_str.to_string());
}
}
}
}
}
}
}
Ok(mp3_files)
}
pub fn input_value(&self) -> &str {
&self.input
}
pub fn is_input_focused(&self) -> bool {
true }
}