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
- Entra a python.org/downloads y descarga la última versión.
- Abre el instalador y marca la casilla "Add Python to PATH" antes de continuar.
- 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?
- Abre la aplicación.
- Pega la URL del video (de YouTube, TikTok, Instagram, etc.) en el campo de texto.
- Haz clic en Cargar metadatos para ver los formatos y calidades disponibles.
- Selecciona la calidad que quieras de la lista.
- Elige una carpeta donde guardar el archivo con el botón Cambiar.
- 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)
- X / Twitter
- Twitch
- Vimeo
- SoundCloud
- 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()