update add_video route and change queue method
All checks were successful
Build Docker Image / build (push) Successful in 14m39s
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:
parent
b7aa64935f
commit
ce605006f5
@ -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
|
||||
|
123
app/utils.py
Normal file
123
app/utils.py
Normal 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
|
116
app/views.py
116
app/views.py
@ -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"])
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user