diff --git a/bot.py b/bot.py index 15d52af..2711125 100644 --- a/bot.py +++ b/bot.py @@ -22,10 +22,6 @@ HTTP_TIMEOUT = httpx.Timeout(connect=None, read=None, write=None, pool=None) # Таймаут для запроса форматов (не такой критичный, но не должен висеть вечно) FORMATS_TIMEOUT = httpx.Timeout(connect=15, read=30, write=15, pool=15) -# Клиентский кэш форматов: {normalized_url: (timestamp, formats)} -_formats_cache: dict[str, tuple[float, list[dict]]] = {} -_FORMATS_CACHE_TTL = 30 * 60 # 30 минут - # Настройка логирования logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', @@ -971,29 +967,10 @@ async def download_tiktok_video(url: str, chat_id: int, max_retries: int = 3) -> # ============================================================================ -def _normalize_youtube_url_for_cache(url: str) -> str: - """Нормализует URL для кэша: оставляет только video ID""" - import re - m = re.search(r'(youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})', url) - if m: - return f"https://www.youtube.com/watch?v={m.group(2)}" - return url - - async def get_formats_from_service(url: str) -> list[dict] | None: """Получает список доступных форматов для YouTube URL через сервис youtube-downloader""" logger.info(f"Получение форматов для YouTube: {url}") - cache_key = _normalize_youtube_url_for_cache(url) - now = time.time() - - if cache_key in _formats_cache: - cached_time, cached_formats = _formats_cache[cache_key] - if now - cached_time < _FORMATS_CACHE_TTL: - logger.info(f"Форматы взяты из кэша ({len(cached_formats)} шт., возраст {now - cached_time:.0f}с)") - return cached_formats - del _formats_cache[cache_key] - try: async with httpx.AsyncClient(timeout=FORMATS_TIMEOUT) as client: response = await client.post( @@ -1003,9 +980,7 @@ async def get_formats_from_service(url: str) -> list[dict] | None: ) if response.status_code == 200: data = response.json() - formats = data.get('formats', []) - _formats_cache[cache_key] = (time.time(), formats) - return formats + return data.get('formats', []) logger.warning(f"Не удалось получить форматы: {response.status_code}") return None except Exception as e: diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index d1f243f..82daab3 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -150,8 +150,6 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) 'user_agent': user_agent, 'socket_timeout': 60, 'extractor_retries': 3, - # Предпочитаем русскую озвучку — YouTube иногда подсовывает авто-дубляж на английском - 'format_sort': ['lang:ru'], 'http_headers': { 'User-Agent': user_agent, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', @@ -175,6 +173,68 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) return opts +def _find_video_file() -> Path | None: + """Находит видеофайл среди загрузок. Если видео+аудио раздельные — мержит ffmpeg. + Возвращает путь к готовому видеофайлу или None если видео нет.""" + files = list(DOWNLOADS_DIR.glob('*')) + if not files: + return None + files.sort(key=lambda x: x.stat().st_mtime, reverse=True) + + video_file = None + audio_file = None + + for f in files: + if f.suffix in ('.part', '.ytdl'): + continue + if _file_has_video_stream(f): + has_audio = _file_has_audio_stream(f) + if has_audio: + return f # уже смерженный или combined — сразу возвращаем + if video_file is None: + video_file = f + elif not audio_file and _file_has_audio_stream(f): + audio_file = f + + if video_file is None: + return None + + if audio_file: + # Мержим видео + аудио через ffmpeg + import subprocess + merged = DOWNLOADS_DIR / f"{video_file.stem}_merged{video_file.suffix}" + logger.info(f"[MERGE] Мержим {video_file.name} + {audio_file.name} → {merged.name}") + result = subprocess.run( + ['ffmpeg', '-y', '-i', str(video_file), '-i', str(audio_file), + '-c', 'copy', '-map', '0:v:0', '-map', '1:a:0', str(merged)], + capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + logger.error(f"[MERGE] Ошибка ffmpeg: {result.stderr[-300:]}") + return video_file # fallback — только видео + + video_file.unlink(missing_ok=True) + audio_file.unlink(missing_ok=True) + return merged + + return video_file + + +def _file_has_audio_stream(filepath: Path) -> bool: + """Проверяет через ffprobe, содержит ли файл аудио-поток.""" + import subprocess + try: + result = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'a:0', + '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', + str(filepath)], + capture_output=True, text=True, timeout=15 + ) + return result.stdout.strip() == 'audio' + except Exception: + return False + + def _find_latest_downloaded() -> Path | None: """Возвращает самый свежий файл в папке загрузок.""" files = list(DOWNLOADS_DIR.glob('*')) @@ -287,9 +347,9 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None # Это важно, т.к. format_id из get_youtube_formats() может не совпадать # с format_id при повторном extract_info() в download_youtube_video(). default_format_options = [ - 'bestvideo[ext=mp4]+bestaudio[language=ru][ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', + 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 'best[ext=mp4]/best', - 'bestvideo+bestaudio[language=ru]/bestvideo+bestaudio/best', + 'bestvideo+bestaudio/best', 'best', ] @@ -337,7 +397,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None 'format': format_option, 'outtmpl': _safe_filename(video_title), 'fragment_retries': 3, - # Явно включаем фикс растянутого/сплющенного aspect ratio 'postprocessors': [{'key': 'FFmpegFixupStretched'}], }) @@ -360,18 +419,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}") - - # Проверяем, что файл содержит видео-поток (только для видео-форматов) - if not is_audio_only: - downloaded = _find_latest_downloaded() - if downloaded and not _file_has_video_stream(downloaded): - logger.warning(f"[DOWNLOAD] Файл {downloaded.name} не содержит видео-потока (только аудио). Удаляем и пробуем следующий формат...") - try: - downloaded.unlink() - except Exception: - pass - continue - download_success = True break except Exception as download_error: @@ -396,17 +443,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl: ydl.download([url]) logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies") - - if not is_audio_only: - downloaded = _find_latest_downloaded() - if downloaded and not _file_has_video_stream(downloaded): - logger.warning(f"[DOWNLOAD] Файл {downloaded.name} без видео-потока. Удаляем и пробуем следующий формат...") - try: - downloaded.unlink() - except Exception: - pass - continue - download_success = True cookies_valid = False # Отключаем cookies для следующих попыток break @@ -600,10 +636,9 @@ def get_youtube_formats(url: str) -> list[dict]: if vcodec != 'none' and height > 0: available_heights.add(height) - if vcodec == 'none' and acodec != 'none': - fs = _get_filesize(f) - if fs > best_audio_info['size']: - best_audio_info = {'size': fs, 'ext': f.get('ext', 'm4a'), 'format_id': format_id} + if vcodec == 'none' and acodec != 'none' and best_audio_info['format_id'] is None: + # Берём первый попавшийся аудиоформат — yt-dlp отдаёт оригинал первым + best_audio_info = {'size': _get_filesize(f), 'ext': f.get('ext', 'm4a'), 'format_id': format_id} logger.info(f"[FORMATS] Доступные разрешения: {sorted(available_heights)}") logger.info(f"[FORMATS] Лучший аудиопоток: {best_audio_info['size']} bytes, {best_audio_info['ext']}, format_id={best_audio_info['format_id']}") @@ -620,16 +655,27 @@ def get_youtube_formats(url: str) -> list[dict]: best_video = None best_video_height = 0 + is_best_dash = False for f in formats: vcodec = f.get('vcodec', 'none') height = _parse_height(f) - if vcodec == 'none' or height <= 0: + if vcodec == 'none' or height <= 0 or height > max_height: continue - if height <= max_height and height > best_video_height: + + is_dash = (f.get('acodec', 'none') == 'none') + pick = False + + if height > best_video_height: + pick = True + elif height == best_video_height and is_dash and not is_best_dash: + pick = True + + if pick: best_video = f best_video_height = height + is_best_dash = is_dash if not best_video: continue @@ -654,28 +700,16 @@ def get_youtube_formats(url: str) -> list[dict]: logger.info(f"[FORMATS] {display_label} (height={best_video_height}): video_size={video_size}, has_audio={has_audio}, total={total_size}, format_id={video_format_id}") - # format_selector: предпочитаем русскую озвучку (YouTube Shorts часто авто-дублирует) if has_audio: - format_selector = ( - f"{video_format_id}/" - f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" - f"bestvideo[height<={best_video_height}]+bestaudio/" - f"best[height<={best_video_height}]" - ) + format_selector = f"{video_format_id}/best[height<={best_video_height}]/best" elif best_audio_info['format_id']: format_selector = ( f"{video_format_id}+{best_audio_info['format_id']}/" - f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" f"bestvideo[height<={best_video_height}]+bestaudio/" f"best[height<={best_video_height}]" ) else: - format_selector = ( - f"{video_format_id}+bestaudio[language=ru]/" - f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" - f"bestvideo[height<={best_video_height}]+bestaudio/" - f"best[height<={best_video_height}]" - ) + format_selector = f"{video_format_id}+bestaudio/best[height<={best_video_height}]/best" result.append({ 'format_id': format_selector, @@ -685,10 +719,10 @@ def get_youtube_formats(url: str) -> list[dict]: 'filesize_mb': round(total_size / 1024 / 1024, 1) if total_size else None, }) - # Добавляем аудиодорожку (M4A в приоритете — Telegram поддерживает только MP3/M4A для reply_audio) + # Добавляем аудиодорожку if best_audio_info['size']: result.append({ - 'format_id': 'bestaudio[language=ru][ext=m4a]/bestaudio[language=ru][ext=mp3]/bestaudio[language=ru]/bestaudio/best', + 'format_id': 'bestaudio/best', 'label': f"Audio only ({best_audio_info['ext']})", 'quality': 'audio', 'ext': best_audio_info['ext'], diff --git a/youtube-downloader/youtube_cookies.txt b/youtube-downloader/youtube_cookies.txt index 1994e10..6bb31ca 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -27,14 +27,14 @@ .youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA .youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1793319671 VISITOR_INFO1_LIVE vFr43YvHJaE -.youtube.com TRUE / TRUE 1793319671 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D +.youtube.com TRUE / TRUE 1793371363 VISITOR_INFO1_LIVE vFr43YvHJaE +.youtube.com TRUE / TRUE 1793371363 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D .youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en .youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1809303671 SIDCC AKEyXzUEfCEbgyGrH1tNORzoWjcQ4T0V72l_bkp7S12EcJE1BUmxvYDz5rhspZYtTtFg7hzpWkbN -.youtube.com TRUE / TRUE 1809303671 __Secure-1PSIDCC AKEyXzWZMgqSjtmJ_uHCIE-o8z-H3jLbJznGybAMvRcbKNzCej_YDlmQ-ScNrcaJ58mYfK6iaOei -.youtube.com TRUE / TRUE 1809303671 __Secure-3PSIDCC AKEyXzU4244NG2Eo-friqIol4JdiWYR7Ew-fg1o9TggXgU7NJqk_X_6o7WvvME6so9LxwmuM3lmj +.youtube.com TRUE / FALSE 1809355363 SIDCC AKEyXzXpgpaN5o2kMTOlzAUXbOF4YCfxl41G6ZutKAOlEzpeIdskBxux3PemZB5T0T-hVrRplzEw +.youtube.com TRUE / TRUE 1809355363 __Secure-1PSIDCC AKEyXzVOgdvJviP9-nUDUnv0MXGY5L73_iQ1heqaYPuKh8JEpSnrLPN7dDb40XFFWlASf3sYC5Td +.youtube.com TRUE / TRUE 1809355363 __Secure-3PSIDCC AKEyXzUfDASoJeAIdtNkO6vz8MF2NHsJWHdYWfhJlNp6RDnIIzD3XIv2eE7uff2BsY-JaNw5ANNX .youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 0 YSC KTvrS45hA30 .instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA