From 9fd6f281ef14d2ca7ee1d28830dc99eb064d2837 Mon Sep 17 00:00:00 2001 From: Mathieu Broillet Date: Fri, 23 Jan 2026 12:27:01 +0100 Subject: [PATCH] feat: improve logging --- README.md | 56 ++++++++++++--- aac2eac.py | 203 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 215 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ac180b8..ce18ffa 100644 --- a/README.md +++ b/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 \ No newline at end of file +- 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 +``` \ No newline at end of file diff --git a/aac2eac.py b/aac2eac.py index fcf50dd..8027fad 100644 --- a/aac2eac.py +++ b/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"]]) - else: - log.info(" [DRY RUN] Stream %d: %s (copy)", - s["index"], s["codec_name"]) + if args.overwrite: + info(f"Would replace: {Colors.CYAN}{file.name}{Colors.RESET}", 1) + else: + 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__":