mirror of
https://github.com/PeacefulBeastGames/audible-util.git
synced 2025-12-13 06:04:32 +00:00
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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -90,7 +90,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "audible-util"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"anyhow",
|
||||
|
||||
@@ -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
194
README.md
@@ -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
177
examples/python_parser.py
Normal 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()
|
||||
61
examples/simple_json_parser.py
Normal file
61
examples/simple_json_parser.py
Normal 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()
|
||||
26
src/cli.rs
26
src/cli.rs
@@ -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 {
|
||||
|
||||
647
src/main.rs
647
src/main.rs
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user