169 lines
5.6 KiB
Python
169 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Flask API Server for YouTube Web Interface
|
|
Provides REST API endpoints for searching, downloading, and managing YouTube content
|
|
Uses Flask-SocketIO for real-time progress updates
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, send_from_directory
|
|
from flask_cors import CORS
|
|
from flask_socketio import SocketIO
|
|
|
|
# Add parent directory to path to import YouTubeCLI
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
|
# Add server directory to path for local imports
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
from youtube_cli.main import YouTubeCLI
|
|
from models.queue_store import QueueStore
|
|
from models.archive import ArchiveDB
|
|
from download_engine import DownloadEngine
|
|
from routes import search_bp, download_bp, queue_bp, archive_bp
|
|
|
|
# Initialize YouTubeCLI
|
|
yt_cli = YouTubeCLI()
|
|
|
|
# Initialize Flask app
|
|
static_dir = str(Path(__file__).parent.parent / 'web-app' / 'dist')
|
|
app = Flask(__name__, static_folder=static_dir, static_url_path='/')
|
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'youtube-web-secret')
|
|
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
|
|
|
# Initialize SocketIO - use threading mode (compatible with gunicorn gthread worker)
|
|
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
|
|
|
# Persistent config dir from env var
|
|
config_dir = os.environ.get('CONFIG_DIR', str(Path.home() / '.config' / 'youtube_cli'))
|
|
Path(config_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Persistent file logging
|
|
log_dir = os.environ.get('LOG_DIR', '/app/logs')
|
|
Path(log_dir).mkdir(parents=True, exist_ok=True)
|
|
file_handler = logging.FileHandler(str(Path(log_dir) / 'youtube-cli.log'))
|
|
file_handler.setLevel(logging.INFO)
|
|
file_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s'))
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(logging.INFO)
|
|
root_logger.addHandler(file_handler)
|
|
|
|
# Initialize components with persistent paths
|
|
queue_store = QueueStore(store_path=str(Path(config_dir) / 'queue.json'))
|
|
archive_db = ArchiveDB(db_path=str(Path(config_dir) / 'archive.db'))
|
|
download_engine = DownloadEngine(queue_store, archive_db, yt_cli, socketio)
|
|
|
|
|
|
# ==================== Health & Config ====================
|
|
|
|
from utils import make_response, make_error_response
|
|
|
|
|
|
@app.route('/api/health', methods=['GET'])
|
|
def health_check():
|
|
"""Health check endpoint."""
|
|
try:
|
|
yt_dlp_version = yt_cli.get_yt_dlp_version()
|
|
return make_response({
|
|
"status": "healthy",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"yt_dlp_version": yt_dlp_version
|
|
})
|
|
except Exception as e:
|
|
return make_error_response(f"Health check failed: {str(e)}", 500)
|
|
|
|
|
|
@app.route('/api/config', methods=['GET'])
|
|
def get_config():
|
|
"""Get current configuration."""
|
|
try:
|
|
config = yt_cli.config
|
|
return make_response({
|
|
"download_dir": config.get("download_dir"),
|
|
"default_locations": config.get("default_locations", []),
|
|
"max_videos_per_page": config.get("max_videos_per_page", 15),
|
|
"network_share_path": config.get("network_share_path"),
|
|
"default_network_subfolder": config.get("default_network_subfolder")
|
|
})
|
|
except Exception as e:
|
|
return make_error_response(f"Failed to get config: {str(e)}", 500)
|
|
|
|
|
|
@app.route('/api/categories', methods=['GET'])
|
|
def get_categories():
|
|
"""Get available download categories."""
|
|
try:
|
|
config = yt_cli.config
|
|
categories = yt_cli.get_categories(config)
|
|
return make_response({
|
|
"categories": categories,
|
|
"default_download_dir": config.get("download_dir")
|
|
})
|
|
except Exception as e:
|
|
return make_error_response(f"Failed to get categories: {str(e)}", 500)
|
|
|
|
|
|
# ==================== Register Blueprints ====================
|
|
|
|
app.register_blueprint(search_bp)
|
|
app.register_blueprint(download_bp)
|
|
app.register_blueprint(queue_bp)
|
|
app.register_blueprint(archive_bp)
|
|
|
|
|
|
# ==================== WebSocket Events ====================
|
|
|
|
@socketio.on('connect')
|
|
def handle_connect():
|
|
"""Handle client WebSocket connection."""
|
|
from flask_socketio import emit
|
|
emit('connected', {'message': 'Connected to server'})
|
|
|
|
|
|
@socketio.on('disconnect')
|
|
def handle_disconnect():
|
|
"""Handle client WebSocket disconnection."""
|
|
pass
|
|
|
|
|
|
# ==================== Static File Serving ====================
|
|
|
|
@app.route('/')
|
|
def serve_home():
|
|
"""Serve React app home page."""
|
|
static_path = app.static_folder or static_dir
|
|
return send_from_directory(static_path, 'index.html')
|
|
|
|
|
|
# ==================== Error Handlers ====================
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
"""Handle 404 errors - serve index.html for SPA routes."""
|
|
from flask import request
|
|
# For SPA routes (not API), serve index.html
|
|
if not request.path.startswith('/api'):
|
|
static_path = app.static_folder or static_dir
|
|
return send_from_directory(static_path, 'index.html')
|
|
return make_error_response("Endpoint not found", 404)
|
|
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
"""Handle 500 errors."""
|
|
return make_error_response("Internal server error", 500)
|
|
|
|
|
|
# ==================== Run Server ====================
|
|
|
|
if __name__ == '__main__':
|
|
port = int(os.environ.get('PORT', 4096))
|
|
debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
|
|
|
|
# Use eventlet for WebSocket support (required by Flask-SocketIO)
|
|
socketio.run(app, host='0.0.0.0', port=port, debug=debug, allow_unsafe_werkzeug=True) |