use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::process::Command; use tokio::io::{AsyncBufReadExt, BufReader}; use serde::Deserialize; use crate::models::settings::AppConfig; // --- Data Structures --- #[derive(Deserialize, Debug)] struct AudibleAuthor { #[serde(default)] name: String, } #[derive(Deserialize, Debug)] struct AudibleSeries { #[serde(default)] title: String, #[serde(default)] sequence: Option, } #[derive(Deserialize, Debug)] struct AudibleProduct { #[serde(default, alias = "product_asin")] asin: String, #[serde(default, alias = "name")] title: String, #[serde(default)] subtitle: Option, #[serde(default)] authors: Option>, #[serde(default)] narrators: Option>, #[serde(default)] series: Option>, #[serde(default)] publication_name: Option, #[serde(default)] product_images: Option>, #[serde(default)] runtime_length_min: Option, #[serde(default)] publication_datetime: Option, #[serde(default)] release_date: Option, #[serde(default)] publisher_name: Option, #[serde(default)] language: Option, #[serde(default)] merchandising_summary: Option, #[serde(default)] thesaurus_subject_keywords: Option>, #[serde(default)] format_type: Option, } impl From for BookMetadata { fn from(p: AudibleProduct) -> Self { let authors = p.authors.unwrap_or_default() .into_iter().map(|a| a.name).collect(); let narrators = p.narrators.map(|ns| ns.into_iter().map(|n| n.name).collect()); // Series Detection Logic let (series_name, mut series_number) = if let Some(s) = p.series.and_then(|s| s.into_iter().next()) { (Some(s.title), s.sequence) } else { (None, None) }; let mut series_name = series_name; // Fallback 1: Publication Name if series_name.is_none() { series_name = p.publication_name.clone(); } // Fallback 2: Check Subtitle for "Book X" patterns if series_name.is_none() || series_number.is_none() { if let Some(sub) = &p.subtitle { if sub.to_lowercase().contains("book") { if series_name.is_none() { series_name = Some(sub.split(',').next().unwrap_or(sub).trim().to_string()); } if series_number.is_none() { // Try to extract number after "Book" if let Some(pos) = sub.to_lowercase().find("book") { let after_book = &sub[pos + 4..].trim(); series_number = after_book.split(|c: char| !c.is_numeric() && c != '.').next().map(|s| s.to_string()); } } } } } // Date Detection: Prioritize release_date, fallback to publication_datetime let raw_date = p.release_date.or(p.publication_datetime); let cleaned_date = raw_date.map(|d| d.split('T').next().unwrap_or(&d).to_string()); // Strip HTML tags from description let description = p.merchandising_summary.map(|s| { // Very basic HTML tag removal let mut result = String::new(); let mut inside_tag = false; for c in s.chars() { if c == '<' { inside_tag = true; } else if c == '>' { inside_tag = false; } else if !inside_tag { result.push(c); } } // Replace common entities result.replace(" ", " ").replace(""", "\"").replace("&", "&").trim().to_string() }); let cover_url = p.product_images.and_then(|imgs| { imgs.get("1215") .or_else(|| imgs.get("500")) .or_else(|| imgs.get("400")) .or_else(|| imgs.values().next()) .cloned() }); BookMetadata { title: p.title, subtitle: p.subtitle, authors, narrator_names: narrators, series_name, series_number, cover_url, asin: Some(p.asin), duration_minutes: p.runtime_length_min, release_date: cleaned_date, description, genres: p.thesaurus_subject_keywords, publisher: p.publisher_name, language: p.language, quality: Some("128k".to_string()), // Default quality is_abridged: p.format_type.map(|t| t.to_lowercase() == "abridged").unwrap_or(false), ..Default::default() } } } #[derive(Deserialize, Debug)] struct AudibleResult { products: Vec, } #[derive(Deserialize, Debug)] struct AudibleChapter { #[serde(default)] title: String, #[serde(default)] start_offset_ms: u64, #[serde(default)] length_ms: u64, } #[derive(Deserialize, Debug)] struct AudibleContent { #[serde(default)] chapters: Option>, } #[derive(Deserialize, Debug)] struct AudibleChapterResponse { #[serde(default)] content: Option, } #[derive(Deserialize, Debug)] struct AudnexusChapter { #[serde(default)] title: String, #[serde(rename = "startOffsetMs")] start_ms: u64, #[serde(rename = "lengthMs")] length_ms: u64, } #[derive(Deserialize, Debug)] struct AudnexusResponse { #[serde(default)] chapters: Vec, } use crate::models::metadata::BookMetadata; #[derive(Deserialize)] struct ProbeOutput { format: ProbeFormat, } #[derive(Deserialize)] struct ProbeFormat { duration: String, } #[derive(Debug, Clone)] pub struct SimpleChapter { pub title: String, pub start_ms: u64, pub duration_ms: u64, } #[derive(Debug, Clone, Default)] pub struct AudioMap { pub files: Vec, pub durations: Vec, } impl AudioMap { /// Translates a global audiobook timestamp into (file_path, local_offset) pub fn resolve_timestamp(&self, global_ms: u64) -> Option<(PathBuf, u64)> { let mut accumulated = 0; for (i, file) in self.files.iter().enumerate() { let dur = self.durations.get(i).cloned().unwrap_or(0); if global_ms < accumulated + dur { return Some((file.clone(), global_ms - accumulated)); } accumulated += dur; } // If it's the exact end or slightly over, just return the last file at its end if !self.files.is_empty() && global_ms >= accumulated { let last_idx = self.files.len() - 1; return Some((self.files[last_idx].clone(), self.durations[last_idx])); } None } } #[derive(Deserialize)] struct ProbeChapter { #[serde(default)] title: String, start_time_ms: f64, end_time_ms: f64, } #[derive(Deserialize)] struct ProbeResult { chapters: Option>, } // --- Audio Service Implementation --- pub struct AudioService; #[allow(dead_code)] impl AudioService { /// Search for metadata - returns multiple results pub async fn search_metadata(query: &str, by_asin: bool) -> Result, String> { let url = if by_asin { format!( "https://api.audible.com/1.0/catalog/products/{}?response_groups=product_desc,product_attrs,media", urlencoding::encode(query) ) } else { format!( "https://api.audible.com/1.0/catalog/products?title={}&response_groups=product_desc,product_attrs,media", urlencoding::encode(query) ) }; let client = reqwest::Client::new(); let response = client .get(&url) .header("User-Agent", "Lectern/1.0") .send() .await .map_err(|e| format!("Network error: {}", e))?; if !response.status().is_success() { return Err(format!("API returned status: {}", response.status())); } let body = response.text().await.map_err(|e| format!("Failed to read body: {}", e))?; if by_asin { #[derive(Deserialize)] struct SingleProductResponse { product: AudibleProduct } let res: SingleProductResponse = serde_json::from_str(&body) .map_err(|e| format!("JSON parse error (asin): {}", e))?; Ok(vec![BookMetadata::from(res.product)]) } else { let result: AudibleResult = serde_json::from_str(&body) .map_err(|e| format!("JSON parse error (products): {}", e))?; if result.products.is_empty() { Err("No results found".to_string()) } else { Ok(result.products.into_iter().map(BookMetadata::from).collect()) } } } pub async fn fetch_chapters_from_audible(asin: &str, marketplace: &str) -> Result, String> { let tld = match marketplace { "ca" => "ca", "uk" => "co.uk", "au" => "com.au", "de" => "de", "fr" => "fr", "it" => "it", "es" => "es", "jp" => "co.jp", "in" => "in", _ => "com", }; let url = format!( "https://api.audible.{}/1.0/catalog/products/{}/content?response_groups=chapters", tld, asin ); let client = reqwest::Client::new(); let response = client .get(&url) .header("User-Agent", "Lectern/1.0") .send() .await .map_err(|e| format!("Network error: {}", e))?; if !response.status().is_success() { return Err(format!("API returned status: {}", response.status())); } let res: AudibleChapterResponse = response.json().await .map_err(|e| format!("JSON parse error: {}", e))?; let chapters = res.content .and_then(|c| c.chapters) .ok_or_else(|| "No chapters found in Audible response".to_string())?; Ok(chapters.into_iter().map(|c| SimpleChapter { title: c.title, start_ms: c.start_offset_ms, duration_ms: c.length_ms, }).collect()) } pub async fn fetch_chapters_from_audnexus(asin: &str, locale: &str) -> Result, String> { let asin_upper = asin.to_uppercase(); let url = format!("https://api.audnex.us/books/{}/chapters?region={}", asin_upper, locale); println!("🔄 Fetching from Audnexus: {}", url); let client = reqwest::Client::new(); let mut response = client .get(&url) .header("User-Agent", "Lectern/1.0") .send() .await .map_err(|e| format!("Audnexus network error: {}", e))?; if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { // Basic retry once after 2 seconds if rate limited tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; response = client .get(&url) .header("User-Agent", "Lectern/1.0") .send() .await .map_err(|e| format!("Audnexus retry error: {}", e))?; } if !response.status().is_success() { return Err(format!("Audnexus returned status: {}", response.status())); } let res: AudnexusResponse = response.json().await .map_err(|e| format!("Audnexus JSON parse error: {}", e))?; if res.chapters.is_empty() { return Err("No chapters found in Audnexus response".to_string()); } Ok(res.chapters.into_iter().map(|c| SimpleChapter { title: c.title, start_ms: c.start_ms, duration_ms: c.length_ms, }).collect()) } /// Fetch metadata from Audible API (returns first match for backward compatibility) pub async fn fetch_metadata(query: &str) -> Result { let results = Self::search_metadata(query, false).await?; results.into_iter().next().ok_or_else(|| "No results found".to_string()) } /// Convert directory of MP3s to M4B with chapters pub async fn convert_to_m4b_with_chapters( input_dir: &Path, output_path: &str, tx: glib::Sender, ) -> Result<(), String> { // Step 1: Get all MP3 files sorted let _ = tx.send(crate::app_event::AppEvent::Log("📁 Scanning directory for MP3 files...".to_string())); let mp3_files = Self::get_sorted_mp3_files(input_dir)?; let _ = tx.send(crate::app_event::AppEvent::Log( format!("✓ Found {} MP3 files", mp3_files.len()) )); if mp3_files.is_empty() { return Err("No MP3 files found in directory".to_string()); } // Step 2: Generate chapter metadata let _ = tx.send(crate::app_event::AppEvent::Log("⏱️ Analyzing file durations...".to_string())); let metadata_content = Self::build_chapters(&mp3_files, tx.clone()).await?; let metadata_file = "/tmp/ffmetadata.txt"; tokio::fs::write(metadata_file, &metadata_content) .await .map_err(|e| format!("Failed to write metadata file: {}", e))?; // Step 3: Create concat file list let concat_file = "/tmp/concat_list.txt"; let concat_content = mp3_files .iter() .map(|p| format!("file '{}'", p.display())) .collect::>() .join("\n"); tokio::fs::write(concat_file, concat_content) .await .map_err(|e| format!("Failed to write concat file: {}", e))?; // Step 4: Run FFmpeg with real-time logging let _ = tx.send(crate::app_event::AppEvent::Log("🎬 Running FFmpeg conversion...".to_string())); Self::run_ffmpeg_with_logs( vec![ "-f".to_string(), "concat".to_string(), "-safe".to_string(), "0".to_string(), "-i".to_string(), concat_file.to_string(), "-i".to_string(), metadata_file.to_string(), "-map_metadata".to_string(), "1".to_string(), "-c:a".to_string(), "aac".to_string(), "-b:a".to_string(), "128k".to_string(), "-f".to_string(), "mp4".to_string(), output_path.to_string(), ], tx.clone(), ) .await?; // Cleanup temp files let _ = tokio::fs::remove_file(metadata_file).await; let _ = tokio::fs::remove_file(concat_file).await; Ok(()) } /// Get sorted list of MP3 files from directory pub fn get_sorted_mp3_files(dir: &Path) -> Result, String> { let mut entries: Vec = std::fs::read_dir(dir) .map_err(|e| format!("Failed to read directory: {}", e))? .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| { p.extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.eq_ignore_ascii_case("mp3")) .unwrap_or(false) }) .collect(); entries.sort_by(|a, b| { a.file_name() .cmp(&b.file_name()) }); Ok(entries) } /// Try to find a local cover image in the directory pub fn find_local_cover(dir: &Path) -> Option { let common_names = ["cover", "folder", "front", "album", "book"]; let extensions = ["jpg", "jpeg", "png", "webp"]; for entry in std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_file() { let stem = path.file_stem()?.to_str()?.to_lowercase(); let ext = path.extension()?.to_str()?.to_lowercase(); if common_names.iter().any(|&n| stem.contains(n)) && extensions.iter().any(|&e| ext == e) { return Some(path); } } } None } /// Try to find a local metadata file (e.g. metadata.json) pub fn find_local_metadata(dir: &Path) -> Option { let meta_path = dir.join("metadata.json"); if meta_path.exists() { if let Ok(content) = std::fs::read_to_string(meta_path) { if let Ok(meta) = serde_json::from_str::(&content) { return Some(meta); } } } None } /// Get chapters from a single M4B file pub async fn get_chapters_from_m4b(file_path: &Path) -> Result, String> { let output = Command::new("ffprobe") .args([ "-v", "error", "-show_chapters", "-of", "json", file_path.to_str().unwrap(), ]) .output() .await .map_err(|e| format!("ffprobe failed: {}", e))?; if !output.status.success() { return Err("ffprobe execution failed".to_string()); } let probe: ProbeResult = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Failed to parse ffprobe chapter output: {}", e))?; let chapters = probe.chapters.unwrap_or_default(); Ok(chapters.into_iter().map(|c| SimpleChapter { title: c.title, start_ms: c.start_time_ms as u64, duration_ms: (c.end_time_ms - c.start_time_ms) as u64, }).collect()) } /// Get chapters from files pub async fn get_chapters(files: &[PathBuf]) -> Result, String> { let mut chapters = Vec::new(); let mut current_offset_ms: u64 = 0; for file in files { // Note: Parallelizing this would be faster, but sequential is safer for now let duration_ms = Self::get_duration(file).await?; let title = file .file_stem() .unwrap_or_default() .to_string_lossy() .to_string(); chapters.push(SimpleChapter { title, start_ms: current_offset_ms, duration_ms, }); current_offset_ms += duration_ms; } Ok(chapters) } /// Build chapter metadata from MP3 files async fn build_chapters( files: &[PathBuf], tx: glib::Sender, ) -> Result { let mut metadata_file = String::from(";FFMETADATA1\n"); let mut current_offset_ms: u64 = 0; for (idx, file) in files.iter().enumerate() { let duration_ms = Self::get_duration(file).await?; let title = file .file_stem() .unwrap_or_default() .to_string_lossy() .to_string(); let end_ms = current_offset_ms + duration_ms; metadata_file.push_str("[CHAPTER]\n"); metadata_file.push_str("TIMEBASE=1/1000\n"); metadata_file.push_str(&format!("START={}\n", current_offset_ms)); metadata_file.push_str(&format!("END={}\n", end_ms)); metadata_file.push_str(&format!("title={}\n", title)); let _ = tx.send(crate::app_event::AppEvent::Log( format!(" Chapter {}: {} ({:.1}s)", idx + 1, title, duration_ms as f64 / 1000.0) )); current_offset_ms = end_ms; } let _ = tx.send(crate::app_event::AppEvent::Log( format!("✓ Generated {} chapters", files.len()) )); Ok(metadata_file) } /// Get duration of audio file using ffprobe pub async fn get_duration(file_path: &Path) -> Result { let output = Command::new("ffprobe") .args([ "-v", "error", "-show_entries", "format=duration", "-of", "json", file_path.to_str().unwrap(), ]) .output() .await .map_err(|e| format!("ffprobe failed: {}", e))?; if !output.status.success() { return Err("ffprobe execution failed".to_string()); } let probe: ProbeOutput = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Failed to parse ffprobe output: {}", e))?; let seconds: f64 = probe .format .duration .parse() .map_err(|e| format!("Invalid duration format: {}", e))?; Ok((seconds * 1000.0) as u64) } /// Run FFmpeg with real-time logging pub async fn run_ffmpeg_with_logs( args: Vec, tx: glib::Sender, ) -> Result<(), String> { let mut child = Command::new("ffmpeg") .args(&args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Failed to start ffmpeg: {}", e))?; // FFmpeg outputs progress to stderr if let Some(stderr) = child.stderr.take() { let reader = BufReader::new(stderr); let mut lines = reader.lines(); tokio::spawn(async move { while let Ok(Some(line)) = lines.next_line().await { // Only log interesting lines (skip verbose output) if line.contains("time=") || line.contains("error") || line.contains("Error") { let _ = tx.send(crate::app_event::AppEvent::Log(format!(" ffmpeg: {}", line.trim()))); } } }); } let status = child .wait() .await .map_err(|e| format!("FFmpeg process error: {}", e))?; if !status.success() { return Err(format!("FFmpeg exited with code: {:?}", status.code())); } Ok(()) } /// Apply metadata tags to M4B file pub async fn apply_tags(file_path: &str, metadata: &BookMetadata) -> Result<(), String> { // Note: audiotags crate doesn't work well with async, so we use blocking operations tokio::task::spawn_blocking({ let file_path = file_path.to_string(); let metadata = metadata.clone(); move || -> Result<(), String> { use audiotags::Tag; let mut tag = Tag::new() .read_from_path(&file_path) .map_err(|e| format!("Failed to read file for tagging: {}", e))?; tag.set_title(&metadata.title); tag.set_album_title(&metadata.title); tag.set_artist(&metadata.authors.join(", ")); tag.set_album_artist(&metadata.authors.join(", ")); if let Some(series) = &metadata.series_name { // Use album field for series (common convention) tag.set_album_title(series); } if let Some(date) = &metadata.release_date { if let Ok(year) = date.split('-').next().unwrap_or(date).parse::() { tag.set_year(year); } } tag.write_to_path(&file_path) .map_err(|e| format!("Failed to write tags: {}", e))?; Ok(()) } }) .await .map_err(|e| format!("Task join error: {}", e))? } /// Upload M4B to Audiobookshelf and trigger scan pub async fn upload_and_scan( file_path: &str, config: &AppConfig, ) -> Result<(), String> { let client = reqwest::Client::new(); // Read file let file_bytes = tokio::fs::read(file_path) .await .map_err(|e| format!("Failed to read file: {}", e))?; let file_name = Path::new(file_path) .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); // Create multipart form let part = reqwest::multipart::Part::bytes(file_bytes) .file_name(file_name); let form = reqwest::multipart::Form::new().part("file", part); // Upload let upload_url = format!("{}/api/upload", config.abs_host); let response = client .post(&upload_url) .header("Authorization", format!("Bearer {}", config.abs_token)) .multipart(form) .send() .await .map_err(|e| format!("Upload request failed: {}", e))?; if !response.status().is_success() { return Err(format!("Upload failed with status: {}", response.status())); } // Trigger library scan let scan_url = format!("{}/api/libraries/{}/scan", config.abs_host, config.abs_library_id); let scan_response = client .post(&scan_url) .header("Authorization", format!("Bearer {}", config.abs_token)) .send() .await .map_err(|e| format!("Scan request failed: {}", e))?; if !scan_response.status().is_success() { return Err(format!("Scan trigger failed with status: {}", scan_response.status())); } Ok(()) } /// Test connection to Audiobookshelf server #[allow(dead_code)] pub async fn test_connection(config: &AppConfig) -> Result { let client = reqwest::Client::new(); let url = format!("{}/api/libraries", config.abs_host); let response = client .get(&url) .header("Authorization", format!("Bearer {}", config.abs_token)) .send() .await .map_err(|e| format!("Connection failed: {}", e))?; if response.status().is_success() { Ok("Connection successful!".to_string()) } else { Err(format!("Authentication failed: {}", response.status())) } } /// Fetch image bytes from URL pub async fn fetch_image(url: &str) -> Result, String> { let client = reqwest::Client::new(); let response = client.get(url) .send() .await .map_err(|e| format!("Failed to fetch image: {}", e))?; if !response.status().is_success() { return Err(format!("Image fetch failed: {}", response.status())); } let bytes = response.bytes() .await .map_err(|e| format!("Failed to get bytes: {}", e))?; Ok(bytes.to_vec()) } /// Resolve output path based on template and metadata pub fn resolve_output_path(config: &AppConfig, metadata: &crate::models::metadata::BookMetadata) -> Option { let base = config.local_library.as_ref()?; let mut path = PathBuf::from(base); let mut template = config.path_template.clone(); let author = metadata.authors.first().map(|s| s.as_str()).unwrap_or("Unknown Author"); let title = &metadata.title; let series = metadata.series_name.as_deref().unwrap_or(""); let series_num = metadata.series_number.as_deref().unwrap_or(""); let disk_num = metadata.disk_number.as_deref().unwrap_or(""); let chapter_num = metadata.chapter_number.as_deref().unwrap_or(""); let year = metadata.release_date.as_deref().map(|d| d.split('-').next().unwrap_or("")).unwrap_or(""); let quality = metadata.quality.as_deref().unwrap_or(""); let asin = metadata.asin.as_deref().unwrap_or(""); // Simple replacements template = template.replace("{Author}", &Self::sanitize_filename(author)); template = template.replace("{Title}", &Self::sanitize_filename(title)); template = template.replace("{Series}", &Self::sanitize_filename(series)); template = template.replace("{Year}", &Self::sanitize_filename(year)); template = template.replace("{ASIN}", &Self::sanitize_filename(asin)); template = template.replace("{Quality}", &Self::sanitize_filename(quality)); // Replacements with potential padding template = Self::replace_with_padding(template, "SeriesNumber", series_num); template = Self::replace_with_padding(template, "DiskNumber", disk_num); template = Self::replace_with_padding(template, "ChapterNumber", chapter_num); // Fallback for non-padded versions if not already replaced template = template.replace("{SeriesNumber}", &Self::sanitize_filename(series_num)); template = template.replace("{DiskNumber}", &Self::sanitize_filename(disk_num)); template = template.replace("{ChapterNumber}", &Self::sanitize_filename(chapter_num)); for part in template.split('/') { if !part.is_empty() { path.push(part); } } if path.extension().map(|e| e != "m4b").unwrap_or(true) { path.set_extension("m4b"); } Some(path) } fn replace_with_padding(mut template: String, token: &str, value: &str) -> String { let pattern = format!("{{{}:", token); while let Some(start) = template.find(&pattern) { if let Some(end) = template[start..].find('}') { let full_token = &template[start..start + end + 1]; let padding_spec = &full_token[pattern.len()..full_token.len() - 1]; // e.g. "00" let formatted = if let Ok(val_int) = value.parse::() { format!("{:0>width$}", val_int, width = padding_spec.len()) } else { value.to_string() }; template = template.replace(full_token, &Self::sanitize_filename(&formatted)); } else { break; } } template } fn sanitize_filename(s: &str) -> String { s.chars() .map(|c| if c.is_alphanumeric() || " _-.".contains(c) { c } else { '_' }) .collect::() .trim() .to_string() } /// Build chapter metadata from ChapterObjects (from UI) pub fn build_chapters_from_objects(chapters: &[crate::models::chapter::ChapterObject]) -> String { let mut metadata_file = String::from(";FFMETADATA1\n"); for ch in chapters { let start_ms = ch.start_time(); let duration_ms = ch.duration(); let end_ms = start_ms + duration_ms; let title = ch.title(); metadata_file.push_str("[CHAPTER]\n"); metadata_file.push_str("TIMEBASE=1/1000\n"); metadata_file.push_str(&format!("START={}\n", start_ms)); metadata_file.push_str(&format!("END={}\n", end_ms)); metadata_file.push_str(&format!("title={}\n", title)); } metadata_file } }