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()