commit b05ea516eebef64a103ffa2b28206c57b9e29e4c Author: Trebellar Date: Thu Aug 27 14:38:59 2026 +0200 Inicial, descargar y conversion basicas diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d673eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Entornos virtuales +venv/ +.venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# Binarios y ejecutables descargados localmente (FFmpeg/FFprobe) +bin/ffmpeg.exe +bin/ffprobe.exe +bin/ffplay.exe +bin/*.exe +bin/*.dll + +# Archivos multimedia de pruebas (Entrada y Salida) +*.mp4 +*.mkv +*.avi +*.mov +*.mp3 +*.wav +*.flac +*.m4a +*.webm + +# Configuración e IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# Distribución / Compilación (PyInstaller / CX_Freeze / Nuitka) +build/ +dist/ +*.spec + +# Archivos de sistema operativo +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c96f55 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Aplicación de escritorio desarrollada en Python y PyQt6 que actúa como una interfaz gráfica sobre FFmpeg para facilitar la conversión rápida de archivos multimedia. Permite exportar vídeo MP4 (estándar y escalado automático a máximo 1080p), extraer audio MP3 estéreo a 192 kbps y aislar los canales izquierdo/derecho (Canal 1 y Canal 2) en pistas monocanal independientes. \ No newline at end of file diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..801d81c --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,63 @@ +from PyQt6.QtWidgets import (QMainWindow, QTabWidget, QProgressBar, + QStatusBar, QMessageBox) +from gui.tab_youtube import YoutubeTab +from gui.tab_converter import ConverterTab # <-- Nueva importación +from utils.updater import DependencyChecker + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("seteTools - Media") + self.resize(600, 350) + + self.init_menu() + self.init_tabs() + self.init_statusbar() + self.check_dependencies() + + def init_menu(self): + menubar = self.menuBar() + menu_actualizaciones = menubar.addMenu("Actualizaciones") + + check_action = menu_actualizaciones.addAction("Buscar actualizaciones de binarios") + check_action.triggered.connect(self.force_update_check) + + def init_tabs(self): + self.tabs = QTabWidget() + + # Pestaña 1: Descarga YouTube + self.youtube_tab = YoutubeTab() + self.tabs.addTab(self.youtube_tab, "Descarga YouTube") + + # Pestaña 2: Conversión con FFmpeg + self.converter_tab = ConverterTab() + self.tabs.addTab(self.converter_tab, "Convertidor FFmpeg") + + self.setCentralWidget(self.tabs) + + def init_statusbar(self): + self.statusBar = QStatusBar() + self.setStatusBar(self.statusBar) + self.progress_bar = QProgressBar() + self.progress_bar.setMaximumWidth(200) + self.progress_bar.hide() + self.statusBar.addPermanentWidget(self.progress_bar) + + def check_dependencies(self): + self.thread = DependencyChecker() + self.thread.progress_signal.connect(self.update_progress) + self.thread.finished_signal.connect(self.on_check_finished) + + self.progress_bar.show() + self.thread.start() + + def update_progress(self, message, percent): + self.statusBar.showMessage(message) + self.progress_bar.setValue(percent) + + def on_check_finished(self): + self.progress_bar.hide() + + def force_update_check(self): + QMessageBox.information(self, "Actualizaciones", "Verificando componentes...") + self.check_dependencies() \ No newline at end of file diff --git a/gui/tab_converter.py b/gui/tab_converter.py new file mode 100644 index 0000000..917896b --- /dev/null +++ b/gui/tab_converter.py @@ -0,0 +1,141 @@ +import os +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QComboBox, + QFileDialog, QMessageBox) +from utils.converter import MediaConverter + +class ConverterTab(QWidget): + PRESETS = { + "MP4 Estándar (H.264 + AAC)": {"key": "mp4_std", "ext": ".mp4", "tag": "_converted"}, + "MP4 Estándar (Máx. 1080p Vertical)": {"key": "mp4_1080p", "ext": ".mp4", "tag": "_1080p"}, + "Audio MP3 Estándar (Estéreo 192 kbps)": {"key": "mp3_192k", "ext": ".mp3", "tag": "_audio"}, + "Audio MP3 - Solo Canal 1 / Izquierdo (192 kbps)": {"key": "mp3_ch1", "ext": ".mp3", "tag": "_ch1"}, + "Audio MP3 - Solo Canal 2 / Derecho (192 kbps)": {"key": "mp3_ch2", "ext": ".mp3", "tag": "_ch2"} + } + + def __init__(self): + super().__init__() + self.converter_thread = None + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + # Archivo de entrada + layout.addWidget(QLabel("Archivo de entrada (In):")) + in_layout = QHBoxLayout() + self.in_input = QLineEdit() + self.in_input.setPlaceholderText("Selecciona un archivo de vídeo o audio...") + in_layout.addWidget(self.in_input) + + self.in_browse_btn = QPushButton("Examinar") + self.in_browse_btn.clicked.connect(self.browse_input_file) + in_layout.addWidget(self.in_browse_btn) + layout.addLayout(in_layout) + + # Combo de Presets + layout.addWidget(QLabel("Preset de conversión:")) + self.preset_combo = QComboBox() + for label in self.PRESETS.keys(): + self.preset_combo.addItem(label) + self.preset_combo.currentIndexChanged.connect(self.on_preset_changed) + layout.addWidget(self.preset_combo) + + # Archivo de salida + layout.addWidget(QLabel("Archivo de salida (Out):")) + out_layout = QHBoxLayout() + self.out_input = QLineEdit() + self.out_input.setPlaceholderText("Ruta del archivo de salida...") + out_layout.addWidget(self.out_input) + + self.out_browse_btn = QPushButton("Examinar") + self.out_browse_btn.clicked.connect(self.browse_output_file) + out_layout.addWidget(self.out_browse_btn) + layout.addLayout(out_layout) + + # Botón de Conversión + self.convert_btn = QPushButton("Iniciar Conversión") + self.convert_btn.clicked.connect(self.start_conversion) + layout.addWidget(self.convert_btn) + + # Label de estado + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + layout.addStretch() + self.setLayout(layout) + + def browse_input_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Seleccionar archivo de entrada", "", "Archivos Multimedia (*.mkv *.mp4 *.avi *.mov *.m4a *.mp3 *.wav *.flac *.webm);;Todos los archivos (*.*)" + ) + if file_path: + self.in_input.setText(file_path) + self.update_output_path_from_input(file_path) + + def browse_output_file(self): + selected_preset = self.PRESETS[self.preset_combo.currentText()] + ext = selected_preset["ext"] + file_path, _ = QFileDialog.getSaveFileName( + self, "Guardar archivo como", self.out_input.text(), f"Archivo (*{ext})" + ) + if file_path: + self.out_input.setText(file_path) + + def update_output_path_from_input(self, input_path): + base_path, _ = os.path.splitext(input_path) + selected_preset = self.PRESETS[self.preset_combo.currentText()] + new_output = f"{base_path}{selected_preset['tag']}{selected_preset['ext']}" + self.out_input.setText(new_output) + + def on_preset_changed(self): + input_path = self.in_input.text().strip() + if input_path: + self.update_output_path_from_input(input_path) + else: + current_out = self.out_input.text().strip() + if current_out: + base_path, _ = os.path.splitext(current_out) + selected_preset = self.PRESETS[self.preset_combo.currentText()] + self.out_input.setText(f"{base_path}{selected_preset['ext']}") + + def start_conversion(self): + input_file = self.in_input.text().strip() + output_file = self.out_input.text().strip() + selected_preset = self.PRESETS[self.preset_combo.currentText()] + + if not input_file or not os.path.exists(input_file): + QMessageBox.warning(self, "Atención", "Por favor, selecciona un archivo de entrada válido.") + return + + if not output_file: + QMessageBox.warning(self, "Atención", "Especifica la ruta del archivo de salida.") + return + + self.set_inputs_enabled(False) + self.status_label.setText("Iniciando conversión...") + + self.converter_thread = MediaConverter(input_file, output_file, selected_preset["key"]) + self.converter_thread.progress_signal.connect(self.update_status) + self.converter_thread.finished_signal.connect(self.on_conversion_finished) + self.converter_thread.start() + + def update_status(self, message): + self.status_label.setText(message) + + def on_conversion_finished(self, success, message): + self.set_inputs_enabled(True) + self.status_label.setText("") + + if success: + QMessageBox.information(self, "Éxito", message) + else: + QMessageBox.critical(self, "Error", message) + + def set_inputs_enabled(self, enabled: bool): + self.in_input.setEnabled(enabled) + self.in_browse_btn.setEnabled(enabled) + self.out_input.setEnabled(enabled) + self.out_browse_btn.setEnabled(enabled) + self.preset_combo.setEnabled(enabled) + self.convert_btn.setEnabled(enabled) \ No newline at end of file diff --git a/gui/tab_youtube.py b/gui/tab_youtube.py new file mode 100644 index 0000000..5e90f81 --- /dev/null +++ b/gui/tab_youtube.py @@ -0,0 +1,90 @@ +import os +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox) +from utils.downloader import VideoDownloader + +class YoutubeTab(QWidget): + def __init__(self): + super().__init__() + self.downloader_thread = None + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + # Input URL + layout.addWidget(QLabel("URL del vídeo:")) + self.url_input = QLineEdit() + self.url_input.setPlaceholderText("https://www.youtube.com/watch?v=...") + layout.addWidget(self.url_input) + + # Input Ruta de destino + layout.addWidget(QLabel("Ruta de destino:")) + path_layout = QHBoxLayout() + self.path_input = QLineEdit() + # Carpeta por defecto: Descargas del usuario + default_path = os.path.join(os.path.expanduser("~"), "Downloads") + self.path_input.setText(default_path) + path_layout.addWidget(self.path_input) + + self.browse_btn = QPushButton("Examinar") + self.browse_btn.clicked.connect(self.browse_folder) + path_layout.addWidget(self.browse_btn) + layout.addLayout(path_layout) + + # Botón de Descarga + self.download_btn = QPushButton("Descargar Vídeo MP4") + self.download_btn.clicked.connect(self.start_download) + layout.addWidget(self.download_btn) + + # Label de Estado local + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + layout.addStretch() + self.setLayout(layout) + + def browse_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Seleccionar carpeta de destino") + if folder: + self.path_input.setText(folder) + + def start_download(self): + url = self.url_input.text().strip() + save_path = self.path_input.text().strip() + + if not url: + QMessageBox.warning(self, "Atención", "Por favor, introduce una URL válida.") + return + + if not os.path.exists(save_path): + QMessageBox.warning(self, "Atención", "La ruta de destino seleccionada no existe.") + return + + # Bloquear controles para evitar descargas simultáneas + self.set_inputs_enabled(False) + self.status_label.setText("Iniciando proceso...") + + # Iniciar hilo en segundo plano + self.downloader_thread = VideoDownloader(url, save_path) + self.downloader_thread.progress_signal.connect(self.update_status) + self.downloader_thread.finished_signal.connect(self.on_download_finished) + self.downloader_thread.start() + + def update_status(self, message): + self.status_label.setText(message) + + def on_download_finished(self, success, message): + self.set_inputs_enabled(True) + self.status_label.setText("") + + if success: + QMessageBox.information(self, "Éxito", message) + self.url_input.clear() + else: + QMessageBox.critical(self, "Error", message) + + def set_inputs_enabled(self, enabled: bool): + self.url_input.setEnabled(enabled) + self.path_input.setEnabled(enabled) + self.browse_btn.setEnabled(enabled) + self.download_btn.setEnabled(enabled) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..415ad75 --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +import sys +from PyQt6.QtWidgets import QApplication +from gui.main_window import MainWindow + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/converter.py b/utils/converter.py new file mode 100644 index 0000000..cd6a6cd --- /dev/null +++ b/utils/converter.py @@ -0,0 +1,88 @@ +import os +import subprocess +import re +from PyQt6.QtCore import QThread, pyqtSignal + +class MediaConverter(QThread): + progress_signal = pyqtSignal(str) # Mensaje de estado + finished_signal = pyqtSignal(bool, str) # (Éxito?, Mensaje final) + + def __init__(self, input_file, output_file, preset_key, bin_dir="bin"): + super().__init__() + self.input_file = input_file + self.output_file = output_file + self.preset_key = preset_key + self.bin_dir = os.path.abspath(bin_dir) + + def run(self): + ffmpeg_exe = os.path.join(self.bin_dir, "ffmpeg.exe") + + if not os.path.exists(ffmpeg_exe): + self.finished_signal.emit(False, "No se encontró ffmpeg.exe en la carpeta bin.") + return + + if not os.path.exists(self.input_file): + self.finished_signal.emit(False, "El archivo de entrada no existe.") + return + + # Configuración de argumentos según el preset + if self.preset_key == "mp4_std": + # MP4 Estándar sin reescalado + codec_args = ["-c:v", "libx264", "-c:a", "aac"] + + elif self.preset_key == "mp4_1080p": + # MP4 Max 1080p Verticales (mantiene aspecto, no escala si es <= 1080) + codec_args = ["-vf", "scale=-2:'min(1080,ih)'", "-c:v", "libx264", "-c:a", "aac"] + + elif self.preset_key == "mp3_192k": + # Estéreo completo a MP3 192 kbps + codec_args = ["-vn", "-c:a", "libmp3lame", "-b:a", "192k"] + + elif self.preset_key == "mp3_ch1": + # Solo Canal 1 (Izquierdo / Left) -> Mono MP3 192 kbps + codec_args = ["-vn", "-af", "pan=mono|c0=c0", "-c:a", "libmp3lame", "-b:a", "192k"] + + elif self.preset_key == "mp3_ch2": + # Solo Canal 2 (Derecho / Right) -> Mono MP3 192 kbps + codec_args = ["-vn", "-af", "pan=mono|c0=c1", "-c:a", "libmp3lame", "-b:a", "192k"] + + else: + self.finished_signal.emit(False, "Preset no reconocido.") + return + + # Sobrescribir archivo de salida si ya existe (-y) + command = [ffmpeg_exe, "-y", "-i", self.input_file] + codec_args + [self.output_file] + + try: + startupinfo = None + if os.name == 'nt': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + startupinfo=startupinfo, + encoding='utf-8', + errors='replace' + ) + + for line in process.stdout: + line_str = line.strip() + if line_str: + if "time=" in line_str: + match = re.search(r'time=(\d{2}:\d{2}:\d{2}\.\d{2})', line_str) + if match: + self.progress_signal.emit(f"Procesando... Tiempo: {match.group(1)}") + + process.wait() + + if process.returncode == 0: + self.finished_signal.emit(True, "¡Conversión completada con éxito!") + else: + self.finished_signal.emit(False, "Ocurrió un error durante la conversión con FFmpeg.") + + except Exception as e: + self.finished_signal.emit(False, f"Error de ejecución: {str(e)}") \ No newline at end of file diff --git a/utils/downloader.py b/utils/downloader.py new file mode 100644 index 0000000..81b7943 --- /dev/null +++ b/utils/downloader.py @@ -0,0 +1,81 @@ +import os +import subprocess +import re +from PyQt6.QtCore import QThread, pyqtSignal + +class VideoDownloader(QThread): + progress_signal = pyqtSignal(str) + finished_signal = pyqtSignal(bool, str) + + def __init__(self, url, download_path, bin_dir="bin"): + super().__init__() + self.url = url + self.download_path = download_path + self.bin_dir = os.path.abspath(bin_dir) + + def run(self): + ytdlp_exe = os.path.join(self.bin_dir, "yt-dlp.exe") + ffmpeg_exe = os.path.join(self.bin_dir, "ffmpeg.exe") + + if not os.path.exists(ytdlp_exe): + self.finished_signal.emit(False, f"No se encontró yt-dlp.exe en: {ytdlp_exe}") + return + + if not os.path.exists(ffmpeg_exe): + self.finished_signal.emit(False, f"No se encontró ffmpeg.exe en: {ffmpeg_exe}") + return + + # Creamos una copia del entorno de sistema y añadimos la carpeta 'bin' al PATH + # Esto asegura que tanto yt-dlp como subprocess encuentren ffmpeg y ffprobe sin fallar + env = os.environ.copy() + env["PATH"] = self.bin_dir + os.pathsep + env.get("PATH", "") + + # Comando idéntico a tu terminal + command = [ + ytdlp_exe, + "-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio", + "--merge-output-format", "mp4", + "--ffmpeg-location", self.bin_dir, + "-P", self.download_path, + self.url + ] + + try: + startupinfo = None + if os.name == 'nt': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + startupinfo=startupinfo, + env=env, # Le pasamos el entorno enriquecido con el PATH a bin + encoding='utf-8', + errors='replace' + ) + + for line in process.stdout: + line_str = line.strip() + if line_str: + # Imprime en la consola de Python para poder ver cualquier advertencia de yt-dlp + print(line_str) + + if "[download]" in line_str and "%" in line_str: + match = re.search(r'(\d+\.\d+%)', line_str) + if match: + self.progress_signal.emit(f"Descargando: {match.group(1)}") + elif "[Merger]" in line_str or "Merging" in line_str: + self.progress_signal.emit("Uniendo vídeo y audio en MP4...") + + process.wait() + + if process.returncode == 0: + self.finished_signal.emit(True, "¡Vídeo descargado y unido correctamente!") + else: + self.finished_signal.emit(False, "Error durante la descarga o la fusión.") + + except Exception as e: + self.finished_signal.emit(False, f"Error de ejecución: {str(e)}") \ No newline at end of file diff --git a/utils/updater.py b/utils/updater.py new file mode 100644 index 0000000..1cca586 --- /dev/null +++ b/utils/updater.py @@ -0,0 +1,93 @@ +import os +import requests +import zipfile +import shutil +from PyQt6.QtCore import QThread, pyqtSignal + +# URL de yt-dlp ejecutable directo +URL_YTDLP = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" + +class DependencyChecker(QThread): + progress_signal = pyqtSignal(str, int) # (Mensaje, Porcentaje) + finished_signal = pyqtSignal() + + def __init__(self, bin_dir="bin"): + super().__init__() + self.bin_dir = os.path.abspath(bin_dir) + + def run(self): + os.makedirs(self.bin_dir, exist_ok=True) + + ytdlp_path = os.path.join(self.bin_dir, "yt-dlp.exe") + ffmpeg_path = os.path.join(self.bin_dir, "ffmpeg.exe") + ffprobe_path = os.path.join(self.bin_dir, "ffprobe.exe") + + # Limpiar archivos corruptos o de tamaño inferior a 1 MB + self.cleanup_invalid_files([ytdlp_path, ffmpeg_path, ffprobe_path]) + + # 1. Verificar / Descargar yt-dlp.exe + if not os.path.exists(ytdlp_path): + self.download_file("yt-dlp.exe", URL_YTDLP, ytdlp_path) + + # 2. Verificar / Descargar FFmpeg y FFprobe reales desde la API de GitHub + if not os.path.exists(ffmpeg_path) or not os.path.exists(ffprobe_path): + self.download_ffmpeg_suite() + + self.progress_signal.emit("Todos los componentes están listos.", 100) + self.finished_signal.emit() + + def cleanup_invalid_files(self, paths): + for p in paths: + if os.path.exists(p) and os.path.getsize(p) < 1000000: # Menor a ~1 MB + try: + os.remove(p) + except Exception: + pass + + def download_file(self, name, url, save_path): + headers = {"User-Agent": "Mozilla/5.0"} + response = requests.get(url, headers=headers, stream=True, allow_redirects=True) + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + + with open(save_path, 'wb') as file: + for chunk in response.iter_content(chunk_size=16384): + if chunk: + file.write(chunk) + downloaded += len(chunk) + if total_size > 0: + percent = int((downloaded / total_size) * 100) + self.progress_signal.emit(f"Descargando {name}...", percent) + + def download_ffmpeg_suite(self): + self.progress_signal.emit("Buscando versión de FFmpeg...", 5) + headers = {"User-Agent": "Mozilla/5.0"} + + # Consultar la API de GitHub de GyanD/codexffmpeg para obtener la URL directa del ZIP + api_url = "https://api.github.com/repos/GyanD/codexffmpeg/releases/latest" + r = requests.get(api_url, headers=headers).json() + + download_url = None + for asset in r.get("assets", []): + if asset["name"].endswith("essentials_build.zip") or asset["name"].endswith("full_build.zip"): + download_url = asset["browser_download_url"] + break + + if not download_url: + # URL de respaldo estática si falla la API + download_url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" + + zip_path = os.path.join(self.bin_dir, "ffmpeg.zip") + self.download_file("FFmpeg Suite (ZIP)", download_url, zip_path) + + self.progress_signal.emit("Extrayendo FFmpeg y FFprobe...", 90) + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + for member in zip_ref.namelist(): + filename = os.path.basename(member) + if filename in ["ffmpeg.exe", "ffprobe.exe"]: + target_path = os.path.join(self.bin_dir, filename) + with zip_ref.open(member) as source, open(target_path, "wb") as target: + shutil.copyfileobj(source, target) + + if os.path.exists(zip_path): + os.remove(zip_path) \ No newline at end of file