feat: improve logging
This commit is contained in:
56
README.md
56
README.md
@@ -11,16 +11,16 @@ Converts AAC 5.1/7.1 audio tracks to E-AC-3 while preserving all other streams (
|
||||
|
||||
```bash
|
||||
# Preview conversions (recommended first step)
|
||||
python aac2eac.py /path/to/media --dry-run
|
||||
python aac2eac3.py /path/to/media --dry-run
|
||||
|
||||
# Convert to new files (original.eac.mkv)
|
||||
python aac2eac.py /path/to/media
|
||||
python aac2eac3.py /path/to/media
|
||||
|
||||
# Overwrite original files (creates temporary backups)
|
||||
python aac2eac.py /path/to/media --overwrite
|
||||
python aac2eac3.py /path/to/media --overwrite
|
||||
|
||||
# Custom FFmpeg path
|
||||
python aac2eac.py /path/to/media --ffmpeg /usr/local/bin/ffmpeg
|
||||
# Custom FFmpeg path (e.g., Jellyfin's FFmpeg)
|
||||
python aac2eac3.py /path/to/media --ffmpeg /usr/lib/jellyfin-ffmpeg/ffmpeg
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -32,10 +32,46 @@ python aac2eac.py /path/to/media --ffmpeg /usr/local/bin/ffmpeg
|
||||
|
||||
## What it does
|
||||
|
||||
- Scans directory for .mkv and .mp4 files
|
||||
- Scans directory for .mkv and .mp4 files recursively
|
||||
- Identifies AAC audio tracks with 6 channels (5.1) or 8 channels (7.1)
|
||||
- Converts those tracks to E-AC-3:
|
||||
- 5.1 → 640 kbps
|
||||
- 7.1 → 1024 kbps
|
||||
- Converts those tracks to E-AC-3 @ 640 kbps
|
||||
- Note: 7.1 AAC is downmixed to 5.1 E-AC-3 (FFmpeg limitation)
|
||||
- Copies all other streams unchanged (video, subtitles, other audio)
|
||||
- Preserves all metadata
|
||||
- Preserves all metadata (language tags, track titles, etc.)
|
||||
|
||||
## Features
|
||||
|
||||
- **Detailed progress tracking** - Shows file-by-file progress with conversion times
|
||||
- **Smart skipping** - Automatically skips files without AAC 5.1/7.1 tracks
|
||||
- **Comprehensive summary** - Displays statistics at the end (converted, skipped, failed, time)
|
||||
- **Color-coded output** - Easy-to-read terminal output with visual indicators
|
||||
- **Safe operation** - Creates temporary backups when using `--overwrite`
|
||||
|
||||
## Example output
|
||||
|
||||
```
|
||||
════════════════════════════════════════════════════════════════════
|
||||
AAC to E-AC-3 Converter
|
||||
════════════════════════════════════════════════════════════════════
|
||||
|
||||
Scanning: /media/movies
|
||||
Found 5 media files
|
||||
|
||||
▶ [1/5] movie.mkv
|
||||
Track 1: AAC 6ch (eng) → E-AC-3 @ 640k
|
||||
Converting → movie.eac.mkv
|
||||
✓ Completed in 45s (4.2 GB)
|
||||
|
||||
▶ [2/5] another_movie.mkv
|
||||
No AAC 5.1/7.1 tracks found - skipping
|
||||
|
||||
════════════════════════════════════════════════════════════════════
|
||||
Conversion Summary
|
||||
════════════════════════════════════════════════════════════════════
|
||||
Total files scanned: 5
|
||||
Successfully converted: 3
|
||||
Skipped (no AAC): 2
|
||||
|
||||
Total time: 3m 25s
|
||||
Average per file: 1m 8s
|
||||
```
|
||||
201
aac2eac.py
201
aac2eac.py
@@ -4,6 +4,7 @@ import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
EXTENSIONS = {".mkv", ".mp4"}
|
||||
@@ -25,12 +26,106 @@ args = parser.parse_args()
|
||||
# ----------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)-7s | %(message)s",
|
||||
format="%(message)s",
|
||||
datefmt="%H:%M:%S"
|
||||
)
|
||||
log = logging.getLogger("aac2eac3")
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Colors & Formatting
|
||||
# ----------------------------
|
||||
class Colors:
|
||||
HEADER = '\033[95m'
|
||||
BLUE = '\033[94m'
|
||||
CYAN = '\033[96m'
|
||||
GREEN = '\033[92m'
|
||||
YELLOW = '\033[93m'
|
||||
RED = '\033[91m'
|
||||
BOLD = '\033[1m'
|
||||
DIM = '\033[2m'
|
||||
RESET = '\033[0m'
|
||||
|
||||
|
||||
def header(text):
|
||||
log.info(f"\n{Colors.BOLD}{Colors.CYAN}{'═' * 70}{Colors.RESET}")
|
||||
log.info(f"{Colors.BOLD}{Colors.CYAN} {text}{Colors.RESET}")
|
||||
log.info(f"{Colors.BOLD}{Colors.CYAN}{'═' * 70}{Colors.RESET}\n")
|
||||
|
||||
|
||||
def section(text):
|
||||
log.info(f"\n{Colors.BOLD}{Colors.BLUE}▶ {text}{Colors.RESET}")
|
||||
|
||||
|
||||
def success(text):
|
||||
log.info(f"{Colors.GREEN}✓ {text}{Colors.RESET}")
|
||||
|
||||
|
||||
def info(text, indent=0):
|
||||
prefix = " " * indent
|
||||
log.info(f"{prefix}{Colors.DIM}{text}{Colors.RESET}")
|
||||
|
||||
|
||||
def warning(text):
|
||||
log.info(f"{Colors.YELLOW}⚠ {text}{Colors.RESET}")
|
||||
|
||||
|
||||
def error(text):
|
||||
log.info(f"{Colors.RED}✗ {text}{Colors.RESET}")
|
||||
|
||||
|
||||
def format_size(bytes):
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if bytes < 1024.0:
|
||||
return f"{bytes:.1f} {unit}"
|
||||
bytes /= 1024.0
|
||||
return f"{bytes:.1f} TB"
|
||||
|
||||
|
||||
def format_time(seconds):
|
||||
if seconds < 60:
|
||||
return f"{seconds:.0f}s"
|
||||
elif seconds < 3600:
|
||||
return f"{seconds // 60:.0f}m {seconds % 60:.0f}s"
|
||||
else:
|
||||
return f"{seconds // 3600:.0f}h {(seconds % 3600) // 60:.0f}m"
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Stats Tracking
|
||||
# ----------------------------
|
||||
class Stats:
|
||||
def __init__(self):
|
||||
self.total_files = 0
|
||||
self.processed = 0
|
||||
self.skipped = 0
|
||||
self.converted = 0
|
||||
self.failed = 0
|
||||
self.start_time = time.time()
|
||||
self.conversion_times = []
|
||||
|
||||
def print_summary(self):
|
||||
elapsed = time.time() - self.start_time
|
||||
header("Conversion Summary")
|
||||
|
||||
log.info(f" {Colors.BOLD}Total files scanned:{Colors.RESET} {self.total_files}")
|
||||
log.info(f" {Colors.GREEN}{Colors.BOLD}Successfully converted:{Colors.RESET} {self.converted}")
|
||||
log.info(f" {Colors.YELLOW}{Colors.BOLD}Skipped (no AAC):{Colors.RESET} {self.skipped}")
|
||||
if self.failed > 0:
|
||||
log.info(f" {Colors.RED}{Colors.BOLD}Failed:{Colors.RESET} {self.failed}")
|
||||
|
||||
log.info(f"\n {Colors.BOLD}Total time:{Colors.RESET} {format_time(elapsed)}")
|
||||
|
||||
if self.conversion_times:
|
||||
avg_time = sum(self.conversion_times) / len(self.conversion_times)
|
||||
log.info(f" {Colors.BOLD}Average per file:{Colors.RESET} {format_time(avg_time)}")
|
||||
|
||||
log.info("")
|
||||
|
||||
|
||||
stats = Stats()
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Helpers
|
||||
# ----------------------------
|
||||
@@ -65,15 +160,20 @@ def build_output_name(file: Path):
|
||||
# ----------------------------
|
||||
|
||||
def convert(file: Path):
|
||||
stats.processed += 1
|
||||
|
||||
section(f"[{stats.processed}/{stats.total_files}] {file.name}")
|
||||
|
||||
streams = probe_streams(file)
|
||||
if not streams:
|
||||
log.warning("No streams found: %s", file.name)
|
||||
warning("No streams found")
|
||||
stats.skipped += 1
|
||||
return
|
||||
|
||||
# Build FFmpeg codec maps
|
||||
codec_map = {}
|
||||
target_bitrates = {}
|
||||
has_aac_multichannel = False
|
||||
aac_tracks = []
|
||||
|
||||
for s in streams:
|
||||
if s["codec_type"] == "audio":
|
||||
@@ -81,7 +181,7 @@ def convert(file: Path):
|
||||
if s["codec_name"] == "aac" and s.get("channels") in (6, 8):
|
||||
codec_map[s["index"]] = "eac3"
|
||||
target_bitrates[s["index"]] = "640k"
|
||||
has_aac_multichannel = True
|
||||
aac_tracks.append(s)
|
||||
else:
|
||||
codec_map[s["index"]] = "copy"
|
||||
elif s["codec_type"] == "video":
|
||||
@@ -89,59 +189,79 @@ def convert(file: Path):
|
||||
elif s["codec_type"] == "subtitle":
|
||||
codec_map[s["index"]] = "copy"
|
||||
|
||||
if not has_aac_multichannel:
|
||||
log.info("Skipping %s (no AAC 5.1/7.1 tracks)", file.name)
|
||||
if not aac_tracks:
|
||||
info("No AAC 5.1/7.1 tracks found - skipping", 1)
|
||||
stats.skipped += 1
|
||||
return
|
||||
|
||||
# Show what will be converted
|
||||
for s in aac_tracks:
|
||||
channels = s.get("channels", 0)
|
||||
lang = s.get("tags", {}).get("language", "und")
|
||||
title = s.get("tags", {}).get("title", "")
|
||||
track_info = f"Track {s['index']}: AAC {channels}ch"
|
||||
if lang != "und":
|
||||
track_info += f" ({lang})"
|
||||
if title:
|
||||
track_info += f" - {title}"
|
||||
info(f"{track_info} → E-AC-3 @ 640k", 1)
|
||||
|
||||
output = build_output_name(file)
|
||||
|
||||
if args.dry_run:
|
||||
log.info("[DRY RUN] Would convert %s → %s", file.name, output.name)
|
||||
for s in streams:
|
||||
if s["codec_type"] == "audio" and s["index"] in codec_map:
|
||||
action = codec_map[s["index"]]
|
||||
if action == "eac3":
|
||||
log.info(" [DRY RUN] Stream %d: %s %dch → E-AC-3 @ %s",
|
||||
s["index"], s["codec_name"], s.get("channels", 0),
|
||||
target_bitrates[s["index"]])
|
||||
if args.overwrite:
|
||||
info(f"Would replace: {Colors.CYAN}{file.name}{Colors.RESET}", 1)
|
||||
else:
|
||||
log.info(" [DRY RUN] Stream %d: %s (copy)",
|
||||
s["index"], s["codec_name"])
|
||||
info(f"Would create: {Colors.CYAN}{output.name}{Colors.RESET}", 1)
|
||||
stats.converted += 1
|
||||
return
|
||||
|
||||
cmd = [args.ffmpeg, "-y", "-hide_banner", "-loglevel", "info", "-stats", "-i", str(file)]
|
||||
|
||||
# Map all streams
|
||||
# Build FFmpeg command
|
||||
cmd = [args.ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-stats", "-i", str(file)]
|
||||
cmd += ["-map", "0"]
|
||||
|
||||
# Set codecs
|
||||
for idx, c in codec_map.items():
|
||||
cmd += [f"-c:{idx}", c]
|
||||
if c == "eac3":
|
||||
cmd += [f"-b:a:{idx}", target_bitrates[idx]]
|
||||
|
||||
# Preserve metadata
|
||||
cmd += ["-map_metadata", "0", str(output)]
|
||||
|
||||
log.info("Converting %s → %s", file.name, output.name)
|
||||
info(f"Converting → {Colors.CYAN}{output.name}{Colors.RESET}", 1)
|
||||
|
||||
start_time = time.time()
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
# Show FFmpeg progress
|
||||
for line in process.stdout:
|
||||
if line.strip():
|
||||
print(f" {Colors.DIM}{line.rstrip()}{Colors.RESET}")
|
||||
|
||||
process = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr)
|
||||
process.wait()
|
||||
elapsed = time.time() - start_time
|
||||
stats.conversion_times.append(elapsed)
|
||||
|
||||
if process.returncode != 0:
|
||||
log.error("FFmpeg failed: %s", file.name)
|
||||
error(f"FFmpeg failed (exit code {process.returncode})")
|
||||
stats.failed += 1
|
||||
if output.exists():
|
||||
output.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
if args.overwrite:
|
||||
backup = file.with_suffix(file.suffix + ".bak")
|
||||
log.info("Replacing original file")
|
||||
shutil.move(file, backup)
|
||||
shutil.move(output, file)
|
||||
backup.unlink(missing_ok=True)
|
||||
info(f"Original file replaced", 1)
|
||||
|
||||
log.info("Completed: %s", file.name)
|
||||
# Show file sizes
|
||||
if output.exists() or (args.overwrite and file.exists()):
|
||||
final_file = file if args.overwrite else output
|
||||
size = format_size(final_file.stat().st_size)
|
||||
success(f"Completed in {format_time(elapsed)} ({size})")
|
||||
|
||||
stats.converted += 1
|
||||
|
||||
|
||||
# ----------------------------
|
||||
@@ -149,27 +269,42 @@ def convert(file: Path):
|
||||
# ----------------------------
|
||||
|
||||
def main():
|
||||
header("AAC to E-AC-3 Converter")
|
||||
|
||||
root = Path(args.path).resolve()
|
||||
if not root.is_dir():
|
||||
raise SystemExit(f"Invalid directory: {root}")
|
||||
error(f"Invalid directory: {root}")
|
||||
raise SystemExit(1)
|
||||
|
||||
info(f"Scanning: {Colors.CYAN}{root}{Colors.RESET}")
|
||||
files = sorted(f for f in root.rglob("*") if f.suffix.lower() in EXTENSIONS)
|
||||
|
||||
if not files:
|
||||
log.warning("No media files found")
|
||||
warning("No media files found")
|
||||
return
|
||||
|
||||
log.info("Found %d files", len(files))
|
||||
stats.total_files = len(files)
|
||||
|
||||
log.info(f"Found {Colors.BOLD}{len(files)}{Colors.RESET} media files")
|
||||
|
||||
if args.dry_run:
|
||||
log.info("=== DRY RUN MODE - No files will be modified ===")
|
||||
log.info(f"\n{Colors.YELLOW}{Colors.BOLD}DRY RUN MODE{Colors.RESET} - No files will be modified\n")
|
||||
|
||||
if args.overwrite:
|
||||
log.info(f"{Colors.YELLOW}Overwrite mode enabled{Colors.RESET} - Original files will be replaced\n")
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
convert(file)
|
||||
except KeyboardInterrupt:
|
||||
log.warning("Interrupted by user")
|
||||
log.info(f"\n\n{Colors.YELLOW}Interrupted by user{Colors.RESET}")
|
||||
stats.print_summary()
|
||||
sys.exit(130)
|
||||
except Exception:
|
||||
log.exception("Unexpected error while processing %s", file.name)
|
||||
except Exception as e:
|
||||
error(f"Unexpected error: {e}")
|
||||
stats.failed += 1
|
||||
|
||||
stats.print_summary()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user