#!/usr/bin/env python3 """ Tool to download a specified format using yt-dlp as a Python library. """ import argparse import contextlib import copy import io import json import logging import os import re import shlex import sys import time from datetime import datetime try: import yt_dlp from yt_dlp.utils import match_filter_func except ImportError: print("yt-dlp is not installed. Please install it with: pip install yt-dlp", file=sys.stderr) sys.exit(1) logger = logging.getLogger('download_native_py_tool') # A custom logger for yt-dlp to capture output and key events class YTDLPLogger: def __init__(self): self.final_filename = None self.is_403 = False self.is_timeout = False self.has_errors = False def debug(self, msg): # yt-dlp logs the destination file path at the debug level. if msg.startswith('[download] Destination:'): self.final_filename = msg.split(':', 1)[1].strip() elif msg.startswith('[Merger] Merging formats into "'): # This captures the final filename after merging. self.final_filename = msg.split('"')[1] elif msg.startswith('[download]') and 'has already been downloaded' in msg: match = re.search(r'\[download\]\s+(.*)\s+has already been downloaded', msg) if match: self.final_filename = match.group(1).strip() logger.debug(msg) def info(self, msg): logger.info(msg) def warning(self, msg): logger.warning(msg) def error(self, msg): if "HTTP Error 403" in msg: self.is_403 = True if "Read timed out" in msg: self.is_timeout = True self.has_errors = True logger.error(msg) def ytdlp_progress_hook(d, ytdlp_logger): """Progress hook to capture the final filename.""" if d['status'] == 'finished': ytdlp_logger.final_filename = d.get('filename') logger.info(f"Download finished. Final file: {ytdlp_logger.final_filename}") def add_download_native_py_parser(subparsers): """Add the parser for the 'download py' command.""" parser = subparsers.add_parser( 'py', description='Download using yt-dlp as a Python library (recommended). This method calls yt-dlp functions directly.', formatter_class=argparse.RawTextHelpFormatter, help='Download using a direct Python call to yt-dlp (recommended).' ) parser.add_argument('--load-info-json', type=argparse.FileType('r', encoding='utf-8'), help="Path to the info.json file. If not provided, reads from stdin.") parser.add_argument('-f', '--format', required=True, help='The format selection string to download (e.g., "18", "299/137", "bestvideo+bestaudio").') parser.add_argument('--output-dir', default='.', help='Directory to save the downloaded file. Defaults to current directory.') parser.add_argument('--save-info-json-dir', help='If specified, save the info.json received from stdin to this directory with an auto-generated name.') parser.add_argument('--proxy', help='Proxy to use for the download, e.g., "socks5://127.0.0.1:1080".') parser.add_argument('--proxy-rename', help='Apply sed-style regex substitution to the proxy URL. Format: s/pattern/replacement/') parser.add_argument('--temp-path', help='Directory for temporary files (e.g., fragments). Use a RAM disk for best performance.') parser.add_argument('--pause', type=int, default=0, help='Seconds to wait before starting the download.') parser.add_argument('--download-continue', action='store_true', help='Enable download continuation (--no-overwrites and --continue flags for yt-dlp).') parser.add_argument('--verbose', action='store_true', help='Enable verbose output for this script and yt-dlp.') parser.add_argument('--config', default=None, help='Path to a yt-dlp JSON configuration file (e.g., ytdlp.json). If not provided, searches for ytdlp.json.') parser.add_argument('--downloader', help='Name of the external downloader backend for yt-dlp to use (e.g., "aria2c", "native").') parser.add_argument('--downloader-args', help='Arguments to pass to the external downloader backend (e.g., "aria2c:-x 8").') parser.add_argument('--extra-ytdlp-args', help='A string of extra command-line arguments to pass to yt-dlp.') parser.add_argument('--output-buffer', action='store_true', help='Download to an in-memory buffer and print raw bytes to stdout. Final filename is printed to stderr.') parser.add_argument('--cleanup', action='store_true', help='After download, rename the file to include a timestamp and truncate it to 0 bytes.') parser.add_argument('--merge-output-format', help='Container format to merge to (e.g., "mp4", "mkv"). Overrides config file.') parser.add_argument('--retries', type=int, help='Number of retries for the entire download (default: 10).') parser.add_argument('--fragment-retries', type=int, help='Number of retries for each fragment (default: 10).') parser.add_argument('--socket-timeout', type=int, help='Timeout for socket operations in seconds (default: 20).') parser.add_argument('--add-header', action='append', help='Add a custom HTTP header for the download. Format: "Key: Value". Can be used multiple times.') parser.add_argument('--concurrent-fragments', type=int, help='Number of fragments to download concurrently for each media.') # Arguments to pass through to yt-dlp parser.add_argument('--download-sections', help='yt-dlp --download-sections argument (e.g., "*0-10240").') parser.add_argument('--test', action='store_true', help='yt-dlp --test argument (download small part).') return parser def _download_single_format(format_id, info_data, base_ydl_opts, args): """ Download a single format ID from the given info_data. This function filters info_data to only contain the requested format, preventing yt-dlp from auto-merging with other streams. Returns a tuple: (success: bool, ytdlp_logger: YTDLPLogger) """ # Deep copy info_data so we can modify it without affecting other downloads local_info_data = copy.deepcopy(info_data) available_formats = local_info_data.get('formats', []) # Find the exact format target_format = next((f for f in available_formats if f.get('format_id') == format_id), None) if not target_format: logger.error(f"Format '{format_id}' not found in info.json") ytdlp_logger = YTDLPLogger() ytdlp_logger.has_errors = True return False, ytdlp_logger # Filter to only this format - this is the key to preventing auto-merge local_info_data['formats'] = [target_format] # Clear any pre-selected format fields that might trigger merging local_info_data.pop('requested_formats', None) local_info_data.pop('format', None) local_info_data.pop('format_id', None) logger.info(f"Filtered info_data to only contain format '{format_id}' (removed {len(available_formats) - 1} other formats)") # Create a fresh logger for this download ytdlp_logger = YTDLPLogger() # Copy base options and update with this format's specifics ydl_opts = dict(base_ydl_opts) ydl_opts['format'] = format_id ydl_opts['logger'] = ytdlp_logger ydl_opts['progress_hooks'] = [lambda d, yl=ytdlp_logger: ytdlp_progress_hook(d, yl)] try: download_buffer = None if args.output_buffer: download_buffer = io.BytesIO() ctx_mgr = contextlib.redirect_stdout(download_buffer) else: ctx_mgr = contextlib.nullcontext() with ctx_mgr, yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.process_ie_result(local_info_data) if ytdlp_logger.has_errors: logger.error(f"Download of format '{format_id}' failed: yt-dlp reported an error during execution.") return False, ytdlp_logger logger.info(f"Download of format '{format_id}' completed successfully.") if args.output_buffer and download_buffer: sys.stdout.buffer.write(download_buffer.getvalue()) sys.stdout.buffer.flush() return True, ytdlp_logger except yt_dlp.utils.DownloadError as e: logger.error(f"yt-dlp DownloadError for format '{format_id}': {e}") ytdlp_logger.has_errors = True return False, ytdlp_logger except Exception as e: logger.exception(f"Unexpected error downloading format '{format_id}': {e}") ytdlp_logger.has_errors = True return False, ytdlp_logger def main_download_native_py(args): """Main logic for the 'download-native-py' command.""" # All logging should go to stderr to keep stdout clean for the final filename, or for binary data with --output-buffer. log_stream = sys.stderr log_level = logging.DEBUG if args.verbose else logging.INFO # Reconfigure root logger for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig(level=log_level, stream=log_stream, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') if args.pause > 0: logger.info(f"Pausing for {args.pause} seconds...") time.sleep(args.pause) info_json_content = "" input_source_name = "" if args.load_info_json: info_json_content = args.load_info_json.read() input_source_name = args.load_info_json.name else: info_json_content = sys.stdin.read() input_source_name = "stdin" if not info_json_content.strip(): logger.error(f"Failed to read info.json from {input_source_name}. Input is empty.") return 1 try: info_data = json.loads(info_json_content) logger.info(f"Successfully loaded info.json from {input_source_name}.") except json.JSONDecodeError: logger.error(f"Failed to parse info.json from {input_source_name}. Is the input valid JSON?") return 1 if args.save_info_json_dir: try: video_id = info_data.get('id', 'unknown_video_id') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"{timestamp}-{video_id}-info.json" output_path = os.path.join(args.save_info_json_dir, filename) os.makedirs(args.save_info_json_dir, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: json.dump(info_data, f, indent=2) logger.info(f"Saved info.json to {output_path}") except Exception as e: logger.error(f"Failed to save info.json: {e}") # Handle proxy and proxy rename proxy_url = args.proxy if not proxy_url: proxy_url = info_data.get('_proxy_url') if proxy_url: logger.info(f"Using proxy from info.json: {proxy_url}") if proxy_url and args.proxy_rename: rename_rule = args.proxy_rename.strip("'\"") if rename_rule.startswith('s/') and rename_rule.count('/') >= 2: try: parts = rename_rule.split('/') pattern, replacement = parts[1], parts[2] original_proxy = proxy_url proxy_url = re.sub(pattern, replacement, proxy_url) logger.info(f"Renamed proxy URL from '{original_proxy}' to '{proxy_url}' using rule '{rename_rule}'") except re.error as e: logger.error(f"Invalid regex in --proxy-rename: {e}") return 1 else: logger.error("Invalid --proxy-rename format. Expected: s/pattern/replacement/") return 1 # For library usage, ensure proxy URL has a scheme. Default to http if missing. if proxy_url and '://' not in proxy_url: original_proxy = proxy_url proxy_url = 'http://' + proxy_url logger.info(f"Proxy URL '{original_proxy}' has no scheme. Defaulting to '{proxy_url}'.") # Build the yt-dlp options dictionary logger.info("--- Configuring yt-dlp options ---") param_sources = {} ydl_opts = {} def _parse_ytdlp_args(args_list, source_name, opts_dict, sources_dict): """Helper to parse a list of yt-dlp CLI-style args into an options dict.""" i = 0 while i < len(args_list): arg = args_list[i] if not arg.startswith('--'): logger.warning(f"Skipping non-option argument from {source_name}: {arg}") i += 1 continue key_cli = arg.lstrip('-') key_py = key_cli.replace('-', '_') is_flag = i + 1 >= len(args_list) or args_list[i + 1].startswith('--') if is_flag: if key_py.startswith('no_'): real_key = key_py[3:] # Handle special cases where the Python option name is different if real_key == 'resize_buffer': real_key = 'noresizebuffer' opts_dict[real_key] = False sources_dict[real_key] = source_name else: # Handle special cases if key_py == 'resize_buffer': opts_dict['noresizebuffer'] = False sources_dict['noresizebuffer'] = source_name else: opts_dict[key_py] = True sources_dict[key_py] = source_name i += 1 else: # Has a value value = args_list[i + 1] # Special key name conversions if key_py == 'limit_rate': key_py = 'ratelimit' elif key_py == 'buffer_size': key_py = 'buffersize' # Special value conversion for match_filter if key_py == 'match_filter': try: value = match_filter_func(value) except Exception as e: logger.error(f"Failed to compile --match-filter '{value}': {e}") # Skip this option i += 2 continue else: # Try to convert values to numbers, which yt-dlp expects try: value = int(value) except (ValueError, TypeError): try: value = float(value) except (ValueError, TypeError): pass # Keep as string opts_dict[key_py] = value sources_dict[key_py] = source_name i += 2 # 1. Load from JSON config file config_path = args.config log_msg = "" if config_path: log_msg = f"1. [Source: Config File] Loading from: {config_path}" else: if os.path.exists('ytdlp.json'): config_path = 'ytdlp.json' log_msg = f"1. [Source: Config File] No --config provided. Found and loading local '{config_path}'." if config_path and os.path.exists(config_path): if log_msg: logger.info(log_msg) try: with open(config_path, 'r', encoding='utf-8') as f: config_data = json.load(f) # All yt-dlp options are expected under the 'ytdlp_params' key. config_opts = config_data.get('ytdlp_params', {}) if config_opts: logger.info(f"Parameters from config file ('{config_path}'):") config_str = json.dumps(config_opts, indent=2) for line in config_str.splitlines(): logger.info(f" {line}") # Special handling for match_filter before updating ydl_opts if 'match_filter' in config_opts and isinstance(config_opts['match_filter'], str): logger.info(f" Compiling 'match_filter' string from config file...") try: config_opts['match_filter'] = match_filter_func(config_opts['match_filter']) except Exception as e: logger.error(f"Failed to compile match_filter from config: {e}") del config_opts['match_filter'] ydl_opts.update(config_opts) for key in config_opts: param_sources[key] = "Config File" except (json.JSONDecodeError, IOError) as e: logger.error(f"Failed to read or parse JSON config file {config_path}: {e}") return 1 elif args.config: logger.warning(f"Config file '{args.config}' not found. Ignoring.") # 2. Load from extra command-line args if args.extra_ytdlp_args: logger.info(f"2. [Source: CLI Extra Args] Loading extra arguments...") extra_args_list = shlex.split(args.extra_ytdlp_args) _parse_ytdlp_args(extra_args_list, "CLI Extra Args", ydl_opts, param_sources) # 3. Apply internal defaults if not already set if 'noresizebuffer' not in ydl_opts: ydl_opts['noresizebuffer'] = True param_sources['noresizebuffer'] = "Internal Default" if 'buffersize' not in ydl_opts: ydl_opts['buffersize'] = '4M' param_sources['buffersize'] = "Internal Default" if 'force_progress' not in ydl_opts: ydl_opts['force_progress'] = True param_sources['force_progress'] = "Internal Default" # 4. Apply explicit arguments from this script's CLI (highest priority) logger.info("3. [Source: CLI Explicit Args] Applying explicit overrides:") os.makedirs(args.output_dir, exist_ok=True) ydl_opts['verbose'] = args.verbose param_sources['verbose'] = "CLI Explicit" # Handle output template ('outtmpl') if args.output_buffer: ydl_opts['outtmpl'] = '-' param_sources['outtmpl'] = "CLI Explicit (Buffer)" elif 'outtmpl' in ydl_opts: # Respect outtmpl from config, but prepend the output directory outtmpl_val = ydl_opts['outtmpl'] if isinstance(outtmpl_val, dict): # Prepend dir to each template in the dict ydl_opts['outtmpl'] = {k: os.path.join(args.output_dir, v) for k, v in outtmpl_val.items()} else: # Prepend dir to the single template string ydl_opts['outtmpl'] = os.path.join(args.output_dir, str(outtmpl_val)) param_sources['outtmpl'] = "Config File (Path Applied)" logger.info(f" Using 'outtmpl' from config, with output directory '{args.output_dir}' prepended.") else: # Use a default template if not specified in config output_template = os.path.join(args.output_dir, '%(title)s [%(id)s].f%(format_id)s.%(ext)s') ydl_opts['outtmpl'] = output_template param_sources['outtmpl'] = "Internal Default" logger.info(f" Using default 'outtmpl': {output_template}") if args.temp_path: ydl_opts['paths'] = {'temp': args.temp_path} param_sources['paths'] = "CLI Explicit" logger.info(f" Temporary path: {args.temp_path}") if args.add_header: if 'http_headers' not in ydl_opts: ydl_opts['http_headers'] = {} elif not isinstance(ydl_opts['http_headers'], dict): logger.warning(f"Overwriting non-dictionary http_headers with headers from command line.") ydl_opts['http_headers'] = {} for header in args.add_header: if ':' in header: key, value = header.split(':', 1) ydl_opts['http_headers'][key.strip()] = value.strip() else: logger.error(f"Invalid header format: '{header}'. Expected 'Key: Value'.") param_sources['http_headers'] = "CLI Explicit (Merged)" if args.download_continue: ydl_opts['continuedl'] = True ydl_opts['nooverwrites'] = True param_sources['continuedl'] = "CLI Explicit" param_sources['nooverwrites'] = "CLI Explicit" if proxy_url: ydl_opts['proxy'] = proxy_url param_sources['proxy'] = "CLI Explicit" if args.downloader: ydl_opts['downloader'] = {args.downloader: None} param_sources['downloader'] = "CLI Explicit" if args.downloader_args: try: downloader_name, args_str = args.downloader_args.split(':', 1) ydl_opts.setdefault('downloader_args', {})[downloader_name] = shlex.split(args_str) param_sources['downloader_args'] = "CLI Explicit" except ValueError: logger.error(f"Invalid --downloader-args format. Expected 'downloader:args'.") return 1 if args.merge_output_format: ydl_opts['merge_output_format'] = args.merge_output_format param_sources['merge_output_format'] = "CLI Explicit" if args.download_sections: ydl_opts['download_sections'] = args.download_sections param_sources['download_sections'] = "CLI Explicit" if args.test: ydl_opts['test'] = True param_sources['test'] = "CLI Explicit" if args.retries is not None: ydl_opts['retries'] = args.retries param_sources['retries'] = "CLI Explicit" if args.fragment_retries is not None: ydl_opts['fragment_retries'] = args.fragment_retries param_sources['fragment_retries'] = "CLI Explicit" if args.socket_timeout is not None: ydl_opts['socket_timeout'] = args.socket_timeout param_sources['socket_timeout'] = "CLI Explicit" if args.concurrent_fragments is not None: ydl_opts['concurrent_fragments'] = args.concurrent_fragments param_sources['concurrent_fragments'] = "CLI Explicit" # To prevent timeouts on slow connections, ensure progress reporting is not disabled. # The CLI wrapper enables this by default, so we match its behavior for robustness. if ydl_opts.get('noprogress'): logger.info("Overriding 'noprogress' option. Progress reporting is enabled to prevent network timeouts.") ydl_opts['noprogress'] = False param_sources['noprogress'] = "Internal Override" # Ensure byte-size options are integers for library use try: from yt_dlp.utils import parse_bytes if 'buffersize' in ydl_opts and isinstance(ydl_opts['buffersize'], str): ydl_opts['buffersize'] = parse_bytes(ydl_opts['buffersize']) param_sources['buffersize'] = param_sources.get('buffersize', 'Unknown') + ' (Parsed)' except (ImportError, Exception) as e: logger.warning(f"Could not parse 'buffersize' option: {e}") # Force skip_download to False, as this script's purpose is to download. if ydl_opts.get('skip_download'): logger.info("Overriding 'skip_download: true' from config. This tool is for downloading.") ydl_opts['skip_download'] = False param_sources['skip_download'] = "Internal Override" # Log final effective options with sources logger.info("=== Final Effective yt-dlp Options (base) ===") for k in sorted(ydl_opts.keys()): v = ydl_opts[k] src = param_sources.get(k, "Unknown") if k in ['logger', 'progress_hooks']: continue logger.info(f" {k}: {v} [Source: {src}]") # --- Parse and process the format string --- requested_format = args.format available_formats = [str(f['format_id']) for f in info_data.get('formats', []) if 'format_id' in f] # Determine what kind of format string we have # Keywords that yt-dlp treats as special selectors selector_keywords = ('best', 'worst', 'bestvideo', 'bestaudio') # Split by comma to get individual format requests # Each item could be a simple format ID or a fallback chain (with /) format_items = [f.strip() for f in requested_format.split(',') if f.strip()] logger.info(f"Format string '{requested_format}' parsed into {len(format_items)} item(s): {format_items}") # Process each format item all_success = True final_filename = None for format_item in format_items: logger.info(f"--- Processing format item: '{format_item}' ---") # Check if this specific item is a simple format ID or a complex selector item_has_complex_syntax = any(c in format_item for c in '/+[]()') or format_item.startswith(selector_keywords) if item_has_complex_syntax: # This is a complex selector like "299/298/137" or "bestvideo+bestaudio" # We need to handle fallback chains specially if '/' in format_item and '+' not in format_item: # This is a fallback chain like "299/298/137" # Try each format in order until one succeeds fallback_formats = [f.strip() for f in format_item.split('/') if f.strip()] logger.info(f"Detected fallback chain with {len(fallback_formats)} options: {fallback_formats}") item_success = False for fallback_fmt in fallback_formats: # Check if this fallback is a simple format ID that exists if fallback_fmt in available_formats: logger.info(f"Trying fallback format '{fallback_fmt}'...") success, ytdlp_logger = _download_single_format(fallback_fmt, info_data, ydl_opts, args) if success: item_success = True if ytdlp_logger.final_filename: final_filename = ytdlp_logger.final_filename break else: logger.warning(f"Fallback format '{fallback_fmt}' failed, trying next...") else: # Try to find a matching format with a suffix (e.g., "140" matches "140-0") prefix_match_re = re.compile(rf'^{re.escape(fallback_fmt)}-\d+$') first_match = next((af for af in available_formats if prefix_match_re.match(af)), None) if first_match: logger.info(f"Fallback format '{fallback_fmt}' not found exactly. Using match: '{first_match}'...") success, ytdlp_logger = _download_single_format(first_match, info_data, ydl_opts, args) if success: item_success = True if ytdlp_logger.final_filename: final_filename = ytdlp_logger.final_filename break else: logger.warning(f"Fallback format '{first_match}' failed, trying next...") else: logger.warning(f"Fallback format '{fallback_fmt}' not available, trying next...") if not item_success: logger.error(f"All fallback formats in '{format_item}' failed or were unavailable.") all_success = False else: # This is a merge request or other complex selector # We can't safely filter for these, so we pass through to yt-dlp # but warn the user logger.warning(f"Complex format selector '{format_item}' detected. Cannot prevent auto-merge for this type.") logger.warning("If you experience merge errors, try specifying simple format IDs separated by commas.") # Use the original yt-dlp behavior for complex selectors ytdlp_logger = YTDLPLogger() local_ydl_opts = dict(ydl_opts) local_ydl_opts['format'] = format_item local_ydl_opts['logger'] = ytdlp_logger local_ydl_opts['progress_hooks'] = [lambda d, yl=ytdlp_logger: ytdlp_progress_hook(d, yl)] try: download_buffer = None if args.output_buffer: download_buffer = io.BytesIO() ctx_mgr = contextlib.redirect_stdout(download_buffer) else: ctx_mgr = contextlib.nullcontext() with ctx_mgr, yt_dlp.YoutubeDL(local_ydl_opts) as ydl: ydl.process_ie_result(copy.deepcopy(info_data)) if ytdlp_logger.has_errors: logger.error(f"Download of '{format_item}' failed.") all_success = False else: if ytdlp_logger.final_filename: final_filename = ytdlp_logger.final_filename if args.output_buffer and download_buffer: sys.stdout.buffer.write(download_buffer.getvalue()) sys.stdout.buffer.flush() except yt_dlp.utils.DownloadError as e: logger.error(f"yt-dlp DownloadError for '{format_item}': {e}") all_success = False except Exception as e: logger.exception(f"Unexpected error downloading '{format_item}': {e}") all_success = False else: # This is a simple format ID like "299-dashy" or "140" # Check if it exists in available formats if format_item in available_formats: success, ytdlp_logger = _download_single_format(format_item, info_data, ydl_opts, args) if success: if ytdlp_logger.final_filename: final_filename = ytdlp_logger.final_filename else: all_success = False else: # Try to find a matching format with a suffix (e.g., "140" matches "140-0") prefix_match_re = re.compile(rf'^{re.escape(format_item)}-\d+$') first_match = next((af for af in available_formats if prefix_match_re.match(af)), None) if first_match: logger.info(f"Requested format '{format_item}' not found. Using first available match: '{first_match}'.") success, ytdlp_logger = _download_single_format(first_match, info_data, ydl_opts, args) if success: if ytdlp_logger.final_filename: final_filename = ytdlp_logger.final_filename else: all_success = False else: logger.error(f"Requested format '{format_item}' not found in available formats: {available_formats}") all_success = False # Final output if all_success: logger.info("All format downloads completed successfully.") if final_filename: print(final_filename, file=sys.stdout) if args.cleanup and final_filename and os.path.exists(final_filename): try: logger.info(f"Cleanup: Renaming and truncating '{final_filename}'") timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') directory, original_filename = os.path.split(final_filename) filename_base, filename_ext = os.path.splitext(original_filename) new_filename = f"{filename_base}_{timestamp}{filename_ext}.empty" new_filepath = os.path.join(directory, new_filename) os.rename(final_filename, new_filepath) logger.info(f"Renamed to '{new_filepath}'") with open(new_filepath, 'w') as f: pass logger.info(f"Truncated '{new_filepath}' to 0 bytes.") except Exception as e: logger.error(f"Cleanup failed: {e}") return 1 return 0 else: logger.error("One or more format downloads failed.") return 1