youtube-cli/app.py

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)