architectural changes, preparing for playlist & artist watching
This commit is contained in:
@@ -1,6 +1,85 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from deezspot.spotloader import SpoLogin
|
||||
from deezspot.deezloader import DeeLogin
|
||||
import traceback # For logging detailed error messages
|
||||
import time # For retry delays
|
||||
|
||||
def _get_spotify_search_creds(creds_dir: Path):
|
||||
"""Helper to load client_id and client_secret from search.json for a Spotify account."""
|
||||
search_file = creds_dir / 'search.json'
|
||||
if search_file.exists():
|
||||
try:
|
||||
with open(search_file, 'r') as f:
|
||||
search_data = json.load(f)
|
||||
return search_data.get('client_id'), search_data.get('client_secret')
|
||||
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):
|
||||
"""
|
||||
Attempts to validate credentials with retries for connection errors.
|
||||
- For Spotify, cred_file_path is used.
|
||||
- For Deezer, data_for_validation (which contains the 'arl' key) is used.
|
||||
Returns True if validated, raises ValueError if not.
|
||||
"""
|
||||
max_retries = 5
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if is_spotify:
|
||||
client_id, client_secret = _get_spotify_search_creds(creds_dir_path)
|
||||
SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret)
|
||||
else: # Deezer
|
||||
arl = data_for_validation.get('arl')
|
||||
if not arl:
|
||||
# This should be caught by prior checks, but as a safeguard:
|
||||
raise ValueError("Missing 'arl' for Deezer validation.")
|
||||
DeeLogin(arl=arl)
|
||||
|
||||
print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).")
|
||||
return True # Validation successful
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
error_str = str(e).lower()
|
||||
# More comprehensive check for connection-related errors
|
||||
is_connection_error = (
|
||||
"connection refused" in error_str or
|
||||
"connection error" in error_str or
|
||||
"timeout" in error_str or
|
||||
"temporary failure in name resolution" in error_str or
|
||||
"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:
|
||||
retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s)
|
||||
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...")
|
||||
time.sleep(retry_delay)
|
||||
continue # Go to next retry attempt
|
||||
else:
|
||||
# Not a connection error, or it's the last retry for a connection error
|
||||
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 # 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:
|
||||
base_error_message = str(last_exception).splitlines()[-1]
|
||||
detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}"
|
||||
if is_spotify and "incorrect padding" in base_error_message.lower():
|
||||
detailed_error_message += ". Hint: Do not throw your password here, read the docs"
|
||||
# traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace
|
||||
raise ValueError(detailed_error_message)
|
||||
else: # Should not happen if loop runs at least once
|
||||
raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).")
|
||||
|
||||
def get_credential(service, name, cred_type='credentials'):
|
||||
"""
|
||||
@@ -28,7 +107,7 @@ def get_credential(service, name, cred_type='credentials'):
|
||||
if service == 'deezer' and cred_type == 'search':
|
||||
raise ValueError("Search credentials are only supported for Spotify")
|
||||
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
file_path = creds_dir / f'{cred_type}.json'
|
||||
|
||||
if not file_path.exists():
|
||||
@@ -56,7 +135,7 @@ def list_credentials(service):
|
||||
if service not in ['spotify', 'deezer']:
|
||||
raise ValueError("Service must be 'spotify' or 'deezer'")
|
||||
|
||||
service_dir = Path('./creds') / service
|
||||
service_dir = Path('./data/creds') / service
|
||||
if not service_dir.exists():
|
||||
return []
|
||||
|
||||
@@ -116,21 +195,80 @@ def create_credential(service, name, data, cred_type='credentials'):
|
||||
raise ValueError(f"Missing required field for {cred_type}: {field}")
|
||||
|
||||
# Create directory
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
file_created_now = False
|
||||
dir_created_now = False
|
||||
|
||||
if cred_type == 'credentials':
|
||||
try:
|
||||
creds_dir.mkdir(parents=True, exist_ok=False)
|
||||
dir_created_now = True
|
||||
except FileExistsError:
|
||||
raise FileExistsError(f"Credential '{name}' already exists for {service}")
|
||||
else:
|
||||
# Directory already exists, which is fine for creating credentials.json
|
||||
# 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():
|
||||
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
|
||||
|
||||
# Write credentials file
|
||||
file_path = creds_dir / f'{cred_type}.json'
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
# 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:
|
||||
raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}")
|
||||
|
||||
file_path = creds_dir / 'search.json'
|
||||
# No specific validation for client_id/secret themselves, they are validated in use.
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def delete_credential(service, name, cred_type=None):
|
||||
"""
|
||||
@@ -145,7 +283,7 @@ def delete_credential(service, name, cred_type=None):
|
||||
Raises:
|
||||
FileNotFoundError: If the credential directory or specified file does not exist
|
||||
"""
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
|
||||
if cred_type:
|
||||
if cred_type not in ['credentials', 'search']:
|
||||
@@ -193,23 +331,29 @@ def edit_credential(service, name, new_data, cred_type='credentials'):
|
||||
raise ValueError("Search credentials are only supported for Spotify")
|
||||
|
||||
# Get file path
|
||||
creds_dir = Path('./creds') / service / name
|
||||
creds_dir = Path('./data/creds') / service / name
|
||||
file_path = creds_dir / f'{cred_type}.json'
|
||||
|
||||
# For search.json, create if it doesn't exist
|
||||
if cred_type == 'search' and not file_path.exists():
|
||||
if not creds_dir.exists():
|
||||
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
|
||||
data = {}
|
||||
else:
|
||||
# Load existing data
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}")
|
||||
|
||||
original_data_str = None # Store original data as string to revert
|
||||
file_existed_before_edit = file_path.exists()
|
||||
|
||||
if file_existed_before_edit:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Validate new_data fields
|
||||
original_data_str = f.read()
|
||||
try:
|
||||
data = json.loads(original_data_str)
|
||||
except json.JSONDecodeError:
|
||||
# If existing file is corrupt, treat as if we are creating it anew for edit
|
||||
data = {}
|
||||
original_data_str = None # Can't revert to corrupt data
|
||||
else:
|
||||
# If file doesn't exist, and we're editing (PUT), it's usually an error
|
||||
# unless it's for search.json which can be created during an edit flow.
|
||||
if cred_type == 'credentials':
|
||||
raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}")
|
||||
data = {} # Start with empty data for search.json creation
|
||||
|
||||
# Validate new_data fields (data to be merged)
|
||||
allowed_fields = []
|
||||
if cred_type == 'credentials':
|
||||
if service == 'spotify':
|
||||
@@ -223,15 +367,66 @@ def edit_credential(service, name, new_data, cred_type='credentials'):
|
||||
if key not in allowed_fields:
|
||||
raise ValueError(f"Invalid field '{key}' for {cred_type} credentials")
|
||||
|
||||
# Update data
|
||||
# Update data (merging new_data into existing or empty data)
|
||||
data.update(new_data)
|
||||
|
||||
# For Deezer: Strip all fields except 'arl'
|
||||
# --- Write and Validate Step for 'credentials' type ---
|
||||
if cred_type == 'credentials':
|
||||
try:
|
||||
# Temporarily write new data for validation
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
_validate_with_retry(
|
||||
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
|
||||
elif cred_type == 'search':
|
||||
if not creds_dir.exists(): # Should not happen if we're editing
|
||||
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")
|
||||
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':
|
||||
|
||||
Reference in New Issue
Block a user