#! /usr/bin/env python3 import copy import dataclasses import json import logging import os.path import pathlib import re import time import traceback from abc import ABC from dataclasses import dataclass, field from datetime import timedelta from typing import Tuple, List, Dict, Optional from urllib.parse import urlparse, parse_qs import cachetools import yt_dlp.utils from cachetools.keys import hashkey from sqlalchemy.orm import Session from yt_dlp import YoutubeDL, DownloadError from yt_dlp.extractor import YoutubeTabIE # noqa from wrolpi.captions import strip_youtube_caption_positioning from wrolpi.cmd import YT_DLP_BIN from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task, \ trim_file_name, cached_multiprocessing_result, get_absolute_media_path, aiohttp_post, normalize_domain from wrolpi.dates import now from wrolpi.db import get_db_session from wrolpi.downloader import Downloader, Download, DownloadContext, DownloadResult, \ clear_download_progress, _parse_size, make_progress_callback, get_download_cache_config from wrolpi.errors import UnrecoverableDownloadError, BotBlockedDownloadError, ValidationError from wrolpi.files.lib import glob_shared_stem, split_path_stem_and_suffix from wrolpi.files.models import FileGroup from wrolpi.files.worker import file_worker from wrolpi.vars import PYTEST, YTDLP_CACHE_DIR from .channel.lib import create_channel, get_channel from .common import get_no_channel_directory, update_view_counts_and_censored, \ ffmpeg_video_complete, get_or_create_ffprobe_json from .errors import UnknownChannel, ChannelDirectoryConflict, ChannelNameConflict, ChannelURLConflict, \ ChannelSourceIdConflict from .cookies import cookies_unlocked, cookies_for_download from .lib import get_videos_downloader_config, get_effective_video_settings, CHANNEL_INHERITABLE_SETTINGS, YDL, \ ydl_logger, format_videos_destination, get_yt_dlp_http_headers, get_yt_dlp_sleep_opts from .models import Video, Channel from .normalize_video_url import normalize_video_url from .schema import ChannelPostRequest from .transcode import codecs_match, get_transcode_target, transcode_video_file, \ transcode_can_satisfy_codecs, TRANSCODE_VIDEO_TARGETS, TRANSCODE_AUDIO_TARGETS from .video.lib import download_video_info_json logger = logger.getChild(__name__) YTDLP_PROGRESS_RE = re.compile( r'\[download\]\s+' r'([\d.]+)%\s+' # percent r'of\s+~?\s*([\d.]+\w+)\s+' # total size (with optional ~) r'at\s+([\d.]+\w+/s)\s+' # speed r'ETA\s+(\S+)' # ETA ) def parse_ytdlp_progress(line: str) -> dict | None: """Parse a yt-dlp progress line (with --newline) and return a dict, or None if not a progress line.""" m = YTDLP_PROGRESS_RE.search(line) if not m: return None percent = float(m.group(1)) total_bytes = _parse_size(m.group(2)) speed_str = m.group(3).rstrip('/s') speed = _parse_size(speed_str) eta = m.group(4) if eta == 'Unknown': eta = None return dict( bytes_downloaded=int(percent / 100.0 * total_bytes), total_bytes=total_bytes, percent=int(percent), speed=speed, eta=eta, ) def _log_ytdlp_versions(): """Log yt-dlp and yt-dlp-ejs versions for debugging download issues.""" try: import yt_dlp.version ytdlp_version = yt_dlp.version.__version__ except Exception: ytdlp_version = 'unknown' try: from importlib.metadata import version ejs_version = version('yt-dlp-ejs') except Exception: ejs_version = 'not installed' logger.info(f'yt-dlp={ytdlp_version} yt-dlp-ejs={ejs_version}') # yt-dlp codec names that differ from their output file extension. AUDIO_CODEC_EXTENSION_MAP = { 'vorbis': 'ogg', } def audio_output_extension(audio_format: str) -> str: """Map a yt-dlp audio codec name to the file extension it actually produces.""" return AUDIO_CODEC_EXTENSION_MAP.get(audio_format, audio_format) VIDEO_RESOLUTION_MAP = { '360p': [ '134+bestaudio', '230+bestaudio', 'mp4-360p', 'res:360', 'hls-175+bestaudio', # 360p avc1 + best audio '396+bestaudio', '243+bestaudio', # Vertical 'hls-655+bestaudio', 'hls-655', 'bestvideo[height=360]+bestaudio/bestvideo[width=360]+bestaudio/best[height=360]', ], '480p': [ 'hls-96', # 480p (sort of) Vertical '135+bestaudio', '231+bestaudio', '135+139', 'mp4-480p', 'res:480', 'hls-312+bestaudio', # 480p avc1 + best audio '788+bestaudio', '780+bestaudio', '397+bestaudio', # Vertical 'bestvideo[height=480]+bestaudio/bestvideo[width=480]+bestaudio/best[height=480]', ], '720p': [ 'hls-258+bestaudio', # 720p (sort of) Vertical '136+bestaudio', '22', '311+bestaudio', 'res:720', '298+bestaudio', '232+bestaudio', # 720@60 avc1 + best audio 'hls-517+bestaudio', 'hls-637+bestaudio', # 720p avc1 + best audio '398+bestaudio', '247+bestaudio', # Vertical 'hls-2890+bestaudio', 'hls-2890', 'bestvideo[height=720]+bestaudio/bestvideo[width=720]+bestaudio/best[height=720]', ], '1080p': [ 'hls-509+bestaudio', # 1080p Vertical '137+bestaudio', '614+bestaudio', 'res:1080', '299+bestaudio', '312+bestaudio', '270+bestaudio', # 1080@60 avc1 + best audio 'hls-899+bestaudio', 'hls-1271+bestaudio', # 1080p avc1 + best audio '399+bestaudio', '616+bestaudio', # Vertical 'hls-4026+bestaudio', 'hls-4026', 'bestvideo[height=1080]+bestaudio/bestvideo[width=1080]+bestaudio/best[height=1080]', ], '1440p': [ '639+bestaudio', # vp9, best audio, hdr '623+bestaudio', # vp9, best audio, no hdr '620+bestaudio', 'res:1440', 'bestvideo[height=1440]+bestaudio/bestvideo[width=1440]+bestaudio/best[height=1440]', ], '2160p': [ '642+bestaudio', # vp9, best audio, hdr '628+bestaudio', # vp9, best audio, no hdr '625+bestaudio', 'res:2160', 'bestvideo[height=2160]+bestaudio/bestvideo[width=2160]+bestaudio/best[height=2160]', ], 'maximum': ['bestvideo*+bestaudio/best'], } # yt-dlp `vcodec`/`acodec` values vary by extractor (avc1.64001f, vp09.00.10.08, mp4a.40.2, ...); # match them by prefix regex. These are used inside `-f` bracket filters, so they must never # contain a comma or a forward slash. VIDEO_CODEC_YT_DLP_PATTERNS = { 'h264': r'^(avc|h264)', 'hevc': r'^(hev|hvc|h265|hevc)', 'vp9': r'^vp0?9', 'av1': r'^av0?1', 'vp8': r'^vp0?8', } AUDIO_CODEC_YT_DLP_PATTERNS = { 'aac': r'^(aac|mp4a\.40)', 'opus': r'^opus', 'mp3': r'^(mp3|mp4a\.69|mp4a\.6[bB])', # extractors report both mp4a.6b and mp4a.6B 'vorbis': r'^vorbis', } RESOLUTION_HEIGHTS = {'360p': 360, '480p': 480, '720p': 720, '1080p': 1080, '1440p': 1440, '2160p': 2160, 'maximum': None} # VTT is the only format that a browser can use to display captions on a