Add example parsers and update documentation

- Add Python parser example (python_parser.py)
- Add simple JSON parser example (simple_json_parser.py)
- Update README with comprehensive documentation
- Enhance CLI functionality and main application logic
- Update dependencies in Cargo.toml and Cargo.lock
This commit is contained in:
2025-09-19 13:55:23 +02:00
parent f76a50ea43
commit 8bb432a498
7 changed files with 1055 additions and 56 deletions

2
Cargo.lock generated
View File

@@ -90,7 +90,7 @@ dependencies = [
[[package]]
name = "audible-util"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"Inflector",
"anyhow",

View File

@@ -1,8 +1,8 @@
[package]
name = "audible-util"
version = "0.3.0"
version = "0.5.0"
edition = "2021"
description = "A utility for converting Audible .aaxc files to common audio formats (mp3, wav, flac), with optional splitting by chapters."
description = "A utility for converting Audible .aaxc files to common audio formats (mp3, wav, flac), with optional splitting by chapters and machine-readable JSON output."
[dependencies]
serde = { version = "1.0", features = ["derive"] }

194
README.md
View File

@@ -2,7 +2,7 @@
**audible-util** is a command-line utility for converting Audible `.aaxc` audiobook files into standard audio formats (MP3, WAV, FLAC) using a voucher file generated by [audible-cli](https://github.com/mkb79/audible-cli). It leverages `ffmpeg` and `ffprobe` for decoding and metadata extraction, providing a robust and extensible tool for audiobook enthusiasts and archivists.
---
---ca
## Features
@@ -15,7 +15,10 @@
- **Flexible naming formats** - Customize how chapter files are named.
- **Duration filtering** - Filter out chapters below a minimum duration.
- Extensible output format system (easy to add new formats).
- Progress indication and detailed logging.
- **Enhanced progress reporting** - Real-time progress bars with ETA, speed, bitrate, and file size information.
- **Verbose progress mode** - Detailed logging and additional progress metrics for long conversions.
- **Machine-readable output** - JSON progress events perfect for integration with Python, shell scripts, and automation tools.
- **Multi-core processing** - Configurable FFmpeg threading for optimal CPU utilization and faster conversions.
- Helpful error messages and validation.
- **Short CLI options** - Use `-s`, `-v`, `-o`, etc. for faster command entry.
@@ -83,6 +86,9 @@ If `-v` is omitted, the tool will look for a voucher file named `<book>.voucher`
| `--split-structure` | `-t` | Structure | No | Output structure: `flat` or `hierarchical`. Default: `flat`. |
| `--merge-short-chapters` | `-m` | Flag | No | Merge short chapters with next chapter instead of filtering them out. |
| `--output-type` | `-T` | Format | No | Output file type. Default: `mp3`. Supports: mp3, wav, flac, ogg, m4a. |
| `--verbose-progress` | `-P` | Flag | No | Enable verbose progress reporting with detailed metrics. |
| `--machine-readable` | `-M` | Flag | No | Enable machine-readable JSON output mode for programmatic parsing. |
| `--threads` | | String | No | Number of threads for FFmpeg processing. Default: `0` (auto-detect all cores). |
#### Example: Convert to FLAC with custom output path
@@ -108,10 +114,36 @@ audible-util -a book.aaxc -v book.voucher -s -t hierarchical -o chapters/
audible-util -a book.aaxc -v book.voucher -s -d 10 -m -f number-title -o chapters/
```
#### Example: Full featured command
#### Example: Full featured command with verbose progress
```sh
audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hierarchical -m -T flac -o output_dir/
audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hierarchical -m -T flac -P -o output_dir/
```
#### Example: Convert with verbose progress reporting
```sh
audible-util -a book.aaxc -P
```
#### Example: Convert with machine-readable JSON output
```sh
audible-util -a book.aaxc -M
```
#### Example: Use specific number of threads for FFmpeg
```sh
audible-util -a book.aaxc --threads 4
```
#### Example: Let FFmpeg auto-detect optimal thread count (default)
```sh
audible-util -a book.aaxc --threads 0
# or simply
audible-util -a book.aaxc
```
---
@@ -163,15 +195,159 @@ The tool can split audiobooks into individual chapter files using chapter metada
---
## Machine-Readable Output
The `--machine-readable` flag enables structured JSON output that's perfect for integration with Python programs, shell scripts, and automation tools.
### JSON Event Types
#### `conversion_started`
```json
{
"type": "conversion_started",
"total_chapters": 5,
"output_format": "mp3",
"output_path": "/path/to/output"
}
```
#### `chapter_started`
```json
{
"type": "chapter_started",
"chapter_number": 1,
"total_chapters": 5,
"chapter_title": "Chapter 1: Introduction",
"duration_seconds": 120.5
}
```
#### `chapter_progress`
```json
{
"type": "chapter_progress",
"chapter_number": 1,
"total_chapters": 5,
"chapter_title": "Chapter 1: Introduction",
"progress_percentage": 45.2,
"current_time": 54.5,
"total_duration": 120.5,
"speed": 1.2,
"bitrate": 128000.0,
"file_size": 2048576,
"fps": 25.0,
"eta_seconds": 55.0
}
```
#### `chapter_completed`
```json
{
"type": "chapter_completed",
"chapter_number": 1,
"total_chapters": 5,
"chapter_title": "Chapter 1: Introduction",
"output_file": "/path/to/output/Chapter01_Introduction.mp3",
"duration_seconds": 120.5
}
```
#### `conversion_completed`
```json
{
"type": "conversion_completed",
"total_chapters": 5,
"total_duration_seconds": 600.0,
"success": true
}
```
#### `error`
```json
{
"type": "error",
"message": "ffmpeg failed to convert chapter",
"chapter_number": 3
}
```
### Python Integration Examples
#### Simple JSON Parser
```python
import json
import subprocess
# Run audible-util with machine-readable output
process = subprocess.Popen(
["audible-util", "-a", "book.aaxc", "-M"],
stdout=subprocess.PIPE,
text=True
)
# Parse each JSON event
for line in process.stdout:
if line.strip():
event = json.loads(line)
print(f"Event: {event['type']}")
if event['type'] == 'chapter_progress':
print(f"Progress: {event['progress_percentage']:.1f}%")
```
#### Advanced Progress Parser
See `examples/python_parser.py` for a complete example with progress bars, file size formatting, and error handling.
### Usage Examples
#### Basic Machine-Readable Output
```sh
audible-util -a book.aaxc -M
```
#### Chapter Splitting with Machine-Readable Output
```sh
audible-util -a book.aaxc -s -M -o chapters/
```
#### Integration with Python
```sh
python3 examples/python_parser.py -a book.aaxc -v book.voucher -s
```
---
## Advanced Features
### Progress Tracking
The tool provides detailed progress information during conversion:
- Real-time ffmpeg progress for each chapter
The tool provides comprehensive progress information during conversion:
#### Standard Progress Mode
- Real-time progress bars for each chapter and overall conversion
- ETA (Estimated Time of Arrival) calculations
- Conversion speed (e.g., 1.2x real-time)
- Current bitrate and file size information
- Chapter-by-chapter conversion status
- File counting and validation
- Detailed logging with `RUST_LOG=info`
#### Verbose Progress Mode (`-V` or `--verbose-progress`)
- All standard progress information plus:
- Detailed time tracking (current/total time)
- FPS (Frames Per Second) information
- Enhanced logging with detailed progress metrics
- More comprehensive progress bar information
#### Multi-Chapter Conversions
- Overall progress tracking across all chapters
- Individual chapter progress with detailed metrics
- Hierarchical progress display for complex chapter structures
- Smart progress estimation based on chapter durations
#### Machine-Readable Output (`-M` or `--machine-readable`)
- **JSON Progress Events**: Structured JSON output for easy parsing
- **Event Types**: `conversion_started`, `chapter_started`, `chapter_progress`, `chapter_completed`, `conversion_completed`, `error`
- **Python Integration**: Ready-to-use Python examples for parsing
- **Automation Friendly**: Perfect for shell scripts, CI/CD pipelines, and monitoring tools
- **No Progress Bars**: Clean JSON output without visual progress indicators
### Error Handling
@@ -289,6 +465,8 @@ audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hie
- `-t` = `--split-structure`
- `-m` = `--merge-short-chapters`
- `-T` = `--output-type`
- `-V` = `--verbose-progress`
- `-M` = `--machine-readable`
---

177
examples/python_parser.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Python example for parsing audible-util machine-readable output.
This script demonstrates how to parse the JSON progress events emitted by
audible-util when run with the --machine-readable flag.
Usage:
python3 python_parser.py
"""
import json
import subprocess
import sys
import time
from typing import Dict, Any, Optional
class AudibleUtilParser:
"""Parser for audible-util machine-readable output."""
def __init__(self):
self.current_chapter = 0
self.total_chapters = 0
self.start_time = None
self.conversion_success = False
def parse_event(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse a single JSON event line."""
try:
return json.loads(line.strip())
except json.JSONDecodeError:
return None
def handle_event(self, event: Dict[str, Any]) -> None:
"""Handle a parsed progress event."""
event_type = event.get("type")
if event_type == "conversion_started":
self.total_chapters = event.get("total_chapters", 0)
output_format = event.get("output_format", "unknown")
output_path = event.get("output_path", "unknown")
print(f"🚀 Conversion started: {self.total_chapters} chapters to {output_format} format")
print(f"📁 Output path: {output_path}")
self.start_time = time.time()
elif event_type == "chapter_started":
chapter_num = event.get("chapter_number", 0)
chapter_title = event.get("chapter_title", "Unknown")
duration = event.get("duration_seconds", 0)
self.current_chapter = chapter_num
print(f"\n📖 Chapter {chapter_num}/{self.total_chapters}: {chapter_title}")
print(f"⏱️ Duration: {duration:.1f} seconds")
elif event_type == "chapter_progress":
chapter_num = event.get("chapter_number", 0)
progress_pct = event.get("progress_percentage", 0)
current_time = event.get("current_time", 0)
total_duration = event.get("total_duration", 0)
speed = event.get("speed", 0)
bitrate = event.get("bitrate", 0)
file_size = event.get("file_size", 0)
eta = event.get("eta_seconds")
# Create progress bar
bar_length = 40
filled_length = int(bar_length * progress_pct / 100)
bar = "" * filled_length + "" * (bar_length - filled_length)
# Format file size
size_str = self.format_file_size(file_size)
# Format ETA
eta_str = f"{eta:.0f}s" if eta else "Unknown"
print(f"\r {bar} {progress_pct:5.1f}% | Speed: {speed:.1f}x | Bitrate: {bitrate/1000:.0f}kbps | Size: {size_str} | ETA: {eta_str}", end="", flush=True)
elif event_type == "chapter_completed":
chapter_num = event.get("chapter_number", 0)
chapter_title = event.get("chapter_title", "Unknown")
output_file = event.get("output_file", "Unknown")
duration = event.get("duration_seconds", 0)
print(f"\n✅ Chapter {chapter_num} completed: {chapter_title}")
print(f"📄 Output: {output_file}")
print(f"⏱️ Duration: {duration:.1f} seconds")
elif event_type == "conversion_completed":
total_duration = event.get("total_duration_seconds", 0)
success = event.get("success", False)
self.conversion_success = success
if success:
print(f"\n🎉 Conversion completed successfully!")
print(f"⏱️ Total time: {total_duration:.1f} seconds")
else:
print(f"\n❌ Conversion failed!")
elif event_type == "error":
message = event.get("message", "Unknown error")
chapter_num = event.get("chapter_number")
if chapter_num:
print(f"\n❌ Error in chapter {chapter_num}: {message}")
else:
print(f"\n❌ Error: {message}")
def format_file_size(self, size_bytes: int) -> str:
"""Format file size in human-readable format."""
if size_bytes == 0:
return "0 B"
units = ["B", "KB", "MB", "GB"]
size = float(size_bytes)
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
return f"{size:.1f} {units[unit_index]}"
def run_conversion(self, args: list) -> int:
"""Run audible-util with machine-readable output and parse events."""
print("🎵 Starting audible-util conversion with machine-readable output...")
print("=" * 60)
try:
# Run audible-util with machine-readable flag
process = subprocess.Popen(
args + ["--machine-readable"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
# Parse output line by line
for line in process.stdout:
event = self.parse_event(line)
if event:
self.handle_event(event)
# Wait for process to complete
return_code = process.wait()
if return_code != 0:
stderr_output = process.stderr.read()
print(f"\n❌ Process failed with return code {return_code}")
print(f"Error output: {stderr_output}")
return return_code
return 0
except Exception as e:
print(f"\n❌ Error running audible-util: {e}")
return 1
def main():
"""Main function."""
if len(sys.argv) < 2:
print("Usage: python3 python_parser.py <audible-util-args>")
print("Example: python3 python_parser.py -a book.aaxc -v book.voucher -s")
sys.exit(1)
# Get audible-util arguments
audible_args = sys.argv[1:]
# Create parser and run conversion
parser = AudibleUtilParser()
return_code = parser.run_conversion(audible_args)
sys.exit(return_code)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Simple Python example for parsing audible-util machine-readable output.
This script shows how to parse the raw JSON events from audible-util.
Usage:
python3 simple_json_parser.py
"""
import json
import subprocess
import sys
def main():
"""Parse audible-util machine-readable output."""
if len(sys.argv) < 2:
print("Usage: python3 simple_json_parser.py <audible-util-args>")
print("Example: python3 simple_json_parser.py -a book.aaxc -v book.voucher")
sys.exit(1)
# Get audible-util arguments
audible_args = sys.argv[1:]
try:
# Run audible-util with machine-readable flag
process = subprocess.Popen(
audible_args + ["--machine-readable"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# Parse each line of JSON output
for line in process.stdout:
line = line.strip()
if line:
try:
event = json.loads(line)
print(f"Event: {json.dumps(event, indent=2)}")
except json.JSONDecodeError as e:
print(f"Failed to parse JSON: {line} - Error: {e}")
# Wait for process to complete
return_code = process.wait()
if return_code != 0:
stderr_output = process.stderr.read()
print(f"Process failed with return code {return_code}")
print(f"Error output: {stderr_output}")
sys.exit(return_code)
except Exception as e:
print(f"Error running audible-util: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -81,6 +81,32 @@ pub struct Cli {
/// Example: --output_type mp3
#[clap(short = 'T', long, value_enum, value_name = "TYPE", default_value = "mp3", help = "Output format")]
pub output_type: OutputType,
/// Enable verbose progress reporting.
///
/// When enabled, shows detailed progress information including bitrate, file size, and conversion speed.
/// This provides more detailed feedback during long conversions.
#[clap(short = 'P', long, help = "Enable verbose progress reporting")]
pub verbose_progress: bool,
/// Enable machine-readable output mode.
///
/// When enabled, outputs structured JSON progress information to stdout, making it easy to parse
/// from other programs. Progress bars and human-readable output are suppressed in this mode.
/// Perfect for integration with Python, shell scripts, or other automation tools.
#[clap(short = 'M', long, help = "Enable machine-readable JSON output mode")]
pub machine_readable: bool,
/// Number of threads for FFmpeg processing.
///
/// Controls how many CPU cores FFmpeg will use for encoding/decoding.
/// - 0 or "auto": Let FFmpeg automatically detect and use all available cores (default)
/// - N: Use exactly N threads (e.g., 4 for 4 cores)
/// - "auto": Same as 0, auto-detect optimal thread count
///
/// Example: --threads 4 or --threads auto
#[clap(long, value_name = "THREADS", default_value = "0", help = "Number of threads for FFmpeg processing (0=auto)")]
pub threads: String,
}
pub trait OutputFormat {

View File

@@ -13,7 +13,362 @@ use std::{
};
use anyhow::{Context, Result};
use log::{info, error, warn};
use indicatif::{ProgressBar, ProgressStyle};
use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
use std::time::{Duration, Instant};
use serde::Serialize;
/// Machine-readable progress events for JSON output
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
enum ProgressEvent {
#[serde(rename = "conversion_started")]
ConversionStarted {
total_chapters: usize,
output_format: String,
output_path: String,
},
#[serde(rename = "chapter_started")]
ChapterStarted {
chapter_number: usize,
total_chapters: usize,
chapter_title: String,
duration_seconds: f64,
},
#[serde(rename = "chapter_progress")]
ChapterProgress {
chapter_number: usize,
total_chapters: usize,
chapter_title: String,
progress_percentage: f64,
current_time: f64,
total_duration: f64,
speed: f64,
bitrate: f64,
file_size: u64,
fps: f64,
eta_seconds: Option<f64>,
},
#[serde(rename = "chapter_completed")]
ChapterCompleted {
chapter_number: usize,
total_chapters: usize,
chapter_title: String,
output_file: String,
duration_seconds: f64,
},
#[serde(rename = "conversion_completed")]
ConversionCompleted {
total_chapters: usize,
total_duration_seconds: f64,
success: bool,
},
#[serde(rename = "error")]
Error {
message: String,
chapter_number: Option<usize>,
},
}
impl ProgressEvent {
fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
}
/// Progress tracking information for a single conversion
#[derive(Debug, Clone)]
struct ConversionProgress {
current_time: f64,
total_duration: f64,
speed: f64,
bitrate: f64,
size: u64,
fps: f64,
}
impl ConversionProgress {
fn new(total_duration: f64) -> Self {
Self {
current_time: 0.0,
total_duration,
speed: 0.0,
bitrate: 0.0,
size: 0,
fps: 0.0,
}
}
fn percentage(&self) -> f64 {
if self.total_duration > 0.0 {
(self.current_time / self.total_duration * 100.0).min(100.0)
} else {
0.0
}
}
fn eta(&self) -> Option<Duration> {
if self.speed > 0.0 && self.current_time < self.total_duration {
let remaining_time = (self.total_duration - self.current_time) / self.speed;
Some(Duration::from_secs(remaining_time as u64))
} else {
None
}
}
fn format_time(seconds: f64) -> String {
let hours = (seconds / 3600.0) as u64;
let minutes = ((seconds % 3600.0) / 60.0) as u64;
let secs = (seconds % 60.0) as u64;
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
}
fn format_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.1} {}", size, UNITS[unit_index])
}
}
/// Progress manager for tracking overall conversion progress
struct ProgressManager {
multi: MultiProgress,
overall_pb: ProgressBar,
current_pb: Option<ProgressBar>,
start_time: Instant,
total_chapters: usize,
current_chapter: usize,
verbose: bool,
machine_readable: bool,
}
impl ProgressManager {
fn new_with_verbose(total_chapters: usize, verbose: bool) -> Self {
Self::new_with_options(total_chapters, verbose, false)
}
fn new_machine_readable(total_chapters: usize) -> Self {
Self::new_with_options(total_chapters, false, true)
}
fn new_with_options(total_chapters: usize, verbose: bool, machine_readable: bool) -> Self {
let multi = MultiProgress::new();
let overall_pb = multi.add(ProgressBar::new(total_chapters as u64));
if !machine_readable {
overall_pb.set_style(
ProgressStyle::default_bar()
.template("{bar:40.cyan/blue} {pos:>3}/{len:3} chapters [{elapsed_precise}] {msg}")
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "),
);
overall_pb.set_message("Starting conversion...");
} else {
// Hide progress bars in machine-readable mode
overall_pb.set_style(ProgressStyle::default_bar().template("").unwrap());
}
Self {
multi,
overall_pb,
current_pb: None,
start_time: Instant::now(),
total_chapters,
current_chapter: 0,
verbose,
machine_readable,
}
}
fn start_chapter(&mut self, chapter_title: &str, duration: f64) -> ProgressBar {
self.current_chapter += 1;
if self.machine_readable {
let event = ProgressEvent::ChapterStarted {
chapter_number: self.current_chapter,
total_chapters: self.total_chapters,
chapter_title: chapter_title.to_string(),
duration_seconds: duration,
};
println!("{}", event.to_json());
} else {
self.overall_pb.set_message(format!("Chapter {}/{}: {}",
self.current_chapter, self.total_chapters, chapter_title));
}
let current_pb = self.multi.add(ProgressBar::new(duration as u64));
if !self.machine_readable {
current_pb.set_style(
ProgressStyle::default_bar()
.template("{bar:40.green/yellow} {percent:>3}% [{elapsed_precise}] {msg}")
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "),
);
current_pb.set_message(format!("Converting: {}", chapter_title));
current_pb.enable_steady_tick(Duration::from_millis(100));
} else {
// Hide progress bars in machine-readable mode
current_pb.set_style(ProgressStyle::default_bar().template("").unwrap());
}
self.current_pb = Some(current_pb.clone());
current_pb
}
fn update_chapter_progress(&self, progress: &ConversionProgress) {
if self.machine_readable {
let event = ProgressEvent::ChapterProgress {
chapter_number: self.current_chapter,
total_chapters: self.total_chapters,
chapter_title: "".to_string(), // Will be filled by caller
progress_percentage: progress.percentage(),
current_time: progress.current_time,
total_duration: progress.total_duration,
speed: progress.speed,
bitrate: progress.bitrate,
file_size: progress.size,
fps: progress.fps,
eta_seconds: progress.eta().map(|eta| eta.as_secs() as f64),
};
println!("{}", event.to_json());
} else {
if let Some(ref pb) = self.current_pb {
pb.set_position(progress.current_time as u64);
let eta_str = progress.eta()
.map(|eta| format!("ETA: {}", Self::format_duration(eta)))
.unwrap_or_else(|| "ETA: --:--:--".to_string());
let speed_str = if progress.speed > 0.0 {
format!("Speed: {:.1}x", progress.speed)
} else {
"Speed: --".to_string()
};
let bitrate_str = if progress.bitrate > 0.0 {
format!("Bitrate: {:.0} kbps", progress.bitrate / 1000.0)
} else {
"Bitrate: --".to_string()
};
let size_str = if progress.size > 0 {
format!("Size: {}", ConversionProgress::format_size(progress.size))
} else {
"Size: --".to_string()
};
let fps_str = if self.verbose && progress.fps > 0.0 {
format!("FPS: {:.1}", progress.fps)
} else {
String::new()
};
let time_str = if self.verbose {
format!("Time: {}/{}",
ConversionProgress::format_time(progress.current_time),
ConversionProgress::format_time(progress.total_duration))
} else {
String::new()
};
let mut message_parts = vec![eta_str, speed_str, bitrate_str, size_str];
if self.verbose {
if !fps_str.is_empty() {
message_parts.push(fps_str);
}
if !time_str.is_empty() {
message_parts.push(time_str);
}
}
pb.set_message(message_parts.join(" | "));
}
// Log detailed progress in verbose mode
if self.verbose {
info!("Progress: {:.1}% | Time: {}/{} | Speed: {:.1}x | Bitrate: {:.0} kbps | Size: {}",
progress.percentage(),
ConversionProgress::format_time(progress.current_time),
ConversionProgress::format_time(progress.total_duration),
progress.speed,
progress.bitrate / 1000.0,
ConversionProgress::format_size(progress.size)
);
}
}
}
fn complete_chapter(&mut self, chapter_title: &str, output_file: &str, duration: f64) {
if self.machine_readable {
let event = ProgressEvent::ChapterCompleted {
chapter_number: self.current_chapter,
total_chapters: self.total_chapters,
chapter_title: chapter_title.to_string(),
output_file: output_file.to_string(),
duration_seconds: duration,
};
println!("{}", event.to_json());
} else {
if let Some(pb) = self.current_pb.take() {
pb.finish_with_message("Chapter completed");
}
}
self.overall_pb.inc(1);
}
fn complete_all(&self, success: bool) {
if self.machine_readable {
let event = ProgressEvent::ConversionCompleted {
total_chapters: self.total_chapters,
total_duration_seconds: self.start_time.elapsed().as_secs() as f64,
success,
};
println!("{}", event.to_json());
} else {
self.overall_pb.finish_with_message(format!(
"All {} chapters completed in {}",
self.total_chapters,
Self::format_duration(self.start_time.elapsed())
));
}
}
fn emit_error(&self, message: &str, chapter_number: Option<usize>) {
if self.machine_readable {
let event = ProgressEvent::Error {
message: message.to_string(),
chapter_number,
};
println!("{}", event.to_json());
}
}
fn emit_conversion_started(&self, output_format: &str, output_path: &str) {
if self.machine_readable {
let event = ProgressEvent::ConversionStarted {
total_chapters: self.total_chapters,
output_format: output_format.to_string(),
output_path: output_path.to_string(),
};
println!("{}", event.to_json());
}
}
fn format_duration(duration: Duration) -> String {
let total_seconds = duration.as_secs();
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}
}
fn main() -> Result<()> {
// Initialize logger
@@ -372,6 +727,9 @@ fn run() -> Result<()> {
&output_base_path,
&ext,
&codec,
cli.verbose_progress,
cli.machine_readable,
&cli.threads,
)?;
info!("Chapter splitting completed successfully");
@@ -381,6 +739,16 @@ fn run() -> Result<()> {
info!("Title: {}", title);
info!("Output file name: {}", file_name);
// Handle machine-readable mode for single file conversion
if cli.machine_readable {
let event = ProgressEvent::ConversionStarted {
total_chapters: 1,
output_format: ext.to_string(),
output_path: file_name.clone(),
};
println!("{}", event.to_json());
}
info!("Starting ffmpeg conversion");
let mut cmd = ffmpeg(
aaxc_file_path,
@@ -389,6 +757,9 @@ fn run() -> Result<()> {
duration,
file_name.clone(),
codec,
cli.verbose_progress,
cli.machine_readable,
&cli.threads,
)
.with_context(|| {
"Failed to start ffmpeg. Please ensure ffmpeg is installed and available in your PATH."
@@ -398,8 +769,23 @@ fn run() -> Result<()> {
.with_context(|| "ffmpeg process failed to complete. Please check your input files and try again.")?;
if status.success() {
if cli.machine_readable {
let event = ProgressEvent::ConversionCompleted {
total_chapters: 1,
total_duration_seconds: 0.0, // Will be calculated if needed
success: true,
};
println!("{}", event.to_json());
}
info!("ffmpeg conversion completed successfully");
} else {
if cli.machine_readable {
let event = ProgressEvent::Error {
message: "ffmpeg conversion failed".to_string(),
chapter_number: Some(1),
};
println!("{}", event.to_json());
}
error!("ffmpeg conversion failed with status: {:?}", status);
anyhow::bail!(
"ffmpeg failed to convert the file. Please check your input files and try again. \
@@ -487,10 +873,23 @@ fn convert_chapters(
output_base_path: &Path,
extension: &str,
codec: &str,
verbose: bool,
machine_readable: bool,
threads: &str,
) -> Result<()> {
let total_chapters = chapters.len();
info!("Converting {} chapters", total_chapters);
// Initialize progress manager
let mut progress_manager = if machine_readable {
ProgressManager::new_machine_readable(total_chapters)
} else {
ProgressManager::new_with_verbose(total_chapters, verbose)
};
// Emit conversion started event
progress_manager.emit_conversion_started(extension, &output_base_path.to_string_lossy());
for (index, chapter) in chapters.iter().enumerate() {
let chapter_number = index + 1;
info!("Converting chapter {}/{}: {}", chapter_number, total_chapters, chapter.title);
@@ -511,6 +910,7 @@ fn convert_chapters(
// Convert time to ffmpeg format (HH:MM:SS.mmm)
let start_time = format_time_from_ms(chapter.start_offset_ms);
let duration_time = format_time_from_ms(chapter.length_ms);
let duration_seconds = chapter.length_ms as f64 / 1000.0;
info!("Chapter time range: {} to {} (duration: {})",
start_time,
@@ -523,8 +923,11 @@ fn convert_chapters(
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
// Run ffmpeg for this chapter
let mut cmd = ffmpeg_chapter(
// Start progress tracking for this chapter
progress_manager.start_chapter(&chapter.title, duration_seconds);
// Run ffmpeg for this chapter with enhanced progress tracking
let mut cmd = ffmpeg_chapter_with_progress(
aaxc_file_path.to_path_buf(),
audible_key.to_string(),
audible_iv.to_string(),
@@ -532,15 +935,32 @@ fn convert_chapters(
duration_time,
output_path.to_string_lossy().to_string(),
codec,
&progress_manager,
threads,
)?;
// Parse ffmpeg progress in the main thread
if let Some(stdout) = cmd.stdout.as_mut() {
let stdout_reader = std::io::BufReader::new(stdout);
let mut progress = ConversionProgress::new(duration_seconds);
for line in stdout_reader.lines() {
if let Ok(l) = line {
parse_ffmpeg_progress_line(&l, &mut progress);
progress_manager.update_chapter_progress(&progress);
}
}
}
let status = cmd.wait()
.with_context(|| format!("ffmpeg process failed for chapter: {}", chapter.title))?;
if status.success() {
progress_manager.complete_chapter(&chapter.title, &output_path.to_string_lossy(), duration_seconds);
info!("Chapter {}/{} completed: {}", chapter_number, total_chapters, output_path.display());
} else {
error!("ffmpeg conversion failed for chapter: {}", chapter.title);
progress_manager.emit_error(&format!("ffmpeg failed to convert chapter '{}'", chapter.title), Some(chapter_number));
anyhow::bail!(
"ffmpeg failed to convert chapter '{}'. Please check your input files and try again.",
chapter.title
@@ -548,6 +968,7 @@ fn convert_chapters(
}
}
progress_manager.complete_all(true);
info!("All {} chapters converted successfully", total_chapters);
Ok(())
}
@@ -563,8 +984,8 @@ fn format_time_from_ms(ms: i64) -> String {
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, milliseconds)
}
/// Run ffmpeg for a specific chapter with time range
fn ffmpeg_chapter(
/// Run ffmpeg for a specific chapter with enhanced progress tracking
fn ffmpeg_chapter_with_progress(
aaxc_file_path: PathBuf,
audible_key: String,
audible_iv: String,
@@ -572,8 +993,10 @@ fn ffmpeg_chapter(
duration: String,
file_name: String,
codec: &str,
_progress_manager: &ProgressManager,
threads: &str,
) -> Result<Child> {
let mut cmd = Command::new("ffmpeg")
let cmd = Command::new("ffmpeg")
.args([
"-audible_key",
audible_key.as_str(),
@@ -583,6 +1006,8 @@ fn ffmpeg_chapter(
aaxc_file_path
.to_str()
.context("Failed to convert input file path to string.")?,
"-threads",
threads,
"-ss",
start_time.as_str(),
"-t",
@@ -602,34 +1027,73 @@ fn ffmpeg_chapter(
.spawn()
.with_context(|| "Failed to execute ffmpeg. Is ffmpeg installed and available in your PATH?")?;
{
let stdout = cmd.stdout.as_mut().context("Failed to capture ffmpeg stdout.")?;
let stdout_reader = std::io::BufReader::new(stdout);
// Note: Progress parsing will be handled in the main thread
// The progress manager will be updated by the calling function
// Progress bar setup
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner} [{elapsed_precise}] {msg}")
.unwrap()
);
pb.enable_steady_tick(std::time::Duration::from_millis(100));
for line in stdout_reader.lines() {
let l = line.context("Failed to read line from ffmpeg output.")?;
if l.contains("time=") {
pb.set_message(format!("Progress: {} / {}", l, duration));
}
if l.contains("speed=") {
pb.set_message(format!("{} | {}", pb.message(), l));
}
}
pb.finish_with_message("Chapter conversion complete");
}
info!("ffmpeg process finished for chapter");
info!("ffmpeg process started for chapter");
Ok(cmd)
}
/// Parse ffmpeg progress line and update progress struct
fn parse_ffmpeg_progress_line(line: &str, progress: &mut ConversionProgress) {
// Parse time=HH:MM:SS.mmm
if let Some(time_str) = line.strip_prefix("time=") {
if let Ok(time_seconds) = parse_time_to_seconds(time_str.trim()) {
progress.current_time = time_seconds;
}
}
// Parse speed=N.Nx
if let Some(speed_str) = line.strip_prefix("speed=") {
if let Some(speed_value) = speed_str.strip_suffix('x') {
if let Ok(speed) = speed_value.parse::<f64>() {
progress.speed = speed;
}
}
}
// Parse bitrate=N
if let Some(bitrate_str) = line.strip_prefix("bitrate=") {
if let Ok(bitrate) = bitrate_str.trim().parse::<f64>() {
progress.bitrate = bitrate;
}
}
// Parse size=N
if let Some(size_str) = line.strip_prefix("size=") {
if let Ok(size) = size_str.trim().parse::<u64>() {
progress.size = size;
}
}
// Parse fps=N.N
if let Some(fps_str) = line.strip_prefix("fps=") {
if let Ok(fps) = fps_str.trim().parse::<f64>() {
progress.fps = fps;
}
}
}
/// Parse time string (HH:MM:SS.mmm) to seconds
fn parse_time_to_seconds(time_str: &str) -> Result<f64, std::num::ParseFloatError> {
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() == 3 {
let hours: f64 = parts[0].parse()?;
let minutes: f64 = parts[1].parse()?;
let seconds: f64 = parts[2].parse()?;
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
} else {
// Fallback to direct parsing
time_str.parse()
}
}
/// Parse duration string (HH:MM:SS.mmm) to seconds
fn parse_duration_to_seconds(duration: &str) -> f64 {
parse_time_to_seconds(duration).unwrap_or(0.0)
}
fn ffmpeg(
aaxc_file_path: PathBuf,
audible_key: String,
@@ -637,6 +1101,9 @@ fn ffmpeg(
duration: String,
file_name: String,
codec: &str,
verbose: bool,
machine_readable: bool,
threads: &str,
) -> Result<Child> {
let mut cmd = Command::new("ffmpeg")
.args([
@@ -648,6 +1115,8 @@ fn ffmpeg(
aaxc_file_path
.to_str()
.context("Failed to convert input file path to string.")?,
"-threads",
threads,
"-progress",
"/dev/stdout",
"-y",
@@ -667,25 +1136,113 @@ fn ffmpeg(
let stdout = cmd.stdout.as_mut().context("Failed to capture ffmpeg stdout.")?;
let stdout_reader = std::io::BufReader::new(stdout);
// Progress bar setup
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner} [{elapsed_precise}] {msg}")
.unwrap()
);
pb.enable_steady_tick(std::time::Duration::from_millis(100));
if machine_readable {
// Machine-readable mode: output JSON progress events
let mut progress = ConversionProgress::new(parse_duration_to_seconds(&duration));
for line in stdout_reader.lines() {
let l = line.context("Failed to read line from ffmpeg output.")?;
if l.contains("time=") {
pb.set_message(format!("Progress: {} / {}", l, duration));
for line in stdout_reader.lines() {
let l = line.context("Failed to read line from ffmpeg output.")?;
parse_ffmpeg_progress_line(&l, &mut progress);
let event = ProgressEvent::ChapterProgress {
chapter_number: 1,
total_chapters: 1,
chapter_title: "Single File".to_string(),
progress_percentage: progress.percentage(),
current_time: progress.current_time,
total_duration: progress.total_duration,
speed: progress.speed,
bitrate: progress.bitrate,
file_size: progress.size,
fps: progress.fps,
eta_seconds: progress.eta().map(|eta| eta.as_secs() as f64),
};
println!("{}", event.to_json());
}
if l.contains("speed=") {
pb.set_message(format!("{} | {}", pb.message(), l));
} else {
// Enhanced progress bar setup
let pb = ProgressBar::new(100);
pb.set_style(
ProgressStyle::default_bar()
.template("{bar:40.cyan/blue} {percent:>3}% [{elapsed_precise}] {msg}")
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "),
);
pb.set_message("Starting conversion...");
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let mut progress = ConversionProgress::new(parse_duration_to_seconds(&duration));
for line in stdout_reader.lines() {
let l = line.context("Failed to read line from ffmpeg output.")?;
parse_ffmpeg_progress_line(&l, &mut progress);
// Update progress bar
let percentage = progress.percentage() as u64;
pb.set_position(percentage);
let eta_str = progress.eta()
.map(|eta| format!("ETA: {}", ProgressManager::format_duration(eta)))
.unwrap_or_else(|| "ETA: --:--:--".to_string());
let speed_str = if progress.speed > 0.0 {
format!("Speed: {:.1}x", progress.speed)
} else {
"Speed: --".to_string()
};
let bitrate_str = if progress.bitrate > 0.0 {
format!("Bitrate: {:.0} kbps", progress.bitrate / 1000.0)
} else {
"Bitrate: --".to_string()
};
let size_str = if progress.size > 0 {
format!("Size: {}", ConversionProgress::format_size(progress.size))
} else {
"Size: --".to_string()
};
let fps_str = if verbose && progress.fps > 0.0 {
format!("FPS: {:.1}", progress.fps)
} else {
String::new()
};
let time_str = if verbose {
format!("Time: {}/{}",
ConversionProgress::format_time(progress.current_time),
ConversionProgress::format_time(progress.total_duration))
} else {
String::new()
};
let mut message_parts = vec![eta_str, speed_str, bitrate_str, size_str];
if verbose {
if !fps_str.is_empty() {
message_parts.push(fps_str);
}
if !time_str.is_empty() {
message_parts.push(time_str);
}
}
pb.set_message(message_parts.join(" | "));
// Log detailed progress in verbose mode
if verbose {
info!("Progress: {:.1}% | Time: {}/{} | Speed: {:.1}x | Bitrate: {:.0} kbps | Size: {}",
progress.percentage(),
ConversionProgress::format_time(progress.current_time),
ConversionProgress::format_time(progress.total_duration),
progress.speed,
progress.bitrate / 1000.0,
ConversionProgress::format_size(progress.size)
);
}
}
pb.finish_with_message("Conversion complete");
}
pb.finish_with_message("Conversion complete");
}
info!("ffmpeg process finished");
Ok(cmd)