| 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") |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 = 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 = 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 = 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) |
|
|
| |
| 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_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" |
| 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() |