# meta developer: @RUIS_VlP, @RoKrz # meta banner: https://raw.githubusercontent.com/Ruslan-Isaev/modules/refs/heads/main/photos/banner.jpg # meta pic: https://kappa.lol/21nHvy # requires: yt_dlp aiohttp aiofiles mutagen curl_cffi __version__ = (3, 5, 1) import yt_dlp import uuid import os import re import html as html_escaping import json import random import time import threading import asyncio import shutil import tempfile import zipfile import platform import urllib.parse import hmac import hashlib import base64 import struct import aiohttp import aiofiles from mutagen import File as MutagenFile from mutagen.id3 import ID3, TIT2, TPE1, TALB, APIC, ID3NoHeaderError from mutagen.mp3 import MP3 from mutagen.flac import FLAC, Picture as FlacPicture from pathlib import Path from telethon.tl.types import MessageEntityTextUrl from telethon.tl import types as tl_types from telethon.tl.custom import Message from telethon import utils as tl_utils from herokutl.tl.functions.messages import SendMessageRequest, UploadMediaRequest, UpdatePinnedMessageRequest, GetPeerDialogsRequest, SendMultiMediaRequest from herokutl.tl.functions.account import UpdateNotifySettingsRequest, GetNotifySettingsRequest from herokutl.tl.types import ( DocumentAttributeAudio, DocumentAttributeFilename, InputDialogPeer, InputMediaUploadedDocument, InputMediaUploadedPhoto, InputNotifyPeer, InputPeerNotifySettings, InputPhoto, InputReplyToMessage, InputRichMessage, InputSingleMedia, PageBlockPhoto, PageBlockSlideshow, PageCaption, TextEmpty, TextPlain, ) from herokutl.extensions import html as herokutl_html from herokutl import utils as herokutl_utils from .. import loader, utils import logging logger = logging.getLogger(__name__) EMOJI_OK = "✅" EMOJI_FAIL = "👎" EMOJI_WARN = "⚠️" EMOJI_DOWNLOAD = "📥" EMOJI_COMPRESS = "🛠" EMOJI_INFO = "ℹ️" EMOJI_NOTE = "🎵" EMOJI_CHECK = "✅" EMOJI_CROSS = "❌" EMOJI_SCISSORS = "✂️" EMOJI_GEAR = "⚙️" EMOJI_MIC = "🗣️" EMOJI_ARROW = "➡️" EMOJI_GLOBE = "🌐" EMOJI_COOKIE = "🍪" EMOJI_WAND = "🪄" EMOJI_CLOCK = "🕓" EMOJI_PHOTO = "🖼" ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def redact_secrets(text, cookies_text=None, proxy=None): """Вычищает из пользовательских сообщений значения кук/прокси.""" if not text: return text out = str(text) secrets = [] if proxy: secrets.append(str(proxy).strip()) if cookies_text: for line in str(cookies_text).splitlines(): line = line.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if "\t" in line else line.split() if len(parts) >= 7: secrets.append(parts[6]) elif len(parts) >= 2 and "=" not in parts[0]: secrets.append(parts[-1]) blob = str(cookies_text).strip() if len(blob) > 24: secrets.append(blob) for s in sorted({x for x in secrets if x and len(x) >= 6}, key=len, reverse=True): out = out.replace(s, "•••") return out def clean_error_text(text, cookies_text=None, proxy=None): text = ANSI_RE.sub("", str(text)) text = re.sub(r"^ERROR:\s*", "", text.strip()) text = re.sub(r"^\[\w+\]\s*[\w.-]+:\s*", "", text) text = re.sub(r":\s+", ":\n", text) text = re.sub(r"(https?://[^:\s/]+:)[^@\s/]+@", r"\1•••@", text) text = re.sub(r"(socks5?://[^:\s/]+:)[^@\s/]+@", r"\1•••@", text) text = re.sub( r"(?i)\b(session_id|sessionid2|sid|psuid|yandexuid|auth_token|login_info|SAPISID|HSID|SSID|APISID|SIDCC)=[^\s;\"']+", r"\1=•••", text, ) text = redact_secrets(text, cookies_text=cookies_text, proxy=proxy) return text.strip() def cookies_error_message(site_name, robots_url, detail): """Единый текст ошибки нехватки куки — тот же, что при обычной нехватке куки YouTube, плюс явное пояснение, что для этого сайта нужен ВТОРОЙ, отдельный набор куки, добавленный отдельным элементом списка youtube_cookies (рядом с куками YouTube, не вместо них).""" return ( f"{EMOJI_CROSS} Ошибка куки. Просьба вставить куки через команду " f".cfg YouTube-DLD youtube_cookies.\n" f"Если куки YouTube там уже есть — добавьте ещё и куки {site_name} отдельным элементом " f"списка («Добавить элемент»): откройте залогиненными {robots_url}, " f"экспортируйте куки тем же Cookie-Editor в формате Netscape и вставьте — нужны оба набора " f"сразу, каждый своим элементом.\n\n" f"{detail}" ) AUDIO_ONLY_DOMAINS = ( "myinstants.com", "music.yandex.", "soundcloud.com", "bandcamp.com", "mixcloud.com", "spotify.com", ) def is_audio_only_platform(link): """Площадки, где видео в принципе нет — только звук (VK-аудио/музыка входят отдельной проверкой, у них нет единого домена-паттерна с остальными).""" link_lower = (link or "").lower() if any(d in link_lower for d in AUDIO_ONLY_DOMAINS): return True return any(p in link_lower for p in ("vk.com/audio", "vk.ru/audio", "vk.com/music", "vk.ru/music")) COOKIE_DOMAIN_GROUPS = [ ("youtube", ("youtube.com",)), ("yandex", ("yandex.",)), ("vk", ("vk.com", "vk.ru")), ("instagram", ("instagram.com",)), ("twitter", ("twitter.com", "x.com")), ] def config_cookies_text(cfg_val): if not cfg_val: return "" if isinstance(cfg_val, (list, tuple)): return clean_cookies_text("\n".join(str(x) for x in cfg_val if x)) return clean_cookies_text(str(cfg_val)) def clean_cookies_text(raw_text): """Пользователь обычно вставляет в youtube_cookies весь экспорт Cookie-Editor целиком — десятки кук с посторонних доменов плюс шапка-комментарий. Модулю из этого реально нужны только куки YouTube, Яндекса, VK и Instagram — остальное (и комментарии) выкидываем, а то, что нужно, оставляем сгруппированным в порядке ют → музыка → vk → инста. Строки вида '#HttpOnly_.youtube.com ...' — это НЕ комментарий, а обычная HttpOnly-кука в формате curl/Netscape, отличить их от настоящих комментариев ("# Netscape HTTP Cookie File" и т.п.) можно по числу табов: у настоящей строки куки их всегда 6.""" if not raw_text: return None grouped = {"youtube": [], "yandex": [], "vk": [], "instagram": [], "twitter": []} for line in raw_text.splitlines(): stripped = line.rstrip("\r\n") if not stripped.strip(): continue parts = stripped.split("\t") if len(parts) < 7: parts = stripped.split() if len(parts) < 7: continue if len(parts) > 7: parts = parts[:6] + [" ".join(parts[6:])] if parts[0] == "#": continue normalized_line = "\t".join(parts) domain = parts[0].lower() for group_name, needles in COOKIE_DOMAIN_GROUPS: if any(n in domain for n in needles): grouped[group_name].append(normalized_line) break ordered_lines = grouped["youtube"] + grouped["yandex"] + grouped["vk"] + grouped["instagram"] + grouped["twitter"] if not ordered_lines: return None return "# Netscape HTTP Cookie File\n" + "\n".join(ordered_lines) def extract_video_link(text): if not text: return None video_sites_patterns = [ r"(https?://)?(www\.)?(youtube\.com|youtu\.be|music\.youtube\.com)/[^\s]+", r"(https?://)?(www\.)?(tiktok\.com|vt\.tiktok\.com|vm\.tiktok\.com)/[^\s]+", r"(https?://)?(www\.)?instagram\.com/(p|reel|tv)/[^\s]+", r"(https?://)?(www\.)?(twitter\.com|x\.com)/[^\s]+/status/[^\s]+", r"(https?://)?(www\.)?facebook\.com/[^\s]+/videos/[^\s]+", r"(https?://)?(www\.)?reddit\.com/r/[^\s]+/(comments|s)/[^\s]+", r"(https?://)?(www\.)?vimeo\.com/[^\s]+", r"(https?://)?(www\.)?dailymotion\.com/video/[^\s]+", r"(https?://)?(www\.)?twitch\.tv/(videos/|clip/|[^/]+$)[^\s]*", r"(https?://)?(www\.)?streamable\.com/[^\s]+", r"(https?://)?(www\.)?rule34video\.com/videos?/[^\s]+", r"(https?://)?(music\.)?yandex\.(ru|com|by|kz|ua)/album/[^\s]+", r"(https?://)?(music\.)?yandex\.(ru|com|by|kz|ua)/track/[^\s]+", r"(https?://)?(music\.)?yandex\.(ru|com|by|kz|ua)/(users/[^\s]+/)?playlists/[^\s]+", r"(https?://)?(www\.)?soundcloud\.com/[^\s]+", r"(https?://)?(www\.)?bandcamp\.com/[^\s]+", r"(https?://)?(www\.)?mixcloud\.com/[^\s]+", r"(https?://)?(www\.)?spotify\.com/(track|album|playlist)/[^\s]+", r"(https?://)?(www\.)?rutube\.ru/video/[^\s]+", r"(https?://)?(www\.)?(vk\.com|vk\.ru)/(video|clip|audio|music)[^\s]+", r"(https?://)?(www\.)?ok\.ru/video/[^\s]+", r"(https?://)?(www\.)?(cdn\.discordapp\.com|media\.discordapp\.net)/attachments/[^\s]+", r"https?://[^\s]+\.(mp4|webm|avi|mkv|mov|flv|m4v|mp3|m4a|wav|flac)(\?[^\s]*)?", ] all_matches = [] for pattern in video_sites_patterns: for match in re.finditer(pattern, text, re.IGNORECASE): all_matches.append(match) if all_matches: all_matches.sort(key=lambda m: m.start()) return all_matches[0].group(0) general_url_pattern = r"https?://[^\s]+" match = re.search(general_url_pattern, text) if match: url = match.group(0) host = (urllib.parse.urlsplit(url).hostname or "").lower() if host == "t.me" or host.endswith(".t.me"): return None if host == "raw.githubusercontent.com": media_exts = ( '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.mp4', '.webm', '.mov', '.mkv', '.avi', '.m4v', ) clean_url = url.split('?')[0].split('#')[0] if not clean_url.lower().endswith(media_exts): return None excluded_domains = [ 'google.com', 'yandex.ru', 'wikipedia.org', 'github.com', 'stackoverflow.com', 'reddit.com/r/', 'amazon.com', 'fixupx.com', 'vxtwitter.com', 'ozon.ru', ] if not any(domain in url.lower() for domain in excluded_domains): return url return None INSTAGRAM_MIRROR_DOMAINS = ("kkinstagram.com",) def normalize_link(link): if not link: return link for mirror in INSTAGRAM_MIRROR_DOMAINS: if mirror in link.lower(): return re.sub(re.escape(mirror), "instagram.com", link, flags=re.IGNORECASE) return link def find_video_link_in_message(message): if not message: return None link = extract_video_link(message.raw_text or "") if link: return link for entity in (message.entities or []): if isinstance(entity, MessageEntityTextUrl): found = extract_video_link(entity.url) if found: return found return None def parse_time_to_seconds(time_str): if not time_str: return None time_str = time_str.strip().lower() yt_style = re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?", time_str) if yt_style and any(yt_style.groups()): h, m, s = yt_style.groups() return int(h or 0) * 3600 + int(m or 0) * 60 + float(s or 0) if ":" in time_str: parts = time_str.split(":") try: parts = [float(p) if i == len(parts) - 1 else int(p) for i, p in enumerate(parts)] except ValueError: return None if len(parts) == 2: return parts[0] * 60 + parts[1] if len(parts) == 3: return parts[0] * 3600 + parts[1] * 60 + parts[2] return None try: return float(time_str) except ValueError: return None return None def format_seconds(total_seconds): if total_seconds is None: total_seconds = 0 whole = int(total_seconds) frac_ms = round((total_seconds - whole) * 1000) if frac_ms >= 1000: whole += 1 frac_ms = 0 h, rem = divmod(whole, 3600) m, s = divmod(rem, 60) ms_part = f".{frac_ms:03d}" if frac_ms else "" if h: return f"{h}:{m:02d}:{s:02d}{ms_part}" return f"{m}:{s:02d}{ms_part}" SITE_EMOJI = [ (("youtube.com/shorts",), "🔴", "5352632932857035523"), (("youtube.com", "youtu.be"), "🔴", "5355235592844095825"), (("tiktok.com",), "🎵", "5353034628263330616"), (("instagram.com",), "📸", "5355097780228470775"), (("x.com", "twitter.com"), "🐦", "5355148941878900494"), (("facebook.com",), "👥", "5355254460635428635"), (("vimeo.com",), "🎬", "5334764984142412896"), (("twitch.tv",), "🎮", "5352759664457038886"), (("reddit.com",), "👽", "5352531593103686999"), (("music.yandex",), "🎧", "5346296430166293639"), (("soundcloud.com",), "☁️", "5345844509412444249"), (("bandcamp.com",), "🎸", "5451966206334513619"), (("spotify.com",), "🟢", "5346074681004801565"), (("rutube.ru",), "▶️", "5298747646096187189"), (("vk.com/clip", "vk.ru/clip"), "🎥", "5280894678227492455"), (("vk.com", "vk.ru"), "🔵", "5278229754099540071"), (("ok.ru",), "🟠", "5310076528577491230"), (("cdn.discordapp.com", "media.discordapp.net"), "🎮", "5352866798121271480"), (("pornhub.com",), "🔞", "5370975411033356097"), (("likee.video", "likee.com"), "🌐", "5352672553930342216"), (("snapchat.com",), "🌐", "5352719553757466112"), (("pinterest.com", "pin.it"), "📷", "5303183810442044150"), (("steamcommunity.com", "store.steampowered.com"), "🎮", "5298975451161565553"), (("github.com",), "💻", "5303382121967001310"), ] def get_site_emoji_html(url): url_lower = (url or "").lower() for domains, fallback, premium_id in SITE_EMOJI: if any(d in url_lower for d in domains): if premium_id: return f'{fallback}' return fallback return '🎥' def extract_url_timecode(url): match = re.search(r"[?&]t=([0-9hms]+)", url) if not match: match = re.search(r"[?&]start=(\d+)", url) if match: return parse_time_to_seconds(match.group(1)) return None def parse_dlvideo_args(args_str): result = {"audio_only": False, "start": None, "end": None, "raw_quality": False, "playlist": False, "rest": ""} if not args_str: return result tokens = args_str.split() merged_tokens = [] i = 0 while i < len(tokens): tok = tokens[i] if tok == "-" and i + 1 < len(tokens) and re.fullmatch(r"[a-zA-Z]", tokens[i + 1]): merged_tokens.append("-" + tokens[i + 1]) i += 2 else: merged_tokens.append(tok) i += 1 tokens = merged_tokens rest_tokens = [] i = 0 while i < len(tokens): tok = tokens[i] low = tok.lower() if low in ("-a", "-audio", "--audio"): result["audio_only"] = True elif low in ("-q", "-quality", "--quality", "-raw", "--raw"): result["raw_quality"] = True elif low in ("-p", "-playlist", "--playlist"): result["playlist"] = True elif low in ("-s", "-start", "--start") and i + 1 < len(tokens): result["start"] = parse_time_to_seconds(tokens[i + 1]) i += 1 elif low in ("-e", "-end", "--end") and i + 1 < len(tokens): result["end"] = parse_time_to_seconds(tokens[i + 1]) i += 1 else: rest_tokens.append(tok) i += 1 result["rest"] = " ".join(rest_tokens) return result def get_random_user_agent(): agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:123.0) Gecko/20100101 Firefox/123.0", "Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", ] return random.choice(agents) async def check_proxy_health(proxy, timeout_seconds=5): """Проверяет, что прокси реально работает, а не просто отвечает на TCP-хендшейк. Для HTTP(S)-прокси делаем настоящий CONNECT-туннель к эталонному хосту — мёртвый/«полуживой» прокси, который принимает соединение, но ничего не проксирует, такую проверку не пройдёт. Для SOCKS (которому этот стек напрямую не умеет) остаётся TCP-проверка — она хотя бы отсекает явно недоступные адреса.""" try: parsed = urllib.parse.urlsplit(proxy) if not parsed.hostname or not parsed.port: return False scheme = (parsed.scheme or "http").lower() if scheme in ("http", "https"): reader, writer = await asyncio.wait_for( asyncio.open_connection(parsed.hostname, parsed.port), timeout=timeout_seconds, ) try: auth = "" if parsed.username: cred = ( f"{urllib.parse.unquote(parsed.username)}:" f"{urllib.parse.unquote(parsed.password or '')}" ) auth = "\r\nProxy-Authorization: Basic " + base64.b64encode(cred.encode()).decode() writer.write( f"CONNECT www.gstatic.com:443 HTTP/1.1\r\nHost: www.gstatic.com:443{auth}\r\n\r\n".encode() ) await writer.drain() status_line = await asyncio.wait_for(reader.readline(), timeout=timeout_seconds) while True: line = await asyncio.wait_for(reader.readline(), timeout=timeout_seconds) if line in (b"\r\n", b"\n", b""): break return status_line.startswith(b"HTTP/1.1 2") finally: writer.close() try: await writer.wait_closed() except Exception: pass reader, writer = await asyncio.wait_for( asyncio.open_connection(parsed.hostname, parsed.port), timeout=timeout_seconds ) writer.close() try: await writer.wait_closed() except Exception: pass return True except Exception: return False SPONSORBLOCK_CATEGORY_IDS = ["sponsor", "interaction", "selfpromo", "intro", "outro", "preview", "hook", "filler"] DEFAULT_SB_CATEGORIES = ["sponsor", "interaction"] MAX_DOWNLOAD_ATTEMPTS = 10 SAVEASBOT_ID = 523131145 FORMAT_STANDARD = ( 'bestvideo[height<=720][height>=480][ext=mp4]+bestaudio[ext=m4a]/' 'bestvideo[height<=720][height>=480]+bestaudio/' 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/' 'bestvideo[height<=720]+bestaudio/' 'best[height<=720][height>=480][ext=mp4]/best[height<=720][height>=480]/' 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=480]+bestaudio/' 'best[height<=480][ext=mp4]/best[height<=480]/' 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best' ) FORMAT_CAPPED_2K = ( 'bestvideo[height<=1080][height>=720]+bestaudio/best[height<=1080][height>=720]/' 'bestvideo[height<=1080]+bestaudio/best[height<=1080]/' 'bestvideo[height<=720]+bestaudio/best[height<=720]/best' ) FORMAT_RAW_1080 = ( 'bestvideo[height<=1080][height>=720][vcodec^=avc1]+bestaudio[ext=m4a]/' 'bestvideo[height<=1080][vcodec^=avc1]+bestaudio[ext=m4a]/' 'bestvideo[height<=1080][vcodec^=avc1]+bestaudio/' 'bestvideo[height<=1080]+bestaudio/best[height<=1080]/best' ) QUALITY_FORMAT_MAP = { "standard": (FORMAT_STANDARD, "mp4"), "best": (FORMAT_CAPPED_2K, "mkv"), "capped_2k": (FORMAT_CAPPED_2K, "mkv"), "raw": (FORMAT_RAW_1080, "mp4"), } QUALITY_SPEEDTEST_URL = "https://speed.cloudflare.com/__down?bytes=25000000" QUALITY_SPEEDTEST_WINDOW_SECONDS = 2.5 QUALITY_SPEEDTEST_THRESHOLD_MBPS = 80 QUALITY_FAST_LINE_THRESHOLD_MBPS = 200 QUALITY_SHORT_VIDEO_SECONDS = 300 QUALITY_LIGHT_VIDEO_SECONDS = 180 QUALITY_LONG_VIDEO_SECONDS = 3600 QUALITY_EXTENDED_VIDEO_SECONDS = 10800 MEDIUM_COMPRESS_ARGS = { "hevc_nvenc": ["-c:v", "hevc_nvenc", "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", "29", "-spatial_aq", "1", "-temporal_aq", "1", "-rc-lookahead", "32", "-b_ref_mode", "middle", "-pix_fmt", "yuv420p"], "hevc_amf": ["-c:v", "hevc_amf", "-quality", "quality", "-rc", "cqp", "-qp_i", "27", "-qp_p", "29", "-qp_b", "31", "-vbaq", "true", "-preanalysis", "true", "-pix_fmt", "yuv420p"], "hevc_qsv": ["-c:v", "hevc_qsv", "-preset", "slower", "-global_quality", "29", "-look_ahead", "1", "-pix_fmt", "nv12"], "libx264": ["-c:v", "libx264", "-preset", "medium", "-crf", "26", "-pix_fmt", "yuv420p"], } LIGHT_COMPRESS_ARGS = { "hevc_nvenc": ["-c:v", "hevc_nvenc", "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", "20", "-spatial_aq", "1", "-temporal_aq", "1", "-rc-lookahead", "32", "-b_ref_mode", "middle", "-pix_fmt", "yuv420p"], "hevc_amf": ["-c:v", "hevc_amf", "-quality", "quality", "-rc", "cqp", "-qp_i", "18", "-qp_p", "20", "-qp_b", "22", "-vbaq", "true", "-preanalysis", "true", "-pix_fmt", "yuv420p"], "hevc_qsv": ["-c:v", "hevc_qsv", "-preset", "slower", "-global_quality", "20", "-look_ahead", "1", "-pix_fmt", "nv12"], "libx264": ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p"], } COMPRESS_TIERS = {"light": LIGHT_COMPRESS_ARGS, "medium": MEDIUM_COMPRESS_ARGS} HW_ENCODER_PROBE_ORDER = ["hevc_nvenc", "hevc_amf", "hevc_qsv"] async def _ffmpeg_encoder_works(codec_args, timeout_seconds=8): probe_path = os.path.join(tempfile.gettempdir(), f"dld_hwprobe_{uuid.uuid4().hex}.mp4") try: proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=0.1", *codec_args, probe_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) try: await asyncio.wait_for(proc.wait(), timeout=timeout_seconds) except asyncio.TimeoutError: proc.kill() return False return proc.returncode == 0 and os.path.exists(probe_path) and os.path.getsize(probe_path) > 0 except Exception: return False finally: try: os.remove(probe_path) except Exception: pass async def probe_hw_encoder(): """Пробует реально закодировать тестовый кадр каждым аппаратным энкодером по очереди (NVIDIA → AMD → Intel). Первый, который завёлся — то и есть железо на этой машине. Если не завёлся ни один (нет GPU/дров — обычное дело на голом VPS/в докере) — едем на CPU.""" for encoder_name in HW_ENCODER_PROBE_ORDER: if await _ffmpeg_encoder_works(MEDIUM_COMPRESS_ARGS[encoder_name]): return encoder_name return "libx264" EMOJI_QUEUE = "🕒" class QueueCancelled(Exception): """Пользователь (или кто угодно в чате) удалил сообщение об ожидании в очереди — отменяем эту загрузку, не начиная её.""" pass class DownloadCancelled(Exception): """.dlstop — юзер сам остановил активную загрузку.""" pass class DownloadTurnQueue: """FIFO-очередь с видимыми позициями и поддержкой отмены: пока ждёшь своей очереди, в статусе видно место (Видео в очереди(N)...). Если удалить это сообщение, ожидающий сам выпадает из очереди при следующей проверке, и следующие сдвигаются вперёд — защита от OOM (см. release/acquire) остаётся той же: тяжёлые загрузки строго по одной.""" def __init__(self): self._waiters = [] async def acquire(self, status_msg, message, position_text_fn): entry = {"event": asyncio.Event(), "status_msg": status_msg, "message": message} self._waiters.append(entry) if len(self._waiters) == 1: entry["event"].set() try: while True: if self._waiters and self._waiters[0] is entry and entry["event"].is_set(): return entry try: await asyncio.wait_for(entry["event"].wait(), timeout=3) except asyncio.TimeoutError: pass if entry not in self._waiters: raise QueueCancelled() if self._waiters[0] is entry and entry["event"].is_set(): return entry position = self._waiters.index(entry) if position > 0: if not await self._still_alive(entry): self._waiters.remove(entry) self._wake_front() raise QueueCancelled() try: await status_msg.edit(position_text_fn(position)) except Exception: pass except asyncio.CancelledError: if entry in self._waiters: self._waiters.remove(entry) self._wake_front() raise def release(self, entry): if entry in self._waiters: self._waiters.remove(entry) self._wake_front() def cancel_all(self, chat_id=None): """Убирает из очереди записи, которые ещё реально ЖДУТ своей очереди (не забрали event) — или только для chat_id, если он передан. Запись активной загрузки acquire() не убирает из _waiters при выдаче хода (она там и остаётся, просто с set() событием, как "текущий фронт"), поэтому без фильтра по event.is_set() тут задваивался счёт с _active_jobs в .dlstop — одна и та же загрузка считалась и активной, и стоящей в очереди одновременно.""" removed = [] for entry in list(self._waiters): if entry["event"].is_set(): continue if chat_id is not None and getattr(entry["message"], "chat_id", None) != chat_id: continue self._waiters.remove(entry) entry["event"].set() removed.append(entry) self._wake_front() return removed def _wake_front(self): if self._waiters: self._waiters[0]["event"].set() async def _still_alive(self, entry): try: check_id = entry["status_msg"].id or entry["message"].id result = await entry["message"].client.get_messages(entry["message"].chat_id, ids=check_id) return result is not None except Exception: return True async def target_still_exists(message, status_msg): """Проверяет, что сообщение(-я), к которым привязана загрузка, всё ещё существуют — если юзер (или кто угодно в чате) удалил команду/статус посреди скачивания, не будем ни переводить озвучку, ни слать готовое видео в пустоту.""" try: check_id = status_msg.id or message.id result = await message.client.get_messages(message.chat_id, ids=check_id) return result is not None except Exception: return True class _MutedStatus: """Заглушка вместо статус-сообщения для тихого авторезюма после краша: .edit()/.delete() просто ничего не делают, чтобы весь обычный код (update_status, quality_downloading и т.д.) работал без единой правки, но ни одного нового сообщения в чат не улетало.""" id = None async def edit(self, *args, **kwargs): return None async def delete(self, *args, **kwargs): return None async def compress_video(input_path, encoder_key, tier="medium", duration_hint=None, on_progress=None, cancel_event=None): """Перегоняет видео в HEVC (или H.264 на софте) по выбранному пресету, аудио — в AAC. Возвращает путь к новому файлу или None, если сжатие не удалось (тогда шлём как скачалось). Довешивает safety-фильтр на чётность сторон — нечётная высота/ширина (нередко у вертикальных Shorts) иначе может отправить кодировщик в артефакты или вовсе уронить его. on_progress(eta_seconds) — тот же принцип, что и для скачивания: -progress pipe:1 отдаёт машиночитаемый прогресс (out_time_ms, speed), по ним считаем оставшееся время и троттлим коллбек до раза в 3 секунды, чтобы не долбить правками сообщения. cancel_event — .dlstop: раньше отмена проверялась только внутри yt-dlp прогресс-хука, то есть только во время самого скачивания — если юзер жал .dlstop уже на этапе сжатия, ничего не происходило, и видео всё равно прилетало через несколько секунд.""" preset_args = COMPRESS_TIERS.get(tier, MEDIUM_COMPRESS_ARGS) args = preset_args.get(encoder_key, preset_args["libx264"]) output_path = os.path.splitext(input_path)[0] + "_compressed.mp4" duration_total = None if duration_hint: try: duration_total = float(duration_hint) except (TypeError, ValueError): duration_total = None if not duration_total: duration_total = await probe_media_duration(input_path) use_progress = bool(on_progress and duration_total and duration_total > 0) proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", input_path, *args, "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:a", "aac", "-b:a", "192k", *(["-progress", "pipe:1", "-nostats"] if use_progress else []), output_path, stdout=asyncio.subprocess.PIPE if use_progress else asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) if use_progress: last_ts = 0.0 out_time_seconds = 0.0 speed = 1.0 while True: if cancel_event is not None and cancel_event.is_set(): try: proc.kill() except Exception: pass try: os.remove(output_path) except Exception: pass raise DownloadCancelled() try: line = await asyncio.wait_for(proc.stdout.readline(), timeout=1) except asyncio.TimeoutError: continue if not line: break try: text = line.decode("utf-8", "ignore").strip() except Exception: continue if "=" not in text: continue key, _, value = text.partition("=") if key == "out_time_ms": try: out_time_seconds = max(0, int(value)) / 1_000_000 except ValueError: pass elif key == "out_time": out_time_seconds = format_seconds_to_number(value) or out_time_seconds elif key == "speed": try: speed = float(value.rstrip("x")) or speed except ValueError: pass elif key == "progress" and value == "end": break now = time.monotonic() if now - last_ts < 3: continue remaining = max(0, duration_total - out_time_seconds) if speed <= 0: continue eta_seconds = remaining / speed last_ts = now try: await on_progress(eta_seconds) except Exception: pass await proc.wait() if proc.returncode != 0 or not os.path.exists(output_path) or os.path.getsize(output_path) == 0: try: os.remove(output_path) except Exception: pass return None if not await has_video_stream(output_path): try: os.remove(output_path) except Exception: pass return None try: os.remove(input_path) except Exception: pass return output_path async def has_video_stream(path): try: proc = await asyncio.create_subprocess_exec( "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) stdout, _ = await proc.communicate() return bool(stdout.decode().strip()) except Exception: return True async def probe_media_duration(path): """ffprobe-длительность файла в секундах (для ETA сжатия, если duration неизвестна заранее).""" try: proc = await asyncio.create_subprocess_exec( "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) stdout, _ = await proc.communicate() return float(stdout.decode().strip()) except Exception: return None def format_seconds_to_number(hms): """'HH:MM:SS.micro' -> секунды (float). Формат ffmpeg -progress out_time.""" try: parts = hms.strip().split(":") if len(parts) != 3: return None h, m, s = parts return int(h) * 3600 + int(m) * 60 + float(s) except Exception: return None _speedtest_cache = {"ts": 0.0, "mbps": None} SPEEDTEST_CACHE_TTL_SECONDS = 300 async def measure_download_speed_mbps(window_seconds=QUALITY_SPEEDTEST_WINDOW_SECONDS): """Короткий (~2.5 сек) замер входящей скорости — качаем кусок с Cloudflare, считаем Мбит/с. Результат кэшируется на 5 минут: на одном и том же сервере скорость между двумя видео подряд почти не меняется, а качать по 25 МБ под каждую ссылку — трата времени/трафика.""" now = time.monotonic() if _speedtest_cache["mbps"] is not None and now - _speedtest_cache["ts"] < SPEEDTEST_CACHE_TTL_SECONDS: return _speedtest_cache["mbps"] try: total_bytes = 0 loop = asyncio.get_event_loop() start = loop.time() timeout = aiohttp.ClientTimeout(total=window_seconds + 5) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(QUALITY_SPEEDTEST_URL) as resp: async for chunk in resp.content.iter_chunked(65536): total_bytes += len(chunk) if loop.time() - start >= window_seconds: break elapsed = loop.time() - start if elapsed <= 0 or total_bytes == 0: return None mbps = (total_bytes * 8) / elapsed / 1_000_000 _speedtest_cache["ts"] = time.monotonic() _speedtest_cache["mbps"] = mbps return mbps except Exception: return None _probe_cache = {} PROBE_CACHE_TTL_SECONDS = 600 async def quick_probe_duration(url, timeout_seconds=6): """Лёгкий пик метаданных без скачивания — узнаём длительность видео заранее, чтобы решить, нужен ли спидтест вообще и не отвалиться по лимиту потом. Результат кэшируется на 10 минут — повторная загрузка той же ссылки не пингует YouTube снова.""" now = time.monotonic() cached = _probe_cache.get(url) if cached is not None and now - cached[0] < PROBE_CACHE_TTL_SECONDS: return cached[1] def _extract(): opts = {"quiet": True, "no_warnings": True, "skip_download": True, "noplaylist": True} with yt_dlp.YoutubeDL(opts) as ydl: info = ydl.extract_info(url, download=False) return info.get("duration") try: duration = await asyncio.wait_for(asyncio.to_thread(_extract), timeout=timeout_seconds) except Exception: return None _probe_cache[url] = (time.monotonic(), duration) if len(_probe_cache) > 200: _probe_cache.clear() return duration async def probe_playlist_entries(url, cookies_text=None, proxy=None, max_entries=50, timeout_seconds=30): """Плоский список роликов внутри плейлиста (YouTube) — flat-экстракт без скачивания каждого элемента. Возвращает [{url, title}, ...], None — если это не плейлист/ничего не удалось достать, или список из 1 элемента — тогда вызывающий идёт обычным путём.""" cookiefile = None if cookies_text and cookies_text.strip(): cookiefile = os.path.join(tempfile.gettempdir(), f"pl_cookies_{uuid.uuid4().hex}.txt") with open(cookiefile, "w", encoding="utf-8") as f: f.write(cookies_text.strip()) def _extract(): opts = { "quiet": True, "no_warnings": True, "skip_download": True, "noplaylist": False, "extract_flat": "in_playlist", "playlistend": max_entries, } if cookiefile: opts["cookiefile"] = cookiefile if proxy: opts["proxy"] = proxy with yt_dlp.YoutubeDL(opts) as ydl: info = ydl.extract_info(url, download=False) entries = info.get("entries") if isinstance(info, dict) else None if not entries: return None out = [] for e in entries: if not e: continue entry_url = e.get("url") or e.get("webpage_url") if not entry_url: continue if not entry_url.startswith("http"): entry_url = "https://www.youtube.com/watch?v=" + entry_url out.append({"url": entry_url, "title": e.get("title")}) return out or None try: return await asyncio.wait_for(asyncio.to_thread(_extract), timeout=timeout_seconds) except Exception: return None finally: if cookiefile: try: os.remove(cookiefile) except Exception: pass async def probe_title_channel(url, cookies_text=None, timeout_seconds=8): """Лёгкий пик title/channel без скачивания — иногда метаданные доступны, даже когда сама медиа-загрузка упирается в куки. Пригождается для фолбека через @SaveAsBot, чтобы оформить итог как обычно (название, канал), а не голым файлом.""" cookiefile = None def _extract(): opts = {"quiet": True, "no_warnings": True, "skip_download": True, "noplaylist": True} if cookiefile: opts["cookiefile"] = cookiefile with yt_dlp.YoutubeDL(opts) as ydl: info = ydl.extract_info(url, download=False) title = info.get("title") channel = info.get("uploader") or info.get("channel") or info.get("uploader_id") if channel: channel = channel.lstrip("@") return title, channel try: if cookies_text and cookies_text.strip(): cookiefile = os.path.join(tempfile.gettempdir(), f"probe_cookies_{uuid.uuid4().hex}.txt") with open(cookiefile, "w", encoding="utf-8") as f: f.write(cookies_text.strip()) return await asyncio.wait_for(asyncio.to_thread(_extract), timeout=timeout_seconds) except Exception: return None, None finally: if cookiefile: try: os.remove(cookiefile) except Exception: pass async def fetch_og_preview(url, timeout_seconds=8): """Если метаданные вообще никак не достать (ссылка "не бьётся" совсем даже для пика) — последний резерв: тянем og:title/og:description прямо со страницы, как это делает предпросмотр ссылок в Telegram.""" try: timeout = aiohttp.ClientTimeout(total=timeout_seconds) headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"} async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session: async with session.get(url) as resp: status = resp.status html = await resp.text(errors="ignore") except Exception as fetch_err: logger.warning(f"fetch_og_preview: request failed for {url}: {fetch_err}") return None, None def _meta(prop): m = re.search( rf']+property=["\']og:{prop}["\'][^>]+content=["\']([^"\']*)["\']', html, re.IGNORECASE ) if not m: m = re.search( rf']+content=["\']([^"\']*)["\'][^>]+property=["\']og:{prop}["\']', html, re.IGNORECASE ) return m.group(1).strip() if m else None title, desc = _meta("title"), _meta("description") if not title and not desc: logger.warning(f"fetch_og_preview: no og:title/description found for {url} (status={status}, len={len(html)})") return title, desc async def decide_quality_mode(is_short, duration, auto_quality_enabled, is_discord, audio_only): """Решает, в каком режиме качать видео: standard / best / capped_2k. Принимает уже вычисленные is_short/duration снаружи — чтобы не пинговать yt-dlp дважды (длительность и так нужна отдельно для выбора пресета сжатия, light/medium). Правила: - Выключено в конфиге, Discord-ссылка или качаем только аудио → всегда standard. - Shorts — всегда standard, старым способом, без сжатия. На практике их "улучшенное" 2K-качество почему-то выходило хуже обычного (похоже на заниженный битрейт у формата без потолка на коротких вертикальных роликах) — не разбираясь глубже, проще откатить. - Ролик до 5 минут (но не Shorts) → best, спидтест не нужен (мелочь, скорость не важна). - Длительность не удалось узнать → standard (перестраховка, вдруг это многочасовое видео). - 200+ Мбит/с стабильно и ролик до 3 часов → capped_2k (2K, при отсутствии — 1080p). - 80+ Мбит/с стабильно и ролик до 1 часа → best (без потолка разрешения). - Иначе → standard. """ if not auto_quality_enabled or is_discord or audio_only or is_short: return "standard" if duration is not None and duration <= QUALITY_SHORT_VIDEO_SECONDS: return "best" if duration is None: return "standard" speed = await measure_download_speed_mbps() if speed is None: return "standard" if speed >= QUALITY_FAST_LINE_THRESHOLD_MBPS and duration <= QUALITY_EXTENDED_VIDEO_SECONDS: return "capped_2k" if speed >= QUALITY_SPEEDTEST_THRESHOLD_MBPS and duration <= QUALITY_LONG_VIDEO_SECONDS: return "best" return "standard" VOT_BRIDGE_SCRIPT = """function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function loadVOTClient() { if (typeof globalThis.File === "undefined") { const bufferModule = await import("node:buffer"); if (bufferModule.File) { globalThis.File = bufferModule.File; } } const mod = await import("@vot.js/node"); return { VOTClient: mod.default, videoDataUtil: mod.videoData }; } async function translateVideoUrl(videoUrl, responseLang, maxWaitSeconds) { const { VOTClient, videoDataUtil } = await loadVOTClient(); const data = await videoDataUtil.getVideoData(videoUrl); const client = new VOTClient(); const deadline = Date.now() + maxWaitSeconds * 1000; let result = await client.translateVideo({ videoData: data, requestLang: "auto", responseLang, }); if (typeof result.remainingTime === "number") { console.log(JSON.stringify({ progress: true, remainingTime: result.remainingTime })); } while (!result.translated || result.remainingTime >= 1) { if (Date.now() > deadline) { throw new Error(`Timed out waiting for translation (status ${result.status})`); } const waitMs = Math.min(Math.max(result.remainingTime, 1), 15) * 1000; await sleep(waitMs); result = await client.translateVideo({ videoData: data, requestLang: "auto", responseLang, }); if (typeof result.remainingTime === "number") { console.log(JSON.stringify({ progress: true, remainingTime: result.remainingTime })); } } return { url: result.url, translationId: result.translationId, title: data.title || null, }; } async function main() { const [, , videoUrl, responseLang = "ru", maxWaitSeconds = "480"] = process.argv; if (!videoUrl) { console.log(JSON.stringify({ ok: false, error: "no_url" })); process.exit(1); } try { const translation = await translateVideoUrl(videoUrl, responseLang, Number(maxWaitSeconds)); console.log(JSON.stringify({ ok: true, ...translation })); } catch (err) { console.log(JSON.stringify({ ok: false, error: String((err && err.message) || err) })); process.exit(1); } } main(); """ LANG_DISPLAY = { "en": "EN", "ru": "RU", "uk": "UK", "de": "DE", "ja": "JA", "es": "ES", "fr": "FR", "it": "IT", "pt": "PT", "ko": "KO", "zh": "ZH", "tr": "TR", "pl": "PL", "ar": "AR", "hi": "HI", } def lang_display(code): if not code: return "??" code = code.lower().split("-")[0] return LANG_DISPLAY.get(code, code.upper()) def get_vot_bridge_dir(): return os.path.join(utils.get_base_dir(), "vot_bridge") async def ensure_vot_bridge_ready(): bridge_dir = get_vot_bridge_dir() script_path = os.path.join(bridge_dir, "vot_bridge.mjs") node_modules_path = os.path.join(bridge_dir, "node_modules", "@vot.js") os.makedirs(bridge_dir, exist_ok=True) async with aiofiles.open(script_path, "w", encoding="utf-8") as f: await f.write(VOT_BRIDGE_SCRIPT) if not shutil.which("node") or not shutil.which("npm"): raise Exception("Node.js/npm не найдены на сервере — озвучка требует их установки отдельно") await ensure_node_version_ok() if not os.path.isdir(node_modules_path): proc = await asyncio.create_subprocess_exec( "npm", "install", "@vot.js/node", "--no-audit", "--no-fund", cwd=bridge_dir, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: raise Exception(f"npm install @vot.js/node не удался: {stderr.decode()[:300]}") return script_path class NodeVersionError(Exception): """Отдельный тип ошибки для проблем с версией Node.js — несёт готовый HTML с инструкцией по .terminal, чтобы вызывающий код показал её отдельным, не исчезающим сообщением.""" def __init__(self, html_message): self.html_message = html_message super().__init__(html_message) async def get_node_major_version(): proc = await asyncio.create_subprocess_exec( "node", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) stdout, _ = await proc.communicate() version_str = stdout.decode().strip() match = re.match(r"v?(\d+)\.", version_str) return int(match.group(1)) if match else None async def ensure_node_version_ok(minimum=20): major = await get_node_major_version() if major is not None and major >= minimum: return has_n = bool(shutil.which("n")) if not has_n: proc = await asyncio.create_subprocess_exec( "npm", "i", "-g", "n", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) await proc.communicate() has_n = proc.returncode == 0 and bool(shutil.which("n")) if not has_n: raise NodeVersionError( f"{EMOJI_WARN} Перевод не удался: нужна более новая версия Node.js ({minimum}+).\n\n" f"Введите по очереди:\n.terminal npm i -g n\n.terminal n latest" ) proc = await asyncio.create_subprocess_exec( "n", "latest", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) await proc.communicate() await asyncio.sleep(10) new_major = await get_node_major_version() if new_major is None or new_major < minimum: raise NodeVersionError( f"{EMOJI_WARN} Перевод не удался: нужна более новая версия Node.js ({minimum}+).\n\n" f"Введите:\n.terminal n latest" ) async def get_translated_audio(video_url, response_lang="ru", max_wait_seconds=480, on_progress=None): script_path = await ensure_vot_bridge_ready() proc = await asyncio.create_subprocess_exec( "node", script_path, video_url, response_lang, str(max_wait_seconds), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) result = None last_line = "" while True: line = await proc.stdout.readline() if not line: break try: text = line.decode("utf-8", "ignore").strip() except Exception: continue if not text: continue last_line = text try: parsed = json.loads(text) except Exception: continue if parsed.get("progress") and on_progress: remaining = parsed.get("remainingTime") if remaining is not None: try: await on_progress(remaining) except Exception: pass continue result = parsed stderr = await proc.stderr.read() await proc.wait() if result is None: try: result = json.loads(last_line) except Exception: raise Exception(f"Не удалось разобрать ответ моста озвучки: {stderr.decode()[:300] or last_line[:300]}") if not result.get("ok"): raise Exception(result.get("error", "неизвестная ошибка озвучки")) return result["url"], result.get("title") async def extract_audio_from_video(video_path, output_dir): """Вырезает звуковую дорожку из локального видеофайла в mp3. Для случая "реплай на видео в чате (без ссылки) + -a" — там качать через yt-dlp нечего, звук вырезаем из самого видеофайла, который уже есть в Telegram.""" output_path = os.path.join(output_dir, f"{uuid.uuid4()}.mp3") proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", video_path, "-vn", "-c:a", "libmp3lame", "-q:a", "2", output_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() if proc.returncode != 0 or not os.path.isfile(output_path) or os.path.getsize(output_path) == 0: try: os.remove(output_path) except Exception: pass return None return output_path async def mux_translated_audio(video_path, audio_url, orig_volume_percent=50, clip_start=None): output_path = video_path + ".vo.mp4" audio_temp = video_path + ".vo_audio.tmp" async with aiohttp.ClientSession() as session: async with session.get(audio_url) as resp: async with aiofiles.open(audio_temp, "wb") as f: async for chunk in resp.content.iter_chunked(8192): await f.write(chunk) extra_gain = max(0, min(100, orig_volume_percent)) / 100 audio_seek_args = ["-ss", str(clip_start)] if clip_start else [] proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", video_path, *audio_seek_args, "-i", audio_temp, "-filter_complex", f"[0:a][1:a]sidechaincompress=threshold=0.02:ratio=15:attack=50:release=400:makeup=1[ducked];" f"[ducked]volume={extra_gain}[quiet];" f"[quiet][1:a]amix=inputs=2:duration=shortest:dropout_transition=0:normalize=0[aout]", "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest", output_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() try: os.remove(audio_temp) except Exception: pass if proc.returncode != 0 or not os.path.exists(output_path): raise Exception("ffmpeg не смог вклеить переведённую дорожку") try: os.remove(video_path) except Exception: pass return output_path async def resolve_tiktok_url(url): parts = urllib.parse.urlsplit(url) return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) async def tikwm_lookup(url): resolved = await resolve_tiktok_url(url) async with aiohttp.ClientSession() as session: async with session.get( "https://www.tikwm.com/api/", params={"url": resolved}, timeout=aiohttp.ClientTimeout(total=15), ) as resp: payload = await resp.json(content_type=None) if payload.get("code") != 0: raise Exception(payload.get("msg", "tikwm вернул ошибку")) return payload.get("data") or {} async def download_file(url, output_dir, ext, min_size=512, retries=2): path = os.path.join(output_dir, f"{uuid.uuid4()}.{ext}") last_err = None for attempt in range(retries): try: async with aiohttp.ClientSession() as session: async with session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as resp: async with aiofiles.open(path, "wb") as f: async for chunk in resp.content.iter_chunked(8192): await f.write(chunk) if os.path.isfile(path) and os.path.getsize(path) >= min_size: return path last_err = Exception(f"файл слишком маленький ({os.path.getsize(path) if os.path.isfile(path) else 0} байт)") except Exception as e: last_err = e if os.path.isfile(path): try: os.remove(path) except Exception: pass raise last_err or Exception("не удалось скачать файл") async def download_tiktok_via_api(url, output_dir): data = await tikwm_lookup(url) video_url = data.get("hdplay") or data.get("play") if not video_url: raise Exception("tikwm не вернул ссылку на видео") title = data.get("title") or "TikTok" author = ((data.get("author") or {}).get("nickname")) or None file_path = await download_file(video_url, output_dir, "mp4") return file_path, title, author async def download_tiktok_audio_via_api(url, output_dir): data = await tikwm_lookup(url) music_url = data.get("music") if not music_url: raise Exception("tikwm не вернул ссылку на аудио") title = (data.get("music_info") or {}).get("title") or data.get("title") or "TikTok" author = ((data.get("author") or {}).get("nickname")) or None file_path = await download_file(music_url, output_dir, "mp3") return file_path, title, author DISCORD_RE = re.compile( r"https?://(?:cdn\.discordapp\.com|media\.discordapp\.net)/attachments/(\d+)/\d+/[^\s?]+\.(\w+)(?:\?[^\s]*)?", re.IGNORECASE, ) async def download_discord_video(url, output_dir): match = DISCORD_RE.search(url) channel_id = match.group(1) if match else None ext = match.group(2) if match else "mp4" filename = url.split("/")[-1].split("?")[0] file_path = await download_file(url, output_dir, ext) title = filename channel = f"#{channel_id}" if channel_id else None return file_path, title, channel async def download_tiktok_slideshow(url, output_dir): data = await tikwm_lookup(url) images = data.get("images") or [] if not images: return None image_paths = [] for image_url in images: image_paths.append(await download_file(image_url, output_dir, "jpg")) audio_path = None music_url = data.get("music") if music_url: audio_path = await download_file(music_url, output_dir, "mp3") title = data.get("title") or "TikTok" author = ((data.get("author") or {}).get("nickname")) or None music_title = (data.get("music_info") or {}).get("title") return image_paths, audio_path, title, author, music_title async def download_instagram_carousel(url, output_dir, cookies_text=None, proxy=None): """Instagram-карусель (несколько фото/видео в одном посте) — yt-dlp отдаёт её как playlist с несколькими entries. Обычный noplaylist=True, что используется для одиночных постов, тут не подходит — он либо хватает только первый элемент, либо (судя по багрепорту) роняет всё с ошибкой про куки. Возвращает список путей к файлам, или None если пост не карусель (обычный одиночный пост/reel — пусть идёт по старому, обычному пути).""" cookiefile = None if cookies_text and cookies_text.strip(): cookiefile = os.path.join(output_dir, f"ig_cookies_{uuid.uuid4().hex}.txt") with open(cookiefile, "w", encoding="utf-8") as f: f.write(cookies_text.strip()) random_uuid = uuid.uuid4().hex ydl_opts = { "quiet": True, "no_warnings": True, "noplaylist": False, "outtmpl": os.path.join(output_dir, f"ig_{random_uuid}_%(playlist_index)s.%(ext)s"), } if cookiefile: ydl_opts["cookiefile"] = cookiefile if proxy: ydl_opts["proxy"] = proxy def _extract_and_download(): with yt_dlp.YoutubeDL(ydl_opts) as ydl: return ydl.extract_info(url, download=True) try: info = await asyncio.wait_for(asyncio.to_thread(_extract_and_download), timeout=180) finally: if cookiefile: try: os.remove(cookiefile) except Exception: pass entries = info.get("entries") if info else None if not entries or len(entries) < 2: return None files = [] for entry in entries: if not entry: continue for rd in entry.get("requested_downloads") or []: fp = rd.get("filepath") if fp and os.path.exists(fp): files.append(fp) return files or None YANDEX_MUSIC_SIGN_KEY = "7tvSmFbyf5hJnIHhCimDDD" YANDEX_MUSIC_TRACK_RE = re.compile( r"music\.yandex\.(?:ru|com|by|kz|ua)/album/\d+/track/(\d+)", re.IGNORECASE ) YANDEX_MUSIC_TRACK_ONLY_RE = re.compile( r"music\.yandex\.(?:ru|com|by|kz|ua)/track/(\d+)", re.IGNORECASE ) YANDEX_MUSIC_ALBUM_RE = re.compile( r"music\.yandex\.(?:ru|com|by|kz|ua)/album/(\d+)(?:/|$|\?)", re.IGNORECASE ) YANDEX_MUSIC_PLAYLIST_RE = re.compile( r"music\.yandex\.(?:ru|com|by|kz|ua)/users/([^/]+)/playlists/(\d+)", re.IGNORECASE ) YANDEX_MUSIC_PLAYLIST_UUID_RE = re.compile( r"music\.yandex\.(?:ru|com|by|kz|ua)/playlists/([0-9a-fA-F-]{36})", re.IGNORECASE ) def extract_yandex_track_id(url): if not url: return None m = YANDEX_MUSIC_TRACK_RE.search(url) if m: return m.group(1) m = YANDEX_MUSIC_TRACK_ONLY_RE.search(url) if m: return m.group(1) return None def extract_yandex_album_id(url): if not url or extract_yandex_track_id(url): return None m = YANDEX_MUSIC_ALBUM_RE.search(url or "") return m.group(1) if m else None def extract_yandex_playlist(url): m = YANDEX_MUSIC_PLAYLIST_RE.search(url or "") return (m.group(1), m.group(2)) if m else None def extract_yandex_playlist_uuid(url): m = YANDEX_MUSIC_PLAYLIST_UUID_RE.search(url or "") return m.group(1) if m else None def parse_netscape_cookies(cookies_text, domain_filter=None): """Парсит cookies.txt в Netscape-формате (тот же, что и youtube_cookies) и отдаёт {name: value}, отфильтрованный по вхождению domain_filter в поле домена.""" cookies = {} if not cookies_text: return cookies for line in cookies_text.splitlines(): line = line.rstrip("\r\n") if not line.strip(): continue parts = line.split("\t") if len(parts) < 7: parts = line.split() if len(parts) < 7: continue if len(parts) > 7: parts = parts[:6] + [" ".join(parts[6:])] if parts[0] == "#": continue domain, _flag, _path, _secure, _expiry, name, value = parts[:7] if domain_filter and domain_filter not in domain.lower(): continue cookies[name] = value return cookies def _yandex_music_sign(payload): digest = hmac.new(YANDEX_MUSIC_SIGN_KEY.encode(), payload.encode(), hashlib.sha256).digest() return base64.b64encode(digest).decode().rstrip("=") def _iter_mp4_boxes(data, start, end): pos = start while pos + 8 <= end: size = struct.unpack(">I", data[pos:pos + 4])[0] btype = data[pos + 4:pos + 8].decode("latin1") data_start = pos + 8 if size == 1: if pos + 16 > end: break size = struct.unpack(">Q", data[pos + 8:pos + 16])[0] data_start = pos + 16 elif size == 0: size = end - pos if size < 8 or pos + size > end: break yield btype, pos, size, data_start pos += size _MP4_CONTAINER_BOXES = {"moov", "trak", "mdia", "minf", "stbl", "udta", "edts", "mvex", "moof", "traf"} def _find_mp4_box(data, start, end, target): for btype, box_start, size, data_start in _iter_mp4_boxes(data, start, end): if btype == target: return (box_start, size, data_start) if btype in _MP4_CONTAINER_BOXES: found = _find_mp4_box(data, data_start, box_start + size, target) if found: return found return None def demux_flac_from_mp4(data): """Яндекс.Музыка отдаёт lossless-поток завёрнутым в MP4-контейнер (codec 'flac-mp4') — тот же формат, что и расширение получает и распаковывает у себя в браузере (см. функцию me() в его content.js). Достаём raw FLAC: находим 'mdat' (сырые сэмплы) и 'dfLa' box внутри 'stsd' (там лежат оригинальные FLAC metadata-блоки, включая STREAMINFO), склеиваем. Упрощение относительно расширения: сэмплы берём из mdat одним куском по смещению/размеру (без разбора stsz/stsc/stco) — для одиночного аудиотрека (один run, не фрагментированный moof/mfra) сэмплы и так лежат в mdat подряд, этого достаточно.""" n = len(data) mdat = _find_mp4_box(data, 0, n, "mdat") if not mdat: raise ValueError("mdat box not found") mdat_start, mdat_size, mdat_data_start = mdat stsd = _find_mp4_box(data, 0, n, "stsd") if not stsd: raise ValueError("stsd box not found") stsd_start, stsd_size, stsd_data_start = stsd sample_entries_start = stsd_data_start + 4 + 4 sample_entries_end = stsd_start + stsd_size flac_entry = None for btype, box_start, size, data_start in _iter_mp4_boxes(data, sample_entries_start, sample_entries_end): if btype == "fLaC": flac_entry = (box_start, size, data_start) break if not flac_entry: raise ValueError("fLaC sample entry not found in stsd") flac_start, flac_size, flac_data_start = flac_entry dfla = None search_end = flac_start + flac_size pos = flac_data_start while pos + 8 <= search_end: if data[pos + 4:pos + 8] == b"dfLa": box_size = struct.unpack(">I", data[pos:pos + 4])[0] if box_size >= 8 and pos + box_size <= search_end: dfla = (pos, box_size, pos + 8) break pos += 1 if not dfla: raise ValueError("dfLa box not found") dfla_start, dfla_size, dfla_data_start = dfla meta_start = dfla_data_start + 4 meta_end = dfla_start + dfla_size meta_bytes = data[meta_start:meta_end] if not meta_bytes: raise ValueError("empty FLAC metadata in dfLa") blocks = [] pos2 = 0 while pos2 + 4 <= len(meta_bytes): header_byte = meta_bytes[pos2] block_len = (meta_bytes[pos2 + 1] << 16) | (meta_bytes[pos2 + 2] << 8) | meta_bytes[pos2 + 3] total = 4 + block_len if pos2 + total > len(meta_bytes): break blocks.append(bytearray(meta_bytes[pos2:pos2 + total])) pos2 += total if header_byte & 0x80: break if not blocks: raise ValueError("no FLAC metadata blocks parsed from dfLa") for b in blocks[:-1]: b[0] &= 0x7F blocks[-1][0] |= 0x80 flac_bytes = b"fLaC" + b"".join(bytes(b) for b in blocks) + data[mdat_data_start:mdat_start + mdat_size] return flac_bytes async def download_yandex_music_track(url, output_dir, cookies_text=None, prefer_flac=False): """Скачивание трека Яндекс.Музыки через их же неофициальный web-API — теми же запросами (get-file-info с HMAC-подписью), что использует расширение Yandex Music Downloader. yt-dlp с Я.Музыкой почти не справляется: без залогиненного аккаунта отдаёт максимум превью на 30-60 секунд. Нужны куки залогиненного аккаунта music.yandex.* — берутся из той же настройки youtube_cookies (это просто общий cookies.txt, туда можно добавить куки с любых доменов, не только YouTube). prefer_flac=True — пробуем lossless (MP4-обёрнутый FLAC, распаковываем сами), если недоступно — тихий фолбек на mp3 320.""" track_id = extract_yandex_track_id(url) if not track_id: raise ValueError("Не удалось определить ID трека из ссылки Яндекс.Музыки") yandex_cookies = parse_netscape_cookies(cookies_text, domain_filter="yandex") if not yandex_cookies: raw_len = len(cookies_text) if cookies_text else 0 raw_has_word = "yandex" in (cookies_text or "").lower() raise ValueError( "Нужны куки залогиненного аккаунта music.yandex.ru — добавьте их в youtube_cookies " "(команда .cfg YouTube-DLD youtube_cookies). " f"[диагностика: конфиг прочитан, длина {raw_len} симв., слово 'yandex' в тексте: " f"{'есть' if raw_has_word else 'НЕТ'}, распознано строк кук: 0]" ) cookie_header = "; ".join(f"{k}={v}" for k, v in yandex_cookies.items()) api_headers = { "x-yandex-music-client": "YandexMusicWebNext/1.0.0", "x-yandex-music-without-invocation-info": "1", "X-Requested-With": "XMLHttpRequest", "Referer": "https://music.yandex.ru/", "Cookie": cookie_header, } async with aiohttp.ClientSession() as session: title, artist, album = "Unknown Track", "Unknown Artist", None cover_bytes = None duration_sec = 0 meta_debug = None try: meta_headers = dict(api_headers, **{"Content-Type": "application/x-www-form-urlencoded"}) async with session.post( "https://api.music.yandex.ru/tracks", data=f"trackIds={track_id}&removeDuplicates=false&withProgress=true", headers=meta_headers, timeout=aiohttp.ClientTimeout(total=15), ) as resp: if resp.status == 200: meta_json = await resp.json(content_type=None) result = meta_json.get("result") if isinstance(meta_json, dict) else meta_json track_meta = (result or [None])[0] if track_meta: title = track_meta.get("title") or title artists = track_meta.get("artists") or [] artist_names = ", ".join(a.get("name", "") for a in artists if a.get("name")) artist = artist_names or artist duration_sec = int((track_meta.get("durationMs") or 0) / 1000) albums = track_meta.get("albums") or [] album = (albums[0].get("title") if albums else None) or album cover_uri = track_meta.get("coverUri") or (albums[0].get("coverUri") if albums else None) if cover_uri: cover_url = "https://" + cover_uri.replace("%%", "400x400") try: async with session.get(cover_url, timeout=aiohttp.ClientTimeout(total=15)) as cover_resp: if cover_resp.status == 200: cover_bytes = await cover_resp.read() except Exception as cover_err: logger.warning(f"Yandex Music: cover fetch failed: {cover_err}") else: meta_debug = "пустой result" else: meta_debug = f"HTTP {resp.status}" except Exception as meta_err: meta_debug = str(meta_err)[:150] logger.warning(f"Yandex Music: metadata fetch failed: {meta_err}") async def fetch_stream_url(quality, codec, transport): ts = int(time.time()) sign = _yandex_music_sign(f"{ts}{track_id}{quality}{codec}{transport}") file_info_url = ( f"https://api.music.yandex.ru/get-file-info?ts={ts}&trackId={track_id}" f"&quality={quality}&codecs={codec}&transports={transport}&sign={urllib.parse.quote(sign)}" ) async with session.get(file_info_url, headers=api_headers, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status != 200: raise ValueError(f"Яндекс.Музыка API вернула {resp.status} — куки протухли или трек недоступен") info_json = await resp.json(content_type=None) download_info = (info_json or {}).get("downloadInfo") or {} stream_url = download_info.get("url") if not stream_url: raise ValueError("Яндекс.Музыка не отдала ссылку на поток — куки протухли или трек недоступен без подписки") return urllib.parse.unquote(stream_url) used_flac = False stream_bytes = None stream_url = None if prefer_flac: try: stream_url = await fetch_stream_url("lossless", "flac-mp4", "raw") async with session.get(stream_url, timeout=aiohttp.ClientTimeout(total=180)) as resp: if resp.status != 200: raise ValueError(f"HTTP {resp.status} при скачивании lossless-потока") raw_bytes = await resp.read() stream_bytes = demux_flac_from_mp4(raw_bytes) used_flac = True except Exception as flac_err: logger.warning(f"Yandex Music: FLAC unavailable, falling back to mp3: {flac_err}") used_flac = False stream_bytes = None if not used_flac: stream_url = await fetch_stream_url("hq", "mp3", "raw") safe_name = re.sub(r'[\\/*?:"<>|]', "_", f"{artist} - {title}").strip()[:150] or track_id ext = "flac" if used_flac else "mp3" out_path = os.path.join(output_dir, f"{safe_name}.{ext}") if used_flac: async with aiofiles.open(out_path, "wb") as f: await f.write(stream_bytes) else: async with session.get(stream_url, timeout=aiohttp.ClientTimeout(total=180)) as resp: if resp.status != 200: raise ValueError(f"Не удалось скачать сам файл трека (HTTP {resp.status})") async with aiofiles.open(out_path, "wb") as f: async for chunk in resp.content.iter_chunked(1024 * 256): await f.write(chunk) try: if used_flac: flac_tags = FLAC(out_path) flac_tags["title"] = title flac_tags["artist"] = artist if album: flac_tags["album"] = album if cover_bytes: pic = FlacPicture() pic.data = cover_bytes pic.type = 3 pic.mime = "image/jpeg" flac_tags.clear_pictures() flac_tags.add_picture(pic) flac_tags.save() if not duration_sec: try: duration_sec = int(flac_tags.info.length) except Exception: pass else: try: tags = ID3(out_path) except ID3NoHeaderError: tags = ID3() tags["TIT2"] = TIT2(encoding=3, text=title) tags["TPE1"] = TPE1(encoding=3, text=artist) if album: tags["TALB"] = TALB(encoding=3, text=album) if cover_bytes: tags["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_bytes) tags.save(out_path) if not duration_sec: try: duration_sec = int(MP3(out_path).info.length) except Exception: pass except Exception as tag_err: logger.warning(f"Yandex Music: tagging failed: {tag_err}") return out_path, title, artist, album, duration_sec, cover_bytes, meta_debug async def _yandex_cookie_header(cookies_text): yandex_cookies = parse_netscape_cookies(cookies_text, domain_filter="yandex") if not yandex_cookies: raise ValueError( "Нужны куки залогиненного аккаунта music.yandex.ru — добавьте их в youtube_cookies" ) return yandex_cookies, "; ".join(f"{k}={v}" for k, v in yandex_cookies.items()) async def _yandex_oauth_from_cookies(cookies_text): yandex_cookies, cookie_header = await _yandex_cookie_header(cookies_text) session_id = yandex_cookies.get("Session_id") or yandex_cookies.get("sessionid2") if not session_id: return None try: async with aiohttp.ClientSession() as session: async with session.post( "https://mobileproxy.passport.yandex.net/1/bundle/oauth/token_by_sessionid", data={ "client_id": "c0ebe342af7d48fbbbfcf2d2eedb8f9e", "client_secret": "ad0a908f0aa341a182a37ecd75bc319e", }, headers={ "Ya-Client-Host": "passport.yandex.ru", "Ya-Client-Cookie": f"Session_id={session_id}", "Content-Type": "application/x-www-form-urlencoded", }, timeout=aiohttp.ClientTimeout(total=15), ) as resp: if resp.status != 200: return None data = await resp.json(content_type=None) x_token = (data or {}).get("access_token") if not x_token: return None async with session.post( "https://oauth.mobile.yandex.net/1/token", data={ "client_id": "23cabbbdc6cd418abb4b39c32c41195d", "client_secret": "53bc75238f0c4d08a118e51fe9203300", "grant_type": "x-token", "access_token": x_token, }, timeout=aiohttp.ClientTimeout(total=15), ) as resp2: if resp2.status != 200: return x_token data2 = await resp2.json(content_type=None) return (data2 or {}).get("access_token") or x_token except Exception: return None async def _yandex_api_headers(cookies_text): _, cookie_header = await _yandex_cookie_header(cookies_text) headers = { "x-yandex-music-client": "YandexMusicWebNext/1.0.0", "x-yandex-music-without-invocation-info": "1", "X-Requested-With": "XMLHttpRequest", "Referer": "https://music.yandex.ru/", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Cookie": cookie_header, } oauth = await _yandex_oauth_from_cookies(cookies_text) if oauth: headers["Authorization"] = f"OAuth {oauth}" return headers def _yandex_artists_line(obj): if not isinstance(obj, dict): return "" names = [a.get("name", "") for a in (obj.get("artists") or []) if isinstance(a, dict) and a.get("name")] return ", ".join(names) def _yandex_playlist_debug_hint(result): if not isinstance(result, dict): return f" (result type={type(result).__name__})" tracks_val = result.get("tracks") parts = [ f"trackCount={result.get('trackCount')}", f"tracks={'list[' + str(len(tracks_val)) + ']' if isinstance(tracks_val, list) else type(tracks_val).__name__}", ] if isinstance(tracks_val, list) and tracks_val and isinstance(tracks_val[0], dict): parts.append(f"item_keys={sorted(tracks_val[0].keys())}") parts.append(f"result_keys={sorted(result.keys())[:12]}") if not result: parts.append("raw=" + repr(result)[:300]) return " (" + ", ".join(parts) + ")" def _yandex_collect_track_ids(result): track_ids = [] if not isinstance(result, dict): return track_ids for vol in (result.get("volumes") or []): for t in (vol or []): if isinstance(t, dict): tid = t.get("id") or t.get("realId") if tid is not None: track_ids.append(str(tid).split(":")[0]) if track_ids: return track_ids for t in (result.get("tracks") or []): if not isinstance(t, dict): continue track = t.get("track") if isinstance(t.get("track"), dict) else t tid = track.get("id") or track.get("realId") or t.get("id") or t.get("trackId") if tid is not None: track_ids.append(str(tid).split(":")[0]) return track_ids async def _yandex_get_json(session, url, headers): async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=20)) as resp: body = await resp.text() status = resp.status try: return status, json.loads(body) except Exception: return status, None async def _yandex_download_cover(session, cover_uri): if not cover_uri: return None try: url = "https://" + str(cover_uri).replace("%%", "400x400") async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: return await resp.read() except Exception: pass return None async def fetch_yandex_album_tracks(album_id, cookies_text=None): headers = await _yandex_api_headers(cookies_text) async with aiohttp.ClientSession() as session: for url in ( f"https://api.music.yandex.ru/albums/{album_id}/with-tracks", f"https://api.music.yandex.ru/albums/{album_id}", ): status, data = await _yandex_get_json(session, url, headers) if status != 200 or not isinstance(data, dict): continue result = data.get("result", data) track_ids = _yandex_collect_track_ids(result) if track_ids: title = (result.get("title") if isinstance(result, dict) else None) or f"Album {album_id}" artists = _yandex_artists_line(result if isinstance(result, dict) else {}) cover_bytes = await _yandex_download_cover( session, result.get("coverUri") if isinstance(result, dict) else None ) return title, artists, track_ids, cover_bytes page_url = f"https://music.yandex.ru/album/{album_id}" try: async with session.get( page_url, headers={ "User-Agent": headers.get("User-Agent", "Mozilla/5.0"), "Cookie": headers.get("Cookie", ""), "Referer": "https://music.yandex.ru/", }, timeout=aiohttp.ClientTimeout(total=20), ) as resp: html = await resp.text() except Exception: html = "" html_ids = re.findall(r'"id"\s*:\s*"?(\d{5,})"?\s*,\s*"realId"', html) or re.findall(r'/track/(\d+)', html) seen, track_ids = set(), [] for tid in html_ids: if tid != str(album_id) and tid not in seen: seen.add(tid) track_ids.append(tid) if track_ids: return f"Album {album_id}", "", track_ids, None raise ValueError( "Не удалось получить треки альбома. Обновите куки music.yandex.ru " "(нужен Session_id; для списка треков требуется авторизация)" ) async def fetch_yandex_playlist_tracks(user, kind, cookies_text=None): headers = await _yandex_api_headers(cookies_text) url = f"https://api.music.yandex.ru/users/{urllib.parse.quote(user)}/playlists/{kind}" async with aiohttp.ClientSession() as session: status, data = await _yandex_get_json(session, url, headers) if status != 200 or not isinstance(data, dict): raise ValueError( "Не удалось получить плейлист. Обновите куки music.yandex.ru (нужен Session_id)" ) result = data.get("result") if not isinstance(result, dict) or not result: result = data if isinstance(data, dict) else {} track_ids = _yandex_collect_track_ids(result) if not track_ids: raise ValueError("В плейлисте нет треков" + _yandex_playlist_debug_hint(result)) title = result.get("title") or f"Playlist {kind}" owner = result.get("owner") or {} artists = owner.get("name") or owner.get("login") or "" cover_bytes = await _yandex_download_cover(session, result.get("coverUri") or result.get("ogImage")) return title, artists, track_ids, cover_bytes async def fetch_yandex_playlist_by_uuid(playlist_uuid, cookies_text=None): headers = await _yandex_api_headers(cookies_text) url = f"https://api.music.yandex.ru/playlist/{playlist_uuid}" async with aiohttp.ClientSession() as session: status, data = await _yandex_get_json(session, url, headers) if status != 200 or not isinstance(data, dict): raise ValueError( "Не удалось получить плейлист. Обновите куки music.yandex.ru (нужен Session_id)" ) result = data.get("result") if not isinstance(result, dict) or not result: result = data if isinstance(data, dict) else {} track_ids = _yandex_collect_track_ids(result) if not track_ids: raise ValueError("В плейлисте нет треков" + _yandex_playlist_debug_hint(result)) title = result.get("title") or "Playlist" owner = result.get("owner") or {} artists = owner.get("name") or owner.get("login") or "" cover_bytes = await _yandex_download_cover(session, result.get("coverUri") or result.get("ogImage")) return title, artists, track_ids, cover_bytes async def _yandex_stamp_cover(path, cover_bytes): """Вшивает обложку в mp3/flac. Сначала ffmpeg (как в Telegram/проводнике), иначе mutagen.""" if not path or not cover_bytes or not os.path.isfile(path): return cover_path = path + ".cover.jpg" out_path = path + ".covtmp" + os.path.splitext(path)[1] try: async with aiofiles.open(cover_path, "wb") as cf: await cf.write(cover_bytes) is_mp3 = path.lower().endswith(".mp3") cmd = [ "ffmpeg", "-y", "-i", path, "-i", cover_path, "-map", "0:a", "-map", "1", "-c", "copy", ] if is_mp3: cmd += ["-id3v2_version", "3"] cmd += [ "-metadata:s:v", "title=Album cover", "-metadata:s:v", "comment=Cover (front)", "-disposition:v:0", "attached_pic", out_path, ] proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) try: await asyncio.wait_for(proc.communicate(), timeout=60) except asyncio.TimeoutError: try: proc.kill() except Exception: pass proc = None if proc is not None and proc.returncode == 0 and os.path.isfile(out_path): os.replace(out_path, path) return logger.warning( f"Yandex cover stamp: ffmpeg mux failed (code={proc.returncode if proc else 'timeout'}) " f"for {os.path.basename(path)}, falling back to mutagen" ) if path.lower().endswith(".flac"): tags = FLAC(path) pic = FlacPicture() pic.data = cover_bytes pic.type = 3 pic.mime = "image/jpeg" tags.clear_pictures() tags.add_picture(pic) tags.save() else: try: tags = ID3(path) except ID3NoHeaderError: tags = ID3() tags.delall("APIC") tags.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_bytes)) tags.save(path) except Exception as e: logger.warning(f"Yandex cover stamp failed: {e}") finally: for p in (cover_path, out_path): try: if os.path.isfile(p): os.remove(p) except Exception: pass async def _send_audio_album(client, entity, items, reply_to_id=None, silent=True): """Настоящий Telegram-альбом (messages.sendMultiMedia) с превью и атрибутами НА КАЖДОМ файле — одним чанком (до 10 штук). utils.answer_file/send_file с файлом-списком уходит в herokutl _send_album, а та не прокидывает thumb/attributes ни в один файл пачки, кроме случая одиночной отправки — поэтому в обычном пачечном send'е обложка и не показывалась нигде, кроме последнего трека, отправленного отдельным вызовом. Тут каждый файл проходит через client._file_to_media(..., attributes=, thumb=) индивидуально (ровно то же самое, что делает официальный клиент при перетаскивании нескольких файлов), и только потом всё собирается в один messages.sendMultiMedia. items: [{"path", "attributes", "thumb", "caption"}, ...] (caption обычно только у последнего).""" input_entity = await client.get_input_entity(entity) media_list = [] for item in items: fh, fm, _ = await client._file_to_media( item["path"], attributes=item.get("attributes"), thumb=item.get("thumb"), ) if isinstance(fm, InputMediaUploadedDocument): uploaded = await client(UploadMediaRequest(input_entity, media=fm)) fm = herokutl_utils.get_input_media(uploaded.document) caption_html = item.get("caption") or "" if caption_html: caption_text, entities = await client._parse_message_text(caption_html, "HTML") else: caption_text, entities = "", None media_list.append(InputSingleMedia(fm, message=caption_text, entities=entities)) request = SendMultiMediaRequest( input_entity, reply_to=InputReplyToMessage(reply_to_id) if reply_to_id else None, multi_media=media_list, silent=silent, ) return await client(request) def _ru_track_word(n): if n % 10 == 1 and n % 100 != 11: return "трек" if 2 <= n % 10 <= 4 and not (12 <= n % 100 <= 14): return "трека" return "треков" async def download_yandex_track_by_id(track_id, output_dir, cookies_text=None, prefer_flac=False): fake_url = f"https://music.yandex.ru/track/{track_id}" return await download_yandex_music_track( fake_url, output_dir, cookies_text=cookies_text, prefer_flac=prefer_flac ) async def _resolve_input_peer(client, peer_id): try: return await client.get_input_entity(peer_id) except (ValueError, TypeError): await client.get_entity(peer_id) return await client.get_input_entity(peer_id) async def get_dialog_archived(client, peer_id): """True/False — в архиве ли диалог с этим peer сейчас. None, если не удалось определить (тогда трогать архивное состояние вообще не будем — не рискуем перепутать).""" try: input_peer = await _resolve_input_peer(client, peer_id) result = await client(GetPeerDialogsRequest(peers=[InputDialogPeer(input_peer)])) if result.dialogs: return bool(getattr(result.dialogs[0], "folder_id", 0)) return False except Exception: return None async def get_dialog_muted(client, peer_id): """True/False — заглушен ли диалог с этим peer прямо сейчас (по mute_until в будущем). None, если не удалось определить.""" try: input_peer = await _resolve_input_peer(client, peer_id) settings = await client(GetNotifySettingsRequest(peer=InputNotifyPeer(input_peer))) mute_until = getattr(settings, "mute_until", None) if not mute_until: return False return mute_until > int(time.time()) except Exception: return None async def set_dialog_muted(client, peer_id, muted): try: input_peer = await _resolve_input_peer(client, peer_id) mute_until = (2 ** 31 - 1) if muted else 0 await client(UpdateNotifySettingsRequest( peer=InputNotifyPeer(input_peer), settings=InputPeerNotifySettings(mute_until=mute_until), )) except Exception: pass async def send_tiktok_rich_slideshow(client, chat_id, image_paths, caption_html, reply_to_msg_id=None): input_peer = await client.get_input_entity(chat_id) input_photos = [] for path in image_paths: uploaded_file = await client.upload_file(path) media = await client( UploadMediaRequest(peer=input_peer, media=InputMediaUploadedPhoto(file=uploaded_file)) ) photo = media.photo input_photos.append( InputPhoto(id=photo.id, access_hash=photo.access_hash, file_reference=photo.file_reference) ) items = [ PageBlockPhoto(photo_id=ip.id, caption=PageCaption(text=TextEmpty(), credit=TextEmpty())) for ip in input_photos ] slideshow = PageBlockSlideshow(items=items, caption=PageCaption(text=TextEmpty(), credit=TextEmpty())) rich_message = InputRichMessage(blocks=[slideshow], photos=input_photos) text, entities = herokutl_html.parse(caption_html) if caption_html else ("", []) reply_to = InputReplyToMessage(reply_to_msg_id=reply_to_msg_id) if reply_to_msg_id else None await client( SendMessageRequest( peer=input_peer, message=text, entities=entities or None, rich_message=rich_message, reply_to=reply_to, silent=True, random_id=int.from_bytes(os.urandom(8), "big", signed=True), ) ) STALE_DOWNLOAD_RE = re.compile( r"^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" r"|ig_[0-9a-f]{32}" r"|ymcover_[0-9a-f]{8})" ) STALE_DOWNLOAD_MAX_AGE = 60 * 60 def cleanup_stale_downloads(base_dir, max_age=STALE_DOWNLOAD_MAX_AGE): """Удаляет старые частичные файлы загрузок в base_dir. Возвращает число удалённых файлов.""" removed = 0 try: if not os.path.isdir(base_dir): return 0 now = time.time() for fname in os.listdir(base_dir): if not STALE_DOWNLOAD_RE.match(fname): continue fpath = os.path.join(base_dir, fname) try: if os.path.isfile(fpath) and now - os.path.getmtime(fpath) > max_age: os.remove(fpath) removed += 1 except Exception: continue except Exception: pass return removed async def download_media( url, cookies_text=None, proxy=None, deno_path=None, max_attempts=MAX_DOWNLOAD_ATTEMPTS, audio_only=False, audio_codec="mp3", sponsorblock_categories=None, start_time=None, end_time=None, on_attempt=None, on_progress=None, quality_mode="standard", cancel_event=None, ): video_format, merge_format = QUALITY_FORMAT_MAP.get(quality_mode, QUALITY_FORMAT_MAP["standard"]) progress_loop = asyncio.get_running_loop() last_progress_ts = [0.0] last_activity_ts = [time.monotonic()] speed_ema = [None] bytes_samples = [] def progress_hook(d): if cancel_event is not None and cancel_event.is_set(): raise DownloadCancelled() if d.get('status') != 'downloading': return last_activity_ts[0] = time.monotonic() if not on_progress: return now = time.monotonic() downloaded = d.get('downloaded_bytes') or 0 total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0 speed = d.get('speed') or 0 if downloaded and now: bytes_samples.append((now, downloaded)) if len(bytes_samples) > 12: bytes_samples.pop(0) if len(bytes_samples) >= 2: t0, b0 = bytes_samples[0] dt = now - t0 db = downloaded - b0 if dt >= 0.8 and db > 0: speed = db / dt if speed and speed > 0: speed_ema[0] = speed if speed_ema[0] is None else (0.35 * speed + 0.65 * speed_ema[0]) if now - last_progress_ts[0] < 2.0: return eta = None avg = speed_ema[0] if avg and total and downloaded < total: eta = (total - downloaded) / avg elif d.get('eta') is not None: eta = float(d['eta']) if eta is None or eta < 3: return last_progress_ts[0] = now asyncio.run_coroutine_threadsafe(on_progress(eta), progress_loop) output_dir = utils.get_base_dir() random_uuid = str(uuid.uuid4()) os.makedirs(output_dir, exist_ok=True) is_youtube = 'youtube.com' in url.lower() or 'youtu.be' in url.lower() or 'music.youtube.com' in url.lower() clients_to_try = ['android', 'ios', 'mweb', 'tv_embedded'] if is_youtube else [None] cookies_file = None if cookies_text and cookies_text.strip(): cleaned_cookies = cookies_text.strip() if cleaned_cookies.startswith('"') or cleaned_cookies.startswith("'"): cleaned_cookies = cleaned_cookies[1:] if cleaned_cookies.endswith('"') or cleaned_cookies.endswith("'"): cleaned_cookies = cleaned_cookies[:-1] cookies_file = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt', encoding='utf-8') cookies_file.write(cleaned_cookies) cookies_file.close() methods = [] if proxy: if await check_proxy_health(proxy): methods.append(("proxy", proxy, None)) if cookies_file: methods.append(("cookies", None, cookies_file.name)) methods.append(("direct", None, None)) attempt = 0 last_error = None use_impersonate = False try: while attempt < max_attempts: for method_name, method_proxy, method_cookiefile in methods: for client in clients_to_try: if attempt >= max_attempts: break attempt += 1 if cancel_event is not None and cancel_event.is_set(): raise DownloadCancelled() if on_attempt: await on_attempt(attempt, method_name) user_agent = get_random_user_agent() if audio_only: ydl_opts = { 'format': 'bestaudio/best', 'outtmpl': os.path.join(output_dir, f'{random_uuid}.%(ext)s'), 'noplaylist': True, 'quiet': True, 'no_warnings': True, 'http_headers': {'User-Agent': user_agent}, 'postprocessors': [ { 'key': 'FFmpegExtractAudio', 'preferredcodec': audio_codec, 'preferredquality': '320' if audio_codec != 'flac' else None, }, { 'key': 'FFmpegMetadata', 'add_metadata': True, }, { 'key': 'EmbedThumbnail', }, ], 'writethumbnail': True, } else: ydl_opts = { 'format': video_format, 'outtmpl': os.path.join(output_dir, f'{random_uuid}.%(ext)s'), 'noplaylist': True, 'merge_output_format': merge_format, 'quiet': True, 'no_warnings': True, 'http_headers': {'User-Agent': user_agent}, 'postprocessors': [], } if sponsorblock_categories: ydl_opts['postprocessors'].append({ 'key': 'SponsorBlock', 'categories': sponsorblock_categories, 'when': 'after_filter', }) ydl_opts['postprocessors'].append({ 'key': 'ModifyChapters', 'remove_sponsor_segments': sponsorblock_categories, }) if start_time is not None or end_time is not None: section = {'start_time': start_time or 0} if end_time is not None: section['end_time'] = end_time ydl_opts['download_ranges'] = lambda info, ydl_instance, section=section: [section] ydl_opts['force_keyframes_at_cuts'] = (quality_mode != "raw") ydl_opts['extractor_retries'] = 5 ydl_opts['fragment_retries'] = 15 ydl_opts['retries'] = 15 if method_proxy: ydl_opts['proxy'] = method_proxy if method_cookiefile: ydl_opts['cookiefile'] = method_cookiefile if deno_path and os.path.exists(deno_path): ydl_opts['js_runtimes'] = {'deno': {'path': deno_path}} if is_youtube and client: ydl_opts['extractor_args'] = {'youtube': {'player_client': [client]}} if use_impersonate: ydl_opts['extractor_args'] = { **ydl_opts.get('extractor_args', {}), 'generic': {'impersonate': ['']}, } ydl_opts['impersonate'] = '' ydl_opts['progress_hooks'] = [progress_hook] def _extract_and_download(ydl_opts=ydl_opts): with yt_dlp.YoutubeDL(ydl_opts) as ydl: return ydl.extract_info(url, download=True) try: last_activity_ts[0] = time.monotonic() async def _stall_watchdog(): while True: await asyncio.sleep(1) if time.monotonic() - last_activity_ts[0] > 10: return download_future = asyncio.ensure_future(asyncio.to_thread(_extract_and_download)) watchdog_future = asyncio.ensure_future(_stall_watchdog()) done, _pending = await asyncio.wait( {download_future, watchdog_future}, return_when=asyncio.FIRST_COMPLETED ) if download_future in done: watchdog_future.cancel() info_dict = download_future.result() else: raise Exception("Timed Out") if audio_only: file_path = os.path.join(output_dir, f"{random_uuid}.{audio_codec}") else: video_ext = info_dict.get('ext') or merge_format file_path = os.path.join(output_dir, f"{random_uuid}.{video_ext}") title = info_dict.get('title', 'Media') channel = info_dict.get('uploader') or info_dict.get('channel') or info_dict.get('uploader_id') if channel: channel = channel.lstrip('@') source_lang = info_dict.get('language', None) quality_info = { 'height': info_dict.get('height'), 'vcodec': info_dict.get('vcodec'), 'format_id': info_dict.get('format_id'), 'requested_format': video_format if not audio_only else None, 'duration': info_dict.get('duration'), } h = quality_info.get('height') min_h = 720 if quality_mode == "raw" else (480 if quality_mode == "standard" else None) if ( not audio_only and min_h and h and h < min_h and attempt < min(max_attempts, 6) ): try: os.remove(file_path) except Exception: pass last_error = Exception( f"Раздобыли только {h}p (format {quality_info['format_id']}) — пробую другой клиент/метод" ) await asyncio.sleep(1) continue return file_path, title, channel, source_lang, quality_info except DownloadCancelled: raise except Exception as e: error_str = str(e) last_error = e if "This video is unavailable" in error_str or "Private video" in error_str: raise Exception("Видео недоступно (приватное, удалено или только для подписчиков)") if "Unsupported URL" in error_str or "is not a valid URL" in error_str: raise Exception("Эта ссылка не поддерживается yt-dlp") if not use_impersonate and ( "cloudflare" in error_str.lower() or "403" in error_str or "impersonate" in error_str.lower() ): use_impersonate = True await asyncio.sleep(2) continue if attempt >= max_attempts: break if last_error: raise last_error raise Exception(f"Не удалось скачать после {attempt} попыток") finally: if cookies_file: try: os.unlink(cookies_file.name) except: pass MYINSTANTS_COVER_URL = "https://wsrv.nl/?url=pbs.twimg.com/profile_images/1091271508/myinstants_400x400.png" _myinstants_cover_cache = {"path": None} async def get_myinstants_cover_path(base_dir): if _myinstants_cover_cache["path"] and os.path.isfile(_myinstants_cover_cache["path"]): return _myinstants_cover_cache["path"] try: cover_path = os.path.join(base_dir, "myinstants_cover.jpg") async with aiohttp.ClientSession() as session: async with session.get(MYINSTANTS_COVER_URL, timeout=aiohttp.ClientTimeout(total=10)) as resp: if resp.status != 200: return None data = await resp.read() with open(cover_path, "wb") as f: f.write(data) _myinstants_cover_cache["path"] = cover_path return cover_path except Exception: return None def clean_twitter_title(title): """yt-dlp отдаёт заголовок твита как 'Имя канала - текст твита' — оставляем только сам текст. У твитов без текста (только медиа) в этом же поле иногда остаётся голая t.co-ссылка — в таком случае возвращаем пустую строку (без заголовка вообще).""" if not title: return title if " - " in title: title = title.split(" - ", 1)[1].strip() if re.match(r"^https?://t\.co/\S+$", title.strip(), re.IGNORECASE): return "" return title def clean_myinstants_title(title): """Заголовок страницы myinstants приходит вида 'Название - Sound Button — www.myinstants.com', но 'Sound Button' локализуется под язык страницы ('Звуковая кнопка' и т.п.) — вместо попытки перечислить все варианты просто берём всё до первого ' - ', как и с твиттером.""" if not title: return title if " - " in title: title = title.split(" - ", 1)[0].strip() return title def clean_tenor_title(title): """Заголовок страницы tenor.com приходит вида 'Название GIF - Название - Discover & Share GIFs' — берём всё до первого ' - ' (как и myinstants/твиттер), затем убираем хвостовое 'GIF', которое там всегда приклеено к самому названию.""" if not title: return title if " - " in title: title = title.split(" - ", 1)[0].strip() title = re.sub(r"\s+GIF$", "", title, flags=re.IGNORECASE).strip() return title def sanitize_media_filename(name, max_len=120, fallback="audio"): """Готовит название видео/трека для показа как имя файла: убирает переносы строк и символы, недопустимые в именах файлов на большинстве ОС, режет по длине.""" if not name: return fallback name = re.sub(r"[\r\n\t]+", " ", name).strip() name = re.sub(r'[/\\:*?"<>|]', "_", name) name = re.sub(r"\s+", " ", name).strip() if len(name) > max_len: name = name[:max_len].rstrip() return name or fallback def convert_markdown_to_html(template: str, link: str) -> str: return re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', template).replace("{link}", link) @loader.tds class YouTube_DLDMod(loader.Module): """Помогает скачивать видео с YouTube, TikTok и др. SponsorBlock вырезает рекламу, -s/-e берут только отрезок.""" __version__ = (3, 5, 1) strings = { "name": "YouTube-DLD", "no_link": EMOJI_WARN + " Пожалуйста, укажите ссылку на видео либо ответьте на сообщение с ней.", "youtube_posts_unsupported": EMOJI_WARN + " Посты YouTube временно не поддерживаются.", "default_downloading": "Загружаю видео.\n\n" + EMOJI_CLOCK + " {eta}", "default_downloading_simple": "Загружаю видео.", "eta_unknown": "...", "default_error": "Ошибка загрузки.\n\n{error}", "too_long": "Видео длиннее {minutes} мин. Скачивание отменено — лимит меняется в .cfg YouTube-DLD (max_duration).", "cancelled": EMOJI_WARN + " Загрузка отменена (.dlstop).", "nothing_to_cancel": EMOJI_WARN + " Нет активных загрузок для отмены.", "cancelled_ok": EMOJI_OK + " Остановлено загрузок: {count}", "playlist_progress": EMOJI_DOWNLOAD + " Плейлист {idx}/{total}\n\n{title}", "default_response": "Вот [ваше видео]({link})! {quality}\n\n{title}", "default_music_response": "Вот [ваше аудио]({link})!\n\n{title}", "default_channel": "👤 Канал: {channel}", "downloading_audio": EMOJI_NOTE + " Скачиваю аудио...", "quality_downloading": EMOJI_DOWNLOAD + " Качаю в улучшенном качестве...\n\nЗаймёт чуть дольше обычного.", "quality_compressing": EMOJI_COMPRESS + " Сжимаю видео перед отправкой.\n\n" + EMOJI_INFO + " Осталось ≈ {eta}", "queue_waiting": EMOJI_QUEUE + " Видео в очереди({position})...", "done_fallback": "Готово!", "extracting_audio": EMOJI_NOTE + " Вырезаю звук из видео...", "method_proxy": "прокси", "method_cookies": "куки", "method_direct": "напрямую", "cookies_required_error": EMOJI_CROSS + " Ошибка куки. Просьба вставить куки через команду .cfg YouTube-DLD youtube_cookies.", "supported_sites": """🎥 Поддерживаемые сайты: 🔴 YouTube — youtube.com, youtu.be, music.youtube.com 🎵 TikTok — tiktok.com, vt.tiktok.com, vm.tiktok.com 📸 Instagram — instagram.com 🐦 X (Twitter) — x.com, twitter.com 👥 Facebook — facebook.com 🎬 Vimeo — vimeo.com 🎮 Twitch — twitch.tv 👽 Reddit — reddit.com 🎵 Музыка: 🎧 Яндекс.Музыка — music.yandex.ru ☁️ SoundCloud — soundcloud.com 🎸 Bandcamp — bandcamp.com 🟢 Spotify — spotify.com 🇷🇺 Российские: ▶️ RuTube — rutube.ru 🔵 ВКонтакте — vk.com 🟠 Одноклассники — ok.ru Полный список поддерживаемых сайтов — тут. 📝 Команды: ▫️ .dlvideo <ссылка> — скачать видео ▫️ .dlvideo -a <ссылка> — скачать аудио ▫️ .dlvideo -s 1:30 -e 5:00 <ссылка> — только отрезок (можно и без -e) ▫️ .dlvideo -p <ссылка на плейлист> — скачать плейлист целиком (до 30 роликов, каждый отдельным сообщением) ▫️ .dvlist — список сайтов ▫️ .sblock — настройки SponsorBlock (инлайн-меню) ▫️ .dlwl — вкл/выкл автозагрузку в этом чате, .dlwl — конкретный чат, .dlwl list — список ▫️ .dlvo — то же самое, что .dlvideo, но с переводом озвучки ▫️ .dlstop — остановить все активные загрузки (идущие и в очереди), сразу во всех чатах""", "sb_state_on": "включён ✅", "sb_state_off": "выключен 🚫", "sb_main_text": EMOJI_SCISSORS + " SponsorBlock — {state}\n\n" + EMOJI_CHECK + " — вырежется при скачивании, " + EMOJI_CROSS + " — останется в видео.\nУ пункта с " + EMOJI_GEAR + " есть отдельные настройки.", "sb_master_label": "✂️ SponsorBlock — {state}", "sb_close": "❌ Закрыть", "sb_cut_answer": "✅ Буду вырезать", "sb_keep_answer": "❌ Оставляю в видео", "sb_on_answer": "✅ Включено", "sb_off_answer": "🚫 Выключено", "sb_music_label": "🎵 Немузыкальный момент", "sb_music_text": "{label}\n\nМомент внутри музыкального ролика, где самой музыки нет — например, устная подводка перед клипом.\n\nСейчас: {state}\n\nТолько на music.youtube.com: {music_only}\nЕсли включено — вырезается только когда ссылка с music.youtube.com, на обычном youtube.com сегмент не трогается.", "sb_state_cut": "вырезается", "sb_state_keep": "остаётся в видео", "sb_yes": "да", "sb_no": "нет", "sb_cut_btn": "Вырезать", "sb_keep_btn": "Оставить", "sb_music_only_btn": "Только на music.youtube.com", "sb_back": "◀️ Назад", "sb_saved": "Сохранено", "vo_translating": EMOJI_MIC + " Перевожу озвучку.\n\n" + EMOJI_INFO + " Осталось ≈ {eta}", "vo_translating_simple": EMOJI_MIC + " Перевожу озвучку.", "vo_failed": EMOJI_WARN + " Озвучка не получилась: {error}\n\nОтправляю видео без перевода...", "cat_sponsor": "📢 Спонсор", "cat_interaction": "🔔 Подписка", "cat_selfpromo": "🎗 Самореклама", "cat_intro": "⏯ Интро/пауза", "cat_outro": "🎬 Титры", "cat_preview": "⏪ Промо/повтор", "cat_hook": "👋 Вступление", "cat_filler": "💬 Отступления", } strings_en = { "no_link": EMOJI_WARN + " Please provide a video link, or reply to a message that has one.", "youtube_posts_unsupported": EMOJI_WARN + " YouTube posts are temporarily not supported.", "default_downloading": "Downloading the video.\n\n" + EMOJI_CLOCK + " {eta}", "default_downloading_simple": "Downloading the video.", "eta_unknown": "...", "default_error": "Download failed.\n\n{error}", "too_long": "The video is longer than {minutes} min. Download cancelled — the limit is set in .cfg YouTube-DLD (max_duration).", "cancelled": EMOJI_WARN + " Download cancelled (.dlstop).", "nothing_to_cancel": EMOJI_WARN + " No active downloads to cancel.", "cancelled_ok": EMOJI_OK + " Downloads stopped: {count}", "playlist_progress": EMOJI_DOWNLOAD + " Playlist {idx}/{total}\n\n{title}", "default_response": "Here's [your video]({link})! {quality}\n\n{title}", "default_music_response": "Here's [your audio]({link})!\n\n{title}", "default_channel": "👤 Channel: {channel}", "downloading_audio": EMOJI_NOTE + " Downloading audio...", "quality_downloading": EMOJI_DOWNLOAD + " Downloading in enhanced quality...\n\nTakes a bit longer than usual.", "quality_compressing": EMOJI_COMPRESS + " Compressing the video before sending.\n\n" + EMOJI_INFO + " ≈ {eta} remaining", "queue_waiting": EMOJI_QUEUE + " Video queued ({position})...", "done_fallback": "Done!", "extracting_audio": EMOJI_NOTE + " Extracting audio from the video...", "method_proxy": "proxy", "method_cookies": "cookies", "method_direct": "direct", "cookies_required_error": EMOJI_CROSS + " Cookies error. Please add cookies via .cfg YouTube-DLD youtube_cookies.", "supported_sites": """🎥 Supported sites: 🔴 YouTube — youtube.com, youtu.be, music.youtube.com 🎵 TikTok — tiktok.com, vt.tiktok.com, vm.tiktok.com 📸 Instagram — instagram.com 🐦 X (Twitter) — x.com, twitter.com 👥 Facebook — facebook.com 🎬 Vimeo — vimeo.com 🎮 Twitch — twitch.tv 👽 Reddit — reddit.com 🎵 Music: 🎧 Yandex Music — music.yandex.ru ☁️ SoundCloud — soundcloud.com 🎸 Bandcamp — bandcamp.com 🟢 Spotify — spotify.com 🇷🇺 Russian: ▶️ RuTube — rutube.ru 🔵 VK — vk.com 🟠 Odnoklassniki — ok.ru Full list of supported sites — here. 📝 Commands: ▫️ .dlvideo — download video ▫️ .dlvideo -a — download audio ▫️ .dlvideo -s 1:30 -e 5:00 — just a clip (-e is optional) ▫️ .dlvideo -p — download the whole playlist (up to 30 videos, each as a separate message) ▫️ .dvlist — list of supported sites ▫️ .sblock — SponsorBlock settings (inline menu) ▫️ .dlwl — toggle auto-download in this chat, .dlwl — specific chat, .dlwl list — list of chats ▫️ .dlvo — same as .dlvideo, but with voice-over translation ▫️ .dlstop — stop all active downloads (running and queued), across every chat at once""", "sb_state_on": "enabled ✅", "sb_state_off": "disabled 🚫", "sb_main_text": EMOJI_SCISSORS + " SponsorBlock — {state}\n\n" + EMOJI_CHECK + " — will be cut on download, " + EMOJI_CROSS + " — stays in the video.\nThe item with " + EMOJI_GEAR + " has its own extra settings.", "sb_master_label": "✂️ SponsorBlock — {state}", "sb_close": "❌ Close", "sb_cut_answer": "✅ Will cut", "sb_keep_answer": "❌ Leaving it in", "sb_on_answer": "✅ Enabled", "sb_off_answer": "🚫 Disabled", "sb_music_label": "🎵 Non-music moment", "sb_music_text": "{label}\n\nA moment inside a music video where there's no actual music — e.g. a spoken intro before the song.\n\nRight now: {state}\n\nOnly on music.youtube.com: {music_only}\nIf enabled, it's only cut when the link is from music.youtube.com — on regular youtube.com the segment is left alone.", "sb_state_cut": "will be cut", "sb_state_keep": "stays in the video", "sb_yes": "yes", "sb_no": "no", "sb_cut_btn": "Cut", "sb_keep_btn": "Keep", "sb_music_only_btn": "Only on music.youtube.com", "sb_back": "◀️ Back", "sb_saved": "Saved", "vo_translating": EMOJI_MIC + " Translating voice-over.\n\n" + EMOJI_INFO + " ≈ {eta} remaining", "vo_translating_simple": EMOJI_MIC + " Translating voice-over.", "vo_failed": EMOJI_WARN + " Voice-over failed: {error}\n\nSending the video without translation...", "cat_sponsor": "📢 Sponsor", "cat_interaction": "🔔 Subscribe reminder", "cat_selfpromo": "🎗 Self-promo", "cat_intro": "⏯ Intro/intermission", "cat_outro": "🎬 Outro/credits", "cat_preview": "⏪ Preview/recap", "cat_hook": "👋 Intro hook", "cat_filler": "💬 Filler tangent", } async def get_deno_target(self): system = platform.system() machine = platform.machine().lower() if system == "Windows": return None if system == "Darwin": return "aarch64-apple-darwin" if machine == "arm64" else "x86_64-apple-darwin" if system == "Linux": return "aarch64-unknown-linux-gnu" if machine in ("aarch64", "arm64") else "x86_64-unknown-linux-gnu" return "x86_64-unknown-linux-gnu" async def _resume_pending_downloads(self, client): """Если процесс упал посреди загрузки (крашнулся без лога, OOM и т.п.), при следующем старте здесь найдутся "осиротевшие" записи в self.db — пробуем продолжить их сами, без участия юзера. Максимум 2 попытки на загрузку, дальше — молча сдаёмся и чистим.""" active_downloads = self.get("active_downloads", {}) if not active_downloads: return MAX_RESUME_ATTEMPTS = 2 for resume_job_id, entry in list(active_downloads.items()): attempts = entry.get("attempts", 0) chat_id = entry.get("chat_id") message_id = entry.get("message_id") if attempts >= MAX_RESUME_ATTEMPTS: active_downloads.pop(resume_job_id, None) self.set("active_downloads", active_downloads) try: await client.send_message( chat_id, f"{EMOJI_WARN} Загрузка сорвалась несколько раз подряд, отменяю.", parse_mode="HTML", reply_to=message_id, ) except Exception: pass continue try: orig_message = await client.get_messages(chat_id, ids=message_id) except Exception: orig_message = None if not orig_message: active_downloads.pop(resume_job_id, None) self.set("active_downloads", active_downloads) continue stale_status_msg_id = entry.get("status_msg_id") active_downloads[resume_job_id]["attempts"] = attempts + 1 self.set("active_downloads", active_downloads) resolved_link = entry.get("link") resolved_args_raw = entry.get("args_raw") if not resolved_link: resolved_link = find_video_link_in_message(orig_message) if not resolved_link: try: reply_msg = await orig_message.get_reply_message() except Exception: reply_msg = None if reply_msg: resolved_link = find_video_link_in_message(reply_msg) if not resolved_link: debug_text = (orig_message.raw_text or "")[:200] active_downloads.pop(resume_job_id, None) self.set("active_downloads", active_downloads) fail_text = ( f"{EMOJI_WARN} Не удалось возобновить загрузку после краша: " f"в сообщении не найдена ссылка.\n\n" f"{html_escaping.escape(debug_text) or '(пусто)'}" ) try: await orig_message.edit(fail_text, parse_mode="HTML") except Exception: try: await client.send_message(chat_id, fail_text, parse_mode="HTML", reply_to=message_id) except Exception: pass continue try: await orig_message.edit( f"{EMOJI_WARN} Краш, повторяю попытку...", parse_mode="HTML" ) except Exception: pass try: await self._dlvideo_impl( orig_message, force_translate=entry.get("force_translate", False), link_override=resolved_link, quiet=True, args_override=resolved_args_raw, ) if not getattr(orig_message, "out", True) and stale_status_msg_id and stale_status_msg_id != orig_message.id: try: await client.delete_messages(chat_id, stale_status_msg_id) except Exception: pass except Exception as resume_err: logger.warning(f"Не удалось продолжить загрузку после перезапуска: {resume_err}") async def client_ready(self, client, db): self._client = client asyncio.create_task(self._resume_pending_downloads(client)) cleanup_stale_downloads(utils.get_base_dir()) try: forum_channel_id = db.get("heroku.forums", "channel_id", None) if forum_channel_id: topic = await utils.asset_forum_topic( client, db, forum_channel_id, "YouTube-DLD Logs", description="📃 Сюда прилетают логи автозагрузки модуля YouTube-DLD (тихие сбои: неподдерживаемая ссылка, ошибка скачивания и т.п.).", ) is_new = self.get("log_topic_id") != topic.id self.set("log_topic_id", topic.id) self.set("log_channel_id", forum_channel_id) if is_new: try: channel_entity = await client.get_entity(forum_channel_id) async for topic_msg in client.iter_messages(forum_channel_id, reply_to=topic.id, limit=1): await client(UpdatePinnedMessageRequest(peer=channel_entity, id=topic_msg.id, silent=True)) break except Exception: pass except Exception: pass deno_path = Path("deno") deno_which = shutil.which("deno") if self.get("deno_source") == "file": self.set("deno_source", str(deno_path.resolve())) if not deno_which and not deno_path.is_file(): logger.info("Deno не установлен, начинаю установку...") target = await self.get_deno_target() if not target: logger.warning("Windows не поддерживается для автоустановки Deno") self.set("deno_source", "install_failed") return try: async with aiohttp.ClientSession() as session: download_link = f"https://github.com/denoland/deno/releases/latest/download/deno-{target}.zip" async with session.get(download_link) as resp: if resp.status == 200: async with aiofiles.open("deno.zip", mode="wb") as f: async for chunk in resp.content.iter_chunked(8192): await f.write(chunk) logger.info("Deno успешно скачан") else: logger.error(f"Не удалось скачать Deno: HTTP {resp.status}") self.set("deno_source", "install_failed") return if Path("deno.zip").is_file(): with zipfile.ZipFile("deno.zip", "r") as zip_ref: zip_ref.extractall() os.remove("deno.zip") os.chmod(deno_path, 0o755) self.set("deno_source", str(deno_path.resolve())) logger.info(f"Deno установлен: {deno_path.resolve()}") except Exception as e: logger.error(f"Ошибка установки Deno: {e}") self.set("deno_source", "install_failed") elif deno_which: self.set("deno_source", deno_which) logger.info(f"Deno найден в системе: {deno_which}") def __init__(self): self._download_queue = DownloadTurnQueue() self._active_jobs = {} self.config = loader.ModuleConfig( loader.ConfigValue( "show_link", True, "Показывать ссылку в сообщении?", validator=loader.validators.Boolean(), ), loader.ConfigValue( "downloading_text", self.strings["default_downloading"], EMOJI_DOWNLOAD + " перед этим текстом добавляется в коде и не редактируется здесь.\n\n" "Доступные плейсхолдеры (писать ровно так, с фигурными скобками):\n" "{attempt} — номер попытки, {method} — способ (напрямую/куки/прокси), " "{eta} — оставшееся время (обновляется во время скачивания, примерно раз в 3 секунды)", ), loader.ConfigValue( "error_text", self.strings["default_error"], EMOJI_WARN + " перед этим текстом добавляется в коде и не редактируется здесь.\n\n" "Доступный плейсхолдер (писать ровно так, с фигурными скобками):\n" "{error} — текст самой ошибки", ), loader.ConfigValue( "response_text", self.strings["default_response"], "Ответ после загрузки видео. Иконка сайта добавляется в коде перед текстом и не " "редактируется здесь.\n\nДоступные плейсхолдеры: {title} — название видео, " "{quality} — разрешение и кодек (например \"720p | h264\"), пусто для аудио." ), loader.ConfigValue( "music_response_text", self.strings["default_music_response"], EMOJI_NOTE + " перед этим текстом добавляется в коде и не редактируется здесь." ), loader.ConfigValue( "show_channel", True, "Показывать название канала?", validator=loader.validators.Boolean(), ), loader.ConfigValue( "youtube_cookies", [], EMOJI_COOKIE + " Куки Netscape (ТЕКСТОМ!) — каждый сайт ОТДЕЛЬНЫМ элементом " "(«Добавить элемент»). YouTube, Яндекс.Музыка, VK, Instagram, Twitter/X — " "модуль сам оставит только нужные строки по доменам.\n\n" "Как получить: Cookie-Editor → Export → Netscape " "(youtube.com/robots.txt, music.yandex.ru/robots.txt, …).\n" "Можно вставлять весь экспорт — лишнее срежется при сохранении.\n" "Начинается с: # Netscape HTTP Cookie File", validator=loader.validators.Series(validator=loader.validators.String()), on_change=self._on_youtube_cookies_change, ), loader.ConfigValue( "proxy", "", EMOJI_GLOBE + " Прокси (опционально)\n\n" "Форматы:\n" "• HTTP: http://user:pass@host:port\n" "• SOCKS5: socks5://host:port\n\n" "Пусто — прокси не используется. Заполнено — пробуем через прокси, затем куки, затем напрямую.\n\n" + EMOJI_WARN + " Trojan/VLESS не поддерживаются!", validator=loader.validators.Hidden(), ), loader.ConfigValue( "whitelist", [], "📃 Список чатов (ID), где ссылки скачиваются автоматически. Можно редактировать прямо здесь " "или командой .dlwl в самом чате.", validator=loader.validators.Series(validator=loader.validators.TelegramID()), ), loader.ConfigValue( "auto_quality", True, "🎞 Умное качество. Если интернет достаточно быстрый и стабильный — качаю качество " "получше и сжимаю. Дольше, но чётче.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "max_duration", 0, "🕓 Лимит длительности видео в минутах (только YouTube). 0 — без лимита. " "Видео длиннее лимита не скачивается вообще (дорого качать/сжимать и рискует " "упереться в лимит Telegram на размер файла).", validator=loader.validators.Integer(minimum=0), ), loader.ConfigValue( "vo_orig_volume", 50, "🔊 Громкость оригинальной озвучки при переводе (0-100%), пока идёт перевод поверх", validator=loader.validators.Integer(minimum=0, maximum=100), ), ) def _on_youtube_cookies_change(self): raw = self.config["youtube_cookies"] if raw is None: self.config["youtube_cookies"] = [] return if isinstance(raw, str): items = [raw] else: try: items = list(raw) except TypeError: items = [str(raw)] cleaned = [] seen = set() for item in items: text = item or "" if not text.strip(): cleaned.append(text) continue c = clean_cookies_text(text) if c is None: cleaned.append(text) continue body_lines = [ ln for ln in c.splitlines() if ln.strip() and not ln.strip().startswith("#") ] if not body_lines: cleaned.append(text) continue body = "\n".join(body_lines) if body in seen: continue seen.add(body) if not c.lstrip().startswith("#"): c = "# Netscape HTTP Cookie File\n" + c cleaned.append(c) self.config["youtube_cookies"] = cleaned @loader.command() async def dvlist(self, message): """Список поддерживаемых сайтов и команд модуля""" await utils.answer(message, self.strings["supported_sites"]) @loader.command() async def sblock(self, message): """Настройки SponsorBlock — что вырезать из видео при скачивании""" await self.inline.form( text=self._sb_main_text(), message=message, reply_markup=self._sb_main_markup(), ) def _sb_main_text(self): enabled = self.get("sb_enabled", True) state = self.strings("sb_state_on") if enabled else self.strings("sb_state_off") return self.strings("sb_main_text").format(state=state) def _sb_main_markup(self): enabled = self.get("sb_enabled", True) active = self.get("sb_categories", DEFAULT_SB_CATEGORIES) state = self.strings("sb_state_on") if enabled else self.strings("sb_state_off") master_label = self.strings("sb_master_label").format(state=state) rows = [[{"text": master_label, "callback": self._sb_toggle_master}]] cat_buttons = [] for cat_id in SPONSORBLOCK_CATEGORY_IDS: label = self.strings(f"cat_{cat_id}") state_icon = "✅" if cat_id in active else "❌" cat_buttons.append({ "text": f"{label} {state_icon}", "callback": self._sb_toggle_category, "args": (cat_id,), }) for i in range(0, len(cat_buttons), 2): rows.append(cat_buttons[i:i + 2]) music_icon = "✅" if "music_offtopic" in active else "❌" rows.append([{ "text": f"{self.strings('sb_music_label')} {music_icon} ⚙️", "callback": self._sb_open_music_detail, }]) rows.append([{"text": self.strings("sb_close"), "action": "close"}]) return rows async def _sb_toggle_master(self, call): enabled = self.get("sb_enabled", True) self.set("sb_enabled", not enabled) await call.answer(self.strings("sb_off_answer") if enabled else self.strings("sb_on_answer")) await call.edit(self._sb_main_text(), reply_markup=self._sb_main_markup()) async def _sb_toggle_category(self, call, cat_id): active = list(self.get("sb_categories", DEFAULT_SB_CATEGORIES)) if cat_id in active: active.remove(cat_id) await call.answer(self.strings("sb_keep_answer")) else: active.append(cat_id) await call.answer(self.strings("sb_cut_answer")) self.set("sb_categories", active) await call.edit(self._sb_main_text(), reply_markup=self._sb_main_markup()) async def _saveasbot_fallback(self, call, link, chat_id, reply_to_id, audio_only=False): """Крайний случай: сайт поддерживается (TikTok/Instagram/Pinterest — то, что и сам @SaveAsBot умеет по его же /start), но скачать напрямую не вышло. Пересылаем ссылку @SaveAsBot, забираем у него медиа и постим в чат сами — как будто скачали своими силами, плюс подчищаем переписку с ботом, чтобы не копился мусор.""" try: await call.edit( f"{EMOJI_DOWNLOAD} " + self.config["downloading_text"].replace("{attempt}", "1").replace("{method}", "SaveAsBot").replace("{eta}", self.strings("eta_unknown")), reply_markup=None, ) except Exception: pass client = getattr(call, "client", None) or self._client was_archived = await get_dialog_archived(client, SAVEASBOT_ID) if was_archived is False: try: await client.edit_folder(SAVEASBOT_ID, 1) except Exception: pass was_muted = await get_dialog_muted(client, SAVEASBOT_ID) if was_muted is False: await set_dialog_muted(client, SAVEASBOT_ID, True) try: our_and_their_ids = [] responses = [] try: async with client.conversation(SAVEASBOT_ID, timeout=60) as conv: start_msg = await conv.send_message("/start", parse_mode=None) our_and_their_ids.append(start_msg.id) try: start_resp = await conv.get_response(timeout=10) our_and_their_ids.append(start_resp.id) except asyncio.TimeoutError: pass link_msg = await conv.send_message(link, parse_mode=None) our_and_their_ids.append(link_msg.id) first_resp = await conv.get_response() responses.append(first_resp) our_and_their_ids.append(first_resp.id) while True: try: nxt = await conv.get_response(timeout=4) responses.append(nxt) our_and_their_ids.append(nxt.id) except asyncio.TimeoutError: break except Exception as fallback_err: logger.warning( f"SaveAsBot fallback failed ({type(fallback_err).__name__}): {fallback_err}" ) try: await call.edit( f"{EMOJI_WARN} @SaveAsBot не ответил или не смог скачать:\n\n" f"{type(fallback_err).__name__}: {clean_error_text(fallback_err)}" ) except Exception: pass return media_messages = [m for m in responses if getattr(m, "media", None)] if not media_messages: try: await call.edit(f"{EMOJI_WARN} @SaveAsBot не прислал медиа в ответ.") except Exception: pass try: await client.delete_messages(SAVEASBOT_ID, our_and_their_ids, revoke=True) except Exception: pass return downloaded_files = [] is_photo = [] for m in media_messages: try: path = await client.download_media(m, file=utils.get_base_dir()) if path: downloaded_files.append(path) is_photo.append(bool(getattr(m, "photo", None))) except Exception: continue if not downloaded_files: try: await call.edit(f"{EMOJI_WARN} Не удалось скачать медиа, присланное @SaveAsBot.") except Exception: pass try: await client.delete_messages(SAVEASBOT_ID, our_and_their_ids, revoke=True) except Exception: pass return if audio_only: type_word = "Аудио" elif len(downloaded_files) > 1: type_word = None elif is_photo[0]: type_word = "Фото" else: type_word = "Видео" site_icon = get_site_emoji_html(link) if type_word is None: safe_link_attr = html_escaping.escape(link, quote=True) header = f'{site_icon} Карусель.' else: header = f"{site_icon} {type_word}" is_instagram_link = "instagram.com" in link.lower() if is_instagram_link: title_hint, channel_hint = None, None else: cookies_cfg = config_cookies_text(self.config["youtube_cookies"]) title_hint, channel_hint = await probe_title_channel(link, cookies_text=cookies_cfg) if not title_hint: og_title, og_desc = await fetch_og_preview(link) title_hint = title_hint or og_title channel_hint = channel_hint or og_desc caption = header if title_hint: safe_title = html_escaping.escape(html_escaping.unescape(title_hint)[:300]) caption += f"\n\n{safe_title}" if channel_hint and self.config["show_channel"]: safe_channel = html_escaping.escape(html_escaping.unescape(channel_hint)[:200]) caption += f"\n\n{self.strings('default_channel').replace('{channel}', safe_channel)}" try: chunks = [downloaded_files[i:i + 10] for i in range(0, len(downloaded_files), 10)] for idx, chunk in enumerate(chunks): chunk_caption = caption if idx == 0 else None await client.send_file(chat_id, chunk, caption=chunk_caption, parse_mode="HTML", reply_to=reply_to_id) finally: for f in downloaded_files: try: os.remove(f) except Exception: pass try: await client.delete_messages(SAVEASBOT_ID, our_and_their_ids, revoke=True) except Exception: pass try: await call.delete() except Exception: try: await call.edit(f"{EMOJI_OK} Готово через @SaveAsBot!", reply_markup=None) except Exception: pass finally: if was_archived is False: try: await client.edit_folder(SAVEASBOT_ID, 0) except Exception: pass if was_muted is False: await set_dialog_muted(client, SAVEASBOT_ID, False) def _sb_music_text(self): active = self.get("sb_categories", DEFAULT_SB_CATEGORIES) only_music = self.get("sb_music_only", True) state = self.strings("sb_state_cut") if "music_offtopic" in active else self.strings("sb_state_keep") music_only_text = self.strings("sb_yes") if only_music else self.strings("sb_no") return self.strings("sb_music_text").format( label=self.strings("sb_music_label"), state=state, music_only=music_only_text, ) def _sb_music_markup(self): active = self.get("sb_categories", DEFAULT_SB_CATEGORIES) is_on = "music_offtopic" in active only_music = self.get("sb_music_only", True) return [ [ {"text": f"{'✅' if is_on else '▫️'} {self.strings('sb_cut_btn')}", "callback": self._sb_set_music, "args": (True,)}, {"text": f"{'✅' if not is_on else '▫️'} {self.strings('sb_keep_btn')}", "callback": self._sb_set_music, "args": (False,)}, ], [{ "text": f"{'✅' if only_music else '▫️'} {self.strings('sb_music_only_btn')}", "callback": self._sb_toggle_music_only, }], [{"text": self.strings("sb_back"), "callback": self._sb_back}], ] async def _sb_open_music_detail(self, call): await call.edit(self._sb_music_text(), reply_markup=self._sb_music_markup()) async def _sb_set_music(self, call, cut): active = list(self.get("sb_categories", DEFAULT_SB_CATEGORIES)) if cut and "music_offtopic" not in active: active.append("music_offtopic") elif not cut and "music_offtopic" in active: active.remove("music_offtopic") self.set("sb_categories", active) await call.answer(self.strings("sb_saved")) await call.edit(self._sb_music_text(), reply_markup=self._sb_music_markup()) async def _sb_toggle_music_only(self, call): only_music = self.get("sb_music_only", True) self.set("sb_music_only", not only_music) await call.answer(self.strings("sb_saved")) await call.edit(self._sb_music_text(), reply_markup=self._sb_music_markup()) async def _sb_back(self, call): await call.edit(self._sb_main_text(), reply_markup=self._sb_main_markup()) @staticmethod def _normalize_chat_id(cid): s = str(cid).strip() if s.startswith("-100"): return int(s[4:]) if s.startswith("-") and s[1:].isdigit(): return int(s[1:]) return cid @staticmethod async def _resolve_whitelist_entity(client, cid, first=None): candidates = [cid, int(f"-100{cid}"), -cid if cid > 0 else cid] if first is not None: candidates.insert(0, first) for candidate in candidates: try: return await client.get_entity(candidate) except Exception: continue return None @staticmethod async def _format_chat_ref(client, cid, entity=None): if entity is None: entity = await YouTube_DLDMod._resolve_whitelist_entity(client, cid) if entity is None: return f"{cid}" name = tl_utils.get_display_name(entity) or str(cid) username = getattr(entity, "username", None) if isinstance(entity, tl_types.User): link_url = f"tg://user?id={entity.id}" elif username: link_url = f"https://t.me/{username}" elif isinstance(entity, tl_types.Channel): link_url = f"https://t.me/c/{entity.id}" else: return f"{name} ({cid})" return f'{name} ({cid})' @loader.command() async def dlwl(self, message): """Вкл/выкл автозагрузку ссылок. Без аргумента — этот чат, .dlwl — конкретный чат, .dlwl list — список""" args_raw = utils.get_args_raw(message).strip() if args_raw.lower() == "list": whitelist = self.config["whitelist"] if not whitelist: await utils.answer(message, "📃 Вайтлист пуст.") return lines = [] for cid in whitelist: entity = await self._resolve_whitelist_entity(message.client, cid) if entity is None: lines.append(f"• {cid} ({cid})") continue name = tl_utils.get_display_name(entity) or str(cid) username = getattr(entity, "username", None) if isinstance(entity, tl_types.User): link_url = f"tg://user?id={entity.id}" id_part = f'{cid}' name_part = f'{name}' elif username: link_url = f"https://t.me/{username}" id_part = f"{cid}" name_part = f'{name}' else: id_part = f"{cid}" name_part = f"{name}" lines.append(f"• {name_part} ({id_part})") await utils.answer(message, "📃 Автозагрузка включена в:\n\n" + "\n".join(lines)) return if args_raw: try: if re.fullmatch(r"-?\d+", args_raw): typed_id = int(args_raw) entity = await self._resolve_whitelist_entity( message.client, self._normalize_chat_id(typed_id), first=typed_id ) if entity is None: raise ValueError(args_raw) else: entity = await message.client.get_entity(args_raw) raw_chat_id = await message.client.get_peer_id(entity) chat_id = self._normalize_chat_id(raw_chat_id) except Exception: await utils.answer(message, f"❌ Не найден чат/пользователь по «{args_raw}».") return target_name = tl_utils.get_display_name(entity) or str(raw_chat_id) else: raw_chat_id = message.chat_id chat_id = self._normalize_chat_id(raw_chat_id) target_name = "этот чат" whitelist = list(self.config["whitelist"]) if chat_id in whitelist: whitelist.remove(chat_id) self.config["whitelist"] = whitelist await utils.answer(message, f"{EMOJI_FAIL} Автозагрузка выключена:\n{target_name} ({raw_chat_id})") else: whitelist.append(chat_id) self.config["whitelist"] = whitelist await utils.answer(message, f"{EMOJI_OK} Автозагрузка включена:\n{target_name} ({raw_chat_id})") @loader.watcher() async def watcher(self, message): if not isinstance(message, Message): return if message.out: return if message.media and not isinstance(message.media, tl_types.MessageMediaWebPage): return raw = (message.raw_text or "").strip() if not raw or raw.lower().startswith(".dl"): return whitelist = self.config["whitelist"] if not whitelist or self._normalize_chat_id(message.chat_id) not in whitelist: return link = find_video_link_in_message(message) if not link: return await self._dlvideo_impl(message, link_override=link, silent_errors=True) def _register_active_job(self, job_id, cancel_event, chat_id): self._active_jobs[job_id] = {"cancel_event": cancel_event, "chat_id": chat_id} def _unregister_active_job(self, job_id): self._active_jobs.pop(job_id, None) async def _download_playlist(self, message, link, audio_only, cookies, proxy, deno, answer_target, reply, status_msg, cancel_event): """Плейлист по флагу -p: качаем все ролики по одному, каждый — отдельным сообщением. Возвращает True, если это реально плейлист (обработан целиком или с пропуском упавших роликов), False — если плейлиста нет и вызывающий идёт обычным путём.""" entries = await probe_playlist_entries(link, cookies_text=cookies, proxy=proxy) if not entries or len(entries) < 2: return False entries = entries[:30] total = len(entries) try: for idx, entry in enumerate(entries, start=1): if cancel_event.is_set(): raise DownloadCancelled() entry_url = entry.get("url") entry_title = entry.get("title") or "" if not entry_url: continue try: await status_msg.edit( self.strings("playlist_progress") .replace("{idx}", str(idx)) .replace("{total}", str(total)) .replace("{title}", html_escaping.escape(entry_title or "…")), ) except Exception: pass try: media_path, _, _, _, _ = await download_media( entry_url, cookies_text=cookies, proxy=proxy, deno_path=deno, max_attempts=3, audio_only=audio_only, audio_codec="mp3", sponsorblock_categories=[], on_progress=None, cancel_event=cancel_event, quality_mode="standard", ) except DownloadCancelled: raise except Exception as entry_err: logger.warning(f"Playlist entry {idx}/{total} failed: {entry_err}") continue if not (media_path and os.path.isfile(media_path) and os.path.getsize(media_path) > 0): continue try: caption_head = convert_markdown_to_html(self.config["response_text"], entry_url) caption_head = caption_head.replace("{title}", entry_title or "").replace("{quality}", "") caption = f"{get_site_emoji_html(entry_url)} {caption_head}\n\n{idx}/{total}" try: await utils.answer_file( answer_target, media_path, caption=caption, parse_mode="HTML", silent=True, ) except TypeError as silent_err: if "silent" not in str(silent_err): raise await utils.answer_file( answer_target, media_path, caption=caption, parse_mode="HTML", ) except Exception as send_err: logger.warning(f"Playlist entry {idx}/{total} send failed: {send_err}") finally: try: os.remove(media_path) except Exception: pass finally: try: await status_msg.delete() except Exception: pass return True @loader.command(alias="dlv") async def dlvideo(self, message): """Скачать видео/аудио по ссылке. -a аудио, -s/-e начало/конец отрезка, -p плейлист (до 30 роликов)""" await self._dlvideo_impl(message, force_translate=False) @loader.command() async def dlvo(self, message): """То же самое что .dlvideo (те же флаги -a/-s/-e), но с переводом озвучки""" await self._dlvideo_impl(message, force_translate=True) @loader.command() async def dlstop(self, message): """Остановить ВСЕ активные загрузки — и идущие, и стоящие в очереди, сразу во всех чатах""" targets = list(self._active_jobs.values()) for job in targets: ev = job.get("cancel_event") if ev: ev.set() queued_removed = self._download_queue.cancel_all() for entry in queued_removed: try: await entry["status_msg"].edit(self.strings("cancelled")) except Exception: pass total = len(targets) + len(queued_removed) if total == 0: await utils.answer(message, self.strings("nothing_to_cancel")) return await utils.answer(message, self.strings("cancelled_ok").replace("{count}", str(total))) async def _dlvideo_impl(self, message, force_translate=False, link_override=None, silent_errors=False, quiet=False, args_override=None): args_raw = args_override if args_override is not None else utils.get_args_raw(message) reply = await message.get_reply_message() parsed = parse_dlvideo_args(args_raw) audio_only = parsed["audio_only"] start_time = parsed["start"] end_time = parsed["end"] raw_quality = parsed["raw_quality"] link = link_override or find_video_link_in_message(message) if not link and reply: link = find_video_link_in_message(reply) if not link and reply and audio_only and getattr(reply, "video", None): status_msg = _MutedStatus() if quiet else await utils.answer(message, self.strings("extracting_audio")) video_path = None audio_path = None try: video_path = await self._client.download_media(reply, file=utils.get_base_dir()) if not video_path: raise Exception(self.strings("done_fallback")) audio_path = await extract_audio_from_video(video_path, utils.get_base_dir()) if not audio_path: raise Exception("не удалось извлечь звук из видео (ffmpeg)") reply_title = (reply.raw_text or "").strip().splitlines()[0][:100] if reply.raw_text else "" safe_title = sanitize_media_filename(reply_title, fallback="Аудио") send_attributes = [ DocumentAttributeFilename(f"{safe_title}.mp3"), DocumentAttributeAudio(duration=0, performer=None, voice=False), ] await utils.answer_file( message, audio_path, caption=f"{EMOJI_NOTE} {safe_title}", parse_mode="HTML", reply_to=reply, attributes=send_attributes, ) try: await status_msg.delete() except Exception: pass except Exception as e: try: await status_msg.edit(f"{EMOJI_WARN} {clean_error_text(e)}") except Exception: pass finally: for p in (video_path, audio_path): if p: try: os.remove(p) except Exception: pass return if not link: await utils.answer(message, self.strings["no_link"]) return link = normalize_link(link) if is_audio_only_platform(link): audio_only = True if re.search(r"youtube\.com/post/", link.lower()): await utils.answer(message, self.strings("youtube_posts_unsupported")) return is_short_form_by_url = ( "tiktok.com" in link.lower() or (("youtube.com" in link.lower() or "youtu.be" in link.lower()) and "/shorts/" in link.lower()) or "/reel/" in link.lower() or "/clip/" in link.lower() or "clips.twitch.tv" in link.lower() or bool(re.search(r"vk\.(com|ru)/clip", link.lower())) ) if start_time is None: url_timecode = extract_url_timecode(link) if url_timecode is not None: start_time = url_timecode if start_time is not None and end_time is not None and end_time <= start_time: end_time = None if audio_only: status_msg = _MutedStatus() if quiet else await utils.answer(message, self.strings("downloading_audio")) elif is_short_form_by_url: status_msg = _MutedStatus() if quiet else await utils.answer(message, f"{EMOJI_DOWNLOAD} " + self.strings("default_downloading_simple")) else: status_msg = _MutedStatus() if quiet else await utils.answer(message, f"{EMOJI_DOWNLOAD} " + self.config["downloading_text"].replace("{attempt}", "1").replace("{method}", "...").replace("{eta}", self.strings("eta_unknown"))) answer_target = message if quiet else status_msg resume_job_id = f"{message.chat_id}:{message.id}" active_downloads = self.get("active_downloads", {}) active_downloads[resume_job_id] = { "chat_id": message.chat_id, "message_id": message.id, "force_translate": force_translate, "status_msg_id": status_msg.id, "link": link, "args_raw": args_raw, "attempts": active_downloads.get(resume_job_id, {}).get("attempts", 0), "ts": time.time(), } self.set("active_downloads", active_downloads) cancel_event = threading.Event() self._register_active_job(resume_job_id, cancel_event, message.chat_id) cookies = config_cookies_text(self.config["youtube_cookies"]) proxy = self.config["proxy"].strip() if self.config["proxy"] else None is_tiktok = "tiktok.com" in link.lower() is_instagram = "instagram.com" in link.lower() is_pinterest = "pinterest." in link.lower() or "pin.it" in link.lower() is_twitter = "twitter.com" in link.lower() or "x.com" in link.lower() is_discord = bool(DISCORD_RE.search(link)) yandex_track_id = extract_yandex_track_id(link) yandex_album_id = extract_yandex_album_id(link) yandex_playlist = extract_yandex_playlist(link) yandex_playlist_uuid = extract_yandex_playlist_uuid(link) deno = self.get("deno_source") if self.get("deno_source") not in ["install_failed", None] else None max_attempts = MAX_DOWNLOAD_ATTEMPTS sb_enabled = self.get("sb_enabled", True) sb_categories = list(self.get("sb_categories", DEFAULT_SB_CATEGORIES)) if sb_enabled else [] if "music_offtopic" in sb_categories and self.get("sb_music_only", True) and "music.youtube.com" not in link.lower(): sb_categories.remove("music_offtopic") method_labels = { "proxy": self.strings("method_proxy"), "cookies": self.strings("method_cookies"), "direct": self.strings("method_direct"), } progress = {"attempt": 1, "method": "direct", "eta": self.strings("eta_unknown"), "short_form": is_short_form_by_url} def render_downloading_text(): if progress["short_form"]: return f"{EMOJI_DOWNLOAD} " + self.strings("default_downloading_simple") eta = progress.get("eta") or self.strings("eta_unknown") if eta in ("…", "...", self.strings("eta_unknown")): return f"{EMOJI_DOWNLOAD} " + self.strings("default_downloading_simple") return ( f"{EMOJI_DOWNLOAD} Загружаю видео.\n\n" f"{EMOJI_CLOCK} {eta}" ) async def update_status(attempt, method_name): progress["attempt"] = attempt progress["method"] = method_name progress["eta"] = self.strings("eta_unknown") if not audio_only: try: await status_msg.edit(render_downloading_text()) except Exception: pass async def update_eta(eta_seconds): if audio_only or progress.get("short_form"): return if eta_seconds is None or eta_seconds < 3: return eta_text = format_seconds(max(0, int(eta_seconds))) if progress["eta"] == eta_text: return progress["eta"] = eta_text try: await status_msg.edit(render_downloading_text()) except Exception: pass was_queued = bool(self._download_queue._waiters) try: queue_entry = await self._download_queue.acquire( status_msg, message, lambda position: self.strings("queue_waiting").replace("{position}", str(position)), ) except QueueCancelled: self._unregister_active_job(resume_job_id) try: active_downloads = self.get("active_downloads", {}) active_downloads.pop(resume_job_id, None) self.set("active_downloads", active_downloads) except Exception: pass try: await status_msg.edit(self.strings("cancelled")) except Exception: pass return if was_queued: try: if audio_only: await status_msg.edit(self.strings("downloading_audio")) else: progress["attempt"] = 1 progress["method"] = "direct" progress["eta"] = self.strings("eta_unknown") await status_msg.edit(render_downloading_text()) except Exception: pass try: if parsed["playlist"]: try: handled = await self._download_playlist( message, link, audio_only, cookies, proxy, deno, answer_target, reply, status_msg, cancel_event, ) except DownloadCancelled: raise except Exception as playlist_err: logger.warning(f"Playlist download failed, falling back to single video: {playlist_err}") handled = False if handled: return tiktok_slideshow = None if is_tiktok and not audio_only and not force_translate: try: tiktok_slideshow = await download_tiktok_slideshow(link, utils.get_base_dir()) except Exception: tiktok_slideshow = None if tiktok_slideshow: image_paths, audio_path, title, channel, _ = tiktok_slideshow async def send_album_fallback(files, cap): try: await utils.answer_file( answer_target, files, caption=cap, parse_mode="HTML", reply_to=reply or message, silent=True, ) except TypeError as silent_err: if "silent" not in str(silent_err): raise await utils.answer_file( answer_target, files, caption=cap, parse_mode="HTML", reply_to=reply or message, ) try: caption = convert_markdown_to_html(self.config["response_text"], link) caption = caption.replace("{title}", title or "").replace("{quality}", "") caption = f"{get_site_emoji_html(link)} {caption}" if self.config["show_channel"] and channel: channel_text = self.strings("default_channel").replace("{channel}", channel) caption += f"\n\n{channel_text}" reply_target = reply or message reply_to_id = reply_target.id if reply_target else None slideshow_caption = None if audio_path else caption try: await send_tiktok_rich_slideshow( message.client, message.chat_id, image_paths, slideshow_caption, reply_to_id ) except Exception: chunks = [image_paths[i:i + 10] for i in range(0, len(image_paths), 10)] for idx, chunk in enumerate(chunks): fallback_cap = None if not audio_path and idx == 0: fallback_cap = caption await send_album_fallback(chunk, fallback_cap) if audio_path: await send_album_fallback(audio_path, caption) try: await status_msg.delete() except Exception: pass finally: for path in image_paths: try: os.remove(path) except Exception: pass if audio_path: try: os.remove(audio_path) except Exception: pass return instagram_files = None instagram_carousel_err = None if is_instagram and not audio_only and not force_translate: try: instagram_files = await download_instagram_carousel( link, utils.get_base_dir(), cookies_text=cookies, proxy=proxy ) except Exception as ig_err: instagram_files = None instagram_carousel_err = ig_err logger.warning(f"Instagram carousel extraction failed, falling back to single-file: {ig_err}") if instagram_files: try: site_icon = get_site_emoji_html(link) safe_link_attr = html_escaping.escape(link, quote=True) caption = f'{site_icon} Карусель.' chunks = [instagram_files[i:i + 10] for i in range(0, len(instagram_files), 10)] for idx, chunk in enumerate(chunks): chunk_caption = caption if idx == 0 else None try: await utils.answer_file( answer_target, chunk, caption=chunk_caption, parse_mode="HTML", reply_to=reply or message, silent=True, ) except TypeError as silent_err: if "silent" not in str(silent_err): raise await utils.answer_file( answer_target, chunk, caption=chunk_caption, parse_mode="HTML", reply_to=reply or message, ) try: await status_msg.delete() except Exception: pass finally: for path in instagram_files: try: os.remove(path) except Exception: pass return if yandex_track_id or yandex_album_id or yandex_playlist or yandex_playlist_uuid: try: collection_title = None collection_artists = "" collection_cover = None track_ids = [] if yandex_track_id: track_ids = [yandex_track_id] elif yandex_album_id: collection_title, collection_artists, track_ids, collection_cover = await fetch_yandex_album_tracks( yandex_album_id, cookies_text=cookies ) elif yandex_playlist: collection_title, collection_artists, track_ids, collection_cover = await fetch_yandex_playlist_tracks( yandex_playlist[0], yandex_playlist[1], cookies_text=cookies ) else: collection_title, collection_artists, track_ids, collection_cover = await fetch_yandex_playlist_by_uuid( yandex_playlist_uuid, cookies_text=cookies ) total_n = len(track_ids) safe_link_attr = html_escaping.escape(link, quote=True) downloaded = [] t0 = time.monotonic() for idx, tid in enumerate(track_ids, 1): if cancel_event is not None and cancel_event.is_set(): raise DownloadCancelled() try: if total_n > 1: elapsed = time.monotonic() - t0 if idx > 1 and elapsed > 0: per = elapsed / (idx - 1) eta_left = format_seconds(max(0, int(per * (total_n - idx + 1)))) else: eta_left = self.strings("eta_unknown") try: await status_msg.edit( f"{EMOJI_NOTE} Скачиваю {idx}/{total_n}\n" f"{EMOJI_CLOCK} {eta_left}\n" f"{html_escaping.escape(collection_title or tid)}" ) except Exception: pass if yandex_track_id: (ym_path, ym_title, ym_artist, ym_album, ym_duration, ym_cover_bytes, ym_meta_debug) = await download_yandex_music_track( link, utils.get_base_dir(), cookies_text=cookies, prefer_flac=raw_quality ) else: (ym_path, ym_title, ym_artist, ym_album, ym_duration, ym_cover_bytes, ym_meta_debug) = await download_yandex_track_by_id( tid, utils.get_base_dir(), cookies_text=cookies, prefer_flac=raw_quality ) downloaded.append({ "path": ym_path, "title": ym_title, "artist": ym_artist, "duration": ym_duration or 0, "track_cover_bytes": ym_cover_bytes if total_n == 1 else None, "flac": bool(ym_path) and ym_path.lower().endswith(".flac"), "ext": (os.path.splitext(ym_path)[1].lstrip(".") or "mp3"), }) except DownloadCancelled: for item in downloaded: p = item.get("path") if p: try: os.remove(p) except Exception: pass raise except Exception as one_err: logger.warning(f"Yandex track {tid} failed: {one_err}") continue if not downloaded: raise ValueError("Не удалось скачать ни одного трека") if cancel_event is not None and cancel_event.is_set(): for item in downloaded: p = item.get("path") if p: try: os.remove(p) except Exception: pass raise DownloadCancelled() coll_cover_path = None if collection_cover: coll_cover_path = os.path.join( utils.get_base_dir(), f"ymcover_{uuid.uuid4().hex[:8]}.jpg" ) async with aiofiles.open(coll_cover_path, "wb") as cf: await cf.write(collection_cover) if len(downloaded) == 1: item = downloaded[0] info_line = ( f"{item['title']} — {item['artist']}" if item['artist'] and item['artist'] != "Unknown Artist" else item['title'] ) if item["flac"]: caption = ( f'{get_site_emoji_html(link)} Аудио' f'. Flac\n\n{info_line}' ) else: caption_head = convert_markdown_to_html(self.config["music_response_text"], link) caption_head = caption_head.replace("{title}", "") caption_head = re.sub(r"<(\w+)>\s*\s*$", "", caption_head).rstrip() caption = f"{get_site_emoji_html(link)} {caption_head}\n\n{info_line}" if item["duration"]: caption += f"\n{EMOJI_CLOCK} {format_seconds(item['duration'])}" safe_name = sanitize_media_filename(item["title"]) audio_attributes = [ DocumentAttributeFilename(f"{safe_name}.{item['ext']}"), DocumentAttributeAudio( duration=item["duration"], title=item["title"], performer=item["artist"], ), ] thumb_path = None cover_bytes = item.get("track_cover_bytes") or collection_cover if cover_bytes: await _yandex_stamp_cover(item["path"], cover_bytes) thumb_path = os.path.join( utils.get_base_dir(), f"ymcover_{uuid.uuid4().hex[:8]}.jpg" ) async with aiofiles.open(thumb_path, "wb") as cf: await cf.write(cover_bytes) send_kwargs = dict( caption=caption, parse_mode="HTML", reply_to=reply or message, attributes=audio_attributes, ) if thumb_path: send_kwargs["thumb"] = thumb_path try: await utils.answer_file(answer_target, item["path"], silent=True, **send_kwargs) except TypeError as silent_err: if "silent" not in str(silent_err): raise await utils.answer_file(answer_target, item["path"], **send_kwargs) if thumb_path and thumb_path != coll_cover_path: try: os.remove(thumb_path) except Exception: pass else: n = len(downloaded) word = _ru_track_word(n) coll = html_escaping.escape(collection_title or "Плейлист") arts = html_escaping.escape(collection_artists or "") if not arts: from collections import Counter c = Counter( it["artist"] for it in downloaded if it.get("artist") and it["artist"] != "Unknown Artist" ) arts = html_escaping.escape(c.most_common(1)[0][0]) if c else "" info_line = f"{coll} — {arts}" if arts else coll group_caption = ( f'{get_site_emoji_html(link)} Плейлист. {n} {word}\n\n' f'{info_line}' ) try: await status_msg.edit(f"{EMOJI_NOTE} Отправляю {n} {word}...") except Exception: pass if collection_cover: for item in downloaded: await _yandex_stamp_cover(item["path"], collection_cover) paths = [it["path"] for it in downloaded] attrs_list = [] for item in downloaded: safe_name = sanitize_media_filename(item["title"]) attrs_list.append([ DocumentAttributeFilename(f"{safe_name}.{item['ext']}"), DocumentAttributeAudio( duration=item["duration"], title=item["title"], performer=item["artist"] or None, ), ]) album_items = [ { "path": p, "attributes": a, "thumb": coll_cover_path, "caption": None, } for p, a in zip(paths, attrs_list) ] album_items[-1]["caption"] = group_caption reply_id = (reply or message).id chunks = [album_items[i:i + 10] for i in range(0, len(album_items), 10)] sent_so_far = 0 for chunk in chunks: if cancel_event is not None and cancel_event.is_set(): raise DownloadCancelled() await _send_audio_album( answer_target.client, answer_target.peer_id, chunk, reply_to_id=reply_id, silent=True, ) sent_so_far += len(chunk) remaining = n - sent_so_far if remaining > 0: try: await status_msg.edit( f"{EMOJI_NOTE} Отправлено {sent_so_far}, отправляю ещё " f"{remaining} {_ru_track_word(remaining)}..." ) except Exception: pass try: await status_msg.delete() except Exception: pass if coll_cover_path: try: os.remove(coll_cover_path) except Exception: pass for item in downloaded: p = item.get("path") if p: try: os.remove(p) except Exception: pass except DownloadCancelled: try: await status_msg.edit(self.strings("cancelled")) except Exception: pass except Exception as ym_err: logger.warning(f"Yandex Music download failed: {ym_err}") detail = clean_error_text(ym_err, cookies_text=cookies, proxy=proxy) low = detail.lower() if any(x in low for x in ("куки", "cookie", "session_id", "нужны куки", "авторизац")): error_msg = cookies_error_message( "Яндекс.Музыки", "music.yandex.ru/robots.txt", detail, ) else: error_msg = f"{EMOJI_WARN} Яндекс.Музыка:\n\n{html_escaping.escape(detail)}" try: await utils.answer(answer_target, error_msg) except Exception: pass return is_youtube_link = "youtube.com" in link.lower() or "youtu.be" in link.lower() is_short_link = is_youtube_link and "/shorts/" in link.lower() try: max_duration_minutes = int(self.config["max_duration"] or 0) except (TypeError, ValueError): max_duration_minutes = 0 duration_probe = None if is_youtube_link and (self.config["auto_quality"] or max_duration_minutes) and not is_discord and not audio_only: duration_probe = await quick_probe_duration(link) if duration_probe is not None and max_duration_minutes and duration_probe > max_duration_minutes * 60: raise Exception(self.strings("too_long").replace("{minutes}", str(max_duration_minutes))) quality_mode = await decide_quality_mode( is_short_link, duration_probe, self.config["auto_quality"] and is_youtube_link, is_discord, audio_only ) if raw_quality and not audio_only: quality_mode = "raw" compress_tier = "light" if ( is_short_link or (duration_probe is not None and duration_probe <= QUALITY_LIGHT_VIDEO_SECONDS) ) else "medium" is_short_form_content = ( is_short_form_by_url or (duration_probe is not None and duration_probe <= QUALITY_LIGHT_VIDEO_SECONDS) ) if is_short_form_content and not progress["short_form"] and not audio_only: progress["short_form"] = True try: await status_msg.edit(render_downloading_text()) except Exception: pass if quality_mode != "standard" and not audio_only: try: await status_msg.edit(self.strings("quality_downloading")) except Exception: pass audio_codecs_to_try = ( ["flac", "mp3"] if (raw_quality and audio_only and is_audio_only_platform(link)) else ["mp3"] ) media = title = channel = source_lang = None quality_info = None last_err = None used_quality_path = False for audio_codec in audio_codecs_to_try: for attempt_idx in range(2): used_quality_path = False quality_info = None if is_discord: try: media, title, channel = await download_discord_video(link, utils.get_base_dir()) source_lang = None except Exception as e: last_err = e media = None if media and os.path.isfile(media) and os.path.getsize(media) > 0: if not audio_only and not await has_video_stream(media): try: os.remove(media) except Exception: pass last_err = Exception("Скачался файл без видеодорожки (только звук) — пробую ещё раз") media = None continue break media = None continue try: media, title, channel, source_lang, quality_info = await download_media( link, cookies_text=cookies, proxy=proxy, deno_path=deno, max_attempts=max_attempts, audio_only=audio_only, audio_codec=audio_codec, sponsorblock_categories=sb_categories, start_time=start_time, end_time=end_time, on_attempt=update_status, on_progress=(None if is_short_form_content else update_eta), cancel_event=cancel_event, quality_mode=quality_mode, ) used_quality_path = True except DownloadCancelled: raise except Exception as primary_err: last_err = primary_err media = None if is_tiktok: try: if audio_only: media, title, channel = await download_tiktok_audio_via_api(link, utils.get_base_dir()) else: media, title, channel = await download_tiktok_via_api(link, utils.get_base_dir()) source_lang = None except DownloadCancelled: raise except Exception: media = None elif is_instagram: try: kk_link = re.sub(r"instagram\.com", "kkinstagram.com", link, flags=re.IGNORECASE) media, title, channel, source_lang, quality_info = await download_media( kk_link, cookies_text=cookies, proxy=proxy, deno_path=deno, max_attempts=max_attempts, audio_only=audio_only, audio_codec=audio_codec, sponsorblock_categories=sb_categories, start_time=start_time, end_time=end_time, on_attempt=update_status, on_progress=(None if is_short_form_content else update_eta), cancel_event=cancel_event, quality_mode=quality_mode, ) used_quality_path = True except DownloadCancelled: raise except Exception: media = None elif is_twitter: try: fx_link = re.sub( r"(?:twitter\.com|x\.com)", "fxtwitter.com", link, flags=re.IGNORECASE ) media, title, channel, source_lang, quality_info = await download_media( fx_link, cookies_text=cookies, proxy=proxy, deno_path=deno, max_attempts=max_attempts, audio_only=audio_only, audio_codec=audio_codec, sponsorblock_categories=sb_categories, start_time=start_time, end_time=end_time, on_attempt=update_status, on_progress=(None if is_short_form_content else update_eta), cancel_event=cancel_event, quality_mode=quality_mode, ) used_quality_path = True except DownloadCancelled: raise except Exception: media = None if media and os.path.isfile(media) and os.path.getsize(media) > 0: if not audio_only and not await has_video_stream(media): try: os.remove(media) except Exception: pass last_err = Exception("Скачался файл без видеодорожки (только звук) — пробую ещё раз") media = None continue break media = None if media: break if not media: raise last_err or Exception(self.strings("done_fallback")) if used_quality_path and ( quality_mode in ("best", "capped_2k") or (quality_mode == "standard" and not is_short_form_content and not audio_only) ): try: async def update_compress_eta(eta_seconds): eta_text = format_seconds(max(0, int(eta_seconds))) if progress["eta"] == eta_text: return progress["eta"] = eta_text try: await status_msg.edit(render_downloading_text()) except Exception: pass if compress_tier == "medium": progress["eta"] = self.strings("eta_unknown") try: await status_msg.edit(render_downloading_text()) except Exception: pass hw_encoder = self.get("hw_encoder") if hw_encoder not in MEDIUM_COMPRESS_ARGS: hw_encoder = await probe_hw_encoder() self.set("hw_encoder", hw_encoder) compressed = await compress_video( media, hw_encoder, tier=compress_tier, duration_hint=(quality_info or {}).get("duration"), on_progress=update_compress_eta, cancel_event=cancel_event, ) if compressed: media = compressed except DownloadCancelled: raise except Exception as compress_err: logger.warning(f"Auto-quality compression failed: {compress_err}") if not await target_still_exists(message, status_msg): try: if media and os.path.exists(media): os.remove(media) except Exception: pass return translation_marker = "" if force_translate and not audio_only: ub_lang_raw = (self.db.get("heroku.translations", "lang", "en") or "en").strip().lower() ub_lang_code = ub_lang_raw.split()[0] if ub_lang_raw else "en" vo_lang = "ru" if ub_lang_code in ("ru", "uk", "ua") else "en" source_lang_norm = (source_lang or "").split("-")[0].strip().lower() if source_lang_norm and source_lang_norm == vo_lang: logger.info( f"VOT translation skipped: source_lang={source_lang!r} already matches vo_lang={vo_lang!r} for {link}" ) else: vo_attempts = 2 vo_err = None for vo_attempt in range(1, vo_attempts + 1): try: is_short_vo = bool(quality_info and quality_info.get("duration") and quality_info["duration"] <= QUALITY_LIGHT_VIDEO_SECONDS) try: if is_short_vo: await status_msg.edit(self.strings("vo_translating_simple")) else: await status_msg.edit( self.strings("vo_translating").replace("{eta}", self.strings("eta_unknown")) ) except Exception: pass vo_eta_state = {"value": None} async def update_vo_eta(eta_seconds): eta_text = format_seconds(max(0, int(eta_seconds))) if vo_eta_state["value"] == eta_text: return vo_eta_state["value"] = eta_text try: await status_msg.edit( self.strings("vo_translating").replace("{eta}", eta_text) ) except Exception: pass audio_url, _ = await get_translated_audio( link, response_lang=vo_lang, on_progress=(None if is_short_vo else update_vo_eta), ) media = await mux_translated_audio(media, audio_url, orig_volume_percent=self.config["vo_orig_volume"], clip_start=start_time) translation_marker = f"{EMOJI_GLOBE} {lang_display(source_lang)} ➔ {lang_display(vo_lang)}\n" vo_err = None break except Exception as e: vo_err = e if vo_attempt < vo_attempts: logger.warning(f"VOT translation failed (attempt {vo_attempt}/{vo_attempts}), retrying: {e}") continue if vo_err is not None: logger.warning(f"VOT translation failed: {vo_err}") try: if isinstance(vo_err, NodeVersionError): vo_error_text = vo_err.html_message else: vo_error_text = self.strings("vo_failed").replace("{error}", clean_error_text(vo_err)) await message.client.send_message( message.chat_id, vo_error_text, parse_mode="HTML", reply_to=message.id, ) except Exception: pass if not (media and os.path.isfile(media) and os.path.getsize(media) > 0): raise Exception(self.strings("done_fallback")) clip_marker = "" if start_time is not None or end_time is not None: clip_marker = ( f" {EMOJI_SCISSORS}({format_seconds(start_time or 0)}-" f"{format_seconds(end_time) if end_time is not None else '…'})" ) link_lower = link.lower() if "twitter.com" in link_lower or "x.com" in link_lower: title = clean_twitter_title(title) elif "myinstants.com" in link_lower: title = clean_myinstants_title(title) elif "tenor.com" in link_lower: title = clean_tenor_title(title) quality_line = "" if not audio_only and quality_info and quality_info.get('height'): codec = (quality_info.get('vcodec') or '').split('.')[0] quality_line = f"{quality_info['height']}p | {codec}" if codec else f"{quality_info['height']}p" if audio_only: if is_audio_only_platform(link): caption_head = convert_markdown_to_html(self.config["music_response_text"], link) caption_head = caption_head.replace("{title}", "").replace("{quality}", quality_line) caption_head = re.sub(r"<(\w+)>\s*\s*$", "", caption_head).rstrip() caption = f"{get_site_emoji_html(link)} {caption_head}" info_line = f"{title} — {channel}" if (self.config["show_channel"] and channel) else (title or "") if info_line: caption += f"\n\n{info_line}" duration_line = None if "myinstants.com" not in link.lower(): try: probe = MutagenFile(media) if probe and probe.info and getattr(probe.info, "length", None): duration_line = f"{EMOJI_CLOCK} {format_seconds(int(probe.info.length))}" except Exception: pass if duration_line: caption += f"\n{duration_line}" else: caption = convert_markdown_to_html(self.config["music_response_text"], link) caption = caption.replace("{title}", title or "").replace("{quality}", quality_line) caption = f"{EMOJI_NOTE} {caption}" else: IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".heic") media_ext = os.path.splitext(media)[1].lower() if media else "" is_downloaded_gif = media_ext == ".gif" or "tenor.com" in link.lower() is_downloaded_photo = bool(media) and (media_ext in IMAGE_EXTENSIONS or is_downloaded_gif) if self.config["show_link"]: caption_template = self.config["response_text"] caption = convert_markdown_to_html(caption_template, link) caption = caption.replace("{title}", title or "") caption = caption.replace("{quality}", quality_line) if is_downloaded_gif: for src_word, dst_word in (("Видео", "Гиф"), ("видео", "гиф"), ("ВИДЕО", "ГИФ")): caption = caption.replace(src_word, dst_word) elif is_downloaded_photo: for src_word, dst_word in (("Видео", "Фото"), ("видео", "фото"), ("ВИДЕО", "ФОТО")): caption = caption.replace(src_word, dst_word) icon = EMOJI_PHOTO if is_downloaded_photo else get_site_emoji_html(link) caption = f"{icon} {caption}" if translation_marker: lines = caption.split("\n", 1) lines[0] = translation_marker + lines[0] caption = "\n".join(lines) if clip_marker: lines = caption.split("\n", 1) lines[0] = lines[0] + clip_marker caption = "\n".join(lines) if self.config["show_channel"] and channel: channel_text = self.strings("default_channel").replace("{channel}", channel) caption += f"\n\n{channel_text}" else: caption = (translation_marker + (title or self.strings("done_fallback"))) + clip_marker send_attributes = None send_extra = {} if audio_only: safe_title = sanitize_media_filename(title) ext = (os.path.splitext(media)[1].lstrip(".") or audio_codec) send_attributes = [ DocumentAttributeFilename(f"{safe_title}.{ext}"), DocumentAttributeAudio( duration=int((quality_info or {}).get("duration") or 0), performer=channel or None, voice=False, ), ] if "myinstants.com" in link.lower(): cover_path = await get_myinstants_cover_path(utils.get_base_dir()) if cover_path: send_extra["thumb"] = cover_path try: await utils.answer_file( answer_target, media, caption=caption, parse_mode="HTML", reply_to=reply or message, silent=True, force_document=raw_quality and not audio_only, attributes=send_attributes, **send_extra, ) except TypeError as silent_err: if "silent" not in str(silent_err): raise await utils.answer_file( answer_target, media, caption=caption, parse_mode="HTML", reply_to=reply or message, force_document=raw_quality and not audio_only, attributes=send_attributes, **send_extra, ) try: await status_msg.delete() except: pass try: os.remove(media) except: pass except DownloadCancelled: try: await status_msg.edit(self.strings("cancelled")) except Exception: pass except Exception as e: if silent_errors: try: log_channel_id = self.get("log_channel_id") log_topic_id = self.get("log_topic_id") if not log_channel_id: log_channel_id = logging.getLogger().handlers[0].get_logid_by_client(message.client.tg_id) log_topic_id = None chat_ref = await self._format_chat_ref(message.client, message.chat_id, entity=message.chat) log_text = ( f"{EMOJI_WARN} YouTube-DLD: автозагрузка не смогла скачать ссылку\n\n" f"Чат: {chat_ref}\n" f"Ссылка: {link}\n\n" f"{clean_error_text(e)}" ) try: await message.client.send_message( log_channel_id, log_text, parse_mode="HTML", reply_to=log_topic_id, ) except Exception: self.set("log_topic_id", None) try: await message.client.send_message(log_channel_id, log_text, parse_mode="HTML") except Exception: pass except Exception: pass try: await status_msg.delete() except Exception: pass try: if 'media' in locals(): os.remove(media) except Exception: pass return error_str = str(e) needs_cookies = ("sign in to confirm" in error_str.lower() or "confirm you" in error_str.lower() or "login required" in error_str.lower()) twitter_needs_cookies = is_twitter and "no video could be found" in error_str.lower() saveasbot_eligible = is_tiktok or is_instagram or is_pinterest if needs_cookies: if is_instagram: error_msg = cookies_error_message( "Instagram", "instagram.com/robots.txt", clean_error_text(instagram_carousel_err or e, cookies_text=cookies, proxy=proxy), ) else: error_msg = ( f"{EMOJI_CROSS} Загрузка не удалась (нужны куки).\n\n" f"{clean_error_text(instagram_carousel_err or e, cookies_text=cookies, proxy=proxy)}" ) elif twitter_needs_cookies: error_msg = cookies_error_message("Twitter/X", "x.com/robots.txt", clean_error_text(e, cookies_text=cookies, proxy=proxy)) else: error_msg = f"{EMOJI_WARN} " + self.config["error_text"].replace("{error}", clean_error_text(e, cookies_text=cookies, proxy=proxy)) if saveasbot_eligible and is_instagram: reply_target = reply or message await self._saveasbot_fallback( status_msg, link, message.chat_id, reply_target.id if reply_target else message.id, audio_only, ) elif saveasbot_eligible: reply_target = reply or message try: await self.inline.form( text=error_msg, message=message, reply_markup=[[{ "text": "📥 Скачать через @SaveAsBot", "callback": self._saveasbot_fallback, "args": (link, message.chat_id, reply_target.id if reply_target else message.id, audio_only), }]], ) except Exception: await utils.answer(answer_target, error_msg) else: await utils.answer(answer_target, error_msg) try: if 'media' in locals(): os.remove(media) except: pass finally: self._download_queue.release(queue_entry) self._unregister_active_job(resume_job_id) try: active_downloads = self.get("active_downloads", {}) active_downloads.pop(resume_job_id, None) self.set("active_downloads", active_downloads) except Exception: pass