man, markets are hard

This commit is contained in:
Xoconoch
2025-06-04 22:58:42 -06:00
parent 32040f77c6
commit 0277980a5e
15 changed files with 1496 additions and 1166 deletions

View File

@@ -2,4 +2,4 @@ waitress==3.0.2
celery==5.5.3 celery==5.5.3
Flask==3.1.1 Flask==3.1.1
flask_cors==6.0.0 flask_cors==6.0.0
deezspot-spotizerr==1.5.2 deezspot-spotizerr==1.7.0

View File

@@ -4,45 +4,102 @@ from routes.utils.credentials import (
list_credentials, list_credentials,
create_credential, create_credential,
delete_credential, delete_credential,
edit_credential edit_credential,
# Import new utility functions for global Spotify API creds
_get_global_spotify_api_creds,
save_global_spotify_api_creds
) )
from pathlib import Path from pathlib import Path
import logging
logger = logging.getLogger(__name__)
credentials_bp = Blueprint('credentials', __name__) credentials_bp = Blueprint('credentials', __name__)
@credentials_bp.route('/spotify_api_config', methods=['GET', 'PUT'])
def handle_spotify_api_config():
"""Handles GET and PUT requests for the global Spotify API client_id and client_secret."""
try:
if request.method == 'GET':
client_id, client_secret = _get_global_spotify_api_creds()
if client_id is not None and client_secret is not None:
return jsonify({"client_id": client_id, "client_secret": client_secret}), 200
else:
# If search.json exists but is empty/incomplete, or doesn't exist
return jsonify({
"warning": "Global Spotify API credentials are not fully configured or file is missing.",
"client_id": client_id or "",
"client_secret": client_secret or ""
}), 200
elif request.method == 'PUT':
data = request.get_json()
if not data or 'client_id' not in data or 'client_secret' not in data:
return jsonify({"error": "Request body must contain 'client_id' and 'client_secret'"}), 400
client_id = data['client_id']
client_secret = data['client_secret']
if not isinstance(client_id, str) or not isinstance(client_secret, str):
return jsonify({"error": "'client_id' and 'client_secret' must be strings"}), 400
if save_global_spotify_api_creds(client_id, client_secret):
return jsonify({"message": "Global Spotify API credentials updated successfully."}), 200
else:
return jsonify({"error": "Failed to save global Spotify API credentials."}), 500
except Exception as e:
logger.error(f"Error in /spotify_api_config: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route('/<service>', methods=['GET']) @credentials_bp.route('/<service>', methods=['GET'])
def handle_list_credentials(service): def handle_list_credentials(service):
try: try:
if service not in ['spotify', 'deezer']:
return jsonify({"error": "Invalid service. Must be 'spotify' or 'deezer'"}), 400
return jsonify(list_credentials(service)) return jsonify(list_credentials(service))
except ValueError as e: except ValueError as e: # Should not happen with service check above
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"Error listing credentials for {service}: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route('/<service>/<name>', methods=['GET', 'POST', 'PUT', 'DELETE']) @credentials_bp.route('/<service>/<name>', methods=['GET', 'POST', 'PUT', 'DELETE'])
def handle_single_credential(service, name): def handle_single_credential(service, name):
try: try:
# Get credential type from query parameters, default to 'credentials' if service not in ['spotify', 'deezer']:
cred_type = request.args.get('type', 'credentials') return jsonify({"error": "Invalid service. Must be 'spotify' or 'deezer'"}), 400
if cred_type not in ['credentials', 'search']:
return jsonify({"error": "Invalid credential type. Must be 'credentials' or 'search'"}), 400 # cred_type logic is removed for Spotify as API keys are global.
# For Deezer, it's always 'credentials' type implicitly.
if request.method == 'GET': if request.method == 'GET':
return jsonify(get_credential(service, name, cred_type)) # get_credential for Spotify now only returns region and blob_file_path
return jsonify(get_credential(service, name))
elif request.method == 'POST': elif request.method == 'POST':
data = request.get_json() data = request.get_json()
create_credential(service, name, data, cred_type) if not data:
return jsonify({"message": f"{cred_type.capitalize()} credential created successfully"}), 201 return jsonify({"error": "Request body cannot be empty."}), 400
# create_credential for Spotify now expects 'region' and 'blob_content'
# For Deezer, it expects 'arl' and 'region'
# Validation is handled within create_credential utility function
result = create_credential(service, name, data)
return jsonify({"message": f"Credential for '{name}' ({service}) created successfully.", "details": result}), 201
elif request.method == 'PUT': elif request.method == 'PUT':
data = request.get_json() data = request.get_json()
edit_credential(service, name, data, cred_type) if not data:
return jsonify({"message": f"{cred_type.capitalize()} credential updated successfully"}) return jsonify({"error": "Request body cannot be empty."}), 400
# edit_credential for Spotify now handles updates to 'region', 'blob_content'
# For Deezer, 'arl', 'region'
result = edit_credential(service, name, data)
return jsonify({"message": f"Credential for '{name}' ({service}) updated successfully.", "details": result})
elif request.method == 'DELETE': elif request.method == 'DELETE':
delete_credential(service, name, cred_type if cred_type != 'credentials' else None) # delete_credential for Spotify also handles deleting the blob directory
return jsonify({"message": f"{cred_type.capitalize()} credential deleted successfully"}) result = delete_credential(service, name)
return jsonify({"message": f"Credential for '{name}' ({service}) deleted successfully.", "details": result})
except (ValueError, FileNotFoundError, FileExistsError) as e: except (ValueError, FileNotFoundError, FileExistsError) as e:
status_code = 400 status_code = 400
@@ -50,66 +107,76 @@ def handle_single_credential(service, name):
status_code = 404 status_code = 404
elif isinstance(e, FileExistsError): elif isinstance(e, FileExistsError):
status_code = 409 status_code = 409
logger.warning(f"Client error in /<{service}>/<{name}>: {str(e)}")
return jsonify({"error": str(e)}), status_code return jsonify({"error": str(e)}), status_code
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"Server error in /<{service}>/<{name}>: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route('/search/<service>/<name>', methods=['GET', 'POST', 'PUT']) # The '/search/<service>/<name>' route is now obsolete for Spotify and has been removed.
def handle_search_credential(service, name):
"""Special route specifically for search credentials"""
try:
if request.method == 'GET':
return jsonify(get_credential(service, name, 'search'))
elif request.method in ['POST', 'PUT']:
data = request.get_json()
# Validate required fields
if not data.get('client_id') or not data.get('client_secret'):
return jsonify({"error": "Both client_id and client_secret are required"}), 400
# For POST, first check if the credentials directory exists
if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')):
return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404
# Create or update search credentials
method_func = create_credential if request.method == 'POST' else edit_credential
method_func(service, name, data, 'search')
action = "created" if request.method == 'POST' else "updated"
return jsonify({"message": f"Search credentials {action} successfully"})
except (ValueError, FileNotFoundError) as e:
status_code = 400 if isinstance(e, ValueError) else 404
return jsonify({"error": str(e)}), status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
@credentials_bp.route('/all/<service>', methods=['GET']) @credentials_bp.route('/all/<service>', methods=['GET'])
def handle_all_credentials(service): def handle_all_credentials(service):
"""Lists all credentials for a given service. For Spotify, API keys are global and not listed per account."""
try: try:
credentials = [] if service not in ['spotify', 'deezer']:
for name in list_credentials(service): return jsonify({"error": "Invalid service. Must be 'spotify' or 'deezer'"}), 400
# For each credential, get both the main credentials and search credentials if they exist
cred_data = {
"name": name,
"credentials": get_credential(service, name, 'credentials')
}
# For Spotify accounts, also try to get search credentials credentials_list = []
if service == 'spotify': account_names = list_credentials(service) # This lists names from DB
for name in account_names:
try: try:
search_creds = get_credential(service, name, 'search') # get_credential for Spotify returns region and blob_file_path.
if search_creds: # Only add if not empty # For Deezer, it returns arl and region.
cred_data["search"] = search_creds account_data = get_credential(service, name)
except: # We don't add global Spotify API keys here as they are separate
pass # Ignore errors if search.json doesn't exist credentials_list.append({"name": name, "details": account_data})
except FileNotFoundError:
logger.warning(f"Credential name '{name}' listed for service '{service}' but not found by get_credential. Skipping.")
except Exception as e_inner:
logger.error(f"Error fetching details for credential '{name}' ({service}): {e_inner}", exc_info=True)
credentials_list.append({"name": name, "error": f"Could not retrieve details: {str(e_inner)}"})
credentials.append(cred_data) return jsonify(credentials_list)
return jsonify(credentials)
except (ValueError, FileNotFoundError) as e:
status_code = 400 if isinstance(e, ValueError) else 404
return jsonify({"error": str(e)}), status_code
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"Error in /all/{service}: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route('/markets', methods=['GET'])
def handle_markets():
"""
Returns a list of unique market regions for Deezer and Spotify accounts.
"""
try:
deezer_regions = set()
spotify_regions = set()
# Process Deezer accounts
deezer_account_names = list_credentials('deezer')
for name in deezer_account_names:
try:
account_data = get_credential('deezer', name)
if account_data and 'region' in account_data and account_data['region']:
deezer_regions.add(account_data['region'])
except Exception as e:
logger.warning(f"Could not retrieve region for deezer account {name}: {e}")
# Process Spotify accounts
spotify_account_names = list_credentials('spotify')
for name in spotify_account_names:
try:
account_data = get_credential('spotify', name)
if account_data and 'region' in account_data and account_data['region']:
spotify_regions.add(account_data['region'])
except Exception as e:
logger.warning(f"Could not retrieve region for spotify account {name}: {e}")
return jsonify({
"deezer": sorted(list(deezer_regions)),
"spotify": sorted(list(spotify_regions))
}), 200
except Exception as e:
logger.error(f"Error in /markets: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500

View File

@@ -4,6 +4,8 @@ import traceback
from deezspot.spotloader import SpoLogin from deezspot.spotloader import SpoLogin
from deezspot.deezloader import DeeLogin from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
from routes.utils.celery_config import get_config_params
def download_album( def download_album(
url, url,
@@ -28,88 +30,49 @@ def download_album(
is_spotify_url = 'open.spotify.com' in url.lower() is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower() is_deezer_url = 'deezer.com' in url.lower()
# Determine service exclusively from URL service = ''
if is_spotify_url: if is_spotify_url:
service = 'spotify' service = 'spotify'
elif is_deezer_url: elif is_deezer_url:
service = 'deezer' service = 'deezer'
else: else:
# If URL can't be detected, raise an error
error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com" error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
print(f"ERROR: {error_msg}") print(f"ERROR: {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)
print(f"DEBUG: album.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}")
print(f"DEBUG: album.py - Service determined from URL: {service}") print(f"DEBUG: album.py - Service determined from URL: {service}")
print(f"DEBUG: album.py - Credentials: main={main}, fallback={fallback}") print(f"DEBUG: album.py - Credentials provided: main_account_name='{main}', fallback_account_name='{fallback}'")
# Load Spotify client credentials if available # Get global Spotify API credentials
spotify_client_id = None global_spotify_client_id, global_spotify_client_secret = _get_global_spotify_api_creds()
spotify_client_secret = None if not global_spotify_client_id or not global_spotify_client_secret:
warning_msg = "WARN: album.py - Global Spotify client_id/secret not found in search.json. Spotify operations will likely fail."
print(warning_msg)
# Smartly determine where to look for Spotify search credentials
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
search_creds = json.load(f)
spotify_client_id = search_creds.get('client_id')
spotify_client_secret = search_creds.get('client_secret')
print(f"DEBUG: Loaded Spotify client credentials successfully")
except Exception as e:
print(f"Error loading Spotify search credentials: {e}")
# For Spotify URLs: check if fallback is enabled, if so use the fallback logic,
# otherwise download directly from Spotify
if service == 'spotify': if service == 'spotify':
if fallback: if fallback: # Fallback is a Deezer account name for a Spotify URL
if quality is None: if quality is None: quality = 'FLAC' # Deezer quality for first attempt
quality = 'FLAC' if fall_quality is None: fall_quality = 'HIGH' # Spotify quality for fallback (if Deezer fails)
if fall_quality is None:
fall_quality = 'HIGH'
# First attempt: use DeeLogin's download_albumspo with the 'main' (Deezer credentials)
deezer_error = None deezer_error = None
try: try:
# Load Deezer credentials from 'main' under deezer directory # Attempt 1: Deezer via download_albumspo (using 'fallback' as Deezer account name)
deezer_creds_dir = os.path.join('./data/creds/deezer', main) print(f"DEBUG: album.py - Spotify URL. Attempt 1: Deezer (account: {fallback})")
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) deezer_fallback_creds = get_credential('deezer', fallback)
arl = deezer_fallback_creds.get('arl')
if not arl:
raise ValueError(f"ARL not found for Deezer account '{fallback}'.")
# DEBUG: Print Deezer credential paths being used
print(f"DEBUG: Looking for Deezer credentials at:")
print(f"DEBUG: deezer_creds_dir = {deezer_creds_dir}")
print(f"DEBUG: deezer_creds_path = {deezer_creds_path}")
print(f"DEBUG: Directory exists = {os.path.exists(deezer_creds_dir)}")
print(f"DEBUG: Credentials file exists = {os.path.exists(deezer_creds_path)}")
# List available directories to compare
print(f"DEBUG: Available Deezer credential directories:")
for dir_name in os.listdir('./data/creds/deezer'):
print(f"DEBUG: ./data/creds/deezer/{dir_name}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
# Initialize DeeLogin with Deezer credentials and Spotify client credentials
dl = DeeLogin( dl = DeeLogin(
arl=deezer_creds.get('arl', ''), arl=arl,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting album download using Deezer credentials (download_albumspo)")
# Download using download_albumspo; pass real_time_dl accordingly and the custom formatting
dl.download_albumspo( dl.download_albumspo(
link_album=url, link_album=url, # Spotify URL
output_dir="./downloads", output_dir="./downloads",
quality_download=quality, quality_download=quality, # Deezer quality
recursive_quality=True, recursive_quality=True,
recursive_download=False, recursive_download=False,
not_interface=False, not_interface=False,
@@ -124,35 +87,33 @@ def download_album(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Album download completed successfully using Deezer credentials") print(f"DEBUG: album.py - Album download via Deezer (account: {fallback}) successful for Spotify URL.")
except Exception as e: except Exception as e:
deezer_error = e deezer_error = e
# Immediately report the Deezer error print(f"ERROR: album.py - Deezer attempt (account: {fallback}) for Spotify URL failed: {e}")
print(f"ERROR: Deezer album download attempt failed: {e}")
traceback.print_exc() traceback.print_exc()
print("Attempting Spotify fallback...") print(f"DEBUG: album.py - Attempting Spotify direct download (account: {main} for blob)...")
# Load fallback Spotify credentials and attempt download # Attempt 2: Spotify direct via download_album (using 'main' as Spotify account for blob)
try: try:
spo_creds_dir = os.path.join('./data/creds/spotify', fallback) if not global_spotify_client_id or not global_spotify_client_secret:
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) raise ValueError("Global Spotify API credentials (client_id/secret) not configured for Spotify download.")
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") spotify_main_creds = get_credential('spotify', main) # For blob path
print(f"DEBUG: Fallback credentials exist: {os.path.exists(spo_creds_path)}") blob_file_path = spotify_main_creds.get('blob_file_path')
if not Path(blob_file_path).exists():
# We've already loaded the Spotify client credentials above based on fallback raise FileNotFoundError(f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'")
spo = SpoLogin( spo = SpoLogin(
credentials_path=spo_creds_path, credentials_path=blob_file_path,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting album download using Spotify fallback credentials")
spo.download_album( spo.download_album(
link_album=url, link_album=url, # Spotify URL
output_dir="./downloads", output_dir="./downloads",
quality_download=fall_quality, quality_download=fall_quality, # Spotify quality
recursive_quality=True, recursive_quality=True,
recursive_download=False, recursive_download=False,
not_interface=False, not_interface=False,
@@ -168,30 +129,31 @@ def download_album(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Album download completed successfully using Spotify fallback") print(f"DEBUG: album.py - Spotify direct download (account: {main} for blob) successful.")
except Exception as e2: except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed print(f"ERROR: album.py - Spotify direct download (account: {main} for blob) also failed: {e2}")
print(f"ERROR: Spotify fallback also failed: {e2}")
raise RuntimeError( raise RuntimeError(
f"Both main (Deezer) and fallback (Spotify) attempts failed. " f"Both Deezer attempt (account: {fallback}) and Spotify direct (account: {main} for blob) failed. "
f"Deezer error: {deezer_error}, Spotify error: {e2}" f"Deezer error: {deezer_error}, Spotify error: {e2}"
) from e2 ) from e2
else: else:
# Original behavior: use Spotify main # Spotify URL, no fallback. Direct Spotify download using 'main' (Spotify account for blob)
if quality is None: if quality is None: quality = 'HIGH' # Default Spotify quality
quality = 'HIGH' print(f"DEBUG: album.py - Spotify URL, no fallback. Direct download with Spotify account (for blob): {main}")
creds_dir = os.path.join('./data/creds/spotify', main) if not global_spotify_client_id or not global_spotify_client_secret:
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) raise ValueError("Global Spotify API credentials (client_id/secret) not configured for Spotify download.")
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") spotify_main_creds = get_credential('spotify', main) # For blob path
blob_file_path = spotify_main_creds.get('blob_file_path')
if not Path(blob_file_path).exists():
raise FileNotFoundError(f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'")
spo = SpoLogin( spo = SpoLogin(
credentials_path=credentials_path, credentials_path=blob_file_path,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting album download using Spotify main credentials")
spo.download_album( spo.download_album(
link_album=url, link_album=url,
output_dir="./downloads", output_dir="./downloads",
@@ -211,27 +173,24 @@ def download_album(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Album download completed successfully using Spotify main") print(f"DEBUG: album.py - Direct Spotify download (account: {main} for blob) successful.")
# For Deezer URLs: download directly from Deezer
elif service == 'deezer': elif service == 'deezer':
if quality is None: # Deezer URL. Direct Deezer download using 'main' (Deezer account name for ARL)
quality = 'FLAC' if quality is None: quality = 'FLAC' # Default Deezer quality
# Existing code remains the same, ignoring fallback print(f"DEBUG: album.py - Deezer URL. Direct download with Deezer account: {main}")
creds_dir = os.path.join('./data/creds/deezer', main) deezer_main_creds = get_credential('deezer', main) # For ARL
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) arl = deezer_main_creds.get('arl')
print(f"DEBUG: Using Deezer credentials from: {creds_path}") if not arl:
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") raise ValueError(f"ARL not found for Deezer account '{main}'.")
with open(creds_path, 'r') as f:
creds = json.load(f)
dl = DeeLogin( dl = DeeLogin(
arl=creds.get('arl', ''), arl=arl, # Account specific ARL
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id, # Global Spotify keys
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret, # Global Spotify keys
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting album download using Deezer credentials (download_albumdee)") dl.download_albumdee( # Deezer URL, download via Deezer
dl.download_albumdee(
link_album=url, link_album=url,
output_dir="./downloads", output_dir="./downloads",
quality_download=quality, quality_download=quality,
@@ -248,9 +207,10 @@ def download_album(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Album download completed successfully using Deezer direct") print(f"DEBUG: album.py - Direct Deezer download (account: {main}) successful.")
else: else:
raise ValueError(f"Unsupported service: {service}") # Should be caught by initial service check, but as a safeguard
raise ValueError(f"Unsupported service determined: {service}")
except Exception as e: except Exception as e:
print(f"ERROR: Album download failed with exception: {e}") print(f"ERROR: Album download failed with exception: {e}")
traceback.print_exc() traceback.print_exc()

View File

@@ -6,6 +6,7 @@ import logging
from flask import Blueprint, Response, request, url_for from flask import Blueprint, Response, request, url_for
from routes.utils.celery_queue_manager import download_queue_manager, get_config_params from routes.utils.celery_queue_manager import download_queue_manager, get_config_params
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_spotify_info
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
from routes.utils.celery_tasks import get_last_task_status, ProgressState from routes.utils.celery_tasks import get_last_task_status, ProgressState
from deezspot.easy_spoty import Spo from deezspot.easy_spoty import Spo
@@ -19,36 +20,42 @@ def log_json(message_dict):
print(json.dumps(message_dict)) print(json.dumps(message_dict))
def get_artist_discography(url, main, album_type='album,single,compilation,appears_on', progress_callback=None): def get_artist_discography(url, main_spotify_account_name, album_type='album,single,compilation,appears_on', progress_callback=None):
""" """
Validate the URL, extract the artist ID, and retrieve the discography. Validate the URL, extract the artist ID, and retrieve the discography.
Uses global Spotify API client_id/secret for Spo initialization.
Args:
url (str): Spotify artist URL.
main_spotify_account_name (str): Name of the Spotify account (for context/logging, not API keys for Spo.__init__).
album_type (str): Types of albums to fetch.
progress_callback: Optional callback for progress.
""" """
if not url: if not url:
log_json({"status": "error", "message": "No artist URL provided."}) log_json({"status": "error", "message": "No artist URL provided."})
raise ValueError("No artist URL provided.") raise ValueError("No artist URL provided.")
# This will raise an exception if the link is invalid. link_is_valid(link=url) # This will raise an exception if the link is invalid.
link_is_valid(link=url)
# Initialize Spotify API with credentials client_id, client_secret = _get_global_spotify_api_creds()
spotify_client_id = None
spotify_client_secret = None
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
search_creds = json.load(f)
spotify_client_id = search_creds.get('client_id')
spotify_client_secret = search_creds.get('client_secret')
except Exception as e:
log_json({"status": "error", "message": f"Error loading Spotify search credentials: {e}"})
raise
# Initialize the Spotify client with credentials if not client_id or not client_secret:
if spotify_client_id and spotify_client_secret: log_json({"status": "error", "message": "Global Spotify API client_id or client_secret not configured."})
Spo.__init__(spotify_client_id, spotify_client_secret) raise ValueError("Global Spotify API credentials are not configured.")
if not main_spotify_account_name:
# This is a warning now, as API keys are global.
logger.warning("main_spotify_account_name not provided for get_artist_discography context. Using global API keys.")
else: else:
raise ValueError("No Spotify credentials found") # Check if account exists for context, good for consistency
try:
get_credential('spotify', main_spotify_account_name)
logger.debug(f"Spotify account context '{main_spotify_account_name}' exists for get_artist_discography.")
except FileNotFoundError:
logger.warning(f"Spotify account '{main_spotify_account_name}' provided for discography context not found.")
except Exception as e:
logger.warning(f"Error checking Spotify account '{main_spotify_account_name}' for discography context: {e}")
Spo.__init__(client_id, client_secret) # Initialize with global API keys
try: try:
artist_id = get_ids(url) artist_id = get_ids(url)
@@ -58,6 +65,11 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea
raise ValueError(msg) raise ValueError(msg)
try: try:
# The progress_callback is not a standard param for Spo.get_artist
# If Spo.get_artist is meant to be Spo.get_artist_discography, that would take limit/offset
# Assuming it's Spo.get_artist which takes artist_id and album_type.
# If progress_callback was for a different Spo method, this needs review.
# For now, removing progress_callback from this specific call as Spo.get_artist doesn't use it.
discography = Spo.get_artist(artist_id, album_type=album_type) discography = Spo.get_artist(artist_id, album_type=album_type)
return discography return discography
except Exception as fetch_error: except Exception as fetch_error:

View File

@@ -24,6 +24,8 @@ from .celery_tasks import (
from .celery_config import get_config_params from .celery_config import get_config_params
# Import history manager # Import history manager
from .history_manager import init_history_db from .history_manager import init_history_db
# Import credentials manager for DB init
from .credentials import init_credentials_db
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -174,6 +176,8 @@ class CeleryManager:
# Initialize history database # Initialize history database
init_history_db() init_history_db()
# Initialize credentials database
init_credentials_db()
# Clean up stale tasks BEFORE starting/restarting workers # Clean up stale tasks BEFORE starting/restarting workers
self._cleanup_stale_tasks() self._cleanup_stale_tasks()

View File

@@ -1,447 +1,470 @@
import json import json
from pathlib import Path from pathlib import Path
import shutil import shutil
from deezspot.spotloader import SpoLogin import sqlite3
from deezspot.deezloader import DeeLogin
import traceback # For logging detailed error messages import traceback # For logging detailed error messages
import time # For retry delays import time # For retry delays
import logging
def _get_spotify_search_creds(creds_dir: Path): # Assuming deezspot is in a location findable by Python's import system
"""Helper to load client_id and client_secret from search.json for a Spotify account.""" # from deezspot.spotloader import SpoLogin # Used in validation
search_file = creds_dir / 'search.json' # from deezspot.deezloader import DeeLogin # Used in validation
if search_file.exists(): # For now, as per original, validation calls these directly.
logger = logging.getLogger(__name__) # Assuming logger is configured elsewhere
# --- New Database and Path Definitions ---
CREDS_BASE_DIR = Path('./data/creds')
ACCOUNTS_DB_PATH = CREDS_BASE_DIR / 'accounts.db'
BLOBS_DIR = CREDS_BASE_DIR / 'blobs'
GLOBAL_SEARCH_JSON_PATH = CREDS_BASE_DIR / 'search.json' # Global Spotify API creds
EXPECTED_SPOTIFY_TABLE_COLUMNS = {
"name": "TEXT PRIMARY KEY",
# client_id and client_secret are now global
"region": "TEXT", # ISO 3166-1 alpha-2
"created_at": "REAL",
"updated_at": "REAL"
}
EXPECTED_DEEZER_TABLE_COLUMNS = {
"name": "TEXT PRIMARY KEY",
"arl": "TEXT",
"region": "TEXT", # ISO 3166-1 alpha-2
"created_at": "REAL",
"updated_at": "REAL"
}
def _get_db_connection():
ACCOUNTS_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
BLOBS_DIR.mkdir(parents=True, exist_ok=True) # Ensure blobs directory also exists
conn = sqlite3.connect(ACCOUNTS_DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
return conn
def _ensure_table_schema(cursor: sqlite3.Cursor, table_name: str, expected_columns: dict):
"""Ensures the given table has all expected columns, adding them if necessary."""
try: try:
with open(search_file, 'r') as f: cursor.execute(f"PRAGMA table_info({table_name})")
search_data = json.load(f) existing_columns_info = cursor.fetchall()
return search_data.get('client_id'), search_data.get('client_secret') existing_column_names = {col[1] for col in existing_columns_info}
except Exception:
# Log error if search.json is malformed or unreadable
print(f"Warning: Could not read Spotify search credentials from {search_file}")
traceback.print_exc()
return None, None
def _validate_with_retry(service_name, account_name, creds_dir_path, cred_file_path, data_for_validation, is_spotify): added_columns = False
for col_name, col_type in expected_columns.items():
if col_name not in existing_column_names:
# Basic protection against altering PK after creation if table is not empty
if 'PRIMARY KEY' in col_type.upper() and existing_columns_info:
logger.warning(
f"Column '{col_name}' is part of PRIMARY KEY for table '{table_name}' "
f"and was expected to be created by CREATE TABLE. Skipping explicit ADD COLUMN."
)
continue
col_type_for_add = col_type.replace(' PRIMARY KEY', '').strip()
try:
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_for_add}")
logger.info(f"Added missing column '{col_name} {col_type_for_add}' to table '{table_name}'.")
added_columns = True
except sqlite3.OperationalError as alter_e:
logger.warning(
f"Could not add column '{col_name}' to table '{table_name}': {alter_e}. "
f"It might already exist with a different definition or there's another schema mismatch."
)
return added_columns
except sqlite3.Error as e:
logger.error(f"Error ensuring schema for table '{table_name}': {e}", exc_info=True)
return False
def init_credentials_db():
"""Initializes the accounts.db and its tables if they don't exist."""
try:
with _get_db_connection() as conn:
cursor = conn.cursor()
cursor.row_factory = sqlite3.Row # Apply row_factory here as well for consistency
# Spotify Table
cursor.execute("""
CREATE TABLE IF NOT EXISTS spotify (
name TEXT PRIMARY KEY,
region TEXT,
created_at REAL,
updated_at REAL
)
""")
if _ensure_table_schema(cursor, "spotify", EXPECTED_SPOTIFY_TABLE_COLUMNS):
conn.commit()
# Deezer Table
cursor.execute("""
CREATE TABLE IF NOT EXISTS deezer (
name TEXT PRIMARY KEY,
arl TEXT,
region TEXT,
created_at REAL,
updated_at REAL
)
""")
if _ensure_table_schema(cursor, "deezer", EXPECTED_DEEZER_TABLE_COLUMNS):
conn.commit()
# Ensure global search.json exists, create if not
if not GLOBAL_SEARCH_JSON_PATH.exists():
logger.info(f"Global Spotify search credential file not found at {GLOBAL_SEARCH_JSON_PATH}. Creating empty file.")
with open(GLOBAL_SEARCH_JSON_PATH, 'w') as f_search:
json.dump({"client_id": "", "client_secret": ""}, f_search, indent=4)
conn.commit()
logger.info(f"Credentials database initialized/schema checked at {ACCOUNTS_DB_PATH}")
except sqlite3.Error as e:
logger.error(f"Error initializing credentials database: {e}", exc_info=True)
raise
def _get_global_spotify_api_creds():
"""Loads client_id and client_secret from the global search.json."""
if GLOBAL_SEARCH_JSON_PATH.exists():
try:
with open(GLOBAL_SEARCH_JSON_PATH, 'r') as f:
search_data = json.load(f)
client_id = search_data.get('client_id')
client_secret = search_data.get('client_secret')
if client_id and client_secret:
return client_id, client_secret
else:
logger.warning(f"Global Spotify API credentials in {GLOBAL_SEARCH_JSON_PATH} are incomplete.")
except Exception as e:
logger.error(f"Error reading global Spotify API credentials from {GLOBAL_SEARCH_JSON_PATH}: {e}", exc_info=True)
else:
logger.warning(f"Global Spotify API credential file {GLOBAL_SEARCH_JSON_PATH} not found.")
return None, None # Return None if file doesn't exist or creds are incomplete/invalid
def save_global_spotify_api_creds(client_id: str, client_secret: str):
"""Saves client_id and client_secret to the global search.json."""
try:
GLOBAL_SEARCH_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(GLOBAL_SEARCH_JSON_PATH, 'w') as f:
json.dump({"client_id": client_id, "client_secret": client_secret}, f, indent=4)
logger.info(f"Global Spotify API credentials saved to {GLOBAL_SEARCH_JSON_PATH}")
return True
except Exception as e:
logger.error(f"Error saving global Spotify API credentials to {GLOBAL_SEARCH_JSON_PATH}: {e}", exc_info=True)
return False
def _validate_with_retry(service_name, account_name, validation_data):
""" """
Attempts to validate credentials with retries for connection errors. Attempts to validate credentials with retries for connection errors.
- For Spotify, cred_file_path is used. validation_data (dict): For Spotify, expects {'client_id': ..., 'client_secret': ..., 'blob_file_path': ...}
- For Deezer, data_for_validation (which contains the 'arl' key) is used. For Deezer, expects {'arl': ...}
Returns True if validated, raises ValueError if not. Returns True if validated, raises ValueError if not.
""" """
max_retries = 5 # Deezspot imports need to be available. Assuming they are.
from deezspot.spotloader import SpoLogin
from deezspot.deezloader import DeeLogin
max_retries = 3 # Reduced for brevity, was 5
last_exception = None last_exception = None
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
if is_spotify: if service_name == 'spotify':
client_id, client_secret = _get_spotify_search_creds(creds_dir_path) # For Spotify, validation uses the account's blob and GLOBAL API creds
SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret) global_client_id, global_client_secret = _get_global_spotify_api_creds()
if not global_client_id or not global_client_secret:
raise ValueError("Global Spotify API client_id or client_secret not configured for validation.")
blob_file_path = validation_data.get('blob_file_path')
if not blob_file_path or not Path(blob_file_path).exists():
raise ValueError(f"Spotify blob file missing for validation of account {account_name}")
SpoLogin(credentials_path=str(blob_file_path), spotify_client_id=global_client_id, spotify_client_secret=global_client_secret)
else: # Deezer else: # Deezer
arl = data_for_validation.get('arl') arl = validation_data.get('arl')
if not arl: if not arl:
# This should be caught by prior checks, but as a safeguard:
raise ValueError("Missing 'arl' for Deezer validation.") raise ValueError("Missing 'arl' for Deezer validation.")
DeeLogin(arl=arl) DeeLogin(arl=arl)
print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).") logger.info(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).")
return True # Validation successful return True
except Exception as e: except Exception as e:
last_exception = e last_exception = e
error_str = str(e).lower() error_str = str(e).lower()
# More comprehensive check for connection-related errors
is_connection_error = ( is_connection_error = (
"connection refused" in error_str or "connection refused" in error_str or "connection error" in error_str or
"connection error" in error_str or "timeout" in error_str or "temporary failure in name resolution" in error_str or
"timeout" in error_str or "dns lookup failed" in error_str or "network is unreachable" in error_str or
"temporary failure in name resolution" in error_str or "ssl handshake failed" in error_str or "connection reset by peer" in error_str
"dns lookup failed" in error_str or
"network is unreachable" in error_str or
"ssl handshake failed" in error_str or # Can be network-related
"connection reset by peer" in error_str
) )
if is_connection_error and attempt < max_retries - 1: if is_connection_error and attempt < max_retries - 1:
retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s) retry_delay = 2 + attempt
print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1}/{max_retries} due to connection issue: {e}. Retrying in {retry_delay}s...") logger.warning(f"Validation for {account_name} ({service_name}) failed (attempt {attempt + 1}) due to connection issue: {e}. Retrying in {retry_delay}s...")
time.sleep(retry_delay) time.sleep(retry_delay)
continue # Go to next retry attempt continue
else: else:
# Not a connection error, or it's the last retry for a connection error logger.error(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} (non-retryable or max retries).")
print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} with non-retryable error or max retries reached for connection error.") break
break # Exit retry loop
# If loop finished without returning True, validation failed
print(f"ERROR: Credential validation definitively failed for {service_name} account {account_name} after {attempt + 1} attempt(s).")
if last_exception: if last_exception:
base_error_message = str(last_exception).splitlines()[-1] base_error_message = str(last_exception).splitlines()[-1]
detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}" detailed_error_message = f"Invalid {service_name} credentials for {account_name}. Verification failed: {base_error_message}"
if is_spotify and "incorrect padding" in base_error_message.lower(): if service_name == 'spotify' and "incorrect padding" in base_error_message.lower():
detailed_error_message += ". Hint: Do not throw your password here, read the docs" detailed_error_message += ". Hint: For Spotify, ensure the credentials blob content is correct."
# traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace
raise ValueError(detailed_error_message) raise ValueError(detailed_error_message)
else: # Should not happen if loop runs at least once else:
raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).") raise ValueError(f"Invalid {service_name} credentials for {account_name}. Verification failed (unknown reason after retries).")
def get_credential(service, name, cred_type='credentials'):
def create_credential(service, name, data):
""" """
Retrieves existing credential contents by name. Creates a new credential.
Args:
service (str): 'spotify' or 'deezer'
name (str): Custom name of the credential to retrieve
cred_type (str): 'credentials' or 'search' - type of credential file to read
Returns:
dict: Credential data as dictionary
Raises:
FileNotFoundError: If the credential doesn't exist
ValueError: For invalid service name or cred_type
"""
if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'")
if cred_type not in ['credentials', 'search']:
raise ValueError("Credential type must be 'credentials' or 'search'")
# For Deezer, only credentials.json is supported
if service == 'deezer' and cred_type == 'search':
raise ValueError("Search credentials are only supported for Spotify")
creds_dir = Path('./data/creds') / service / name
file_path = creds_dir / f'{cred_type}.json'
if not file_path.exists():
if cred_type == 'search':
# Return empty dict if search.json doesn't exist
return {}
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
with open(file_path, 'r') as f:
return json.load(f)
def list_credentials(service):
"""
Lists all available credential names for a service
Args:
service (str): 'spotify' or 'deezer'
Returns:
list: Array of credential names
Raises:
ValueError: For invalid service name
"""
if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'")
service_dir = Path('./data/creds') / service
if not service_dir.exists():
return []
return [d.name for d in service_dir.iterdir() if d.is_dir()]
def create_credential(service, name, data, cred_type='credentials'):
"""
Creates a new credential file for the specified service.
Args: Args:
service (str): 'spotify' or 'deezer' service (str): 'spotify' or 'deezer'
name (str): Custom name for the credential name (str): Custom name for the credential
data (dict): Dictionary containing the credential data data (dict): For Spotify: {'client_id', 'client_secret', 'region', 'blob_content'}
cred_type (str): 'credentials' or 'search' - type of credential file to create For Deezer: {'arl', 'region'}
Raises: Raises:
ValueError: If service is invalid, data has invalid fields, or missing required fields ValueError, FileExistsError
FileExistsError: If the credential directory already exists (for credentials.json)
""" """
if service not in ['spotify', 'deezer']: if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'") raise ValueError("Service must be 'spotify' or 'deezer'")
if not name or not isinstance(name, str):
raise ValueError("Credential name must be a non-empty string.")
if cred_type not in ['credentials', 'search']: current_time = time.time()
raise ValueError("Credential type must be 'credentials' or 'search'")
# For Deezer, only credentials.json is supported with _get_db_connection() as conn:
if service == 'deezer' and cred_type == 'search': cursor = conn.cursor()
raise ValueError("Search credentials are only supported for Spotify") conn.row_factory = sqlite3.Row
try:
# Validate data structure
required_fields = []
allowed_fields = []
if cred_type == 'credentials':
if service == 'spotify': if service == 'spotify':
required_fields = ['username', 'credentials'] required_fields = {'region', 'blob_content'} # client_id/secret are global
allowed_fields = required_fields + ['type'] if not required_fields.issubset(data.keys()):
data['type'] = 'AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS' raise ValueError(f"Missing fields for Spotify. Required: {required_fields}")
else:
required_fields = ['arl']
allowed_fields = required_fields.copy()
# Check for extra fields
extra_fields = set(data.keys()) - set(allowed_fields)
if extra_fields:
raise ValueError(f"Deezer credentials can only contain 'arl'. Extra fields found: {', '.join(extra_fields)}")
elif cred_type == 'search':
required_fields = ['client_id', 'client_secret']
allowed_fields = required_fields.copy()
# Check for extra fields
extra_fields = set(data.keys()) - set(allowed_fields)
if extra_fields:
raise ValueError(f"Search credentials can only contain 'client_id' and 'client_secret'. Extra fields found: {', '.join(extra_fields)}")
for field in required_fields: blob_path = BLOBS_DIR / name / 'credentials.json'
if field not in data: validation_data = {'blob_file_path': str(blob_path)} # Validation uses global API creds
raise ValueError(f"Missing required field for {cred_type}: {field}")
# Create directory blob_path.parent.mkdir(parents=True, exist_ok=True)
creds_dir = Path('./data/creds') / service / name with open(blob_path, 'w') as f_blob:
file_created_now = False if isinstance(data['blob_content'], dict):
dir_created_now = False json.dump(data['blob_content'], f_blob, indent=4)
else: # assume string
f_blob.write(data['blob_content'])
if cred_type == 'credentials':
try: try:
creds_dir.mkdir(parents=True, exist_ok=False) _validate_with_retry('spotify', name, validation_data)
dir_created_now = True cursor.execute(
except FileExistsError: "INSERT INTO spotify (name, region, created_at, updated_at) VALUES (?, ?, ?, ?)",
# Directory already exists, which is fine for creating credentials.json (name, data['region'], current_time, current_time)
# if it doesn't exist yet, or if we are overwriting (though POST usually means new)
pass
except Exception as e:
raise ValueError(f"Could not create directory {creds_dir}: {e}")
file_path = creds_dir / 'credentials.json'
if file_path.exists() and request.method == 'POST': # type: ignore
# Safety check for POST to not overwrite if file exists unless it's an edit (PUT)
raise FileExistsError(f"Credential file {file_path} already exists. Use PUT to modify.")
# Write the credential file first
try:
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
file_created_now = True # Mark as created for potential cleanup
except Exception as e:
if dir_created_now: # Cleanup directory if file write failed
try:
creds_dir.rmdir()
except OSError: # rmdir fails if not empty, though it should be
pass
raise ValueError(f"Could not write credential file {file_path}: {e}")
# --- Validation Step ---
try:
_validate_with_retry(
service_name=service,
account_name=name,
creds_dir_path=creds_dir,
cred_file_path=file_path,
data_for_validation=data, # 'data' contains the arl for Deezer
is_spotify=(service == 'spotify')
) )
except ValueError as val_err: # Catch the specific error from our helper
print(f"ERROR: Credential validation failed during creation for {service} account {name}: {val_err}")
traceback.print_exc() # Print full traceback here for creation failure context
# Clean up the created file and directory if validation fails
if file_created_now:
try:
file_path.unlink(missing_ok=True)
except OSError:
pass # Ignore if somehow already gone
if dir_created_now and not any(creds_dir.iterdir()): # Only remove if empty
try:
creds_dir.rmdir()
except OSError:
pass
raise # Re-raise the ValueError from validation
elif cred_type == 'search': # Spotify only
# For search.json, ensure the directory exists (it should if credentials.json exists)
if not creds_dir.exists():
# This implies credentials.json was not created first, which is an issue.
# However, the form logic might allow adding API creds to an existing empty dir.
# For now, let's create it if it's missing, assuming API creds can be standalone.
try:
creds_dir.mkdir(parents=True, exist_ok=True)
except Exception as e: except Exception as e:
raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}") if blob_path.exists(): blob_path.unlink() # Cleanup blob
if blob_path.parent.exists() and not any(blob_path.parent.iterdir()): blob_path.parent.rmdir()
raise # Re-raise validation or DB error
file_path = creds_dir / 'search.json' elif service == 'deezer':
# No specific validation for client_id/secret themselves, they are validated in use. required_fields = {'arl', 'region'}
with open(file_path, 'w') as f: if not required_fields.issubset(data.keys()):
json.dump(data, f, indent=4) raise ValueError(f"Missing fields for Deezer. Required: {required_fields}")
def delete_credential(service, name, cred_type=None): validation_data = {'arl': data['arl']}
_validate_with_retry('deezer', name, validation_data)
cursor.execute(
"INSERT INTO deezer (name, arl, region, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(name, data['arl'], data['region'], current_time, current_time)
)
conn.commit()
logger.info(f"Credential '{name}' for {service} created successfully.")
return {"status": "created", "service": service, "name": name}
except sqlite3.IntegrityError:
raise FileExistsError(f"Credential '{name}' already exists for {service}.")
except Exception as e:
logger.error(f"Error creating credential {name} for {service}: {e}", exc_info=True)
raise ValueError(f"Could not create credential: {e}")
def get_credential(service, name):
""" """
Deletes an existing credential directory or specific credential file. Retrieves a specific credential by name.
For Spotify, returns dict with name, region, and blob_content (from file).
Args: For Deezer, returns dict with name, arl, and region.
service (str): 'spotify' or 'deezer' Raises FileNotFoundError if the credential does not exist.
name (str): Name of the credential to delete
cred_type (str, optional): If specified ('credentials' or 'search'), only deletes
that specific file. If None, deletes the whole directory.
Raises:
FileNotFoundError: If the credential directory or specified file does not exist
"""
creds_dir = Path('./data/creds') / service / name
if cred_type:
if cred_type not in ['credentials', 'search']:
raise ValueError("Credential type must be 'credentials' or 'search'")
file_path = creds_dir / f'{cred_type}.json'
if not file_path.exists():
raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}")
# Delete just the specific file
file_path.unlink()
# If it was credentials.json and no other credential files remain, also delete the directory
if cred_type == 'credentials' and not any(creds_dir.iterdir()):
creds_dir.rmdir()
else:
# Delete the entire directory
if not creds_dir.exists():
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
shutil.rmtree(creds_dir)
def edit_credential(service, name, new_data, cred_type='credentials'):
"""
Edits an existing credential file.
Args:
service (str): 'spotify' or 'deezer'
name (str): Name of the credential to edit
new_data (dict): Dictionary containing fields to update
cred_type (str): 'credentials' or 'search' - type of credential file to edit
Raises:
FileNotFoundError: If the credential does not exist
ValueError: If new_data contains invalid fields or missing required fields after update
""" """
if service not in ['spotify', 'deezer']: if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'") raise ValueError("Service must be 'spotify' or 'deezer'")
if cred_type not in ['credentials', 'search']: with _get_db_connection() as conn:
raise ValueError("Credential type must be 'credentials' or 'search'") cursor = conn.cursor()
conn.row_factory = sqlite3.Row # Ensure row_factory is set for this cursor
cursor.execute(f"SELECT * FROM {service} WHERE name = ?", (name,))
row = cursor.fetchone()
# For Deezer, only credentials.json is supported if not row:
if service == 'deezer' and cred_type == 'search': raise FileNotFoundError(f"No {service} credential found with name '{name}'")
raise ValueError("Search credentials are only supported for Spotify")
# Get file path data = dict(row)
creds_dir = Path('./data/creds') / service / name
file_path = creds_dir / f'{cred_type}.json'
original_data_str = None # Store original data as string to revert if service == 'spotify':
file_existed_before_edit = file_path.exists() blob_file_path = BLOBS_DIR / name / 'credentials.json'
data['blob_file_path'] = str(blob_file_path) # Keep for internal use
if file_existed_before_edit:
with open(file_path, 'r') as f:
original_data_str = f.read()
try: try:
data = json.loads(original_data_str) with open(blob_file_path, 'r') as f_blob:
blob_data = json.load(f_blob)
data['blob_content'] = blob_data
except FileNotFoundError:
logger.warning(f"Spotify blob file not found for {name} at {blob_file_path} during get_credential.")
data['blob_content'] = None
except json.JSONDecodeError: except json.JSONDecodeError:
# If existing file is corrupt, treat as if we are creating it anew for edit logger.warning(f"Error decoding JSON from Spotify blob file for {name} at {blob_file_path}.")
data = {} data['blob_content'] = None
original_data_str = None # Can't revert to corrupt data except Exception as e:
else: logger.error(f"Unexpected error reading Spotify blob for {name}: {e}", exc_info=True)
# If file doesn't exist, and we're editing (PUT), it's usually an error data['blob_content'] = None
# unless it's for search.json which can be created during an edit flow.
if cred_type == 'credentials': cleaned_data = {
raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}") 'name': data.get('name'),
data = {} # Start with empty data for search.json creation 'region': data.get('region'),
'blob_content': data.get('blob_content')
}
return cleaned_data
elif service == 'deezer':
cleaned_data = {
'name': data.get('name'),
'region': data.get('region'),
'arl': data.get('arl')
}
return cleaned_data
# Fallback, should not be reached if service is spotify or deezer
return None
def list_credentials(service):
if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'")
with _get_db_connection() as conn:
cursor = conn.cursor()
conn.row_factory = sqlite3.Row
cursor.execute(f"SELECT name FROM {service}")
return [row['name'] for row in cursor.fetchall()]
def delete_credential(service, name):
if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'")
with _get_db_connection() as conn:
cursor = conn.cursor()
conn.row_factory = sqlite3.Row
cursor.execute(f"DELETE FROM {service} WHERE name = ?", (name,))
if cursor.rowcount == 0:
raise FileNotFoundError(f"Credential '{name}' not found for {service}.")
# Validate new_data fields (data to be merged)
allowed_fields = []
if cred_type == 'credentials':
if service == 'spotify': if service == 'spotify':
allowed_fields = ['username', 'credentials'] blob_dir = BLOBS_DIR / name
else: if blob_dir.exists():
allowed_fields = ['arl'] shutil.rmtree(blob_dir)
else: # search.json conn.commit()
allowed_fields = ['client_id', 'client_secret'] logger.info(f"Credential '{name}' for {service} deleted.")
return {"status": "deleted", "service": service, "name": name}
for key in new_data.keys(): def edit_credential(service, name, new_data):
if key not in allowed_fields: """
raise ValueError(f"Invalid field '{key}' for {cred_type} credentials") Edits an existing credential.
new_data for Spotify can include: client_id, client_secret, region, blob_content.
new_data for Deezer can include: arl, region.
Fields not in new_data remain unchanged.
"""
if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'")
# Update data (merging new_data into existing or empty data) current_time = time.time()
data.update(new_data)
# --- Write and Validate Step for 'credentials' type --- # Fetch existing data first to preserve unchanged fields and for validation backup
if cred_type == 'credentials':
try: try:
# Temporarily write new data for validation existing_cred = get_credential(service, name) # This will raise FileNotFoundError if not found
with open(file_path, 'w') as f: except FileNotFoundError:
json.dump(data, f, indent=4) raise
except Exception as e: # Catch other errors from get_credential
raise ValueError(f"Could not retrieve existing credential {name} for edit: {e}")
_validate_with_retry( updated_fields = new_data.copy()
service_name=service,
account_name=name,
creds_dir_path=creds_dir,
cred_file_path=file_path,
data_for_validation=data, # 'data' is the merged data with 'arl' for Deezer
is_spotify=(service == 'spotify')
)
except ValueError as val_err: # Catch the specific error from our helper
print(f"ERROR: Edited credential validation failed for {service} account {name}: {val_err}")
traceback.print_exc() # Print full traceback here for edit failure context
# Revert or delete the file
if original_data_str is not None:
with open(file_path, 'w') as f:
f.write(original_data_str) # Restore original content
elif file_existed_before_edit: # file existed but original_data_str is None (corrupt)
pass
else: # File didn't exist before this edit attempt, so remove it
try:
file_path.unlink(missing_ok=True)
except OSError:
pass # Ignore if somehow already gone
raise # Re-raise the ValueError from validation
except Exception as e: # Catch other potential errors like file IO during temp write
print(f"ERROR: Unexpected error during edit/validation for {service} account {name}: {e}")
traceback.print_exc()
# Attempt revert/delete
if original_data_str is not None:
with open(file_path, 'w') as f: f.write(original_data_str)
elif file_existed_before_edit:
pass
else:
try:
file_path.unlink(missing_ok=True)
except OSError: pass
raise ValueError(f"Failed to save edited {service} credentials due to: {str(e).splitlines()[-1]}")
# For 'search' type, just write, no specific validation here for client_id/secret with _get_db_connection() as conn:
elif cred_type == 'search': cursor = conn.cursor()
if not creds_dir.exists(): # Should not happen if we're editing conn.row_factory = sqlite3.Row
raise FileNotFoundError(f"Credential directory {creds_dir} not found for editing search credentials.")
with open(file_path, 'w') as f:
json.dump(data, f, indent=4) # `data` here is the merged data for search
# For Deezer: Strip all fields except 'arl' - This should use `data` which is `updated_data`
if service == 'deezer' and cred_type == 'credentials':
if 'arl' not in data:
raise ValueError("Missing 'arl' field for Deezer credential after edit.")
data = {'arl': data['arl']}
# Ensure required fields are present
required_fields = []
if cred_type == 'credentials':
if service == 'spotify': if service == 'spotify':
required_fields = ['username', 'credentials', 'type'] # Prepare data for DB update
data['type'] = 'AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS' db_update_data = {
'region': updated_fields.get('region', existing_cred['region']),
'updated_at': current_time,
'name': name # for WHERE clause
}
blob_path = Path(existing_cred['blob_file_path']) # Use path from existing
original_blob_content = None
if blob_path.exists():
with open(blob_path, 'r') as f_orig_blob:
original_blob_content = f_orig_blob.read()
# If blob_content is being updated, write it temporarily for validation
if 'blob_content' in updated_fields:
blob_path.parent.mkdir(parents=True, exist_ok=True)
with open(blob_path, 'w') as f_new_blob:
if isinstance(updated_fields['blob_content'], dict):
json.dump(updated_fields['blob_content'], f_new_blob, indent=4)
else: else:
required_fields = ['arl'] f_new_blob.write(updated_fields['blob_content'])
else: # search.json
required_fields = ['client_id', 'client_secret']
for field in required_fields: validation_data = {'blob_file_path': str(blob_path)}
if field not in data:
raise ValueError(f"Missing required field '{field}' after update for {cred_type}")
# Save updated data try:
with open(file_path, 'w') as f: _validate_with_retry('spotify', name, validation_data)
json.dump(data, f, indent=4)
set_clause = ", ".join([f"{key} = ?" for key in db_update_data if key != 'name'])
values = [db_update_data[key] for key in db_update_data if key != 'name'] + [name]
cursor.execute(f"UPDATE spotify SET {set_clause} WHERE name = ?", tuple(values))
# If validation passed and blob was in new_data, it's already written.
# If blob_content was NOT in new_data, the existing blob (if any) remains.
except Exception as e:
# Revert blob if it was changed and validation failed
if 'blob_content' in updated_fields and original_blob_content is not None:
with open(blob_path, 'w') as f_revert_blob:
f_revert_blob.write(original_blob_content)
elif 'blob_content' in updated_fields and original_blob_content is None and blob_path.exists():
# If new blob was written but there was no original to revert to, delete the new one.
blob_path.unlink()
raise # Re-raise validation or DB error
elif service == 'deezer':
db_update_data = {
'arl': updated_fields.get('arl', existing_cred['arl']),
'region': updated_fields.get('region', existing_cred['region']),
'updated_at': current_time,
'name': name # for WHERE clause
}
validation_data = {'arl': db_update_data['arl']}
_validate_with_retry('deezer', name, validation_data) # Validation happens before DB write for Deezer
set_clause = ", ".join([f"{key} = ?" for key in db_update_data if key != 'name'])
values = [db_update_data[key] for key in db_update_data if key != 'name'] + [name]
cursor.execute(f"UPDATE deezer SET {set_clause} WHERE name = ?", tuple(values))
if cursor.rowcount == 0: # Should not happen if get_credential succeeded
raise FileNotFoundError(f"Credential '{name}' for {service} disappeared during edit.")
conn.commit()
logger.info(f"Credential '{name}' for {service} updated successfully.")
return {"status": "updated", "service": service, "name": name}
# --- Helper for credential file path (mainly for Spotify blob) ---
def get_spotify_blob_path(account_name: str) -> Path:
return BLOBS_DIR / account_name / 'credentials.json'
# It's good practice to call init_credentials_db() when the app starts.
# This can be done in the main application setup. For now, defining it here.
# If this script is run directly for setup, you could add:
# if __name__ == '__main__':
# init_credentials_db()
# print("Credentials database initialized.")

View File

@@ -4,48 +4,63 @@ from deezspot.easy_spoty import Spo
import json import json
from pathlib import Path from pathlib import Path
from routes.utils.celery_queue_manager import get_config_params from routes.utils.celery_queue_manager import get_config_params
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
# Import Deezer API and logging
from deezspot.deezloader.dee_api import API as DeezerAPI
import logging
# Initialize logger
logger = logging.getLogger(__name__)
# We'll rely on get_config_params() instead of directly loading the config file # We'll rely on get_config_params() instead of directly loading the config file
def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None): def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None):
""" """
Get info from Spotify API using the default Spotify account configured in main.json Get info from Spotify API. Uses global client_id/secret from search.json.
The default Spotify account from main.json might still be relevant for other Spo settings or if Spo uses it.
Args: Args:
spotify_id: The Spotify ID of the entity spotify_id: The Spotify ID of the entity
spotify_type: The type of entity (track, album, playlist, artist) spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode)
limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist". limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist_discography".
offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist". offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist_discography".
Returns: Returns:
Dictionary with the entity information Dictionary with the entity information
""" """
client_id = None client_id, client_secret = _get_global_spotify_api_creds()
client_secret = None
# Get config parameters including Spotify account if not client_id or not client_secret:
raise ValueError("Global Spotify API client_id or client_secret not configured in ./data/creds/search.json.")
# Get config parameters including default Spotify account name
# This might still be useful if Spo uses the account name for other things (e.g. market/region if not passed explicitly)
# For now, we are just ensuring the API keys are set.
config_params = get_config_params() config_params = get_config_params()
main = config_params.get('spotify', '') main_spotify_account_name = config_params.get('spotify', '') # Still good to know which account is 'default' contextually
if not main: if not main_spotify_account_name:
raise ValueError("No Spotify account configured in settings") # This is less critical now that API keys are global, but could indicate a misconfiguration
# if other parts of Spo expect an account context.
if spotify_id: print(f"WARN: No default Spotify account name configured in settings (main.json). API calls will use global keys.")
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
search_creds = json.load(f)
client_id = search_creds.get('client_id')
client_secret = search_creds.get('client_secret')
except Exception as e:
print(f"Error loading search credentials: {e}")
# Initialize the Spotify client with credentials (if available)
if client_id and client_secret:
Spo.__init__(client_id, client_secret)
else: else:
raise ValueError("No Spotify credentials found") # Optionally, one could load the specific account's region here if Spo.init or methods need it,
# but easy_spoty's Spo doesn't seem to take region directly in __init__.
# It might use it internally based on account details if credentials.json (blob) contains it.
try:
# We call get_credential just to check if the account exists,
# not for client_id/secret anymore for Spo.__init__
get_credential('spotify', main_spotify_account_name)
except FileNotFoundError:
# This is a more serious warning if an account is expected to exist.
print(f"WARN: Default Spotify account '{main_spotify_account_name}' configured in main.json was not found in credentials database.")
except Exception as e:
print(f"WARN: Error accessing default Spotify account '{main_spotify_account_name}': {e}")
# Initialize the Spotify client with GLOBAL credentials
Spo.__init__(client_id, client_secret)
if spotify_type == "track": if spotify_type == "track":
return Spo.get_track(spotify_id) return Spo.get_track(spotify_id)
elif spotify_type == "album": elif spotify_type == "album":
@@ -67,3 +82,58 @@ def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None):
return Spo.get_episode(spotify_id) return Spo.get_episode(spotify_id)
else: else:
raise ValueError(f"Unsupported Spotify type: {spotify_type}") raise ValueError(f"Unsupported Spotify type: {spotify_type}")
def get_deezer_info(deezer_id, deezer_type, limit=None):
"""
Get info from Deezer API.
Args:
deezer_id: The Deezer ID of the entity.
deezer_type: The type of entity (track, album, playlist, artist, episode,
artist_top_tracks, artist_albums, artist_related,
artist_radio, artist_playlists).
limit (int, optional): The maximum number of items to return. Used for
artist_top_tracks, artist_albums, artist_playlists.
Deezer API methods usually have their own defaults (e.g., 25)
if limit is not provided or None is passed to them.
Returns:
Dictionary with the entity information.
Raises:
ValueError: If deezer_type is unsupported.
Various exceptions from DeezerAPI (NoDataApi, QuotaExceeded, requests.exceptions.RequestException, etc.)
"""
logger.debug(f"Fetching Deezer info for ID {deezer_id}, type {deezer_type}, limit {limit}")
# DeezerAPI uses class methods; its @classmethod __init__ handles setup.
# No specific ARL or account handling here as DeezerAPI seems to use general endpoints.
if deezer_type == "track":
return DeezerAPI.get_track(deezer_id)
elif deezer_type == "album":
return DeezerAPI.get_album(deezer_id)
elif deezer_type == "playlist":
return DeezerAPI.get_playlist(deezer_id)
elif deezer_type == "artist":
return DeezerAPI.get_artist(deezer_id)
elif deezer_type == "episode":
return DeezerAPI.get_episode(deezer_id)
elif deezer_type == "artist_top_tracks":
if limit is not None:
return DeezerAPI.get_artist_top_tracks(deezer_id, limit=limit)
return DeezerAPI.get_artist_top_tracks(deezer_id) # Use API default limit
elif deezer_type == "artist_albums": # Maps to get_artist_top_albums
if limit is not None:
return DeezerAPI.get_artist_top_albums(deezer_id, limit=limit)
return DeezerAPI.get_artist_top_albums(deezer_id) # Use API default limit
elif deezer_type == "artist_related":
return DeezerAPI.get_artist_related(deezer_id)
elif deezer_type == "artist_radio":
return DeezerAPI.get_artist_radio(deezer_id)
elif deezer_type == "artist_playlists":
if limit is not None:
return DeezerAPI.get_artist_top_playlists(deezer_id, limit=limit)
return DeezerAPI.get_artist_top_playlists(deezer_id) # Use API default limit
else:
logger.error(f"Unsupported Deezer type: {deezer_type}")
raise ValueError(f"Unsupported Deezer type: {deezer_type}")

View File

@@ -9,13 +9,40 @@ logger = logging.getLogger(__name__)
HISTORY_DIR = Path('./data/history') HISTORY_DIR = Path('./data/history')
HISTORY_DB_FILE = HISTORY_DIR / 'download_history.db' HISTORY_DB_FILE = HISTORY_DIR / 'download_history.db'
EXPECTED_COLUMNS = {
'task_id': 'TEXT PRIMARY KEY',
'download_type': 'TEXT',
'item_name': 'TEXT',
'item_artist': 'TEXT',
'item_album': 'TEXT',
'item_url': 'TEXT',
'spotify_id': 'TEXT',
'status_final': 'TEXT', # 'COMPLETED', 'ERROR', 'CANCELLED'
'error_message': 'TEXT',
'timestamp_added': 'REAL',
'timestamp_completed': 'REAL',
'original_request_json': 'TEXT',
'last_status_obj_json': 'TEXT',
'service_used': 'TEXT',
'quality_profile': 'TEXT',
'convert_to': 'TEXT',
'bitrate': 'TEXT'
}
def init_history_db(): def init_history_db():
"""Initializes the download history database and creates the table if it doesn't exist.""" """Initializes the download history database, creates the table if it doesn't exist,
and adds any missing columns to an existing table."""
conn = None
try: try:
HISTORY_DIR.mkdir(parents=True, exist_ok=True) HISTORY_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(HISTORY_DB_FILE) conn = sqlite3.connect(HISTORY_DB_FILE)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("""
# Create table if it doesn't exist (idempotent)
# The primary key constraint is handled by the initial CREATE TABLE.
# If 'task_id' is missing, it cannot be added as PRIMARY KEY to an existing table
# without complex migrations. We assume 'task_id' will exist if the table exists.
create_table_sql = f"""
CREATE TABLE IF NOT EXISTS download_history ( CREATE TABLE IF NOT EXISTS download_history (
task_id TEXT PRIMARY KEY, task_id TEXT PRIMARY KEY,
download_type TEXT, download_type TEXT,
@@ -24,7 +51,7 @@ def init_history_db():
item_album TEXT, item_album TEXT,
item_url TEXT, item_url TEXT,
spotify_id TEXT, spotify_id TEXT,
status_final TEXT, -- 'COMPLETED', 'ERROR', 'CANCELLED' status_final TEXT,
error_message TEXT, error_message TEXT,
timestamp_added REAL, timestamp_added REAL,
timestamp_completed REAL, timestamp_completed REAL,
@@ -35,9 +62,48 @@ def init_history_db():
convert_to TEXT, convert_to TEXT,
bitrate TEXT bitrate TEXT
) )
""") """
cursor.execute(create_table_sql)
conn.commit() conn.commit()
logger.info(f"Download history database initialized at {HISTORY_DB_FILE}")
# Check for missing columns and add them
cursor.execute("PRAGMA table_info(download_history)")
existing_columns_info = cursor.fetchall()
existing_column_names = {col[1] for col in existing_columns_info}
added_columns = False
for col_name, col_type in EXPECTED_COLUMNS.items():
if col_name not in existing_column_names:
if 'PRIMARY KEY' in col_type.upper() and col_name == 'task_id':
# This case should be handled by CREATE TABLE, but as a safeguard:
# If task_id is somehow missing and table exists, this is a problem.
# Adding it as PK here is complex and might fail if data exists.
# For now, we assume CREATE TABLE handles the PK.
# If we were to add it, it would be 'ALTER TABLE download_history ADD COLUMN task_id TEXT;'
# and then potentially a separate step to make it PK if table is empty, which is non-trivial.
logger.warning(f"Column '{col_name}' is part of PRIMARY KEY and was expected to be created by CREATE TABLE. Skipping explicit ADD COLUMN.")
continue
# For other columns, just add them.
# Remove PRIMARY KEY from type definition if present, as it's only for table creation.
col_type_for_add = col_type.replace(' PRIMARY KEY', '').strip()
try:
cursor.execute(f"ALTER TABLE download_history ADD COLUMN {col_name} {col_type_for_add}")
logger.info(f"Added missing column '{col_name} {col_type_for_add}' to download_history table.")
added_columns = True
except sqlite3.OperationalError as alter_e:
# This might happen if a column (e.g. task_id) without "PRIMARY KEY" is added by this loop
# but the initial create table already made it a primary key.
# Or other more complex scenarios.
logger.warning(f"Could not add column '{col_name}': {alter_e}. It might already exist or there's a schema mismatch.")
if added_columns:
conn.commit()
logger.info(f"Download history table schema updated at {HISTORY_DB_FILE}")
else:
logger.info(f"Download history database schema is up-to-date at {HISTORY_DB_FILE}")
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error(f"Error initializing download history database: {e}", exc_info=True) logger.error(f"Error initializing download history database: {e}", exc_info=True)
finally: finally:

View File

@@ -4,6 +4,8 @@ import traceback
from deezspot.spotloader import SpoLogin from deezspot.spotloader import SpoLogin
from deezspot.deezloader import DeeLogin from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
from routes.utils.celery_config import get_config_params
def download_playlist( def download_playlist(
url, url,
@@ -28,83 +30,49 @@ def download_playlist(
is_spotify_url = 'open.spotify.com' in url.lower() is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower() is_deezer_url = 'deezer.com' in url.lower()
# Determine service exclusively from URL service = ''
if is_spotify_url: if is_spotify_url:
service = 'spotify' service = 'spotify'
elif is_deezer_url: elif is_deezer_url:
service = 'deezer' service = 'deezer'
else: else:
# If URL can't be detected, raise an error
error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com" error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
print(f"ERROR: {error_msg}") print(f"ERROR: {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)
print(f"DEBUG: playlist.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}")
print(f"DEBUG: playlist.py - Service determined from URL: {service}") print(f"DEBUG: playlist.py - Service determined from URL: {service}")
print(f"DEBUG: playlist.py - Credentials: main={main}, fallback={fallback}") print(f"DEBUG: playlist.py - Credentials provided: main_account_name='{main}', fallback_account_name='{fallback}'")
# Load Spotify client credentials if available # Get global Spotify API credentials
spotify_client_id = None global_spotify_client_id, global_spotify_client_secret = _get_global_spotify_api_creds()
spotify_client_secret = None if not global_spotify_client_id or not global_spotify_client_secret:
warning_msg = "WARN: playlist.py - Global Spotify client_id/secret not found in search.json. Spotify operations will likely fail."
print(warning_msg)
# Smartly determine where to look for Spotify search credentials
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
search_creds = json.load(f)
spotify_client_id = search_creds.get('client_id')
spotify_client_secret = search_creds.get('client_secret')
print(f"DEBUG: Loaded Spotify client credentials successfully")
except Exception as e:
print(f"Error loading Spotify search credentials: {e}")
# For Spotify URLs: check if fallback is enabled, if so use the fallback logic,
# otherwise download directly from Spotify
if service == 'spotify': if service == 'spotify':
if fallback: if fallback: # Fallback is a Deezer account name for a Spotify URL
if quality is None: if quality is None: quality = 'FLAC' # Deezer quality for first attempt
quality = 'FLAC' if fall_quality is None: fall_quality = 'HIGH' # Spotify quality for fallback (if Deezer fails)
if fall_quality is None:
fall_quality = 'HIGH'
# First attempt: use DeeLogin's download_playlistspo with the 'main' (Deezer credentials)
deezer_error = None deezer_error = None
try: try:
# Load Deezer credentials from 'main' under deezer directory # Attempt 1: Deezer via download_playlistspo (using 'fallback' as Deezer account name)
deezer_creds_dir = os.path.join('./data/creds/deezer', main) print(f"DEBUG: playlist.py - Spotify URL. Attempt 1: Deezer (account: {fallback})")
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) deezer_fallback_creds = get_credential('deezer', fallback)
arl = deezer_fallback_creds.get('arl')
if not arl:
raise ValueError(f"ARL not found for Deezer account '{fallback}'.")
# DEBUG: Print Deezer credential paths being used
print(f"DEBUG: Looking for Deezer credentials at:")
print(f"DEBUG: deezer_creds_dir = {deezer_creds_dir}")
print(f"DEBUG: deezer_creds_path = {deezer_creds_path}")
print(f"DEBUG: Directory exists = {os.path.exists(deezer_creds_dir)}")
print(f"DEBUG: Credentials file exists = {os.path.exists(deezer_creds_path)}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
# Initialize DeeLogin with Deezer credentials
dl = DeeLogin( dl = DeeLogin(
arl=deezer_creds.get('arl', ''), arl=arl,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting playlist download using Deezer credentials (download_playlistspo)")
# Download using download_playlistspo; pass the custom formatting parameters.
dl.download_playlistspo( dl.download_playlistspo(
link_playlist=url, link_playlist=url, # Spotify URL
output_dir="./downloads", output_dir="./downloads",
quality_download=quality, quality_download=quality, # Deezer quality
recursive_quality=True, recursive_quality=True,
recursive_download=False, recursive_download=False,
not_interface=False, not_interface=False,
@@ -119,35 +87,33 @@ def download_playlist(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Playlist download completed successfully using Deezer credentials") print(f"DEBUG: playlist.py - Playlist download via Deezer (account: {fallback}) successful for Spotify URL.")
except Exception as e: except Exception as e:
deezer_error = e deezer_error = e
# Immediately report the Deezer error print(f"ERROR: playlist.py - Deezer attempt (account: {fallback}) for Spotify URL failed: {e}")
print(f"ERROR: Deezer playlist download attempt failed: {e}")
traceback.print_exc() traceback.print_exc()
print("Attempting Spotify fallback...") print(f"DEBUG: playlist.py - Attempting Spotify direct download (account: {main} for blob)...")
# Load fallback Spotify credentials and attempt download # Attempt 2: Spotify direct via download_playlist (using 'main' as Spotify account for blob)
try: try:
spo_creds_dir = os.path.join('./data/creds/spotify', fallback) if not global_spotify_client_id or not global_spotify_client_secret:
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) raise ValueError("Global Spotify API credentials (client_id/secret) not configured for Spotify download.")
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") spotify_main_creds = get_credential('spotify', main) # For blob path
print(f"DEBUG: Fallback credentials exist: {os.path.exists(spo_creds_path)}") blob_file_path = spotify_main_creds.get('blob_file_path')
if not Path(blob_file_path).exists():
# We've already loaded the Spotify client credentials above based on fallback raise FileNotFoundError(f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'")
spo = SpoLogin( spo = SpoLogin(
credentials_path=spo_creds_path, credentials_path=blob_file_path,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting playlist download using Spotify fallback credentials")
spo.download_playlist( spo.download_playlist(
link_playlist=url, link_playlist=url, # Spotify URL
output_dir="./downloads", output_dir="./downloads",
quality_download=fall_quality, quality_download=fall_quality, # Spotify quality
recursive_quality=True, recursive_quality=True,
recursive_download=False, recursive_download=False,
not_interface=False, not_interface=False,
@@ -163,30 +129,32 @@ def download_playlist(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Playlist download completed successfully using Spotify fallback") print(f"DEBUG: playlist.py - Spotify direct download (account: {main} for blob) successful.")
except Exception as e2: except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed print(f"ERROR: playlist.py - Spotify direct download (account: {main} for blob) also failed: {e2}")
print(f"ERROR: Spotify fallback also failed: {e2}")
raise RuntimeError( raise RuntimeError(
f"Both main (Deezer) and fallback (Spotify) attempts failed. " f"Both Deezer attempt (account: {fallback}) and Spotify direct (account: {main} for blob) failed. "
f"Deezer error: {deezer_error}, Spotify error: {e2}" f"Deezer error: {deezer_error}, Spotify error: {e2}"
) from e2 ) from e2
else: else:
# Original behavior: use Spotify main # Spotify URL, no fallback. Direct Spotify download using 'main' (Spotify account for blob)
if quality is None: if quality is None: quality = 'HIGH' # Default Spotify quality
quality = 'HIGH' print(f"DEBUG: playlist.py - Spotify URL, no fallback. Direct download with Spotify account (for blob): {main}")
creds_dir = os.path.join('./data/creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) if not global_spotify_client_id or not global_spotify_client_secret:
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") raise ValueError("Global Spotify API credentials (client_id/secret) not configured for Spotify download.")
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
spotify_main_creds = get_credential('spotify', main) # For blob path
blob_file_path = spotify_main_creds.get('blob_file_path')
if not Path(blob_file_path).exists():
raise FileNotFoundError(f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'")
spo = SpoLogin( spo = SpoLogin(
credentials_path=credentials_path, credentials_path=blob_file_path,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting playlist download using Spotify main credentials")
spo.download_playlist( spo.download_playlist(
link_playlist=url, link_playlist=url,
output_dir="./downloads", output_dir="./downloads",
@@ -206,31 +174,28 @@ def download_playlist(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Playlist download completed successfully using Spotify main") print(f"DEBUG: playlist.py - Direct Spotify download (account: {main} for blob) successful.")
# For Deezer URLs: download directly from Deezer
elif service == 'deezer': elif service == 'deezer':
if quality is None: # Deezer URL. Direct Deezer download using 'main' (Deezer account name for ARL)
quality = 'FLAC' if quality is None: quality = 'FLAC' # Default Deezer quality
# Existing code for Deezer, using main as Deezer account. print(f"DEBUG: playlist.py - Deezer URL. Direct download with Deezer account: {main}")
creds_dir = os.path.join('./data/creds/deezer', main) deezer_main_creds = get_credential('deezer', main) # For ARL
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) arl = deezer_main_creds.get('arl')
print(f"DEBUG: Using Deezer credentials from: {creds_path}") if not arl:
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") raise ValueError(f"ARL not found for Deezer account '{main}'.")
with open(creds_path, 'r') as f:
creds = json.load(f)
dl = DeeLogin( dl = DeeLogin(
arl=creds.get('arl', ''), arl=arl, # Account specific ARL
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id, # Global Spotify keys
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret, # Global Spotify keys
progress_callback=progress_callback progress_callback=progress_callback
) )
print(f"DEBUG: Starting playlist download using Deezer direct") dl.download_playlistdee( # Deezer URL, download via Deezer
dl.download_playlistdee(
link_playlist=url, link_playlist=url,
output_dir="./downloads", output_dir="./downloads",
quality_download=quality, quality_download=quality,
recursive_quality=False, recursive_quality=False, # Usually False for playlists to get individual track qualities
recursive_download=False, recursive_download=False,
make_zip=False, make_zip=False,
custom_dir_format=custom_dir_format, custom_dir_format=custom_dir_format,
@@ -243,9 +208,10 @@ def download_playlist(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: Playlist download completed successfully using Deezer direct") print(f"DEBUG: playlist.py - Direct Deezer download (account: {main}) successful.")
else: else:
raise ValueError(f"Unsupported service: {service}") # Should be caught by initial service check, but as a safeguard
raise ValueError(f"Unsupported service determined: {service}")
except Exception as e: except Exception as e:
print(f"ERROR: Playlist download failed with exception: {e}") print(f"ERROR: Playlist download failed with exception: {e}")
traceback.print_exc() traceback.print_exc()

View File

@@ -2,6 +2,7 @@ from deezspot.easy_spoty import Spo
import json import json
from pathlib import Path from pathlib import Path
import logging import logging
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -12,48 +13,38 @@ def search(
limit: int = 3, limit: int = 3,
main: str = None main: str = None
) -> dict: ) -> dict:
logger.info(f"Search requested: query='{query}', type={search_type}, limit={limit}, main={main}") logger.info(f"Search requested: query='{query}', type={search_type}, limit={limit}, main_account_name={main}")
# If main account is specified, load client ID and secret from the account's search.json client_id, client_secret = _get_global_spotify_api_creds()
client_id = None
client_secret = None if not client_id or not client_secret:
logger.error("Global Spotify API client_id or client_secret not configured in ./data/creds/search.json.")
raise ValueError("Spotify API credentials are not configured globally for search.")
if main: if main:
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') logger.debug(f"Spotify account context '{main}' was provided for search. API keys are global, but this account might be used for other context by Spo if relevant.")
logger.debug(f"Looking for credentials at: {search_creds_path}")
if search_creds_path.exists():
try: try:
with open(search_creds_path, 'r') as f: get_credential('spotify', main)
search_creds = json.load(f) logger.debug(f"Spotify account '{main}' exists.")
client_id = search_creds.get('client_id') except FileNotFoundError:
client_secret = search_creds.get('client_secret') logger.warning(f"Spotify account '{main}' provided for search context not found in credentials. Search will proceed with global API keys.")
logger.debug(f"Credentials loaded successfully for account: {main}")
except Exception as e: except Exception as e:
logger.error(f"Error loading search credentials: {e}") logger.warning(f"Error checking existence of Spotify account '{main}': {e}. Search will proceed with global API keys.")
print(f"Error loading search credentials: {e}")
else: else:
logger.warning(f"Credentials file not found at: {search_creds_path}") logger.debug("No specific 'main' account context provided for search. Using global API keys.")
# Initialize the Spotify client with credentials (if available) logger.debug(f"Initializing Spotify client with global API credentials for search.")
if client_id and client_secret:
logger.debug("Initializing Spotify client with account credentials")
Spo.__init__(client_id, client_secret) Spo.__init__(client_id, client_secret)
else:
logger.debug("Using default Spotify client credentials")
# Perform the Spotify search logger.debug(f"Executing Spotify search with query='{query}', type={search_type}, limit={limit}")
logger.debug(f"Executing Spotify search with query='{query}', type={search_type}")
try: try:
spotify_response = Spo.search( spotify_response = Spo.search(
query=query, query=query,
search_type=search_type, search_type=search_type,
limit=limit, limit=limit
client_id=client_id,
client_secret=client_secret
) )
logger.info(f"Search completed successfully") logger.info(f"Search completed successfully for query: '{query}'")
return spotify_response return spotify_response
except Exception as e: except Exception as e:
logger.error(f"Error during Spotify search: {e}") logger.error(f"Error during Spotify search for query '{query}': {e}", exc_info=True)
raise raise

View File

@@ -4,6 +4,8 @@ import traceback
from deezspot.spotloader import SpoLogin from deezspot.spotloader import SpoLogin
from deezspot.deezloader import DeeLogin from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds, get_spotify_blob_path
from routes.utils.celery_config import get_config_params
def download_track( def download_track(
url, url,
@@ -28,84 +30,53 @@ def download_track(
is_spotify_url = 'open.spotify.com' in url.lower() is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower() is_deezer_url = 'deezer.com' in url.lower()
# Determine service exclusively from URL service = ''
if is_spotify_url: if is_spotify_url:
service = 'spotify' service = 'spotify'
elif is_deezer_url: elif is_deezer_url:
service = 'deezer' service = 'deezer'
else: else:
# If URL can't be detected, raise an error
error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com" error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
print(f"ERROR: {error_msg}") print(f"ERROR: {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)
print(f"DEBUG: track.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}")
print(f"DEBUG: track.py - Service determined from URL: {service}") print(f"DEBUG: track.py - Service determined from URL: {service}")
print(f"DEBUG: track.py - Credentials: main={main}, fallback={fallback}") print(f"DEBUG: track.py - Credentials provided: main_account_name='{main}', fallback_account_name='{fallback}'")
# Load Spotify client credentials if available # Get global Spotify API credentials for SpoLogin and DeeLogin (if it uses Spotify search)
spotify_client_id = None global_spotify_client_id, global_spotify_client_secret = _get_global_spotify_api_creds()
spotify_client_secret = None if not global_spotify_client_id or not global_spotify_client_secret:
# This is a critical failure if Spotify operations are involved
warning_msg = "WARN: track.py - Global Spotify client_id/secret not found in search.json. Spotify operations will likely fail."
print(warning_msg)
# Depending on flow, might want to raise error here if service is 'spotify'
# For now, let it proceed and fail at SpoLogin/DeeLogin init if keys are truly needed and missing.
# Smartly determine where to look for Spotify search credentials
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
search_creds = json.load(f)
spotify_client_id = search_creds.get('client_id')
spotify_client_secret = search_creds.get('client_secret')
print(f"DEBUG: Loaded Spotify client credentials successfully")
except Exception as e:
print(f"Error loading Spotify search credentials: {e}")
# For Spotify URLs: check if fallback is enabled, if so use the fallback logic,
# otherwise download directly from Spotify
if service == 'spotify': if service == 'spotify':
if fallback: if fallback: # Fallback is a Deezer account name for a Spotify URL
if quality is None: if quality is None: quality = 'FLAC' # Deezer quality for first attempt
quality = 'FLAC' if fall_quality is None: fall_quality = 'HIGH' # Spotify quality for fallback (if Deezer fails)
if fall_quality is None:
fall_quality = 'HIGH'
# First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials)
deezer_error = None deezer_error = None
try: try:
deezer_creds_dir = os.path.join('./data/creds/deezer', main) # Attempt 1: Deezer via download_trackspo (using 'fallback' as Deezer account name)
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) print(f"DEBUG: track.py - Spotify URL. Attempt 1: Deezer (account: {fallback})")
deezer_fallback_creds = get_credential('deezer', fallback)
arl = deezer_fallback_creds.get('arl')
if not arl:
raise ValueError(f"ARL not found for Deezer account '{fallback}'.")
# DEBUG: Print Deezer credential paths being used
print(f"DEBUG: Looking for Deezer credentials at:")
print(f"DEBUG: deezer_creds_dir = {deezer_creds_dir}")
print(f"DEBUG: deezer_creds_path = {deezer_creds_path}")
print(f"DEBUG: Directory exists = {os.path.exists(deezer_creds_dir)}")
print(f"DEBUG: Credentials file exists = {os.path.exists(deezer_creds_path)}")
# List available directories to compare
print(f"DEBUG: Available Deezer credential directories:")
for dir_name in os.listdir('./data/creds/deezer'):
print(f"DEBUG: ./data/creds/deezer/{dir_name}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
dl = DeeLogin( dl = DeeLogin(
arl=deezer_creds.get('arl', ''), arl=arl,
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id, # Global creds
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret, # Global creds
progress_callback=progress_callback progress_callback=progress_callback
) )
# download_trackspo means: Spotify URL, download via Deezer
dl.download_trackspo( dl.download_trackspo(
link_track=url, link_track=url, # Spotify URL
output_dir="./downloads", output_dir="./downloads",
quality_download=quality, quality_download=quality, # Deezer quality
recursive_quality=False, recursive_quality=False,
recursive_download=False, recursive_download=False,
not_interface=False, not_interface=False,
@@ -118,30 +89,33 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: track.py - Track download via Deezer (account: {fallback}) successful for Spotify URL.")
except Exception as e: except Exception as e:
deezer_error = e deezer_error = e
# Immediately report the Deezer error print(f"ERROR: track.py - Deezer attempt (account: {fallback}) for Spotify URL failed: {e}")
print(f"ERROR: Deezer download attempt failed: {e}")
traceback.print_exc() traceback.print_exc()
print("Attempting Spotify fallback...") print(f"DEBUG: track.py - Attempting Spotify direct download (account: {main})...")
# If the first attempt fails, use the fallback Spotify credentials # Attempt 2: Spotify direct via download_track (using 'main' as Spotify account for blob)
try: try:
spo_creds_dir = os.path.join('./data/creds/spotify', fallback) if not global_spotify_client_id or not global_spotify_client_secret:
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) raise ValueError("Global Spotify API credentials (client_id/secret) not configured for Spotify download.")
# We've already loaded the Spotify client credentials above based on fallback # Use get_spotify_blob_path directly
blob_file_path = get_spotify_blob_path(main)
if not blob_file_path.exists(): # Check existence on the Path object
raise FileNotFoundError(f"Spotify credentials blob file not found at {str(blob_file_path)} for account '{main}'")
spo = SpoLogin( spo = SpoLogin(
credentials_path=spo_creds_path, credentials_path=str(blob_file_path), # Account specific blob
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id, # Global API keys
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret, # Global API keys
progress_callback=progress_callback progress_callback=progress_callback
) )
spo.download_track( spo.download_track(
link_track=url, link_track=url, # Spotify URL
output_dir="./downloads", output_dir="./downloads",
quality_download=fall_quality, quality_download=fall_quality, # Spotify quality
recursive_quality=False, recursive_quality=False,
recursive_download=False, recursive_download=False,
not_interface=False, not_interface=False,
@@ -156,22 +130,30 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful.")
except Exception as e2: except Exception as e2:
# If fallback also fails, raise an error indicating both attempts failed print(f"ERROR: track.py - Spotify direct download (account: {main} for blob) also failed: {e2}")
raise RuntimeError( raise RuntimeError(
f"Both main (Deezer) and fallback (Spotify) attempts failed. " f"Both Deezer attempt (account: {fallback}) and Spotify direct (account: {main} for blob) failed. "
f"Deezer error: {deezer_error}, Spotify error: {e2}" f"Deezer error: {deezer_error}, Spotify error: {e2}"
) from e2 ) from e2
else: else:
# Directly use Spotify main account # Spotify URL, no fallback. Direct Spotify download using 'main' (Spotify account for blob)
if quality is None: if quality is None: quality = 'HIGH' # Default Spotify quality
quality = 'HIGH' print(f"DEBUG: track.py - Spotify URL, no fallback. Direct download with Spotify account (for blob): {main}")
creds_dir = os.path.join('./data/creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) if not global_spotify_client_id or not global_spotify_client_secret:
raise ValueError("Global Spotify API credentials (client_id/secret) not configured for Spotify download.")
# Use get_spotify_blob_path directly
blob_file_path = get_spotify_blob_path(main)
if not blob_file_path.exists(): # Check existence on the Path object
raise FileNotFoundError(f"Spotify credentials blob file not found at {str(blob_file_path)} for account '{main}'")
spo = SpoLogin( spo = SpoLogin(
credentials_path=credentials_path, credentials_path=str(blob_file_path), # Account specific blob
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id, # Global API keys
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret, # Global API keys
progress_callback=progress_callback progress_callback=progress_callback
) )
spo.download_track( spo.download_track(
@@ -192,22 +174,24 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
# For Deezer URLs: download directly from Deezer print(f"DEBUG: track.py - Direct Spotify download (account: {main} for blob) successful.")
elif service == 'deezer': elif service == 'deezer':
if quality is None: # Deezer URL. Direct Deezer download using 'main' (Deezer account name for ARL)
quality = 'FLAC' if quality is None: quality = 'FLAC' # Default Deezer quality
# Deezer download logic remains unchanged, with the custom formatting parameters passed along. print(f"DEBUG: track.py - Deezer URL. Direct download with Deezer account: {main}")
creds_dir = os.path.join('./data/creds/deezer', main) deezer_main_creds = get_credential('deezer', main) # For ARL
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) arl = deezer_main_creds.get('arl')
with open(creds_path, 'r') as f: if not arl:
creds = json.load(f) raise ValueError(f"ARL not found for Deezer account '{main}'.")
dl = DeeLogin( dl = DeeLogin(
arl=creds.get('arl', ''), arl=arl, # Account specific ARL
spotify_client_id=spotify_client_id, spotify_client_id=global_spotify_client_id, # Global Spotify keys for internal Spo use by DeeLogin
spotify_client_secret=spotify_client_secret, spotify_client_secret=global_spotify_client_secret, # Global Spotify keys
progress_callback=progress_callback progress_callback=progress_callback
) )
dl.download_trackdee( dl.download_trackdee( # Deezer URL, download via Deezer
link_track=url, link_track=url,
output_dir="./downloads", output_dir="./downloads",
quality_download=quality, quality_download=quality,
@@ -223,8 +207,10 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate bitrate=bitrate
) )
print(f"DEBUG: track.py - Direct Deezer download (account: {main}) successful.")
else: else:
raise ValueError(f"Unsupported service: {service}") # Should be caught by initial service check, but as a safeguard
raise ValueError(f"Unsupported service determined: {service}")
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
raise raise

View File

@@ -14,6 +14,101 @@ ARTISTS_DB_PATH = DB_DIR / 'artists.db'
# Config path for watch.json is managed in routes.utils.watch.manager now # Config path for watch.json is managed in routes.utils.watch.manager now
# CONFIG_PATH = Path('./data/config/watch.json') # Removed # CONFIG_PATH = Path('./data/config/watch.json') # Removed
# Expected column definitions
EXPECTED_WATCHED_PLAYLISTS_COLUMNS = {
'spotify_id': 'TEXT PRIMARY KEY',
'name': 'TEXT',
'owner_id': 'TEXT',
'owner_name': 'TEXT',
'total_tracks': 'INTEGER',
'link': 'TEXT',
'snapshot_id': 'TEXT',
'last_checked': 'INTEGER',
'added_at': 'INTEGER',
'is_active': 'INTEGER DEFAULT 1'
}
EXPECTED_PLAYLIST_TRACKS_COLUMNS = {
'spotify_track_id': 'TEXT PRIMARY KEY',
'title': 'TEXT',
'artist_names': 'TEXT',
'album_name': 'TEXT',
'album_artist_names': 'TEXT',
'track_number': 'INTEGER',
'album_spotify_id': 'TEXT',
'duration_ms': 'INTEGER',
'added_at_playlist': 'TEXT',
'added_to_db': 'INTEGER',
'is_present_in_spotify': 'INTEGER DEFAULT 1',
'last_seen_in_spotify': 'INTEGER'
}
EXPECTED_WATCHED_ARTISTS_COLUMNS = {
'spotify_id': 'TEXT PRIMARY KEY',
'name': 'TEXT',
'link': 'TEXT',
'total_albums_on_spotify': 'INTEGER', # Number of albums found via API
'last_checked': 'INTEGER',
'added_at': 'INTEGER',
'is_active': 'INTEGER DEFAULT 1',
'genres': 'TEXT', # Comma-separated
'popularity': 'INTEGER',
'image_url': 'TEXT'
}
EXPECTED_ARTIST_ALBUMS_COLUMNS = {
'album_spotify_id': 'TEXT PRIMARY KEY',
'artist_spotify_id': 'TEXT', # Foreign key to watched_artists
'name': 'TEXT',
'album_group': 'TEXT', # album, single, compilation, appears_on
'album_type': 'TEXT', # album, single, compilation
'release_date': 'TEXT',
'release_date_precision': 'TEXT', # year, month, day
'total_tracks': 'INTEGER',
'link': 'TEXT',
'image_url': 'TEXT',
'added_to_db': 'INTEGER',
'last_seen_on_spotify': 'INTEGER', # Timestamp when last confirmed via API
'download_task_id': 'TEXT',
'download_status': 'INTEGER DEFAULT 0', # 0: Not Queued, 1: Queued/In Progress, 2: Downloaded, 3: Error
'is_fully_downloaded_managed_by_app': 'INTEGER DEFAULT 0' # 0: No, 1: Yes (app has marked all its tracks as downloaded)
}
def _ensure_table_schema(cursor: sqlite3.Cursor, table_name: str, expected_columns: dict, table_description: str):
"""
Ensures the given table has all expected columns, adding them if necessary.
"""
try:
cursor.execute(f"PRAGMA table_info({table_name})")
existing_columns_info = cursor.fetchall()
existing_column_names = {col[1] for col in existing_columns_info}
added_columns_to_this_table = False
for col_name, col_type in expected_columns.items():
if col_name not in existing_column_names:
if 'PRIMARY KEY' in col_type.upper() and existing_columns_info: # Only warn if table already exists
logger.warning(
f"Column '{col_name}' is part of PRIMARY KEY for {table_description} '{table_name}' "
f"and was expected to be created by CREATE TABLE. Skipping explicit ADD COLUMN. "
f"Manual schema review might be needed if this table was not empty."
)
continue
col_type_for_add = col_type.replace(' PRIMARY KEY', '').strip()
try:
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_for_add}")
logger.info(f"Added missing column '{col_name} {col_type_for_add}' to {table_description} table '{table_name}'.")
added_columns_to_this_table = True
except sqlite3.OperationalError as alter_e:
logger.warning(
f"Could not add column '{col_name}' to {table_description} table '{table_name}': {alter_e}. "
f"It might already exist with a different definition or there's another schema mismatch."
)
return added_columns_to_this_table
except sqlite3.Error as e:
logger.error(f"Error ensuring schema for {table_description} table '{table_name}': {e}", exc_info=True)
return False
def _get_playlists_db_connection(): def _get_playlists_db_connection():
DB_DIR.mkdir(parents=True, exist_ok=True) DB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(PLAYLISTS_DB_PATH, timeout=10) conn = sqlite3.connect(PLAYLISTS_DB_PATH, timeout=10)
@@ -27,7 +122,7 @@ def _get_artists_db_connection():
return conn return conn
def init_playlists_db(): def init_playlists_db():
"""Initializes the playlists database and creates the main watched_playlists table if it doesn't exist.""" """Initializes the playlists database and creates/updates the main watched_playlists table."""
try: try:
with _get_playlists_db_connection() as conn: with _get_playlists_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -45,15 +140,17 @@ def init_playlists_db():
is_active INTEGER DEFAULT 1 is_active INTEGER DEFAULT 1
) )
""") """)
# Ensure schema
if _ensure_table_schema(cursor, 'watched_playlists', EXPECTED_WATCHED_PLAYLISTS_COLUMNS, "watched playlists"):
conn.commit() conn.commit()
logger.info(f"Playlists database initialized successfully at {PLAYLISTS_DB_PATH}") logger.info(f"Playlists database initialized/updated successfully at {PLAYLISTS_DB_PATH}")
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error(f"Error initializing watched_playlists table: {e}", exc_info=True) logger.error(f"Error initializing watched_playlists table: {e}", exc_info=True)
raise raise
def _create_playlist_tracks_table(playlist_spotify_id: str): def _create_playlist_tracks_table(playlist_spotify_id: str):
"""Creates a table for a specific playlist to store its tracks if it doesn't exist in playlists.db.""" """Creates or updates a table for a specific playlist to store its tracks in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" # Sanitize table name table_name = f"playlist_{playlist_spotify_id.replace('-', '_').replace(' ', '_')}" # Sanitize table name
try: try:
with _get_playlists_db_connection() as conn: # Use playlists connection with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor() cursor = conn.cursor()
@@ -73,8 +170,10 @@ def _create_playlist_tracks_table(playlist_spotify_id: str):
last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist
) )
""") """)
# Ensure schema
if _ensure_table_schema(cursor, table_name, EXPECTED_PLAYLIST_TRACKS_COLUMNS, f"playlist tracks ({playlist_spotify_id})"):
conn.commit() conn.commit()
logger.info(f"Tracks table '{table_name}' created or already exists in {PLAYLISTS_DB_PATH}.") logger.info(f"Tracks table '{table_name}' created/updated or already exists in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error(f"Error creating playlist tracks table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) logger.error(f"Error creating playlist tracks table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
raise raise
@@ -388,50 +487,66 @@ def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db:
# --- Artist Watch Database Functions --- # --- Artist Watch Database Functions ---
def init_artists_db(): def init_artists_db():
"""Initializes the artists database and creates the watched_artists table if it doesn't exist.""" """Initializes the artists database and creates/updates the main watched_artists table."""
try: try:
with _get_artists_db_connection() as conn: with _get_artists_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Note: total_albums_on_spotify, genres, popularity, image_url added to EXPECTED_WATCHED_ARTISTS_COLUMNS
# and will be added by _ensure_table_schema if missing.
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS watched_artists ( CREATE TABLE IF NOT EXISTS watched_artists (
spotify_id TEXT PRIMARY KEY, spotify_id TEXT PRIMARY KEY,
name TEXT, name TEXT,
total_albums_on_spotify INTEGER, link TEXT,
total_albums_on_spotify INTEGER, -- Number of albums found via API on last full check
last_checked INTEGER, last_checked INTEGER,
added_at INTEGER, added_at INTEGER,
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
last_known_status TEXT, genres TEXT, -- Comma-separated list of genres
last_task_id TEXT popularity INTEGER, -- Artist popularity (0-100)
image_url TEXT -- URL of the artist's image
) )
""") """)
# Ensure schema
if _ensure_table_schema(cursor, 'watched_artists', EXPECTED_WATCHED_ARTISTS_COLUMNS, "watched artists"):
conn.commit() conn.commit()
logger.info(f"Artists database initialized successfully at {ARTISTS_DB_PATH}") logger.info(f"Artists database initialized/updated successfully at {ARTISTS_DB_PATH}")
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error(f"Error initializing watched_artists table in {ARTISTS_DB_PATH}: {e}", exc_info=True) logger.error(f"Error initializing watched_artists table in {ARTISTS_DB_PATH}: {e}", exc_info=True)
raise raise
def _create_artist_albums_table(artist_spotify_id: str): def _create_artist_albums_table(artist_spotify_id: str):
"""Creates a table for a specific artist to store its albums if it doesn't exist in artists.db.""" """Creates or updates a table for a specific artist to store their albums in artists.db."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" table_name = f"artist_{artist_spotify_id.replace('-', '_').replace(' ', '_')}" # Sanitize table name
try: try:
with _get_artists_db_connection() as conn: with _get_artists_db_connection() as conn: # Use artists connection
cursor = conn.cursor() cursor = conn.cursor()
# Note: Several columns including artist_spotify_id, release_date_precision, image_url,
# last_seen_on_spotify, download_task_id, download_status, is_fully_downloaded_managed_by_app
# are part of EXPECTED_ARTIST_ALBUMS_COLUMNS and will be added by _ensure_table_schema.
cursor.execute(f""" cursor.execute(f"""
CREATE TABLE IF NOT EXISTS {table_name} ( CREATE TABLE IF NOT EXISTS {table_name} (
album_spotify_id TEXT PRIMARY KEY, album_spotify_id TEXT PRIMARY KEY,
artist_spotify_id TEXT,
name TEXT, name TEXT,
album_group TEXT, album_group TEXT,
album_type TEXT, album_type TEXT,
release_date TEXT, release_date TEXT,
release_date_precision TEXT,
total_tracks INTEGER, total_tracks INTEGER,
added_to_db_at INTEGER, link TEXT,
is_download_initiated INTEGER DEFAULT 0, image_url TEXT,
task_id TEXT, added_to_db INTEGER,
last_checked_for_tracks INTEGER last_seen_on_spotify INTEGER,
download_task_id TEXT,
download_status INTEGER DEFAULT 0,
is_fully_downloaded_managed_by_app INTEGER DEFAULT 0
) )
""") """)
# Ensure schema for the specific artist's album table
if _ensure_table_schema(cursor, table_name, EXPECTED_ARTIST_ALBUMS_COLUMNS, f"artist albums ({artist_spotify_id})"):
conn.commit() conn.commit()
logger.info(f"Albums table '{table_name}' for artist {artist_spotify_id} created or exists in {ARTISTS_DB_PATH}.") logger.info(f"Albums table '{table_name}' created/updated or already exists in {ARTISTS_DB_PATH}.")
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error(f"Error creating artist albums table {table_name} in {ARTISTS_DB_PATH}: {e}", exc_info=True) logger.error(f"Error creating artist albums table {table_name} in {ARTISTS_DB_PATH}: {e}", exc_info=True)
raise raise

View File

@@ -1,45 +1,41 @@
import { downloadQueue } from './queue.js'; import { downloadQueue } from './queue.js';
// Interfaces for validator data // Updated Interfaces for validator data
interface SpotifyValidatorData { interface SpotifyFormData {
username: string; accountName: string; // Formerly username, maps to 'name' in backend
credentials?: string; // Credentials might be optional if only username is used as an identifier authBlob: string; // Formerly credentials, maps to 'blob_content' in backend
accountRegion?: string; // Maps to 'region' in backend
} }
interface SpotifySearchValidatorData { interface DeezerFormData {
client_id: string; accountName: string; // Maps to 'name' in backend
client_secret: string;
}
interface DeezerValidatorData {
arl: string; arl: string;
accountRegion?: string; // Maps to 'region' in backend
} }
// Global service configuration object
const serviceConfig: Record<string, any> = { const serviceConfig: Record<string, any> = {
spotify: { spotify: {
fields: [ fields: [
{ id: 'username', label: 'Username', type: 'text' }, { id: 'accountName', label: 'Account Name', type: 'text' },
{ id: 'credentials', label: 'Credentials', type: 'text' } // Assuming this is password/token { id: 'accountRegion', label: 'Region (ISO 3166-1 alpha-2)', type: 'text', placeholder: 'E.g., US, DE, GB (Optional)'},
{ id: 'authBlob', label: 'Auth Blob (JSON content)', type: 'textarea', rows: 5 }
], ],
validator: (data: SpotifyValidatorData) => ({ // Typed data validator: (data: SpotifyFormData) => ({
username: data.username, name: data.accountName,
credentials: data.credentials region: data.accountRegion || null, // Send null if empty, backend might have default
blob_content: data.authBlob
}), }),
// Adding search credentials fields
searchFields: [
{ id: 'client_id', label: 'Client ID', type: 'text' },
{ id: 'client_secret', label: 'Client Secret', type: 'password' }
],
searchValidator: (data: SpotifySearchValidatorData) => ({ // Typed data
client_id: data.client_id,
client_secret: data.client_secret
})
}, },
deezer: { deezer: {
fields: [ fields: [
{ id: 'arl', label: 'ARL', type: 'text' } { id: 'accountName', label: 'Account Name', type: 'text' },
{ id: 'accountRegion', label: 'Region (ISO 3166-1 alpha-2)', type: 'text', placeholder: 'E.g., US, DE, FR (Optional)'},
{ id: 'arl', label: 'ARL Token', type: 'text' }
], ],
validator: (data: DeezerValidatorData) => ({ // Typed data validator: (data: DeezerFormData) => ({
name: data.accountName,
region: data.accountRegion || null, // Send null if empty
arl: data.arl arl: data.arl
}) })
} }
@@ -69,6 +65,9 @@ let credentialsFormCard: HTMLElement | null = null;
let showAddAccountFormBtn: HTMLElement | null = null; let showAddAccountFormBtn: HTMLElement | null = null;
let cancelAddAccountBtn: HTMLElement | null = null; let cancelAddAccountBtn: HTMLElement | null = null;
// Ensure this is defined, typically at the top with other DOM element getters if used frequently
let spotifyApiConfigStatusDiv: HTMLElement | null = null;
// Helper function to manage visibility of form and add button // Helper function to manage visibility of form and add button
function setFormVisibility(showForm: boolean) { function setFormVisibility(showForm: boolean) {
if (credentialsFormCard && showAddAccountFormBtn) { if (credentialsFormCard && showAddAccountFormBtn) {
@@ -245,6 +244,7 @@ async function initConfig() {
await updateAccountSelectors(); await updateAccountSelectors();
loadCredentials(currentService); loadCredentials(currentService);
updateFormFields(); updateFormFields();
await loadSpotifyApiConfig();
} }
function setupServiceTabs() { function setupServiceTabs() {
@@ -262,6 +262,7 @@ function setupServiceTabs() {
function setupEventListeners() { function setupEventListeners() {
(document.getElementById('credentialForm') as HTMLFormElement | null)?.addEventListener('submit', handleCredentialSubmit); (document.getElementById('credentialForm') as HTMLFormElement | null)?.addEventListener('submit', handleCredentialSubmit);
(document.getElementById('saveSpotifyApiConfigBtn') as HTMLButtonElement | null)?.addEventListener('click', saveSpotifyApiConfig);
// Config change listeners // Config change listeners
(document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.addEventListener('change', function() { (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.addEventListener('change', function() {
@@ -471,22 +472,12 @@ function renderCredentialsList(service: string, credentials: any[]) {
const credItem = document.createElement('div'); const credItem = document.createElement('div');
credItem.className = 'credential-item'; credItem.className = 'credential-item';
const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0;
credItem.innerHTML = ` credItem.innerHTML = `
<div class="credential-info"> <div class="credential-info">
<span class="credential-name">${credData.name}</span> <span class="credential-name">${credData.name}</span>
${service === 'spotify' ?
`<div class="search-credentials-status ${hasSearchCreds ? 'has-api' : 'no-api'}">
${hasSearchCreds ? 'API Configured' : 'No API Credentials'}
</div>` : ''}
</div> </div>
<div class="credential-actions"> <div class="credential-actions">
<button class="edit-btn" data-name="${credData.name}" data-service="${service}">Edit Account</button> <button class="edit-btn" data-name="${credData.name}" data-service="${service}">Edit Account</button>
${service === 'spotify' ?
`<button class="edit-search-btn" data-name="${credData.name}" data-service="${service}">
${hasSearchCreds ? 'Edit API' : 'Add API'}
</button>` : ''}
<button class="delete-btn" data-name="${credData.name}" data-service="${service}">Delete</button> <button class="delete-btn" data-name="${credData.name}" data-service="${service}">Delete</button>
</div> </div>
`; `;
@@ -505,12 +496,6 @@ function renderCredentialsList(service: string, credentials: any[]) {
handleEditCredential(e as MouseEvent); handleEditCredential(e as MouseEvent);
}); });
}); });
if (service === 'spotify') {
list.querySelectorAll('.edit-search-btn').forEach(btn => {
btn.addEventListener('click', handleEditSearchCredential as EventListener);
});
}
} }
async function handleDeleteCredential(e: Event) { async function handleDeleteCredential(e: Event) {
@@ -557,33 +542,49 @@ async function handleDeleteCredential(e: Event) {
async function handleEditCredential(e: MouseEvent) { async function handleEditCredential(e: MouseEvent) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const service = target.dataset.service; const service = target.dataset.service;
const name = target.dataset.name; const name = target.dataset.name; // This is the name of the credential being edited
try { try {
(document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click();
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
setFormVisibility(true); // Show form for editing, will hide add button setFormVisibility(true);
const response = await fetch(`/api/credentials/${service}/${name}`); const response = await fetch(`/api/credentials/${service}/${name}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load credential: ${response.statusText}`); throw new Error(`Failed to load credential: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json(); // data = {name, region, blob_content/arl}
currentCredential = name ? name : null; currentCredential = name ? name : null; // Set the global currentCredential to the one being edited
const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null;
if (credentialNameInput) { // Populate the dynamic fields created by updateFormFields
credentialNameInput.value = name || ''; // including 'accountName', 'accountRegion', and 'authBlob' or 'arl'.
credentialNameInput.disabled = true; if (serviceConfig[service!] && serviceConfig[service!].fields) {
serviceConfig[service!].fields.forEach((fieldConf: { id: string; }) => {
const element = document.getElementById(fieldConf.id) as HTMLInputElement | HTMLTextAreaElement | null;
if (element) {
if (fieldConf.id === 'accountName') {
element.value = data.name || name || ''; // Use data.name from fetched, fallback to clicked name
(element as HTMLInputElement).disabled = true; // Disable editing of account name
} else if (fieldConf.id === 'accountRegion') {
element.value = data.region || '';
} else if (fieldConf.id === 'authBlob' && service === 'spotify') {
// data.blob_content might be an object or string. Ensure textarea gets string.
element.value = typeof data.blob_content === 'object' ? JSON.stringify(data.blob_content, null, 2) : (data.blob_content || '');
} else if (fieldConf.id === 'arl' && service === 'deezer') {
element.value = data.arl || '';
} }
// Add more specific population if other fields are introduced
}
});
}
(document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Edit ${service!.charAt(0).toUpperCase() + service!.slice(1)} Account`; (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Edit ${service!.charAt(0).toUpperCase() + service!.slice(1)} Account`;
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Update Account'; (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Update Account';
// Show regular fields toggleSearchFieldsVisibility(false); // Ensure old per-account search fields are hidden
populateFormFields(service!, data);
toggleSearchFieldsVisibility(false);
} catch (error: any) { } catch (error: any) {
showConfigError(error.message); showConfigError(error.message);
} }
@@ -592,148 +593,92 @@ async function handleEditCredential(e: MouseEvent) {
async function handleEditSearchCredential(e: Event) { async function handleEditSearchCredential(e: Event) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const service = target.dataset.service; const service = target.dataset.service;
const name = target.dataset.name; // const name = target.dataset.name; // Account name, not used here anymore
try { if (service === 'spotify') {
if (service !== 'spotify') { showConfigError("Spotify API credentials are now managed globally in the 'Global Spotify API Credentials' section.");
throw new Error('Search credentials are only available for Spotify'); // Optionally, scroll to or highlight the global section
} const globalSection = document.querySelector('.global-spotify-api-config') as HTMLElement | null;
if (globalSection) globalSection.scrollIntoView({ behavior: 'smooth' });
setFormVisibility(true); // Show form for editing search creds, will hide add button
(document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click();
await new Promise(resolve => setTimeout(resolve, 50));
isEditingSearch = true;
currentCredential = name ? name : null;
const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null;
if (credentialNameInput) {
credentialNameInput.value = name || '';
credentialNameInput.disabled = true;
}
(document.getElementById('formTitle')as HTMLElement | null)!.textContent = `Spotify API Credentials for ${name}`;
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save API Credentials';
// Try to load existing search credentials
try {
const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`);
if (searchResponse.ok) {
const searchData = await searchResponse.json();
// Populate search fields
serviceConfig[service].searchFields.forEach((field: { id: string; }) => {
const element = document.getElementById(field.id) as HTMLInputElement | null;
if (element) element.value = searchData[field.id] || '';
});
} else { } else {
// Clear search fields if no existing search credentials // If this function were ever used for other services, that logic would go here.
serviceConfig[service].searchFields.forEach((field: { id: string; }) => { console.warn(`handleEditSearchCredential called for unhandled service: ${service} or function is obsolete.`);
const element = document.getElementById(field.id) as HTMLInputElement | null;
if (element) element.value = '';
});
}
} catch (error) {
// Clear search fields if there was an error
serviceConfig[service].searchFields.forEach((field: { id: string; }) => {
const element = document.getElementById(field.id) as HTMLInputElement | null;
if (element) element.value = '';
});
}
// Hide regular account fields, show search fields
toggleSearchFieldsVisibility(true);
} catch (error: any) {
showConfigError(error.message);
} }
setFormVisibility(false); // Ensure the main account form is hidden if it was opened.
} }
function toggleSearchFieldsVisibility(showSearchFields: boolean) { function toggleSearchFieldsVisibility(showSearchFields: boolean) {
const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null;
const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; // This div might be removed from HTML if not used by other services
if (showSearchFields) { // Simplified: Always show serviceFields, always hide (old) searchFields in this form context.
// Hide regular fields and remove 'required' attribute // The new global Spotify API fields are in a separate card and handled by different functions.
if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'none';
// Remove required attribute from service fields
serviceConfig[currentService].fields.forEach((field: { id: string }) => {
const input = document.getElementById(field.id) as HTMLInputElement | null;
if (input) input.removeAttribute('required');
});
// Show search fields and add 'required' attribute
if(searchFieldsDiv) searchFieldsDiv.style.display = 'block';
// Make search fields required
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach((field: { id: string }) => {
const input = document.getElementById(field.id) as HTMLInputElement | null;
if (input) input.setAttribute('required', '');
});
}
} else {
// Show regular fields and add 'required' attribute
if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'block'; if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'block';
// Make service fields required if(searchFieldsDiv) searchFieldsDiv.style.display = 'none';
// Ensure required attributes are set correctly for visible service fields
if (serviceConfig[currentService] && serviceConfig[currentService].fields) {
serviceConfig[currentService].fields.forEach((field: { id: string }) => { serviceConfig[currentService].fields.forEach((field: { id: string }) => {
const input = document.getElementById(field.id) as HTMLInputElement | null; const input = document.getElementById(field.id) as HTMLInputElement | null;
if (input) input.setAttribute('required', ''); if (input) input.setAttribute('required', '');
}); });
}
// Hide search fields and remove 'required' attribute // Ensure required attributes are removed from (old) search fields as they are hidden
if(searchFieldsDiv) searchFieldsDiv.style.display = 'none'; // This is mainly for cleanup if the searchFieldsDiv still exists for some reason.
// Remove required from search fields if (currentService === 'spotify' && serviceConfig[currentService] && serviceConfig[currentService].searchFields) { // This condition will no longer be true for spotify
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach((field: { id: string }) => { serviceConfig[currentService].searchFields.forEach((field: { id: string }) => {
const input = document.getElementById(field.id) as HTMLInputElement | null; const input = document.getElementById(field.id) as HTMLInputElement | null;
if (input) input.removeAttribute('required'); if (input) input.removeAttribute('required');
}); });
} }
}
} }
function updateFormFields() { function updateFormFields() {
const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null;
const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null;
// Clear any existing fields
if(serviceFieldsDiv) serviceFieldsDiv.innerHTML = ''; if(serviceFieldsDiv) serviceFieldsDiv.innerHTML = '';
if(searchFieldsDiv) searchFieldsDiv.innerHTML = '';
// Add regular account fields if (serviceConfig[currentService] && serviceConfig[currentService].fields) {
serviceConfig[currentService].fields.forEach((field: { className: string; label: string; type: string; id: string; }) => { serviceConfig[currentService].fields.forEach((field: { id: string; label: string; type: string; placeholder?: string; rows?: number; }) => {
const fieldDiv = document.createElement('div'); const fieldDiv = document.createElement('div');
fieldDiv.className = 'form-group'; fieldDiv.className = 'form-group';
fieldDiv.innerHTML = `
<label>${field.label}:</label> let inputElementHTML = '';
<input type="${field.type}" if (field.type === 'textarea') {
inputElementHTML = `<textarea
id="${field.id}" id="${field.id}"
name="${field.id}" name="${field.id}"
required rows="${field.rows || 3}"
${field.type === 'password' ? 'autocomplete="new-password"' : ''}> class="form-input"
placeholder="${field.placeholder || ''}"
required></textarea>`;
} else {
inputElementHTML = `<input
type="${field.type}"
id="${field.id}"
name="${field.id}"
class="form-input"
placeholder="${field.placeholder || ''}"
${field.type === 'password' ? 'autocomplete="new-password"' : ''}
required>`;
}
// Region field is optional, so remove 'required' if id is 'accountRegion'
if (field.id === 'accountRegion') {
inputElementHTML = inputElementHTML.replace(' required', '');
}
fieldDiv.innerHTML = `
<label for="${field.id}">${field.label}:</label>
${inputElementHTML}
`; `;
serviceFieldsDiv?.appendChild(fieldDiv); serviceFieldsDiv?.appendChild(fieldDiv);
}); });
// Add search fields for Spotify
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach((field: { className: string; label: string; type: string; id: string; }) => {
const fieldDiv = document.createElement('div');
fieldDiv.className = 'form-group';
fieldDiv.innerHTML = `
<label>${field.label}:</label>
<input type="${field.type}"
id="${field.id}"
name="${field.id}"
required
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
`;
searchFieldsDiv?.appendChild(fieldDiv);
});
} }
// Reset form title and button text
(document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`; (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`;
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account';
// Initially show regular fields, hide search fields
toggleSearchFieldsVisibility(false); toggleSearchFieldsVisibility(false);
isEditingSearch = false; isEditingSearch = false;
} }
@@ -748,81 +693,82 @@ function populateFormFields(service: string, data: Record<string, string>) {
async function handleCredentialSubmit(e: Event) { async function handleCredentialSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
const service = (document.querySelector('.tab-button.active') as HTMLElement | null)?.dataset.service; const service = (document.querySelector('.tab-button.active') as HTMLElement | null)?.dataset.service;
const nameInput = document.getElementById('credentialName') as HTMLInputElement | null;
const name = nameInput?.value.trim(); // Get the account name from the 'accountName' field within the dynamically generated serviceFields
const accountNameInput = document.getElementById('accountName') as HTMLInputElement | null;
const accountNameValue = accountNameInput?.value.trim();
try { try {
if (!currentCredential && !name) { // If we are editing (currentCredential is set), the name comes from currentCredential.
throw new Error('Credential name is required'); // If we are creating a new one, the name comes from the form's 'accountName' field.
if (!currentCredential && !accountNameValue) {
// Ensure accountNameInput is focused if it's empty during new credential creation
if(accountNameInput && !accountNameValue) accountNameInput.focus();
throw new Error('Account Name is required');
} }
if (!service) { if (!service) {
throw new Error('Service not selected'); throw new Error('Service not selected');
} }
const endpointName = currentCredential || name; // For POST (new), endpointName is from form. For PUT (edit), it's from currentCredential.
const endpointName = currentCredential || accountNameValue;
if (!endpointName) {
// This should ideally not be reached if the above check for accountNameValue is done correctly.
throw new Error("Account name could not be determined.");
}
let method: string, data: any, endpoint: string; let method: string, data: any, endpoint: string;
if (isEditingSearch && service === 'spotify') {
// Handle search credentials
const formData: Record<string, string> = {}; const formData: Record<string, string> = {};
let isValid = true; let isValid = true;
let firstInvalidField: HTMLInputElement | null = null; let firstInvalidField: HTMLInputElement | HTMLTextAreaElement | null = null;
// Manually validate search fields const currentServiceFields = serviceConfig[service!]?.fields as Array<{id: string, label: string, type: string}> | undefined;
serviceConfig[service!].searchFields.forEach((field: { id: string; }) => {
const input = document.getElementById(field.id) as HTMLInputElement | null; if (currentServiceFields) {
currentServiceFields.forEach((field: { id: string; }) => {
const input = document.getElementById(field.id) as HTMLInputElement | HTMLTextAreaElement | null;
const value = input ? input.value.trim() : ''; const value = input ? input.value.trim() : '';
formData[field.id] = value; formData[field.id] = value;
if (!value) { const isRequired = input?.hasAttribute('required');
if (isRequired && !value) {
isValid = false; isValid = false;
if (!firstInvalidField && input) firstInvalidField = input; if (!firstInvalidField && input) firstInvalidField = input;
} }
}); });
if (!isValid) {
if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus();
throw new Error('All fields are required');
}
data = serviceConfig[service!].searchValidator(formData);
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
// Check if search credentials already exist for this account
const checkResponse = await fetch(endpoint);
method = checkResponse.ok ? 'PUT' : 'POST';
} else { } else {
// Handle regular account credentials throw new Error(`No fields configured for service: ${service}`);
const formData: Record<string, string> = {};
let isValid = true;
let firstInvalidField: HTMLInputElement | null = null;
// Manually validate account fields
serviceConfig[service!].fields.forEach((field: { id: string; }) => {
const input = document.getElementById(field.id) as HTMLInputElement | null;
const value = input ? input.value.trim() : '';
formData[field.id] = value;
if (!value) {
isValid = false;
if (!firstInvalidField && input) firstInvalidField = input;
} }
});
if (!isValid) { if (!isValid) {
if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus(); if (firstInvalidField) {
throw new Error('All fields are required'); const nonNullInvalidField = firstInvalidField as HTMLInputElement | HTMLTextAreaElement;
nonNullInvalidField.focus();
const fieldName = (nonNullInvalidField as HTMLInputElement).labels?.[0]?.textContent || nonNullInvalidField.id || 'Unknown field';
throw new Error(`Field '${fieldName}' is required.`);
} else {
throw new Error('All required fields must be filled, but a specific invalid field was not identified.');
}
} }
// The validator in serviceConfig now expects fields like 'accountName', 'accountRegion', etc.
data = serviceConfig[service!].validator(formData); data = serviceConfig[service!].validator(formData);
// If it's a new credential and the validator didn't explicitly set 'name' from 'accountName',
// (though it should: serviceConfig.spotify.validator expects data.accountName and sets 'name')
// we ensure the 'name' in the payload matches accountNameValue if it's a new POST.
// For PUT, the name is part of the URL and shouldn't be in the body unless changing it is allowed.
// The current validators *do* map e.g. data.accountName to data.name in the output object.
// So, `data` should already have the correct `name` field from `accountName` form field.
endpoint = `/api/credentials/${service}/${endpointName}`; endpoint = `/api/credentials/${service}/${endpointName}`;
method = currentCredential ? 'PUT' : 'POST'; method = currentCredential ? 'PUT' : 'POST';
}
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data) // Data should contain {name, region, blob_content/arl}
}); });
if (!response.ok) { if (!response.ok) {
@@ -831,16 +777,13 @@ async function handleCredentialSubmit(e: Event) {
} }
await updateAccountSelectors(); await updateAccountSelectors();
await saveConfig();
loadCredentials(service!); loadCredentials(service!);
// Show success message showConfigSuccess('Account saved successfully');
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
// Add a delay before hiding the form
setTimeout(() => { setTimeout(() => {
setFormVisibility(false); // Hide form and show add button on successful submission setFormVisibility(false);
}, 2000); // 2 second delay }, 2000);
} catch (error: any) { } catch (error: any) {
showConfigError(error.message); showConfigError(error.message);
} }
@@ -849,26 +792,25 @@ async function handleCredentialSubmit(e: Event) {
function resetForm() { function resetForm() {
currentCredential = null; currentCredential = null;
isEditingSearch = false; isEditingSearch = false;
const nameInput = document.getElementById('credentialName') as HTMLInputElement | null; // The static 'credentialName' input is gone. Resetting the form should clear dynamic fields.
if (nameInput) {
nameInput.value = '';
nameInput.disabled = false;
}
(document.getElementById('credentialForm') as HTMLFormElement | null)?.reset(); (document.getElementById('credentialForm') as HTMLFormElement | null)?.reset();
// Reset conversion dropdowns to ensure bitrate is updated correctly // Enable the accountName field again if it was disabled during an edit operation
const convertToSelect = document.getElementById('convertToSelect') as HTMLSelectElement | null; const accountNameInput = document.getElementById('accountName') as HTMLInputElement | null;
if (convertToSelect) { if (accountNameInput) {
convertToSelect.value = ''; // Reset to 'No Conversion' accountNameInput.disabled = false;
updateBitrateOptions(''); // Update bitrate for 'No Conversion' }
const convertToSelect = document.getElementById('convertToSelect') as HTMLSelectElement | null;
if (convertToSelect) {
convertToSelect.value = '';
updateBitrateOptions('');
} }
// Reset form title and button text
const serviceName = currentService.charAt(0).toUpperCase() + currentService.slice(1); const serviceName = currentService.charAt(0).toUpperCase() + currentService.slice(1);
(document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${serviceName} Account`; (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${serviceName} Account`;
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account';
// Show regular account fields, hide search fields
toggleSearchFieldsVisibility(false); toggleSearchFieldsVisibility(false);
} }
@@ -1181,3 +1123,95 @@ function updateBitrateOptions(selectedFormat: string) {
bitrateSelect.value = ''; bitrateSelect.value = '';
} }
} }
// Function to load global Spotify API credentials
async function loadSpotifyApiConfig() {
const clientIdInput = document.getElementById('globalSpotifyClientId') as HTMLInputElement | null;
const clientSecretInput = document.getElementById('globalSpotifyClientSecret') as HTMLInputElement | null;
spotifyApiConfigStatusDiv = document.getElementById('spotifyApiConfigStatus') as HTMLElement | null; // Assign here or ensure it's globally available
if (!clientIdInput || !clientSecretInput || !spotifyApiConfigStatusDiv) {
console.error("Global Spotify API config form elements not found.");
if(spotifyApiConfigStatusDiv) spotifyApiConfigStatusDiv.textContent = 'Error: Form elements missing.';
return;
}
try {
const response = await fetch('/api/credentials/spotify_api_config');
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Failed to load Spotify API config, server error.' }));
throw new Error(errorData.error || `HTTP error ${response.status}`);
}
const data = await response.json();
clientIdInput.value = data.client_id || '';
clientSecretInput.value = data.client_secret || '';
if (data.warning) {
spotifyApiConfigStatusDiv.textContent = data.warning;
spotifyApiConfigStatusDiv.className = 'status-message warning';
} else if (data.client_id && data.client_secret) {
spotifyApiConfigStatusDiv.textContent = 'Current API credentials loaded.';
spotifyApiConfigStatusDiv.className = 'status-message success';
} else {
spotifyApiConfigStatusDiv.textContent = 'Global Spotify API credentials are not set.';
spotifyApiConfigStatusDiv.className = 'status-message neutral';
}
} catch (error: any) {
console.error('Error loading Spotify API config:', error);
if(spotifyApiConfigStatusDiv) {
spotifyApiConfigStatusDiv.textContent = `Error loading config: ${error.message}`;
spotifyApiConfigStatusDiv.className = 'status-message error';
}
}
}
// Function to save global Spotify API credentials
async function saveSpotifyApiConfig() {
const clientIdInput = document.getElementById('globalSpotifyClientId') as HTMLInputElement | null;
const clientSecretInput = document.getElementById('globalSpotifyClientSecret') as HTMLInputElement | null;
// spotifyApiConfigStatusDiv should be already assigned by loadSpotifyApiConfig or be a global var
if (!spotifyApiConfigStatusDiv) { // Re-fetch if null, though it should not be if load ran.
spotifyApiConfigStatusDiv = document.getElementById('spotifyApiConfigStatus') as HTMLElement | null;
}
if (!clientIdInput || !clientSecretInput || !spotifyApiConfigStatusDiv) {
console.error("Global Spotify API config form elements not found for saving.");
if(spotifyApiConfigStatusDiv) spotifyApiConfigStatusDiv.textContent = 'Error: Form elements missing.';
return;
}
const client_id = clientIdInput.value.trim();
const client_secret = clientSecretInput.value.trim();
if (!client_id || !client_secret) {
spotifyApiConfigStatusDiv.textContent = 'Client ID and Client Secret cannot be empty.';
spotifyApiConfigStatusDiv.className = 'status-message error';
if(!client_id) clientIdInput.focus(); else clientSecretInput.focus();
return;
}
try {
spotifyApiConfigStatusDiv.textContent = 'Saving...';
spotifyApiConfigStatusDiv.className = 'status-message neutral';
const response = await fetch('/api/credentials/spotify_api_config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id, client_secret })
});
const responseData = await response.json(); // Try to parse JSON regardless of ok status for error messages
if (!response.ok) {
throw new Error(responseData.error || `Failed to save Spotify API config. Status: ${response.status}`);
}
spotifyApiConfigStatusDiv.textContent = responseData.message || 'Spotify API credentials saved successfully!';
spotifyApiConfigStatusDiv.className = 'status-message success';
} catch (error: any) {
console.error('Error saving Spotify API config:', error);
if(spotifyApiConfigStatusDiv) {
spotifyApiConfigStatusDiv.textContent = `Error saving: ${error.message}`;
spotifyApiConfigStatusDiv.className = 'status-message error';
}
}
}

View File

@@ -263,6 +263,29 @@ body {
transform: translateY(-2px); transform: translateY(-2px);
} }
/* Master Accounts Configuration Section (Global API Keys + Per-Account Lists) */
.master-accounts-config-section {
background: #181818; /* Consistent with other sections */
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transition: transform 0.3s ease; /* Optional, for consistency */
}
.master-accounts-config-section:hover {
transform: translateY(-2px); /* Optional, for consistency */
}
/* Section for Global Spotify API Key Configuration */
.global-api-keys-config {
background: #2a2a2a; /* Slightly different background to stand out or match input groups */
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem; /* Space before the per-account sections start */
border: 1px solid #404040; /* Subtle border */
}
/* Section Titles */ /* Section Titles */
.section-title { .section-title {
font-size: 1.5rem; font-size: 1.5rem;
@@ -841,11 +864,6 @@ input:checked + .slider:before {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* Where individual credential items will be rendered */
.credentials-list-items {
/* No specific styles needed here unless items need separation from the add button */
}
/* Styling for the Add New Account button to make it look like a list item */ /* Styling for the Add New Account button to make it look like a list item */
.add-account-item { .add-account-item {
margin-top: 0.75rem; /* Space above the add button if there are items */ margin-top: 0.75rem; /* Space above the add button if there are items */

View File

@@ -318,6 +318,27 @@
</div> </div>
</div> </div>
<div class="master-accounts-config-section">
<h2 class="section-title">Accounts configuration</h2>
<!-- Global Spotify API Credentials Card: MOVED HERE -->
<div class="global-api-keys-config card"> <!-- Changed class to global-api-keys-config -->
<h2 class="section-title">Global Spotify API Credentials</h2>
<div class="config-item">
<label for="globalSpotifyClientId">Client ID:</label>
<input type="text" id="globalSpotifyClientId" class="form-input" placeholder="Enter your Spotify Client ID">
</div>
<div class="config-item">
<label for="globalSpotifyClientSecret">Client Secret:</label>
<input type="password" id="globalSpotifyClientSecret" class="form-input" placeholder="Enter your Spotify Client Secret">
</div>
<div class="config-item">
<button id="saveSpotifyApiConfigBtn" class="btn btn-primary">Save</button>
</div>
<div id="spotifyApiConfigStatus" class="status-message" style="margin-top: 10px;"></div>
</div>
<!-- End Global Spotify API Credentials Card -->
<div class="accounts-section"> <div class="accounts-section">
<div class="service-tabs"> <div class="service-tabs">
<button class="tab-button active" data-service="spotify">Spotify</button> <button class="tab-button active" data-service="spotify">Spotify</button>
@@ -340,10 +361,6 @@
<div class="credentials-form card"> <div class="credentials-form card">
<h2 id="formTitle" class="section-title">Add New Spotify Account</h2> <h2 id="formTitle" class="section-title">Add New Spotify Account</h2>
<form id="credentialForm"> <form id="credentialForm">
<div class="config-item">
<label>Name:</label>
<input type="text" id="credentialName" class="form-input" required />
</div>
<div id="serviceFields"></div> <div id="serviceFields"></div>
<div id="searchFields" style="display: none;"></div> <div id="searchFields" style="display: none;"></div>
<button type="submit" id="submitCredentialBtn" class="btn btn-primary save-btn">Save Account</button> <button type="submit" id="submitCredentialBtn" class="btn btn-primary save-btn">Save Account</button>
@@ -355,6 +372,7 @@
<div id="configError" class="error"></div> <div id="configError" class="error"></div>
</div> </div>
</div> </div>
</div> <!-- End of accounts-section -->
</div> </div>
</div> </div>