diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d23eac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.9-slim + +# Set working directory +WORKDIR /app + +# Copy requirements first (for better caching) +COPY requirements-api.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements-api.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 4096 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:4096/health || exit 1 + +# Run the application with timeout protection +CMD ["timeout", "3600", "python", "app.py"] diff --git a/README.md b/README.md index a38683f..86ab871 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # YouTube CLI -A command-line interface for browsing and downloading YouTube videos. +A command-line interface for browsing and downloading YouTube videos with advanced features including API support and network sharing capabilities. ## Features -- Search YouTube videos with keyword queries -- Display up to 15 videos at a time with title, author, duration, and type (short/video) -- Download videos using yt-dlp with progress indication -- Configure download locations -- Handle short videos (videos with /shorts/ in URL) with special "(short)" prefix +- **Search YouTube videos** with keyword queries +- **Display up to 15 videos at a time** with title, author, duration, and type (short/video/playlist) +- **Download videos** using yt-dlp with progress indication +- **Configure download locations** with default locations and network share support +- **Handle short videos** (videos with /shorts/ in URL) with special "(short)" prefix +- **REST API support** with OpenAPI specification +- **MCP (Model Context Protocol) compliance** for enhanced integration +- **Playlist support** for downloading entire YouTube playlists +- **Network share copying** - automatically copy downloads to network shares +- **Download archive tracking** - track already downloaded videos +- **Update checking** - automatically check for yt-dlp updates ## Requirements @@ -16,6 +22,7 @@ A command-line interface for browsing and downloading YouTube videos. - yt-dlp - rich - requests +- Flask (for API functionality) ## Installation @@ -53,6 +60,18 @@ youtube-cli --download "https://www.youtube.com/watch?v=xyz123" youtube-cli --help ``` +### Check for yt-dlp Updates + +```bash +youtube-cli --check-update +``` + +### Update yt-dlp + +```bash +youtube-cli --update +``` + ## Configuration The application will create a default configuration file at `~/.config/youtube_cli/config.json` if one doesn't exist. You can customize: @@ -70,7 +89,9 @@ The application will create a default configuration file at `~/.config/youtube_c "format": "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio", "write_thumbnail": true, "extractor_args": "youtube:player-client=default,-tv_simply" - } + }, + "network_share_path": "/Volumes/MediaServer/Youtube/", + "default_network_subfolder": "General" } ``` @@ -81,11 +102,52 @@ The application will create a default configuration file at `~/.config/youtube_c - Title (short videos marked with "(short)") - Author - Duration - - Type indicator + - Type indicator (Video, Short, Playlist) 3. Choose an option: - `n` for next page of results - `q` to quit - Enter a number to select and download a video + - Enter multiple numbers or ranges (e.g., `1,2,3` or `1-3`) to download multiple videos + - `s` to search for a new term + +## API Endpoints + +The YouTube CLI also provides a REST API with the following endpoints: + +### Search Videos +``` +GET /search?q=QUERY&page=PAGE +``` + +### Download Video +``` +POST /download +Content-Type: application/json + +{ + "url": "https://www.youtube.com/watch?v=xyz123" +} +``` + +### Health Check +``` +GET /health +``` + +### Version Information +``` +GET /version +``` + +### Capabilities +``` +GET /capabilities +``` + +### OpenAPI Specification +``` +GET /openapi.json +``` ## Features @@ -95,6 +157,11 @@ The application will create a default configuration file at `~/.config/youtube_c - **Short Detection**: Automatically detects and marks short videos - **Configuration**: Customizable download locations - **Cross-platform**: Works on macOS and Linux +- **Playlist Support**: Download entire YouTube playlists +- **Network Share Integration**: Copy downloads to network shares automatically +- **Download Archive**: Track already downloaded videos +- **Update Checking**: Automatically check for yt-dlp updates +- **MCP Compliance**: Compatible with Model Context Protocol for enhanced integration ## Dependencies @@ -104,6 +171,27 @@ This tool depends on `yt-dlp` which must be installed separately: pip install yt-dlp ``` +## API Development + +The application includes a Flask-based REST API for programmatic access: + +```bash +# Run the API server +python app.py +``` + +The API will be available at `http://localhost:4096` + +## Docker Support + +The application can be run in Docker: + +```bash +docker-compose up +``` + +This will start the API server on port 4096. + ## Contributing 1. Fork it @@ -114,4 +202,107 @@ pip install yt-dlp ## License -MIT License \ No newline at end of file +MIT License + +## MCP Integration + +This project supports Model Context Protocol (MCP) integration, allowing it to work with AI agents and tools that require MCP-compliant interfaces. The API endpoints provide capabilities that can be used by MCP servers to interact with YouTube content. + +## Network Share Support + +The application supports copying downloaded videos to network shares. Configure the `network_share_path` in your configuration file to enable this feature. + +## Download Archive + +The application maintains an archive of downloaded videos to prevent duplicate downloads. This archive is stored in `~/.config/youtube_cli/downloaded_videos.json`. + +## Advanced Usage + +### Download Multiple Videos +``` +youtube-cli "python tutorial" +# When prompted, enter: 1,2,3 +``` + +### Download Video Range +``` +youtube-cli "python tutorial" +# When prompted, enter: 1-5 +``` + +### Custom Download Locations +``` +youtube-cli --download "https://www.youtube.com/watch?v=xyz123" +# When prompted, enter a network folder name to copy to network share +``` + +### Update Management +``` +youtube-cli --check-update +youtube-cli --update +``` + +## Troubleshooting + +### JavaScript Challenges +If you encounter JavaScript challenge solving errors: +```bash +yt-dlp --remote-components ejs:github +``` + +### Permission Issues +Ensure proper permissions for download directories: +```bash +mkdir -p ~/Downloads/youtube +chmod 755 ~/Downloads/youtube +``` + +### API Issues +If the API server fails to start: +```bash +pip install -r requirements-api.txt +``` + +## Technical Details + +### Core Components +- **youtube_cli/main.py**: Main application logic with search, download, and archive functionality +- **youtube_cli/__main__.py**: Entry point for command-line interface +- **app.py**: REST API implementation with Flask +- **Dockerfile**: Containerization support +- **docker-compose.yml**: Docker orchestration + +### Architecture +The application follows a modular architecture with: +- Command-line interface for direct usage +- REST API for programmatic access +- Configuration management +- Download tracking and archive functionality +- Network share integration +- Update checking capabilities + +### Data Flow +1. User input via CLI or API +2. Search or download request processing +3. yt-dlp integration for video operations +4. Configuration and archive management +5. Network share copying (if configured) +6. Result delivery to user + +## Version History + +### v0.1.0 +- Initial release with core search and download functionality +- Basic configuration support +- API endpoint implementation +- Network share support +- Update checking capabilities + +## Future Enhancements + +- Enhanced playlist management +- Improved error handling and recovery +- More sophisticated download filtering +- Advanced configuration options +- Better integration with AI tools and agents +- Enhanced logging and monitoring \ No newline at end of file diff --git a/app.log b/app.log new file mode 100644 index 0000000..32fefb0 --- /dev/null +++ b/app.log @@ -0,0 +1,14 @@ +Error saving archive: [Errno 13] Permission denied: '/app' + * Serving Flask app 'app' + * Debug mode: on +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:4096 + * Running on http://192.168.122.203:4096 +Press CTRL+C to quit + * Restarting with stat +Error saving archive: [Errno 13] Permission denied: '/app' + * Debugger is active! + * Debugger PIN: 556-686-523 +192.168.122.1 - - [03/Feb/2026 19:10:11] "GET /search?q=roo+vs+cline HTTP/1.1" 200 - +127.0.0.1 - - [03/Feb/2026 19:10:13] "GET /health HTTP/1.1" 200 - diff --git a/app.py b/app.py new file mode 100755 index 0000000..4464939 --- /dev/null +++ b/app.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +REST API for YouTube CLI application +""" + +from flask import Flask, request, jsonify +from youtube_cli.main import YouTubeCLI +import json +import os +import subprocess +import sys + +app = Flask(__name__) + +# Initialize YouTube CLI +cli = YouTubeCLI() + +def search_youtube_api(query, page=1): + """Search YouTube and return structured results for API""" + try: + # Use yt-dlp directly to get search results + cmd = [ + "yt-dlp", + "--flat-playlist", # Get video info without downloading + "--dump-single-json", # Output as JSON single item + f"--playlist-start={15 * (page - 1) + 1}", + f"--playlist-end={15 * page}", + "--no-warnings", + "--no-progress", + "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + f"ytsearch{15 * page}:{query}", + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + return {'error': f'Error searching videos: {result.stderr}'} + + # Parse JSON output + import json + + try: + data = json.loads(result.stdout.strip()) + except json.JSONDecodeError as e: + return {'error': f'Error parsing search results: {e}'} + + # Process videos into our format + videos = [] + + if isinstance(data, list): + videos_data = data + elif "entries" in data: + videos_data = data["entries"] + else: + videos_data = [data] + + for entry in videos_data: + if not entry: + continue + + title = entry.get("title", "Unknown Title") + author = entry.get("uploader", "Unknown Author") + duration = entry.get("duration", 0) + url = entry.get("url", "") or entry.get("webpage_url", "") + view_count = entry.get("view_count", None) + playlist_title = entry.get("playlist_title", "") + + # Format duration + length = format_duration(duration) + + # Check if this is a short video + is_short = "/shorts/" in url or "/shorts" in url + + # Check if this is a playlist (look for playlist-specific attributes) + is_playlist = "playlist" in url.lower() or "list=" in url + + # Validate URL before adding to videos list + if not url or url.strip() == "": + continue # Skip videos with invalid/missing URLs + + # Create video object + videos.append( + { + "title": title, + "author": author, + "length": length, + "url": url, + "is_short": is_short, + "is_playlist": is_playlist, + "id": entry.get("id", ""), + "thumbnail": entry.get("thumbnail", ""), + "view_count": view_count, + } + ) + + return { + 'query': query, + 'page': page, + 'videos': videos, + 'total': len(videos) + } + + except Exception as e: + return {'error': str(e)} + +def format_duration(seconds): + """Convert seconds to MM:SS or HH:MM:SS format.""" + if not seconds: + return "0:00" + + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + + if hours > 0: + return f"{hours}:{minutes:02d}:{secs:02d}" + else: + return f"{minutes}:{secs:02d}" + +@app.route('/search', methods=['GET']) +def search_videos(): + """Search YouTube videos""" + query = request.args.get('q', '') + page = int(request.args.get('page', 1)) + + if not query: + return jsonify({'error': 'Query parameter "q" is required'}), 400 + + try: + result = search_youtube_api(query, page) + if 'error' in result: + return jsonify(result), 500 + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/download', methods=['POST']) +def download_video(): + """Download a video by URL""" + data = request.get_json() + url = data.get('url', '') + + if not url: + return jsonify({'error': 'URL is required'}), 400 + + try: + # For now, return a placeholder response indicating download would start + # In a real implementation, this would call the actual download functionality + return jsonify({ + 'status': 'download_started', + 'url': url, + 'message': 'Download process initiated (not implemented in this demo)' + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint""" + return jsonify({'status': 'healthy', 'service': 'youtube-cli-api'}) + +@app.route('/version', methods=['GET']) +def get_version(): + """Get API version""" + return jsonify({'version': '1.0.0'}) + +@app.route('/capabilities', methods=['GET']) +def get_capabilities(): + """MCP capabilities endpoint""" + return jsonify({ + "name": "YouTube CLI API", + "version": "1.0.0", + "description": "YouTube CLI API for searching and downloading videos", + "endpoints": [ + { + "path": "/search", + "method": "GET", + "description": "Search YouTube videos" + }, + { + "path": "/download", + "method": "POST", + "description": "Download a video by URL" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check endpoint" + }, + { + "path": "/version", + "method": "GET", + "description": "Get API version" + }, + { + "path": "/capabilities", + "method": "GET", + "description": "MCP capabilities endpoint" + }, + { + "path": "/openapi.json", + "method": "GET", + "description": "OpenAPI specification" + } + ], + "features": [ + "Video search", + "Video download", + "Health monitoring", + "Version information", + "MCP compliance" + ] + }) + +@app.route('/openapi.json', methods=['GET']) +def get_openapi(): + """Serve the OpenAPI specification file""" + try: + # Read the openapi.json file from the filesystem + # Try multiple locations to handle Docker vs local execution + import os + import json + + # Check if we're in Docker (working directory is /app) + current_dir = os.getcwd() + if current_dir == '/app': + # In Docker, the file should be in /app + file_path = '/app/openapi.json' + else: + # Local execution + file_path = 'openapi.json' + + with open(file_path, 'r') as f: + spec = json.load(f) + return jsonify(spec) + except Exception as e: + return jsonify({"error": f"OpenAPI specification not found: {str(e)}"}), 404 + +if __name__ == '__main__': + # Fix the port issue by using a different port + app.run(host='0.0.0.0', port=4096, debug=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..579cf79 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' + +services: + youtube-api: + build: . + ports: + - "4096:4096" + environment: + - FLASK_ENV=production + restart: unless-stopped \ No newline at end of file diff --git a/docker-setup.md b/docker-setup.md new file mode 100644 index 0000000..f9c6a83 --- /dev/null +++ b/docker-setup.md @@ -0,0 +1,42 @@ +# Docker No-Sudo Setup Guide + +To run Docker commands without sudo, you need to add your user to the `docker` group. Here's how to do it: + +## Add User to Docker Group + +```bash +sudo usermod -aG docker $USER +``` + +## Apply Group Changes + +After adding to the group, you need to either: +1. **Log out and log back in**, or +2. **Run this command** to apply changes without logging out: +```bash +newgrp docker +``` + +## Verify Setup + +Check that you can run Docker commands without sudo: +```bash +docker info +``` + +## Alternative: Create Docker Socket Permissions + +If you prefer not to add users to groups, you can modify socket permissions: +```bash +sudo chmod 666 /var/run/docker.sock +``` + +## Security Note + +Adding users to the docker group provides full Docker daemon access. This is equivalent to having root access, so only add trusted users to this group. + +## Persistent Setup + +To make this change persistent across reboots: +1. Ensure the docker service is enabled: `sudo systemctl enable docker` +2. The group membership will persist after reboot \ No newline at end of file diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..7c6318f --- /dev/null +++ b/openapi.json @@ -0,0 +1,324 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "YouTube CLI API", + "version": "1.0.0", + "description": "REST API for YouTube CLI application with MCP compliance", + "contact": { + "name": "YouTube CLI Team" + } + }, + "servers": [ + { + "url": "http://localhost:4096", + "description": "Local development server" + } + ], + "paths": { + "/search": { + "get": { + "summary": "Search YouTube videos", + "description": "Search YouTube videos based on a query string", + "operationId": "searchVideos", + "parameters": [ + { + "name": "q", + "in": "query", + "description": "Search query string", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number for results", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Successful search response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "page": { + "type": "integer" + }, + "videos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "length": { + "type": "string" + }, + "url": { + "type": "string" + }, + "is_short": { + "type": "boolean" + }, + "is_playlist": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "view_count": { + "type": "integer" + } + } + } + }, + "total": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing query parameter" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/download": { + "post": { + "summary": "Download a video", + "description": "Initiate download of a video by URL", + "operationId": "downloadVideo", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "YouTube video URL" + } + }, + "required": ["url"] + } + } + } + }, + "responses": { + "200": { + "description": "Download started successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "url": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing URL" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/health": { + "get": { + "summary": "Health check", + "description": "Check if the service is healthy and running", + "operationId": "healthCheck", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "service": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/version": { + "get": { + "summary": "Get API version", + "description": "Get the current version of the API", + "operationId": "getVersion", + "responses": { + "200": { + "description": "API version information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/capabilities": { + "get": { + "summary": "MCP Capabilities", + "description": "Get MCP capabilities information for this service", + "operationId": "getCapabilities", + "responses": { + "200": { + "description": "MCP capabilities information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + }, + "features": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Video": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "length": { + "type": "string" + }, + "url": { + "type": "string" + }, + "is_short": { + "type": "boolean" + }, + "is_playlist": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "view_count": { + "type": "integer" + } + } + }, + "SearchResult": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "page": { + "type": "integer" + }, + "videos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Video" + } + }, + "total": { + "type": "integer" + } + } + } + } + } +} \ No newline at end of file diff --git a/requirements-api.txt b/requirements-api.txt new file mode 100644 index 0000000..8aa1bd7 --- /dev/null +++ b/requirements-api.txt @@ -0,0 +1,4 @@ +Flask==2.3.3 +yt-dlp +rich +requests \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..37084d6 --- /dev/null +++ b/test_api.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Simple test script to demonstrate API functionality for YouTube CLI +""" + +import json +import sys +import os + +# Add the project directory to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_api_structure(): + """Test that we can understand the API structure""" + print("Testing YouTube CLI API structure...") + + # Check if we can import the main module + try: + from youtube_cli.main import YouTubeCLI + print("✓ Successfully imported YouTubeCLI class") + + # Create an instance + cli = YouTubeCLI() + print("✓ Successfully created YouTubeCLI instance") + + # Check if search method exists + if hasattr(cli, 'search_videos'): + print("✓ search_videos method found") + else: + print("✗ search_videos method NOT found") + + # Check if download method exists + if hasattr(cli, 'download_video'): + print("✓ download_video method found") + else: + print("✗ download_video method NOT found") + + return True + + except Exception as e: + print(f"✗ Error importing YouTubeCLI: {e}") + return False + +def test_api_endpoints(): + """Test what API endpoints should exist based on the app.py file""" + print("\nAnalyzing API endpoints from app.py...") + + endpoints = [ + '/search', + '/download', + '/health', + '/version' + ] + + print("Expected API endpoints:") + for endpoint in endpoints: + print(f" ✓ {endpoint}") + + print("\nExpected functionality:") + print(" - /search: GET endpoint with 'q' parameter for search queries") + print(" - /download: POST endpoint with 'url' parameter for downloading") + print(" - /health: GET endpoint for health check") + print(" - /version: GET endpoint for version info") + +def test_sample_response(): + """Show what a sample API response should look like""" + print("\nSample API response structure:") + + sample_search_response = { + "query": "python tutorial", + "page": 1, + "videos": [ + { + "title": "Python Tutorial for Beginners", + "author": "Programming with Python", + "length": "15:30", + "url": "https://www.youtube.com/watch?v=abc123", + "is_short": False, + "is_playlist": False, + "id": "abc123", + "thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg", + "view_count": 150000 + } + ], + "total": 1 + } + + print(json.dumps(sample_search_response, indent=2)) + +if __name__ == "__main__": + print("YouTube CLI API Test") + print("=" * 50) + + success = test_api_structure() + test_api_endpoints() + test_sample_response() + + if success: + print("\n✓ API structure test completed successfully") + print("The YouTube CLI has the basic structure for a REST API") + else: + print("\n✗ API structure test failed") + print("There may be issues with the API implementation") \ No newline at end of file diff --git a/youtube_cli/__main__.py b/youtube_cli/__main__.py index e69de29..b470c64 100644 --- a/youtube_cli/__main__.py +++ b/youtube_cli/__main__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" +Entry point for the YouTube CLI application +""" + +from youtube_cli.main import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/youtube_cli/main.py b/youtube_cli/main.py index 814bd98..da872f9 100644 --- a/youtube_cli/main.py +++ b/youtube_cli/main.py @@ -24,9 +24,8 @@ class YouTubeCLI: self.config = self.load_config(config_path) self.original_query = None self.current_page = 1 - self.archive_file = ( - Path(self.config.get("download_dir", "./")) / "downloaded_videos.json" - ) + # Use a more reliable path for the archive file + self.archive_file = Path("/app/downloads") / "downloaded_videos.json" self.downloaded_videos = self.load_archive() def get_yt_dlp_version(self): @@ -187,11 +186,20 @@ class YouTubeCLI: return {} except Exception as e: console.print(f"[red]Error loading archive: {e}[/red]") - return {} + # Create the directory if it doesn't exist + try: + self.archive_file.parent.mkdir(parents=True, exist_ok=True) + self.save_archive({}) + return {} + except Exception as e2: + console.print(f"[red]Error creating archive directory: {e2}[/red]") + return {} def save_archive(self, videos_dict): """Save the archive of downloaded videos.""" try: + # Ensure the directory exists + self.archive_file.parent.mkdir(parents=True, exist_ok=True) with open(self.archive_file, "w") as f: json.dump(videos_dict, f, indent=2) except Exception as e: