#!/usr/bin/env python3 """ Tool to convert yt-dlp command-line flags to a JSON config using go-ytdlp. """ import argparse import json import logging import os import shlex import subprocess import sys from pathlib import Path from typing import Dict, List logger = logging.getLogger('config_tool') def get_go_ytdlp_path(user_path: str = None) -> str: """ Get the path to the go-ytdlp binary. Checks in order: 1. User-provided path 2. 'go-ytdlp' in PATH 3. Local binary in ytops_client/go_ytdlp_cli/go-ytdlp 4. Binary in go-ytdlp/go-ytdlp (the library's built binary) 5. Binary in /usr/local/bin/go-ytdlp """ def is_exe(fpath): return os.path.isfile(fpath) and os.access(fpath, os.X_OK) if user_path: if is_exe(user_path): return user_path # If user provided a path, we return it even if check fails, # so subprocess can raise the appropriate error for that specific path. return user_path # Check in PATH import shutil path_exe = shutil.which('go-ytdlp') if path_exe: return path_exe # Check local build directory local_path = Path(__file__).parent / 'go_ytdlp_cli' / 'go-ytdlp' if is_exe(str(local_path)): return str(local_path) # Check the go-ytdlp library directory project_root = Path(__file__).parent.parent library_binary = project_root / 'go-ytdlp' / 'go-ytdlp' if is_exe(str(library_binary)): return str(library_binary) # Check /usr/local/bin if is_exe('/usr/local/bin/go-ytdlp'): return '/usr/local/bin/go-ytdlp' # Default to 'go-ytdlp' which will raise FileNotFoundError if not in PATH return 'go-ytdlp' def convert_flags_to_json(flags: List[str], go_ytdlp_path: str = None) -> Dict: """ Converts a list of yt-dlp command-line flags to a JSON config dictionary. Args: flags: A list of strings representing the command-line flags. go_ytdlp_path: Path to the go-ytdlp executable. If None, will try to find it. Returns: A dictionary representing the JSON config. Raises: ValueError: If no flags are provided. FileNotFoundError: If the go-ytdlp executable is not found. subprocess.CalledProcessError: If go-ytdlp returns a non-zero exit code. json.JSONDecodeError: If the output from go-ytdlp is not valid JSON. """ if not flags: raise ValueError("No flags provided to convert.") # Get the actual binary path actual_path = get_go_ytdlp_path(go_ytdlp_path) # Use '--' to separate the subcommand flags from the flags to be converted. # This prevents go-ytdlp from trying to parse the input flags as its own flags. cmd = [actual_path, 'flags-to-json', '--'] + flags logger.debug(f"Executing command: {' '.join(shlex.quote(s) for s in cmd)}") try: process = subprocess.run(cmd, capture_output=True, check=True, encoding='utf-8') if process.stderr: logger.info(f"go-ytdlp output on stderr:\n{process.stderr.strip()}") return json.loads(process.stdout) except json.JSONDecodeError: logger.error("Failed to parse JSON from go-ytdlp stdout.") logger.error(f"Stdout was: {process.stdout.strip()}") raise except FileNotFoundError: logger.error(f"Executable '{actual_path}' not found.") logger.error("Please ensure go-ytdlp is installed and in your PATH.") logger.error("You can run the 'bin/install-goytdlp.sh' script to install it.") raise except subprocess.CalledProcessError as e: logger.error(f"go-ytdlp exited with error code {e.returncode}.") logger.error(f"Stderr:\n{e.stderr.strip()}") if "not supported" in e.stderr: logger.error("NOTE: The installed version of go-ytdlp does not support converting flags to JSON.") raise except PermissionError: logger.error(f"Permission denied executing '{actual_path}'.") logger.error("Please ensure the file is executable (chmod +x).") raise def add_flags_to_json_parser(subparsers): """Add the parser for the 'flags-to-json' command.""" parser = subparsers.add_parser( 'flags-to-json', description='Convert yt-dlp command-line flags to a JSON config using go-ytdlp.', formatter_class=argparse.RawTextHelpFormatter, help='Convert yt-dlp flags to a JSON config.', epilog=""" Examples: # Convert flags from a string ytops-client flags-to-json --from-string "-f best --no-playlist" # Convert flags from a file (like cli.config) ytops-client flags-to-json --from-file cli.config # Convert flags passed directly as arguments ytops-client flags-to-json -- --retries 5 --fragment-retries 5 # Combine sources (direct arguments override file/string) ytops-client flags-to-json --from-file cli.config -- --retries 20 The go-ytdlp executable must be in your PATH. You can install it by running the 'bin/install-goytdlp.sh' script. """ ) source_group = parser.add_mutually_exclusive_group() source_group.add_argument('--from-file', type=argparse.FileType('r', encoding='utf-8'), help='Read flags from a file (e.g., a yt-dlp config file).') source_group.add_argument('--from-string', help='Read flags from a single string.') parser.add_argument('flags', nargs=argparse.REMAINDER, help='yt-dlp flags to convert. Use "--" to separate them from this script\'s own flags.') parser.add_argument('--go-ytdlp-path', default='go-ytdlp', help='Path to the go-ytdlp executable. Defaults to "go-ytdlp" in PATH.') parser.add_argument('--verbose', action='store_true', help='Enable verbose output for this script.') return parser def main_flags_to_json(args): """Main logic for the 'flags-to-json' command.""" if args.verbose: # Reconfigure root logger for verbose output to stderr for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig(level=logging.DEBUG, format='%(name)s - %(levelname)s - %(message)s', stream=sys.stderr) else: # Default to INFO level, also to stderr for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s', stream=sys.stderr) flags = [] if args.from_file: logger.info(f"Reading flags from file: {args.from_file.name}") content = args.from_file.read() # A config file can have comments and one arg per line, or be a single line of args. # shlex.split is good for single lines, but for multi-line we should split by line and filter. lines = content.splitlines() for line in lines: line = line.strip() if line and not line.startswith('#'): # shlex.split can handle quoted arguments within the line flags.extend(shlex.split(line)) elif args.from_string: logger.info("Reading flags from string.") flags.extend(shlex.split(args.from_string)) if args.flags: # The 'flags' remainder might contain '--' which we should remove if it's the first element. remainder_flags = args.flags if remainder_flags and remainder_flags[0] == '--': remainder_flags = remainder_flags[1:] if remainder_flags: logger.info("Appending flags from command-line arguments.") flags.extend(remainder_flags) if not flags: logger.error("No flags provided to convert.") return 1 try: json_output = convert_flags_to_json(flags, args.go_ytdlp_path) # Print to actual stdout for piping. print(json.dumps(json_output, indent=2)) return 0 except (ValueError, FileNotFoundError, subprocess.CalledProcessError, json.JSONDecodeError, PermissionError): # Specific error is already logged by the helper function. return 1 except Exception as e: logger.error(f"An unexpected error occurred: {e}", exc_info=args.verbose) return 1 def convert_json_to_flags(json_input: str, go_ytdlp_path: str = None) -> str: """ Converts a JSON config string to yt-dlp command-line flags. Args: json_input: A string containing the JSON config. go_ytdlp_path: Path to the go-ytdlp executable. If None, will try to find it. Returns: A string of command-line flags. Raises: ValueError: If the json_input is empty. FileNotFoundError: If the go-ytdlp executable is not found. subprocess.CalledProcessError: If go-ytdlp returns a non-zero exit code. """ if not json_input: raise ValueError("No JSON input provided to convert.") # Get the actual binary path actual_path = get_go_ytdlp_path(go_ytdlp_path) cmd = [actual_path, 'json-to-flags'] logger.debug(f"Executing command: {' '.join(shlex.quote(s) for s in cmd)}") try: process = subprocess.run(cmd, input=json_input, capture_output=True, check=True, encoding='utf-8') if process.stderr: logger.info(f"go-ytdlp output on stderr:\n{process.stderr.strip()}") return process.stdout.strip() except FileNotFoundError: logger.error(f"Executable '{actual_path}' not found.") logger.error("Please ensure go-ytdlp is installed and in your PATH.") logger.error("You can run the 'bin/install-goytdlp.sh' script to install it.") raise except subprocess.CalledProcessError as e: logger.error(f"go-ytdlp exited with error code {e.returncode}.") logger.error(f"Stderr:\n{e.stderr.strip()}") raise except PermissionError: logger.error(f"Permission denied executing '{actual_path}'.") logger.error("Please ensure the file is executable (chmod +x).") raise def add_json_to_flags_parser(subparsers): """Add the parser for the 'json-to-flags' command.""" parser = subparsers.add_parser( 'json-to-flags', description='Convert a JSON config to yt-dlp command-line flags using go-ytdlp.', formatter_class=argparse.RawTextHelpFormatter, help='Convert a JSON config to yt-dlp flags.', epilog=""" Examples: # Convert JSON from a string ytops-client json-to-flags --from-string '{"postprocessor": {"ffmpeg": {"ppa": "SponsorBlock"}}}' # Convert JSON from a file ytops-client json-to-flags --from-file config.json The go-ytdlp executable must be in your PATH. You can install it by running the 'bin/install-goytdlp.sh' script. """ ) source_group = parser.add_mutually_exclusive_group(required=True) source_group.add_argument('--from-file', type=argparse.FileType('r', encoding='utf-8'), help='Read JSON from a file.') source_group.add_argument('--from-string', help='Read JSON from a single string.') parser.add_argument('--go-ytdlp-path', default='go-ytdlp', help='Path to the go-ytdlp executable. Defaults to "go-ytdlp" in PATH.') parser.add_argument('--verbose', action='store_true', help='Enable verbose output for this script.') return parser def main_json_to_flags(args): """Main logic for the 'json-to-flags' command.""" if args.verbose: # Reconfigure root logger for verbose output to stderr for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig(level=logging.DEBUG, format='%(name)s - %(levelname)s - %(message)s', stream=sys.stderr) else: # Default to INFO level, also to stderr for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s', stream=sys.stderr) json_input = "" if args.from_file: logger.info(f"Reading JSON from file: {args.from_file.name}") json_input = args.from_file.read() elif args.from_string: logger.info("Reading JSON from string.") json_input = args.from_string try: flags_output = convert_json_to_flags(json_input, args.go_ytdlp_path) # Print to actual stdout for piping. print(flags_output) return 0 except (ValueError, FileNotFoundError, subprocess.CalledProcessError, PermissionError): # Specific error is already logged by the helper function. return 1 except Exception as e: logger.error(f"An unexpected error occurred: {e}", exc_info=args.verbose) return 1