diff --git a/setup.cfg b/setup.cfg index d097611..44a60ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = surround-to-eac3 -version = 0.3.1 +version = 0.3.2 author = Jonathan Rampersad author_email = jonathan@jono-rams.work description = A CLI tool to transcode 5.1 audio in video files to E-AC3. diff --git a/src/surround_to_eac3/main.py b/src/surround_to_eac3/main.py index 598c086..da6e6aa 100644 --- a/src/surround_to_eac3/main.py +++ b/src/surround_to_eac3/main.py @@ -5,6 +5,7 @@ import shutil import argparse import json import threading +import queue from functools import partial from tqdm import tqdm @@ -13,6 +14,25 @@ tqdm_lock = threading.Lock() SUPPORTED_EXTENSIONS = (".mkv", ".mp4") +def get_video_duration(filepath: str) -> float: + """Gets the duration of a video file in seconds.""" + if not shutil.which("ffprobe"): + return 0.0 + + command = [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + filepath + ] + try: + process = subprocess.run(command, capture_output=True, text=True, check=True, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + return float(process.stdout.strip()) + except (subprocess.CalledProcessError, ValueError): + return 0.0 + + def get_stream_info(filepath: str, stream_type: str = "audio") -> tuple[list[dict], list[str]]: """ Retrieves details for specified stream types (audio, video, subtitle) in a file. @@ -57,19 +77,33 @@ def get_stream_info(filepath: str, stream_type: str = "audio") -> tuple[list[dic detail["channels"] = stream.get("channels") detail["language"] = stream.get("tags", {}).get("language", "und").lower() streams_details.append(detail) - return streams_details + return streams_details, logs except json.JSONDecodeError: logs.append(f" âš ī¸ Warning: Failed to decode ffprobe JSON for {stream_type} streams in '{os.path.basename(filepath)}'.") return [], logs except Exception as e: logs.append(f" âš ī¸ Error getting {stream_type} stream info for '{os.path.basename(filepath)}': {e}") return [], logs + + +def time_str_to_seconds(time_str: str) -> float: + """Converts HH:MM:SS.ms time string to seconds.""" + parts = time_str.split(':') + seconds = float(parts[-1]) + if len(parts) > 1: + seconds += int(parts[-2]) * 60 + if len(parts) > 2: + seconds += int(parts[-3]) * 3600 + return seconds + def process_file_with_ffmpeg( input_filepath: str, final_output_filepath: str | None, audio_bitrate: str, - audio_processing_ops: list[dict] # [{'index':X, 'op':'transcode'/'copy', 'lang':'eng'}] + audio_processing_ops: list[dict], # [{'index':X, 'op':'transcode'/'copy', 'lang':'eng'}] + duration: float, + pbar_position: int ) -> tuple[bool, list[str]]: """ Processes a single video file using ffmpeg, writing to a temporary file first. @@ -79,22 +113,18 @@ def process_file_with_ffmpeg( logs.append(" 🚨 Error: ffmpeg is not installed or not found.") return False, logs - base_filename = os.path.basename(input_filepath) - name, ext = os.path.splitext(base_filename) - output_filename = f"{name}_eac3{ext}" - # FFMpeg will write to a temporary file, which we will rename upon success temp_output_filepath = final_output_filepath + ".tmp" base_filename = os.path.basename(input_filepath) output_filename = os.path.basename(final_output_filepath) - ffmpeg_cmd = ["ffmpeg", "-i", input_filepath] + ffmpeg_cmd = ["ffmpeg", "-nostdin", "-i", input_filepath] map_operations = [] output_audio_stream_ffmpeg_idx = 0 # For -c:a:0, -c:a:1 etc. - # Map Video Streams (optional mapping) + # Map Video Streams map_operations.extend(["-map", "0:v?", "-c:v", "copy"]) - # Map Subtitle Streams (optional mapping) + # Map Subtitle Streams map_operations.extend(["-map", "0:s?", "-c:s", "copy"]) # Map Audio Streams based on operations @@ -113,50 +143,64 @@ def process_file_with_ffmpeg( elif final_output_filepath.lower().endswith('.mp4'): ffmpeg_cmd.extend(['-f', 'mp4']) - ffmpeg_cmd.extend(["-y", temp_output_filepath]) + ffmpeg_cmd.extend(["-y", "-v", "quiet", "-stats_period", "1", "-progress", "pipe:1", temp_output_filepath]) logs.append(f" âš™ī¸ Processing: '{base_filename}' -> '{output_filename}'") - try: - process = subprocess.run( - ffmpeg_cmd, capture_output=True, text=True, check=False, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 - ) - if process.returncode == 0: - if os.path.exists(temp_output_filepath) and os.path.getsize(temp_output_filepath) > 0: - os.rename(temp_output_filepath, final_output_filepath) # Atomic rename on success - logs.append(f" ✅ Success: '{output_filename}' saved.") - return True, logs - else: # Should not happen if ffmpeg returncode is 0 and no "-f null" output. - if process.stderr: logs.append(f" ffmpeg stderr:\n{process.stderr.strip()}") - return False, logs + process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + + file_pbar = None + if duration > 0: + file_pbar = tqdm(total=int(duration), desc=f"└─'{base_filename[:30]}â€Ļ'", position=pbar_position, unit='s', leave=False, ncols=100) + + for line in process.stdout: + if "out_time_ms" in line: + try: + time_us = int(line.strip().split("=")[1]) + elapsed_seconds = time_us / 1_000_000 + update_amount = max(0, elapsed_seconds - file_pbar.n) + if update_amount > 0: + file_pbar.update(update_amount) + except (ValueError, IndexError): + continue + + process.wait() + file_pbar.close() + + if process.returncode == 0: + if os.path.exists(temp_output_filepath) and os.path.getsize(temp_output_filepath) > 0: + os.rename(temp_output_filepath, final_output_filepath) + logs.append(f" ✅ Success: '{output_filename}' saved.") + return True, logs else: - logs.append(f" 🚨 Error during ffmpeg processing for '{base_filename}'. RC: {process.returncode}") - if process.stderr: logs.append(f" ffmpeg stderr:\n{process.stderr.strip()}") + logs.append(f" âš ī¸ Warning: ffmpeg reported success, but temp file is missing or empty.") return False, logs - except Exception as e: - logs.append(f" 🚨 An unexpected error occurred during transcoding of '{base_filename}': {e}") + else: + logs.append(f" 🚨 Error during ffmpeg processing for '{base_filename}'. RC: {process.returncode}") + stderr_output = process.stderr.read() + if stderr_output: + logs.append(f" ffmpeg stderr:\n{stderr_output.strip()}") return False, logs -def process_single_file(filepath: str, args: argparse.Namespace, input_path_abs: str) -> str: +def process_single_file(filepath: str, pbar_position: int, args: argparse.Namespace, input_path_abs: str) -> str: """ Analyzes and processes a single file, managing temporary files for graceful exit. """ file_specific_logs = [] + final_status = "failed" + # Determine a display name relative to the initial input path for cleaner logs - if os.path.isdir(input_path_abs): - display_name = os.path.relpath(filepath, input_path_abs) - else: - display_name = os.path.basename(filepath) - - file_specific_logs.append(f"â–ļī¸ Checking: '{display_name}'") + display_name = os.path.relpath(filepath, input_path_abs) if os.path.isdir(input_path_abs) else os.path.basename(filepath) + file_specific_logs.append(f"â–ļī¸ Checked: '{display_name}'") target_languages = [lang.strip().lower() for lang in args.languages.split(',') if lang.strip()] - audio_streams_details = get_stream_info(filepath, "audio") - audio_ops_for_ffmpeg = [] + audio_streams_details, get_info_logs = get_stream_info(filepath, "audio") + file_specific_logs.extend(get_info_logs) + + audio_ops_for_ffmpeg = [] if not audio_streams_details: file_specific_logs.append(" â„šī¸ No audio streams found in this file.") else: @@ -189,15 +233,17 @@ def process_single_file(filepath: str, args: argparse.Namespace, input_path_abs: with tqdm_lock: for log_msg in file_specific_logs: tqdm.write(log_msg) - return "skipped_no_ops" + final_status = "skipped_no_ops" + return final_status needs_transcode = any(op['op'] == 'transcode' for op in audio_ops_for_ffmpeg) if not needs_transcode: - file_specific_logs.append(f" â­ī¸ Skipping '{display_name}': All target audio operations are 'copy'; no transcoding required.") + file_specific_logs.append(f" â­ī¸ Skipping '{display_name}': No transcoding required.") with tqdm_lock: for log_msg in file_specific_logs: tqdm.write(log_msg) - return "skipped_no_transcode" + final_status = "skipped_no_transcode" + return final_status # Determine final output path name, ext = os.path.splitext(os.path.basename(filepath)) @@ -214,11 +260,12 @@ def process_single_file(filepath: str, args: argparse.Namespace, input_path_abs: # Check for identical paths before starting if os.path.abspath(filepath) == os.path.abspath(final_output_filepath): - file_specific_logs.append(f" âš ī¸ Warning: Input and output file paths are identical ('{filepath}'). Skipping.") + file_specific_logs.append(f" âš ī¸ Warning: Input and output paths are identical. Skipping.") with tqdm_lock: for log_msg in file_specific_logs: tqdm.write(log_msg) - return "skipped_identical_path" + final_status = "skipped_identical_path" + return final_status if args.dry_run: file_specific_logs.append(f" DRY RUN: Would process '{display_name}'. No changes will be made.") @@ -226,7 +273,8 @@ def process_single_file(filepath: str, args: argparse.Namespace, input_path_abs: for log_msg in file_specific_logs: tqdm.write(log_msg) # We return 'processed' to indicate it *would* have been processed - return "processed" + final_status = "processed" + return final_status # Ensure output directory exists before processing if not os.path.isdir(output_dir_for_this_file): @@ -238,18 +286,16 @@ def process_single_file(filepath: str, args: argparse.Namespace, input_path_abs: for log_msg in file_specific_logs: tqdm.write(log_msg) return "failed" + + duration = get_video_duration(filepath) + if duration == 0: + file_specific_logs.append(f" âš ī¸ Could not determine duration for '{display_name}'. Per-file progress will not be shown.") temp_filepath = final_output_filepath + ".tmp" - final_status = "failed" try: - success, ffmpeg_logs = process_file_with_ffmpeg( - filepath, - final_output_filepath, - args.audio_bitrate, - audio_ops_for_ffmpeg - ) + success, ffmpeg_logs = process_file_with_ffmpeg(filepath, final_output_filepath, args.audio_bitrate, audio_ops_for_ffmpeg, duration, pbar_position) file_specific_logs.extend(ffmpeg_logs) - return "processed" if success else "failed" + final_status = "processed" if success else "failed" finally: # This block will run whether the try block succeeded, failed, or was interrupted. if os.path.exists(temp_filepath): @@ -261,10 +307,14 @@ def process_single_file(filepath: str, args: argparse.Namespace, input_path_abs: with tqdm_lock: # Print all logs for this file at the very end of its processing for log_msg in file_specific_logs: tqdm.write(log_msg) - return final_status +# Worker initializer to assign a unique position to each worker's progress bar +def worker_init(worker_id_queue): + threading.current_thread().worker_id = worker_id_queue.get() + + def main(): # Initial check for ffmpeg and ffprobe if not shutil.which("ffmpeg") or not shutil.which("ffprobe"): @@ -356,21 +406,33 @@ def main(): "failed": 0 } + worker_id_queue = queue.Queue() + for i in range(args.jobs): + worker_id_queue.put(i + 1) + try: - with tqdm(total=len(files_to_process_paths), desc="Overall Progress", unit="file", ncols=100, smoothing=0.1, leave=True) as pbar: - with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as executor: - future_to_path = { - executor.submit(partial(process_single_file, args=args, input_path_abs=input_path_abs), filepath): filepath - for filepath in files_to_process_paths - } + with tqdm(total=len(files_to_process_paths), desc="Overall Progress", unit="file", ncols=100, smoothing=0.1, position=0, leave=True) as pbar: + with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs, initializer=worker_init, initargs=(worker_id_queue,)) as executor: + + def submit_task(filepath): + worker_id = threading.current_thread().worker_id + return process_single_file(filepath, worker_id, args, input_path_abs) + + future_to_path = {executor.submit(submit_task, path): path for path in files_to_process_paths} for future in concurrent.futures.as_completed(future_to_path): path = future_to_path[future] try: - status = future.result() - stats[status] += 1 + status = future.result() + if status in stats: + stats[status] += 1 + else: + stats["failed"] += 1 + with tqdm_lock: + tqdm.write(f"🚨 UNKNOWN STATUS '{status}' for '{os.path.basename(path)}'.") except Exception as exc: - tqdm.write(f"🚨 An unexpected error occurred while processing '{os.path.basename(path)}': {exc}") + with tqdm_lock: + tqdm.write(f"🚨 CRITICAL ERROR during task for '{os.path.basename(path)}': {exc}") stats["failed"] += 1 finally: pbar.update(1) @@ -385,6 +447,7 @@ def main(): summary_title = "--- Dry Run Summary ---" if args.dry_run else "--- Processing Summary ---" processed_label = "Would be processed" if args.dry_run else "Successfully processed" + print() print(f"\n{summary_title}") print(f"Total files checked: {len(files_to_process_paths)}") print(f"✅ {processed_label}: {stats['processed']}")