295 lines
8.6 KiB
Python
Executable File
295 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
REST API for YouTube CLI application
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
from logging.handlers import RotatingFileHandler
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, jsonify, request
|
|
|
|
from youtube_cli.main import YouTubeCLI
|
|
|
|
# Configure logging
|
|
_config_dir = os.environ.get("CONFIG_DIR", str(Path.home() / ".config" / "youtube_cli"))
|
|
LOG_DIR = Path(_config_dir) / "logs"
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
LOG_FILE = LOG_DIR / "app.log"
|
|
|
|
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
|
file_handler = RotatingFileHandler(
|
|
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5
|
|
)
|
|
file_handler.setLevel(logging.DEBUG)
|
|
file_handler.setFormatter(
|
|
logging.Formatter(
|
|
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
"%Y-%m-%d %H:%M:%S",
|
|
)
|
|
)
|
|
|
|
# Create console handler
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setLevel(logging.INFO)
|
|
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
|
|
# Configure root logger
|
|
logging.basicConfig(
|
|
level=logging.DEBUG,
|
|
handlers=[
|
|
file_handler,
|
|
console_handler,
|
|
],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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)
|
|
|
|
# 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 with fields matching frontend expectations
|
|
videos.append(
|
|
{
|
|
"id": entry.get("id", ""),
|
|
"videoId": entry.get("id", ""),
|
|
"title": title,
|
|
"description": "",
|
|
"thumbnail": entry.get("thumbnail", ""),
|
|
"url": url,
|
|
"category": "General",
|
|
"duration": length,
|
|
"views": str(view_count) if view_count else "0",
|
|
"channel": author,
|
|
"isShort": is_short,
|
|
"published": "2024-01-01",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"results": videos,
|
|
"total": len(videos),
|
|
"page": page,
|
|
"hasMore": len(videos) >= 15,
|
|
}
|
|
|
|
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 json
|
|
import os
|
|
|
|
# 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)
|