Files
ffmpeg-audio-transcoder/src/surround_to_eac3/gui.py
2025-10-26 14:43:52 -04:00

417 lines
18 KiB
Python

import customtkinter as ctk
import threading
import sys
import os
import queue
import concurrent.futures
import argparse
import shutil
from tkinter import filedialog
from tqdm import tqdm
import json
from platformdirs import user_config_dir
# Import the processing functions from our new module
try:
from . import processing
except ImportError:
# Fallback for running file directly
import processing
# --- Constants ---
APP_NAME = "eac3-transcode"
APP_AUTHOR = "eac3-transcode"
CONFIG_FILENAME = "options.json"
# --- Worker Initializer (needed for GUI thread pool) ---
def worker_init(worker_id_queue):
"""Assigns a unique ID to each worker thread for its progress bar."""
threading.current_thread().worker_id = worker_id_queue.get()
class GuiLogger:
"""A file-like object to redirect stdout/stderr to the GUI text box."""
def __init__(self, app, textbox):
self.app = app
self.textbox = textbox
def write(self, msg):
"""Write message to the textbox, ensuring it's thread-safe."""
def _write_to_box():
"""Internal function to run on the main thread."""
self.textbox.configure(state="normal")
self.textbox.insert("end", str(msg))
self.textbox.see("end") # Auto-scroll
self.textbox.configure(state="disabled")
# Use app.after to schedule the GUI update on the main thread
self.app.after(0, _write_to_box)
def flush(self):
"""Required for file-like object interface."""
pass
class TranscoderApp(ctk.CTk):
"""Main GUI application window."""
def __init__(self):
super().__init__()
self.title("E-AC3 Transcoder")
self.geometry("800x600")
ctk.set_appearance_mode("system")
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=0)
# --- Load Config File ---
default_config = self.load_default_config()
# --- Main Frame ---
self.main_frame = ctk.CTkFrame(self)
self.main_frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
self.main_frame.grid_columnconfigure(0, weight=1)
self.main_frame.grid_rowconfigure(1, weight=1) # Log box row
# --- Options Frame ---
self.options_frame = ctk.CTkFrame(self.main_frame)
self.options_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew")
self.options_frame.grid_columnconfigure(1, weight=1)
# --- Log Frame ---
self.log_frame = ctk.CTkFrame(self.main_frame)
self.log_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew")
self.log_frame.grid_columnconfigure(0, weight=1)
self.log_frame.grid_rowconfigure(0, weight=1)
# --- Button Frame ---
self.button_frame = ctk.CTkFrame(self)
self.button_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="ew")
self.button_frame.grid_columnconfigure(0, weight=1)
# --- Widgets: Options ---
# Input Path
self.input_label = ctk.CTkLabel(self.options_frame, text="Input Path:")
self.input_label.grid(row=0, column=0, padx=10, pady=5, sticky="w")
self.input_entry = ctk.CTkEntry(self.options_frame, placeholder_text="Select a file or folder...")
self.input_entry.grid(row=0, column=1, padx=(0, 5), pady=5, sticky="ew")
self.input_file_button = ctk.CTkButton(self.options_frame, text="File...", width=80, command=self.select_input_file)
self.input_file_button.grid(row=0, column=2, padx=5, pady=5)
self.input_folder_button = ctk.CTkButton(self.options_frame, text="Folder...", width=80, command=self.select_input_folder)
self.input_folder_button.grid(row=0, column=3, padx=(0, 10), pady=5)
# Output Path
self.output_label = ctk.CTkLabel(self.options_frame, text="Output Dir:")
self.output_label.grid(row=1, column=0, padx=10, pady=5, sticky="w")
self.output_entry = ctk.CTkEntry(self.options_frame, placeholder_text="Optional (defaults to same as input)")
self.output_entry.grid(row=1, column=1, padx=(0, 5), pady=5, sticky="ew")
self.output_folder_button = ctk.CTkButton(self.options_frame, text="Select...", width=80, command=self.select_output_folder)
self.output_folder_button.grid(row=1, column=2, columnspan=2, padx=(0, 10), pady=5, sticky="ew")
# Bitrate
self.bitrate_label = ctk.CTkLabel(self.options_frame, text="Bitrate:")
self.bitrate_label.grid(row=2, column=0, padx=10, pady=5, sticky="w")
self.bitrate_entry = ctk.CTkEntry(self.options_frame)
self.bitrate_entry.grid(row=2, column=1, padx=(0, 10), pady=5, sticky="w")
# Languages
self.langs_label = ctk.CTkLabel(self.options_frame, text="Languages:")
self.langs_label.grid(row=3, column=0, padx=10, pady=5, sticky="w")
self.langs_entry = ctk.CTkEntry(self.options_frame)
self.langs_entry.grid(row=3, column=1, padx=(0, 10), pady=5, sticky="w")
# Jobs
self.jobs_label = ctk.CTkLabel(self.options_frame, text=f"Jobs (CPUs: {os.cpu_count()}):")
self.jobs_label.grid(row=4, column=0, padx=10, pady=5, sticky="w")
self.jobs_slider = ctk.CTkSlider(self.options_frame, from_=1, to=os.cpu_count(), number_of_steps=os.cpu_count() - 1, command=lambda v: self.jobs_value_label.configure(text=int(v)))
self.jobs_slider.grid(row=4, column=1, padx=(0, 10), pady=5, sticky="ew")
self.jobs_value_label = ctk.CTkLabel(self.options_frame, text=os.cpu_count(), width=30)
self.jobs_value_label.grid(row=4, column=2, padx=(0, 10), pady=5)
# Checkboxes
self.dry_run_var = ctk.IntVar()
self.dry_run_check = ctk.CTkCheckBox(self.options_frame, text="Dry Run (Analyze only)", variable=self.dry_run_var)
self.dry_run_check.grid(row=5, column=0, padx=10, pady=10, sticky="w")
self.force_reprocess_var = ctk.IntVar()
self.force_reprocess_check = ctk.CTkCheckBox(self.options_frame, text="Force Reprocess (Overwrite existing)", variable=self.force_reprocess_var)
self.force_reprocess_check.grid(row=5, column=1, padx=10, pady=10, sticky="w")
# Load Config Button
self.load_config_button = ctk.CTkButton(self.options_frame, text="Load Config...", width=80, command=self.load_config_from_file)
self.load_config_button.grid(row=5, column=3, padx=(0, 10), pady=10, sticky="e")
# --- Widgets: Log ---
self.log_textbox = ctk.CTkTextbox(self.log_frame, state="disabled", font=("Courier New", 12))
self.log_textbox.grid(row=0, column=0, padx=0, pady=0, sticky="nsew")
# --- Widgets: Buttons ---
self.start_button = ctk.CTkButton(self.button_frame, text="Start Processing", height=40, command=self.start_processing)
self.start_button.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
# --- Member Variables ---
self.processing_thread = None
# --- Apply Initial Config ---
self.apply_config(default_config)
# --- Config Loader ---
def load_default_config(self) -> dict:
"""Loads default config from file, mimicking main.py logic."""
user_config_dir_path = user_config_dir(APP_NAME, APP_AUTHOR)
user_config_file_path = os.path.join(user_config_dir_path, CONFIG_FILENAME)
potential_paths = [os.path.join(os.getcwd(), CONFIG_FILENAME), user_config_file_path]
config = {}
for path in potential_paths:
if os.path.exists(path):
try:
with open(path, 'r') as f:
config = json.load(f)
# We found the config, stop looking
break
except (json.JSONDecodeError, IOError):
# Config is corrupt, just use defaults
break
return config
def load_config_from_file(self):
"""Opens a dialog to load a config .json file and applies it."""
path = filedialog.askopenfilename(
title="Load Config File",
filetypes=[("JSON files", "*.json"), ("All Files", "*.*")]
)
if not path:
return # User cancelled
try:
with open(path, 'r') as f:
config = json.load(f)
self.apply_config(config)
# Log success
self.log_textbox.configure(state="normal")
self.log_textbox.insert("1.0", f"✅ Successfully loaded config from: {os.path.basename(path)}\n\n")
self.log_textbox.configure(state="disabled")
except (json.JSONDecodeError, IOError, Exception) as e:
# Log failure
self.log_textbox.configure(state="normal")
self.log_textbox.insert("1.0", f"🚨 Error loading config: {e}\n\n")
self.log_textbox.configure(state="disabled")
def apply_config(self, config: dict):
"""Applies a config dictionary to all the GUI fields."""
# Bitrate
self.bitrate_entry.delete(0, "end")
self.bitrate_entry.insert(0, config.get("audio_bitrate", "1536k"))
# Languages
self.langs_entry.delete(0, "end")
self.langs_entry.insert(0, config.get("languages", "eng,jpn"))
# Jobs
default_jobs = config.get("jobs", os.cpu_count())
self.jobs_slider.set(default_jobs)
self.jobs_value_label.configure(text=default_jobs)
# Checkboxes
self.dry_run_var.set(config.get("dry_run", 0))
self.force_reprocess_var.set(config.get("force_reprocess", 0))
# --- Button Callbacks ---
def select_input_file(self):
path = filedialog.askopenfilename(filetypes=[("Video Files", "*.mkv *.mp4"), ("All Files", "*.*")])
if path:
self.input_entry.delete(0, "end")
self.input_entry.insert(0, path)
def select_input_folder(self):
path = filedialog.askdirectory()
if path:
self.input_entry.delete(0, "end")
self.input_entry.insert(0, path)
def select_output_folder(self):
path = filedialog.askdirectory()
if path:
self.output_entry.delete(0, "end")
self.output_entry.insert(0, path)
# --- Processing Logic ---
def start_processing(self):
"""Starts the transcoding job in a new thread."""
input_path = self.input_entry.get()
if not input_path:
self.log_textbox.configure(state="normal")
self.log_textbox.delete("1.0", "end")
self.log_textbox.insert("end", "🚨 Error: Please select an input file or folder first.")
self.log_textbox.configure(state="disabled")
return
# Disable button, clear log
self.start_button.configure(state="disabled", text="Processing...")
self.log_textbox.configure(state="normal")
self.log_textbox.delete("1.0", "end")
self.log_textbox.configure(state="disabled")
# Start the job in a separate thread to keep the GUI responsive
self.processing_thread = threading.Thread(target=self.run_processing_job, daemon=True)
self.processing_thread.start()
def run_processing_job(self):
"""
THE CORE PROCESSING LOOP - This runs on a worker thread.
It mimics the logic from `main.py` but uses the GUI logger.
"""
# 1. Create a logger that writes to our GUI
gui_logger = GuiLogger(self, self.log_textbox)
# 2. Gather settings from GUI into a mock 'args' object
mock_args = argparse.Namespace(
input_path=self.input_entry.get(),
output_directory_base=self.output_entry.get() or None,
audio_bitrate=self.bitrate_entry.get(),
languages=self.langs_entry.get(),
jobs=int(self.jobs_slider.get()),
dry_run=bool(self.dry_run_var.get()),
force_reprocess=bool(self.force_reprocess_var.get())
)
# 3. Setup locks and queues for this job
tqdm_lock = threading.Lock()
worker_id_queue = queue.Queue()
# 4. File Discovery (mirrors main.py)
try:
input_path_abs = os.path.abspath(mock_args.input_path)
files_to_process_paths = []
if os.path.isdir(input_path_abs):
gui_logger.write(f"📁 Scanning folder: {input_path_abs}\n")
for root, _, filenames in os.walk(input_path_abs):
for filename in filenames:
if filename.lower().endswith(processing.SUPPORTED_EXTENSIONS):
files_to_process_paths.append(os.path.join(root, filename))
if not files_to_process_paths:
gui_logger.write(" No .mkv or .mp4 files found.\n")
elif os.path.isfile(input_path_abs):
if input_path_abs.lower().endswith(processing.SUPPORTED_EXTENSIONS):
files_to_process_paths.append(input_path_abs)
else:
gui_logger.write(f"⚠️ Provided file is not an .mkv or .mp4.\n")
else:
gui_logger.write(f"🚨 Error: Input path is not a valid file or directory.\n")
self.processing_finished()
return
if not files_to_process_paths:
gui_logger.write("No files to process.\n")
self.processing_finished()
return
gui_logger.write(f"\nFound {len(files_to_process_paths)} file(s) to potentially process...\n")
stats = {
"processed": 0, "skipped_no_ops": 0, "skipped_no_transcode": 0,
"skipped_identical_path": 0, "skipped_existing": 0, "failed": 0
}
num_jobs = min(mock_args.jobs, len(files_to_process_paths))
for i in range(num_jobs):
worker_id_queue.put(i + 1) # TQDM positions 1, 2, 3...
# 5. Run ThreadPoolExecutor (mirrors main.py)
# The 'file=gui_logger' is the magic that redirects all tqdm output
with tqdm(total=len(files_to_process_paths), desc="Overall Progress", unit="file", ncols=100, smoothing=0.1, position=0, leave=True, file=gui_logger) as pbar:
with concurrent.futures.ThreadPoolExecutor(max_workers=num_jobs, initializer=worker_init, initargs=(worker_id_queue,)) as executor:
def submit_task(filepath):
"""Wrapper to pass correct params to the processing function."""
worker_id = threading.current_thread().worker_id
return processing.process_single_file(
filepath, worker_id, mock_args, input_path_abs,
tqdm_lock, gui_logger # Pass the lock and GUI logger
)
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()
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)}'.\n", file=gui_logger)
except Exception as exc:
with tqdm_lock:
tqdm.write(f"🚨 CRITICAL ERROR during task for '{os.path.basename(path)}': {exc}\n", file=gui_logger)
stats["failed"] += 1
finally:
pbar.update(1)
# 6. Print Summary (mirrors main.py)
summary_title = "--- Dry Run Summary ---" if mock_args.dry_run else "--- Processing Summary ---"
processed_label = "Would be processed" if mock_args.dry_run else "Successfully processed"
summary = [
f"\n\n{summary_title}\n",
f"Total files checked: {len(files_to_process_paths)}\n",
f"{processed_label}: {stats['processed']}\n"
]
total_skipped = stats['skipped_no_ops'] + stats['skipped_no_transcode'] + stats['skipped_identical_path'] + stats['skipped_existing']
summary.append(f"⏭️ Total Skipped: {total_skipped}\n")
if total_skipped > 0:
summary.append(f" - No target audio operations: {stats['skipped_no_ops']}\n")
summary.append(f" - No transcoding required (all copy): {stats['skipped_no_transcode']}\n")
summary.append(f" - Identical input/output path: {stats['skipped_identical_path']}\n")
summary.append(f" - Output file already exists: {stats['skipped_existing']}\n")
summary.append(f"🚨 Failed to process: {stats['failed']}\n")
summary.append("--------------------------\n")
gui_logger.write("".join(summary))
except Exception as e:
gui_logger.write(f"\n\n🚨 A CRITICAL ERROR occurred: {e}\n")
finally:
# 7. Re-enable the button on the main thread
self.processing_finished()
def processing_finished(self):
"""Schedules the 'Start' button to be re-enabled on the main GUI thread."""
# Use self.after, not self.app.after, as 'self' is the app instance
self.after(0, lambda: self.start_button.configure(state="normal", text="Start Processing"))
def launch():
"""Entry point for launching the GUI."""
# Check for ffmpeg/ffprobe before launching
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
ctk.set_appearance_mode("system")
root = ctk.CTk()
root.withdraw() # Hide the main window
# Simple message box
from tkinter import messagebox
messagebox.showerror(
"Missing Dependencies",
"Error: ffmpeg and/or ffprobe are not installed or not found in your system's PATH. Please install ffmpeg to use this tool."
)
root.destroy()
return
app = TranscoderApp()
app.mainloop()