update add_video route and change queue method
All checks were successful
Build Docker Image / build (push) Successful in 14m39s

- change add to queue method from `GET` on `/` to `POST` on `/queue`
- update `/add_video` route to accept single video url
This commit is contained in:
sudacode 2025-02-25 02:02:54 -08:00
parent b7aa64935f
commit ce605006f5
Signed by: sudacode
SSH Key Fingerprint: SHA256:lT5C2bB398DcX6daCF/gYFNSTK3y+Du3oTGUnYzfTEw
5 changed files with 219 additions and 27 deletions

View File

@ -23,4 +23,4 @@ EXPOSE "${PORT_NUMBER}"
RUN pip3 install --no-cache-dir -r requirements.txt
# Run server.py when the container launches
CMD gunicorn --bind "${LISTEN_ADDRESS}":"${LISTEN_PORT}" run:app
CMD gunicorn --bind ${LISTEN_ADDRESS}:${LISTEN_PORT} run:app

View File

@ -1 +1 @@
0.2.1
0.3.0

123
app/utils.py Normal file
View File

@ -0,0 +1,123 @@
import logging
from urllib.parse import urlparse
import bleach
from yt_dlp import YoutubeDL
def fetch_video_info(video_url):
"""
Fetch comprehensive video information using yt-dlp.
Returns a dictionary with video metadata or None if an error occurs.
"""
ydl_opts = {
"format": "best",
"quiet": True,
"noplaylist": True,
}
with YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(video_url, download=False)
# Get the first category or default to "Unknown"
category = "Unknown"
if "categories" in info and info["categories"]:
category = info["categories"][0]
# Extract basic required info plus additional metadata
return {
"video_url": video_url,
"video_name": info.get("title", "Unknown Title"),
"channel_url": info.get("channel_url", ""),
"channel_name": info.get("uploader", "Unknown Channel"),
"category": category,
"view_count": info.get("view_count", 0),
"subscriber_count": info.get("channel_follower_count", 0),
"thumbnail_url": info.get("thumbnail", ""),
"upload_date": info.get("upload_date", None),
}
except Exception as e:
logging.error(f"Error fetching info for {video_url}: {e}")
return None
# Helper functions for validation and sanitization
def is_valid_url(url, allowed_domains=None):
"""Validates URL format and optionally checks domain."""
if not url or not isinstance(url, str):
return False
try:
result = urlparse(url)
# Check for valid scheme and netloc
valid_format = all([result.scheme in ["http", "https"], result.netloc])
# Check domain if specified
if valid_format and allowed_domains:
return any(domain in result.netloc for domain in allowed_domains)
return valid_format
except:
return False
def validate_video_data(data):
"""Validates all fields in the video data."""
errors = {}
# URL validation
if not is_valid_url(data.get("video_url")):
errors["video_url"] = "Invalid video URL format"
if not is_valid_url(data.get("channel_url")):
errors["channel_url"] = "Invalid channel URL format"
if data.get("thumbnail_url") and not is_valid_url(data.get("thumbnail_url")):
errors["thumbnail_url"] = "Invalid thumbnail URL format"
# String length validation
if len(data.get("video_name", "")) > 500:
errors["video_name"] = "Video name too long (max 500 characters)"
if len(data.get("channel_name", "")) > 200:
errors["channel_name"] = "Channel name too long (max 200 characters)"
if data.get("category") and len(data.get("category")) > 100:
errors["category"] = "Category too long (max 100 characters)"
# Type validation for numeric fields
if data.get("view_count") is not None:
try:
int(data.get("view_count"))
except (ValueError, TypeError):
errors["view_count"] = "View count must be a valid integer"
if data.get("subscriber") is not None:
try:
int(data.get("subscriber"))
except (ValueError, TypeError):
errors["subscriber"] = "Subscriber count must be a valid integer"
# Date validation
if data.get("upload_date"):
# Implement appropriate date validation based on expected format
pass
return errors
def sanitize_video_data(data):
"""Sanitizes all string fields to prevent XSS."""
sanitized = {}
# Copy all fields, sanitizing strings
for key, value in data.items():
if isinstance(value, str):
# Remove potentially harmful HTML/scripts
sanitized[key] = bleach.clean(value, strip=True)
else:
sanitized[key] = value
return sanitized

View File

@ -1,12 +1,18 @@
"""Views for the Flask app."""
from flask import Blueprint, current_app, g, jsonify, request
import re
from urllib.parse import urlparse
from flask import Blueprint, abort, current_app, g, jsonify, request
from sqlalchemy import inspect
from sqlalchemy.exc import SQLAlchemyError
from yt_dlp import YoutubeDL
from app.database import get_db_session
from app.models import SavedQueue, WatchHistory
from app.mpv import send_to_mpv
from app.utils import (fetch_video_info, is_valid_url, sanitize_video_data,
validate_video_data)
bp = Blueprint("views", __name__)
@ -59,35 +65,66 @@ def load_queue():
@bp.route("/add_video", methods=["POST"])
def add_video():
data = request.get_json()
if not data or "video_url" not in data:
current_app.logger.error("Missing video_url field")
return jsonify(message="Missing video_url field"), 400
# Validate video URL format and allowed domains
if not is_valid_url(
data.get("video_url"), allowed_domains=["youtube.com", "youtu.be"]
):
current_app.logger.error("Invalid video URL format or domain")
return jsonify(message="Invalid video URL"), 400
# Check if we only have video_url
if all(key == "video_url" for key in data.keys()):
current_app.logger.info(
"Only video_url provided. Fetching additional information..."
)
video_info = fetch_video_info(data["video_url"])
if not video_info:
return jsonify(message="Failed to fetch video information"), 400
# Replace the data with our fetched info
data = video_info
# Validate all required fields exist
if not all(
k in data for k in ["video_url", "video_name", "channel_url", "channel_name"]
):
current_app.logger.error("Missing required fields")
current_app.logger.error(
"Required fields: video_url, video_name, channel_url, channel_name"
)
current_app.logger.error("Missing required fields after fetching")
return jsonify(message="Missing required fields"), 400
# Validate and sanitize all inputs
validation_errors = validate_video_data(data)
if validation_errors:
current_app.logger.error(f"Validation errors: {validation_errors}")
return jsonify(message="Invalid input data", errors=validation_errors), 400
# Sanitize string inputs
sanitized_data = sanitize_video_data(data)
new_entry = WatchHistory(
video_url=data["video_url"],
video_name=data["video_name"],
channel_url=data["channel_url"],
channel_name=data["channel_name"],
category=data.get("category") if data.get("category") else None,
view_count=data.get("view_count") if data.get("view_count") else None,
subscriber_count=data.get("subscribers") if data.get("subscribers") else None,
thumbnail_url=data.get("thumbnail_url") if data.get("thumbnail_url") else None,
upload_date=data.get("upload_date") if data.get("upload_date") else None,
video_url=sanitized_data["video_url"],
video_name=sanitized_data["video_name"],
channel_url=sanitized_data["channel_url"],
channel_name=sanitized_data["channel_name"],
category=sanitized_data.get("category"),
view_count=sanitized_data.get("view_count"),
subscriber_count=sanitized_data.get("subscribers"),
thumbnail_url=sanitized_data.get("thumbnail_url"),
upload_date=sanitized_data.get("upload_date"),
)
db_session = g.db_session
try:
current_app.logger.debug("Adding video to watch history")
current_app.logger.debug(f"Data: {sanitized_data}")
db_session.add(new_entry)
db_session.commit()
current_app.logger.debug("Video added to watch history")
current_app.logger.debug("Data: %s", data)
return jsonify(message="Video added"), 200
except SQLAlchemyError as e:
db_session.rollback()
@ -95,18 +132,47 @@ def add_video():
return jsonify(message="Failed to add video"), 500
@bp.route("/", methods=["GET"])
def handle_request():
video_url = request.args.get("url")
if not video_url:
return "Missing 'url' parameter", 400
@bp.route("/queue", methods=["POST"])
def handle_queue():
data = request.get_json()
if not data or "url" not in data:
current_app.logger.warning("Request missing 'url' parameter")
return jsonify(message="Missing 'url' parameter"), 400
command = (
f'{{"command": ["script-message", "add_to_youtube_queue", "{video_url}"]}}\n'
)
video_url = data["url"]
# Basic URL validation
if not isinstance(video_url, str) or not video_url.strip():
current_app.logger.warning(f"Invalid URL format: {repr(video_url)}")
return jsonify(message="Invalid URL format"), 400
# URL validation to check for http/https protocol
if not (video_url.startswith("http://") or video_url.startswith("https://")):
current_app.logger.warning(f"URL missing protocol: {repr(video_url)}")
return jsonify(message="URL must start with http:// or https://"), 400
# Validate URL structure
try:
parsed_url = urlparse(video_url)
if not all([parsed_url.scheme, parsed_url.netloc]):
current_app.logger.warning(f"Invalid URL structure: {repr(video_url)}")
return jsonify(message="Invalid URL structure"), 400
except Exception as e:
current_app.logger.error(f"Failed to parse URL {repr(video_url)}: {str(e)}")
return jsonify(message="Failed to parse URL"), 400
# Sanitize the URL to prevent command injection
sanitized_url = video_url.replace('"', '\\"')
command = f'{{"command": ["script-message", "add_to_youtube_queue", "{sanitized_url}"]}}\n'
current_app.logger.debug(f"Sending URL to MPV: {repr(sanitized_url)}")
if send_to_mpv(command):
return "URL added to mpv queue", 200
return "Failed to add URL to mpv queue", 500
current_app.logger.info("Successfully added URL to MPV queue")
return jsonify(message="URL added to mpv queue"), 200
current_app.logger.error("Failed to add URL to MPV queue")
return jsonify(message="Failed to add URL to mpv queue"), 500
@bp.route("/migrate_watch_history", methods=["POST"])

View File

@ -1,4 +1,5 @@
alembic==1.14.1
bleach==6.2.0
blinker==1.9.0
cffi==1.17.1
click==8.1.7
@ -17,4 +18,6 @@ pycparser==2.22
PyMySQL==1.1.1
SQLAlchemy==2.0.38
typing_extensions==4.12.2
webencodings==0.5.1
Werkzeug==3.1.3
yt-dlp==2025.2.19