Initial clean commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# This file makes the helpers directory a proper Python package
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user