Как я написал современный GUI для yt-dlp на Python
- понедельник, 28 июля 2025 г. в 00:00:11
Надоело каждый раз лезть в терминал, чтобы скачать видео с YouTube? Мне тоже. Поэтому я сделал нормальный GUI для yt-dlp - без лишних кнопок, с современным интерфейсом и чтобы просто работал. Код на GitHub, готовая сборка тоже есть.
Да, yt-dlp крутой - качает с кучи сайтов, быстрый, надёжный. Но блин, каждый раз набирать команды в консоли - это не для всех. Особенно когда нужно быстро скачать что-то и не париться с параметрами.
Посмотрел на существующие GUI - одни выглядят как из 2005 года, другие напичканы настройками, которые 99% пользователей никогда не трогают. Захотелось сделать что-то простое: вставил ссылку, выбрал качество, скачал. Всё.
Простоту - минимум кликов от ссылки до файла
Нормальный вид - тёмная тема, без уродских кнопок из 90-х
Скорость - никаких тормозов и зависаний
Работает везде - Windows точно, остальные ОС в планах
Не требует установки - скачал exe и пользуешься
Интерфейс работает по принципу "от простого к сложному":
Стартовая страница - только поле для ссылки, ничего лишнего
Превью - показываем видео, даём выбрать качество
Скачивание - прогресс-бар и всякая полезная инфа
Долго выбирал между разными вариантами. В итоге остановился на CustomTkinter - это такая современная обёртка над обычным Tkinter.
Плюсы:
Выглядит нормально сразу из коробки
Плавные анимации есть
Совместим с обычным Tkinter
Активно развивается
Что ещё рассматривал:
PyQt/PySide - мощно, но лицензия для коммерции геморрой
Kivy - больше для мобилок заточен
Electron - для простого даунлоадера это перебор
Обычный tkinter - работает, но выглядит как поделка
Сразу решил не лепить всё в одну кучу, а разложить по папкам:
src/ytdlp_gui/
├── core/ # Вся логика работы
│ ├── download_manager.py # Качает файлы
│ ├── format_detector.py # Разбирается с форматами
│ ├── settings_manager.py # Настройки
│ └── cookie_manager.py # Куки для обхода блокировок
├── gui/ # Интерфейс
│ ├── main_window.py # Главное окно
│ └── components/ # Отдельные части UI
└── utils/ # Всякие полезности
├── logger.py # Логи
└── notifications.py # Уведомления
Зачем так заморачивался:
Проще искать баги - каждая штука в своём файле
Можно тестировать части по отдельности
Если захочу что-то добавить, не придётся ковыряться во всём коде
Другим разработчикам будет понятно, что где лежит
Основная фишка - DownloadManager
. Он умеет:
Качать в фоне и не тормозить интерфейс:
def _download_worker(self, download_item: DownloadItem):
"""Отдельный поток для скачивания"""
try:
ydl_opts = self._prepare_ydl_options(download_item)
# Подключаем отслеживание прогресса
ydl_opts['progress_hooks'] = [
lambda d: self._progress_hook(d, download_item.id)
]
ydl_opts['postprocessor_hooks'] = [
lambda d: self._postprocessor_hook(d, download_item.id)
]
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([download_item.url])
except Exception as e:
self._handle_download_error(e, download_item)
Обновлять интерфейс по ходу дела:
def add_progress_callback(self, download_id: str, callback: Callable):
"""Подписаться на обновления прогресса"""
if download_id not in self.progress_callbacks:
self.progress_callbacks[download_id] = []
self.progress_callbacks[download_id].append(callback)
def _notify_progress_change(self, download_id: str):
"""Сказать интерфейсу, что что-то изменилось"""
if download_id in self.progress_callbacks:
for callback in self.progress_callbacks[download_id]:
try:
callback()
except Exception as e:
self.logger.error(f"Callback error: {e}")
FormatDetector
разбирается, какие форматы доступны, и сортирует их по качеству:
def _calculate_quality_score(self, fmt: Dict) -> int:
"""Считаем очки качества для сортировки"""
score = 0
# Очки за разрешение
height = fmt.get('height', 0) or 0
if height >= 2160: # 4K
score += 1000
elif height >= 1440: # 1440p
score += 800
elif height >= 1080: # 1080p
score += 600
# ... и так далее
# Очки за битрейт
tbr = fmt.get('tbr', 0) or 0
score += min(tbr, 500) # Чтобы не было совсем диких значений
# Бонусы за хорошие кодеки
vcodec = fmt.get('vcodec', '')
if 'av01' in vcodec: # AV1
score += 50
elif 'vp9' in vcodec: # VP9
score += 30
elif 'h264' in vcodec: # H.264
score += 20
return score
Сделал по принципу "показываем только то, что нужно сейчас":
Стартовая - только поле для ссылки
После вставки ссылки - грузим инфо о видео
Превью - показываем видео и даём выбрать настройки
Скачивание - прогресс и всякие детали
Каждый экран - отдельный компонент:
SimpleURLInputFrame
- ввод ссылки
VideoPreviewFrame
- превью и настройки
ProgressDisplayFrame
- прогресс скачивания
Самая большая головная боль - получить нормальное название видео. YouTube ведёт себя по-разному в зависимости от времени, региона, есть ли VPN. Иногда вместо названия получаешь какую-то фигню.
Решил парсить HTML напрямую:
def _extract_title_from_html(self, url: str) -> Optional[str]:
"""Берём название прямо со страницы"""
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
# Специально не указываем язык, чтобы получить оригинал
}
response = requests.get(url, headers=headers, timeout=10)
# Ищем название в разных местах
patterns = [
r'<meta property="og:title" content="([^"]+)"',
r'<meta name="title" content="([^"]+)"',
r'"title":"([^"]+)"',
]
for pattern in patterns:
match = re.search(pattern, response.text)
if match:
return html.unescape(match.group(1))
except Exception as e:
self.logger.error(f"Не смог вытащить название: {e}")
return None
Чтобы обойти всякие региональные блокировки, тащу куки из браузера:
def get_cookie_options(self, url: str = None) -> Dict[str, Any]:
"""Берём куки из браузера для yt-dlp"""
# Для разных сайтов разные браузеры работают лучше
site_browsers = self.site_browser_preferences.get(
self._extract_domain(url),
self.browser_priority
)
for browser in site_browsers:
if self._is_browser_available(browser):
return {
'cookiesfrombrowser': (browser, None, None, None)
}
return {}
Tkinter не умеет в асинхронность из коробки, поэтому пришлось городить threading + callback'и:
def _progress_hook(self, d: Dict, download_id: str):
"""Хук для обновления прогресса (работает в фоновом потоке)"""
try:
download_item = self.get_download_item(download_id)
if not download_item:
return
if d['status'] == 'downloading':
# Обновляем данные
download_item.progress = (d.get('downloaded_bytes', 0) /
d.get('total_bytes', 1)) * 100
download_item.speed = self._clean_display_string(d.get('_speed_str', ''))
# Говорим интерфейсу обновиться
self._notify_progress_change(download_id)
except Exception as e:
self.logger.error(f"Ошибка в progress hook: {e}")
А интерфейс подписывается на изменения и обновляется в основном потоке:
def update_progress(self, download_item):
"""Обновляем прогресс-бар (в основном потоке)"""
if download_item:
# Обновляем полоску прогресса
progress = download_item.progress / 100.0
self.progress_bar.set(progress)
# Обновляем текст
self.percentage_label.configure(text=f"{download_item.progress:.1f}%")
self.speed_label.configure(text=f"Скорость: {download_item.speed}")
Сделал всплывающие уведомления с анимацией:
class ToastNotification(ctk.CTkToplevel):
"""Всплывающее уведомление"""
def show_animation(self):
"""Плавно появляемся"""
# Начинаем невидимыми
self.attributes('-alpha', 0.0)
# Постепенно становимся видимыми
for i in range(20):
alpha = i / 20.0
self.attributes('-alpha', alpha)
self.update()
time.sleep(0.01)
def close_animation(self):
"""Плавно исчезаем"""
for i in range(20, 0, -1):
alpha = i / 20.0
self.attributes('-alpha', alpha)
self.update()
time.sleep(0.01)
self.destroy()
Чтобы не заставлять людей ставить Python, собираю всё в один exe файл через PyInstaller:
def create_pyinstaller_spec():
"""Создаём spec-файл для PyInstaller"""
hidden_imports = [
"customtkinter",
"yt_dlp",
"PIL._tkinter_finder",
"tkinter",
"sqlite3",
"threading",
"psutil"
]
# Подключаем ресурсы
datas = [
("src", "src"),
("assets", "assets") if Path("assets").exists() else None
]
datas = [d for d in datas if d] # Убираем пустые
# Генерим spec-файл
spec_content = f'''
a = Analysis(
['main.py'],
pathex=['src'],
datas={datas!r},
hiddenimports={hidden_imports!r},
# ... остальные настройки
)
'''
Проблема: CustomTkinter не может найти свои файлы в exe
Решение: Прописываем пути явно:
# В spec-файле
datas=[
('venv/Lib/site-packages/customtkinter', 'customtkinter'),
]
Проблема: yt-dlp пытается обновиться через интернет
Решение: Отключаем обновления:
ydl_opts = {
'no_check_certificate': True,
'call_home': False, # Не проверять обновления
}
Скрипт сам определяет систему и делает нужный архив:
def create_archive():
"""Создаём архив для раздачи"""
system = platform.system().lower()
if system == "windows":
exe_name = f"{APP_NAME}.exe"
archive_format = "zip"
elif system == "darwin": # macOS
exe_name = APP_NAME
archive_format = "zip"
else: # Linux
exe_name = APP_NAME
archive_format = "gztar"
# Архив с версией и платформой в названии
archive_name = f"{APP_NAME}-v{APP_VERSION}-{system}-{platform.machine()}"
✅ Нормальный современный интерфейс
✅ Быстро качает без лишней обработки
✅ Поддерживает кучу сайтов через yt-dlp
✅ Работает на Windows (на других ОС пока не тестил)
✅ Готовый exe файл
✅ Уведомления и обработка ошибок
✅ Проверено на YouTube и ВКонтакте - всё ок
⚠️ Надо протестить на macOS и Linux
⚠️ Проверить работу с другими сайтами из списка yt-dlp
Размер: ~27MB со всеми зависимостями
Запуск: 2-3 секунды на нормальном компе
Память: ~55MB когда просто висит
Форматы: MP4 для видео, MP3 для аудио
Выбор папки для сохранения - пока всё сохраняется на рабочий стол
Субтитры - скачивание субтитров в разных форматах
Тестирование на других ОС - проверить работу на macOS и Linux
Больше сайтов - протестить Одноклассники, Rutube, TikTok и прочие
Делать GUI для консольной утилиты - интересная задачка. Главное - не переборщить с функциями и сделать так, чтобы было удобно пользоваться. CustomTkinter оказался отличным выбором: выглядит современно, работает быстро, не такой тяжёлый как Qt и не такой монстр как Electron.
Архитектура важна - если сразу всё разложить по полочкам, потом легче добавлять новые фичи
Простота рулит - лучше сделать 3 кнопки, которые работают, чем 30, которые никто не использует
Многопоточность в GUI - боль - но без неё интерфейс тормозит
Тестирование на разных ОС критично - что работает на Windows, может не работать на Linux
В общем, Python + CustomTkinter - хорошая связка для десктопных приложений. Если думаете над GUI для Python - попробуйте.