Как я написал современный GUI для yt-dlp на Python

от автора

Надоело каждый раз лезть в терминал, чтобы скачать видео с YouTube? Мне тоже. Поэтому я сделал нормальный GUI для yt-dlp — без лишних кнопок, с современным интерфейсом и чтобы просто работал. Код на GitHub, готовая сборка тоже есть.

Зачем вообще это делать?

Да, yt-dlp крутой — качает с кучи сайтов, быстрый, надёжный. Но блин, каждый раз набирать команды в консоли — это не для всех. Особенно когда нужно быстро скачать что-то и не париться с параметрами.

Посмотрел на существующие GUI — одни выглядят как из 2005 года, другие напичканы настройками, которые 99% пользователей никогда не трогают. Захотелось сделать что-то простое: вставил ссылку, выбрал качество, скачал. Всё.

Что хотел получить:

  • Простоту — минимум кликов от ссылки до файла

  • Нормальный вид — тёмная тема, без уродских кнопок из 90-х

  • Скорость — никаких тормозов и зависаний

  • Работает везде — Windows точно, остальные ОС в планах

  • Не требует установки — скачал exe и пользуешься

Что в итоге получилось

Интерфейс работает по принципу «от простого к сложному»:

  1. Стартовая страница — только поле для ссылки, ничего лишнего

  2. Превью — показываем видео, даём выбрать качество

  3. Скачивание — прогресс-бар и всякая полезная инфа

Стартовая страница

Стартовая страница с полем ввода URL

Стартовая страница с полем ввода URL

Превью видео

Интерфейс выбора качества и формата

Интерфейс выбора качества и формата

Прогресс загрузки

Отображение прогресса с детальной информацией

Отображение прогресса с детальной информацией

На чём писал

CustomTkinter — почему именно он

Долго выбирал между разными вариантами. В итоге остановился на 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 

Как устроен интерфейс

Сделал по принципу «показываем только то, что нужно сейчас»:

  1. Стартовая — только поле для ссылки

  2. После вставки ссылки — грузим инфо о видео

  3. Превью — показываем видео и даём выбрать настройки

  4. Скачивание — прогресс и всякие детали

Каждый экран — отдельный компонент:

  • SimpleURLInputFrame — ввод ссылки

  • VideoPreviewFrame — превью и настройки

  • ProgressDisplayFrame — прогресс скачивания

Проблемы, с которыми столкнулся

YouTube и его капризы

Самая большая головная боль — получить нормальное название видео. YouTube ведёт себя по-разному в зависимости от времени, региона, есть ли VPN. Иногда вместо названия получаешь какую-то фигню.

Решил парсить HTML напрямую:

Вытаскиваем название из 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 

Обход блокировок через куки

Чтобы обойти всякие региональные блокировки, тащу куки из браузера:

Автоматическое извлечение cookies
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

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() 

Сборка в exe

Чтобы не заставлять людей ставить 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 для аудио

Что планирую добавить

  1. Выбор папки для сохранения — пока всё сохраняется на рабочий стол

  2. Субтитры — скачивание субтитров в разных форматах

  3. Тестирование на других ОС — проверить работу на macOS и Linux

  4. Больше сайтов — протестить Одноклассники, Rutube, TikTok и прочие

Выводы

Делать GUI для консольной утилиты — интересная задачка. Главное — не переборщить с функциями и сделать так, чтобы было удобно пользоваться. CustomTkinter оказался отличным выбором: выглядит современно, работает быстро, не такой тяжёлый как Qt и не такой монстр как Electron.

Что понял в процессе:

  • Архитектура важна — если сразу всё разложить по полочкам, потом легче добавлять новые фичи

  • Простота рулит — лучше сделать 3 кнопки, которые работают, чем 30, которые никто не использует

  • Многопоточность в GUI — боль — но без неё интерфейс тормозит

  • Тестирование на разных ОС критично — что работает на Windows, может не работать на Linux

Полезные ссылки:

В общем, Python + CustomTkinter — хорошая связка для десктопных приложений. Если думаете над GUI для Python — попробуйте.


ссылка на оригинал статьи https://habr.com/ru/articles/930260/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *