Video Tutorial

Documentación

¿Buscas descargar videos de YouTube, TikTok, Instagram o cualquier otra red social directamente en tu computadora, gratis y sin límites? En este tutorial te muestro cómo hacerlo usando Python y yt-dlp, una herramienta de código abierto compatible con más de 1000 sitios, con una interfaz gráfica sencilla que no requiere usar la terminal.

¿Qué necesitas instalar?

  • Python 3.8 o superior — el lenguaje en el que está hecho el script
  • FFmpeg — necesario para convertir y fusionar el audio y video
  • yt-dlp — el motor que se conecta a YouTube, TikTok, Instagram y más

Instalación en Windows

Opción A: Instalador automático (más fácil)

Descarga el proyecto y haz doble clic en el archivo instalador.bat. Él instalará FFmpeg y yt-dlp automáticamente sin que tengas que hacer nada más.

Opción B: Instalación manual paso a paso

Paso 1 — Instalar Python

  1. Entra a python.org/downloads y descarga la última versión.
  2. Abre el instalador y marca la casilla "Add Python to PATH" antes de continuar.
  3. Haz clic en Install Now y espera que termine.

Paso 2 — Instalar FFmpeg

Abre el Símbolo del sistema (busca "cmd" en el menú inicio) y pega esto:

winget install --id Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements

Paso 3 — Instalar yt-dlp

En el mismo CMD, escribe:

py -m pip install yt-dlp

Paso 4 — Ejecutar el descargador

Haz doble clic en el archivo social_downloader_gui.py o ejecútalo con:

python social_downloader_gui.py

Instalación en macOS

Paso 1 — Instalar Python

Descarga desde python.org o usa Homebrew:

brew install python

Paso 2 — Instalar FFmpeg

brew install ffmpeg

Paso 3 — Instalar yt-dlp

pip3 install yt-dlp

Paso 4 — Ejecutar el descargador

python3 social_downloader_gui.py

Instalación en Linux (Ubuntu / Debian)

Paso 1 — Instalar Python

Verifica si ya lo tienes:

python3 --version

Si no está instalado:

sudo apt update && sudo apt install python3 python3-pip -y

Paso 2 — Instalar FFmpeg

sudo apt update && sudo apt install ffmpeg -y

Paso 3 — Instalar yt-dlp

pip3 install yt-dlp

Paso 4 — Ejecutar el descargador

python3 social_downloader_gui.py

¿Cómo descargar un video?

  1. Abre la aplicación.
  2. Pega la URL del video (de YouTube, TikTok, Instagram, etc.) en el campo de texto.
  3. Haz clic en Cargar metadatos para ver los formatos y calidades disponibles.
  4. Selecciona la calidad que quieras de la lista.
  5. Elige una carpeta donde guardar el archivo con el botón Cambiar.
  6. Haz clic en Descargar video para obtener el MP4 o en Descargar audio para el MP3.

Sitios web compatibles

  • YouTube (incluyendo videos en 4K)
  • TikTok (sin marca de agua)
  • Instagram (Reels, publicaciones, Stories)
  • Facebook
  • X / Twitter
  • Twitch
  • Vimeo
  • SoundCloud
  • Reddit
  • Dailymotion
  • Rumble
  • Y más de 1000 sitios compatibles con yt-dlp

Librerías Necesarias

winget install --id Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements
py -m pip install yt-dlp

Código Fuente Principal

#!/usr/bin/env python3
"""Descargador GUI para redes sociales usando yt-dlp y ffmpeg."""

import os
import re
import shutil
import sys
import threading
from pathlib import Path
from tkinter import Tk, StringVar, BooleanVar, filedialog, messagebox
from tkinter import ttk

try:
    import yt_dlp
except ImportError:
    print("Error: la biblioteca 'yt-dlp' no está instalada. Ejecuta 'pip install yt-dlp'.")
    sys.exit(1)

URL_PATTERN = re.compile(r"^https?://")


class SocialDownloaderGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("Social Downloader Pro")
        self.root.geometry("800x700")
        self.root.minsize(600, 500)
        self.root.resizable(True, True)

        self.url_var = StringVar()
        self.output_dir = None
        self.video_info = None
        self.formats = []
        self.download_audio_only = BooleanVar(value=False)
        self.ffmpeg_installed = self._check_ffmpeg_installed()

        self._apply_dark_theme()
        self._build_ui()

    def _apply_dark_theme(self) -> None:
        style = ttk.Style(self.root)
        style.theme_use("clam")
        style.configure("TFrame", background="#252525")
        style.configure("TLabel", background="#252525", foreground="#EEEEEE")
        style.configure("TButton", background="#2B2B2B", foreground="#FFFFFF", borderwidth=1)
        style.map("TButton",
                  background=[("active", "#3C3C3C"), ("pressed", "#1F1F1F")],
                  foreground=[("disabled", "#888888")])
        style.configure("TEntry", fieldbackground="#333333", background="#333333", foreground="#FFFFFF")
        style.configure("Treeview",
                        background="#1F1F1F",
                        fieldbackground="#1F1F1F",
                        foreground="#FFFFFF",
                        rowheight=24,
                        bordercolor="#444444",
                        lightcolor="#444444",
                        darkcolor="#444444")
        style.configure("Treeview.Heading",
                        background="#2B2B2B",
                        foreground="#FFFFFF",
                        relief="flat")
        style.map("Treeview.Heading",
                  background=[("active", "#3C3C3C")])
        style.configure("Custom.Horizontal.TProgressbar",
                        troughcolor="#2B2B2B",
                        background="#4CAF50",
                        bordercolor="#444444",
                        lightcolor="#4CAF50",
                        darkcolor="#2E7D32")
        style.configure("ProgressText.TLabel",
                        background="#252525",
                        foreground="#FFFFFF")
        style.configure("ProcessingText.TLabel",
                        background="#252525",
                        foreground="#FFCA28",
                        font=(None, 11, "bold"))
        self.root.configure(bg="#252525")

    def _build_ui(self) -> None:
        # Top container for static elements
        top_container = ttk.Frame(self.root)
        top_container.pack(side="top", fill="x")

        frame_top = ttk.Frame(top_container, padding=(16, 16, 16, 4))
        frame_top.pack(fill="x")
        frame_top.columnconfigure(1, weight=1)

        ttk.Label(frame_top, text="URL del video:", font=(None, 10, "bold")).grid(row=0, column=0, sticky="w")
        url_entry = ttk.Entry(frame_top, textvariable=self.url_var)
        url_entry.grid(row=0, column=1, padx=(8, 0), sticky="ew")
        url_entry.focus()

        load_button = ttk.Button(frame_top, text="Cargar metadatos", command=self.load_metadata)
        load_button.grid(row=0, column=2, padx=(8, 0))

        output_frame = ttk.Frame(top_container, padding=(16, 0, 16, 4))
        output_frame.pack(fill="x")

        ttk.Label(output_frame, text="Carpeta de salida:").grid(row=0, column=0, sticky="w")
        self.output_label = ttk.Label(output_frame, text="Ninguna (Seleccione una carpeta)", foreground="#FFAA00")
        self.output_label.grid(row=0, column=1, sticky="w", padx=(8, 0))
        select_folder_button = ttk.Button(output_frame, text="Cambiar", command=self.choose_output_folder)
        select_folder_button.grid(row=0, column=2, padx=(8, 0))

        # ── Panel de plataformas soportadas ──────────────────────────────────
        platforms_frame = ttk.LabelFrame(
            top_container, text="  🌐 Plataformas soportadas  ",
            padding=(10, 4, 10, 4)
        )
        platforms_frame.pack(fill="x", padx=16, pady=(4, 0))

        PLATFORMS = [
            ("▶ YouTube",       "youtube.com"),
            ("📸 Instagram",     "instagram.com"),
            ("🎵 TikTok",       "tiktok.com"),
            ("🐦 X (Twitter)",  "x.com"),
            ("📘 Facebook",     "facebook.com"),
            ("🎮 Twitch",       "twitch.tv"),
            ("🎬 Vimeo",        "vimeo.com"),
            ("🎙 SoundCloud",  "soundcloud.com"),
            ("🔴 Reddit",       "reddit.com"),
            ("📺 Dailymotion",  "dailymotion.com"),
            ("🎞 Rumble",      "rumble.com"),
            ("➕ Y más...",     "+1000 sitios"),
        ]

        cols = 4
        for idx, (name, site) in enumerate(PLATFORMS):
            row_idx = idx // cols
            col_idx = idx % cols
            cell = ttk.Frame(platforms_frame)
            cell.grid(row=row_idx, column=col_idx, sticky="w", padx=(0, 10), pady=1)
            platforms_frame.columnconfigure(col_idx, weight=1)
            ttk.Label(cell, text=name, font=(None, 9, "bold"), foreground="#CCDDFF").pack(anchor="w")
            ttk.Label(cell, text=site, font=(None, 8), foreground="#888888").pack(anchor="w")

        # ── Separador ────────────────────────────────────────────────────────
        self.root.configure(bg="#252525")
        separator = ttk.Separator(top_container, orient="horizontal")
        separator.pack(fill="x", pady=8, padx=16)

        info_frame = ttk.Frame(top_container, padding=(16, 0, 16, 4))
        info_frame.pack(fill="x")

        self.title_label = ttk.Label(info_frame, text="Título: -")
        self.title_label.pack(anchor="w")
        self.source_label = ttk.Label(info_frame, text="Fuente: -")
        self.source_label.pack(anchor="w", pady=(2, 0))
        self.selection_hint = ttk.Label(
            info_frame,
            text="Seleccione un formato de video. El video se guardará en MP4 (H.264 + AAC) para máxima compatibilidad con Windows, VLC y cualquier dispositivo.",
            foreground="#AAAAAA", justify="left"
        )
        self.selection_hint.pack(anchor="w", pady=(2, 0))

        def on_info_resize(event):
            w = max(200, event.width - 10)
            self.title_label.config(wraplength=w)
            self.source_label.config(wraplength=w)
            self.selection_hint.config(wraplength=w)
        info_frame.bind('', on_info_resize)

        # Bottom container for action and progress
        bottom_container = ttk.Frame(self.root, padding=(16, 8, 16, 16))
        bottom_container.pack(side="bottom", fill="x")

        action_frame = ttk.Frame(bottom_container)
        action_frame.pack(fill="x")
        action_frame.columnconfigure(0, weight=1)

        btn_frame = ttk.Frame(action_frame)
        btn_frame.grid(row=0, column=0, sticky="w")
        
        self.video_button = ttk.Button(btn_frame, text="Descargar video", command=self.start_video_download)
        self.video_button.pack(side="left", padx=(0, 8))

        self.audio_button = ttk.Button(btn_frame, text="Descargar audio (MP3)", command=self.start_audio_download)
        self.audio_button.pack(side="left")

        self.status_label = ttk.Label(action_frame, text="Estado: esperando URL...", style="ProgressText.TLabel", anchor="w", justify="left")
        self.status_label.grid(row=1, column=0, sticky="we", pady=(8, 4))
        
        def on_status_resize(event):
            w = max(200, event.width - 10)
            self.status_label.config(wraplength=w)
        action_frame.bind('', on_status_resize)

        self.progress_bar = ttk.Progressbar(bottom_container, orient="horizontal", mode="determinate", maximum=100, style="Custom.Horizontal.TProgressbar")
        self.progress_bar.pack(fill="x", pady=(0, 4))
        
        self.progress_text = ttk.Label(bottom_container, text="", style="ProgressText.TLabel", anchor="w", justify="left")
        self.progress_text.pack(fill="x")

        # The grid container goes in the middle and expands
        grid_frame = ttk.Frame(self.root, padding=(16, 4, 16, 4))
        grid_frame.pack(side="top", fill="both", expand=True)

        columns = ("id", "ext", "res", "fps", "size", "type", "note")
        self.format_tree = ttk.Treeview(grid_frame, columns=columns, show="headings", height=8)
        self.format_tree.heading("id", text="ID")
        self.format_tree.heading("ext", text="Ext")
        self.format_tree.heading("res", text="Resolución")
        self.format_tree.heading("fps", text="FPS")
        self.format_tree.heading("size", text="Tamaño")
        self.format_tree.heading("type", text="Tipo")
        self.format_tree.heading("note", text="Nota")
        
        self.format_tree.column("id", width=70, anchor="center")
        self.format_tree.column("ext", width=50, anchor="center")
        self.format_tree.column("res", width=100, anchor="center")
        self.format_tree.column("fps", width=50, anchor="center")
        self.format_tree.column("size", width=80, anchor="center")
        self.format_tree.column("type", width=90, anchor="center")
        self.format_tree.column("note", width=180, anchor="w")
        
        self.format_tree.pack(side="left", fill="both", expand=True)

        scrollbar = ttk.Scrollbar(grid_frame, orient="vertical", command=self.format_tree.yview)
        scrollbar.pack(side="right", fill="y")
        self.format_tree.configure(yscrollcommand=scrollbar.set)

        if not self.ffmpeg_installed:
            self._set_status("ffmpeg no detectado. Instale ffmpeg y reinicie la aplicación.")
            self.video_button.config(state="disabled")
            self.audio_button.config(state="disabled")

    def validate_url(self, url: str) -> bool:
        return bool(url and URL_PATTERN.match(url.strip()))

    def choose_output_folder(self) -> None:
        selected = filedialog.askdirectory(initialdir=self.output_dir if self.output_dir else Path.cwd(), title="Seleccione carpeta de salida")
        if selected:
            self.output_dir = Path(selected)
            self.output_label.config(text=str(self.output_dir), foreground="#EEEEEE")

    def load_metadata(self) -> None:
        url = self.url_var.get().strip()
        if not self.validate_url(url):
            messagebox.showerror("URL inválida", "Por favor ingrese una URL válida que comience con http:// o https://")
            return

        self._set_status("Cargando metadatos...")
        self._set_progress(0)
        self._toggle_controls(state="disabled")
        threading.Thread(target=self._fetch_metadata, args=(url,), daemon=True).start()

    def _fetch_metadata(self, url: str) -> None:
        try:
            ydl_opts = {"quiet": True, "skip_download": True}
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                info = ydl.extract_info(url, download=False)

            self.video_info = info
            raw_formats = [
                f for f in info.get("formats", [])
                if f.get("vcodec") != "none" and str(f.get("ext", "")).lower() == "mp4"
            ]
            self.formats = self._filter_available_formats(ydl, raw_formats)
            self.formats = sorted(
                self.formats,
                key=lambda f: (
                    int(f.get("height") or 0),
                    0 if f.get("acodec") != "none" else 1,
                    int(f.get("fps") or 0),
                ),
                reverse=True,
            )
            self.root.after(0, self._populate_formats)
        except Exception as exc:
            self.root.after(0, lambda: self._handle_error(f"Error al cargar metadatos: {exc}"))

    def _populate_formats(self) -> None:
        self.format_tree.delete(*self.format_tree.get_children())
        if not self.video_info:
            self._handle_error("No se pudo obtener información del video.")
            return

        self.title_label.config(text=f"Título: {self.video_info.get('title', 'N/D')}")
        self.source_label.config(text=f"Fuente: {self.video_info.get('webpage_url', self.url_var.get())}")

        self.formats = sorted(
            self.formats,
            key=lambda fmt: (
                int(fmt.get("height") or 0),
                fmt.get("acodec") != "none",
                int(fmt.get("fps") or 0),
            ),
            reverse=True,
        )

        for fmt in self.formats:
            size_text = self._format_size(fmt.get("filesize") or fmt.get("filesize_approx"))
            resolution = self._format_resolution(fmt)
            fmt_type = self._get_format_type(fmt)
            note = fmt.get("format_note") or self._build_format_note(fmt)
            self.format_tree.insert(
                "",
                "end",
                iid=str(fmt["format_id"]),
                values=(
                    fmt["format_id"],
                    fmt.get("ext", "N/A"),
                    resolution,
                    fmt.get("fps", "N/A"),
                    size_text,
                    fmt_type,
                    note,
                ),
            )

        self._set_status("Metadatos cargados. Seleccione un formato y pulse 'Descargar video' o 'Descargar audio'.")
        self._toggle_controls(state="normal")

    def _set_status(self, message: str) -> None:
        self.status_label.config(text=f"Estado: {message}")

    def _set_progress(self, value: int) -> None:
        self.progress_bar['value'] = value
        if value == 0:
            self.status_label.config(style="ProgressText.TLabel")
            self.progress_text.config(style="ProgressText.TLabel")

    def _format_eta(self, eta_seconds: float | None) -> str:
        if eta_seconds is None:
            return ""
        try:
            eta = int(eta_seconds)
        except (TypeError, ValueError):
            return ""
        minutes, seconds = divmod(eta, 60)
        if minutes:
            return f"{minutes}m {seconds}s"
        return f"{seconds}s"

    def _filter_available_formats(self, ydl: yt_dlp.YoutubeDL, formats: list[dict]) -> list[dict]:
        """Devuelve solo los formatos que están realmente disponibles para descargar."""
        orientation = self._get_video_orientation(self.video_info or {})
        available = []
        for fmt in formats:
            if fmt.get("format_id") is None:
                continue
            width, height = self._parse_resolution(fmt)
            if width is None or height is None:
                continue
            if orientation and not self._format_matches_orientation(width, height, orientation):
                continue
            if self._format_selects(ydl, fmt.get("format_id")):
                fmt["_combine_with_audio"] = False
                available.append(fmt)
            elif fmt.get("acodec") == "none" and self._format_selects(ydl, f"{fmt.get('format_id')}+bestaudio/best"):
                fmt["_combine_with_audio"] = True
                available.append(fmt)
        return available

    def _format_selects(self, ydl: yt_dlp.YoutubeDL, format_id: str) -> bool:
        try:
            selector = ydl.build_format_selector(str(format_id))
            selected = ydl._select_formats(self.video_info.get("formats", []), selector)
            return bool(selected)
        except Exception:
            return False

    def _pick_best_audio_format(self) -> dict | None:
        audio_formats = [
            fmt for fmt in self.video_info.get("formats", [])
            if fmt.get("vcodec") == "none" and fmt.get("acodec") != "none"
        ]
        if not audio_formats:
            return None

        def sort_key(fmt: dict) -> tuple[int, bool, bool]:
            abr = int(fmt.get("abr") or 0)
            ext = str(fmt.get("ext", "")).lower()
            is_preferred_ext = ext in ("m4a", "mp4", "mov")
            is_aac = fmt.get("acodec") in ("aac", "mp4a.40.2")
            return abr, is_preferred_ext, is_aac

        return max(audio_formats, key=sort_key)

    def _choose_video_audio_format(self, selected_format: dict) -> tuple[str, str]:
        best_audio = self._pick_best_audio_format()
        if best_audio is None:
            return f"{selected_format['format_id']}+bestaudio/best", "mkv"

        audio_id = best_audio.get("format_id")
        if audio_id is None:
            return f"{selected_format['format_id']}+bestaudio/best", "mkv"

        audio_ext = str(best_audio.get("ext", "")).lower()
        if audio_ext in ("m4a", "mp4", "mov") or best_audio.get("acodec") in ("aac", "mp4a.40.2"):
            return f"{selected_format['format_id']}+{audio_id}", "mp4"
        return f"{selected_format['format_id']}+{audio_id}", "mkv"

    def _get_video_orientation(self, info: dict) -> str | None:
        width, height = self._parse_resolution(info)
        if width is None or height is None:
            return None
        return "portrait" if height >= width else "landscape"

    def _format_matches_orientation(self, width: int, height: int, orientation: str) -> bool:
        if orientation == "portrait":
            return height >= width
        return width >= height

    def _parse_resolution(self, fmt: dict) -> tuple[int | None, int | None]:
        width = fmt.get("width")
        height = fmt.get("height")
        if isinstance(width, int) and isinstance(height, int) and width > 0 and height > 0:
            return width, height
        resolution = fmt.get("resolution") or fmt.get("format_note") or ""
        match = re.search(r"(\d+)\s*[xX]\s*(\d+)", str(resolution))
        if match:
            return int(match.group(1)), int(match.group(2))
        return None, None

    def _format_resolution(self, fmt: dict) -> str:
        width, height = self._parse_resolution(fmt)
        if width is not None and height is not None:
            return f"{width}x{height}"
        return fmt.get("resolution") or fmt.get("format_note") or "N/A"

    def _build_format_note(self, fmt: dict) -> str:
        parts = []
        if fmt.get("acodec") != "none" and fmt.get("vcodec") != "none":
            parts.append("Video+Audio")
        elif fmt.get("vcodec") != "none":
            if fmt.get("_combine_with_audio"):
                parts.append("Video-only (combina con mejor audio)")
            else:
                parts.append("Video-only")
        if fmt.get("width") and fmt.get("height"):
            parts.append(f"{fmt['width']}x{fmt['height']}")
        if fmt.get("tbr"):
            parts.append(f"{int(fmt['tbr'])}kbps")
        if fmt.get("dynamic_range"):
            parts.append(fmt.get("dynamic_range"))
        return " | ".join(parts)

    def _get_format_type(self, fmt: dict) -> str:
        vcodec = fmt.get("vcodec")
        acodec = fmt.get("acodec")
        if vcodec != "none" and acodec != "none":
            return "Video+Audio"
        if vcodec != "none":
            return "Video-only"
        return "Audio-only"

    def _format_size(self, bytes_value) -> str:
        try:
            num = int(bytes_value)
        except (TypeError, ValueError):
            return "N/D"
        for unit in ["B", "KB", "MB", "GB"]:
            if num < 1024:
                return f"{num:.1f} {unit}"
            num /= 1024.0
        return f"{num:.1f} TB"

    def _check_ffmpeg_installed(self) -> bool:
        return bool(shutil.which("ffmpeg"))

    def _toggle_controls(self, state: str) -> None:
        for child in self.root.winfo_children():
            if isinstance(child, ttk.Frame):
                for widget in child.winfo_children():
                    if isinstance(widget, ttk.Button) or isinstance(widget, ttk.Entry):
                        widget.config(state=state)
        if not self.ffmpeg_installed:
            self.video_button.config(state="disabled")
            self.audio_button.config(state="disabled")
        self.root.update_idletasks()

    def start_video_download(self) -> None:
        if not self.output_dir:
            messagebox.showwarning("Carpeta no seleccionada", "Por favor seleccione una carpeta de salida antes de descargar.")
            return

        selected = self.format_tree.selection()
        if not selected:
            messagebox.showwarning("Formato no seleccionado", "Seleccione un formato de video antes de descargar.")
            return

        format_id = selected[0]
        self.download_audio_only.set(False)
        self._set_status("Iniciando descarga de video...")
        self._set_progress(0)
        self._toggle_controls(state="disabled")
        threading.Thread(target=self._download_video, args=(self.url_var.get().strip(), format_id), daemon=True).start()

    def start_audio_download(self) -> None:
        if not self.output_dir:
            messagebox.showwarning("Carpeta no seleccionada", "Por favor seleccione una carpeta de salida antes de descargar.")
            return

        if not self.validate_url(self.url_var.get().strip()):
            messagebox.showwarning("URL inválida", "Ingrese una URL válida antes de descargar audio.")
            return

        self.download_audio_only.set(True)
        self._set_status("Iniciando descarga de audio...")
        self._set_progress(0)
        self._toggle_controls(state="disabled")
        threading.Thread(target=self._download_audio, args=(self.url_var.get().strip(),), daemon=True).start()

    def _download_video(self, url: str, format_id: str) -> None:
        try:
            output_dir = self._ensure_output_dir()
            selected_format = next((f for f in self.formats if str(f["format_id"]) == str(format_id)), None)
            if selected_format is None:
                raise RuntimeError("Formato seleccionado no encontrado.")

            if selected_format.get("vcodec") == "none":
                raise RuntimeError("Debe seleccionar un formato de video, no solo audio.")

            # Siempre descargar video+audio y recodificar a H.264/AAC en MP4
            # para garantizar reproducción en Windows Media Player, VLC y
            # cualquier dispositivo sin instalar codecs adicionales.
            if selected_format.get("acodec") == "none":
                format_string = f"{format_id}+bestaudio/best"
            else:
                format_string = format_id

            ydl_opts = {
                "format": format_string,
                "outtmpl": str(output_dir / "%(title).120s [%(id)s].%(ext)s"),
                "noplaylist": True,
                "progress_hooks": [self._progress_hook],
                "merge_output_format": "mkv",  # Forzamos MKV primero para que yt-dlp NO omita la conversión a MP4
                "quiet": True,
                "ratelimit": None,
                # Re-codificar a H.264 + AAC para compatibilidad universal
                "postprocessors": [
                    {
                        "key": "FFmpegVideoConvertor",
                        "preferedformat": "mp4",
                    }
                ],
                "postprocessor_args": {
                    "FFmpegVideoConvertor": [
                        "-c:v", "libx264",
                        "-preset", "fast",
                        "-crf", "22",
                        "-c:a", "aac",
                        "-b:a", "192k",
                        "-movflags", "+faststart",
                        "-pix_fmt", "yuv420p",
                    ]
                },
            }
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                ydl.download([url])
            self.root.after(0, lambda: self._download_complete(
                "Video descargado correctamente.\n📁 Formato: MP4 · H.264 + AAC (compatible con Windows y VLC)"
            ))
        except Exception as exc:
            self.root.after(0, lambda: self._handle_error(f"Error al descargar video: {exc}"))

    def _download_audio(self, url: str) -> None:
        try:
            output_dir = self._ensure_output_dir()
            ydl_opts = {
                "format": "bestaudio/best",
                "outtmpl": str(output_dir / "%(title).120s [%(id)s].%(ext)s"),
                "noplaylist": True,
                "progress_hooks": [self._progress_hook],
                "postprocessors": [
                    {
                        "key": "FFmpegExtractAudio",
                        "preferredcodec": "mp3",
                        "preferredquality": "192",
                    }
                ],
                "quiet": True,
            }
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                ydl.download([url])
            self.root.after(0, lambda: self._download_complete("Audio descargado correctamente."))
        except Exception as exc:
            self.root.after(0, lambda: self._handle_error(f"Error al descargar audio: {exc}"))

    def _ensure_output_dir(self) -> Path:
        self.output_dir.mkdir(parents=True, exist_ok=True)
        return self.output_dir

    def _progress_hook(self, status: dict) -> None:
        if status.get("status") == "downloading":
            total = status.get("total_bytes") or status.get("total_bytes_estimate") or 0
            downloaded = status.get("downloaded_bytes", 0)
            speed = status.get("speed") or status.get("download_speed") or 0
            eta = status.get("eta")
            speed_text = self._format_size(speed) + "/s" if speed else ""
            eta_text = f"ETA {self._format_eta(eta)}" if eta is not None else "ETA N/D"

            if total:
                percent = min(100, int(downloaded / total * 100))
                self.root.after(0, lambda: self.progress_bar.config(mode="determinate", maximum=100))
                self.root.after(0, lambda: self._set_progress(percent))
                self.root.after(0, lambda: self._set_status(
                    f"Descargando... {percent}% ({self._format_size(downloaded)} / {self._format_size(total)})".strip()
                ))
                self.root.after(0, lambda: self.progress_text.config(
                    text=f"{percent}% · {speed_text} · {eta_text}".strip()
                ))
            else:
                self.root.after(0, lambda: self.progress_bar.config(mode="indeterminate"))
                self.root.after(0, lambda: self.progress_bar.start(50))
                self.root.after(0, lambda: self._set_status(
                    f"Descargando... {self._format_size(downloaded)}".strip()
                ))
                self.root.after(0, lambda: self.progress_text.config(
                    text=f"{speed_text} · {eta_text}".strip()
                ))
        elif status.get("status") == "finished":
            self.root.after(0, lambda: self.progress_bar.stop())
            self.root.after(0, lambda: self.progress_bar.config(mode="indeterminate"))
            self.root.after(0, lambda: self.progress_bar.start(20))
            self.root.after(0, lambda: self.status_label.config(style="ProcessingText.TLabel"))
            self.root.after(0, lambda: self.progress_text.config(style="ProcessingText.TLabel"))
            self.root.after(0, lambda: self._set_status("⏳ CONVIRTIENDO Y PROCESANDO ARCHIVO... (ESTO PUEDE TARDAR VARIOS MINUTOS) ⏳"))
            self.root.after(0, lambda: self.progress_text.config(text="⚙️ Aplicando recodificación FFmpeg a H.264/AAC..."))

    def _download_complete(self, message: str) -> None:
        self.progress_bar.stop()
        self.progress_bar.config(mode="determinate", maximum=100)
        self.status_label.config(style="ProgressText.TLabel")
        self.progress_text.config(style="ProgressText.TLabel")
        self._set_progress(100)
        self.progress_text.config(text="")
        self._set_status(message)
        self._toggle_controls(state="normal")
        messagebox.showinfo("Descarga completada", f"{message}\nArchivo guardado en: {self.output_dir}")

    def _handle_error(self, message: str) -> None:
        self._set_status(message)
        self._set_progress(0)
        self._toggle_controls(state="normal")
        messagebox.showerror("Error", message)


def main() -> None:
    root = Tk()
    SocialDownloaderGUI(root)
    root.mainloop()


if __name__ == "__main__":
    main()