import tkinter as tk from tkinter import ttk, filedialog, messagebox import subprocess import os import threading import json from pathlib import Path import re from urllib.parse import urlparse, unquote import platform import shutil import stat try: from tkinterdnd2 import DND_FILES, TkinterDnD DRAG_DROP_AVAILABLE = True except ImportError: DRAG_DROP_AVAILABLE = False TkinterDnD = tk try: import requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False class HLSConverterApp: def __init__(self, root): self.root = root self.root.title("HLS Converter Pro + GitHub Uploader") self.root.geometry("1100x900") self.root.configure(bg="#121212") # Variables principales self.video_file = tk.StringVar() self.video_url = tk.StringVar() self.input_type = tk.StringVar(value="file") self.conversion_mode = tk.StringVar(value="copy_video") self.audio_bitrate = tk.StringVar(value="192k") self.repo_url = tk.StringVar() self.github_token = tk.StringVar() self.video_batch_size = tk.IntVar(value=50) self.audio_batch_size = tk.IntVar(value=50) self.mode = tk.StringVar(value="normal") self.delete_local = tk.BooleanVar(value=True) self.bulk_sources = [] self.output_dir = "" self.last_output_dir = "" self.last_direct_link = "" self.processing = False # Progreso self.progress_popup = None self.step_var = None self.percent_var = None self.progress_var = None self.base_progress = 0.0 self.progress_span = 100.0 self.subprocess_flags = subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 # Idiomas self.lang_names = { 'und': 'Principal', 'eng': 'English', 'en': 'English', 'spa': 'Español', 'es': 'Español', 'fra': 'Français', 'fr': 'Français', 'deu': 'Deutsch', 'de': 'Deutsch', 'ita': 'Italiano', 'it': 'Italiano', 'por': 'Português', 'pt': 'Português', 'rus': 'Русский', 'ru': 'Русский', 'jpn': '日本語', 'ja': '日本語', 'kor': '한국어', 'ko': '한국어', 'zho': '中文', 'zh': '中文', 'ara': 'العربية', 'ar': 'العربية' } self.setup_theme() self.create_widgets() self.github_token.set("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") def setup_theme(self): style = ttk.Style() style.theme_use('clam') style.configure("TFrame", background="#121212") style.configure("Card.TFrame", background="#1e1e1e") style.configure("TLabel", background="#121212", foreground="#e0e0e0", font=("Segoe UI", 10)) style.configure("Title.TLabel", font=("Segoe UI", 18, "bold"), foreground="#00d4ff") style.configure("TButton", font=("Segoe UI", 10, "bold")) style.configure("Accent.TButton", background="#00d4ff", foreground="#000000") style.configure("TEntry", fieldbackground="#2d2d2d", foreground="#e0e0e0") style.configure("TRadiobutton", background="#121212", foreground="#e0e0e0") style.configure("Custom.Horizontal.TProgressbar", thickness=20, background="#00d4ff") def create_widgets(self): main_frame = ttk.Frame(self.root, padding="20") main_frame.pack(fill=tk.BOTH, expand=True) ttk.Label(main_frame, text="🎬 HLS Converter Pro 2026", style="Title.TLabel").pack(pady=(0, 20)) self.notebook = ttk.Notebook(main_frame) self.notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 15)) # Tab General tab_general = ttk.Frame(self.notebook, padding="10") self.notebook.add(tab_general, text="General") token_frame = ttk.LabelFrame(tab_general, text="GitHub Token", padding="15", style="Card.TFrame") token_frame.pack(fill=tk.X, pady=(0, 15)) ttk.Entry(token_frame, textvariable=self.github_token, width=70, show="*").pack(fill=tk.X) mode_frame = ttk.LabelFrame(tab_general, text="Modo", padding="15", style="Card.TFrame") mode_frame.pack(fill=tk.X, pady=(0, 15)) ttk.Radiobutton(mode_frame, text="Normal (un video, repo existente)", variable=self.mode, value="normal", command=self.toggle_mode).pack(anchor=tk.W, pady=3) ttk.Radiobutton(mode_frame, text="Bulk (múltiples videos, crea repos automáticamente)", variable=self.mode, value="bulk", command=self.toggle_mode).pack(anchor=tk.W, pady=3) conv_frame = ttk.LabelFrame(tab_general, text="Conversión", padding="15", style="Card.TFrame") conv_frame.pack(fill=tk.X, pady=(0, 15)) ttk.Radiobutton(conv_frame, text="⚡ Copy Video + Copy Audio (más rápido)", variable=self.conversion_mode, value="copy_all").pack(anchor=tk.W, pady=3) ttk.Radiobutton(conv_frame, text="⚡ Copy Video + MP3 Audio (recomendado)", variable=self.conversion_mode, value="copy_video").pack(anchor=tk.W, pady=3) ttk.Radiobutton(conv_frame, text="🔄 Convertir todo (más lento)", variable=self.conversion_mode, value="convert").pack(anchor=tk.W, pady=3) audio_frame = ttk.Frame(conv_frame) audio_frame.pack(fill=tk.X, pady=(10, 0)) ttk.Label(audio_frame, text="Bitrate MP3:").pack(side=tk.LEFT, padx=(0, 10)) ttk.Combobox(audio_frame, textvariable=self.audio_bitrate, values=["128k", "192k", "256k", "320k"], state="readonly", width=10).pack(side=tk.LEFT) adv_frame = ttk.LabelFrame(tab_general, text="Avanzado", padding="15", style="Card.TFrame") adv_frame.pack(fill=tk.X) batch_frame = ttk.Frame(adv_frame) batch_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Label(batch_frame, text="Lote Video:").grid(row=0, column=0, padx=(0, 10)) ttk.Spinbox(batch_frame, from_=10, to=200, textvariable=self.video_batch_size, width=10).grid(row=0, column=1, padx=(0, 20)) ttk.Label(batch_frame, text="Lote Audio:").grid(row=0, column=2, padx=(0, 10)) ttk.Spinbox(batch_frame, from_=10, to=200, textvariable=self.audio_batch_size, width=10).grid(row=0, column=3) ttk.Checkbutton(adv_frame, text="Borrar archivos locales después de subir", variable=self.delete_local).pack(anchor=tk.W) # Tab Normal tab_normal = ttk.Frame(self.notebook, padding="10") self.notebook.add(tab_normal, text="Modo Normal") input_frame = ttk.LabelFrame(tab_normal, text="Fuente", padding="15", style="Card.TFrame") input_frame.pack(fill=tk.X, pady=(0, 15)) type_frame = ttk.Frame(input_frame) type_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Radiobutton(type_frame, text="📁 Archivo", variable=self.input_type, value="file", command=self.toggle_input).pack(side=tk.LEFT, padx=(0, 20)) ttk.Radiobutton(type_frame, text="🌐 URL", variable=self.input_type, value="url", command=self.toggle_input).pack(side=tk.LEFT) self.file_frame = ttk.Frame(input_frame) ttk.Button(self.file_frame, text="📁 Seleccionar", command=self.browse_file).pack(anchor=tk.W, pady=(0, 10)) ttk.Label(self.file_frame, textvariable=self.video_file, foreground="#888").pack(fill=tk.X) self.url_frame = ttk.Frame(input_frame) ttk.Label(self.url_frame, text="URL del video:").pack(anchor=tk.W, pady=(0, 5)) ttk.Entry(self.url_frame, textvariable=self.video_url, width=70).pack(fill=tk.X) repo_frame = ttk.LabelFrame(tab_normal, text="Repositorio GitHub (existente)", padding="15", style="Card.TFrame") repo_frame.pack(fill=tk.X) ttk.Entry(repo_frame, textvariable=self.repo_url, width=70).pack(fill=tk.X) # Tab Bulk tab_bulk = ttk.Frame(self.notebook, padding="10") self.notebook.add(tab_bulk, text="Modo Bulk") bulk_frame = ttk.LabelFrame(tab_bulk, text="Lista de Videos", padding="15", style="Card.TFrame") bulk_frame.pack(fill=tk.BOTH, expand=True) ttk.Label(bulk_frame, text="Cada video creará su propio repositorio automáticamente", foreground="#ffff00").pack(anchor=tk.W, pady=(0, 10)) self.bulk_list = tk.Listbox(bulk_frame, height=15, bg="#2d2d2d", fg="#e0e0e0") self.bulk_list.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) btn_bulk = ttk.Frame(bulk_frame) btn_bulk.pack(fill=tk.X) ttk.Button(btn_bulk, text="+ Archivos", command=self.add_bulk_files).pack(side=tk.LEFT, padx=(0, 5)) ttk.Button(btn_bulk, text="+ URLs", command=self.add_bulk_urls).pack(side=tk.LEFT, padx=(0, 5)) ttk.Button(btn_bulk, text="Remover", command=self.remove_bulk).pack(side=tk.LEFT, padx=(0, 5)) ttk.Button(btn_bulk, text="Limpiar", command=self.clear_bulk).pack(side=tk.LEFT) # Botones principales btn_frame = ttk.Frame(main_frame) btn_frame.pack(pady=15) self.start_btn = ttk.Button(btn_frame, text="🚀 Iniciar", style="Accent.TButton", command=self.start_processing) self.start_btn.pack(side=tk.LEFT, padx=(0, 10), ipady=8, ipadx=25) self.btn_folder = ttk.Button(btn_frame, text="📂 Abrir Carpeta", command=self.open_folder, state=tk.DISABLED) self.btn_folder.pack(side=tk.LEFT, padx=(0, 10), ipady=8, ipadx=20) self.btn_link = ttk.Button(btn_frame, text="📋 Copiar Link", command=self.copy_link, state=tk.DISABLED) self.btn_link.pack(side=tk.LEFT, ipady=8, ipadx=20) # Log log_frame = ttk.LabelFrame(main_frame, text="Registro", padding="15", style="Card.TFrame") log_frame.pack(fill=tk.BOTH, expand=True) self.log_text = tk.Text(log_frame, height=15, bg="#1e1e1e", fg="#00ff9d", font=("Consolas", 9), wrap=tk.WORD) self.log_text.pack(fill=tk.BOTH, expand=True) self.toggle_input() self.toggle_mode() def toggle_mode(self): if self.mode.get() == "normal": self.notebook.select(1) else: self.notebook.select(2) def toggle_input(self): self.file_frame.pack_forget() self.url_frame.pack_forget() if self.input_type.get() == "file": self.file_frame.pack(fill=tk.X) else: self.url_frame.pack(fill=tk.X) def browse_file(self): filename = filedialog.askopenfilename( title="Seleccionar video", filetypes=[("Videos", "*.mp4 *.mkv *.avi *.mov *.ts"), ("Todos", "*.*")] ) if filename: self.video_file.set(filename) self.log(f"✓ Archivo: {os.path.basename(filename)}") def add_bulk_files(self): files = filedialog.askopenfilenames( title="Seleccionar videos", filetypes=[("Videos", "*.mp4 *.mkv *.avi *.mov *.ts"), ("Todos", "*.*")] ) for f in files: if f not in [s['value'] for s in self.bulk_sources]: self.bulk_sources.append({"type": "file", "value": f}) self.bulk_list.insert(tk.END, f"Archivo: {os.path.basename(f)}") def add_bulk_urls(self): dialog = tk.Toplevel(self.root) dialog.title("Añadir URLs") dialog.geometry("600x400") ttk.Label(dialog, text="URLs (una por línea):").pack(pady=10) text = tk.Text(dialog, height=20) text.pack(fill=tk.BOTH, expand=True, padx=15, pady=10) def add(): lines = [l.strip() for l in text.get("1.0", tk.END).splitlines() if l.strip()] for url in lines: if url not in [s['value'] for s in self.bulk_sources]: self.bulk_sources.append({"type": "url", "value": url}) self.bulk_list.insert(tk.END, f"URL: {url[:60]}...") dialog.destroy() ttk.Button(dialog, text="Añadir", command=add).pack(pady=10) def remove_bulk(self): selected = self.bulk_list.curselection() for i in reversed(selected): del self.bulk_sources[i] self.bulk_list.delete(i) def clear_bulk(self): self.bulk_sources.clear() self.bulk_list.delete(0, tk.END) def open_folder(self): if self.last_output_dir and os.path.exists(self.last_output_dir): if platform.system() == "Windows": os.startfile(self.last_output_dir) else: subprocess.call(["open" if platform.system() == "Darwin" else "xdg-open", self.last_output_dir]) def copy_link(self): if self.last_direct_link: self.root.clipboard_clear() self.root.clipboard_append(self.last_direct_link) messagebox.showinfo("Copiado", f"Link copiado:\n\n{self.last_direct_link}") def log(self, msg): self.log_text.insert(tk.END, f"{msg}\n") self.log_text.see(tk.END) self.root.update_idletasks() def show_progress(self): if self.progress_popup: return self.step_var = tk.StringVar(value="Iniciando...") self.percent_var = tk.StringVar(value="0%") self.progress_var = tk.DoubleVar(value=0) popup = tk.Toplevel(self.root) popup.title("Progreso") popup.geometry("500x200") popup.configure(bg="#1e1e1e") popup.transient(self.root) popup.grab_set() frame = ttk.Frame(popup, padding="25") frame.pack(fill=tk.BOTH, expand=True) ttk.Label(frame, textvariable=self.step_var, font=("Segoe UI", 11)).pack(pady=(0, 15)) ttk.Progressbar(frame, variable=self.progress_var, length=420, style="Custom.Horizontal.TProgressbar").pack(pady=(0, 15)) ttk.Label(frame, textvariable=self.percent_var, font=("Segoe UI", 16, "bold"), foreground="#00ff9d").pack() self.progress_popup = popup def close_progress(self): if self.progress_popup: self.progress_popup.destroy() self.progress_popup = None def update_progress(self, value, step=""): if not self.progress_popup: return overall = self.base_progress + (value / 100.0 * self.progress_span) overall = min(100.0, max(0.0, overall)) self.progress_var.set(overall) self.percent_var.set(f"{int(overall)}%") if step: self.step_var.set(step) self.root.update_idletasks() def is_url(self, source): return str(source).startswith(('http://', 'https://')) def get_url_headers(self): return ['-user_agent', 'Mozilla/5.0', '-headers', 'Referer: https://rumble.com/\r\n'] def run_cmd(self, cmd, source=""): if self.is_url(source): try: i = cmd.index('-i') cmd = cmd[:i] + self.get_url_headers() + cmd[i:] except ValueError: pass return subprocess.run(cmd, capture_output=True, text=True, creationflags=self.subprocess_flags) def get_duration(self, source): try: cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', '-i', source] result = self.run_cmd(cmd, source) if result.returncode == 0 and result.stdout.strip(): return float(result.stdout.strip()) except: pass return None def get_metadata(self, source): try: cmd = ['ffprobe', '-v', 'error', '-show_format', '-print_format', 'json', '-i', source] result = self.run_cmd(cmd, source) if result.returncode != 0: return None, None data = json.loads(result.stdout) tags = data.get('format', {}).get('tags', {}) title = tags.get('title', '').strip() or None show = tags.get('show', '').strip() or None return title, show except: return None, None def get_filename_from_url(self, url): parsed = urlparse(url) path = unquote(parsed.path) basename = os.path.basename(path) if basename and '.' in basename: return Path(basename).stem return None def build_name_for_bulk(self, source, is_file): if is_file: base_name = Path(source).stem else: base_name = self.get_filename_from_url(source) or "video" base_name = re.sub(r'\.(mp4|mkv|avi|mov|ts|webm)$', '', base_name, flags=re.IGNORECASE) title, show = self.get_metadata(source) parts = [base_name] if title: title_clean = re.sub(r'[^\w\s\-\(\)]', '_', title) title_clean = re.sub(r'\s+', '_', title_clean).strip('_') parts.append(title_clean) if show: show_clean = re.sub(r'[^\w\s\-\(\)]', '_', show) show_clean = re.sub(r'\s+', '_', show_clean).strip('_') parts.append(show_clean) final_name = '_'.join(parts) self.log(f"📝 Nombre generado: {final_name}") if title: self.log(f" • Title: {title}") if show: self.log(f" • Show: {show}") return final_name def clean_for_folder(self, name): name = re.sub(r'[<>:"/\\|?*]', '_', name) return name.strip()[:200] def clean_for_repo(self, name): name = re.sub(r'[^a-zA-Z0-9_-]', '-', name) name = re.sub(r'-+', '-', name) return name.strip('-')[:100] def get_audio_streams(self, source): try: cmd = ['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index:stream_tags=language', '-of', 'json', '-i', source] result = self.run_cmd(cmd, source) data = json.loads(result.stdout) streams = [] for s in data.get('streams', []): lang = s.get('tags', {}).get('language', 'und') streams.append({'index': s['index'], 'lang': lang}) return streams except: return [] def convert_audio(self, source, stream_index, output_dir, audio_file, segment_file): mode = self.conversion_mode.get() if mode == "copy_all": codec = ['-c:a', 'copy'] else: codec = ['-c:a', 'libmp3lame', '-b:a', self.audio_bitrate.get()] cmd = ['ffmpeg', '-i', source, '-map', f'0:{stream_index}', *codec, '-vn', '-hls_time', '10', '-hls_list_size', '0', '-hls_segment_filename', str(segment_file), '-start_number', '1', '-hls_playlist_type', 'vod', str(audio_file), '-y', '-loglevel', 'error'] result = self.run_cmd(cmd, source) if result.returncode != 0: raise Exception(f"Error audio: {result.stderr}") def convert_video(self, source, output_dir): video_file = output_dir / "index-v1-a1.m3u8" segment_file = output_dir / "index-v1-a1_%03d.ts" if self.conversion_mode.get() in ["copy_all", "copy_video"]: codec = ['-c:v', 'copy'] else: codec = ['-c:v', 'libx264', '-preset', 'fast', '-crf', '23'] cmd = ['ffmpeg', '-i', source, *codec, '-map', '0:v?', '-an', '-hls_time', '10', '-hls_list_size', '0', '-hls_segment_filename', str(segment_file), '-start_number', '1', '-hls_playlist_type', 'vod', str(video_file), '-y', '-loglevel', 'error'] result = self.run_cmd(cmd, source) if result.returncode != 0: raise Exception(f"Error video: {result.stderr}") def post_process_playlist(self, path: Path): if not path.exists(): return with open(path, 'r', encoding='utf-8') as f: lines = f.readlines() if not lines or lines[0].strip() != '#EXTM3U': return new_lines = ['#EXTM3U\n'] new_lines.append('#EXT-X-ALLOW-CACHE:YES\n') new_lines.append('#EXT-X-VERSION:3\n') for line in lines[1:]: new_lines.append(line) with open(path, 'w', encoding='utf-8') as f: f.writelines(new_lines) def create_master(self, output_dir, renditions): master = output_dir / "master.m3u8" with open(master, 'w', encoding='utf-8') as f: f.write("#EXTM3U\n") for i, rend in enumerate(renditions, 1): f.write(f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio0",NAME="{rend["name"]}",LANGUAGE="{rend["lang"]}",' f'AUTOSELECT={rend["autoselect"]},DEFAULT={rend["default"]},CHANNELS="2",URI="index-a{i}.m3u8"\n') bandwidth = 1381285 frame_rate = "24.000" codecs = "avc1.64001f,mp3" # MP3 f.write(f'#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH={bandwidth},RESOLUTION=1920x1080,FRAME-RATE={frame_rate},' f'CODECS="{codecs}",VIDEO-RANGE=SDR,AUDIO="audio0"\n') f.write("index-v1-a1.m3u8\n") f.write(f'#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH={bandwidth},RESOLUTION=1280x720,FRAME-RATE={frame_rate},' f'CODECS="{codecs}",VIDEO-RANGE=SDR,AUDIO="audio0"\n') f.write("index-v1-a1.m3u8\n") def create_repo(self, name, token): url = "https://api.github.com/user/repos" headers = {"Authorization": f"token {token}"} data = {"name": name, "private": False} response = requests.post(url, headers=headers, json=data, timeout=30) if response.status_code == 201: repo_data = response.json() return repo_data['clone_url'], repo_data['html_url'] elif response.status_code == 422: r = requests.get("https://api.github.com/user", headers=headers) username = r.json()['login'] return f"https://github.com/{username}/{name}.git", f"https://github.com/{username}/{name}" else: raise Exception(f"Error creando repo: {response.status_code}") def git_upload(self, output_dir, repo_url, token, repo_name): git_dir = str(output_dir) if '@' not in repo_url: remote = repo_url.replace('https://', f'https://{token}@') else: remote = repo_url subprocess.run(['git', 'init'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'branch', '-M', 'main'], cwd=git_dir, creationflags=self.subprocess_flags) subprocess.run(['git', 'remote', 'remove', 'origin'], cwd=git_dir, stderr=subprocess.DEVNULL, creationflags=self.subprocess_flags) subprocess.run(['git', 'remote', 'add', 'origin', remote], cwd=git_dir, check=True, creationflags=self.subprocess_flags) files = [f for f in os.listdir(git_dir) if os.path.isfile(os.path.join(git_dir, f))] m3u8_files = [f for f in files if f.endswith('.m3u8')] video_ts = [f for f in files if f.startswith('index-v1-a1') and f.endswith('.ts')] audio_ts = [f for f in files if f.startswith('index-a') and f.endswith('.ts') and not f.startswith('index-v')] if m3u8_files: subprocess.run(['git', 'add'] + m3u8_files, cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'commit', '-m', 'Add playlists'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'push', '-f', 'origin', 'main'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) self.update_progress(20, "Playlists subidas") if video_ts: batch_size = self.video_batch_size.get() for i in range(0, len(video_ts), batch_size): batch = video_ts[i:i+batch_size] subprocess.run(['git', 'add'] + batch, cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'commit', '-m', f'Video batch {i//batch_size + 1}'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'push', '-f', 'origin', 'main'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) progress = 20 + (50 * (i + len(batch)) / len(video_ts)) self.update_progress(progress, f"Video batch {i//batch_size + 1}") if audio_ts: batch_size = self.audio_batch_size.get() for i in range(0, len(audio_ts), batch_size): batch = audio_ts[i:i+batch_size] subprocess.run(['git', 'add'] + batch, cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'commit', '-m', f'Audio batch {i//batch_size + 1}'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) subprocess.run(['git', 'push', '-f', 'origin', 'main'], cwd=git_dir, check=True, creationflags=self.subprocess_flags) progress = 70 + (30 * (i + len(batch)) / len(audio_ts)) self.update_progress(progress, f"Audio batch {i//batch_size + 1}") def process_video(self, source, is_file, is_bulk): self.log(f"\n{'='*60}") self.log(f"{'📁 ARCHIVO' if is_file else '🌐 URL'}: {os.path.basename(source) if is_file else source[:80]}") if is_bulk: output_name = self.build_name_for_bulk(source, is_file) else: if is_file: output_name = Path(source).stem else: output_name = self.get_filename_from_url(source) or "video" folder_name = self.clean_for_folder(output_name) repo_name = self.clean_for_repo(output_name) if is_file: base_dir = Path(source).parent else: base_dir = Path.cwd() self.output_dir = base_dir / f"{folder_name}_hls" self.output_dir.mkdir(exist_ok=True) self.log(f"📁 Carpeta: {self.output_dir.name}") self.update_progress(5, "Analizando...") audio_streams = self.get_audio_streams(source) self.log(f"🎵 Audios detectados: {len(audio_streams)}") audio_streams.sort(key=lambda x: 0 if x['lang'] in ['eng', 'en'] else 1) renditions = [] for idx, stream in enumerate(audio_streams): lang = stream['lang'] name = self.lang_names.get(lang, lang.upper()) if lang == 'und': name = 'Principal' renditions.append({ 'name': name, 'lang': lang, 'default': "YES" if idx == 0 else "NO", 'autoselect': "YES" if idx == 0 else "NO", 'stream_index': stream['index'] }) if len(renditions) == 1: self.log("Solo un audio → añadiendo uno falso duplicado") renditions.append({ 'name': 'Original', 'lang': 'und', 'default': "NO", 'autoselect': "NO", 'stream_index': audio_streams[0]['index'] }) self.update_progress(10, "Convirtiendo audios...") for i, rend in enumerate(renditions, 1): audio_file = self.output_dir / f"index-a{i}.m3u8" segment_file = self.output_dir / f"index-a{i}_%03d.ts" self.convert_audio(source, rend['stream_index'], self.output_dir, audio_file, segment_file) self.update_progress(50, "Convirtiendo video...") self.convert_video(source, self.output_dir) self.update_progress(75, "Ajustando playlists...") for playlist in self.output_dir.glob("index-*.m3u8"): self.post_process_playlist(playlist) self.update_progress(80, "Creando master.m3u8...") self.create_master(self.output_dir, renditions) self.update_progress(85, "Subiendo a GitHub...") token = self.github_token.get().strip() if is_bulk: clone_url, html_url = self.create_repo(repo_name, token) self.log(f"✓ Repo creado: {html_url}") else: repo_url = self.repo_url.get().strip() if not repo_url.endswith('.git'): repo_url += '.git' clone_url = repo_url html_url = repo_url.replace('.git', '') self.git_upload(self.output_dir, clone_url, token, repo_name) raw_url = html_url.replace('github.com', 'raw.githubusercontent.com') + '/main/master.m3u8' self.last_direct_link = raw_url self.log(f"\n{'='*60}") self.log(f"✅ COMPLETADO") self.log(f"📦 Repo: {html_url}") self.log(f"🎬 Link: {raw_url}") self.log(f"{'='*60}\n") if self.delete_local.get(): try: shutil.rmtree(self.output_dir, onerror=lambda func, path, exc: (os.chmod(path, stat.S_IWRITE), func(path))) self.log("🗑️ Archivos locales borrados") except: pass self.last_output_dir = str(self.output_dir) self.update_progress(100, "Completado") def start_processing(self): if self.processing: return token = self.github_token.get().strip() if not token: messagebox.showerror("Error", "Token de GitHub requerido") return self.processing = True self.start_btn.config(state=tk.DISABLED) self.show_progress() def process(): try: if self.mode.get() == "normal": if self.input_type.get() == "file": source = self.video_file.get() if not source or not os.path.exists(source): raise Exception("Selecciona un archivo válido") is_file = True else: source = self.video_url.get().strip() if not source: raise Exception("Ingresa una URL válida") is_file = False if not self.repo_url.get().strip(): raise Exception("Ingresa URL del repositorio") self.process_video(source, is_file, False) else: if not self.bulk_sources: raise Exception("Agrega videos en modo bulk") if not HAS_REQUESTS: raise Exception("Instala 'requests' para modo bulk") total = len(self.bulk_sources) for idx, item in enumerate(self.bulk_sources, 1): self.log(f"\n{'='*60}") self.log(f"VIDEO {idx}/{total}") self.log(f"{'='*60}") source = item['value'] is_file = item['type'] == 'file' self.base_progress = ((idx - 1) / total) * 100 self.progress_span = 100 / total try: self.process_video(source, is_file, True) except Exception as e: self.log(f"❌ Error: {str(e)}") self.log(f"\n🎉 BULK COMPLETADO: {total} videos procesados") self.root.after(0, lambda: self.btn_folder.config(state=tk.NORMAL)) self.root.after(0, lambda: self.btn_link.config(state=tk.NORMAL)) except Exception as e: self.log(f"\n❌ ERROR: {str(e)}") self.root.after(0, lambda msg=str(e): messagebox.showerror("Error", msg)) finally: self.processing = False self.root.after(0, lambda: self.start_btn.config(state=tk.NORMAL)) self.close_progress() threading.Thread(target=process, daemon=True).start() if __name__ == "__main__": root = TkinterDnD.Tk() if DRAG_DROP_AVAILABLE else tk.Tk() app = HLSConverterApp(root) root.mainloop()