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
|
```bash
|
||||||
# Preview conversions (recommended first step)
|
# 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)
|
# 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)
|
# Overwrite original files (creates temporary backups)
|
||||||
python aac2eac.py /path/to/media --overwrite
|
python aac2eac3.py /path/to/media --overwrite
|
||||||
|
|
||||||
# Custom FFmpeg path
|
# Custom FFmpeg path (e.g., Jellyfin's FFmpeg)
|
||||||
python aac2eac.py /path/to/media --ffmpeg /usr/local/bin/ffmpeg
|
python aac2eac3.py /path/to/media --ffmpeg /usr/lib/jellyfin-ffmpeg/ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
@@ -32,10 +32,46 @@ python aac2eac.py /path/to/media --ffmpeg /usr/local/bin/ffmpeg
|
|||||||
|
|
||||||
## What it does
|
## 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)
|
- Identifies AAC audio tracks with 6 channels (5.1) or 8 channels (7.1)
|
||||||
- Converts those tracks to E-AC-3:
|
- Converts those tracks to E-AC-3 @ 640 kbps
|
||||||
- 5.1 → 640 kbps
|
- Note: 7.1 AAC is downmixed to 5.1 E-AC-3 (FFmpeg limitation)
|
||||||
- 7.1 → 1024 kbps
|
|
||||||
- Copies all other streams unchanged (video, subtitles, other audio)
|
- 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
|
||||||
|
```
|
||||||
203
aac2eac.py
203
aac2eac.py
@@ -4,6 +4,7 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
EXTENSIONS = {".mkv", ".mp4"}
|
EXTENSIONS = {".mkv", ".mp4"}
|
||||||
@@ -25,12 +26,106 @@ args = parser.parse_args()
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s | %(levelname)-7s | %(message)s",
|
format="%(message)s",
|
||||||
datefmt="%H:%M:%S"
|
datefmt="%H:%M:%S"
|
||||||
)
|
)
|
||||||
log = logging.getLogger("aac2eac3")
|
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
|
# Helpers
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -65,15 +160,20 @@ def build_output_name(file: Path):
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|
||||||
def convert(file: Path):
|
def convert(file: Path):
|
||||||
|
stats.processed += 1
|
||||||
|
|
||||||
|
section(f"[{stats.processed}/{stats.total_files}] {file.name}")
|
||||||
|
|
||||||
streams = probe_streams(file)
|
streams = probe_streams(file)
|
||||||
if not streams:
|
if not streams:
|
||||||
log.warning("No streams found: %s", file.name)
|
warning("No streams found")
|
||||||
|
stats.skipped += 1
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build FFmpeg codec maps
|
# Build FFmpeg codec maps
|
||||||
codec_map = {}
|
codec_map = {}
|
||||||
target_bitrates = {}
|
target_bitrates = {}
|
||||||
has_aac_multichannel = False
|
aac_tracks = []
|
||||||
|
|
||||||
for s in streams:
|
for s in streams:
|
||||||
if s["codec_type"] == "audio":
|
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):
|
if s["codec_name"] == "aac" and s.get("channels") in (6, 8):
|
||||||
codec_map[s["index"]] = "eac3"
|
codec_map[s["index"]] = "eac3"
|
||||||
target_bitrates[s["index"]] = "640k"
|
target_bitrates[s["index"]] = "640k"
|
||||||
has_aac_multichannel = True
|
aac_tracks.append(s)
|
||||||
else:
|
else:
|
||||||
codec_map[s["index"]] = "copy"
|
codec_map[s["index"]] = "copy"
|
||||||
elif s["codec_type"] == "video":
|
elif s["codec_type"] == "video":
|
||||||
@@ -89,59 +189,79 @@ def convert(file: Path):
|
|||||||
elif s["codec_type"] == "subtitle":
|
elif s["codec_type"] == "subtitle":
|
||||||
codec_map[s["index"]] = "copy"
|
codec_map[s["index"]] = "copy"
|
||||||
|
|
||||||
if not has_aac_multichannel:
|
if not aac_tracks:
|
||||||
log.info("Skipping %s (no AAC 5.1/7.1 tracks)", file.name)
|
info("No AAC 5.1/7.1 tracks found - skipping", 1)
|
||||||
|
stats.skipped += 1
|
||||||
return
|
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)
|
output = build_output_name(file)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
log.info("[DRY RUN] Would convert %s → %s", file.name, output.name)
|
if args.overwrite:
|
||||||
for s in streams:
|
info(f"Would replace: {Colors.CYAN}{file.name}{Colors.RESET}", 1)
|
||||||
if s["codec_type"] == "audio" and s["index"] in codec_map:
|
else:
|
||||||
action = codec_map[s["index"]]
|
info(f"Would create: {Colors.CYAN}{output.name}{Colors.RESET}", 1)
|
||||||
if action == "eac3":
|
stats.converted += 1
|
||||||
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"])
|
|
||||||
return
|
return
|
||||||
|
|
||||||
cmd = [args.ffmpeg, "-y", "-hide_banner", "-loglevel", "info", "-stats", "-i", str(file)]
|
# Build FFmpeg command
|
||||||
|
cmd = [args.ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-stats", "-i", str(file)]
|
||||||
# Map all streams
|
|
||||||
cmd += ["-map", "0"]
|
cmd += ["-map", "0"]
|
||||||
|
|
||||||
# Set codecs
|
|
||||||
for idx, c in codec_map.items():
|
for idx, c in codec_map.items():
|
||||||
cmd += [f"-c:{idx}", c]
|
cmd += [f"-c:{idx}", c]
|
||||||
if c == "eac3":
|
if c == "eac3":
|
||||||
cmd += [f"-b:a:{idx}", target_bitrates[idx]]
|
cmd += [f"-b:a:{idx}", target_bitrates[idx]]
|
||||||
|
|
||||||
# Preserve metadata
|
|
||||||
cmd += ["-map_metadata", "0", str(output)]
|
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()
|
process.wait()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
stats.conversion_times.append(elapsed)
|
||||||
|
|
||||||
if process.returncode != 0:
|
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():
|
if output.exists():
|
||||||
output.unlink(missing_ok=True)
|
output.unlink(missing_ok=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.overwrite:
|
if args.overwrite:
|
||||||
backup = file.with_suffix(file.suffix + ".bak")
|
backup = file.with_suffix(file.suffix + ".bak")
|
||||||
log.info("Replacing original file")
|
|
||||||
shutil.move(file, backup)
|
shutil.move(file, backup)
|
||||||
shutil.move(output, file)
|
shutil.move(output, file)
|
||||||
backup.unlink(missing_ok=True)
|
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():
|
def main():
|
||||||
|
header("AAC to E-AC-3 Converter")
|
||||||
|
|
||||||
root = Path(args.path).resolve()
|
root = Path(args.path).resolve()
|
||||||
if not root.is_dir():
|
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)
|
files = sorted(f for f in root.rglob("*") if f.suffix.lower() in EXTENSIONS)
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
log.warning("No media files found")
|
warning("No media files found")
|
||||||
return
|
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:
|
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:
|
for file in files:
|
||||||
try:
|
try:
|
||||||
convert(file)
|
convert(file)
|
||||||
except KeyboardInterrupt:
|
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)
|
sys.exit(130)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
log.exception("Unexpected error while processing %s", file.name)
|
error(f"Unexpected error: {e}")
|
||||||
|
stats.failed += 1
|
||||||
|
|
||||||
|
stats.print_summary()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user