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