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
+1
View File
@@ -0,0 +1 @@
# This file makes the helpers directory a proper Python package
+280
View File
@@ -0,0 +1,280 @@
"""
Authentication helper functions for OAuth providers
"""
import os
from flask import current_app, url_for, session, flash, redirect, request
from authlib.integrations.flask_client import OAuth
from flask_login import login_user, current_user
from functools import wraps
from datetime import datetime, timedelta
import requests
from musicround.models import db, User
# Initialize OAuth object
oauth = OAuth()
def init_oauth(app):
"""
Initialize OAuth with the Flask app and register providers
"""
oauth.init_app(app)
# Register Google OAuth client
if app.config.get('GOOGLE_CLIENT_ID') and app.config.get('GOOGLE_CLIENT_SECRET'):
oauth.register(
name='google',
client_id=app.config.get('GOOGLE_CLIENT_ID'),
client_secret=app.config.get('GOOGLE_CLIENT_SECRET'),
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile'
}
)
app.logger.info("Google OAuth client registered")
else:
app.logger.warning("Google OAuth client not registered - missing client ID or secret")
# Register Authentik OAuth client
if app.config.get('AUTHENTIK_CLIENT_ID') and app.config.get('AUTHENTIK_CLIENT_SECRET'):
oauth.register(
name='authentik',
client_id=app.config.get('AUTHENTIK_CLIENT_ID'),
client_secret=app.config.get('AUTHENTIK_CLIENT_SECRET'),
server_metadata_url=app.config.get('AUTHENTIK_METADATA_URL'),
client_kwargs={
'scope': 'openid email profile'
}
)
app.logger.info("Authentik OAuth client registered")
else:
app.logger.warning("Authentik OAuth client not registered - missing client ID or secret")
# Register Dropbox OAuth client
if app.config.get('DROPBOX_APP_KEY') and app.config.get('DROPBOX_APP_SECRET'):
oauth.register(
name='dropbox',
client_id=app.config.get('DROPBOX_APP_KEY'),
client_secret=app.config.get('DROPBOX_APP_SECRET'),
authorize_url='https://www.dropbox.com/oauth2/authorize',
authorize_params=None,
access_token_url='https://api.dropboxapi.com/oauth2/token',
access_token_params=None,
refresh_token_url='https://api.dropboxapi.com/oauth2/token',
client_kwargs={
'scope': 'files.content.write account_info.read'
}
)
app.logger.info("Dropbox OAuth client registered")
else:
app.logger.warning("Dropbox OAuth client not registered - missing app key or secret")
return oauth
def get_google_user_info(token):
"""
Get Google user info from the token
"""
try:
resp = oauth.google.get('https://www.googleapis.com/oauth2/v3/userinfo')
profile = resp.json()
# Create a standardized user info dictionary
user_info = {
'id': profile.get('sub'), # Google uses 'sub' as the unique identifier
'email': profile.get('email'),
'name': profile.get('name'),
'given_name': profile.get('given_name'),
'family_name': profile.get('family_name'),
'picture': profile.get('picture')
}
# Add 'sub' field explicitly for backwards compatibility
if profile.get('sub'):
user_info['sub'] = profile.get('sub')
return user_info
except Exception as e:
current_app.logger.error(f"Error getting Google user info: {str(e)}")
return None
def get_authentik_user_info(token):
"""
Get Authentik user info from the token
"""
try:
resp = oauth.authentik.get('userinfo')
profile = resp.json()
return {
'id': profile.get('sub'),
'email': profile.get('email'),
'name': profile.get('name'),
'given_name': profile.get('given_name', ''),
'family_name': profile.get('family_name', ''),
'picture': profile.get('picture', '')
}
except Exception as e:
current_app.logger.error(f"Error getting Authentik user info: {str(e)}")
return None
def get_dropbox_user_info(token):
"""
Get Dropbox user info from the token
"""
try:
# Add debug logging for token
current_app.logger.debug(f"Retrieving Dropbox user info with token: {token}")
# Make sure we have an access token
access_token = token.get("access_token")
if not access_token:
# Try direct token string if token is not a dict
if isinstance(token, str):
access_token = token
else:
current_app.logger.error("No access token found in token object")
return None
# Set proper headers for Dropbox API - no Content-Type for null body
headers = {
'Authorization': f'Bearer {access_token}'
}
# The Dropbox API for get_current_account actually expects a null body with no Content-Type header
response = requests.post(
'https://api.dropboxapi.com/2/users/get_current_account',
headers=headers,
data=None # Send null body
)
# Check for successful response
if response.status_code != 200:
current_app.logger.error(f"Dropbox API error: {response.status_code} - {response.text}")
return None
# Parse response
profile = response.json()
current_app.logger.debug(f"Dropbox user info response: {profile}")
# Create a standardized user info dictionary
user_info = {
'id': profile.get('account_id', ''),
'email': profile.get('email', ''),
'name': profile.get('name', {}).get('display_name', ''),
'given_name': profile.get('name', {}).get('given_name', ''),
'family_name': profile.get('name', {}).get('surname', ''),
'picture': profile.get('profile_photo_url', '')
}
return user_info
except Exception as e:
current_app.logger.error(f"Error getting Dropbox user info: {str(e)}")
return None
def find_or_create_user(user_info, auth_provider):
"""
Find existing user or create a new one based on OAuth user info
"""
if not user_info:
return None
# First try to find user by provider-specific ID
if auth_provider == 'google':
user = User.query.filter_by(google_id=user_info['id']).first()
elif auth_provider == 'authentik':
user = User.query.filter_by(authentik_id=user_info['id']).first()
elif auth_provider == 'dropbox':
user = User.query.filter_by(dropbox_id=user_info['id']).first()
else:
return None
# If not found by provider ID, try email
if user is None and user_info.get('email'):
user = User.query.filter_by(email=user_info['email']).first()
# If user exists but doesn't have provider ID, update it
if user:
if auth_provider == 'google':
user.google_id = user_info['id']
elif auth_provider == 'authentik':
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
db.session.commit()
current_app.logger.info(f"Updated existing user {user.username} with {auth_provider} ID")
# If user still not found, check if new signups are allowed before creating
if user is None:
# Check system setting if new signups are allowed
from musicround.models import SystemSetting
allow_signups = SystemSetting.get('allow_signups', 'true') == 'true'
if not allow_signups:
current_app.logger.warning(f"OAuth signup attempted for {auth_provider} but new signups are disabled")
return None
# Generate a username from email
email = user_info.get('email', '')
base_username = email.split('@')[0] if email else f"{auth_provider}_{user_info['id']}"
# Ensure username is unique
username = base_username
counter = 1
while User.query.filter_by(username=username).first():
username = f"{base_username}_{counter}"
counter += 1
# Create new user
user = User(
username=username,
email=user_info.get('email', ''),
first_name=user_info.get('given_name', ''),
last_name=user_info.get('family_name', ''),
auth_provider=auth_provider,
created_at=datetime.now(),
last_login=datetime.now()
)
# Set provider-specific fields
if auth_provider == 'google':
user.google_id = user_info['id']
elif auth_provider == 'authentik':
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
db.session.add(user)
try:
db.session.commit()
current_app.logger.info(f"Created new user {username} with {auth_provider} auth")
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating user: {str(e)}")
return None
return user
def update_oauth_tokens(user, tokens, auth_provider):
"""
Update user's OAuth tokens
"""
if auth_provider == 'google':
user.google_token = tokens.get('access_token')
user.google_refresh_token = tokens.get('refresh_token')
elif auth_provider == 'authentik':
user.authentik_token = tokens.get('access_token')
user.authentik_refresh_token = tokens.get('refresh_token')
elif auth_provider == 'dropbox':
user.dropbox_token = tokens.get('access_token')
user.dropbox_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.dropbox_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
user.last_login = datetime.now()
try:
db.session.commit()
return True
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error updating {auth_provider} tokens: {str(e)}")
return False
+724
View File
@@ -0,0 +1,724 @@
"""
Backup helper functions for creating, managing, and restoring backups.
"""
import os
import shutil
import json
import logging
import sqlite3
import zipfile
from datetime import datetime
import tempfile
from flask import current_app
# Set up logging
logger = logging.getLogger(__name__)
def create_backup(backup_name=None, include_mp3s=True, include_config=True):
"""
Create a full system backup including database, MP3s, and configuration.
Args:
backup_name: Optional name for the backup (defaults to timestamp)
include_mp3s: Whether to include MP3 files in the backup
include_config: Whether to include configuration files
Returns:
dict: Backup information including path and status
"""
try:
# Generate backup name if not provided
if not backup_name:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_name = f"backup_{timestamp}"
# Ensure backup directory exists
backup_dir = os.path.join('/data', 'backups')
os.makedirs(backup_dir, exist_ok=True)
# Create backup zip file path
backup_path = os.path.join(backup_dir, f"{backup_name}.zip")
# Create a temporary directory for collecting files
with tempfile.TemporaryDirectory() as temp_dir:
# Step 1: Backup the database
db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
if os.path.exists(db_path):
# Create a copy of the database (to avoid locking issues)
temp_db = os.path.join(temp_dir, 'song_data.db')
# Connect to source database and back it up
conn = sqlite3.connect(db_path)
backup_conn = sqlite3.connect(temp_db)
conn.backup(backup_conn)
conn.close()
backup_conn.close()
logger.info(f"Database backed up to {temp_db}")
else:
logger.error(f"Database not found at {db_path}")
return {
"status": "error",
"message": f"Database not found at {db_path}",
"path": None
}
# Step 2: Copy MP3 files if requested
if include_mp3s:
mp3_dir = os.path.join(os.path.dirname(current_app.root_path), 'mp3')
if os.path.exists(mp3_dir):
mp3_backup_dir = os.path.join(temp_dir, 'mp3')
os.makedirs(mp3_backup_dir, exist_ok=True)
# Copy all MP3 files
for mp3_file in os.listdir(mp3_dir):
if mp3_file.endswith('.mp3'):
source_path = os.path.join(mp3_dir, mp3_file)
dest_path = os.path.join(mp3_backup_dir, mp3_file)
shutil.copy2(source_path, dest_path)
logger.info(f"MP3 files backed up to {mp3_backup_dir}")
else:
logger.warning(f"MP3 directory not found at {mp3_dir}")
# Step 3: Add configuration files if requested
if include_config:
config_dir = os.path.join(temp_dir, 'config')
os.makedirs(config_dir, exist_ok=True)
# Copy .env file if it exists
env_path = os.path.join(os.path.dirname(current_app.root_path), '.env')
if os.path.exists(env_path):
shutil.copy2(env_path, os.path.join(config_dir, '.env'))
logger.info(f".env file backed up")
# Extract system settings from database and save as JSON
try:
from musicround.models import SystemSetting
settings = SystemSetting.all_settings()
# Save settings to JSON file
settings_path = os.path.join(config_dir, 'system_settings.json')
with open(settings_path, 'w') as f:
json.dump(settings, f, indent=2)
logger.info(f"System settings backed up to {settings_path}")
except Exception as e:
logger.error(f"Error backing up system settings: {str(e)}")
# Step 4: Add backup metadata file with version info and timestamp
from musicround.version import VERSION_INFO
metadata = {
"backup_name": backup_name,
"timestamp": datetime.now().isoformat(),
"version": VERSION_INFO['version'],
"release_name": VERSION_INFO['release_name'],
"includes_mp3s": include_mp3s,
"includes_config": include_config
}
metadata_path = os.path.join(temp_dir, 'backup_metadata.json')
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Step 5: Create a ZIP archive of all backed up content
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Add all files from temp directory to ZIP
for root, _, files in os.walk(temp_dir):
for file in files:
file_path = os.path.join(root, file)
# Add file to ZIP with a relative path
arcname = os.path.relpath(file_path, temp_dir)
zipf.write(file_path, arcname)
# Get backup file size
backup_size = os.path.getsize(backup_path)
return {
"status": "success",
"message": "Backup created successfully",
"path": backup_path,
"name": backup_name,
"size": backup_size,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"Error during backup creation: {str(e)}")
return {
"status": "error",
"message": f"Backup failed: {str(e)}",
"path": None
}
def list_backups():
"""
List all available backups with their metadata.
Returns:
list: List of backup information dictionaries
"""
backup_dir = os.path.join('/data', 'backups')
if not os.path.exists(backup_dir):
os.makedirs(backup_dir, exist_ok=True)
return []
backups = []
for filename in os.listdir(backup_dir):
if filename.endswith('.zip'):
backup_path = os.path.join(backup_dir, filename)
try:
# Extract metadata from ZIP file
with zipfile.ZipFile(backup_path, 'r') as zipf:
if 'backup_metadata.json' in zipf.namelist():
with zipf.open('backup_metadata.json') as f:
metadata = json.load(f)
# Add file information to metadata
file_info = os.stat(backup_path)
metadata['file_size'] = file_info.st_size
metadata['file_name'] = filename
metadata['file_path'] = backup_path
metadata['file_date'] = datetime.fromtimestamp(file_info.st_mtime).isoformat()
backups.append(metadata)
else:
# No metadata file, create basic info
file_info = os.stat(backup_path)
backups.append({
'backup_name': os.path.splitext(filename)[0],
'file_name': filename,
'file_path': backup_path,
'file_size': file_info.st_size,
'file_date': datetime.fromtimestamp(file_info.st_mtime).isoformat(),
'timestamp': datetime.fromtimestamp(file_info.st_mtime).isoformat(),
'version': 'Unknown',
'release_name': 'Unknown'
})
except Exception as e:
logger.error(f"Error reading backup metadata from {filename}: {str(e)}")
# Sort backups by timestamp (newest first)
backups.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
return backups
def delete_backup(backup_filename):
"""
Delete a backup file.
Args:
backup_filename: Name of the backup file to delete
Returns:
dict: Operation status information
"""
backup_dir = os.path.join('/data', 'backups')
backup_path = os.path.join(backup_dir, backup_filename)
if not os.path.exists(backup_path):
return {
"status": "error",
"message": f"Backup file {backup_filename} not found"
}
try:
os.remove(backup_path)
return {
"status": "success",
"message": f"Backup {backup_filename} deleted successfully"
}
except Exception as e:
logger.error(f"Error deleting backup {backup_filename}: {str(e)}")
return {
"status": "error",
"message": f"Error deleting backup: {str(e)}"
}
def restore_backup(backup_filename):
"""
Restore system from a backup file.
Args:
backup_filename: Name of the backup file to restore
Returns:
dict: Operation status information
"""
backup_dir = os.path.join('/data', 'backups')
backup_path = os.path.join(backup_dir, backup_filename)
if not os.path.exists(backup_path):
return {
"status": "error",
"message": f"Backup file {backup_filename} not found"
}
try:
# Create a temporary directory for extracting backup
with tempfile.TemporaryDirectory() as temp_dir:
# Extract the backup ZIP
with zipfile.ZipFile(backup_path, 'r') as zipf:
zipf.extractall(temp_dir)
# Get backup metadata
metadata_path = os.path.join(temp_dir, 'backup_metadata.json')
if os.path.exists(metadata_path):
with open(metadata_path, 'r') as f:
metadata = json.load(f)
else:
metadata = {
"includes_mp3s": True,
"includes_config": True
}
# Restore database
db_backup_path = os.path.join(temp_dir, 'song_data.db')
db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
if os.path.exists(db_backup_path):
# Create a backup of the current database before overwriting
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
db_current_backup = f"{db_path}.{timestamp}.bak"
if os.path.exists(db_path):
shutil.copy2(db_path, db_current_backup)
logger.info(f"Created backup of current database at {db_current_backup}")
# Restore the database from backup
shutil.copy2(db_backup_path, db_path)
logger.info(f"Restored database from backup")
else:
logger.error("Database file not found in backup")
return {
"status": "error",
"message": "Database file not found in backup"
}
# Restore MP3 files if included in backup
if metadata.get("includes_mp3s", True):
mp3_backup_dir = os.path.join(temp_dir, 'mp3')
if os.path.exists(mp3_backup_dir):
mp3_dir = os.path.join(os.path.dirname(current_app.root_path), 'mp3')
# Create backup of current MP3 files
if os.path.exists(mp3_dir):
mp3_backup = f"{mp3_dir}.{timestamp}.bak"
shutil.copytree(mp3_dir, mp3_backup)
logger.info(f"Created backup of current MP3 files at {mp3_backup}")
# Remove current MP3 directory and replace with backup
if os.path.exists(mp3_dir):
shutil.rmtree(mp3_dir)
# Create MP3 directory if it doesn't exist
os.makedirs(mp3_dir, exist_ok=True)
# Copy MP3 files from backup
for mp3_file in os.listdir(mp3_backup_dir):
if mp3_file.endswith('.mp3'):
source_path = os.path.join(mp3_backup_dir, mp3_file)
dest_path = os.path.join(mp3_dir, mp3_file)
shutil.copy2(source_path, dest_path)
logger.info(f"Restored MP3 files from backup")
# Restore config files if included in backup
if metadata.get("includes_config", True):
config_backup_dir = os.path.join(temp_dir, 'config')
if os.path.exists(config_backup_dir):
# Restore .env file if present in backup
env_backup_path = os.path.join(config_backup_dir, '.env')
if os.path.exists(env_backup_path):
env_path = os.path.join(os.path.dirname(current_app.root_path), '.env')
# Backup current .env
if os.path.exists(env_path):
env_backup = f"{env_path}.{timestamp}.bak"
shutil.copy2(env_path, env_backup)
logger.info(f"Created backup of current .env file at {env_backup}")
# Restore .env from backup
shutil.copy2(env_backup_path, env_path)
logger.info(f"Restored .env file from backup")
# Restore system settings from JSON if present
settings_backup_path = os.path.join(config_backup_dir, 'system_settings.json')
if os.path.exists(settings_backup_path):
try:
with open(settings_backup_path, 'r') as f:
settings = json.load(f)
# Import within function to avoid circular imports
from musicround.models import SystemSetting, db
# Restore each setting
for key, value in settings.items():
SystemSetting.set(key, value)
logger.info("Restored system settings from backup")
except Exception as e:
logger.error(f"Error restoring system settings: {str(e)}")
return {
"status": "success",
"message": "Backup restored successfully",
"backup_name": backup_filename
}
except Exception as e:
logger.error(f"Error restoring backup {backup_filename}: {str(e)}")
return {
"status": "error",
"message": f"Error restoring backup: {str(e)}"
}
def verify_backup(backup_filename):
"""
Verify the integrity of a backup file.
Args:
backup_filename: Name of the backup file to verify
Returns:
dict: Verification result
"""
backup_dir = os.path.join('/data', 'backups')
backup_path = os.path.join(backup_dir, backup_filename)
if not os.path.exists(backup_path):
return {
"status": "error",
"message": f"Backup file {backup_filename} not found",
"is_valid": False
}
try:
# Check if the file is a valid ZIP
if not zipfile.is_zipfile(backup_path):
return {
"status": "error",
"message": f"Backup file is not a valid ZIP archive",
"is_valid": False
}
# Try to open the ZIP and extract metadata
with zipfile.ZipFile(backup_path, 'r') as zipf:
# Test the integrity of all files in the ZIP
test_result = zipf.testzip()
if test_result is not None:
return {
"status": "error",
"message": f"Backup file contains corrupted files, first bad file: {test_result}",
"is_valid": False
}
# Check for essential files
if 'song_data.db' not in zipf.namelist():
return {
"status": "error",
"message": "Backup file does not contain a database",
"is_valid": False
}
# Extract metadata if available
if 'backup_metadata.json' in zipf.namelist():
with zipf.open('backup_metadata.json') as f:
metadata = json.load(f)
else:
metadata = {"version": "Unknown"}
# If we got here, the backup is valid
return {
"status": "success",
"message": "Backup file is valid",
"is_valid": True,
"version": metadata.get("version", "Unknown"),
"timestamp": metadata.get("timestamp", "Unknown")
}
except Exception as e:
logger.error(f"Error verifying backup {backup_filename}: {str(e)}")
return {
"status": "error",
"message": f"Error verifying backup: {str(e)}",
"is_valid": False
}
def schedule_backup(schedule_time=None, frequency='daily', retention_days=30):
"""
Schedule automatic backups.
Args:
schedule_time: Time to run the backup (HH:MM format)
frequency: Frequency of backups ('hourly', 'daily', 'weekly')
retention_days: Number of days of backups to keep (0 = keep all)
Returns:
dict: Operation status information
"""
# This would typically integrate with a scheduler like cron
# For now, we'll just store the settings in SystemSetting
try:
from musicround.models import SystemSetting
# Get current time if not provided
if schedule_time is None:
schedule_time = datetime.now().strftime('%H:%M')
# Store backup schedule settings
SystemSetting.set('backup_schedule_time', schedule_time)
SystemSetting.set('backup_schedule_frequency', frequency)
SystemSetting.set('backup_schedule_enabled', 'true')
SystemSetting.set('backup_retention_days', str(retention_days))
# If retention policy is set, apply it immediately
if retention_days > 0:
apply_retention_policy(retention_days)
return {
"status": "success",
"message": f"Backup scheduled for {schedule_time} ({frequency}), keeping {retention_days} days of backups",
"schedule_time": schedule_time,
"frequency": frequency,
"retention_days": retention_days
}
except Exception as e:
logger.error(f"Error scheduling backup: {str(e)}")
return {
"status": "error",
"message": f"Error scheduling backup: {str(e)}"
}
def get_backup_summary():
"""
Get a summary of backup system status.
Returns:
dict: Summary information including counts, schedule info, etc.
"""
from musicround.models import SystemSetting
# Get all backups
backups = list_backups()
# Extract info from settings
schedule_enabled = SystemSetting.get('backup_schedule_enabled', 'false') == 'true'
schedule_time = SystemSetting.get('backup_schedule_time', '03:00')
schedule_frequency = SystemSetting.get('backup_schedule_frequency', 'daily')
retention_days = int(SystemSetting.get('backup_retention_days', '30'))
# Calculate next backup time based on schedule
from datetime import datetime, time, timedelta
now = datetime.now()
next_backup = None
if schedule_enabled:
try:
# Parse schedule time
hour, minute = map(int, schedule_time.split(':'))
schedule_time_obj = time(hour, minute)
# Calculate next occurrence
next_backup_date = now.date()
next_backup_datetime = datetime.combine(next_backup_date, schedule_time_obj)
# If today's scheduled time has passed, move to next occurrence based on frequency
if next_backup_datetime < now:
if schedule_frequency == 'hourly':
next_backup_datetime = now + timedelta(hours=1)
elif schedule_frequency == 'daily':
next_backup_datetime = datetime.combine(next_backup_date + timedelta(days=1), schedule_time_obj)
elif schedule_frequency == 'weekly':
next_backup_datetime = datetime.combine(next_backup_date + timedelta(days=7), schedule_time_obj)
next_backup = next_backup_datetime.strftime('%Y-%m-%d %H:%M')
except:
next_backup = "Error calculating next backup time"
# Get latest backup info
latest_backup = backups[0] if backups else None
return {
"backup_count": len(backups),
"latest_backup": latest_backup,
"schedule_enabled": schedule_enabled,
"schedule_time": schedule_time,
"schedule_frequency": schedule_frequency,
"next_backup": next_backup,
"backup_location": "/data/backups",
"retention_days": retention_days
}
def generate_backup_config_suggestion(retention_days=30):
"""
Generate a configuration suggestion for setting up automated backups.
This does NOT modify any files, it only returns a suggestion.
Args:
retention_days: Number of days to keep backups
Returns:
dict: Configuration suggestion and instructions
"""
# Generate the backup schedule configuration suggestion
from musicround.models import SystemSetting
# Get backup schedule information
schedule_time = SystemSetting.get('backup_schedule_time', '03:00')
schedule_frequency = SystemSetting.get('backup_schedule_frequency', 'daily')
# Map schedule frequency to cron expressions for documentation
frequency_map = {
'hourly': '@hourly',
'daily': '@daily',
'weekly': '@weekly'
}
schedule_cron = frequency_map.get(schedule_frequency, '@daily')
# Generate docker-compose config example
docker_compose_suggestion = f"""labels:
ofelia.enabled: "true"
ofelia.job-exec.backup.schedule: "{schedule_cron}"
ofelia.job-exec.backup.command: "python /app/run.py backup create --auto"
ofelia.job-exec.backup.no-overlap: "true"
# Retention policy - automatically delete backups older than {retention_days} days
ofelia.job-exec.retention.schedule: "@weekly"
ofelia.job-exec.retention.command: "python /app/run.py backup retention --days {retention_days}"
ofelia.job-exec.retention.no-overlap: "true"
"""
# Generate ofelia.ini config example (for standalone setups)
ofelia_ini_suggestion = f"""[global]
save-folder = /var/log/ofelia
[job-exec "backup"]
schedule = {schedule_cron}
command = python /app/run.py backup create --auto
user = root
no-overlap = true
[job-exec "retention"]
schedule = @weekly
command = python /app/run.py backup retention --days {retention_days}
user = root
no-overlap = true
"""
# Generate instructions for manual setup
instructions = f"""To set up automated backups, add the configuration to your Docker Compose file OR use the ofelia.ini file.
Option 1: Add these labels to your main service in docker-compose.yml:
{docker_compose_suggestion}
Option 2: Add these sections to ofelia.ini:
{ofelia_ini_suggestion}
After making changes, restart your containers to apply the configuration:
docker-compose down
docker-compose up -d
"""
return {
"status": "success",
"schedule": {
"frequency": schedule_frequency,
"time": schedule_time,
"retention_days": retention_days
},
"docker_compose_suggestion": docker_compose_suggestion,
"ofelia_ini_suggestion": ofelia_ini_suggestion,
"instructions": instructions,
"message": "Generated backup configuration suggestion"
}
def apply_retention_policy(retention_days=30):
"""
Apply the backup retention policy by deleting old backups.
Args:
retention_days: Number of days of backups to keep (0 = keep all)
Returns:
dict: Operation status information
"""
if retention_days <= 0:
return {
"status": "success",
"message": "Retention policy disabled, all backups kept",
"deleted_count": 0,
"deleted_backups": []
}
try:
from datetime import datetime, timedelta
import os
# Get the cutoff date
cutoff_date = datetime.now() - timedelta(days=retention_days)
# Get list of all backups
backups = list_backups()
# Filter to find backups older than the cutoff date
deleted_backups = []
for backup in backups:
# Get backup timestamp
backup_time = None
if 'timestamp' in backup:
try:
backup_time = datetime.fromisoformat(backup['timestamp'])
except (ValueError, TypeError):
# Try the file date as fallback
if 'file_date' in backup:
try:
backup_time = datetime.fromisoformat(backup['file_date'])
except (ValueError, TypeError):
# Can't determine date, skip this backup
continue
elif 'file_date' in backup:
try:
backup_time = datetime.fromisoformat(backup['file_date'])
except (ValueError, TypeError):
# Can't determine date, skip this backup
continue
# If we couldn't determine when this backup was created, skip it
if not backup_time:
continue
# Check if this backup is older than the cutoff date
if backup_time < cutoff_date:
backup_path = backup.get('file_path')
if backup_path and os.path.exists(backup_path):
try:
os.remove(backup_path)
deleted_backups.append({
'name': backup.get('backup_name') or os.path.basename(backup_path),
'date': backup_time.isoformat()
})
except Exception as e:
logger.error(f"Error deleting old backup {backup_path}: {str(e)}")
return {
"status": "success",
"message": f"Retention policy applied: deleted {len(deleted_backups)} backups older than {retention_days} days",
"deleted_count": len(deleted_backups),
"deleted_backups": deleted_backups
}
except Exception as e:
logger.error(f"Error applying retention policy: {str(e)}")
return {
"status": "error",
"message": f"Error applying retention policy: {str(e)}",
"deleted_count": 0,
"deleted_backups": []
}
+495
View File
@@ -0,0 +1,495 @@
"""
Helpers for Dropbox API integration
"""
from flask import current_app, url_for, redirect, session
import requests
import json
import os
from datetime import datetime, timedelta
from flask_login import current_user
def get_dropbox_auth_url():
"""Get the authorization URL for Dropbox OAuth flow"""
app_key = current_app.config.get('DROPBOX_APP_KEY')
redirect_uri = url_for('users.dropbox_callback', _external=True)
# Add the required scopes for our application
scopes = ["files.content.read", "files.content.write", "sharing.write","account_info.read"]
auth_url = f'https://www.dropbox.com/oauth2/authorize?client_id={app_key}&response_type=code&redirect_uri={redirect_uri}&scope={" ".join(scopes)}&token_access_type=offline'
return auth_url
def exchange_code_for_token(code):
"""Exchange the authorization code for an access token"""
app_key = current_app.config.get('DROPBOX_APP_KEY')
app_secret = current_app.config.get('DROPBOX_APP_SECRET')
redirect_uri = url_for('users.dropbox_callback', _external=True)
data = {
'code': code,
'grant_type': 'authorization_code',
'client_id': app_key,
'client_secret': app_secret,
'redirect_uri': redirect_uri
}
response = requests.post('https://api.dropboxapi.com/oauth2/token', data=data)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error exchanging code for token: {response.text}")
return None
def refresh_dropbox_token(refresh_token):
"""Refresh an expired Dropbox access token"""
app_key = current_app.config.get('DROPBOX_APP_KEY')
app_secret = current_app.config.get('DROPBOX_APP_SECRET')
data = {
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
'client_id': app_key,
'client_secret': app_secret
}
response = requests.post('https://api.dropboxapi.com/oauth2/token', data=data)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error refreshing token: {response.text}")
return None
def get_dropbox_user_info(access_token):
"""Get user info from Dropbox API"""
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
response = requests.post('https://api.dropboxapi.com/2/users/get_current_account', headers=headers)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error getting user info: {response.text}")
return None
def get_current_user_dropbox_token():
"""Get a valid Dropbox access token for the current user, refreshing if needed"""
if not current_user or not current_user.is_authenticated:
current_app.logger.error("No authenticated user")
return None
# Check if token exists and is valid
if (current_user.dropbox_token and
current_user.dropbox_token_expiry and
current_user.dropbox_token_expiry > datetime.now() + timedelta(minutes=5)):
# Token is valid and not about to expire
return current_user.dropbox_token
# Token is missing or about to expire - try to refresh
if current_user.dropbox_refresh_token:
from musicround.models import db
# Try to refresh the token
token_info = refresh_dropbox_token(current_user.dropbox_refresh_token)
if token_info and 'access_token' in token_info:
# Update token in database
current_user.dropbox_token = token_info['access_token']
expires_in = token_info.get('expires_in', 14400) # Default to 4 hours if not specified
current_user.dropbox_token_expiry = datetime.now() + timedelta(seconds=expires_in)
db.session.commit()
return current_user.dropbox_token
# If we get here, we couldn't refresh the token
current_app.logger.error("Failed to get valid Dropbox token")
return None
def upload_and_share(file_path, dropbox_path):
"""
Upload a file to Dropbox and create a shared link
Args:
file_path: Local path to the file to upload
dropbox_path: Destination path in Dropbox (including filename)
Returns:
Shared link URL or None if upload failed
"""
token = get_current_user_dropbox_token()
if not token:
current_app.logger.error("No valid Dropbox token available")
return None
# Make sure dropbox_path starts with /
if not dropbox_path.startswith('/'):
dropbox_path = '/' + dropbox_path
# First, upload the file
try:
# Check if file exists
if not os.path.exists(file_path):
current_app.logger.error(f"File not found: {file_path}")
return None
# Get file size
file_size = os.path.getsize(file_path)
# For small files (< 150 MB), use simple upload
if file_size < 150 * 1024 * 1024:
with open(file_path, 'rb') as f:
file_data = f.read()
headers = {
'Authorization': f'Bearer {token}',
'Dropbox-API-Arg': json.dumps({
'path': dropbox_path,
'mode': 'overwrite',
'autorename': True,
'mute': False
}),
'Content-Type': 'application/octet-stream'
}
response = requests.post(
'https://content.dropboxapi.com/2/files/upload',
headers=headers,
data=file_data
)
if response.status_code != 200:
current_app.logger.error(f"Error uploading file: {response.text}")
return None
file_metadata = response.json()
current_app.logger.info(f"File uploaded successfully: {file_metadata.get('path_display')}")
else:
# For larger files, we'd implement chunked upload here
current_app.logger.error(f"File too large for simple upload: {file_size} bytes")
return None
# Now create a shared link
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
data = {
'path': file_metadata.get('path_lower', dropbox_path),
'settings': {
'requested_visibility': 'public' # Make link publicly accessible
}
}
response = requests.post(
'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings',
headers=headers,
json=data
)
# If the link already exists, we'll get a 409 error with "shared_link_already_exists"
if response.status_code == 409 and "shared_link_already_exists" in response.text:
# Get existing links
list_data = {
'path': file_metadata.get('path_lower', dropbox_path)
}
list_response = requests.post(
'https://api.dropboxapi.com/2/sharing/list_shared_links',
headers=headers,
json=list_data
)
if list_response.status_code == 200:
links_data = list_response.json()
if links_data.get('links') and len(links_data['links']) > 0:
# Return the first link's URL
return links_data['links'][0].get('url')
elif response.status_code == 200:
share_data = response.json()
current_app.logger.info(f"Created shared link: {share_data.get('url')}")
return share_data.get('url')
current_app.logger.error(f"Error creating shared link: {response.text}")
return None
except Exception as e:
current_app.logger.error(f"Exception in upload_and_share: {str(e)}")
return None
def refresh_dropbox_token_if_needed(user):
"""
Check if user's Dropbox token needs refreshing and refresh it if needed
Args:
user: The User object with Dropbox token information
Returns:
dict: {'success': True/False, 'message': 'success or error message'}
"""
if not user.dropbox_token or not user.dropbox_refresh_token:
return {'success': False, 'message': 'No Dropbox token available'}
# If token is still valid, return success
if user.dropbox_token_expiry and user.dropbox_token_expiry > datetime.now() + timedelta(minutes=5):
return {'success': True, 'message': 'Token is still valid'}
# Token needs refreshing
from musicround.models import db
try:
token_info = refresh_dropbox_token(user.dropbox_refresh_token)
if token_info and 'access_token' in token_info:
# Update token in database
user.dropbox_token = token_info['access_token']
expires_in = token_info.get('expires_in', 14400) # Default to 4 hours if not specified
user.dropbox_token_expiry = datetime.now() + timedelta(seconds=expires_in)
# If we got a new refresh token, update that too
if token_info.get('refresh_token'):
user.dropbox_refresh_token = token_info['refresh_token']
db.session.commit()
return {'success': True, 'message': 'Token refreshed successfully'}
else:
return {'success': False, 'message': 'Failed to refresh token'}
except Exception as e:
current_app.logger.error(f"Error refreshing Dropbox token: {str(e)}")
return {'success': False, 'message': f'Error refreshing token: {str(e)}'}
def upload_to_dropbox(access_token, dropbox_path, data, mode='binary'):
"""
Upload data to Dropbox
Args:
access_token: Dropbox access token
dropbox_path: Destination path in Dropbox (including filename)
data: The data to upload (bytes for binary mode, string for text mode)
mode: 'binary' or 'text'
Returns:
dict: {'success': True/False, 'message': 'success or error message', 'metadata': file metadata if successful}
"""
# Make sure dropbox_path starts with /
if not dropbox_path.startswith('/'):
dropbox_path = '/' + dropbox_path
try:
# Convert string data to bytes if text mode
if mode == 'text' and isinstance(data, str):
data = data.encode('utf-8')
# Debug token information
token_preview = access_token[:10] + '...' if access_token else 'None'
current_app.logger.debug(f"Upload to Dropbox - Path: {dropbox_path}, Token preview: {token_preview}, Data size: {len(data) if data else 0} bytes")
headers = {
'Authorization': f'Bearer {access_token}',
'Dropbox-API-Arg': json.dumps({
'path': dropbox_path,
'mode': 'overwrite',
'autorename': True,
'mute': False
}),
'Content-Type': 'application/octet-stream'
}
current_app.logger.debug(f"Dropbox API headers: {headers}")
response = requests.post(
'https://content.dropboxapi.com/2/files/upload',
headers=headers,
data=data
)
current_app.logger.debug(f"Dropbox upload response code: {response.status_code}")
if response.status_code != 200:
current_app.logger.error(f"Error uploading to Dropbox: Status code {response.status_code}")
current_app.logger.error(f"Response headers: {response.headers}")
# Try to parse response body
try:
error_json = response.json()
current_app.logger.error(f"Error details: {json.dumps(error_json, indent=2)}")
error_message = error_json.get('error_summary', 'Unknown error')
except:
error_message = response.text[:500] # Limit to first 500 chars
current_app.logger.error(f"Raw error response: {error_message}")
return {
'success': False,
'message': f"Error uploading file: {response.status_code} - {error_message}",
'status_code': response.status_code
}
file_metadata = response.json()
current_app.logger.info(f"File uploaded successfully: {file_metadata.get('path_display')}")
current_app.logger.debug(f"Upload metadata: {json.dumps(file_metadata, indent=2)}")
return {
'success': True,
'message': 'File uploaded successfully',
'metadata': file_metadata
}
except Exception as e:
import traceback
current_app.logger.error(f"Exception in upload_to_dropbox: {str(e)}")
current_app.logger.error(traceback.format_exc())
return {
'success': False,
'message': f"Error uploading file: {str(e)}"
}
def create_shared_link(access_token, dropbox_path):
"""
Create a shared link for a file in Dropbox
Args:
access_token: Dropbox access token
dropbox_path: Path to the file in Dropbox
Returns:
dict: {'success': True/False, 'message': 'success or error message', 'url': shared link URL if successful}
"""
# Make sure dropbox_path starts with /
if not dropbox_path.startswith('/'):
dropbox_path = '/' + dropbox_path
try:
# Debug token information
token_preview = access_token[:10] + '...' if access_token else 'None'
current_app.logger.debug(f"Creating shared link - Path: {dropbox_path}, Token preview: {token_preview}")
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
data = {
'path': dropbox_path,
'settings': {
'requested_visibility': 'public' # Make link publicly accessible
}
}
current_app.logger.debug(f"Sharing API request data: {json.dumps(data, indent=2)}")
response = requests.post(
'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings',
headers=headers,
json=data
)
current_app.logger.debug(f"Sharing API response code: {response.status_code}")
# If the link already exists, we'll get a 409 error with "shared_link_already_exists"
if response.status_code == 409 and "shared_link_already_exists" in response.text:
current_app.logger.debug("Shared link already exists, retrieving existing link")
# Get existing links
list_data = {
'path': dropbox_path
}
list_response = requests.post(
'https://api.dropboxapi.com/2/sharing/list_shared_links',
headers=headers,
json=list_data
)
current_app.logger.debug(f"List shared links response code: {list_response.status_code}")
if list_response.status_code == 200:
links_data = list_response.json()
current_app.logger.debug(f"Existing links data: {json.dumps(links_data, indent=2)}")
if links_data.get('links') and len(links_data['links']) > 0:
# Return the first link's URL
url = links_data['links'][0].get('url')
current_app.logger.info(f"Retrieved existing shared link: {url}")
return {
'success': True,
'message': 'Existing shared link retrieved',
'url': url
}
else:
current_app.logger.error("No links found despite 'shared_link_already_exists' error")
else:
current_app.logger.error(f"Error listing shared links: {list_response.text}")
elif response.status_code == 200:
share_data = response.json()
current_app.logger.info(f"Created shared link: {share_data.get('url')}")
current_app.logger.debug(f"Shared link data: {json.dumps(share_data, indent=2)}")
return {
'success': True,
'message': 'Shared link created successfully',
'url': share_data.get('url')
}
# Try to parse error response
try:
error_json = response.json()
current_app.logger.error(f"Sharing API error details: {json.dumps(error_json, indent=2)}")
error_message = error_json.get('error_summary', 'Unknown error')
except:
error_message = response.text[:500] # Limit to first 500 chars
current_app.logger.error(f"Raw sharing API error response: {error_message}")
current_app.logger.error(f"Error creating shared link: {response.status_code} - {error_message}")
return {
'success': False,
'message': f"Error creating shared link: {response.status_code} - {error_message}",
'status_code': response.status_code
}
except Exception as e:
import traceback
current_app.logger.error(f"Exception in create_shared_link: {str(e)}")
current_app.logger.error(traceback.format_exc())
return {
'success': False,
'message': f"Error creating shared link: {str(e)}"
}
def get_dropbox_account_info(access_token):
"""
Get account information for a Dropbox user
Args:
access_token: Dropbox access token
Returns:
dict: Account information or None if failed
"""
headers = {
'Authorization': f'Bearer {access_token}'
}
try:
# According to the API documentation, this endpoint requires no request body
response = requests.post(
'https://api.dropboxapi.com/2/users/get_current_account',
headers=headers
)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error getting Dropbox account info: {response.text}")
return None
except Exception as e:
current_app.logger.error(f"Exception in get_dropbox_account_info: {str(e)}")
return None
+103
View File
@@ -0,0 +1,103 @@
"""
Email helper functions for Quizzical Beats
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from flask import current_app
def send_email(recipient, subject, body_text, attachments=None):
"""
Sends an email with optional attachments.
Args:
recipient (str): Email address of the recipient
subject (str): Email subject
body_text (str): Plain text email body
attachments (list): Optional list of attachment dictionaries with keys:
- 'data': The binary data of the attachment
- 'filename': Filename for the attachment
- 'mimetype': Mimetype string like 'application/pdf'
Returns:
tuple: (success, message) where success is a boolean and message contains
details about the result
"""
# Get mail configuration from environment variables
mail_host = current_app.config.get('MAIL_HOST')
mail_port = current_app.config.get('MAIL_PORT')
mail_username = current_app.config.get('MAIL_USERNAME')
mail_password = current_app.config.get('MAIL_PASSWORD')
mail_sender = current_app.config.get('MAIL_SENDER')
# Check if all email configuration parameters are available
missing_config = []
if not mail_host:
missing_config.append("MAIL_HOST")
if not mail_port:
missing_config.append("MAIL_PORT")
if not mail_username:
missing_config.append("MAIL_USERNAME")
if not mail_password:
missing_config.append("MAIL_PASSWORD")
if not mail_sender:
missing_config.append("MAIL_SENDER")
if missing_config:
missing_params = ", ".join(missing_config)
error_msg = f"Email server configuration is incomplete. Missing parameters: {missing_params}."
current_app.logger.error(f"Email configuration error: {error_msg}")
current_app.logger.error(f"Current config values - MAIL_HOST: {'set' if mail_host else 'missing'}, "
f"MAIL_PORT: {'set' if mail_port else 'missing'}, "
f"MAIL_USERNAME: {'set' if mail_username else 'missing'}, "
f"MAIL_PASSWORD: {'set' if mail_password else 'missing'}, "
f"MAIL_SENDER: {'set' if mail_sender else 'missing'}")
return False, error_msg
# Create message object
msg = MIMEMultipart()
msg['From'] = mail_sender
msg['To'] = recipient
msg['Subject'] = subject
# Attach text body
msg.attach(MIMEText(body_text, 'plain'))
# Attach files if provided
if attachments:
for attachment in attachments:
part = MIMEBase(
attachment.get('mimetype', 'application/octet-stream').split('/')[0],
attachment.get('mimetype', 'application/octet-stream').split('/')[1]
)
part.set_payload(attachment['data'])
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename={attachment["filename"]}'
)
msg.attach(part)
try:
current_app.logger.info(f"Attempting to send email to {recipient} via {mail_host}:{mail_port}")
with smtplib.SMTP(mail_host, mail_port) as server:
server.starttls()
current_app.logger.debug("STARTTLS established")
server.login(mail_username, mail_password)
current_app.logger.debug(f"Login successful for {mail_username}")
server.sendmail(mail_sender, recipient, msg.as_string())
current_app.logger.info(f"Email sent successfully from {mail_sender} to {recipient}")
return True, f'Email sent successfully to {recipient}!'
except smtplib.SMTPException as e:
error_msg = str(e)
current_app.logger.error(f"SMTP Error: {error_msg}")
current_app.logger.error(f"Failed to send email from {mail_sender} to {recipient} via {mail_host}:{mail_port}")
return False, error_msg
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
current_app.logger.error(error_msg)
return False, error_msg
+611
View File
@@ -0,0 +1,611 @@
"""
Unified import helper for importing music content across different services.
This module provides consistent import functionality for tracks, albums, and playlists
from various music streaming services like Spotify and Deezer.
"""
import json
import logging
import secrets
import string
from flask import current_app, flash
from musicround.models import Song, Tag, db
from musicround.helpers.metadata import get_song_metadata_by_isrc
def generate_token(length=32):
"""
Generate a secure random token for authentication or validation purposes.
Args:
length (int): The length of the token to generate (default: 32)
Returns:
str: A secure random token string
"""
# Use secrets module for cryptographically strong random numbers
alphabet = string.ascii_letters + string.digits
token = ''.join(secrets.choice(alphabet) for _ in range(length))
return token
class ImportHelper:
"""Unified helper for importing music content from different services."""
# Helper method to create tags from genres
@staticmethod
def create_tags_from_genre(song, genre_data):
"""
Create tags from genre data and associate them with a song
Args:
song (Song): Song object to associate tags with
genre_data (str or list): Genre data that could be string, list, or comma-separated values
"""
if not genre_data:
return
genres = []
# Handle different types of genre data
if isinstance(genre_data, str):
# Handle comma-separated genre string
genres = [g.strip() for g in genre_data.split(',')]
elif isinstance(genre_data, list):
# Handle genre list
genres = [g.strip() if isinstance(g, str) else str(g).strip() for g in genre_data]
# Add each genre as a tag
for genre_name in genres:
if not genre_name:
continue
# Convert to lowercase for consistency
genre_name = genre_name.lower()
# Find existing tag or create new one
tag = Tag.query.filter(Tag.name.ilike(genre_name)).first()
if not tag:
tag = Tag(name=genre_name)
db.session.add(tag)
try:
db.session.flush() # Flush to get ID but don't commit yet
except Exception as e:
current_app.logger.error(f"Error creating tag '{genre_name}': {e}")
continue
# Add tag to song if not already present
if tag not in song.tags:
song.tags.append(tag)
current_app.logger.info(f"Added tag '{tag.name}' to song '{song.title}'")
@staticmethod
def import_item(service_name, item_type, item_id):
"""
Import a track, album, or playlist from a specific service.
Args:
service_name (str): Name of the service (e.g., 'spotify', 'deezer')
item_type (str): Type of item ('track', 'album', 'playlist')
item_id (str): ID of the item to import
Returns:
dict: Summary of import operation with counts of imported items
"""
current_app.logger.info(f"Importing {item_type} {item_id} from {service_name}")
result = {
'success': False,
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': [],
'service': service_name,
'item_type': item_type,
'item_id': item_id
}
try:
if service_name.lower() == 'spotify':
# Get Spotify client
sp = current_app.config.get('sp')
if not sp:
result['errors'].append("Spotify client not configured")
return result
# Handle based on item type
if item_type.lower() == 'track':
track_result = ImportHelper.import_spotify_track(sp, item_id)
result.update(track_result)
elif item_type.lower() == 'album':
album_result = ImportHelper.import_spotify_album(sp, item_id)
result.update(album_result)
elif item_type.lower() == 'playlist':
playlist_result = ImportHelper.import_spotify_playlist(sp, item_id)
result.update(playlist_result)
else:
result['errors'].append(f"Unknown item type: {item_type}")
return result
elif service_name.lower() == 'deezer':
# Get Deezer client
deezer_client = current_app.config.get('deezer')
if not deezer_client:
result['errors'].append("Deezer client not configured")
return result
# Handle based on item type
if item_type.lower() == 'track':
track_result = ImportHelper.import_deezer_track(deezer_client, item_id)
result.update(track_result)
elif item_type.lower() == 'album':
album_result = ImportHelper.import_deezer_album(deezer_client, item_id)
result.update(album_result)
elif item_type.lower() == 'playlist':
playlist_result = ImportHelper.import_deezer_playlist(deezer_client, item_id)
result.update(playlist_result)
else:
result['errors'].append(f"Unknown item type: {item_type}")
return result
else:
result['errors'].append(f"Unsupported service: {service_name}")
return result
result['success'] = len(result['errors']) == 0
return result
except Exception as e:
current_app.logger.error(f"Error importing {item_type} {item_id} from {service_name}: {str(e)}")
result['errors'].append(str(e))
result['success'] = False
return result
# ------------- SPOTIFY IMPORT METHODS -------------
@staticmethod
def import_spotify_track(sp, track_id):
"""Import a single track from Spotify"""
result = {
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': []
}
try:
# First check if this Spotify track is already in our database
existing_song = Song.query.filter_by(spotify_id=track_id).first()
if existing_song:
current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}')
result['skipped_count'] += 1
return result
# Get track info from Spotify
track_info = sp.track(track_id)
if not track_info:
result['errors'].append(f"Track with ID {track_id} not found on Spotify")
result['error_count'] += 1
return result
# Try to get ISRC if available
isrc = track_info.get('external_ids', {}).get('isrc')
song = None
# If we have an ISRC, check if a song with this ISRC already exists
if isrc:
existing_by_isrc = Song.query.filter(Song.isrc == isrc).first()
if existing_by_isrc:
current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}')
result['skipped_count'] += 1
return result
# Get comprehensive metadata using ISRC
current_app.logger.info(f"Looking up metadata for ISRC: {isrc}")
metadata = get_song_metadata_by_isrc(isrc, current_app)
if metadata and metadata.get("title"):
# Create song with enriched metadata
song = Song(
spotify_id=track_id,
deezer_id=metadata.get("deezer_id"),
title=metadata.get("title", track_info['name']),
artist=metadata.get("artist_name", ", ".join([artist['name'] for artist in track_info['artists']])),
genre=metadata.get("genre"),
year=metadata.get("year"),
preview_url=metadata.get("preview_url", track_info.get('preview_url')),
cover_url=metadata.get("cover_url") or (track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None),
popularity=metadata.get("popularity", track_info.get('popularity')),
isrc=isrc,
album_name=track_info.get('album', {}).get('name'),
metadata_sources=','.join(metadata.get("sources", [])),
source='spotify',
spotify_preview_url=metadata.get("spotify_preview_url"),
deezer_preview_url=metadata.get("deezer_preview_url"),
apple_preview_url=metadata.get("apple_preview_url"),
youtube_preview_url=metadata.get("youtube_preview_url"),
spotify_cover_url=metadata.get("spotify_cover_url"),
deezer_cover_url=metadata.get("deezer_cover_url"),
apple_cover_url=metadata.get("apple_cover_url"),
additional_data=json.dumps({
k: v for k, v in metadata.items()
if k not in ['artist_name', 'title', 'year', 'genre', 'popularity',
'preview_url', 'sources', 'isrc', 'spotify_id',
'deezer_id', 'cover_url', 'spotify_preview_url',
'deezer_preview_url', 'apple_preview_url',
'youtube_preview_url', 'spotify_cover_url',
'deezer_cover_url', 'apple_cover_url']
}) if metadata else None
)
current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}")
else:
# Fallback to just Spotify data with the ISRC
song = ImportHelper._create_song_from_spotify(track_info, isrc)
else:
# No ISRC available, just use Spotify data
song = ImportHelper._create_song_from_spotify(track_info)
# We already checked for duplicates above, so we can add the song directly
if song:
db.session.add(song)
# Create tags from genre information
if song.genre:
ImportHelper.create_tags_from_genre(song, song.genre)
# Also check additional data for genres
if song.additional_data:
try:
additional_data = json.loads(song.additional_data)
if 'genres' in additional_data:
ImportHelper.create_tags_from_genre(song, additional_data['genres'])
except Exception as e:
current_app.logger.error(f"Error parsing additional data for genres: {e}")
# Get audio features if this is a Spotify track - NEW ADDITION
ImportHelper._fetch_audio_features_for_song(sp, song)
db.session.commit()
current_app.logger.info(f'Imported Spotify track {song.title} by {song.artist}')
result['imported_count'] += 1
else:
current_app.logger.warning(f'Could not create song from Spotify track {track_id}')
result['skipped_count'] += 1
return result
except Exception as e:
current_app.logger.error(f"Error importing Spotify track {track_id}: {str(e)}")
result['errors'].append(str(e))
result['error_count'] += 1
return result
@staticmethod
def import_spotify_album(sp, album_id):
"""Import all tracks from a Spotify album"""
result = {
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': []
}
try:
# Get album tracks from Spotify
album_tracks = sp.album_tracks(album_id)
if not album_tracks or 'items' not in album_tracks:
result['errors'].append(f"Album with ID {album_id} not found on Spotify")
result['error_count'] += 1
return result
# Import each track in the album
for track in album_tracks['items']:
if track and 'id' in track:
track_result = ImportHelper.import_spotify_track(sp, track['id'])
result['imported_count'] += track_result['imported_count']
result['skipped_count'] += track_result['skipped_count']
result['error_count'] += track_result['error_count']
result['errors'].extend(track_result['errors'])
return result
except Exception as e:
current_app.logger.error(f"Error importing Spotify album {album_id}: {str(e)}")
result['errors'].append(str(e))
result['error_count'] += 1
return result
@staticmethod
def import_spotify_playlist(sp, playlist_id):
"""Import all tracks from a Spotify playlist"""
result = {
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': []
}
try:
# Get playlist tracks from Spotify
tracks_info = sp.playlist_tracks(playlist_id)
if not tracks_info or 'items' not in tracks_info:
result['errors'].append(f"Playlist with ID {playlist_id} not found on Spotify")
result['error_count'] += 1
return result
# Import each track in the playlist
items = tracks_info.get('items', [])
for item in items:
# 'track' can be None if it's a local or unavailable track
track_obj = item.get('track')
if track_obj and 'id' in track_obj:
track_result = ImportHelper.import_spotify_track(sp, track_obj['id'])
result['imported_count'] += track_result['imported_count']
result['skipped_count'] += track_result['skipped_count']
result['error_count'] += track_result['error_count']
result['errors'].extend(track_result['errors'])
return result
except Exception as e:
current_app.logger.error(f"Error importing Spotify playlist {playlist_id}: {str(e)}")
result['errors'].append(str(e))
result['error_count'] += 1
return result
@staticmethod
def _create_song_from_spotify(track_info, isrc=None):
"""Create a song object from Spotify track data"""
# Basic song with just Spotify data
preview_url = track_info.get('preview_url')
cover_url = track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None
# Extract year from album if available
year = None
if track_info.get('album') and track_info['album'].get('release_date'):
year = track_info['album']['release_date'][:4]
# Try to get genre from album
genre = None
# Create song object
return Song(
spotify_id=track_info['id'],
title=track_info['name'],
artist=", ".join([artist['name'] for artist in track_info['artists']]),
genre=genre,
year=year,
preview_url=preview_url,
cover_url=cover_url,
popularity=track_info.get('popularity'),
isrc=isrc,
album_name=track_info.get('album', {}).get('name'),
metadata_sources='spotify',
source='spotify'
)
@staticmethod
def _fetch_audio_features_for_song(sp, song):
"""Fetch audio features for a Spotify song and update the song object"""
try:
audio_features = sp.audio_features(song.spotify_id)
if audio_features and len(audio_features) > 0:
features = audio_features[0]
song.danceability = features.get('danceability')
song.energy = features.get('energy')
song.key = features.get('key')
song.loudness = features.get('loudness')
song.mode = features.get('mode')
song.speechiness = features.get('speechiness')
song.acousticness = features.get('acousticness')
song.instrumentalness = features.get('instrumentalness')
song.liveness = features.get('liveness')
song.valence = features.get('valence')
song.tempo = features.get('tempo')
current_app.logger.info(f"Audio features fetched for song '{song.title}'")
except Exception as e:
current_app.logger.error(f"Error fetching audio features for song '{song.title}': {str(e)}")
# ------------- DEEZER IMPORT METHODS -------------
@staticmethod
def import_deezer_track(deezer_client, track_id):
"""Import a single track from Deezer"""
result = {
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': []
}
try:
# First check if this Deezer track is already in our database
existing_song = Song.query.filter_by(deezer_id=track_id).first()
if existing_song:
current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}')
result['skipped_count'] += 1
return result
# Get track info from Deezer
track = deezer_client.get_track(track_id)
if not track:
result['errors'].append(f"Track with ID {track_id} not found on Deezer")
result['error_count'] += 1
return result
# Check if ISRC is available
isrc = track.get('isrc')
song = None
# If we have an ISRC, check if a song with this ISRC already exists
if isrc:
existing_by_isrc = Song.query.filter(Song.isrc == isrc).first()
if existing_by_isrc:
current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}')
result['skipped_count'] += 1
return result
# Use metadata helper function to get comprehensive metadata
current_app.logger.info(f"Looking up metadata for ISRC: {isrc}")
metadata = get_song_metadata_by_isrc(isrc, current_app)
# Use metadata if found, otherwise fall back to Deezer data only
if metadata and metadata.get("title"):
song = Song(
deezer_id=track.get('id'),
spotify_id=metadata.get("spotify_id"),
title=metadata.get("title", track.get('title')),
artist=metadata.get("artist_name", track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist'),
preview_url=metadata.get("preview_url", track.get('preview')),
cover_url=metadata.get("cover_url") or track.get('album', {}).get('cover'),
genre=metadata.get("genre"),
year=metadata.get("year"),
popularity=metadata.get("popularity"),
isrc=isrc,
album_name=track.get('album', {}).get('title'),
metadata_sources=','.join(metadata.get("sources", [])),
source='deezer',
spotify_preview_url=metadata.get("spotify_preview_url"),
deezer_preview_url=metadata.get("deezer_preview_url"),
apple_preview_url=metadata.get("apple_preview_url"),
youtube_preview_url=metadata.get("youtube_preview_url"),
spotify_cover_url=metadata.get("spotify_cover_url"),
deezer_cover_url=metadata.get("deezer_cover_url"),
apple_cover_url=metadata.get("apple_cover_url"),
additional_data=json.dumps(
{k: v for k, v in metadata.items() if k not in ['artist_name', 'title', 'year', 'genre',
'popularity', 'preview_url', 'sources',
'isrc', 'spotify_id', 'deezer_id', 'cover_url',
'spotify_preview_url', 'deezer_preview_url',
'apple_preview_url', 'youtube_preview_url',
'spotify_cover_url', 'deezer_cover_url',
'apple_cover_url']}
) if metadata else None
)
current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}")
else:
# Fallback to just Deezer data
song = Song(
deezer_id=track.get('id'),
title=track.get('title'),
artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist',
preview_url=track.get('preview'),
cover_url=track.get('album', {}).get('cover'),
album_name=track.get('album', {}).get('title'),
metadata_sources='deezer',
source='deezer'
)
else:
# No ISRC available, just use Deezer data
song = Song(
deezer_id=track.get('id'),
title=track.get('title'),
artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist',
preview_url=track.get('preview'),
cover_url=track.get('album', {}).get('cover'),
album_name=track.get('album', {}).get('title'),
metadata_sources='deezer',
source='deezer'
)
# We already checked for duplicates above, so we can add the song directly
if song:
db.session.add(song)
# Create tags from genre information
if song.genre:
ImportHelper.create_tags_from_genre(song, song.genre)
# Also check additional data for genres
if song.additional_data:
try:
additional_data = json.loads(song.additional_data)
if 'genres' in additional_data:
ImportHelper.create_tags_from_genre(song, additional_data['genres'])
except Exception as e:
current_app.logger.error(f"Error parsing additional data for genres: {e}")
db.session.commit()
current_app.logger.info(f'Imported Deezer track {song.title} by {song.artist}')
result['imported_count'] += 1
else:
current_app.logger.warning(f'Could not create song from Deezer track {track_id}')
result['skipped_count'] += 1
return result
except Exception as e:
current_app.logger.error(f"Error importing Deezer track {track_id}: {str(e)}")
result['errors'].append(str(e))
result['error_count'] += 1
return result
@staticmethod
def import_deezer_album(deezer_client, album_id):
"""Import all tracks from a Deezer album"""
result = {
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': []
}
try:
# Get album info from Deezer
album = deezer_client.get_album(album_id)
if not album or not album.get('tracks') or not album['tracks'].get('data'):
result['errors'].append(f"Album with ID {album_id} not found on Deezer")
result['error_count'] += 1
return result
# Import each track in the album
tracks = album['tracks']['data']
for track in tracks:
track_id = track.get('id')
if track_id:
track_result = ImportHelper.import_deezer_track(deezer_client, track_id)
result['imported_count'] += track_result['imported_count']
result['skipped_count'] += track_result['skipped_count']
result['error_count'] += track_result['error_count']
result['errors'].extend(track_result['errors'])
return result
except Exception as e:
current_app.logger.error(f"Error importing Deezer album {album_id}: {str(e)}")
result['errors'].append(str(e))
result['error_count'] += 1
return result
@staticmethod
def import_deezer_playlist(deezer_client, playlist_id):
"""Import all tracks from a Deezer playlist"""
result = {
'imported_count': 0,
'skipped_count': 0,
'error_count': 0,
'errors': []
}
try:
# Get playlist info from Deezer
playlist = deezer_client.get_playlist(playlist_id)
if not playlist or not playlist.get('tracks') or not playlist['tracks'].get('data'):
result['errors'].append(f"Playlist with ID {playlist_id} not found on Deezer")
result['error_count'] += 1
return result
# Import each track in the playlist
tracks = playlist['tracks']['data']
for track in tracks:
track_id = track.get('id')
if track_id:
track_result = ImportHelper.import_deezer_track(deezer_client, track_id)
result['imported_count'] += track_result['imported_count']
result['skipped_count'] += track_result['skipped_count']
result['error_count'] += track_result['error_count']
result['errors'].extend(track_result['errors'])
return result
except Exception as e:
current_app.logger.error(f"Error importing Deezer playlist {playlist_id}: {str(e)}")
result['errors'].append(str(e))
result['error_count'] += 1
return result
+839
View File
@@ -0,0 +1,839 @@
import requests
import json
import statistics
import musicbrainzngs
import openai
from flask import current_app
from collections import Counter
import traceback # Added for detailed error tracking
def get_song_metadata_by_isrc(isrc, app=None):
"""
Get comprehensive song metadata by ISRC code from multiple sources.
Args:
isrc (str): The ISRC code to look up
app: Flask application context (optional)
Returns:
dict: Standardized metadata with the following keys:
- artist_name: Artist name(s)
- title: Title of the song
- year: Year the song was first released
- genre: Primary genre
- genres: All genres as an array
- popularity: Popularity rating (0-100)
- preview_url: Main preview URL (prioritized from sources)
- sources: List of sources that provided data
- spotify_id: Spotify track ID if available
- deezer_id: Deezer track ID if available
- And more provider-specific data
"""
# Initialize result dictionary
metadata = {
"artist_name": None,
"title": None,
"year": None,
"genre": None,
"genres": [], # New array to store all genres
"popularity": None,
"preview_url": None,
"sources": [],
"isrc": isrc,
"spotify_id": None,
"deezer_id": None,
# Cover URLs from different sources
"cover_url": None,
"spotify_cover_url": None,
"deezer_cover_url": None,
"apple_cover_url": None,
# Preview URLs from different sources
"spotify_preview_url": None,
"deezer_preview_url": None,
"apple_preview_url": None,
"youtube_preview_url": None
}
# Store results from different sources to compare
artist_names = []
titles = []
years = []
genres = [] # This will collect all genres for final processing
preview_urls = []
# Initialize logger if app context provided
logger = app.logger if app else None
if logger:
logger.info(f"=== DEBUG: Starting metadata refresh for ISRC: {isrc} ===")
try:
# 0. Query ACRCloud first (provides info from multiple platforms)
if logger:
logger.info(f"DEBUG: Querying ACRCloud for ISRC: {isrc}")
acrcloud_data = get_acrcloud_data(isrc, app)
if acrcloud_data:
metadata["sources"].append("acrcloud")
if logger:
logger.info(f"DEBUG: ACRCloud data received: {json.dumps(acrcloud_data, default=str)}")
if acrcloud_data.get("artist_name"):
artist_names.append(acrcloud_data["artist_name"])
if acrcloud_data.get("title"):
titles.append(acrcloud_data["title"])
if acrcloud_data.get("year"):
years.append(acrcloud_data["year"])
if acrcloud_data.get("genre"):
# Debug the genre value
if logger:
logger.info(f"DEBUG: ACRCloud genre type: {type(acrcloud_data['genre']).__name__}")
logger.info(f"DEBUG: ACRCloud genre value: {acrcloud_data['genre']}")
# Handle genre properly whether it's a string, list, or dict
if isinstance(acrcloud_data["genre"], list):
if logger:
logger.info(f"DEBUG: Processing genre as list: {acrcloud_data['genre']}")
genres.extend(acrcloud_data["genre"]) # ACRCloud might return multiple genres
elif isinstance(acrcloud_data["genre"], str):
if logger:
logger.info(f"DEBUG: Processing genre as string: {acrcloud_data['genre']}")
genres.append(acrcloud_data["genre"])
elif isinstance(acrcloud_data["genre"], dict):
# Debug the dict structure
if logger:
logger.info(f"DEBUG: Processing genre as dict: {acrcloud_data['genre']}")
# Extract genre name from dict if available
for genre_key, genre_value in acrcloud_data["genre"].items():
if logger:
logger.info(f"DEBUG: Genre key: {genre_key}, value type: {type(genre_value).__name__}")
if isinstance(genre_value, str):
if logger:
logger.info(f"DEBUG: Adding genre string: {genre_value}")
genres.append(genre_value)
elif isinstance(genre_value, list) and genre_value:
if logger:
logger.info(f"DEBUG: Adding genres from list: {genre_value}")
genres.extend([g for g in genre_value if isinstance(g, str)])
else:
if logger:
logger.info(f"DEBUG: Skipping genre value of type: {type(genre_value).__name__}")
else:
if logger:
logger.info(f"DEBUG: Unknown genre type: {type(acrcloud_data['genre']).__name__}")
# Store platform IDs
if acrcloud_data.get("spotify_id"):
metadata["spotify_id"] = acrcloud_data["spotify_id"]
if acrcloud_data.get("deezer_id"):
metadata["deezer_id"] = acrcloud_data["deezer_id"]
# Store preview URLs from different sources
if acrcloud_data.get("spotify_preview_url"):
metadata["spotify_preview_url"] = acrcloud_data["spotify_preview_url"]
if acrcloud_data.get("deezer_preview_url"):
metadata["deezer_preview_url"] = acrcloud_data["deezer_preview_url"]
if acrcloud_data.get("apple_preview_url"):
metadata["apple_preview_url"] = acrcloud_data["apple_preview_url"]
if acrcloud_data.get("youtube_preview_url"):
metadata["youtube_preview_url"] = acrcloud_data["youtube_preview_url"]
# Store cover URLs from different sources
if acrcloud_data.get("spotify_cover_url"):
metadata["spotify_cover_url"] = acrcloud_data["spotify_cover_url"]
if acrcloud_data.get("deezer_cover_url"):
metadata["deezer_cover_url"] = acrcloud_data["deezer_cover_url"]
if acrcloud_data.get("apple_cover_url"):
metadata["apple_cover_url"] = acrcloud_data["apple_cover_url"]
# Store album cover as main cover if available
if acrcloud_data.get("album_cover"):
metadata["cover_url"] = acrcloud_data["album_cover"]
except Exception as e:
if logger:
logger.error(f"ACRCloud error for ISRC {isrc}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
try:
# 1. Query MusicBrainz (direct ISRC support)
mb_data = get_musicbrainz_data(isrc, logger)
if mb_data:
metadata["sources"].append("musicbrainz")
if mb_data.get("artist_name"):
artist_names.append(mb_data["artist_name"])
if mb_data.get("title"):
titles.append(mb_data["title"])
if mb_data.get("year"):
years.append(mb_data["year"])
if mb_data.get("genre"):
genres.append(mb_data["genre"])
except Exception as e:
if logger:
logger.error(f"MusicBrainz error for ISRC {isrc}: {e}")
try:
# 2. Query Spotify (direct ISRC support)
spotify_data = get_spotify_data(isrc, app)
if spotify_data:
metadata["sources"].append("spotify")
if spotify_data.get("artist_name"):
artist_names.append(spotify_data["artist_name"])
if spotify_data.get("title"):
titles.append(spotify_data["title"])
if spotify_data.get("year"):
years.append(spotify_data["year"])
if spotify_data.get("genre"):
genres.append(spotify_data["genre"])
if spotify_data.get("popularity") is not None:
metadata["popularity"] = spotify_data["popularity"]
if spotify_data.get("spotify_preview_url"):
metadata["spotify_preview_url"] = spotify_data["spotify_preview_url"]
if spotify_data.get("id"):
metadata["spotify_id"] = spotify_data["id"]
if spotify_data.get("spotify_cover_url"):
metadata["spotify_cover_url"] = spotify_data["spotify_cover_url"]
except Exception as e:
if logger:
logger.error(f"Spotify error for ISRC {isrc}: {e}")
try:
# 3. Query Deezer (direct ISRC support in newer API)
deezer_data = get_deezer_data(isrc, app)
if deezer_data:
metadata["sources"].append("deezer")
if deezer_data.get("artist_name"):
artist_names.append(deezer_data["artist_name"])
if deezer_data.get("title"):
titles.append(deezer_data["title"])
if deezer_data.get("year"):
years.append(deezer_data["year"])
if deezer_data.get("genre"):
genres.append(deezer_data["genre"])
if deezer_data.get("deezer_preview_url"):
metadata["deezer_preview_url"] = deezer_data["deezer_preview_url"]
if deezer_data.get("id"):
metadata["deezer_id"] = deezer_data["id"]
if deezer_data.get("deezer_cover_url"):
metadata["deezer_cover_url"] = deezer_data["deezer_cover_url"]
except Exception as e:
if logger:
logger.error(f"Deezer error for ISRC {isrc}: {e}")
# Debug the collected data before processing
if logger:
logger.info(f"DEBUG: All collected artist names: {artist_names}")
logger.info(f"DEBUG: All collected titles: {titles}")
logger.info(f"DEBUG: All collected years: {years}")
logger.info(f"DEBUG: All collected genres: {genres}")
# Determine most common values so far
if artist_names and titles:
try:
# Use the most frequent values from collected data
if logger:
logger.info(f"DEBUG: Computing most common artist from: {artist_names}")
metadata["artist_name"] = Counter(artist_names).most_common(1)[0][0]
if logger:
logger.info(f"DEBUG: Computing most common title from: {titles}")
metadata["title"] = Counter(titles).most_common(1)[0][0]
# With artist and title, we can query services that don't support ISRC
try:
# 4. Query Last.fm
lastfm_data = get_lastfm_data(metadata["artist_name"], metadata["title"], app)
if lastfm_data:
metadata["sources"].append("lastfm")
if lastfm_data.get("genre"):
genres.append(lastfm_data["genre"])
except Exception as e:
if logger:
logger.error(f"Last.fm error for {metadata['artist_name']} - {metadata['title']}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
try:
# 5. Query OpenAI for additional verification
openai_data = get_openai_data(metadata["artist_name"], metadata["title"], app)
if openai_data:
metadata["sources"].append("openai")
if openai_data.get("year"):
years.append(openai_data["year"])
if openai_data.get("genre"):
genres.append(openai_data["genre"])
except Exception as e:
if logger:
logger.error(f"OpenAI error for {metadata['artist_name']} - {metadata['title']}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
except Exception as e:
if logger:
logger.error(f"Error determining most common values: {e}")
logger.error(f"Artist names: {artist_names}")
logger.error(f"Titles: {titles}")
logger.error(f"Traceback: {traceback.format_exc()}")
# Process collected data
if years:
try:
# For year, take the earliest one as "first released"
if logger:
logger.info(f"DEBUG: Processing years: {years}")
numeric_years = [int(y) for y in years if y and y.isdigit()]
if numeric_years:
metadata["year"] = str(min(numeric_years))
if logger:
logger.info(f"DEBUG: Selected earliest year: {metadata['year']}")
except Exception as e:
# Fallback to most common if conversion fails
if logger:
logger.error(f"Year processing error: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
try:
metadata["year"] = Counter(years).most_common(1)[0][0]
if logger:
logger.info(f"DEBUG: Fallback to most common year: {metadata['year']}")
except Exception as e2:
if logger:
logger.error(f"Year fallback error: {e2}")
# Process all genres and create a clean list for tagging
if genres:
try:
# First, clean up genres for storage
clean_genres = []
for g in genres:
if isinstance(g, str) and g.strip():
# Translate specific genre names to English
if g.strip().lower() == "vaihtoehtoinen":
clean_genres.append("Alternative")
if logger:
logger.info(f"DEBUG: Translated genre 'Vaihtoehtoinen' to 'Alternative'")
else:
clean_genres.append(g.strip())
elif isinstance(g, list):
# Flatten any nested lists and translate if needed
for item in g:
if isinstance(item, str) and item.strip():
if item.strip().lower() == "vaihtoehtoinen":
clean_genres.append("Alternative")
if logger:
logger.info(f"DEBUG: Translated genre 'Vaihtoehtoinen' to 'Alternative'")
else:
clean_genres.append(item.strip())
# Store all unique genres in the metadata
unique_genres = []
for g in clean_genres:
if g.lower() not in [existing.lower() for existing in unique_genres]:
unique_genres.append(g)
metadata["genres"] = unique_genres
if logger:
logger.info(f"DEBUG: All cleaned genres: {unique_genres}")
# For the main genre field, take the most common one
if clean_genres:
# Get a case-insensitive count by converting all to lowercase
lowercase_genres = [g.lower() for g in clean_genres]
genre_counter = Counter(lowercase_genres)
most_common_genre_lower = genre_counter.most_common(1)[0][0]
# Find the original case version from our clean genres
for g in clean_genres:
if g.lower() == most_common_genre_lower:
metadata["genre"] = g
break
if logger:
logger.info(f"DEBUG: Selected most common genre as main: {metadata['genre']}")
except Exception as e:
if logger:
logger.error(f"Genre processing error: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
# Process preview URLs with priority for Spotify, then Apple Music, then Deezer
preview_sources = [
metadata.get("spotify_preview_url"),
metadata.get("apple_preview_url"),
metadata.get("deezer_preview_url"),
metadata.get("youtube_preview_url")
]
# Select the first available preview URL as the main one
for url in preview_sources:
if url:
metadata["preview_url"] = url
if logger:
logger.info(f"DEBUG: Selected preview URL: {url}")
break
# Process cover URLs with priority
cover_sources = [
metadata.get("spotify_cover_url"),
metadata.get("apple_cover_url"),
metadata.get("deezer_cover_url")
]
# Select the first available cover URL as the main one if not already set
if not metadata["cover_url"]:
for url in cover_sources:
if url:
metadata["cover_url"] = url
if logger:
logger.info(f"DEBUG: Selected cover URL: {url}")
break
if logger:
logger.info(f"=== DEBUG: Completed metadata refresh for ISRC: {isrc} ===")
logger.info(f"=== Final metadata: {json.dumps(metadata, default=str)} ===")
return metadata
def get_musicbrainz_data(isrc, logger=None):
"""Query MusicBrainz API using ISRC"""
result = {}
# Set user agent for MusicBrainz API
musicbrainzngs.set_useragent("MusicRound", "0.1", "fret@fret.de")
try:
# Search MusicBrainz by ISRC
mb_results = musicbrainzngs.search_recordings(isrc=isrc, limit=1)
if mb_results and mb_results.get('recording-list') and len(mb_results['recording-list']) > 0:
recording = mb_results['recording-list'][0]
# Extract title
result["title"] = recording.get('title')
# Extract artist name
if recording.get('artist-credit'):
artist_names = []
for artist_credit in recording['artist-credit']:
if isinstance(artist_credit, dict) and 'artist' in artist_credit:
artist_names.append(artist_credit['artist']['name'])
if artist_names:
result["artist_name"] = ", ".join(artist_names)
# Extract genre tags
if 'tag-list' in recording:
tags = [tag['name'] for tag in recording['tag-list']]
if tags:
result["genre"] = tags[0]
# Get release year
if 'release-list' in recording and recording['release-list']:
release = recording['release-list'][0]
if 'date' in release:
result["year"] = release['date'][:4] # Extract year from date
except Exception as e:
if logger:
logger.error(f"MusicBrainz API error: {e}")
return result
def get_spotify_data(isrc, app=None):
"""Query Spotify API using ISRC"""
result = {}
try:
# Get Spotify client from app context
sp = app.config.get('sp') if app else None
if not sp:
return result
# Search Spotify by ISRC
query = f"isrc:{isrc}"
spotify_result = sp.search(q=query, type='track')
if spotify_result and spotify_result.get('tracks') and spotify_result['tracks'].get('items'):
track = spotify_result['tracks']['items'][0]
# Extract track title
result["title"] = track.get('name')
# Extract artist names
if track.get('artists'):
result["artist_name"] = ", ".join([artist['name'] for artist in track['artists']])
# Extract popularity
result["popularity"] = track.get('popularity')
# Extract preview URL
result["spotify_preview_url"] = track.get('preview_url')
# Store main track ID
result["id"] = track.get('id')
# Get album details to extract more info
if track.get('album') and track['album'].get('id'):
album = sp.album(track['album']['id'])
# Extract genre
if album.get('genres') and len(album['genres']) > 0:
result["genre"] = album['genres'][0]
# Extract release year
if album.get('release_date'):
result["year"] = album['release_date'][:4]
# Extract cover images
if track['album'].get('images') and len(track['album']['images']) > 0:
for img in track['album']['images']:
if img.get('height') and img.get('width') and img.get('url'):
if img['height'] > 600: # Consider this a large image
result["spotify_cover_url"] = img['url']
break
# If we didn't find a large image, use the first one
if not result.get("spotify_cover_url") and track['album']['images'][0].get('url'):
result["spotify_cover_url"] = track['album']['images'][0]['url']
except Exception as e:
if app:
app.logger.error(f"Spotify API error: {e}")
return result
def get_deezer_data(isrc, app=None):
"""Query Deezer API using ISRC"""
result = {}
try:
# Get Deezer client from app context or create a basic one
deezer_client = app.config.get('deezer') if app else None
if not deezer_client:
# If no client in app context, make direct API call
response = requests.get(f"https://api.deezer.com/track/isrc:{isrc}")
if response.status_code == 200:
track = response.json()
else:
return result
else:
# Try to use the ISRC search if available, or search by track if not
try:
track = deezer_client._make_request(f"track/isrc:{isrc}")
except:
# Deezer client might not have direct ISRC support, so try a workaround
# (This would require having title and artist from another source)
track = None
if track and not track.get('error'):
# Extract title
result["title"] = track.get('title')
# Extract artist name
if track.get('artist'):
result["artist_name"] = track['artist'].get('name')
# Extract preview URL
result["deezer_preview_url"] = track.get('preview')
# Extract Deezer ID
result["id"] = track.get('id')
# Get album details to extract more info
if track.get('album') and track['album'].get('id'):
album_id = track['album']['id']
if deezer_client:
album = deezer_client.get_album(album_id)
else:
album_response = requests.get(f"https://api.deezer.com/album/{album_id}")
album = album_response.json() if album_response.status_code == 200 else None
if album and not album.get('error'):
# Extract genre
if album.get('genres') and album['genres'].get('data') and len(album['genres']['data']) > 0:
result["genre"] = album['genres']['data'][0].get('name')
# Extract release year
if album.get('release_date'):
result["year"] = album['release_date'][:4]
# Extract cover image
if track['album'].get('cover'):
result["deezer_cover_url"] = track['album']['cover']
# Try the bigger version
if track['album'].get('cover_xl'):
result["deezer_cover_url"] = track['album']['cover_xl']
elif track['album'].get('cover_big'):
result["deezer_cover_url"] = track['album']['cover_big']
except Exception as e:
if app:
app.logger.error(f"Deezer API error: {e}")
return result
def get_lastfm_data(artist_name, track_title, app=None):
"""Query Last.fm API using artist name and track title"""
result = {}
if not artist_name or not track_title:
return result
try:
# Get Last.fm API key from app context or environment
lastfm_api_key = None
if app:
lastfm_api_key = app.config.get('LASTFM_API_KEY')
if not lastfm_api_key:
return result
# Query Last.fm API
url = 'http://ws.audioscrobbler.com/2.0/'
params = {
'method': 'track.getInfo',
'api_key': lastfm_api_key,
'artist': artist_name,
'track': track_title,
'format': 'json'
}
response = requests.get(url=url, params=params)
if response.status_code == 200:
data = response.json()
# Extract genre from top tags
if (data.get('track') and
data['track'].get('toptags') and
data['track']['toptags'].get('tag')):
tags = data['track']['toptags']['tag']
if tags and len(tags) > 0:
result["genre"] = tags[0]['name']
except Exception as e:
if app:
app.logger.error(f"Last.fm API error: {e}")
return result
def get_openai_data(artist_name, track_title, app=None):
"""Query OpenAI API for additional metadata verification"""
result = {}
if not artist_name or not track_title:
return result
try:
# Get OpenAI API details from app context
if not app:
return result
openai_api_key = app.config.get('OPENAI_API_KEY')
openai_url = app.config.get('OPENAI_URL')
openai_model = app.config.get('OPENAI_MODEL')
if not openai_api_key or not openai_model:
return result
# Configure OpenAI API key
openai.api_key = openai_api_key
# Create prompt
prompt = f"Provide the genre and release year for the song '{track_title}' by {artist_name}. Return the data as a JSON object with keys 'genre' and 'year'. If the information is not available, return null for the corresponding key."
# Log the query
app.logger.info(f"ChatGPT Query: {prompt}")
content = None
# Check which version of the OpenAI library is being used
if hasattr(openai, 'chat') and hasattr(openai.chat, 'completions'):
# New OpenAI API client (>= 1.0.0)
if openai_url:
openai.base_url = openai_url
# Call OpenAI API with new client
try:
response = openai.chat.completions.create(
model=openai_model,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}
)
if response and hasattr(response, 'choices') and response.choices:
content = response.choices[0].message.content
app.logger.info(f"ChatGPT Response: {content}")
except Exception as e:
app.logger.error(f"OpenAI chat completions error: {e}")
# Try falling back to completion API if available
try:
if hasattr(openai, 'Completion'):
response = openai.Completion.create(
engine=openai_model,
prompt=prompt,
max_tokens=200,
temperature=0.2,
top_p=1.0
)
if response and hasattr(response, 'choices') and len(response.choices) > 0:
content = response.choices[0].text.strip()
app.logger.info(f"OpenAI Completion Response: {content}")
except Exception as inner_e:
app.logger.error(f"OpenAI completion fallback error: {inner_e}")
else:
# Old OpenAI API client (< 1.0.0)
if openai_url:
openai.api_base = openai_url # Different attribute in old client
# Call OpenAI API with old client
try:
response = openai.Completion.create(
engine=openai_model, # In old API, it's 'engine' instead of 'model'
prompt=prompt,
max_tokens=200,
temperature=0.2,
top_p=1.0
)
if response and hasattr(response, 'choices') and len(response.choices) > 0:
content = response.choices[0].text.strip()
app.logger.info(f"ChatGPT Response: {content}")
except Exception as e:
app.logger.error(f"OpenAI completion error: {e}")
# Process the response content
if content:
try:
# Try to extract JSON from the content (handle cases where there might be extra text)
import re
json_match = re.search(r'(\{.*\})', content, re.DOTALL)
if json_match:
json_str = json_match.group(1)
data = json.loads(json_str)
else:
data = json.loads(content)
if data.get("genre"):
result["genre"] = data["genre"]
if data.get("year"):
# Always convert year to string
result["year"] = str(data["year"])
# If we got valid data, return it
if "genre" in result or "year" in result:
return result
except Exception as e:
app.logger.error(f"Error parsing OpenAI response: {e}")
app.logger.error(f"Raw response content: {content}")
except AttributeError as e:
app.logger.error(f"OpenAI module error: {e}")
except Exception as e:
if app:
app.logger.error(f"OpenAI API error: {e}")
return result
def get_acrcloud_data(isrc, app=None):
"""Query ACRCloud API using ISRC"""
result = {}
if not app:
return result
try:
# Get ACRCloud API key from app config
acrcloud_token = app.config.get('ACRCLOUD_TOKEN')
if not acrcloud_token:
logger = app.logger if app else None
if logger:
logger.warning("ACRCloud token not found in app config.")
return result
# Query ACRCloud API for track metadata
url = "https://eu-api-v2.acrcloud.com/api/external-metadata/tracks"
headers = {
'Authorization': f'Bearer {acrcloud_token}'
}
params = {
'isrc': isrc,
'platforms': 'spotify,deezer,youtube,applemusic',
'include_works': 1 # Include additional work metadata
}
response = requests.get(url, headers=headers, params=params)
if response.status_code != 200:
app.logger.warning(f"ACRCloud API error: {response.status_code} - {response.text}")
return result
data = response.json()
if not data or not data.get('data') or not len(data['data']) > 0:
return result
track_data = data['data'][0]
logger = app.logger if app else None
if logger:
logger.info(f"ACRCloud API response: {json.dumps(data, default=str)}")
# Extract basic metadata
if track_data.get('name'):
result['title'] = track_data['name']
if track_data.get('artists') and len(track_data['artists']) > 0:
artist_names = [artist['name'] for artist in track_data['artists'] if 'name' in artist]
result['artist_name'] = ', '.join(artist_names)
if track_data.get('release_date'):
result['year'] = track_data['release_date'][:4] # Extract year
if track_data.get('genres'):
result['genre'] = track_data['genres']
# Extract album cover if available
if track_data.get('album') and track_data['album'].get('cover'):
result['album_cover'] = track_data['album']['cover']
# Also get covers from specific sizes if available
if track_data['album'].get('covers'):
covers = track_data['album']['covers']
if covers.get('large'):
result['album_cover_large'] = covers['large']
if covers.get('medium'):
result['album_cover_medium'] = covers['medium']
# Get external metadata from platforms
ext_meta = track_data.get('external_metadata', {})
# Get Spotify metadata
if 'spotify' in ext_meta and ext_meta['spotify'] and len(ext_meta['spotify']) > 0:
spotify_data = ext_meta['spotify'][0]
if spotify_data.get('id'):
result['spotify_id'] = spotify_data['id']
if spotify_data.get('preview'):
result['spotify_preview_url'] = spotify_data['preview']
if spotify_data.get('album') and spotify_data['album'].get('cover'):
result['spotify_cover_url'] = spotify_data['album']['cover']
# Get Deezer metadata
if 'deezer' in ext_meta and ext_meta['deezer'] and len(ext_meta['deezer']) > 0:
deezer_data = ext_meta['deezer'][0]
if deezer_data.get('id'):
result['deezer_id'] = deezer_data['id']
# Deezer preview URL might come from additional API call
if deezer_data.get('album') and deezer_data['album'].get('cover'):
result['deezer_cover_url'] = deezer_data['album']['cover']
# Get Apple Music metadata
if 'applemusic' in ext_meta and ext_meta['applemusic'] and len(ext_meta['applemusic']) > 0:
apple_data = ext_meta['applemusic'][0]
if apple_data.get('preview'):
result['apple_preview_url'] = apple_data['preview']
if apple_data.get('album') and apple_data['album'].get('cover'):
result['apple_cover_url'] = apple_data['album']['cover']
# Get YouTube metadata
if 'youtube' in ext_meta and ext_meta['youtube'] and len(ext_meta['youtube']) > 0:
youtube_data = ext_meta['youtube'][0]
if youtube_data.get('id'):
youtube_id = youtube_data['id']
result['youtube_id'] = youtube_id
# Construct a YouTube Music playback URL
result['youtube_preview_url'] = f"https://music.youtube.com/watch?v={youtube_id}"
return result
except Exception as e:
if app:
app.logger.error(f"ACRCloud API error: {e}")
return result
+304
View File
@@ -0,0 +1,304 @@
"""
Direct Spotify Web API client implementation.
This is an alternative to spotipy for testing and comparison purposes.
"""
import json
import os
import time
import requests
import logging
from flask import current_app
class SpotifyDirectClient:
"""
A client for the Spotify Web API that directly uses the HTTP endpoints
rather than using the spotipy library.
"""
def __init__(self, client_id=None, client_secret=None, cache_path=None, bearer_token=None):
self.client_id = client_id or current_app.config['SPOTIFY_CLIENT_ID']
self.client_secret = client_secret or current_app.config['SPOTIFY_CLIENT_SECRET']
self.cache_path = cache_path or os.path.join('/data', '.spotifycache')
self.base_url = 'https://api.spotify.com/v1'
self.token_url = 'https://accounts.spotify.com/api/token'
self.access_token = bearer_token # Use provided bearer token if available
self.token_expiry = 0
self.refresh_token = None
self.logger = logging.getLogger(__name__)
# Configure session with retry logic
self.session = requests.Session()
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
retries = Retry(
total=5,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504]
)
self.session.mount('https://', HTTPAdapter(max_retries=retries))
# Only load tokens from cache if bearer token was not provided
if not bearer_token:
self._load_token_from_cache()
def _load_token_from_cache(self):
"""Load access and refresh tokens from cache file"""
if not os.path.exists(self.cache_path):
self.logger.warning(f"Cache file not found at {self.cache_path}")
return
try:
with open(self.cache_path, 'r') as f:
token_info = json.load(f)
self.access_token = token_info.get('access_token')
self.refresh_token = token_info.get('refresh_token')
self.token_expiry = token_info.get('expires_at', 0)
self.logger.info("Loaded tokens from cache file")
except Exception as e:
self.logger.error(f"Error loading tokens from cache: {e}")
def _save_token_to_cache(self, token_info):
"""Save token information to cache file"""
try:
with open(self.cache_path, 'w') as f:
json.dump(token_info, f)
self.logger.info("Saved tokens to cache file")
except Exception as e:
self.logger.error(f"Error saving tokens to cache: {e}")
def _refresh_access_token(self):
"""Refresh the access token using the refresh token"""
if not self.refresh_token:
self.logger.error("No refresh token available. User must log in again.")
return False
self.logger.info("Refreshing access token...")
data = {
'grant_type': 'refresh_token',
'refresh_token': self.refresh_token,
'client_id': self.client_id,
'client_secret': self.client_secret
}
try:
response = self.session.post(self.token_url, data=data)
response.raise_for_status()
token_info = response.json()
self.access_token = token_info['access_token']
self.token_expiry = int(time.time()) + token_info['expires_in']
# If new refresh token provided, update it
if 'refresh_token' in token_info:
self.refresh_token = token_info['refresh_token']
# Update cache
token_info['expires_at'] = self.token_expiry
if 'refresh_token' not in token_info and self.refresh_token:
token_info['refresh_token'] = self.refresh_token
self._save_token_to_cache(token_info)
return True
except Exception as e:
self.logger.error(f"Error refreshing access token: {e}")
return False
def _ensure_token_valid(self):
"""Check if token is valid and refresh if needed"""
# If we're using a manually provided bearer token, assume it's valid
if self.access_token and not self.refresh_token:
return True
# Otherwise use the normal refresh logic
if not self.access_token or time.time() > self.token_expiry - 60:
return self._refresh_access_token()
return True
def _make_api_request(self, endpoint, method='GET', params=None, data=None, retry_on_auth_error=True):
"""Make a request to the Spotify API with automatic token refresh"""
if not self._ensure_token_valid():
return None
url = f"{self.base_url}/{endpoint}"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json'
}
try:
if method == 'GET':
response = self.session.get(url, headers=headers, params=params)
elif method == 'POST':
response = self.session.post(url, headers=headers, json=data, params=params)
elif method == 'PUT':
response = self.session.put(url, headers=headers, json=data, params=params)
elif method == 'DELETE':
response = self.session.delete(url, headers=headers, params=params)
else:
self.logger.error(f"Unsupported HTTP method: {method}")
return None
# Handle 401 by refreshing token and retrying once
if response.status_code == 401 and retry_on_auth_error:
self.logger.info("Got 401, refreshing token and retrying...")
if self._refresh_access_token():
return self._make_api_request(endpoint, method, params, data, retry_on_auth_error=False)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
self.logger.error(f"HTTP error: {e}")
# Log the response content for debugging
if hasattr(e, 'response') and e.response:
self.logger.error(f"Response status: {e.response.status_code}")
self.logger.error(f"Response content: {e.response.text}")
return None
except Exception as e:
self.logger.error(f"Error making API request: {e}")
return None
def user_playlists(self, user_id, limit=50, offset=0):
"""
Get a user's playlists. Mirrors the spotipy interface.
Args:
user_id: The Spotify user ID
limit: Maximum number of playlists to return (max 50)
offset: The index of the first playlist to return
Returns:
A dictionary containing the user's playlists or None if an error occurred
"""
endpoint = f"users/{user_id}/playlists"
params = {
'limit': min(limit, 50), # Spotify API has a max limit of 50
'offset': offset
}
self.logger.info(f"Fetching {limit} playlists at offset {offset} for user {user_id}")
result = self._make_api_request(endpoint, params=params)
if result:
self.logger.info(f"Got {len(result.get('items', []))} playlists, total {result.get('total', 0)}")
else:
self.logger.error("Failed to fetch playlists")
return result
def fetch_all_user_playlists(self, user_id, limit=50):
"""
Fetch all playlists for a user with proper pagination.
Args:
user_id: The Spotify user ID
limit: Maximum number of playlists per request (max 50)
Returns:
List of all playlists from the user
"""
all_playlists = []
offset = 0
total = None
max_loops = 50 # Safety limit
loop_count = 0
self.logger.info(f"Starting to fetch all playlists for user {user_id}")
start_time = time.time()
while loop_count < max_loops:
loop_count += 1
result = self.user_playlists(user_id, limit=limit, offset=offset)
if not result:
self.logger.error(f"Failed to fetch playlists for user {user_id} at offset {offset}")
break
# Get total on first request
if total is None:
total = result.get('total', 0)
self.logger.info(f"User has {total} total playlists according to API")
# Process items from this batch
items = result.get('items', [])
item_count = len(items)
all_playlists.extend(items)
self.logger.info(f"Fetched {item_count} playlists for user {user_id}, progress: {len(all_playlists)}/{total}")
# Break if we got fewer items than requested (last page)
if item_count < limit:
self.logger.info(f"Received fewer items than requested, assuming end of list")
break
# Update offset for next batch
offset += item_count
# Break if we've fetched all playlists
if offset >= total:
self.logger.info(f"Reached total {total} playlists")
break
# Break if next URL is None (no more pages)
if not result.get('next'):
self.logger.info(f"No 'next' URL in response, end of pagination")
# Check for inconsistency
if offset < total:
self.logger.warning(f"API inconsistency: no more pages but only fetched {len(all_playlists)}/{total}")
break
duration_ms = int((time.time() - start_time) * 1000)
self.logger.info(f"Completed fetching {len(all_playlists)}/{total} playlists in {duration_ms}ms")
return all_playlists
def get_track_audio_features(self, track_id):
"""
Get audio features for a specific track
Args:
track_id: Spotify ID of the track
Returns:
dict: Audio features for the track or None if not found
"""
self.logger.info(f"Getting audio features for track {track_id}")
if not self._ensure_token_valid():
return None
endpoint = f"audio-features/{track_id}"
return self._make_api_request(endpoint)
def get_tracks_audio_features(self, track_ids):
"""
Get audio features for multiple tracks in a single request
Args:
track_ids: List of Spotify track IDs (max 100)
Returns:
list: List of audio features for tracks
"""
if not track_ids:
return []
self.logger.info(f"Getting audio features for {len(track_ids)} tracks")
if not self._ensure_token_valid():
return None
# Spotify API only accepts up to 100 IDs per request
if len(track_ids) > 100:
self.logger.warning("More than 100 track IDs provided, only fetching the first 100")
track_ids = track_ids[:100]
# Convert list to comma-separated string
ids_param = ",".join(track_ids)
endpoint = "audio-features"
result = self._make_api_request(endpoint, params={"ids": ids_param})
if result and "audio_features" in result:
return result["audio_features"]
return []
+303
View File
@@ -0,0 +1,303 @@
"""
General utility functions used throughout the application.
"""
import secrets
import string
import os
import shutil
from flask import current_app
from werkzeug.utils import secure_filename
import uuid
import requests
import json
def generate_token(length=32):
"""
Generate a secure random token for authentication or validation purposes.
Args:
length (int): The length of the token to generate (default: 32)
Returns:
str: A secure random token string
"""
# Use secrets module for cryptographically strong random numbers
alphabet = string.ascii_letters + string.digits
token = ''.join(secrets.choice(alphabet) for _ in range(length))
return token
def get_user_mp3_directory(username):
"""
Get the directory path for a user's custom MP3 files
Creates the directory if it doesn't exist
Args:
username (str): Username
Returns:
str: Path to the user's MP3 directory
"""
base_dir = os.path.join('/data', 'custommp3')
user_dir = os.path.join(base_dir, secure_filename(username))
# Create directories if they don't exist
if not os.path.exists(base_dir):
os.makedirs(base_dir)
if not os.path.exists(user_dir):
os.makedirs(user_dir)
return user_dir
def save_user_mp3(file, username, mp3_type):
"""
Save a user's uploaded MP3 file
Args:
file: FileStorage object from Flask request.files
username (str): Username
mp3_type (str): Type of MP3 (intro, outro, or replay)
Returns:
str: Path to the saved file relative to data directory
"""
if not file or not allowed_file(file.filename):
return None
# Get user directory and create if needed
user_dir = get_user_mp3_directory(username)
# Generate a unique filename to avoid overwriting
original_filename = secure_filename(file.filename)
file_extension = os.path.splitext(original_filename)[1] # Should be .mp3
unique_filename = f"{mp3_type}{file_extension}"
# Full path to save the file
filepath = os.path.join(user_dir, unique_filename)
# Save the file
file.save(filepath)
# Return the relative path for database storage
return os.path.join('custommp3', secure_filename(username), unique_filename)
def allowed_file(filename):
"""
Check if the file has an allowed extension
Args:
filename (str): Name of the file
Returns:
bool: True if extension is allowed, False otherwise
"""
allowed_extensions = {'mp3'}
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
def get_mp3_path(user, mp3_type):
"""
Get the path to a user's custom MP3 file or the default if not set
Args:
user: User object from database
mp3_type (str): Type of MP3 (intro, outro, or replay)
Returns:
str: Path to the MP3 file
"""
user_setting_attr = f"{mp3_type}_mp3"
user_mp3_path = getattr(user, user_setting_attr)
if user_mp3_path and os.path.exists(os.path.join('/data', user_mp3_path)):
return os.path.join('/data', user_mp3_path)
# Fall back to the default MP3
return os.path.join(current_app.root_path, 'static', 'audio', f'{mp3_type}.mp3')
def get_available_voices(service='polly'):
"""
Get a list of available voices for the specified TTS service
Args:
service (str): TTS service ('polly', 'openai', or 'elevenlabs')
Returns:
list: List of available voice options
"""
if service == 'polly':
# Standard AWS Polly voices - subset of most natural ones
return [
{'id': 'Joanna', 'name': 'Joanna (Female, US)', 'gender': 'Female', 'language': 'en-US'},
{'id': 'Matthew', 'name': 'Matthew (Male, US)', 'gender': 'Male', 'language': 'en-US'},
{'id': 'Amy', 'name': 'Amy (Female, UK)', 'gender': 'Female', 'language': 'en-GB'},
{'id': 'Brian', 'name': 'Brian (Male, UK)', 'gender': 'Male', 'language': 'en-GB'},
{'id': 'Kendra', 'name': 'Kendra (Female, US)', 'gender': 'Female', 'language': 'en-US'},
{'id': 'Kimberly', 'name': 'Kimberly (Female, US)', 'gender': 'Female', 'language': 'en-US'},
{'id': 'Salli', 'name': 'Salli (Female, US)', 'gender': 'Female', 'language': 'en-US'},
{'id': 'Joey', 'name': 'Joey (Male, US)', 'gender': 'Male', 'language': 'en-US'},
]
elif service == 'openai':
return [
{'id': 'alloy', 'name': 'Alloy (Neutral)', 'gender': 'Neutral', 'language': 'en'},
{'id': 'echo', 'name': 'Echo (Male)', 'gender': 'Male', 'language': 'en'},
{'id': 'fable', 'name': 'Fable (Male)', 'gender': 'Male', 'language': 'en'},
{'id': 'onyx', 'name': 'Onyx (Male)', 'gender': 'Male', 'language': 'en'},
{'id': 'nova', 'name': 'Nova (Female)', 'gender': 'Female', 'language': 'en'},
{'id': 'shimmer', 'name': 'Shimmer (Female)', 'gender': 'Female', 'language': 'en'},
]
elif service == 'elevenlabs':
# Check if we have a valid API key
api_key = current_app.config.get('ELEVENLABS_API_KEY')
if not api_key:
return []
try:
# Call the ElevenLabs API to get available voices
headers = {
"xi-api-key": api_key,
"Content-Type": "application/json"
}
response = requests.get("https://api.elevenlabs.io/v1/voices", headers=headers)
if response.status_code == 200:
voices_data = response.json()
voices = []
for voice in voices_data.get('voices', []):
voice_info = {
'id': voice.get('voice_id'),
'name': voice.get('name', 'Unknown'),
'gender': voice.get('labels', {}).get('gender', 'Unknown'),
'language': 'en' # Default to English
}
voices.append(voice_info)
return voices
else:
current_app.logger.error(f"Error fetching ElevenLabs voices: {response.status_code} - {response.text}")
return []
except Exception as e:
current_app.logger.error(f"Exception fetching ElevenLabs voices: {str(e)}")
return []
# Default fallback
return []
def generate_tts_mp3(text, username, mp3_type, service='polly', voice=None, model=None, stability=None, similarity=None):
"""
Generate a text-to-speech MP3 file
Args:
text (str): The text to convert to speech
username (str): Username
mp3_type (str): Type of MP3 (intro, outro, or replay)
service (str): TTS service to use ('polly', 'openai', or 'elevenlabs')
voice (str): Voice ID to use (defaults to service-specific default if None)
model (str): Model to use for OpenAI or ElevenLabs (defaults to service-specific default if None)
stability (float): Voice stability parameter for ElevenLabs (0.0-1.0)
similarity (float): Voice similarity parameter for ElevenLabs (0.0-1.0)
Returns:
str: Path to the generated file relative to data directory
"""
try:
user_dir = get_user_mp3_directory(username)
output_filename = f"{mp3_type}.mp3"
output_path = os.path.join(user_dir, output_filename)
if service == 'polly':
# AWS Polly implementation
import boto3
polly_client = boto3.client('polly',
region_name=current_app.config.get('AWS_REGION', 'us-east-1'),
aws_access_key_id=current_app.config.get('AWS_ACCESS_KEY_ID'),
aws_secret_access_key=current_app.config.get('AWS_SECRET_ACCESS_KEY')
)
# Use provided voice or default
voice_id = voice or current_app.config.get('AWS_POLLY_VOICE', 'Joanna')
# Use neural engine if available
engine = 'neural' if voice_id in ['Joanna', 'Matthew', 'Amy', 'Emma', 'Brian', 'Kendra'] else 'standard'
response = polly_client.synthesize_speech(
Text=text,
OutputFormat='mp3',
VoiceId=voice_id,
Engine=engine
)
# Write the audio stream to a file
if "AudioStream" in response:
with open(output_path, 'wb') as file:
file.write(response["AudioStream"].read())
current_app.logger.info(f"Generated AWS Polly TTS with voice {voice_id} for {username}/{mp3_type}")
elif service == 'openai':
# OpenAI TTS implementation
import openai
openai.api_key = current_app.config.get('OPENAI_API_KEY')
openai.base_url = current_app.config.get('OPENAI_URL', 'https://api.openai.com/v1')
# Use provided voice or default
voice_id = voice or 'alloy'
# Use provided model or default
tts_model = model or 'tts-1' # Options: tts-1, tts-1-hd
response = openai.audio.speech.create(
model=tts_model,
voice=voice_id,
input=text
)
response.stream_to_file(output_path)
current_app.logger.info(f"Generated OpenAI TTS with voice {voice_id} for {username}/{mp3_type}")
elif service == 'elevenlabs':
# ElevenLabs implementation
api_key = current_app.config.get('ELEVENLABS_API_KEY')
if not api_key:
current_app.logger.error("ElevenLabs API key not configured")
return None
# Use provided voice or default
voice_id = voice or "21m00Tcm4TlvDq8ikWAM" # Default to "Rachel" voice
# Set default values for stability and similarity if not provided
stability_value = stability if stability is not None else 0.5
similarity_value = similarity if similarity is not None else 0.75
headers = {
"xi-api-key": api_key,
"Content-Type": "application/json"
}
payload = {
"text": text,
"model_id": model or "eleven_monolingual_v1",
"voice_settings": {
"stability": stability_value,
"similarity_boost": similarity_value
}
}
response = requests.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
headers=headers,
data=json.dumps(payload)
)
if response.status_code == 200:
with open(output_path, 'wb') as file:
file.write(response.content)
current_app.logger.info(f"Generated ElevenLabs TTS with voice {voice_id} for {username}/{mp3_type}")
else:
current_app.logger.error(f"ElevenLabs API error: {response.status_code} - {response.text}")
return None
# Return the relative path for database storage
return os.path.join('custommp3', secure_filename(username), output_filename)
except Exception as e:
current_app.logger.error(f"Error generating TTS MP3: {str(e)}")
return None