architectural changes, preparing for playlist & artist watching

This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-27 12:01:49 -06:00
parent 59370367bd
commit e822284b88
25 changed files with 844 additions and 174 deletions

View File

@@ -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':