Initial clean commit

This commit is contained in:
Christian Krakau-Louis
2025-05-13 08:59:02 +00:00
commit 03b982e7c2
113 changed files with 22973 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
import os
import logging
import importlib.util
from flask import Flask, session, redirect, url_for, request
from flask_login import LoginManager, current_user
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv
from werkzeug.middleware.proxy_fix import ProxyFix
from importlib import import_module
import spotipy
from spotipy.oauth2 import SpotifyOAuth
from musicround.config import Config
from musicround.version import VERSION_INFO, get_version_str
# Initialize SQLAlchemy
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def run_migrations():
"""
Run all migration scripts in the migrations directory
"""
logger.info("Running database migrations...")
# Path to migrations directory
migrations_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migrations')
if not os.path.isdir(migrations_dir):
logger.warning(f"Migrations directory not found at {migrations_dir}")
return
# Get all Python files in the migrations directory
migration_files = [f for f in os.listdir(migrations_dir)
if f.endswith('.py') and not f.startswith('__')]
if not migration_files:
logger.info("No migration scripts found")
return
# Run each migration script
migration_errors = False
for migration_file in sorted(migration_files):
try:
logger.info(f"Loading migration: {migration_file}")
file_path = os.path.join(migrations_dir, migration_file)
# Load the module dynamically
spec = importlib.util.spec_from_file_location(
f"migrations.{migration_file[:-3]}", file_path)
if spec and spec.loader:
migration_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration_module)
# Check if the module has a run_migration function
if hasattr(migration_module, "run_migration"):
logger.info(f"Executing migration: {migration_file}")
result = migration_module.run_migration()
# Handle the three possible return values:
# True: changes were made successfully
# None: no changes needed (already up to date)
# False: errors occurred
if result is True:
logger.info(f"Migration {migration_file} completed successfully")
elif result is None:
logger.info(f"Migration {migration_file} reported no changes needed")
else:
logger.warning(f"Migration {migration_file} reported errors")
migration_errors = True
else:
logger.warning(f"Migration {migration_file} doesn't have run_migration() function")
else:
logger.warning(f"Could not load migration module: {migration_file}")
except Exception as e:
logger.error(f"Error running migration {migration_file}: {str(e)}")
migration_errors = True
if migration_errors:
logger.warning("Some migrations encountered errors, but the application will continue to start")
else:
logger.info("All migrations completed")
def create_app(config=None):
"""
Factory pattern for creating the Flask app
"""
# Load environment variables
load_dotenv()
# Create Flask app
app = Flask(__name__, instance_relative_config=True)
# Configure ProxyFix for reverse proxy (e.g., Nginx)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
# Create data directory if it doesn't exist
data_dir = '/data'
if not os.path.exists(data_dir):
os.makedirs(data_dir, exist_ok=True)
# Set the database file path in the data directory
db_path = os.path.join(data_dir, 'song_data.db')
# Configure the app
app.config.from_object(Config)
# Explicitly set the database URI to ensure correct path
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
# Initialize extensions with app
db.init_app(app)
csrf.init_app(app)
# Register custom Jinja filters
@app.template_filter('timestamp_to_datetime')
def timestamp_to_datetime(timestamp):
"""Convert a Unix timestamp or ISO datetime string to a datetime object"""
from datetime import datetime
if isinstance(timestamp, str):
try:
# Try to parse as ISO format string
return datetime.fromisoformat(timestamp)
except (ValueError, TypeError):
try:
# Try to convert to float first then use as timestamp
return datetime.fromtimestamp(float(timestamp))
except (ValueError, TypeError):
return None
elif timestamp is None:
return None
else:
# Assume it's a numeric timestamp
try:
return datetime.fromtimestamp(timestamp)
except (ValueError, TypeError):
return None
@app.template_filter('format_datetime')
def format_datetime(dt, format='%Y-%m-%d %H:%M:%S'):
"""Format a datetime object to a string"""
if not dt:
return "Unknown"
return dt.strftime(format)
# Set up the Flask-Login extension
login_manager.login_view = 'users.login'
login_manager.login_message_category = 'info'
login_manager.login_message = 'Please log in to access this page.'
login_manager.init_app(app)
# Add version info to template context
@app.context_processor
def inject_version():
from musicround.version import get_version_str, VERSION_INFO
return {
'get_version_str': get_version_str,
'version_info': VERSION_INFO
}
# Import User model here to avoid circular imports
from musicround.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Ensure instance folder exists
try:
os.makedirs(app.instance_path, exist_ok=True)
except OSError:
pass
# Initialize OAuth providers (Google, Authentik)
from musicround.helpers.auth_helpers import init_oauth
init_oauth(app)
# Initialize Spotify client for common API access
# This will be available for any authenticated route
if app.config['SPOTIFY_CLIENT_ID'] and app.config['SPOTIFY_CLIENT_SECRET']:
app.config['sp_oauth'] = SpotifyOAuth(
client_id=app.config['SPOTIFY_CLIENT_ID'],
client_secret=app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=app.config['SPOTIFY_REDIRECT_URI'],
scope=app.config['SPOTIFY_SCOPE']
)
# Create a Spotify client that will be used throughout the app
app.config['sp'] = spotipy.Spotify(auth_manager=app.config['sp_oauth'])
# Initialize Deezer client - import inside the function to avoid circular dependency
from musicround.deezer_client import DeezerClient
app.config['deezer'] = DeezerClient()
# Add before_request handler to ensure Spotify token is available
@app.before_request
def ensure_spotify_token():
"""
Ensure a valid Spotify token is available in the session.
Priority:
1. Use existing manual bearer token if present in session
2. Try to refresh user's token if they have a refresh token
3. Use client credentials flow as fallback (no user login required)
"""
# Skip for static files and certain paths
if request.path.startswith('/static') or request.path.startswith('/favicon.ico'):
return
# If we already have a manual token in session, don't do anything
# Manual tokens take priority over everything else
if 'access_token' in session and session.get('token_source') != 'user' and session.get('token_source') != 'client_credentials':
app.logger.debug("Using existing manual bearer token")
return
from datetime import datetime
from spotipy.oauth2 import SpotifyOAuth
from .models import SystemSetting
import base64
import requests
try:
# Only check user token if user is logged in
if current_user.is_authenticated:
# Step 1: Try to use user's refresh token
if current_user.spotify_refresh_token:
app.logger.debug(f"Attempting to refresh token for user {current_user.username}")
# Create OAuth manager for token refresh
sp_oauth = SpotifyOAuth(
client_id=app.config['SPOTIFY_CLIENT_ID'],
client_secret=app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=app.config['SPOTIFY_SCOPE']
)
try:
# Refresh user's token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
if token_info and 'access_token' in token_info:
# Update user's tokens in database
current_user.spotify_token = token_info['access_token']
current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at'])
# If we got a new refresh token (rare but possible), update it
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
# Save to database
db.session.commit()
# Store token in session
session['access_token'] = token_info['access_token']
session['token_source'] = 'user'
app.logger.debug(f"Generated new token for user {current_user.username}")
return
except Exception as e:
app.logger.warning(f"Failed to refresh user token: {str(e)}")
# Step 2: If no user token or user not logged in, use client credentials flow
# Check if we already have a valid client credentials token
client_token_expiry = session.get('client_token_expiry', 0)
if 'access_token' in session and session.get('token_source') == 'client_credentials' and client_token_expiry > datetime.now().timestamp():
app.logger.debug("Using existing client credentials token")
return
# Get client credentials from config
client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
if client_id and client_secret:
app.logger.debug("Getting new token via client credentials flow")
# Encode client credentials
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
# Prepare headers and payload
headers = {
'Authorization': f'Basic {auth_header}',
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'client_credentials'
}
try:
# Make the POST request
response = requests.post('https://accounts.spotify.com/api/token', headers=headers, data=data)
response.raise_for_status()
token_data = response.json()
if 'access_token' in token_data:
# Store the token in session
session['access_token'] = token_data['access_token']
session['token_source'] = 'client_credentials'
# Calculate and store expiry time (typically 1 hour from now)
expires_in = token_data.get('expires_in', 3600) # Default to 1 hour
expiry_timestamp = datetime.now().timestamp() + expires_in
session['client_token_expiry'] = expiry_timestamp
app.logger.debug("Successfully obtained client credentials token")
return
else:
app.logger.warning("No access token in client credentials response")
except Exception as e:
app.logger.error(f"Error getting client credentials token: {str(e)}")
except Exception as e:
app.logger.error(f"Error in ensure_spotify_token: {str(e)}")
pass # Continue without a token if all methods fail
# Register blueprints
from musicround.routes.core import core_bp
from musicround.routes.users import users_bp
from musicround.routes.import_songs import import_songs_bp
from musicround.routes.rounds import rounds_bp
from musicround.routes.generate import generate_bp
from musicround.routes.api import api_bp
from musicround.routes.import_routes import import_bp
from musicround.routes.process import process_bp
from musicround.routes.deezer_routes import deezer_bp
from musicround.routes.db_admin import db_admin_bp, init_admin
from musicround.routes.auth import auth_bp
app.register_blueprint(core_bp)
app.register_blueprint(users_bp)
app.register_blueprint(import_songs_bp)
app.register_blueprint(rounds_bp)
app.register_blueprint(generate_bp)
app.register_blueprint(api_bp)
app.register_blueprint(import_bp)
app.register_blueprint(process_bp)
app.register_blueprint(deezer_bp)
app.register_blueprint(db_admin_bp)
app.register_blueprint(auth_bp)
# Initialize the admin interface
init_admin(app)
# Register error handlers
from musicround.errors import register_error_handlers
register_error_handlers(app)
# Try to create database tables if they don't exist
with app.app_context():
try:
db.create_all()
logger.info("Database tables created successfully during app initialization")
# Run migrations after tables are created
run_migrations()
except Exception as e:
logger.error(f"Error creating database tables during app initialization: {e}")
# Return the app
return app