yt-dlp-dags/ytdlp-ops-auth/ytdlp_ops_client.py
2025-08-15 18:00:26 +03:00

722 lines
34 KiB
Python

#!/usr/bin/env python3
from typing import Dict, List, Optional, Any
import argparse
import csv
import datetime
import json
import os
import re
import subprocess
import sys
import time
import uuid
import traceback
import logging
import signal
from pathlib import Path
from tabulate import tabulate
import yt_dlp
def signal_handler(sig: int, frame) -> None:
"""Handle shutdown signals gracefully."""
logger.info(f"Received signal {sig}, shutting down...")
# Clean up any resources here
sys.exit(0)
# Register signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Import the patch for Thrift exceptions
try:
import os
from thrift_exceptions_patch import patch_thrift_exceptions
# Explicitly call the patch function to ensure it's applied
patch_thrift_exceptions()
print("Applied Thrift exceptions patch for compatibility")
if 'AIRFLOW_HOME' in os.environ:
print("Running in Airflow environment - patch is essential")
else:
print("Not running in Airflow environment, but patch applied anyway for consistency")
except ImportError:
print("Could not import thrift_exceptions_patch, compatibility may be affected")
print("If running in Airflow, this may cause 'immutable instance' errors")
except Exception as e:
print(f"Error applying Thrift exceptions patch: {e}")
# --- Python Path Setup ---
# Ensure the script can find necessary modules, especially Thrift-generated code.
# Assumes the script is run from the project root or the path is adjusted accordingly.
project_root = Path(__file__).parent.absolute()
gen_py_dir = project_root / "thrift_model" / "gen_py"
# Add project root to sys.path (needed for the 'pangramia' symlink)
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
# Verify paths for debugging
# print("Project Root:", project_root)
# print("Project Root:", project_root)
# print("Gen Py Dir:", gen_py_dir)
# print("Sys Path:", sys.path)
# --- End Python Path Setup ---
from thrift.transport import TSocket, TTransport
from thrift.protocol import TBinaryProtocol
try:
from pangramia.yt.tokens_ops import YTTokenOpService
from pangramia.yt.common.ttypes import JobTokenData, TokenUpdateMode, JobState
from pangramia.yt.exceptions.ttypes import PBServiceException, PBUserException
except ImportError as e:
print(f"Error importing Thrift-generated modules: {e}")
print("Please ensure you have run './generate-thrift.py' successfully from the project root.")
print(f"Current sys.path includes: {gen_py_dir}")
sys.exit(1)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('ytdlp_ops_client.log')
]
)
logger = logging.getLogger(__name__)
def get_info_json(token_data):
"""Get infoJson from token_data"""
if not hasattr(token_data, 'infoJson'):
logger.error("infoJson attribute missing in token_data")
raise ValueError("Server response missing infoJson")
if not token_data.infoJson or token_data.infoJson == "{}":
logger.error("Empty infoJson received from server")
raise ValueError("Empty infoJson received from server")
logger.info(f"Using infoJson from server response ({len(token_data.infoJson)} bytes)")
return token_data.infoJson
def is_valid_json(json_str):
"""Check if a string is valid JSON and not empty"""
if not json_str or json_str == "{}" or json_str == "":
logger.warning("Empty JSON string received")
return False
try:
data = json.loads(json_str)
# Check if it's an empty object
if isinstance(data, dict) and not data:
logger.warning("Empty JSON object received")
return False
# Check if it has an error field
if isinstance(data, dict) and ('error' in data or 'errorCode' in data):
# It's valid JSON but contains an error
logger.warning(f"JSON contains error: {data.get('error', 'Unknown error')} (code: {data.get('errorCode', 'none')})")
return True
# Check if it has at least some basic fields
if isinstance(data, dict) and ('id' in data or 'title' in data):
logger.info(f"Valid JSON with video data: {data.get('title', 'Unknown title')}")
return True
# Check if it has token_data which is important
if isinstance(data, dict) and 'token_data' in data and data['token_data']:
logger.info("Valid JSON with token_data")
return True
logger.warning("JSON is valid but missing expected fields")
return True
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON: {e}")
return False
except Exception as e:
logger.warning(f"Unexpected error validating JSON: {e}")
return False
def extract_video_id(url: str) -> Optional[str]:
"""Extract video ID from a YouTube URL."""
# If it's already a video ID
if re.match(r'^[a-zA-Z0-9_-]{11}$', url):
return url
# Handle youtu.be URLs
youtu_be_match = re.search(r'youtu\.be/([a-zA-Z0-9_-]{11})', url)
if youtu_be_match:
return youtu_be_match.group(1)
# Handle youtube.com URLs
youtube_match = re.search(r'(?:youtube\.com/(?:watch\?v=|embed/|v/)|youtube\.com/.*[?&]v=)([a-zA-Z0-9_-]{11})', url)
if youtube_match:
return youtube_match.group(1)
# Handle shorts URLs
shorts_match = re.search(r'youtube\.com/shorts/([a-zA-Z0-9_-]{11})', url)
if shorts_match:
return shorts_match.group(1)
return None
def list_available_formats(url: str, args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
"""List available formats for a YouTube video."""
ydl_opts = {
'quiet': not args.no_quiet if hasattr(args, 'no_quiet') else True,
'no_warnings': True,
'skip_download': True,
'extract_flat': True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
logger.error("Could not retrieve video information")
return None
formats = info.get('formats', [])
if not formats:
logger.warning("No formats available for this video")
return None
# Create a table of available formats
format_table = []
for f in formats:
format_table.append({
'format_id': f.get('format_id', 'unknown'),
'ext': f.get('ext', 'unknown'),
'resolution': f.get('resolution', 'unknown'),
'fps': f.get('fps', 'unknown'),
'vcodec': f.get('vcodec', 'unknown'),
'acodec': f.get('acodec', 'unknown'),
'filesize': f.get('filesize', 'unknown'),
'format_note': f.get('format_note', '')
})
return format_table
except Exception as e:
logger.error(f"Error listing formats: {e}")
return None
def suggest_best_formats(formats: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Suggest best formats based on resolution and codec."""
best = []
seen_resolutions = set()
# Prioritize higher resolutions and certain codecs
preferred_codecs = ["vp9", "avc1", "av01"] # In order of preference
for f in sorted(formats, key=lambda x: (
-int(x.get('height', 0) or 0), # Higher resolution first
preferred_codecs.index(x.get('vcodec', '').split('.')[0]) if x.get('vcodec', '').split('.')[0] in preferred_codecs else float('inf'), # Preferred codecs
x.get('filesize', 0) or 0 # Smaller filesize
)):
resolution = f.get('resolution')
if resolution and resolution not in seen_resolutions:
best.append(f)
seen_resolutions.add(resolution)
if len(best) >= 3: # Suggest up to 3 formats
break
return best
def load_info_json(path: str) -> Optional[Dict[str, Any]]:
"""Load and validate info.json file."""
try:
path = Path(path).resolve()
if not path.exists():
logger.error(f"Info.json file not found: {path}")
return None
with open(path, 'r') as f:
data = json.load(f)
# Basic validation
if not isinstance(data, dict):
logger.error("Invalid info.json format: not a JSON object")
return None
if 'id' not in data:
logger.warning("Info.json missing video ID")
return data
except Exception as e:
logger.error(f"Error loading info.json: {e}")
return None
def save_info_json(info_json: str, video_id: str, context_dir: str) -> Optional[str]:
"""Save info.json to disk and return the saved path."""
try:
# Ensure context directory exists
Path(context_dir).mkdir(parents=True, exist_ok=True)
# Create filename with video ID and timestamp
timestamp = int(time.time())
output_path = Path(context_dir) / f"info_json_{video_id}_{timestamp}.json"
# Write the file
with open(output_path, 'w') as f:
f.write(info_json)
# Also create a symlink or copy to the standard name for compatibility
standard_path = Path(context_dir) / f"info_json_{video_id}.json"
try:
# Try to create a symlink first (more efficient)
if os.path.exists(standard_path):
os.remove(standard_path)
os.symlink(output_path, standard_path)
except (OSError, AttributeError):
# If symlink fails (e.g., on Windows), make a copy
with open(standard_path, 'w') as f:
f.write(info_json)
# Save latest.json
latest_path = Path(context_dir) / "latest.json"
with open(latest_path, 'w') as f:
f.write(info_json)
logger.info(f"Successfully saved info.json to {output_path} and latest.json to {latest_path}")
return str(output_path)
except Exception as e:
logger.error(f"Failed to save info.json: {e}")
logger.error(traceback.format_exc())
return False
def main():
# Create main parser
parser = argparse.ArgumentParser(description='''YtdlpOpsService Client
This client connects to the YTDLP Operations Server to generate tokens for YouTube videos.
The server performs SOCKS5 proxy connection testing with a 9-second timeout for early detection
of proxy issues. If a proxy connection fails, the server will immediately stop token generation
and return an error instead of trying other clients.''')
# Add global options
parser.add_argument('--host', default=os.getenv('YTDLP_HOST', 'localhost'),
help='Server host (default: localhost or YTDLP_HOST env)')
parser.add_argument('--port', type=int, default=int(os.getenv('YTDLP_PORT', '9090')),
help='Server port (default: 9090 or YTDLP_PORT env)')
parser.add_argument('--timeout', type=int, default=30000,
help='Timeout in milliseconds (default: 30000)')
parser.add_argument('--timeout-sec', type=int, default=30,
help='Timeout in seconds (default: 30, overrides --timeout if provided)')
parser.add_argument('--context-dir', default='.', help='Context directory to save info.json (default: .)')
parser.add_argument('--load-info-json', help='Path to existing info.json file to load')
parser.add_argument('--framed-transport', action='store_true',
help='Use TFramedTransport instead of TBufferedTransport for handling very large messages')
parser.add_argument('--force-framed-transport', action='store_true',
help='Force the use of TFramedTransport (recommended for large messages)')
# Create subparsers for commands
subparsers = parser.add_subparsers(dest='command', required=True, help='Commands')
# getToken command
get_token_parser = subparsers.add_parser('getToken', help='Get token for a YouTube URL',
description='''Get token for a YouTube URL
This command connects to the server to generate tokens for a YouTube video.
The server will test any configured SOCKS5 proxy with a 9-second timeout.
If the proxy connection fails, token generation will stop immediately with an error.''')
get_token_parser.add_argument('--url', required=True,
help='YouTube URL to process')
# --format removed, format/quality is determined by the server or embedded in the command
get_token_parser.add_argument('--account_id', default='default',
help='Account ID (default: default)')
get_token_parser.add_argument('--list-formats', action='store_true',
help='List available formats for the video')
args = parser.parse_args()
# Handle info.json loading
if args.load_info_json:
info_json = load_info_json(args.load_info_json)
if info_json:
print("Loaded info.json:")
print(json.dumps(info_json, indent=2))
return
transport = None
try:
# Ensure context directory exists and is writable
try:
Path(args.context_dir).mkdir(parents=True, exist_ok=True)
test_file = Path(args.context_dir) / "test.txt"
test_file.touch()
test_file.unlink()
except Exception as e:
logger.error(f"Could not access context directory {args.context_dir}: {e}")
print(f"Error: Could not access context directory {args.context_dir}")
sys.exit(1)
try:
# Check if we should use framed transport for very large messages
use_framed_transport = args.framed_transport or args.force_framed_transport or os.environ.get('USE_FRAMED_TRANSPORT', '').lower() in ('1', 'true', 'yes')
logger.debug(f"Using framed transport: {use_framed_transport}") # Changed to DEBUG
# Create socket with configurable timeout, force IPv4
socket = TSocket.TSocket(args.host, args.port, socket_family=2) # AF_INET = 2 for IPv4
# Use timeout-sec if provided, otherwise use timeout in milliseconds
if args.timeout_sec is not None:
socket.setTimeout(args.timeout_sec * 1000) # Convert seconds to milliseconds
logger.debug(f"Using timeout of {args.timeout_sec} seconds") # Changed to DEBUG
else:
socket.setTimeout(args.timeout) # Use timeout from CLI in milliseconds
logger.debug(f"Using timeout of {args.timeout} milliseconds") # Changed to DEBUG
# Always use TFramedTransport to match the server
transport = TTransport.TFramedTransport(socket)
logger.debug("Using TFramedTransport for large messages") # Changed to DEBUG
protocol = TBinaryProtocol.TBinaryProtocol(transport)
client = YTTokenOpService.Client(protocol)
logger.info(f"Attempting to connect to server at {args.host}:{args.port}...")
try:
transport.open()
logger.info("Successfully connected to server")
except TTransport.TTransportException as e:
logger.error(f"Connection failed: {str(e)}")
print(f"Error: Could not connect to server at {args.host}:{args.port}")
print(f"Reason: {str(e)}")
sys.exit(1)
# Add connection test
try:
client.ping()
logger.info("Server connection test successful")
except Exception as e:
logger.error(f"Server connection test failed: {e}")
raise
except TTransport.TTransportException as e:
logger.error(f"Connection failed: {str(e)}")
logger.error(f"Could not connect to {args.host}:{args.port}")
sys.exit(1)
except Exception as e:
logger.error(f"Connection failed: {str(e)}")
logger.error(traceback.format_exc())
sys.exit(1)
if args.command == 'getToken':
url = args.url
# format_codes removed
# Handle format listing
if args.list_formats:
formats = list_available_formats(url, args)
if formats:
print("\nAvailable formats:")
print(tabulate(formats, headers="keys", showindex=True)) # Show index for format selection
# Suggest best formats based on resolution
best_formats = suggest_best_formats(formats)
if best_formats:
print("\nSuggested formats:")
print(tabulate(best_formats, headers="keys"))
else:
print("No formats available or could not retrieve format information")
return
elif args.youtube_url:
url = args.youtube_url
format_code = args.format
print("Warning: --youtube-url is deprecated, use 'getToken --url' instead")
else:
print("Please provide a YouTube URL using 'getToken --url' command")
return
# Get token for URL
try:
# Get token for URL
logger.info(f"Requesting token for URL: {url}")
token_data = client.getOrRefreshToken(
accountId=args.account_id,
updateType=TokenUpdateMode.AUTO,
url=url
)
if not token_data:
logger.error("Received empty token data from server")
print("Error: Received empty token data from server")
sys.exit(1)
# Validate token data
if not hasattr(token_data, 'ytdlpCommand') or not token_data.ytdlpCommand:
logger.error("Token data missing required ytdlpCommand")
print("Error: Token data missing required ytdlpCommand")
sys.exit(1)
logger.info("Successfully received token data from server")
# Log all attributes of token_data for debugging
token_attrs = [attr for attr in dir(token_data) if not attr.startswith('__') and not callable(getattr(token_data, attr))]
logger.debug(f"Received token_data attributes: {token_attrs}")
# Handle case where token_data is a dict-like object
if hasattr(token_data, 'items'):
# Convert to dict if needed
token_dict = dict(token_data.items())
logger.debug(f"Token data as dict: {token_dict}")
# If we have JSON data directly in the response
if isinstance(token_dict.get('infoJson', None), str):
received_info_json = token_dict['infoJson']
elif isinstance(token_dict.get('data', None), (dict, str)):
# Try to use the data field if it exists
data = token_dict['data']
if isinstance(data, str):
received_info_json = data
else:
received_info_json = json.dumps(data)
else:
# Create info.json from available fields
info_data = {
"id": token_dict.get('id', extract_video_id(url)),
"title": token_dict.get('title', ''),
"formats": token_dict.get('formats', []),
"timestamp": int(time.time()),
"ytdlp_command": token_dict.get('ytdlpCommand', '')
}
received_info_json = json.dumps(info_data)
else:
# Handle case where token_data is a regular object
received_info_json = getattr(token_data, 'infoJson', None)
if received_info_json:
logger.debug(f"Received info.json data ({len(received_info_json)} bytes)")
if len(received_info_json) > 100:
logger.debug(f"Preview: {received_info_json[:100]}...")
else:
logger.warning("No valid info.json data found in response")
except PBServiceException as e:
logger.error(f"Service exception: {e.message}")
if hasattr(e, 'errorCode'):
if e.errorCode == "BOT_DETECTED":
print(f"Error: {e.message}")
print("\nYouTube has detected bot activity. Authentication is required.")
# Print suggestions if available
if hasattr(e, 'context') and e.context and 'suggestions' in e.context:
print("\nSuggestions:")
for i, suggestion in enumerate(e.context['suggestions'], 1):
print(f" {i}. {suggestion}")
else:
print("\nTry:")
print(" 1. Use --cookies-from-browser to pass authentication cookies")
print(" 2. Export cookies from a logged-in browser session")
print(" 3. Try a different client type (ios, android, mweb)")
print(" 4. Use a different proxy or IP address")
print(" 5. Try again later")
sys.exit(1)
elif e.errorCode in ["SOCKS5_CONNECTION_FAILED", "SOCKS5_TIMEOUT", "SOCKS5_CONNECTION_REFUSED",
"SOCKS5_CONNECTION_TIMEOUT", "SOCKS5_HOST_NOT_FOUND", "SOCKS5_NETWORK_UNREACHABLE"]:
print(f"Error: {e.message}")
print("\nSOCKS5 proxy connection failed. Please check your proxy settings.")
# Provide more specific guidance based on error code
if e.errorCode == "SOCKS5_TIMEOUT" or e.errorCode == "SOCKS5_CONNECTION_TIMEOUT":
print("The proxy server did not respond within the timeout period (9 seconds).")
print("This could indicate network congestion or a proxy server that's overloaded.")
elif e.errorCode == "SOCKS5_CONNECTION_REFUSED":
print("The proxy server actively refused the connection.")
print("This usually means the proxy server is not running or is not accepting connections on the specified port.")
elif e.errorCode == "SOCKS5_HOST_NOT_FOUND":
print("The proxy host could not be resolved.")
print("Please check that the hostname is correct and your DNS is working properly.")
elif e.errorCode == "SOCKS5_NETWORK_UNREACHABLE":
print("The network containing the proxy server is unreachable.")
print("This could indicate network routing issues or firewall restrictions.")
print("\nPossible solutions:")
print("1. Try using a different proxy server")
print("2. Check if the proxy server is running and accessible")
print("3. Verify your network connection and firewall settings")
print("4. If using a remote proxy, check if it's accessible from your location")
# Exit with a specific error code for proxy failures
sys.exit(2)
elif e.errorCode == "GLOBAL_TIMEOUT":
print(f"Error: {e.message}")
print("\nThe server timed out while processing your request.")
print("This could be due to:")
print("1. Slow network connection")
print("2. Server overload")
print("3. Complex video that takes too long to process")
print("\nTry again later or with a different video.")
sys.exit(3)
elif e.errorCode == "CLIENT_TIMEOUT":
print(f"Error: {e.message}")
print("\nA client-specific timeout occurred while processing your request.")
print("The server has stopped processing to avoid wasting resources.")
print("\nPossible solutions:")
print("1. Try again later when network conditions improve")
print("2. Try a different video")
print("3. Check your internet connection")
sys.exit(3)
else:
print(f"Error: {e.message}")
else:
print(f"Error: {e.message}")
return
except PBUserException as e:
logger.error(f"User exception: {e.message}")
print(f"Error: {e.message}")
return
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
logger.error(traceback.format_exc())
print(f"Unexpected error: {str(e)}")
sys.exit(1)
# Log the entire token_data object for debugging AFTER potential exceptions
logger.debug(f"Processing received token_data: {token_data}")
# Check if valid infoJson was received from the server
info_json = None
if hasattr(token_data, 'infoJson') and token_data.infoJson and token_data.infoJson != "{}":
if is_valid_json(token_data.infoJson):
logger.debug("Valid info.json received from server.") # Changed to DEBUG
info_json = token_data.infoJson
else:
logger.warning("Received infoJson from server, but it is not valid JSON or is empty.")
else:
logger.warning("Valid info.json was NOT received from the server.")
# Proceed only if we have valid info_json
if info_json:
# Save info.json if present in the server response
video_id = extract_video_id(url)
if not video_id:
logger.warning(f"Could not extract video ID from URL: {url}") # Keep as WARNING
video_id = f"unknown_{int(time.time())}"
try:
info_data = json.loads(info_json)
# Check if it contains an error
if isinstance(info_data, dict) and ('error' in info_data or 'errorCode' in info_data):
error_msg = info_data.get('error', 'Unknown error')
error_code = info_data.get('errorCode', 'UNKNOWN_ERROR')
logger.warning(f"infoJson contains error: {error_msg} (code: {error_code})")
# If it's a bot detection error, raise appropriate exception
if error_code == 'BOT_DETECTED' or 'bot' in error_msg.lower() or 'sign in' in error_msg.lower():
raise PBUserException(
message=f"Bot detection triggered: {error_msg}",
errorCode="BOT_DETECTION",
context={
"video_id": extract_video_id(url),
"url": url,
"suggestions": info_data.get('suggestions', ["Try different client", "Use proxy", "Wait and retry later"])
}
)
except json.JSONDecodeError as e:
# This case should ideally not happen due to is_valid_json check, but handle defensively
logger.error(f"Invalid JSON received despite initial check: {e}")
print(f"Error: Received invalid JSON data from server.")
info_json = None # Ensure we don't proceed
# If info_json is still None after checks, handle the failure case
if not info_json:
logger.error("Failed to obtain valid info.json from the server.")
print("Error: No valid video information (info.json) was received from the server.")
# Optionally, print the raw ytdlp command if available
if hasattr(token_data, 'ytdlpCommand') and token_data.ytdlpCommand:
print("\nRaw command from server (may be incomplete or require info.json):")
print(token_data.ytdlpCommand)
sys.exit(1) # Exit with error
# --- We have valid info_json, proceed with saving and command generation ---
try:
info_data = json.loads(info_json) # We know this is valid now
# Check if it's an error response embedded in the JSON
if isinstance(info_data, dict) and "error" in info_data:
logger.error(f"Received error report from server: {info_json}")
# Check if this is a bot detection error
if (info_data.get('errorCode') == "BOT_DETECTED" or
"bot" in info_data.get('message', '').lower() or
"sign in to confirm" in info_data.get('message', '').lower() or
"sign in to confirm" in info_data.get('error', '').lower() or
"unusual traffic" in info_data.get('message', '').lower() or
"captcha" in info_data.get('message', '').lower() or
info_data.get('requires_auth') == True):
logger.error("Bot detection error detected in info.json")
# Raise PBServiceException for bot detection
raise PBServiceException(
message=f"Bot detection triggered: {info_data.get('message', 'Authentication required')}",
errorCode="BOT_DETECTED",
context={
"video_id": video_id,
"url": url,
"requires_auth": True,
"info_data": info_data,
"suggestions": info_data.get('suggestions', [
"Use --cookies-from-browser to pass authentication cookies",
"Export cookies from a logged-in browser session",
"Try a different client type (ios, android, mweb)",
"Use a different proxy or IP address"
])
}
)
else:
# Raise PBServiceException for other errors
raise PBServiceException(
message=f"Error extracting video info: {info_data.get('error', 'Unknown error')}",
errorCode=info_data.get('errorCode', "EXTRACTION_FAILED"),
context={"video_id": video_id, "url": url, "info_data": info_data}
)
# If it's a valid response, process it
if 'title' in info_data or 'id' in info_data:
print(f"Video info retrieved: {info_data.get('title', 'Unknown title')}")
saved_path = save_info_json(info_json, video_id, args.context_dir)
if saved_path:
print(f"info.json saved to: {saved_path}")
# Create simpler base command using only the saved info.json and proxy
base_cmd = f"yt-dlp --load-info-json \"{saved_path}\"" # Quote the path
if hasattr(token_data, 'socks') and token_data.socks:
if token_data.socks.startswith(('socks5://', 'ss://')):
# Quote the proxy URL as well
base_cmd += f" --proxy \"{token_data.socks}\""
# Show format listing command
print("\nTo list available formats:")
format_cmd = f"{base_cmd} -F"
print(format_cmd)
# Show download command (format is usually embedded in info.json or determined by yt-dlp)
simplified_cmd = f"{base_cmd} --simulate" # Removed format codes
print("\nTo download (with --simulate to preview):")
print(simplified_cmd)
print("\nRemove --simulate to actually download")
else:
logger.error("Failed to save info.json file")
print("Failed to save info.json file")
else:
logger.warning("info.json appears to be valid JSON but missing expected video fields")
print("Error: Received incomplete or invalid video data")
print("This usually indicates an authentication or access issue")
sys.exit(1)
except Exception as e: # Catch errors during saving or command generation
logger.error(f"Error processing valid info.json: {str(e)}")
# Re-raise the exception to be handled by the main error handler
raise
finally:
if transport:
transport.close()
if __name__ == "__main__":
main()