322 lines
10 KiB
Python
322 lines
10 KiB
Python
# downloader.py
|
||
import yt_dlp
|
||
import threading
|
||
import os
|
||
import logging
|
||
import subprocess
|
||
import shutil
|
||
import asyncio
|
||
from typing import List
|
||
|
||
from config import (
|
||
DEFAULT_TIKTOK_VIDEO_QUALITY,
|
||
DEFAULT_TIKTOK_AUDIO_QUALITY,
|
||
DEFAULT_INSTAGRAM_VIDEO_QUALITY,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class DownloadCancelled(Exception):
|
||
pass
|
||
|
||
def _progress_hook(cancel_event, progress_cb):
|
||
def hook(d):
|
||
if cancel_event.is_set():
|
||
raise DownloadCancelled("Cancelled by user")
|
||
if d["status"] in ["downloading", "finished"] and progress_cb:
|
||
progress_cb(d)
|
||
return hook
|
||
|
||
def optimize_for_telegram(input_path: str, output_path: str) -> None:
|
||
"""
|
||
Синхронная функция: конвертирует видео в Telegram-safe mp4
|
||
"""
|
||
file_size_mb = os.path.getsize(input_path) / (1024 * 1024)
|
||
crf = 28 if file_size_mb > 50 else 23
|
||
|
||
cmd = [
|
||
'ffmpeg',
|
||
'-i', input_path,
|
||
'-c:v', 'libx264',
|
||
'-preset', 'fast',
|
||
'-crf', str(crf),
|
||
'-c:a', 'aac',
|
||
'-b:a', '128k',
|
||
'-movflags', '+faststart',
|
||
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||
'-y',
|
||
output_path
|
||
]
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||
if result.returncode != 0:
|
||
logger.error(f"FFmpeg error: {result.stderr}")
|
||
shutil.copy2(input_path, output_path)
|
||
|
||
|
||
# ---------------- YOUTUBE/VK: Video (с выбором качества) ------
|
||
def download_video(
|
||
url: str,
|
||
quality: str,
|
||
out_path: str,
|
||
cookies: str | None,
|
||
cancel_event: threading.Event,
|
||
progress_cb,
|
||
):
|
||
ydl_opts = {
|
||
"format": f"best[height<={quality}]/best",
|
||
"outtmpl": out_path,
|
||
"cookiefile": cookies if cookies and os.path.exists(cookies) else None,
|
||
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
||
"quiet": True,
|
||
"no_warnings": True,
|
||
"concurrent_fragment_downloads": 4,
|
||
"http_chunk_size": 10485760,
|
||
}
|
||
|
||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||
ydl.download([url])
|
||
|
||
# Ищем реальный файл
|
||
base = out_path.rsplit('.', 1)[0]
|
||
actual_video = None
|
||
for ext in ['', '.mp4', '.mov', '.webm', '.mkv']:
|
||
candidate = base + ext
|
||
if os.path.exists(candidate):
|
||
actual_video = candidate
|
||
break
|
||
|
||
if not actual_video:
|
||
raise Exception(f"Video file not created for {url}")
|
||
|
||
# Конвертируем в Telegram-safe mp4 (синхронно)
|
||
optimized_path = base + "_telegram.mp4"
|
||
optimize_for_telegram(actual_video, optimized_path) # ← без await!
|
||
|
||
# Удаляем старый файл и ставим конвертированный
|
||
for path in [out_path, actual_video]:
|
||
if os.path.exists(path): os.remove(path)
|
||
os.rename(optimized_path, out_path)
|
||
|
||
|
||
# ---------------- AUDIO (YouTube/VK) ------
|
||
def download_audio(
|
||
url: str,
|
||
out_path: str,
|
||
cookies: str | None,
|
||
cancel_event: threading.Event,
|
||
progress_cb,
|
||
):
|
||
ydl_opts = {
|
||
"format": "bestaudio/best",
|
||
"outtmpl": out_path.replace('.mp3', ''),
|
||
"cookiefile": cookies if cookies and os.path.exists(cookies) else None,
|
||
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
||
"postprocessors": [{
|
||
"key": "FFmpegExtractAudio",
|
||
"preferredcodec": "mp3",
|
||
"preferredquality": "192",
|
||
}],
|
||
"quiet": True,
|
||
"no_warnings": True,
|
||
"concurrent_fragment_downloads": 4,
|
||
"http_chunk_size": 10485760,
|
||
}
|
||
|
||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||
ydl.download([url])
|
||
|
||
# Ищем файл
|
||
base = out_path.rsplit('.', 1)[0]
|
||
actual_audio = None
|
||
for ext in ['', '.mp3', '.m4a', '.opus', '.weba']:
|
||
candidate = base + ext
|
||
if os.path.exists(candidate):
|
||
actual_audio = candidate
|
||
break
|
||
|
||
if not actual_audio:
|
||
raise Exception(f"Audio file not created for {url}")
|
||
|
||
# Конвертируем в mp3
|
||
if actual_audio != out_path:
|
||
optimized = base + "_telegram.mp3"
|
||
optimize_for_telegram_audio(actual_audio, optimized) # ← отдельная функция
|
||
if os.path.exists(out_path): os.remove(out_path)
|
||
os.rename(optimized, out_path)
|
||
|
||
|
||
# ---------------- TikTok: Video + Audio (автоматически) ------
|
||
def download_tiktok_video_and_audio(
|
||
url: str,
|
||
video_path: str,
|
||
audio_path: str,
|
||
cookies: str | None,
|
||
cancel_event: threading.Event,
|
||
progress_cb,
|
||
):
|
||
# Скачиваем видео
|
||
video_opts = {
|
||
"format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best",
|
||
"outtmpl": video_path,
|
||
"cookiefile": cookies if cookies and os.path.exists(cookies) else None,
|
||
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
||
"quiet": True,
|
||
"no_warnings": False,
|
||
"concurrent_fragment_downloads": 4,
|
||
"http_chunk_size": 10485760,
|
||
}
|
||
|
||
with yt_dlp.YoutubeDL(video_opts) as ydl:
|
||
ydl.download([url])
|
||
|
||
# Ищем видеофайл
|
||
base_video = video_path.rsplit('.', 1)[0]
|
||
actual_video = None
|
||
for ext in ['', '.mp4', '.mov', '.webm']:
|
||
candidate = base_video + ext
|
||
if os.path.exists(candidate):
|
||
actual_video = candidate
|
||
break
|
||
if not actual_video:
|
||
raise Exception("TikTok video file not created")
|
||
|
||
# Конвертируем в Telegram-safe mp4
|
||
optimized_video = base_video + "_telegram.mp4"
|
||
optimize_for_telegram(actual_video, optimized_video)
|
||
for path in [video_path, actual_video]:
|
||
if os.path.exists(path): os.remove(path)
|
||
os.rename(optimized_video, video_path)
|
||
|
||
# Аудио
|
||
audio_opts = {
|
||
"format": "bestaudio/best",
|
||
"outtmpl": audio_path.replace('.mp3', ''),
|
||
"cookiefile": cookies if cookies and os.path.exists(cookies) else None,
|
||
"postprocessors": [{
|
||
"key": "FFmpegExtractAudio",
|
||
"preferredcodec": "mp3",
|
||
"preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY,
|
||
}],
|
||
"quiet": True,
|
||
"no_warnings": False,
|
||
"concurrent_fragment_downloads": 4,
|
||
"http_chunk_size": 10485760,
|
||
}
|
||
|
||
with yt_dlp.YoutubeDL(audio_opts) as ydl:
|
||
ydl.download([url])
|
||
|
||
base_audio = audio_path.rsplit('.', 1)[0]
|
||
actual_audio = None
|
||
for ext in ['', '.mp3', '.m4a', '.opus']:
|
||
candidate = base_audio + ext
|
||
if os.path.exists(candidate):
|
||
actual_audio = candidate
|
||
break
|
||
if not actual_audio:
|
||
raise Exception("TikTok audio file not created")
|
||
|
||
if actual_audio != audio_path:
|
||
optimized_audio = base_audio + "_telegram.mp3"
|
||
optimize_for_telegram_audio(actual_audio, optimized_audio)
|
||
if os.path.exists(audio_path): os.remove(audio_path)
|
||
os.rename(optimized_audio, audio_path)
|
||
|
||
|
||
# ---------------- Instagram: Video (1080p) ------
|
||
def download_instagram_video(
|
||
url: str,
|
||
out_path: str,
|
||
cookies: str | None,
|
||
cancel_event: threading.Event,
|
||
progress_cb,
|
||
):
|
||
ydl_opts = {
|
||
"format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best",
|
||
"outtmpl": out_path,
|
||
"cookiefile": cookies if cookies and os.path.exists(cookies) else None,
|
||
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
||
"quiet": True,
|
||
"no_warnings": False,
|
||
"concurrent_fragment_downloads": 4,
|
||
"http_chunk_size": 10485760,
|
||
}
|
||
|
||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||
ydl.download([url])
|
||
|
||
base = out_path.rsplit('.', 1)[0]
|
||
actual = None
|
||
for ext in ['', '.mp4', '.mov', '.webm']:
|
||
candidate = base + ext
|
||
if os.path.exists(candidate):
|
||
actual = candidate
|
||
break
|
||
if not actual:
|
||
raise Exception("Instagram video not created")
|
||
|
||
optimized = base + "_telegram.mp4"
|
||
optimize_for_telegram(actual, optimized)
|
||
if os.path.exists(out_path): os.remove(out_path)
|
||
os.rename(optimized, out_path)
|
||
|
||
|
||
# ---------------- Audio helper (синхронная) ------
|
||
def optimize_for_telegram_audio(input_path: str, output_path: str) -> None:
|
||
"""Конвертирует аудио в Telegram-safe mp3"""
|
||
cmd = [
|
||
'ffmpeg',
|
||
'-i', input_path,
|
||
'-c:a', 'aac',
|
||
'-b:a', '192k',
|
||
'-loglevel', 'error',
|
||
'-y',
|
||
output_path
|
||
]
|
||
subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||
|
||
|
||
# ---------------- Playlist ------
|
||
def download_playlist_videos(
|
||
playlist_info: dict,
|
||
output_dir: str,
|
||
cookies: str | None,
|
||
cancel_event: threading.Event,
|
||
progress_cb,
|
||
) -> List[str]:
|
||
downloaded_files = []
|
||
ydl_opts = {
|
||
"format": "best[height<=1080]/best",
|
||
"outtmpl": os.path.join(output_dir, "%(title)s [%(id)s].%(ext)s"),
|
||
"cookiefile": cookies if cookies and os.path.exists(cookies) else None,
|
||
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
||
"quiet": True,
|
||
"no_warnings": True,
|
||
"ignoreerrors": True,
|
||
"extract_flat": False,
|
||
"nooverwrites": True,
|
||
"concurrent_fragment_downloads": 4,
|
||
"http_chunk_size": 10485760,
|
||
}
|
||
|
||
try:
|
||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||
entries = playlist_info.get('entries', [])
|
||
for i, entry in enumerate(entries, 1):
|
||
if cancel_event.is_set():
|
||
raise DownloadCancelled("Cancelled by user")
|
||
if not entry.get('url'):
|
||
continue
|
||
try:
|
||
info = ydl.extract_info(entry['url'], download=True)
|
||
if info:
|
||
filename = ydl.prepare_filename(info)
|
||
if os.path.exists(filename):
|
||
downloaded_files.append(filename)
|
||
except Exception as e:
|
||
logger.error(f"Error downloading video {i}: {e}")
|
||
return downloaded_files
|
||
except Exception as e:
|
||
logger.error(f"Playlist download error: {e}")
|
||
raise |