Files
impactframesandClaude Opus 5 21090baf28 Fix broken-pipe crashes, 360p quality cap, and failed extractions
- Route all node and yt-dlp output through a logger that survives a
  closed/detached stdout (ComfyUI launched from a terminal that has
  since closed raised BrokenPipeError on every print, aborting
  downloads and masking real errors).
- Remove the hardcoded browser User-Agent: yt-dlp sends a UA matching
  the player client it impersonates, and the stale Chrome UA made
  YouTube refuse the player response entirely.
- Remove default extractor_args (player_skip + pinned legacy clients):
  they hid all adaptive formats, capping "highest" at 360p. The HTTP
  403 handler still applies alternative player clients as a fallback.
- Fix the quality filter: "360p" is now stripped to a number and the
  height cap applies to every format alternative (previously it only
  modified the last fallback, so selecting 360p could download 4K).
- Detect YouTube's SABR-only streaming experiment (yt-dlp#12482),
  which leaves some sessions with only progressive 360p formats, and
  retry with a fresh session.
- Apply the same fixes to the /yt_dl/api/ytdl endpoint.
- Add cookies helper, test script, and .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:58:40 +01:00

138 lines
5.4 KiB
Python

from .YouTubeDL import NODE_CLASS_MAPPINGS, _log, _YtDlpLogger
import os
import nodes
from aiohttp import web
from pathlib import Path
js_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "js")
nodes.EXTENSION_WEB_DIRS["ComfyUI-yt_dl"] = js_dir
__all__ = ['NODE_CLASS_MAPPINGS']
from server import PromptServer
routes = PromptServer.instance.routes
def _get_output_dir():
try:
import folder_paths
base_output = None
if hasattr(folder_paths, 'get_output_directory'):
base_output = folder_paths.get_output_directory()
elif hasattr(folder_paths, 'get_temp_directory'):
base_output = folder_paths.get_temp_directory()
else:
base_output = os.path.abspath(os.path.join(os.getcwd(), 'output'))
yt_dir = os.path.join(base_output, 'yt_dl')
os.makedirs(yt_dir, exist_ok=True)
return yt_dir
except Exception:
return os.path.abspath(os.path.join(os.getcwd(), 'output', 'yt_dl'))
@routes.post('/yt_dl/api/ytdl')
async def ytdl_endpoint(request):
try:
import yt_dlp
data = await request.json()
url = data.get('url', '')
mode = data.get('mode', 'video')
quality = data.get('quality', 'highest')
if not url:
return web.json_response({'error': 'Missing url'}, status=400)
output_dir = _get_output_dir()
os.makedirs(output_dir, exist_ok=True)
if mode == 'video':
if quality == 'highest':
format_string = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'
else:
# quality arrives as "360p"; the height filter needs a bare number
# and must constrain every alternative, not just the last one.
h = quality.rstrip('p')
format_string = (
f"bestvideo[ext=mp4][height<={h}]+bestaudio[ext=m4a]"
f"/best[ext=mp4][height<={h}]"
f"/best[height<={h}]"
)
elif mode == 'audio':
format_string = 'bestaudio[ext=m4a]/best[ext=m4a]'
else:
format_string = None
outtmpl = os.path.join(output_dir, '%(title)s.%(ext)s')
ydl_opts = {
'format': format_string or 'best',
'outtmpl': outtmpl,
'writethumbnail': True,
'quiet': False,
# Never write to stdout: ComfyUI's stdout can be a closed pipe.
'logger': _YtDlpLogger(),
'noprogress': True,
# No http_headers / extractor_args overrides: yt-dlp sends a
# User-Agent matching whichever player client it impersonates, and
# pinning clients or skipping the webpage hides adaptive formats
# (caps downloads at 360p) or breaks extraction entirely.
# Add retry options
'retries': 10,
'fragment_retries': 10,
'skip_unavailable_fragments': False,
# Add sleep requests to avoid rate limiting
'sleep_interval_requests': 1,
'sleep_interval': 1,
# Add cookie support
'cookiefile': None, # Will be set if cookies file exists
}
# Check if cookies file exists in the output directory
cookies_file = os.path.join(output_dir, 'cookies.txt')
if os.path.exists(cookies_file):
ydl_opts['cookiefile'] = cookies_file
paths = []
class CaptureHook(yt_dlp.postprocessor.common.PostProcessor):
def run(self, information):
filepath = information.get('filepath') or information.get('requested_downloads', [{}])[0].get('filepath')
if filepath:
paths.append(filepath)
return [], information
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
ydl.add_post_processor(CaptureHook(ydl))
except Exception:
pass
try:
ydl.download([url])
except yt_dlp.utils.DownloadError as e:
# Handle specific download errors
error_msg = str(e)
if 'HTTP Error 403' in error_msg:
_log('[yt_dl] HTTP 403 error: YouTube is blocking the request. Trying alternative methods...')
# Fall back to alternative player clients
retry_opts = dict(ydl_opts)
retry_opts['extractor_args'] = {'youtube': {'player_client': ['android', 'web']}}
with yt_dlp.YoutubeDL(retry_opts) as ydl2:
ydl2.add_post_processor(CaptureHook(ydl2))
ydl2.download([url])
else:
_log(f'[yt_dl] Download error: {error_msg}')
raise
except Exception as e:
_log(f'[yt_dl] Unexpected error during download: {str(e)}')
raise
file_path = paths[-1] if paths else ''
return web.json_response({'file_path': file_path})
except yt_dlp.utils.DownloadError as e:
return web.json_response({'error': f'Download failed: {str(e)}'}, status=400)
except Exception as e:
return web.json_response({'error': f'Unexpected error: {str(e)}'}, status=500)