337 lines
9.8 KiB
Python
337 lines
9.8 KiB
Python
# downloader.py
|
|
import yt_dlp
|
|
import threading
|
|
import os
|
|
import logging
|
|
import shutil
|
|
from typing import List, Optional
|
|
|
|
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
|
|
|
|
|
|
# ---------------- STANDARD VIDEO (YouTube/VK) --------------
|
|
|
|
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,
|
|
"merge_output_format": "mp4",
|
|
"cookiefile": cookies,
|
|
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extractor_args": {
|
|
"tiktok": {"skip_impersonation": True},
|
|
"instagram": {"skip_impersonation": True},
|
|
},
|
|
"concurrent_fragment_downloads": 4,
|
|
"http_chunk_size": 10485760,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
ydl.download([url])
|
|
|
|
|
|
# ---------------- TikTok: Видео + Аудио (автоматически) --------------
|
|
|
|
def download_tiktok_video_and_audio(
|
|
url: str,
|
|
video_path: str,
|
|
audio_path: str,
|
|
cookies: str | None,
|
|
cancel_event: threading.Event,
|
|
progress_cb,
|
|
):
|
|
"""
|
|
Скачивает TikTok: видео (1080p) + аудио (MP3 192kbps).
|
|
Возвращает (video_path, audio_path) или None.
|
|
"""
|
|
# 1. Скачиваем видео в 1080p
|
|
video_opts = {
|
|
"format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best",
|
|
"outtmpl": video_path.replace('.mp4', ''),
|
|
"merge_output_format": "mp4",
|
|
"cookiefile": cookies,
|
|
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extractor_args": {"tiktok": {"skip_impersonation": True}},
|
|
"concurrent_fragment_downloads": 4,
|
|
"http_chunk_size": 10485760,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(video_opts) as ydl:
|
|
ydl.download([url])
|
|
|
|
# Проверяем, что видео создано
|
|
if not os.path.exists(video_path):
|
|
raise Exception("TikTok video file not created")
|
|
|
|
# 2. Извлекаем аудио в MP3
|
|
audio_opts = {
|
|
"format": "bestaudio/best",
|
|
"outtmpl": audio_path.replace('.mp3', ''),
|
|
"cookiefile": cookies,
|
|
"postprocessors": [{
|
|
"key": "FFmpegExtractAudio",
|
|
"preferredcodec": "mp3",
|
|
"preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY,
|
|
}],
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extractor_args": {"tiktok": {"skip_impersonation": True}},
|
|
"concurrent_fragment_downloads": 4,
|
|
"http_chunk_size": 10485760,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(audio_opts) as ydl:
|
|
ydl.download([url])
|
|
|
|
if not os.path.exists(audio_path):
|
|
raise Exception("TikTok audio file not created")
|
|
|
|
return video_path, audio_path
|
|
|
|
|
|
# ---------------- Instagram: Видео в 1080p (автоматически) --------------
|
|
|
|
def download_instagram_video(
|
|
url: str,
|
|
out_path: str,
|
|
cookies: str | None,
|
|
cancel_event: threading.Event,
|
|
progress_cb,
|
|
):
|
|
"""Instagram Reels: только видео в 1080p"""
|
|
ydl_opts = {
|
|
"format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best",
|
|
"outtmpl": out_path.replace('.mp4', ''),
|
|
"merge_output_format": "mp4",
|
|
"cookiefile": cookies,
|
|
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extractor_args": {"instagram": {"skip_impersonation": True}},
|
|
"concurrent_fragment_downloads": 4,
|
|
"http_chunk_size": 10485760,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
ydl.download([url])
|
|
|
|
|
|
# ---------------- AUDIO (для YouTube) --------------
|
|
|
|
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,
|
|
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
|
"postprocessors": [
|
|
{
|
|
"key": "FFmpegExtractAudio",
|
|
"preferredcodec": "mp3",
|
|
"preferredquality": "192",
|
|
},
|
|
],
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extractor_args": {
|
|
"tiktok": {"skip_impersonation": True},
|
|
"instagram": {"skip_impersonation": True},
|
|
},
|
|
"concurrent_fragment_downloads": 4,
|
|
"http_chunk_size": 10485760,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
ydl.download([url])
|
|
|
|
|
|
# ---------------- ORIGINAL QUALITY (YouTube) --------------
|
|
|
|
def download_original_quality(
|
|
url: str,
|
|
out_path: str,
|
|
cookies: str | None,
|
|
cancel_event: threading.Event,
|
|
progress_cb,
|
|
):
|
|
"""Скачивает видео в оригинальном качестве (YouTube)"""
|
|
ydl_opts = {
|
|
"format": "best",
|
|
"outtmpl": out_path,
|
|
"merge_output_format": "mp4",
|
|
"cookiefile": cookies,
|
|
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"extractor_args": {
|
|
"tiktok": {"skip_impersonation": True},
|
|
"instagram": {"skip_impersonation": True},
|
|
},
|
|
"format_sort": ["quality", "res", "codec", "size"],
|
|
"concurrent_fragment_downloads": 4,
|
|
"http_chunk_size": 10485760,
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
ydl.download([url])
|
|
|
|
|
|
# ---------------- 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"),
|
|
"merge_output_format": "mp4",
|
|
"cookiefile": cookies,
|
|
"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', [])
|
|
total_videos = len(entries)
|
|
|
|
logger.info(f"Starting playlist download with {total_videos} videos")
|
|
|
|
for i, entry in enumerate(entries, 1):
|
|
if cancel_event.is_set():
|
|
raise DownloadCancelled("Cancelled by user")
|
|
|
|
if not entry.get('url'):
|
|
logger.warning(f"Entry {i} has no URL, skipping")
|
|
continue
|
|
|
|
video_url = entry['url']
|
|
video_title = entry.get('title', f'Video {i}')
|
|
|
|
logger.info(f"Downloading video {i}/{total_videos}: {video_title}")
|
|
|
|
try:
|
|
info = ydl.extract_info(video_url, download=True)
|
|
|
|
if not info:
|
|
logger.error(f"Failed to extract info for video {i}")
|
|
continue
|
|
|
|
filename = ydl.prepare_filename(info)
|
|
|
|
if os.path.exists(filename):
|
|
downloaded_files.append(filename)
|
|
else:
|
|
base_name = filename.rsplit('.', 1)[0]
|
|
for ext in ['.mp4', '.mkv', '.webm', '.flv']:
|
|
alt_path = base_name + ext
|
|
if os.path.exists(alt_path):
|
|
downloaded_files.append(alt_path)
|
|
break
|
|
else:
|
|
logger.error(f"File not found for video {i}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error downloading video {i} ({video_title}): {e}")
|
|
continue
|
|
|
|
logger.info(f"Playlist download complete. Downloaded {len(downloaded_files)} files")
|
|
return downloaded_files
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error downloading playlist: {e}")
|
|
raise
|
|
|
|
|
|
# ---------------- METADATA (осталось без изменений) --------------
|
|
|
|
def add_metadata_to_audio(
|
|
input_path: str,
|
|
output_path: str,
|
|
metadata: dict,
|
|
):
|
|
if metadata is None:
|
|
metadata = {}
|
|
|
|
if not input_path.lower().endswith('.mp3'):
|
|
shutil.copy2(input_path, output_path)
|
|
return
|
|
|
|
metadata_args = []
|
|
if metadata.get('title'):
|
|
metadata_args.extend(['-metadata', f'title={metadata["title"][:100]}'])
|
|
if metadata.get('artist'):
|
|
metadata_args.extend(['-metadata', f'artist={metadata["artist"][:100]}'])
|
|
if metadata.get('album'):
|
|
metadata_args.extend(['-metadata', f'album={metadata["album"][:100]}'])
|
|
|
|
cmd = [
|
|
'ffmpeg',
|
|
'-i', input_path,
|
|
'-c', 'copy',
|
|
'-id3v2_version', '3',
|
|
'-loglevel', 'error',
|
|
'-y',
|
|
*metadata_args,
|
|
output_path
|
|
]
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
shutil.copy2(input_path, output_path) |