use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Sha256, Digest};
use crate::config_manager::{ConfigManager, ConfigModule};
use crate::config_modules::YoutubeConfig;
use crate::types::{MediaSource, TuiLogger};
use crate::utils;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
pub url: String,
pub file_path: String,
pub title: Option<String>,
pub duration: Option<f64>,
pub file_size: u64,
pub download_time: u64,
pub last_accessed: u64,
pub format: String,
pub checksum: String,
}
pub struct YoutubeManager {
config: YoutubeConfig,
cache_index: HashMap<String, CacheEntry>,
cache_index_path: PathBuf,
logger: Option<TuiLogger>,
}
impl YoutubeManager {
pub fn new() -> Result<Self> {
Self::new_with_logger(None)
}
pub fn new_with_logger(logger: Option<TuiLogger>) -> Result<Self> {
let config_manager = ConfigManager::new()?;
let mut config = config_manager.get::<YoutubeConfig>();
config.on_load()?;
if let Some(ref log) = logger {
log.info("Initializing YouTube manager");
log.debug(&format!("Cache directory: {}", config.cache_directory.display()));
}
let cache_path = &config.cache_directory;
let cache_index_path = cache_path.join("index.json");
let cache_index = if cache_index_path.exists() {
let content = fs::read_to_string(&cache_index_path)?;
let cache_data = serde_json::from_str(&content).unwrap_or_default();
if let Some(ref log) = logger {
log.debug(&format!("Loaded cache index with {} entries",
if let Ok(map) = serde_json::from_str::<HashMap<String, CacheEntry>>(&content) {
map.len()
} else {
0
}));
}
cache_data
} else {
if let Some(ref log) = logger {
log.debug("No existing cache index found, creating new one");
}
HashMap::new()
};
let manager = Self {
config,
cache_index,
cache_index_path,
logger,
};
Ok(manager)
}
pub fn check_ytdlp_availability(&self) -> Result<()> {
if let Some(ref logger) = self.logger {
logger.debug("Checking yt-dlp availability");
}
let output = Command::new("yt-dlp")
.arg("--version")
.output();
match output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
let message = format!("Found yt-dlp version: {}", version.trim());
if let Some(ref logger) = self.logger {
logger.info(&message);
} else {
println!("{}", message);
}
Ok(())
}
Ok(_) => {
let error_msg = "yt-dlp is installed but returned an error. Please check your installation.";
if let Some(ref logger) = self.logger {
logger.error(error_msg);
}
Err(anyhow::anyhow!(error_msg))
}
Err(_) => {
let error_msg = "yt-dlp is not installed or not in PATH. Please install yt-dlp first.\n\
Installation instructions:\n\
- Windows: Download from https://github.com/yt-dlp/yt-dlp/releases\n\
- Linux: sudo apt install yt-dlp (or your package manager)\n\
- macOS: brew install yt-dlp\n\
- Python: pip install yt-dlp";
if let Some(ref logger) = self.logger {
logger.error(error_msg);
}
Err(anyhow::anyhow!(error_msg))
}
}
}
pub fn download_and_cache(&mut self, url: &str) -> Result<MediaSource> {
if let Some(ref logger) = self.logger {
logger.info(&format!("Processing YouTube URL: {}", url));
}
self.check_ytdlp_availability()?;
let cache_key = self.generate_cache_key(url);
if let Some(ref logger) = self.logger {
logger.debug(&format!("Generated cache key: {}", cache_key));
}
if self.config.enabled {
if let Some(entry) = self.cache_index.get(&cache_key).cloned() {
if let Some(ref logger) = self.logger {
logger.debug(&format!("Found cached entry for URL: {}", entry.file_path));
}
if self.is_cache_entry_valid(&entry)? {
if let Some(ref logger) = self.logger {
logger.info(&format!("Using cached file: {}", entry.file_path));
}
self.update_access_time(&cache_key)?;
return Ok(MediaSource::LocalFile(entry.file_path.clone()));
} else {
if let Some(ref logger) = self.logger {
logger.warn(&format!("Cache entry invalid, removing: {}", entry.file_path));
}
self.remove_cache_entry(&cache_key)?;
}
} else if let Some(ref logger) = self.logger {
logger.debug("No cached entry found for URL");
}
} else if let Some(ref logger) = self.logger {
logger.debug("Caching is disabled");
}
if let Some(ref logger) = self.logger {
logger.info("Starting download...");
}
let downloaded_path = self.download_media(url)?;
if self.config.enabled {
if let Some(ref logger) = self.logger {
logger.debug("Adding downloaded file to cache");
}
self.add_to_cache(url, &downloaded_path, &cache_key)?;
}
if let Some(ref logger) = self.logger {
logger.info(&format!("Download completed: {}", downloaded_path));
}
Ok(MediaSource::LocalFile(downloaded_path))
}
fn download_media(&self, url: &str) -> Result<String> {
let output_dir = if self.config.enabled {
&self.config.cache_directory
} else {
Path::new("./temp_downloads")
};
if let Some(ref logger) = self.logger {
logger.debug(&format!("Output directory: {}", output_dir.display()));
}
fs::create_dir_all(output_dir)?;
let cache_key = self.generate_cache_key(url);
let mut cmd = Command::new("yt-dlp");
cmd.arg("--no-playlist");
if self.config.audio_only {
cmd.arg("-x"); if !self.config.preferred_quality.is_empty() {
cmd.arg("--audio-format").arg("mp3"); }
if let Some(ref logger) = self.logger {
logger.debug("Downloading audio only (mp3 format)");
}
} else if !self.config.preferred_quality.is_empty() {
cmd.arg("-f").arg(&self.config.preferred_quality);
if let Some(ref logger) = self.logger {
logger.debug(&format!("Using quality setting: {}", self.config.preferred_quality));
}
}
let output_template = output_dir.join(format!("{}.%(ext)s", cache_key));
cmd.arg("-o").arg(output_template.to_string_lossy().as_ref());
if let Some(ref logger) = self.logger {
logger.debug(&format!("Using hash-based filename: {}", cache_key));
}
cmd.arg("--socket-timeout").arg("30"); cmd.arg("--retries").arg(self.config.retry_attempts.to_string());
if let Some(ref logger) = self.logger {
logger.debug(&format!("Retry attempts: {}", self.config.retry_attempts));
}
cmd.arg(url);
let download_msg = format!("Downloading: {}", url);
if let Some(ref logger) = self.logger {
logger.info(&download_msg);
} else {
println!("{}", download_msg);
}
if let Some(ref logger) = self.logger {
logger.debug(&format!("Executing command: {:?}", cmd));
}
let output = cmd.output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
let full_error = format!("yt-dlp failed: {}", error_msg);
if let Some(ref logger) = self.logger {
logger.error(&full_error);
}
return Err(anyhow::anyhow!(full_error));
}
if let Some(ref logger) = self.logger {
logger.debug("yt-dlp command completed successfully");
}
self.find_downloaded_file(output_dir, &cache_key)
}
fn find_downloaded_file(&self, output_dir: &Path, cache_key: &str) -> Result<String> {
if let Some(ref logger) = self.logger {
logger.debug(&format!("Locating downloaded file with hash: {}", cache_key));
}
let entries = fs::read_dir(output_dir)?;
for entry in entries {
let entry = entry?;
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with(cache_key) {
let file_path = entry.path();
if file_path.is_file() {
let result = file_path.to_string_lossy().to_string();
if let Some(ref logger) = self.logger {
logger.debug(&format!("Found downloaded file: {}", result));
}
return Ok(result);
}
}
}
let error_msg = format!("Downloaded file not found with hash: {}", cache_key);
if let Some(ref logger) = self.logger {
logger.error(&error_msg);
}
Err(anyhow::anyhow!(error_msg))
}
fn generate_cache_key(&self, url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
format!("{:x}", hasher.finalize())
}
fn is_cache_entry_valid(&self, entry: &CacheEntry) -> Result<bool> {
if let Some(ref logger) = self.logger {
logger.debug(&format!("Validating cache entry: {}", entry.file_path));
}
let file_path = Path::new(&entry.file_path);
if !file_path.exists() {
if let Some(ref logger) = self.logger {
logger.debug("Cache validation failed: file does not exist");
}
return Ok(false);
}
let metadata = fs::metadata(file_path)?;
if metadata.len() != entry.file_size {
if let Some(ref logger) = self.logger {
logger.debug(&format!("Cache validation failed: file size mismatch (expected {}, got {})",
entry.file_size, metadata.len()));
}
return Ok(false);
}
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let expiry_time = entry.download_time + (self.config.max_cache_age_days * 24 * 3600);
if current_time > expiry_time {
if let Some(ref logger) = self.logger {
logger.debug("Cache validation failed: entry expired");
}
return Ok(false);
}
let current_checksum = self.calculate_file_checksum(file_path)?;
if current_checksum != entry.checksum {
if let Some(ref logger) = self.logger {
logger.debug("Cache validation failed: checksum mismatch");
}
return Ok(false);
}
if let Some(ref logger) = self.logger {
logger.debug("Cache entry validation successful");
}
Ok(true)
}
fn calculate_file_checksum(&self, file_path: &Path) -> Result<String> {
let content = fs::read(file_path)?;
let mut hasher = Sha256::new();
hasher.update(&content);
Ok(format!("{:x}", hasher.finalize()))
}
fn add_to_cache(&mut self, url: &str, file_path: &str, cache_key: &str) -> Result<()> {
if let Some(ref logger) = self.logger {
logger.debug(&format!("Adding file to cache: {}", file_path));
}
let file_path_obj = Path::new(file_path);
let metadata = fs::metadata(file_path_obj)?;
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let (title, duration) = self.get_video_info(url)?;
if let Some(ref logger) = self.logger {
if let Some(ref title) = title {
logger.debug(&format!("Video title: {}", title));
}
if let Some(duration) = duration {
logger.debug(&format!("Video duration: {:.2}s", duration));
}
}
let entry = CacheEntry {
url: url.to_string(),
file_path: file_path.to_string(),
title,
duration,
file_size: metadata.len(),
download_time: current_time,
last_accessed: current_time,
format: self.config.preferred_quality.clone(),
checksum: self.calculate_file_checksum(file_path_obj)?,
};
self.cache_index.insert(cache_key.to_string(), entry);
self.save_cache_index()?;
if let Some(ref logger) = self.logger {
logger.info(&format!("File cached successfully: {} ({} bytes)",
file_path, metadata.len()));
}
self.cleanup_cache()?;
Ok(())
}
fn get_video_info(&self, url: &str) -> Result<(Option<String>, Option<f64>)> {
let mut cmd = Command::new("yt-dlp");
cmd.arg("--get-title")
.arg("--get-duration")
.arg("--no-playlist")
.arg(url);
let output = cmd.output()?;
if !output.status.success() {
return Ok((None, None));
}
let output_str = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = output_str.trim().split('\n').collect();
let title = if !lines.is_empty() && !lines[0].is_empty() {
Some(lines[0].to_string())
} else {
None
};
let duration = if lines.len() >= 2 {
lines[1].parse::<f64>().ok()
} else {
None
};
Ok((title, duration))
}
fn update_access_time(&mut self, cache_key: &str) -> Result<()> {
if let Some(entry) = self.cache_index.get_mut(cache_key) {
let file_path = entry.file_path.clone(); entry.last_accessed = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
self.save_cache_index()?;
if let Some(ref logger) = self.logger {
logger.debug(&format!("Updated access time for cache entry: {}", file_path));
}
}
Ok(())
}
fn remove_cache_entry(&mut self, cache_key: &str) -> Result<()> {
if let Some(entry) = self.cache_index.remove(cache_key) {
if let Some(ref logger) = self.logger {
logger.debug(&format!("Removing cache entry: {}", entry.file_path));
}
let file_path = Path::new(&entry.file_path);
if file_path.exists() {
fs::remove_file(file_path)?;
if let Some(ref logger) = self.logger {
logger.debug(&format!("Deleted file: {}", entry.file_path));
}
}
if let Some(stem) = file_path.file_stem() {
if let Some(parent) = file_path.parent() {
if let Ok(entries) = fs::read_dir(parent) {
for entry_dir in entries.flatten() {
let name = entry_dir.file_name().to_string_lossy().to_string();
if name.starts_with(&stem.to_string_lossy().to_string()) &&
name != file_path.file_name().unwrap().to_string_lossy() {
let _ = fs::remove_file(entry_dir.path());
if let Some(ref logger) = self.logger {
logger.debug(&format!("Deleted associated file: {}", name));
}
}
}
}
}
}
self.save_cache_index()?;
if let Some(ref logger) = self.logger {
logger.info(&format!("Cache entry removed: {}", entry.file_path));
}
}
Ok(())
}
fn cleanup_cache(&mut self) -> Result<()> {
let total_size = self.calculate_total_cache_size()?;
let max_size_bytes = self.config.max_cache_size_mb * 1024 * 1024;
if let Some(ref logger) = self.logger {
logger.debug(&format!("Cache size: {:.2} MB / {:.2} MB",
total_size as f64 / (1024.0 * 1024.0),
self.config.max_cache_size_mb as f64));
}
if total_size <= max_size_bytes {
return Ok(());
}
if let Some(ref logger) = self.logger {
logger.info("Cache size limit exceeded, cleaning up old entries");
}
let mut entries: Vec<_> = self.cache_index.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
entries.sort_by_key(|(_, entry)| entry.last_accessed);
let mut current_size = total_size;
for (key, entry) in entries {
if current_size <= max_size_bytes {
break;
}
current_size -= entry.file_size;
self.remove_cache_entry(&key)?;
let message = format!("Removed cached file: {} (freed {} bytes)", entry.file_path, entry.file_size);
if let Some(ref logger) = self.logger {
logger.info(&message);
} else {
println!("{}", message);
}
}
Ok(())
}
fn calculate_total_cache_size(&self) -> Result<u64> {
Ok(self.cache_index.values().map(|entry| entry.file_size).sum())
}
fn save_cache_index(&self) -> Result<()> {
let content = serde_json::to_string_pretty(&self.cache_index)?;
fs::write(&self.cache_index_path, content)?;
Ok(())
}
pub fn get_cache_stats(&self) -> Result<CacheStats> {
let total_files = self.cache_index.len();
let total_size = self.calculate_total_cache_size()?;
let max_size_bytes = self.config.max_cache_size_mb * 1024 * 1024;
Ok(CacheStats {
total_files,
total_size_bytes: total_size,
total_size_mb: total_size as f64 / (1024.0 * 1024.0),
max_size_mb: self.config.max_cache_size_mb as f64,
usage_percentage: (total_size as f64 / max_size_bytes as f64) * 100.0,
})
}
pub fn clear_cache(&mut self) -> Result<()> {
if let Some(ref logger) = self.logger {
logger.info("Clearing entire YouTube cache");
}
let cache_path = &self.config.cache_directory;
if cache_path.exists() {
fs::remove_dir_all(cache_path)?;
fs::create_dir_all(cache_path)?;
if let Some(ref logger) = self.logger {
logger.debug(&format!("Removed cache directory: {}", cache_path.display()));
}
}
self.cache_index.clear();
self.save_cache_index()?;
if let Some(ref logger) = self.logger {
logger.info("YouTube cache cleared successfully");
}
Ok(())
}
pub fn list_cached_entries(&self) -> Vec<&CacheEntry> {
self.cache_index.values().collect()
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub total_files: usize,
pub total_size_bytes: u64,
pub total_size_mb: f64,
pub max_size_mb: f64,
pub usage_percentage: f64,
}
pub fn is_youtube_url(url: &str) -> bool {
utils::is_url(url) && (
url.contains("youtube.com") ||
url.contains("youtu.be") ||
url.contains("youtube-nocookie.com") ||
url.contains("www.youtube.com") ||
url.contains("m.youtube.com")
)
}
pub fn download_youtube_content(url: &str) -> Result<MediaSource> {
download_youtube_content_with_logger(url, None)
}
pub fn download_youtube_content_with_logger(url: &str, logger: Option<TuiLogger>) -> Result<MediaSource> {
let mut manager = YoutubeManager::new_with_logger(logger)?;
manager.download_and_cache(url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_youtube_url_detection() {
assert!(is_youtube_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
assert!(is_youtube_url("https://youtu.be/dQw4w9WgXcQ"));
assert!(is_youtube_url("https://youtube.com/watch?v=dQw4w9WgXcQ"));
assert!(!is_youtube_url("https://example.com"));
assert!(!is_youtube_url("not a url"));
}
#[test]
fn test_cache_key_generation() {
let manager = YoutubeManager::new_with_logger(None).unwrap();
let key1 = manager.generate_cache_key("https://youtube.com/watch?v=test1");
let key2 = manager.generate_cache_key("https://youtube.com/watch?v=test2");
let key3 = manager.generate_cache_key("https://youtube.com/watch?v=test1");
assert_ne!(key1, key2);
assert_eq!(key1, key3);
assert_eq!(key1.len(), 64); }
#[test]
fn test_config_validation() {
let mut config = YoutubeConfig::default();
assert!(config.validate().is_ok());
config.max_cache_size_mb = 0; assert!(config.validate().is_err());
config.max_cache_size_mb = 150000; assert!(config.validate().is_err());
}
}