Compare commits

...

6 Commits

Author SHA1 Message Date
f97dfca46f BUGFIX: typo in main.py
All checks were successful
Publish Python Package to PyPI / deploy (push) Successful in 1m13s
2025-11-13 16:18:54 -04:00
c649a1e593 gui-patch (#8)
All checks were successful
Publish Python Package to PyPI / deploy (push) Successful in 1m27s
Reviewed-on: #8
Co-authored-by: Jonathan Rampersad <rampersad.jonathan@gmail.com>
Co-committed-by: Jonathan Rampersad <rampersad.jonathan@gmail.com>
2025-10-26 19:06:07 +00:00
GitHub Bridge Bot
6be91ca5eb Apply patch from GitHub PR #2 by jono-rams
Some checks failed
Publish Python Package to PyPI / deploy (push) Has been cancelled
2025-06-17 16:14:06 +00:00
06bfb38bbd Update README.md
Some checks failed
Publish Python Package to PyPI / deploy (push) Has been cancelled
Signed-off-by: Jonathan Rampersad <jonathan@jono-rams.work>
2025-06-17 16:00:50 +00:00
4fb400af2a Update to version 0.4.2 that links to GitHub
All checks were successful
Publish Python Package to PyPI / deploy (push) Successful in 14s
Signed-off-by: Jonathan Rampersad <jonathan@jono-rams.work>
2025-06-17 15:58:32 +00:00
405f5a18ee [GitHub PR #1 by jono-rams] Add Contributor badges (#3)
Some checks failed
Publish Python Package to PyPI / deploy (push) Has been cancelled
**Mirrored from GitHub PR `#1` by `jono-rams`**

Original PR: https://github.com/jono-rams/surround-to-eac3/pull/1

---

None

Co-authored-by: GitHub Bridge Bot <bot@yourdomain.com>
Reviewed-on: #3
Co-authored-by: gitea-bot <bot@jono-rams.work>
Co-committed-by: gitea-bot <bot@jono-rams.work>
2025-06-17 15:57:42 +00:00
8 changed files with 954 additions and 341 deletions

29
.all-contributorsrc Normal file
View File

@@ -0,0 +1,29 @@
{
"projectName": "surround-to-eac3",
"projectOwner": "jono-rams",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "angular",
"contributorsPerLine": 7,
"linkToUsage": true,
"skipCi": true,
"contributors": [
{
"login": "jono-rams",
"name": "Jonathan Rampersad",
"avatar_url": "https://avatars.githubusercontent.com/u/29872001?v=4",
"profile": "https://jono-rams.work",
"contributions": [
"code",
"doc",
"infra"
]
}
],
"commitType": "docs"
}

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/.conda
/.venv
/dist

76
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that are considerate, respectful, and collaborative.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **jonathan@jono-rams.work**. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interaction in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html

View File

@@ -11,6 +11,9 @@ This tool is perfect for users who want to standardize their media library's aud
`eac3-transcode` automates the often tedious process of inspecting video files, identifying specific audio tracks, and re-encoding them. It's designed to be smart about which tracks to process:
* **✨ Optional GUI:**
* A user-friendly graphical interface is available for those who prefer not to use the command line. Launch it with `eac3-transcode --launch-gui`.
* **Scans Individual Files or Entire Directories:**
* Process a single video or batch-process an entire folder (including subfolders).
@@ -113,6 +116,24 @@ __*This will use all available CPU cores for maximum speed.*__
`eac3-transcode --input "/path/to/your/video_folder/" --force-reprocess`
6. **Launching the GUI:**
`eac3-transcode --launch-gui`
The GUI provides access to all the same features as the command line, including:
* Browse for input files or folders.
* Browse for an output directory.
* Adjust bitrate, languages, and job count.
* Toggle "Dry Run" and "Force Reprocess".
* Manually load a custom options.json config file.
* A real-time log viewer to see the progress.
## Configuration
For convenience, the script supports a `options.json` file to set your preferred defaults.
@@ -164,6 +185,9 @@ An advanced video transcoder that processes files to use E-AC3 for specific audi
* `-h, --help`
Show this help message and exit.
* `--launch-gui`
**(Optional)** Launch the graphical user interface.
* `-i INPUT_PATH, --input INPUT_PATH`
**(Required)** Path to the input video file or folder.
@@ -248,7 +272,38 @@ An advanced video transcoder that processes files to use E-AC3 for specific audi
## Contributing
Contributions, issues, and feature requests are welcome! Feel free to check [issues page](https://gitea.jono-rams.work/jono/ffmpeg-audio-transcoder/issues).
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com)
[![GitHub issues](https://img.shields.io/github/issues/jono-rams/surround-to-eac3.svg?style=flat-square)](https://github.com/jono-rams/surround-to-eac3/issues)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/jono-rams/surround-to-eac3.svg?style=flat-square)](https://github.com/jono-rams/surround-to-eac3/pulls)
Contributions, issues, and feature requests are welcome! Feel free to check [issues page](https://github.com/jono-rams/surround-to-eac3/issues).
## Contributors
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://jono-rams.work"><img src="https://avatars.githubusercontent.com/u/29872001?v=4?s=100" width="100px;" alt="Jonathan Rampersad"/><br /><sub><b>Jonathan Rampersad</b></sub></a><br /><a href="https://github.com/jono-rams/surround-to-eac3/commits?author=jono-rams" title="Code">💻</a> <a href="https://github.com/jono-rams/surround-to-eac3/commits?author=jono-rams" title="Documentation">📖</a> <a href="#infra-jono-rams" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="center" size="13px" colspan="7">
<img src="https://raw.githubusercontent.com/all-contributors/all-contributors-cli/1b8533af435da9854653492b1327a23a4dbd0a10/assets/logo-small.svg">
<a href="https://all-contributors.js.org/docs/en/bot/usage">Add your contributions</a>
</img>
</td>
</tr>
</tfoot>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
## License

View File

@@ -1,12 +1,12 @@
[metadata]
name = surround-to-eac3
version = 0.4.1
version = 0.5.1
author = Jonathan Rampersad
author_email = jonathan@jono-rams.work
description = A CLI tool to transcode 5.1 audio in video files to E-AC3.
long_description = file: README.md
long_description_content_type = text/markdown
url = https://gitea.jono-rams.work/jono/ffmpeg-audio-transcoder
url = https://github.com/jono-rams/surround-to-eac3
classifiers =
Development Status :: 4 - Beta
Intended Audience :: End Users/Desktop
@@ -26,6 +26,7 @@ python_requires = >=3.10
install_requires =
tqdm
platformdirs
customtkinter >= 5.0.0
[options.packages.find]
where=src

416
src/surround_to_eac3/gui.py Normal file
View File

@@ -0,0 +1,416 @@
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()

View File

@@ -6,323 +6,22 @@ import argparse
import json
import threading
import queue
import sys
from tqdm import tqdm
from platformdirs import user_config_dir
# --- Import refactored processing functions ---
try:
from . import processing
except ImportError:
# Fallback for running file directly
import processing
# --- Constants for Configuration ---
APP_NAME = "eac3-transcode"
APP_AUTHOR = "eac3-transcode"
CONFIG_FILENAME = "options.json"
# Global lock for TQDM writes to prevent interleaving from multiple threads
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.
For audio, returns list of dicts with 'index', 'codec_name', 'channels', 'language'.
For video/subtitle, returns list of dicts with 'index', 'codec_name'.
"""
logs = []
if not shutil.which("ffprobe"):
logs.append(f" ⚠️ Warning: ffprobe is missing. Cannot get {stream_type} stream info for '{os.path.basename(filepath)}'.")
return [], logs
select_streams_option = {
"audio": "a",
"video": "v",
"subtitle": "s"
}.get(stream_type, "a") # Default to audio if type is unknown
ffprobe_cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", select_streams_option, filepath
]
try:
process = subprocess.run(
ffprobe_cmd, capture_output=True, text=True, check=False,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
)
if process.returncode != 0:
# Non-critical error for this function, main processing will decide to skip/fail
return [], logs
if not process.stdout.strip():
return [], logs # No streams of the selected type found
data = json.loads(process.stdout)
streams_details = []
for stream in data.get("streams", []):
detail = {
"index": stream["index"], # Absolute stream index
"codec_name": stream.get("codec_name", "unknown")
}
if stream_type == "audio":
detail["channels"] = stream.get("channels")
detail["language"] = stream.get("tags", {}).get("language", "und").lower()
streams_details.append(detail)
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'}]
duration: float,
pbar_position: int
) -> tuple[bool, list[str]]:
"""
Processes a single video file using ffmpeg, writing to a temporary file first.
"""
logs = []
if not shutil.which("ffmpeg"):
logs.append(" 🚨 Error: ffmpeg is not installed or not found.")
return False, logs
# 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", "-nostdin", "-i", input_filepath, "-map_metadata", "0"]
map_operations = []
output_audio_stream_ffmpeg_idx = 0 # For -c:a:0, -c:a:1 etc.
# Map Video Streams
map_operations.extend(["-map", "0:v?", "-c:v", "copy"])
# Map Subtitle Streams
map_operations.extend(["-map", "0:s?", "-c:s", "copy"])
# Map Audio Streams based on operations
for op_details in audio_processing_ops:
map_operations.extend(["-map", f"0:{op_details['index']}"])
if op_details['op'] == 'transcode':
map_operations.extend([f"-c:a:{output_audio_stream_ffmpeg_idx}", "eac3", f"-b:a:{output_audio_stream_ffmpeg_idx}", audio_bitrate, f"-ac:a:{output_audio_stream_ffmpeg_idx}", "6", f"-metadata:s:a:{output_audio_stream_ffmpeg_idx}", f"language={op_details['lang']}"])
elif op_details['op'] == 'copy':
map_operations.extend([f"-c:a:{output_audio_stream_ffmpeg_idx}", "copy"])
output_audio_stream_ffmpeg_idx += 1
ffmpeg_cmd.extend(map_operations)
if final_output_filepath.lower().endswith('.mkv'):
ffmpeg_cmd.extend(['-f', 'matroska'])
elif final_output_filepath.lower().endswith('.mp4'):
ffmpeg_cmd.extend(['-f', 'mp4'])
ffmpeg_cmd.extend(["-y", "-v", "quiet", "-stats_period", "1", "-progress", "pipe:1", temp_output_filepath])
logs.append(f" ⚙️ Processing: '{base_filename}' -> '{output_filename}'")
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" ⚠️ Warning: ffmpeg reported success, but temp file is missing or empty.")
return False, logs
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, 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
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_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:
for stream in audio_streams_details:
lang = stream['language']
op_to_perform = None
channels_info = f"{stream.get('channels')}ch" if stream.get('channels') is not None else "N/Ach"
codec_name = stream.get('codec_name', 'unknown')
if lang in target_languages:
is_5_1 = stream.get('channels') == 6
is_not_ac3_eac3 = codec_name not in ['ac3', 'eac3']
if is_5_1 and is_not_ac3_eac3:
op_to_perform = 'transcode'
file_specific_logs.append(f" 🔈 Will transcode: Audio stream #{stream['index']} ({lang}, {channels_info}, {codec_name})")
else:
op_to_perform = 'copy'
reason_parts = [f"already {codec_name}" if codec_name in ['ac3', 'eac3'] else None, f"not 5.1 ({channels_info})" if stream.get('channels') != 6 else None]
reason = ", ".join(filter(None, reason_parts)) or "meets other criteria for copying"
file_specific_logs.append(f" 🔈 Will copy: Audio stream #{stream['index']} ({lang}, {channels_info}, {codec_name}) - Reason: {reason}")
else:
file_specific_logs.append(f" 🔈 Will drop: Audio stream #{stream['index']} ({lang}, {channels_info}, {codec_name}) - Not a target language.")
if op_to_perform:
audio_ops_for_ffmpeg.append({'index': stream['index'], 'op': op_to_perform, 'lang': lang})
# First, check if there are any operations at all for target languages
if not audio_ops_for_ffmpeg:
file_specific_logs.append(f" ⏭️ Skipping '{display_name}': No target audio streams to process (copy/transcode).")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg)
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}': No transcoding required.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg)
final_status = "skipped_no_transcode"
return final_status
# Determine final output path
name, ext = os.path.splitext(os.path.basename(filepath))
output_filename = f"{name}_eac3{ext}"
output_dir_for_this_file = os.path.dirname(filepath) # Default to same directory
if args.output_directory_base: # Input was a folder
if os.path.isdir(input_path_abs):
relative_dir = os.path.relpath(os.path.dirname(filepath), start=input_path_abs)
output_dir_for_this_file = os.path.join(args.output_directory_base, relative_dir) if relative_dir != "." else args.output_directory_base
else: # Input was a single file
output_dir_for_this_file = args.output_directory_base
final_output_filepath = os.path.join(output_dir_for_this_file, output_filename)
# Check if the output file already exists and we are NOT forcing reprocessing.
if os.path.exists(final_output_filepath) and not args.force_reprocess:
file_specific_logs.append(f" ⏭️ Skipping: Output file already exists. Use --force-reprocess to override.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg)
final_status = "skipped_existing"
return final_status
# 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 paths are identical. Skipping.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg)
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.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg)
# We return 'processed' to indicate it *would* have been processed
final_status = "processed"
return final_status
# Ensure output directory exists before processing
if not os.path.isdir(output_dir_for_this_file):
try:
os.makedirs(output_dir_for_this_file, exist_ok=True)
except OSError as e:
file_specific_logs.append(f" 🚨 Error creating output directory '{output_dir_for_this_file}': {e}")
with tqdm_lock:
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"
try:
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)
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):
try:
os.remove(temp_filepath)
except OSError as e:
file_specific_logs.append(f" 🚨 Error cleaning up temp file '{temp_filepath}': {e}")
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):
@@ -330,6 +29,22 @@ def worker_init(worker_id_queue):
def main():
# --- GUI LAUNCHER ---
# Check for --launch-gui *before* parsing args
if "--launch-gui" in sys.argv:
print("Launching GUI...")
try:
from . import gui
gui.launch()
except ImportError as e:
print(f"🚨 Error: GUI dependencies are not installed. {e}", file=sys.stderr)
print("Please run: pip install surround-to-eac3[gui]", file=sys.stderr)
except Exception as e:
# Catch other GUI-related errors (e.g., display not found)
print(f"🚨 Error launching GUI: {e}", file=sys.stderr)
sys.exit() # Exit after launching or failing
# ---------------------
# Initial check for ffmpeg and ffprobe
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
missing_tools = []
@@ -342,12 +57,19 @@ def main():
description="Advanced video transcoder: E-AC3 for specific audio, language filtering, folder processing.",
formatter_class=argparse.RawTextHelpFormatter
)
# Add the new --launch-gui argument
parser.add_argument(
"--launch-gui",
action="store_true",
help="Launch the graphical user interface."
)
parser.add_argument(
"-i", "--input",
required=True,
help="Path to the input video file or folder.",
dest="input_path"
)
# ... (all your other arguments: -o, -br, -l, -j, --dry-run, --force-reprocess) ...
parser.add_argument(
"-o", "--outdir",
help="Optional. Base directory to save processed files.\n"
@@ -385,15 +107,14 @@ def main():
help="Force reprocessing of all files, even if an output file with the target name already exists."
)
# --- Configuration File Logic ---
# --- Configuration File Logic (unchanged) ---
config = {}
user_config_dir_path = user_config_dir(APP_NAME, APP_AUTHOR)
user_config_file_path = os.path.join(user_config_dir_path, CONFIG_FILENAME)
if not os.path.exists(user_config_file_path):
try:
defaults = {action.dest: action.default for action in parser._actions if action.dest != "help" and not action.required}
defaults = {action.dest: action.default for action in parser._actions if action.dest != "help" and not action.required and action.dest != "launch_gui"}
os.makedirs(user_config_dir_path, exist_ok=True)
with open(user_config_file_path, 'w') as f:
json.dump(defaults, f, indent=4)
@@ -415,7 +136,12 @@ def main():
break
parser.set_defaults(**config)
# Check for --input manually since it's no longer required by argparse
# to allow --launch-gui to work without it.
args = parser.parse_args()
if not args.input_path:
parser.error("-i/--input is required for CLI mode.")
if loaded_config_path:
print(f"✅ Loaded configuration from: {loaded_config_path}")
@@ -423,19 +149,19 @@ def main():
if args.dry_run:
print("--- DRY RUN MODE ENABLED: No files will be modified. ---")
# --- File Discovery ---
# --- File Discovery (unchanged) ---
input_path_abs = os.path.abspath(args.input_path)
files_to_process_paths = []
if os.path.isdir(input_path_abs):
print(f"📁 Scanning folder: {input_path_abs}")
for root, _, filenames in os.walk(input_path_abs):
for filename in filenames:
if filename.lower().endswith(SUPPORTED_EXTENSIONS):
if filename.lower().endswith(processing.SUPPORTED_EXTENSIONS):
files_to_process_paths.append(os.path.join(root, filename))
if not files_to_process_paths:
print(" No .mkv or .mp4 files found in the specified folder.")
elif os.path.isfile(input_path_abs):
if input_path_abs.lower().endswith((".mkv", ".mp4")):
if input_path_abs.lower().endswith(processing.SUPPORTED_EXTENSIONS):
files_to_process_paths.append(input_path_abs)
else:
print(f"⚠️ Provided file '{args.input_path}' is not an .mkv or .mp4 file. Skipping this input.")
@@ -448,27 +174,31 @@ def main():
return
print(f"\nFound {len(files_to_process_paths)} file(s) to potentially process...")
# Initialize stats counters
stats = {
"processed": 0,
"skipped_no_ops": 0,
"skipped_no_transcode": 0,
"skipped_identical_path": 0,
"skipped_existing": 0,
"failed": 0
"processed": 0, "skipped_no_ops": 0, "skipped_no_transcode": 0,
"skipped_identical_path": 0, "skipped_existing": 0, "failed": 0
}
# --- Main Processing Loop ---
# We create the lock and queue here for the CLI job
tqdm_lock = threading.Lock()
worker_id_queue = queue.Queue()
for i in range(args.jobs):
num_jobs = min(args.jobs, len(files_to_process_paths))
for i in range(num_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, position=0, leave=True) as pbar:
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs, initializer=worker_init, initargs=(worker_id_queue,)) as executor:
with tqdm(total=len(files_to_process_paths), desc="Overall Progress", unit="file", ncols=100, smoothing=0.1, position=0, leave=True, file=sys.stderr) 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 process_single_file(filepath, worker_id, args, input_path_abs)
# We pass the lock and the standard sys.stderr writer
return processing.process_single_file(
filepath, worker_id, args, input_path_abs,
tqdm_lock, sys.stderr
)
future_to_path = {executor.submit(submit_task, path): path for path in files_to_process_paths}
@@ -481,21 +211,19 @@ def main():
else:
stats["failed"] += 1
with tqdm_lock:
tqdm.write(f"🚨 UNKNOWN STATUS '{status}' for '{os.path.basename(path)}'.")
tqdm.write(f"🚨 UNKNOWN STATUS '{status}' for '{os.path.basename(path)}'.", file=sys.stderr)
except Exception as exc:
with tqdm_lock:
tqdm.write(f"🚨 CRITICAL ERROR during task for '{os.path.basename(path)}': {exc}")
tqdm.write(f"🚨 CRITICAL ERROR during task for '{os.path.basename(path)}': {exc}", file=sys.stderr)
stats["failed"] += 1
finally:
pbar.update(1)
except KeyboardInterrupt:
print("\n\n🚨 Process interrupted by user. Shutting down gracefully... Any in-progress files have been cleaned up.")
# The 'finally' blocks in each thread will handle cleanup.
# Exiting here.
print("\n\n🚨 Process interrupted by user. Shutting down gracefully...")
return
# Print summary of operations
# --- Summary (unchanged) ---
summary_title = "--- Dry Run Summary ---" if args.dry_run else "--- Processing Summary ---"
processed_label = "Would be processed" if args.dry_run else "Successfully processed"
@@ -512,3 +240,6 @@ def main():
print(f" - Output file already exists: {stats['skipped_existing']}")
print(f"🚨 Failed to process: {stats['failed']}")
print("--------------------------")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,304 @@
import subprocess
import os
import shutil
import json
import sys
from tqdm import tqdm
# --- Constants ---
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.
"""
logs = []
if not shutil.which("ffprobe"):
logs.append(f" ⚠️ Warning: ffprobe is missing. Cannot get {stream_type} stream info for '{os.path.basename(filepath)}'.")
return [], logs
select_streams_option = {
"audio": "a",
"video": "v",
"subtitle": "s"
}.get(stream_type, "a")
ffprobe_cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", select_streams_option, filepath
]
try:
process = subprocess.run(
ffprobe_cmd, capture_output=True, text=True, check=False,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
)
if process.returncode != 0:
return [], logs
if not process.stdout.strip():
return [], logs
data = json.loads(process.stdout)
streams_details = []
for stream in data.get("streams", []):
detail = {
"index": stream["index"],
"codec_name": stream.get("codec_name", "unknown")
}
if stream_type == "audio":
detail["channels"] = stream.get("channels")
detail["language"] = stream.get("tags", {}).get("language", "und").lower()
streams_details.append(detail)
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 process_file_with_ffmpeg(
input_filepath: str,
final_output_filepath: str | None,
audio_bitrate: str,
audio_processing_ops: list[dict],
duration: float,
pbar_position: int,
tqdm_lock,
tqdm_file_writer=sys.stderr
) -> tuple[bool, list[str]]:
"""
Processes a single video file using ffmpeg, writing to a temporary file first.
"""
logs = []
if not shutil.which("ffmpeg"):
logs.append(" 🚨 Error: ffmpeg is not installed or not found.")
return False, logs
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", "-nostdin", "-i", input_filepath, "-map_metadata", "0"]
map_operations = []
output_audio_stream_ffmpeg_idx = 0
map_operations.extend(["-map", "0:v?", "-c:v", "copy"])
map_operations.extend(["-map", "0:s?", "-c:s", "copy"])
for op_details in audio_processing_ops:
map_operations.extend(["-map", f"0:{op_details['index']}"])
if op_details['op'] == 'transcode':
map_operations.extend([f"-c:a:{output_audio_stream_ffmpeg_idx}", "eac3", f"-b:a:{output_audio_stream_ffmpeg_idx}", audio_bitrate, f"-ac:a:{output_audio_stream_ffmpeg_idx}", "6", f"-metadata:s:a:{output_audio_stream_ffmpeg_idx}", f"language={op_details['lang']}"])
elif op_details['op'] == 'copy':
map_operations.extend([f"-c:a:{output_audio_stream_ffmpeg_idx}", "copy"])
output_audio_stream_ffmpeg_idx += 1
ffmpeg_cmd.extend(map_operations)
if final_output_filepath.lower().endswith('.mkv'):
ffmpeg_cmd.extend(['-f', 'matroska'])
elif final_output_filepath.lower().endswith('.mp4'):
ffmpeg_cmd.extend(['-f', 'mp4'])
ffmpeg_cmd.extend(["-y", "-v", "quiet", "-stats_period", "1", "-progress", "pipe:1", temp_output_filepath])
logs.append(f" ⚙️ Processing: '{base_filename}' -> '{output_filename}'")
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, file=tqdm_file_writer)
for line in process.stdout:
if "out_time_us" in line:
try:
time_us = int(line.strip().split("=")[1])
elapsed_seconds = time_us / 1_000_000
if file_pbar:
update_amount = max(0, elapsed_seconds - file_pbar.n)
if update_amount > 0:
file_pbar.update(update_amount)
except (ValueError, IndexError):
continue
process.wait()
if file_pbar:
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" ⚠️ Warning: ffmpeg reported success, but temp file is missing or empty.")
if os.path.exists(temp_output_filepath):
os.remove(temp_output_filepath)
return False, logs
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,
pbar_position: int,
args: "argparse.Namespace",
input_path_abs: str,
tqdm_lock,
tqdm_file_writer=sys.stderr
) -> str:
"""
Analyzes and processes a single file, managing temporary files for graceful exit.
"""
file_specific_logs = []
final_status = "failed"
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_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:
for stream in audio_streams_details:
lang = stream['language']
op_to_perform = None
channels_info = f"{stream.get('channels')}ch" if stream.get('channels') is not None else "N/Ach"
codec_name = stream.get('codec_name', 'unknown')
if lang in target_languages:
is_5_1 = stream.get('channels') == 6
is_not_ac3_eac3 = codec_name not in ['ac3', 'eac3']
if is_5_1 and is_not_ac3_eac3:
op_to_perform = 'transcode'
file_specific_logs.append(f" 🔈 Will transcode: Audio stream #{stream['index']} ({lang}, {channels_info}, {codec_name})")
else:
op_to_perform = 'copy'
reason_parts = [f"already {codec_name}" if codec_name in ['ac3', 'eac3'] else None, f"not 5.1 ({channels_info})" if stream.get('channels') != 6 else None]
reason = ", ".join(filter(None, reason_parts)) or "meets other criteria for copying"
file_specific_logs.append(f" 🔈 Will copy: Audio stream #{stream['index']} ({lang}, {channels_info}, {codec_name}) - Reason: {reason}")
else:
file_specific_logs.append(f" 🔈 Will drop: Audio stream #{stream['index']} ({lang}, {channels_info}, {codec_name}) - Not a target language.")
if op_to_perform:
audio_ops_for_ffmpeg.append({'index': stream['index'], 'op': op_to_perform, 'lang': lang})
if not audio_ops_for_ffmpeg:
file_specific_logs.append(f" ⏭️ Skipping '{display_name}': No target audio streams to process (copy/transcode).")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg, file=tqdm_file_writer)
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}': No transcoding required.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg, file=tqdm_file_writer)
final_status = "skipped_no_transcode"
return final_status
name, ext = os.path.splitext(os.path.basename(filepath))
output_filename = f"{name}_eac3{ext}"
output_dir_for_this_file = os.path.dirname(filepath)
if args.output_directory_base:
if os.path.isdir(input_path_abs):
relative_dir = os.path.relpath(os.path.dirname(filepath), start=input_path_abs)
output_dir_for_this_file = os.path.join(args.output_directory_base, relative_dir) if relative_dir != "." else args.output_directory_base
else:
output_dir_for_this_file = args.output_directory_base
final_output_filepath = os.path.join(output_dir_for_this_file, output_filename)
if os.path.exists(final_output_filepath) and not args.force_reprocess:
file_specific_logs.append(f" ⏭️ Skipping: Output file already exists. Use --force-reprocess to override.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg, file=tqdm_file_writer)
final_status = "skipped_existing"
return final_status
if os.path.abspath(filepath) == os.path.abspath(final_output_filepath):
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, file=tqdm_file_writer)
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.")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg, file=tqdm_file_writer)
final_status = "processed"
return final_status
if not os.path.isdir(output_dir_for_this_file):
try:
os.makedirs(output_dir_for_this_file, exist_ok=True)
except OSError as e:
file_specific_logs.append(f" 🚨 Error creating output directory '{output_dir_for_this_file}': {e}")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg, file=tqdm_file_writer)
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"
try:
success, ffmpeg_logs = process_file_with_ffmpeg(
filepath, final_output_filepath, args.audio_bitrate,
audio_ops_for_ffmpeg, duration, pbar_position,
tqdm_lock, tqdm_file_writer
)
file_specific_logs.extend(ffmpeg_logs)
final_status = "processed" if success else "failed"
finally:
if os.path.exists(temp_filepath):
try:
os.remove(temp_filepath)
except OSError as e:
file_specific_logs.append(f" 🚨 Error cleaning up temp file '{temp_filepath}': {e}")
with tqdm_lock:
for log_msg in file_specific_logs:
tqdm.write(log_msg, file=tqdm_file_writer)
return final_status