Надоело каждый раз лезть в терминал, чтобы скачать видео с YouTube? Мне тоже. Поэтому я сделал нормальный GUI для yt-dlp - без лишних кнопок, с современным интерфейсом и чтобы просто работал. Код на GitHub, готовая сборка тоже есть.
Зачем вообще это делать?
Да, yt-dlp крутой - качает с кучи сайтов, быстрый, надёжный. Но блин, каждый раз набирать команды в консоли - это не для всех. Особенно когда нужно быстро скачать что-то и не париться с параметрами.
Посмотрел на существующие GUI - одни выглядят как из 2005 года, другие напичканы настройками, которые 99% пользователей никогда не трогают. Захотелось сделать что-то простое: вставил ссылку, выбрал качество, скачал. Всё.
Что хотел получить:
- Простоту - минимум кликов от ссылки до файла 
- Нормальный вид - тёмная тема, без уродских кнопок из 90-х 
- Скорость - никаких тормозов и зависаний 
- Работает везде - Windows точно, остальные ОС в планах 
- Не требует установки - скачал exe и пользуешься 
Что в итоге получилось
Интерфейс работает по принципу "от простого к сложному":
- Стартовая страница - только поле для ссылки, ничего лишнего 
- Превью - показываем видео, даём выбрать качество 
- Скачивание - прогресс-бар и всякая полезная инфа 
Стартовая страница

Превью видео

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

На чём писал
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
Как устроен интерфейс
Сделал по принципу "показываем только то, что нужно сейчас":
- Стартовая - только поле для ссылки 
- После вставки ссылки - грузим инфо о видео 
- Превью - показываем видео и даём выбрать настройки 
- Скачивание - прогресс и всякие детали 
Каждый экран - отдельный компонент:
- 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 для аудио 
Что планирую добавить
- Выбор папки для сохранения - пока всё сохраняется на рабочий стол 
- Субтитры - скачивание субтитров в разных форматах 
- Тестирование на других ОС - проверить работу на macOS и Linux 
- Больше сайтов - протестить Одноклассники, Rutube, TikTok и прочие 
Выводы
Делать GUI для консольной утилиты - интересная задачка. Главное - не переборщить с функциями и сделать так, чтобы было удобно пользоваться. CustomTkinter оказался отличным выбором: выглядит современно, работает быстро, не такой тяжёлый как Qt и не такой монстр как Electron.
Что понял в процессе:
- Архитектура важна - если сразу всё разложить по полочкам, потом легче добавлять новые фичи 
- Простота рулит - лучше сделать 3 кнопки, которые работают, чем 30, которые никто не использует 
- Многопоточность в GUI - боль - но без неё интерфейс тормозит 
- Тестирование на разных ОС критично - что работает на Windows, может не работать на Linux 
Полезные ссылки:
В общем, Python + CustomTkinter - хорошая связка для десктопных приложений. Если думаете над GUI для Python - попробуйте.
 
          