Compare commits

..

10 Commits

Author SHA1 Message Date
ksyasuda
06040a1a05
update logging
update logging to integrate with flask
2024-09-05 04:20:21 -07:00
ksyasuda
ae511bdb93
update variable names 2024-09-05 04:19:13 -07:00
ksyasuda
dc696b51b3
change to gunicorn to serve app 2024-09-05 04:18:58 -07:00
ksyasuda
6b19d96658
update main to master in workflow 2024-09-04 12:07:09 -07:00
ksyasuda
52491358fd
bump version 2024-09-04 12:05:57 -07:00
ksyasuda
cb0334a3e3
update main branch name 2024-09-04 12:03:45 -07:00
ksyasuda
a49bcca3f7
add docker build workflow and version file 2024-09-04 12:03:10 -07:00
bebeedc0ae history-db (#1)
- update to Flask
- add history db for watch history tracking
- update service file

Co-authored-by: ksyasuda <ksyasuda@umich.edu>
Reviewed-on: #1
2024-09-04 11:54:05 -07:00
ksyasuda
3fcd7b1706
update example env file 2024-08-23 01:38:30 -07:00
dbfc19f878 add docker and compose file 2024-08-22 22:47:48 -07:00
9 changed files with 310 additions and 53 deletions

View File

@ -0,0 +1,33 @@
name: Build Docker Image
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Read current version
id: get_version
run: echo "current_version=$(cat VERSION)" >> $GITHUB_ENV
- name: Log in to Gitea Docker Registry
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login https://gitea.suda.codes -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: |
gitea.suda.codes/sudacode/mpv-youtube-queue-server:${{ env.current_version }}
gitea.suda.codes/sudacode/mpv-youtube-queue-server:latest
- name: Log out from Gitea Docker Registry
run: docker logout https://gitea.suda.codes

2
.gitignore vendored
View File

@ -1 +1,3 @@
.env .env
env/*
.git

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# Use an official Python runtime as a parent image
FROM python:3.10-slim
# Set environment variables for the MPV socket and server host/port
ENV LISTEN_ADDRESS="0.0.0.0" \
LISTEN_PORT=8080
# Set the working directory in the container
WORKDIR /app
# Copy the current directory contents into the container at /app
COPY server.py requirements.txt /app/
# Install any needed packages specified in requirements.txt
# If there are no external dependencies, you can skip this step
# RUN pip install --no-cache-dir -r requirements.txt
# Make port 8080 available to the world outside this container
EXPOSE "${PORT_NUMBER}"
RUN pip3 install --no-cache-dir -r requirements.txt
# Run server.py when the container launches
# CMD ["python3", "server.py", "--host", "${LISTEN_ADDRESS}", "--port", "${LISTEN_PORT}", "--input-ipc-server", "${MPV_SOCKET}"]
CMD gunicorn --bind "${LISTEN_ADDRESS}":"${LISTEN_PORT}" server:app

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.0.2

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
---
services:
mpv-youtube-queue-server:
build: .
image: mpv-youtube-queue-server:latest
container_name: mpv-youtube-queue-server
user: 1000:1000
volumes:
- /tmp:/tmp
ports:
- 42069:8080
env_file: .env
networks:
- mpv-youtube-queue-server
restart: unless-stopped
networks:
mpv-youtube-queue-server:
external: true

View File

@ -1,3 +1,13 @@
IP=0.0.0.0 # Listen on all interfaces # Server config
PORT_NUMBER=8080 # You can change this port if needed LISTEN_ADDRESS=0.0.0.0 # Lisen on all interfaces
MPV_SOCKET=/mpvsocket LISTEN_PORT=8080 # Internal port number
MPV_SOCKET=/tmp/mpvsocket # Path to mpv socket
# MySQL connection config
MYSQL_HOST=localhost
MYSQL_USER=mpvuser
MYSQL_PASSWORD=SecretPassword
MYSQL_DATABASE=mpv
# Logging
LOGLEVEL=warning

View File

@ -10,6 +10,10 @@ Restart=on-failure
Environment="MPV_SOCKET=/tmp/mpvsocket" Environment="MPV_SOCKET=/tmp/mpvsocket"
Environment="HOST_NAME=0.0.0.0" Environment="HOST_NAME=0.0.0.0"
Environment="PORT_NUMBER=42069" Environment="PORT_NUMBER=42069"
Environment="MYSQL_HOST=http://localhost"
Environment="MYSQL_USER=mpvuser"
Environment="MYSQL_PASSWORD=SecretPassword"
Environment="MYSQL_PORT=3306"
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
blinker==1.8.2
click==8.1.7
Flask==3.0.3
gunicorn==23.0.0
itsdangerous==2.2.0
Jinja2==3.1.4
MarkupSafe==2.1.5
mysql-connector-python==9.0.0
packaging==24.1
Werkzeug==3.0.4

242
server.py
View File

@ -1,25 +1,120 @@
#!/usr/bin/env python3
import logging import logging
import os import os
import socket import socket
import time import time
import urllib.parse import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer from datetime import date
import mysql.connector
from flask import Flask, jsonify, request
from mysql.connector import Error
MAX_RETRIES = 5
SOCKET_RETRY_DELAY = 1.5
# Configuration # Configuration
MPV_SOCKET = os.getenv("MPV_SOCKET", "/tmp/mpvsocket") MPV_SOCKET: str = os.getenv("MPV_SOCKET", "/tmp/mpvsocket")
HOST_NAME = os.getenv("HOST_NAME", "0.0.0.0") HOST_NAME: str = os.getenv("HOST_NAME", "0.0.0.0")
PORT_NUMBER = int(os.getenv("PORT_NUMBER", "8080")) PORT_NUMBER: int = int(os.getenv("PORT_NUMBER", "8080"))
SOCKET_RETRY_DELAY = 5 # Time in seconds between retries to connect to the socket # MySQL Configuration
MAX_RETRIES = 10 # Maximum number of retries to connect to the socket MYSQL_HOST: str = os.getenv("MYSQL_HOST", "localhost")
MYSQL_DATABASE: str = os.getenv("MYSQL_DATABASE", "your_database")
MYSQL_USER: str = os.getenv("MYSQL_USER", "your_username")
MYSQL_PASSWORD: str = os.getenv("MYSQL_PASSWORD", "your_password")
MYSQL_PORT: int = int(os.getenv("MYSQL_PORT", "3306"))
LOGLEVEL = os.getenv("LOGLEVEL", "INFO").strip().upper()
# Set up basic logging # Initialize Flask
logging.basicConfig( app = Flask(__name__)
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
# Set up logging
def setup_logging():
"""Sets up logging for both the app and flask."""
if not app.logger.hasHandlers(): # Check if there are already handlers
# Create a formatter
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
) )
# Create a console handler
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
def send_to_mpv(command): # Configure the root logger
app.logger.addHandler(console_handler)
# Set the log level based on LOGLEVEL
if LOGLEVEL == "DEBUG":
app.logger.setLevel(logging.DEBUG)
elif LOGLEVEL == "WARNING":
app.logger.setLevel(logging.WARNING)
elif LOGLEVEL == "ERROR":
app.logger.setLevel(logging.ERROR)
else:
app.logger.setLevel(logging.INFO)
# Silence noisy logs from certain libraries if necessary
logging.getLogger("logger").setLevel(logging.INFO)
# Set up logging
setup_logging()
def get_mysql_connection():
"""
Get a MySQL database connection.
--------
Returns
--------
connection: mysql.connector.connection.MySQLConnection
The MySQL connection object if successful, otherwise None.
"""
try:
connection = mysql.connector.connect(
host=MYSQL_HOST,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
port=MYSQL_PORT,
)
if connection.is_connected():
app.logger.debug("Connected to database successfully.")
return connection
except Error as e:
app.logger.error(f"Error while connecting to MySQL: {e}")
return None
def ensure_watch_history_table_exists():
"""Ensure the watch_history table exists in the mpv schema, otherwise create it."""
connection = get_mysql_connection()
if connection:
try:
cursor = connection.cursor()
cursor.execute("CREATE DATABASE IF NOT EXISTS mpv")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS mpv.watch_history (
whid INT AUTO_INCREMENT PRIMARY KEY,
video_url VARCHAR(255) NOT NULL,
video_name VARCHAR(255) NOT NULL,
channel_url VARCHAR(255) NOT NULL,
channel_name VARCHAR(255) NOT NULL,
watch_date DATE NOT NULL
)
"""
)
connection.commit()
app.logger.info("Ensured watch_history table exists")
except Error as e:
app.logger.error(f"Failed to ensure watch_history table exists: {e}")
finally:
cursor.close()
connection.close()
def send_to_mpv(command: str):
"""Send a command to the mpv socket, retrying up to MAX_RETRIES times if the socket is not available.""" """Send a command to the mpv socket, retrying up to MAX_RETRIES times if the socket is not available."""
attempts = 0 attempts = 0
while attempts < MAX_RETRIES: while attempts < MAX_RETRIES:
@ -27,63 +122,122 @@ def send_to_mpv(command):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.connect(MPV_SOCKET) client_socket.connect(MPV_SOCKET)
client_socket.sendall(command.encode("utf-8")) client_socket.sendall(command.encode("utf-8"))
logging.info("Command sent to mpv successfully.") app.logger.info("Command sent to mpv successfully.")
return True return True
except socket.error as e: except socket.error as e:
attempts += 1 attempts += 1
logging.error( app.logger.error(
f"Failed to connect to socket (attempt {attempts}/{MAX_RETRIES}): {e}. Retrying in {SOCKET_RETRY_DELAY} seconds..." f"Failed to connect to socket (attempt {attempts}/{MAX_RETRIES}): {e}. Retrying in {SOCKET_RETRY_DELAY} seconds..."
) )
time.sleep(SOCKET_RETRY_DELAY) time.sleep(SOCKET_RETRY_DELAY)
logging.error(f"Exceeded maximum retries ({MAX_RETRIES}). Ignoring the request.") app.logger.error(f"Exceeded maximum retries ({MAX_RETRIES}). Ignoring the request.")
return False return False
class MyHandler(BaseHTTPRequestHandler): @app.route("/add_video", methods=["POST"])
def do_GET(self): def add_video():
# Parse the URL and extract the "url" parameter """
query_components = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) Adds a video to the mpv queue and the MySQL database.
video_url = query_components.get("url", [None])[0]
--------
Parameters
--------
data: dict
The JSON data containing the video information.
Required fields:
- video_url: str
- video_name: str
- channel_url: str
- channel_name: str
e.g. {"video_url": "https://www.youtube.com/watch?v=video_id", "video_name": "video_name", "channel_url": "https://www.youtube.com/channel/channel_id", "channel_name": "channel_name"}
"""
data = request.get_json()
if data:
video_url: str = data.get("video_url")
video_name: str = data.get("video_name")
channel_url: str = data.get("channel_url")
channel_name: str = data.get("channel_name")
watch_date: date = date.today().strftime("%Y-%m-%d")
if video_url and video_name and channel_url and channel_name and watch_date:
app.logger.debug(f"Received data: {data}")
app.logger.debug(f"Watch date: {watch_date}")
# Insert the data into the MySQL database
connection = get_mysql_connection()
if connection:
try:
query = """
INSERT INTO mpv.watch_history (video_url, video_name, channel_url, channel_name, watch_date)
VALUES (%s, %s, %s, %s, %s)
"""
cursor = connection.cursor()
cursor.execute(
query,
(
video_url,
video_name,
channel_url,
channel_name,
watch_date,
),
)
connection.commit()
app.logger.info("Data inserted into MySQL database")
return (
jsonify(message="Data added to mpv queue and database"),
200,
)
except Error as e:
app.logger.error(f"Failed to insert data into MySQL database: {e}")
return jsonify(message="Failed to add data to database"), 500
finally:
cursor.close()
connection.close()
else:
return jsonify(message="Failed to connect to MySQL database"), 500
else:
app.logger.error("Missing required data fields")
return jsonify(message="Missing required data fields"), 400
else:
app.logger.error("Invalid JSON data")
return jsonify(message="Invalid JSON data"), 400
@app.route("/", methods=["GET"])
def handle_request():
"""
Handle GET requests to the root URL. This function is used to add a video to the mpv queue.
"""
video_url = request.args.get("url")
if video_url: if video_url:
video_url = urllib.parse.unquote(video_url) # Decode the URL video_url = urllib.parse.unquote(video_url) # Decode the URL
logging.info(f"Received URL: {video_url}") app.logger.info(f"Received URL: {video_url}")
# Create the command to send to mpv # Create the command to send to mpv
command = f'{{"command": ["script-message", "add_to_youtube_queue", "{video_url}"]}}\n' command = f'{{"command": ["script-message", "add_to_youtube_queue", "{video_url}"]}}\n'
# Try to send the command to mpv # Try to send the command to mpv
if send_to_mpv(command): if send_to_mpv(command):
self.send_response(200) return "URL added to mpv queue", 200
self.end_headers()
self.wfile.write(b"URL added to mpv queue")
else: else:
self.send_response(500) return "Failed to add URL to mpv queue after max retries", 500
self.end_headers()
self.wfile.write(b"Failed to add URL to mpv queue after max retries")
else: else:
logging.error("Missing 'url' parameter") app.logger.error("Missing 'url' parameter")
self.send_response(400) return "Missing 'url' parameter", 400
self.end_headers()
self.wfile.write(b"Missing 'url' parameter")
def log_message(self, format, *args):
# Override default log_message method to avoid duplicate logging from BaseHTTPRequestHandler
logging.info(f"{self.address_string()} - {format % args}")
if __name__ == "__main__": if __name__ == "__main__":
logging.info(f"Starting server on {HOST_NAME}:{PORT_NUMBER}...") app.logger.info(f"Starting server on {HOST_NAME}:{PORT_NUMBER}...")
ensure_watch_history_table_exists()
try: try:
httpd = HTTPServer((HOST_NAME, PORT_NUMBER), MyHandler) app.run(host=HOST_NAME, port=PORT_NUMBER)
logging.info(f"Server running on port {PORT_NUMBER}...")
httpd.serve_forever()
except Exception as e: except Exception as e:
logging.exception(f"Error occurred: {e}") app.logger.exception(f"Error occurred: {e}")
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("Server is shutting down...") app.logger.info("Server is shutting down...")
httpd.server_close() app.logger.info("Server stopped.")
logging.info("Server stopped.")