#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ YouTube MP3 Downloader v2.0 Изисква: yt-dlp, ffmpeg Инсталация: pip install yt-dlp За ffmpeg: https://ffmpeg.org/download.html """ import os import sys import json import subprocess import platform from pathlib import Path from datetime import datetime from typing import Optional, Dict, Any # Проверка за Python версия if sys.version_info < (3, 7): print("❌ Нужна е Python 3.7 или по-нова!") sys.exit(1) # Опит за импорт на yt-dlp try: import yt_dlp except ImportError: print("❌ yt-dlp не е инсталиран!") print("📦 Изпълни: pip install yt-dlp") sys.exit(1) # ========== КЛАСОВЕ И КОНФИГУРАЦИЯ ========== class Colors: """Цветове за терминала""" HEADER = '\033[95m' BLUE = '\033[94m' CYAN = '\033[96m' GREEN = '\033[92m' YELLOW = '\033[93m' RED = '\033[91m' RESET = '\033[0m' BOLD = '\033[1m' DIM = '\033[2m' class Config: """Управление на конфигурацията""" def __init__(self): self.config_file = Path.home() / ".ytmp3downloader.json" self.default_config = { "quality": "320", "output_dir": str(Path.home() / "Desktop" / "YouTube MP3"), "embed_thumbnail": True, "add_metadata": True, "extract_audio": True, "last_updated": datetime.now().isoformat() } self.load() def load(self): """Зареждане на конфигурацията""" if self.config_file.exists(): try: with open(self.config_file, 'r', encoding='utf-8') as f: saved = json.load(f) self.config = {**self.default_config, **saved} except: self.config = self.default_config.copy() else: self.config = self.default_config.copy() def save(self): """Запазване на конфигурацията""" self.config['last_updated'] = datetime.now().isoformat() with open(self.config_file, 'w', encoding='utf-8') as f: json.dump(self.config, f, indent=2, ensure_ascii=False) def get(self, key: str, default=None): return self.config.get(key, default) def set(self, key: str, value): self.config[key] = value self.save() class YouTubeDownloader: """Основен клас за сваляне""" def __init__(self): self.config = Config() self.check_dependencies() @staticmethod def clear_screen(): """Изчистване на екрана""" os.system('cls' if platform.system() == 'Windows' else 'clear') @staticmethod def print_header(): """Показване на хедъра""" YouTubeDownloader.clear_screen() print(f"\n{Colors.RED} ╔══════════════════════════════════════════╗{Colors.RESET}") print(f"{Colors.RED} ║ YouTube → MP3 Downloader v2.0 ║{Colors.RESET}") print(f"{Colors.DIM} ║ powered by yt-dlp ║{Colors.RESET}") print(f"{Colors.RED} ╚══════════════════════════════════════════╝{Colors.RESET}") print("") def print_ok(self, msg: str): print(f" {Colors.GREEN}✓{Colors.RESET} {msg}") def print_error(self, msg: str): print(f" {Colors.RED}✗{Colors.RESET} {msg}") def print_info(self, msg: str): print(f" {Colors.CYAN}ℹ{Colors.RESET} {msg}") def print_warning(self, msg: str): print(f" {Colors.YELLOW}⚠{Colors.RESET} {msg}") def print_step(self, msg: str): print(f" {Colors.BLUE}→{Colors.RESET} {msg}") def check_ffmpeg(self) -> bool: """Проверка за ffmpeg""" try: result = subprocess.run( ['ffmpeg', '-version'], capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == 'Windows' else 0 ) if result.returncode == 0: version = result.stdout.split('\n')[0].replace('ffmpeg version', '').strip() self.print_ok(f"ffmpeg {version}") return True except: pass self.print_error("ffmpeg не е намерен!") self.print_warning("Изтегли от: https://ffmpeg.org/download.html") return False def check_dependencies(self): """Проверка на всички зависимости""" self.print_info("Проверка на зависимости...") # Проверка за yt-dlp (вече е импортнат) try: import yt_dlp self.print_ok(f"yt-dlp {yt_dlp.version.__version__}") except: self.print_error("yt-dlp не е намерен!") self.print_warning("Изпълни: pip install yt-dlp") sys.exit(1) # Проверка за ffmpeg if not self.check_ffmpeg(): self.print_warning("Ще продължа, но някои функции няма да работят!") print("") def get_video_info(self, url: str) -> Optional[Dict[str, Any]]: """Взема информация за видеото""" try: ydl_opts = { 'quiet': True, 'no_warnings': True, 'extract_flat': False, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=False) return info except Exception as e: self.print_error(f"Грешка при получаване на информация: {str(e)}") return None def select_quality(self) -> str: """Избор на качество""" print(f" {Colors.BOLD}Качество на MP3:{Colors.RESET}") print(f" 1) 320 kbps {Colors.DIM}(най-добро){Colors.RESET}") print(f" 2) 192 kbps {Colors.DIM}(препоръчително){Colors.RESET}") print(f" 3) 128 kbps {Colors.DIM}(компактно){Colors.RESET}") print(f" {Colors.DIM}(Enter = {self.config.get('quality')} kbps){Colors.RESET}") choice = input("\n Избор [1-3]: ").strip() quality_map = { "1": "320", "2": "192", "3": "128" } quality = quality_map.get(choice, self.config.get('quality')) self.config.set('quality', quality) return quality def select_output_dir(self) -> str: """Избор на изходна папка""" default = self.config.get('output_dir') print(f" {Colors.BOLD}Папка за запис:{Colors.RESET}") print(f" {Colors.DIM}(Enter = {default}){Colors.RESET}") user_input = input(" Папка: ").strip().strip('"').strip("'") if not user_input: output_dir = default else: output_dir = user_input # Създаване на папката ако не съществува Path(output_dir).mkdir(parents=True, exist_ok=True) self.config.set('output_dir', output_dir) return output_dir def download_single(self, url: str, quality: str, output_dir: str): """Сваляне на единично видео""" # Валидация на URL if 'youtube.com' not in url and 'youtu.be' not in url: self.print_error("Невалиден YouTube URL!") return # Показване на информация self.print_step("Получаване на информация...") info = self.get_video_info(url) if info: duration_min = info.get('duration', 0) / 60 self.print_info(f"Заглавие: {info.get('title', 'N/A')[:60]}") self.print_info(f"Продължителност: {duration_min:.1f} минути") self.print_info(f"Качване: {info.get('upload_date', 'N/A')}") print("") # Конфигурация за сваляне ydl_opts = { 'format': 'bestaudio/best', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': quality, }], 'outtmpl': str(Path(output_dir) / '%(title)s.%(ext)s'), 'quiet': False, 'no_warnings': False, 'progress_hooks': [self.progress_hook], } # Добавяне на thumbnail и metadata ако са включени if self.config.get('embed_thumbnail'): ydl_opts['postprocessors'].append({ 'key': 'EmbedThumbnail', 'already_have_thumbnail': False, }) if self.config.get('add_metadata'): ydl_opts['postprocessors'].append({ 'key': 'FFmpegMetadata', }) try: self.print_step("Стартиране на сваляне...") self.print_step(f"Качество: {quality} kbps") print(f"\n {Colors.DIM}─" * 50 + f"{Colors.RESET}") with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) print(f"\n {Colors.DIM}─" * 50 + f"{Colors.RESET}") self.print_ok(f"Готово! Файлът е записан в: {output_dir}") # Въпрос за отваряне на папката open_folder = input("\n Да се отвори ли папката? (д/н) [н]: ").strip().lower() if open_folder == 'д': if platform.system() == 'Windows': os.startfile(output_dir) else: subprocess.run(['open', output_dir] if platform.system() == 'Darwin' else ['xdg-open', output_dir]) except Exception as e: self.print_error(f"Грешка при сваляне: {str(e)}") def download_playlist(self, url: str, quality: str, output_dir: str): """Сваляне на плейлист""" # Създаване на папка за плейлиста playlist_dir = Path(output_dir) / "Playlists" playlist_dir.mkdir(parents=True, exist_ok=True) # Показване на информация за плейлиста self.print_step("Получаване на информация за плейлиста...") info = self.get_video_info(url) if info and 'entries' in info: self.print_info(f"Намерени {len(info['entries'])} видеа в плейлиста") print("") ydl_opts = { 'format': 'bestaudio/best', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': quality, }], 'outtmpl': str(playlist_dir / '%(playlist_index)s - %(title)s.%(ext)s'), 'quiet': False, 'no_warnings': False, 'ignoreerrors': True, 'extract_flat': False, } # Добавяне на thumbnail и metadata if self.config.get('embed_thumbnail'): ydl_opts['postprocessors'].append({ 'key': 'EmbedThumbnail', 'already_have_thumbnail': False, }) if self.config.get('add_metadata'): ydl_opts['postprocessors'].append({ 'key': 'FFmpegMetadata', }) try: self.print_step("Стартиране на сваляне на плейлиста...") print(f"\n {Colors.DIM}─" * 50 + f"{Colors.RESET}") with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) print(f"\n {Colors.DIM}─" * 50 + f"{Colors.RESET}") self.print_ok(f"Плейлистът е свален успешно в: {playlist_dir}") except Exception as e: self.print_error(f"Грешка при сваляне на плейлист: {str(e)}") def progress_hook(self, d): """Hook за показване на прогреса""" if d['status'] == 'downloading': if 'total_bytes' in d: percent = d['downloaded_bytes'] / d['total_bytes'] * 100 print(f"\r {Colors.CYAN}Сваляне: {percent:.1f}%{Colors.RESET}", end='') elif d['status'] == 'finished': print(f"\r {Colors.GREEN}Обработка на аудио...{Colors.RESET}") def update_ytdlp(self): """Обновяване на yt-dlp""" self.print_step("Обновяване на yt-dlp...") try: subprocess.run([sys.executable, '-m', 'pip', 'install', '--upgrade', 'yt-dlp']) self.print_ok("yt-dlp е обновен успешно!") except Exception as e: self.print_error(f"Грешка при обновяване: {str(e)}") def clear_cache(self): """Изчистване на кеша""" self.print_step("Изчистване на кеша...") cache_dir = Path.home() / ".cache" / "yt-dlp" if cache_dir.exists(): import shutil shutil.rmtree(cache_dir) self.print_ok("Кешът е изчистен!") else: self.print_info("Няма намерен кеш") def show_stats(self): """Показване на статистика""" print(f"\n {Colors.BOLD}📊 Статистика:{Colors.RESET}") print(f" Качество: {self.config.get('quality')} kbps") print(f" Папка: {self.config.get('output_dir')}") print(f" Thumbnail: {'Да' if self.config.get('embed_thumbnail') else 'Не'}") print(f" Metadata: {'Да' if self.config.get('add_metadata') else 'Не'}") print(f" Последна промяна: {self.config.get('last_updated')}") # Броене на MP3 файловете output_dir = Path(self.config.get('output_dir')) if output_dir.exists(): mp3_files = list(output_dir.rglob("*.mp3")) print(f" Общо MP3 файлове: {len(mp3_files)}") def toggle_setting(self, setting: str): """Превключване на настройка""" current = self.config.get(setting) self.config.set(setting, not current) self.print_ok(f"{setting} е {'включена' if not current else 'изключена'}") # ========== MAIN MENU ========== def main(): """Основно меню""" downloader = YouTubeDownloader() while True: downloader.print_header() print(f" {Colors.DIM}═" * 50 + f"{Colors.RESET}") print(f" {Colors.BOLD}МЕНЮ{Colors.RESET}") print(f" {Colors.DIM}─" * 50 + f"{Colors.RESET}") print(f" {Colors.CYAN}1){Colors.RESET} Свали едно видео като MP3") print(f" {Colors.CYAN}2){Colors.RESET} Свали целия плейлист") print(f" {Colors.CYAN}3){Colors.RESET} Само аудио (без thumbnail)") print(f" {Colors.DIM}─" * 50 + f"{Colors.RESET}") print(f" {Colors.YELLOW}4){Colors.RESET} Обнови yt-dlp") print(f" {Colors.YELLOW}5){Colors.RESET} Настройки") print(f" {Colors.YELLOW}6){Colors.RESET} Статистика") print(f" {Colors.YELLOW}7){Colors.RESET} Изчисти кеша") print(f" {Colors.DIM}─" * 50 + f"{Colors.RESET}") print(f" {Colors.RED}0){Colors.RESET} Изход") print(f" {Colors.DIM}═" * 50 + f"{Colors.RESET}") choice = input("\n Избор [0-7]: ").strip() if choice == "1": print("") url = input(" YouTube URL: ").strip() if not url: downloader.print_warning("Не е въведен URL.") input("\n Натисни Enter за продължение...") continue quality = downloader.select_quality() output_dir = downloader.select_output_dir() downloader.download_single(url, quality, output_dir) input("\n Натисни Enter за менюто...") elif choice == "2": print("") url = input(" YouTube плейлист URL: ").strip() if not url: downloader.print_warning("Не е въведен URL.") input("\n Натисни Enter за продължение...") continue quality = downloader.select_quality() output_dir = downloader.select_output_dir() downloader.download_playlist(url, quality, output_dir) input("\n Натисни Enter за менюто...") elif choice == "3": print("") url = input(" YouTube URL: ").strip() if not url: downloader.print_warning("Не е въведен URL.") input("\n Натисни Enter за продължение...") continue quality = downloader.select_quality() output_dir = downloader.select_output_dir() # Временно изключване на thumbnail и metadata old_thumb = downloader.config.get('embed_thumbnail') old_meta = downloader.config.get('add_metadata') downloader.config.set('embed_thumbnail', False) downloader.config.set('add_metadata', False) downloader.download_single(url, quality, output_dir) # Възстановяване downloader.config.set('embed_thumbnail', old_thumb) downloader.config.set('add_metadata', old_meta) input("\n Натисни Enter за менюто...") elif choice == "4": downloader.update_ytdlp() input("\n Натисни Enter за менюто...") elif choice == "5": while True: downloader.print_header() print(f" {Colors.BOLD}⚙️ НАСТРОЙКИ{Colors.RESET}\n") print(f" 1) Качество: {downloader.config.get('quality')} kbps") print(f" 2) Папка: {downloader.config.get('output_dir')}") print(f" 3) Thumbnail: {'✓' if downloader.config.get('embed_thumbnail') else '✗'}") print(f" 4) Metadata: {'✓' if downloader.config.get('add_metadata') else '✗'}") print(f" {Colors.DIM}─" * 50 + f"{Colors.RESET}") print(f" 0) Назад") sub_choice = input("\n Избор [0-4]: ").strip() if sub_choice == "1": downloader.select_quality() elif sub_choice == "2": downloader.select_output_dir() elif sub_choice == "3": downloader.toggle_setting('embed_thumbnail') elif sub_choice == "4": downloader.toggle_setting('add_metadata') elif sub_choice == "0": break else: downloader.print_warning("Невалиден избор!") elif choice == "6": downloader.show_stats() input("\n Натисни Enter за менюто...") elif choice == "7": downloader.clear_cache() input("\n Натисни Enter за менюто...") elif choice == "0": print("") downloader.print_info("Довиждане!") print("") sys.exit(0) else: downloader.print_warning("Невалиден избор. Моля въведи 0-7.") input("\n Натисни Enter за продължение...") if __name__ == "__main__": try: main() except KeyboardInterrupt: print(f"\n\n {Colors.YELLOW}⚠ Програмата е спряна от потребителя{Colors.RESET}") sys.exit(0) except Exception as e: print(f"\n\n {Colors.RED}❌ Критична грешка: {str(e)}{Colors.RESET}") input("\n Натисни Enter за изход...") sys.exit(1)