This commit is contained in:
architect.in.git
2025-04-23 13:28:55 -06:00
22 changed files with 1565 additions and 767 deletions

13
.env Normal file
View File

@@ -0,0 +1,13 @@
# Docker Compose environment variables
# Redis connection (external or internal)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=CHANGE_ME
EXPLICIT_FILTER=false # Set to true to filter out explicit content
PUID=1000 # User ID for the container
PGID=1000 # Group ID for the container
UMASK=0022 # Optional: Sets the default file permissions for newly created files within the container.

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ queue_state.json
search_demo.py search_demo.py
celery_worker.log celery_worker.log
logs/spotizerr.log logs/spotizerr.log
/.venv

View File

@@ -49,9 +49,13 @@ Music downloader which combines the best of two worlds: Spotify's catalog and De
mkdir spotizerr && cd spotizerr mkdir spotizerr && cd spotizerr
``` ```
2. Copy `docker-compose.yml` from this repo 2. Copy the `.env` file from this repo and update all variables (e.g. Redis credentials, PUID/PGID, UMASK).
3. Copy `docker-compose.yml` from this repo.
3. Launch container: 4. Create required directories:
```bash
mkdir -p creds config downloads logs cache
```
5. Launch containers:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
@@ -234,6 +238,21 @@ Copy that value and paste it into the correspondant setting in Spotizerr
- Track number padding (01. Track or 1. Track) - Track number padding (01. Track or 1. Track)
- Adjust retry parameters (max attempts, delay, delay increase) - Adjust retry parameters (max attempts, delay, delay increase)
### Environment Variables
Define your variables in the `.env` file in the project root:
```dotenv
REDIS_HOST=redis # Redis host name
REDIS_PORT=6379 # Redis port number
REDIS_DB=0 # Redis DB index
REDIS_PASSWORD=CHANGE_ME # Redis AUTH password
EXPLICIT_FILTER=false # Filter explicit content
PUID=1000 # Container user ID
PGID=1000 # Container group ID
UMASK=0022 # Default file permission mask
SPOTIPY_CACHE_PATH=/app/cache/.cache # Spotify token cache path
```
## Troubleshooting ## Troubleshooting
**Common Issues**: **Common Issues**:

16
app.py
View File

@@ -17,6 +17,7 @@ import atexit
import sys import sys
import redis import redis
import socket import socket
from urllib.parse import urlparse
# Import Celery configuration and manager # Import Celery configuration and manager
from routes.utils.celery_tasks import celery_app from routes.utils.celery_tasks import celery_app
@@ -85,11 +86,16 @@ def check_redis_connection():
redis_port = 6379 # default redis_port = 6379 # default
# Parse from REDIS_URL if possible # Parse from REDIS_URL if possible
if REDIS_URL and "://" in REDIS_URL: if REDIS_URL:
parts = REDIS_URL.split("://")[1].split(":") # parse hostname and port (handles optional auth)
if len(parts) >= 2: try:
redis_host = parts[0] parsed = urlparse(REDIS_URL)
redis_port = int(parts[1].split("/")[0]) if parsed.hostname:
redis_host = parsed.hostname
if parsed.port:
redis_port = parsed.port
except Exception:
pass
# Log Redis connection details # Log Redis connection details
logging.info(f"Checking Redis connection to {redis_host}:{redis_port}") logging.info(f"Checking Redis connection to {redis_host}:{redis_port}")

View File

@@ -9,19 +9,20 @@ services:
- ./logs:/app/logs # <-- Volume for persistent logs - ./logs:/app/logs # <-- Volume for persistent logs
ports: ports:
- 7171:7171 - 7171:7171
image: cooldockerizer93/spotizerr image: cooldockerizer93/spotizerr:dev
container_name: spotizerr-app container_name: spotizerr-app
restart: unless-stopped restart: unless-stopped
environment: environment:
- PUID=1000 # Replace with your desired user ID | Remove both if you want to run as root (not recommended, might result in unreadable files) - PUID=${PUID} # Replace with your desired user ID | Remove both if you want to run as root (not recommended, might result in unreadable files)
- PGID=1000 # Replace with your desired group ID | The user must have write permissions in the volume mapped to /app/downloads - PGID=${PGID} # Replace with your desired group ID | The user must have write permissions in the volume mapped to /app/downloads
- UMASK=0022 # Optional: Sets the default file permissions for newly created files within the container. - UMASK=${UMASK} # Optional: Sets the default file permissions for newly created files within the container.
- REDIS_HOST=redis - REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=6379 - REDIS_PORT=${REDIS_PORT}
- REDIS_DB=0 - REDIS_DB=${REDIS_DB}
- REDIS_URL=redis://redis:6379/0 - REDIS_PASSWORD=${REDIS_PASSWORD} # Optional, Redis AUTH password. Leave empty if not using authentication
- REDIS_BACKEND=redis://redis:6379/0 - REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}
- EXPLICIT_FILTER=false # Set to true to filter out explicit content - REDIS_BACKEND=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}
- EXPLICIT_FILTER=${EXPLICIT_FILTER} # Set to true to filter out explicit content
depends_on: depends_on:
- redis - redis
@@ -29,9 +30,11 @@ services:
image: redis:alpine image: redis:alpine
container_name: spotizerr-redis container_name: spotizerr-redis
restart: unless-stopped restart: unless-stopped
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
volumes: volumes:
- redis-data:/data - redis-data:/data
command: redis-server --appendonly yes command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes: volumes:
redis-data: redis-data:

View File

@@ -52,7 +52,10 @@ else
# Ensure proper permissions for all app directories # Ensure proper permissions for all app directories
echo "Setting permissions for /app directories..." echo "Setting permissions for /app directories..."
chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/config /app/creds /app/logs || true chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/config /app/creds /app/logs /app/cache || true
# Ensure Spotipy cache file exists and is writable
touch /app/.cache || true
chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true
# Run as specified user # Run as specified user
echo "Starting application as ${USER_NAME}..." echo "Starting application as ${USER_NAME}..."

View File

@@ -23,12 +23,15 @@ def handle_download():
# Add the task to the queue with only essential parameters # Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters # The queue manager will now handle all config parameters
# Include full original request URL in metadata
orig_params = request.args.to_dict()
orig_params["original_url"] = request.url
task_id = download_queue_manager.add_task({ task_id = download_queue_manager.add_task({
"download_type": "album", "download_type": "album",
"url": url, "url": url,
"name": name, "name": name,
"artist": artist, "artist": artist,
"orig_request": request.args.to_dict() "orig_request": orig_params
}) })
return Response( return Response(

View File

@@ -23,12 +23,15 @@ def handle_download():
# Add the task to the queue with only essential parameters # Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters # The queue manager will now handle all config parameters
# Include full original request URL in metadata
orig_params = request.args.to_dict()
orig_params["original_url"] = request.url
task_id = download_queue_manager.add_task({ task_id = download_queue_manager.add_task({
"download_type": "playlist", "download_type": "playlist",
"url": url, "url": url,
"name": name, "name": name,
"artist": artist, "artist": artist,
"orig_request": request.args.to_dict() "orig_request": orig_params
}) })
return Response( return Response(

View File

@@ -20,8 +20,7 @@ logger = logging.getLogger(__name__)
prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs') prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs')
# The old path for PRG files (keeping for backward compatibility during transition) # (Old .prg file system removed. Using new task system only.)
PRGS_DIR = os.path.join(os.getcwd(), 'prgs')
@prgs_bp.route('/<task_id>', methods=['GET']) @prgs_bp.route('/<task_id>', methods=['GET'])
def get_prg_file(task_id): def get_prg_file(task_id):
@@ -35,236 +34,21 @@ def get_prg_file(task_id):
Args: Args:
task_id: Either a task UUID from Celery or a PRG filename from the old system task_id: Either a task UUID from Celery or a PRG filename from the old system
""" """
try: # Only support new task IDs
# First check if this is a task ID in the new system task_info = get_task_info(task_id)
task_info = get_task_info(task_id) if not task_info:
abort(404, "Task not found")
if task_info: original_request = task_info.get("original_request", {})
# This is a task ID in the new system last_status = get_last_task_status(task_id)
original_request = task_info.get("original_request", {}) status_count = len(get_task_status(task_id))
response = {
# Get the latest status update for this task "original_url": original_request.get("original_url", ""),
last_status = get_last_task_status(task_id) "last_line": last_status,
logger.debug(f"API: Got last_status for {task_id}: {json.dumps(last_status) if last_status else None}") "timestamp": time.time(),
"task_id": task_id,
# Get all status updates for debugging "status_count": status_count
all_statuses = get_task_status(task_id) }
status_count = len(all_statuses) return jsonify(response)
logger.debug(f"API: Task {task_id} has {status_count} status updates")
# Prepare the response with basic info
response = {
"type": task_info.get("type", ""),
"name": task_info.get("name", ""),
"artist": task_info.get("artist", ""),
"last_line": last_status,
"original_request": original_request,
"display_title": original_request.get("display_title", task_info.get("name", "")),
"display_type": original_request.get("display_type", task_info.get("type", "")),
"display_artist": original_request.get("display_artist", task_info.get("artist", "")),
"status_count": status_count,
"task_id": task_id,
"timestamp": time.time()
}
# Handle different status types
if last_status:
status_type = last_status.get("status", "unknown")
# Set event type based on status (like in the previous SSE implementation)
event_type = "update"
if status_type in [ProgressState.COMPLETE, ProgressState.DONE]:
event_type = "complete"
elif status_type == ProgressState.TRACK_COMPLETE:
event_type = "track_complete"
elif status_type == ProgressState.ERROR:
event_type = "error"
elif status_type in [ProgressState.TRACK_PROGRESS, ProgressState.REAL_TIME]:
event_type = "progress"
response["event"] = event_type
# For terminal statuses (complete, error, cancelled)
if status_type in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED]:
response["progress_message"] = last_status.get("message", f"Download {status_type}")
# For progress status with track information
elif status_type == "progress" and last_status.get("track"):
# Add explicit track progress fields to the top level for easy access
response["current_track"] = last_status.get("track", "")
response["track_number"] = last_status.get("parsed_current_track", 0)
response["total_tracks"] = last_status.get("parsed_total_tracks", 0)
response["progress_percent"] = last_status.get("overall_progress", 0)
response["album"] = last_status.get("album", "")
# Format a nice progress message for display
track_info = last_status.get("track", "")
current = last_status.get("parsed_current_track", 0)
total = last_status.get("parsed_total_tracks", 0)
progress = last_status.get("overall_progress", 0)
if current and total:
response["progress_message"] = f"Downloading track {current}/{total} ({progress}%): {track_info}"
elif track_info:
response["progress_message"] = f"Downloading: {track_info}"
# For real-time status messages
elif status_type == "real_time":
# Add real-time specific fields
response["current_song"] = last_status.get("song", "")
response["percent"] = last_status.get("percent", 0)
response["percentage"] = last_status.get("percentage", 0)
response["time_elapsed"] = last_status.get("time_elapsed", 0)
# Format a nice progress message for display
song = last_status.get("song", "")
percent = last_status.get("percent", 0)
if song:
response["progress_message"] = f"Downloading {song} ({percent}%)"
else:
response["progress_message"] = f"Downloading ({percent}%)"
# For initializing status
elif status_type == "initializing":
album = last_status.get("album", "")
if album:
response["progress_message"] = f"Initializing download for {album}"
else:
response["progress_message"] = "Initializing download..."
# For processing status (default)
elif status_type == "processing":
# Search for the most recent track progress in all statuses
has_progress = False
for status in reversed(all_statuses):
if status.get("status") == "progress" and status.get("track"):
# Use this track progress information
track_info = status.get("track", "")
current_raw = status.get("current_track", "")
response["current_track"] = track_info
# Try to parse track numbers if available
if isinstance(current_raw, str) and "/" in current_raw:
try:
parts = current_raw.split("/")
current = int(parts[0])
total = int(parts[1])
response["track_number"] = current
response["total_tracks"] = total
response["progress_percent"] = min(int((current / total) * 100), 100)
response["progress_message"] = f"Processing track {current}/{total}: {track_info}"
except (ValueError, IndexError):
response["progress_message"] = f"Processing: {track_info}"
else:
response["progress_message"] = f"Processing: {track_info}"
has_progress = True
break
if not has_progress:
# Just use the processing message
response["progress_message"] = last_status.get("message", "Processing download...")
# For other status types
else:
response["progress_message"] = last_status.get("message", f"Status: {status_type}")
return jsonify(response)
# If not found in new system, try the old PRG file system
# Security check to prevent path traversal attacks.
if '..' in task_id or '/' in task_id:
abort(400, "Invalid file request")
filepath = os.path.join(PRGS_DIR, task_id)
with open(filepath, 'r') as f:
content = f.read()
lines = content.splitlines()
# If the file is empty, return default values.
if not lines:
return jsonify({
"type": "",
"name": "",
"artist": "",
"last_line": None,
"original_request": None,
"display_title": "",
"display_type": "",
"display_artist": "",
"task_id": task_id,
"event": "unknown"
})
# Attempt to extract the original request from the first line.
original_request = None
display_title = ""
display_type = ""
display_artist = ""
try:
first_line = json.loads(lines[0])
if isinstance(first_line, dict):
if "original_request" in first_line:
original_request = first_line["original_request"]
else:
# The first line might be the original request itself
original_request = first_line
# Extract display information from the original request
if original_request:
display_title = original_request.get("display_title", original_request.get("name", ""))
display_type = original_request.get("display_type", original_request.get("type", ""))
display_artist = original_request.get("display_artist", original_request.get("artist", ""))
except Exception as e:
print(f"Error parsing first line of PRG file: {e}")
original_request = None
# For resource type and name, use the second line if available.
resource_type = ""
resource_name = ""
resource_artist = ""
if len(lines) > 1:
try:
second_line = json.loads(lines[1])
# Directly extract 'type' and 'name' from the JSON
resource_type = second_line.get("type", "")
resource_name = second_line.get("name", "")
resource_artist = second_line.get("artist", "")
except Exception:
resource_type = ""
resource_name = ""
resource_artist = ""
else:
resource_type = ""
resource_name = ""
resource_artist = ""
# Get the last line from the file.
last_line_raw = lines[-1]
try:
last_line_parsed = json.loads(last_line_raw)
except Exception:
last_line_parsed = last_line_raw # Fallback to raw string if JSON parsing fails.
return jsonify({
"type": resource_type,
"name": resource_name,
"artist": resource_artist,
"last_line": last_line_parsed,
"original_request": original_request,
"display_title": display_title,
"display_type": display_type,
"display_artist": display_artist,
"task_id": task_id,
"event": "unknown", # Old files don't have event types
"timestamp": time.time()
})
except FileNotFoundError:
abort(404, "Task or file not found")
except Exception as e:
abort(500, f"An error occurred: {e}")
@prgs_bp.route('/delete/<task_id>', methods=['DELETE']) @prgs_bp.route('/delete/<task_id>', methods=['DELETE'])
@@ -276,42 +60,15 @@ def delete_prg_file(task_id):
Args: Args:
task_id: Either a task UUID from Celery or a PRG filename from the old system task_id: Either a task UUID from Celery or a PRG filename from the old system
""" """
try: # Only support new task IDs
# First try to delete from Redis if it's a task ID task_info = get_task_info(task_id)
task_info = get_task_info(task_id) if not task_info:
abort(404, "Task not found")
if task_info: cancel_task(task_id)
# This is a task ID in the new system - we should cancel it first from routes.utils.celery_tasks import redis_client
# if it's still running, then clear its data from Redis redis_client.delete(f"task:{task_id}:info")
cancel_result = cancel_task(task_id) redis_client.delete(f"task:{task_id}:status")
return {'message': f'Task {task_id} deleted successfully'}, 200
# Use Redis connection to delete the task data
from routes.utils.celery_tasks import redis_client
# Delete task info and status
redis_client.delete(f"task:{task_id}:info")
redis_client.delete(f"task:{task_id}:status")
return {'message': f'Task {task_id} deleted successfully'}, 200
# If not found in Redis, try the old PRG file system
# Security checks to prevent path traversal and ensure correct file type.
if '..' in task_id or '/' in task_id:
abort(400, "Invalid file request")
if not task_id.endswith('.prg'):
abort(400, "Only .prg files can be deleted")
filepath = os.path.join(PRGS_DIR, task_id)
if not os.path.isfile(filepath):
abort(404, "File not found")
os.remove(filepath)
return {'message': f'File {task_id} deleted successfully'}, 200
except FileNotFoundError:
abort(404, "Task or file not found")
except Exception as e:
abort(500, f"An error occurred: {e}")
@prgs_bp.route('/list', methods=['GET']) @prgs_bp.route('/list', methods=['GET'])
@@ -320,25 +77,10 @@ def list_prg_files():
Retrieve a list of all tasks in the system. Retrieve a list of all tasks in the system.
Combines results from both the old PRG file system and the new task ID based system. Combines results from both the old PRG file system and the new task ID based system.
""" """
try: # List only new system tasks
# Get tasks from the new system tasks = get_all_tasks()
tasks = get_all_tasks() task_ids = [task["task_id"] for task in tasks]
task_ids = [task["task_id"] for task in tasks] return jsonify(task_ids)
# Get PRG files from the old system
prg_files = []
if os.path.isdir(PRGS_DIR):
with os.scandir(PRGS_DIR) as entries:
for entry in entries:
if entry.is_file() and entry.name.endswith('.prg'):
prg_files.append(entry.name)
# Combine both lists
all_ids = task_ids + prg_files
return jsonify(all_ids)
except Exception as e:
abort(500, f"An error occurred: {e}")
@prgs_bp.route('/retry/<task_id>', methods=['POST']) @prgs_bp.route('/retry/<task_id>', methods=['POST'])

View File

@@ -3,6 +3,7 @@ import os
import json import json
import traceback import traceback
from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_queue_manager import download_queue_manager
from urllib.parse import urlparse # for URL validation
track_bp = Blueprint('track', __name__) track_bp = Blueprint('track', __name__)
@@ -16,19 +17,30 @@ def handle_download():
# Validate required parameters # Validate required parameters
if not url: if not url:
return Response( return Response(
json.dumps({"error": "Missing required parameter: url"}), json.dumps({"error": "Missing required parameter: url", "original_url": url}),
status=400,
mimetype='application/json'
)
# Validate URL domain
parsed = urlparse(url)
host = parsed.netloc.lower()
if not (host.endswith('deezer.com') or host.endswith('open.spotify.com') or host.endswith('spotify.com')):
return Response(
json.dumps({"error": f"Invalid Link {url} :(", "original_url": url}),
status=400, status=400,
mimetype='application/json' mimetype='application/json'
) )
# Add the task to the queue with only essential parameters # Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters # The queue manager will now handle all config parameters
orig_params = request.args.to_dict()
orig_params["original_url"] = request.url
task_id = download_queue_manager.add_task({ task_id = download_queue_manager.add_task({
"download_type": "track", "download_type": "track",
"url": url, "url": url,
"name": name, "name": name,
"artist": artist, "artist": artist,
"orig_request": request.args.to_dict() "orig_request": orig_params
}) })
return Response( return Response(

View File

@@ -6,7 +6,6 @@ from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
def download_album( def download_album(
service,
url, url,
main, main,
fallback=None, fallback=None,
@@ -22,8 +21,24 @@ def download_album(
progress_callback=None progress_callback=None
): ):
try: try:
# DEBUG: Print parameters # Detect URL source (Spotify or Deezer) from URL
print(f"DEBUG: album.py received - service={service}, main={main}, fallback={fallback}") is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower()
# Determine service exclusively from URL
if is_spotify_url:
service = 'spotify'
elif is_deezer_url:
service = 'deezer'
else:
# If URL can't be detected, raise an error
error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
print(f"ERROR: {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 - Credentials: main={main}, fallback={fallback}")
# Load Spotify client credentials if available # Load Spotify client credentials if available
spotify_client_id = None spotify_client_id = None
@@ -49,6 +64,8 @@ def download_album(
except Exception as e: except Exception as e:
print(f"Error loading Spotify search credentials: {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:
if quality is None: if quality is None:
@@ -186,6 +203,7 @@ def download_album(
max_retries=max_retries max_retries=max_retries
) )
print(f"DEBUG: Album download completed successfully using Spotify main") print(f"DEBUG: Album download completed successfully using Spotify main")
# For Deezer URLs: download directly from Deezer
elif service == 'deezer': elif service == 'deezer':
if quality is None: if quality is None:
quality = 'FLAC' quality = 'FLAC'

View File

@@ -3,6 +3,7 @@ import traceback
from pathlib import Path from pathlib import Path
import os import os
import logging import logging
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
@@ -87,6 +88,16 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
logger.info(f"Fetching artist info for ID: {artist_id}") logger.info(f"Fetching artist info for ID: {artist_id}")
# Detect URL source (only Spotify is supported for artists)
is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower()
# Artist functionality only works with Spotify URLs currently
if not is_spotify_url:
error_msg = "Invalid URL: Artist functionality only supports open.spotify.com URLs"
logger.error(error_msg)
raise ValueError(error_msg)
# Get artist info with albums # Get artist info with albums
artist_data = get_spotify_info(artist_id, "artist") artist_data = get_spotify_info(artist_id, "artist")
@@ -152,17 +163,18 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
"name": album_name, "name": album_name,
"artist": album_artist, "artist": album_artist,
"type": "album", "type": "album",
"service": "spotify", # URL source will be automatically detected in the download functions
# Add reference to parent artist request if needed
"parent_artist_url": url, "parent_artist_url": url,
"parent_request_type": "artist" "parent_request_type": "artist"
} }
# Include original download URL for this album task
album_request_args["original_url"] = url_for('album.handle_download', url=album_url, _external=True)
# Create task for this album # Create task for this album
task_data = { task_data = {
"download_type": "album", "download_type": "album",
"type": "album", # Type for the download task "type": "album", # Type for the download task
"service": "spotify", # Default to Spotify since we're using Spotify API
"url": album_url, # Important: use the album URL, not artist URL "url": album_url, # Important: use the album URL, not artist URL
"retry_url": album_url, # Use album URL for retry logic, not artist URL "retry_url": album_url, # Use album URL for retry logic, not artist URL
"name": album_name, "name": album_name,

View File

@@ -10,7 +10,12 @@ logger = logging.getLogger(__name__)
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
REDIS_PORT = os.getenv('REDIS_PORT', '6379') REDIS_PORT = os.getenv('REDIS_PORT', '6379')
REDIS_DB = os.getenv('REDIS_DB', '0') REDIS_DB = os.getenv('REDIS_DB', '0')
REDIS_URL = os.getenv('REDIS_URL', f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") # Optional Redis password
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '')
# Build default URL with password if provided
_password_part = f":{REDIS_PASSWORD}@" if REDIS_PASSWORD else ""
default_redis_url = f"redis://{_password_part}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
REDIS_URL = os.getenv('REDIS_URL', default_redis_url)
REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL) REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL)
# Log Redis connection details # Log Redis connection details

View File

@@ -120,9 +120,6 @@ class CeleryDownloadQueueManager:
# Extract original request or use empty dict # Extract original request or use empty dict
original_request = task.get("orig_request", task.get("original_request", {})) original_request = task.get("orig_request", task.get("original_request", {}))
# Determine service (spotify or deezer) from config or request
service = original_request.get("service", config_params.get("service", "spotify"))
# Debug retry_url if present # Debug retry_url if present
if "retry_url" in task: if "retry_url" in task:
logger.debug(f"Task has retry_url: {task['retry_url']}") logger.debug(f"Task has retry_url: {task['retry_url']}")
@@ -133,21 +130,20 @@ class CeleryDownloadQueueManager:
"type": task.get("type", download_type), "type": task.get("type", download_type),
"name": task.get("name", ""), "name": task.get("name", ""),
"artist": task.get("artist", ""), "artist": task.get("artist", ""),
"service": service,
"url": task.get("url", ""), "url": task.get("url", ""),
# Preserve retry_url if present # Preserve retry_url if present
"retry_url": task.get("retry_url", ""), "retry_url": task.get("retry_url", ""),
# Use config values but allow override from request # Use main account from config
"main": original_request.get("main", "main": original_request.get("main", config_params['deezer']),
config_params['spotify'] if service == 'spotify' else config_params['deezer']),
# Set fallback if enabled in config
"fallback": original_request.get("fallback", "fallback": original_request.get("fallback",
config_params['spotify'] if config_params['fallback'] and service == 'spotify' else None), config_params['spotify'] if config_params['fallback'] else None),
"quality": original_request.get("quality", # Use default quality settings
config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality']), "quality": original_request.get("quality", config_params['deezerQuality']),
"fall_quality": original_request.get("fall_quality", config_params['spotifyQuality']), "fall_quality": original_request.get("fall_quality", config_params['spotifyQuality']),

View File

@@ -12,7 +12,7 @@ from celery.exceptions import Retry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Setup Redis and Celery # Setup Redis and Celery
from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, get_config_params from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, get_config_params
# Initialize Celery app # Initialize Celery app
celery_app = Celery('download_tasks', celery_app = Celery('download_tasks',
@@ -893,12 +893,11 @@ def download_track(self, **task_data):
custom_track_format = task_data.get("custom_track_format", config_params.get("customTrackFormat", "%tracknum%. %music%")) custom_track_format = task_data.get("custom_track_format", config_params.get("customTrackFormat", "%tracknum%. %music%"))
pad_tracks = task_data.get("pad_tracks", config_params.get("tracknum_padding", True)) pad_tracks = task_data.get("pad_tracks", config_params.get("tracknum_padding", True))
# Execute the download # Execute the download - service is now determined from URL
download_track_func( download_track_func(
service=service,
url=url, url=url,
main=main, main=main,
fallback=fallback, fallback=fallback if fallback_enabled else None,
quality=quality, quality=quality,
fall_quality=fall_quality, fall_quality=fall_quality,
real_time=real_time, real_time=real_time,
@@ -961,12 +960,11 @@ def download_album(self, **task_data):
custom_track_format = task_data.get("custom_track_format", config_params.get("customTrackFormat", "%tracknum%. %music%")) custom_track_format = task_data.get("custom_track_format", config_params.get("customTrackFormat", "%tracknum%. %music%"))
pad_tracks = task_data.get("pad_tracks", config_params.get("tracknum_padding", True)) pad_tracks = task_data.get("pad_tracks", config_params.get("tracknum_padding", True))
# Execute the download # Execute the download - service is now determined from URL
download_album_func( download_album_func(
service=service,
url=url, url=url,
main=main, main=main,
fallback=fallback, fallback=fallback if fallback_enabled else None,
quality=quality, quality=quality,
fall_quality=fall_quality, fall_quality=fall_quality,
real_time=real_time, real_time=real_time,
@@ -1034,12 +1032,11 @@ def download_playlist(self, **task_data):
retry_delay_increase = task_data.get("retry_delay_increase", config_params.get("retry_delay_increase", 5)) retry_delay_increase = task_data.get("retry_delay_increase", config_params.get("retry_delay_increase", 5))
max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3)) max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3))
# Execute the download # Execute the download - service is now determined from URL
download_playlist_func( download_playlist_func(
service=service,
url=url, url=url,
main=main, main=main,
fallback=fallback, fallback=fallback if fallback_enabled else None,
quality=quality, quality=quality,
fall_quality=fall_quality, fall_quality=fall_quality,
real_time=real_time, real_time=real_time,

View File

@@ -6,7 +6,6 @@ from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
def download_playlist( def download_playlist(
service,
url, url,
main, main,
fallback=None, fallback=None,
@@ -22,8 +21,24 @@ def download_playlist(
progress_callback=None, progress_callback=None,
): ):
try: try:
# DEBUG: Print parameters # Detect URL source (Spotify or Deezer) from URL
print(f"DEBUG: playlist.py received - service={service}, main={main}, fallback={fallback}") is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower()
# Determine service exclusively from URL
if is_spotify_url:
service = 'spotify'
elif is_deezer_url:
service = 'deezer'
else:
# If URL can't be detected, raise an error
error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
print(f"ERROR: {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 - Credentials: main={main}, fallback={fallback}")
# Load Spotify client credentials if available # Load Spotify client credentials if available
spotify_client_id = None spotify_client_id = None
@@ -49,6 +64,8 @@ def download_playlist(
except Exception as e: except Exception as e:
print(f"Error loading Spotify search credentials: {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:
if quality is None: if quality is None:
@@ -181,6 +198,7 @@ def download_playlist(
max_retries=max_retries max_retries=max_retries
) )
print(f"DEBUG: Playlist download completed successfully using Spotify main") print(f"DEBUG: Playlist download completed successfully using Spotify main")
# For Deezer URLs: download directly from Deezer
elif service == 'deezer': elif service == 'deezer':
if quality is None: if quality is None:
quality = 'FLAC' quality = 'FLAC'

View File

@@ -6,7 +6,6 @@ from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
def download_track( def download_track(
service,
url, url,
main, main,
fallback=None, fallback=None,
@@ -22,8 +21,24 @@ def download_track(
progress_callback=None progress_callback=None
): ):
try: try:
# DEBUG: Print parameters # Detect URL source (Spotify or Deezer) from URL
print(f"DEBUG: track.py received - service={service}, main={main}, fallback={fallback}") is_spotify_url = 'open.spotify.com' in url.lower()
is_deezer_url = 'deezer.com' in url.lower()
# Determine service exclusively from URL
if is_spotify_url:
service = 'spotify'
elif is_deezer_url:
service = 'deezer'
else:
# If URL can't be detected, raise an error
error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
print(f"ERROR: {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 - Credentials: main={main}, fallback={fallback}")
# Load Spotify client credentials if available # Load Spotify client credentials if available
spotify_client_id = None spotify_client_id = None
@@ -49,6 +64,8 @@ def download_track(
except Exception as e: except Exception as e:
print(f"Error loading Spotify search credentials: {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:
if quality is None: if quality is None:
@@ -166,6 +183,7 @@ def download_track(
retry_delay_increase=retry_delay_increase, retry_delay_increase=retry_delay_increase,
max_retries=max_retries max_retries=max_retries
) )
# For Deezer URLs: download directly from Deezer
elif service == 'deezer': elif service == 'deezer':
if quality is None: if quality is None:
quality = 'FLAC' quality = 'FLAC'

View File

@@ -1 +0,0 @@

View File

@@ -310,6 +310,96 @@
border-radius: 2px; border-radius: 2px;
} }
/* Overall progress container for albums and playlists */
.overall-progress-container {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
position: relative; /* Positioning context for z-index */
z-index: 2; /* Ensure overall progress appears above track progress */
}
.overall-progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 11px;
color: #b3b3b3;
}
.overall-progress-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.overall-progress-count {
font-weight: 600;
color: #1DB954;
}
.overall-progress-bar-container {
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
}
.overall-progress-bar {
height: 100%;
background: linear-gradient(90deg, #4a90e2, #7a67ee); /* Changed to blue-purple gradient */
width: 0;
border-radius: 3px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.overall-progress-bar.complete {
background: #4a90e2; /* Changed to solid blue for completed overall progress */
}
/* Track progress bar container */
.track-progress-bar-container {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
margin-bottom: 4px;
position: relative;
z-index: 1; /* Ensure it's below the overall progress */
}
/* Track progress bar */
.track-progress-bar {
height: 100%;
background: #1DB954; /* Keep green for track-level progress */
width: 0;
border-radius: 2px;
transition: width 0.3s ease;
box-shadow: 0 0 3px rgba(29, 185, 84, 0.5); /* Add subtle glow to differentiate */
}
/* Complete state for track progress */
/* Real-time progress style */
.track-progress-bar.real-time {
background: #1DB954; /* Vivid green for real-time progress */
background: #1DB954;
}
/* Pulsing animation for indeterminate progress */
.track-progress-bar.progress-pulse {
background: linear-gradient(90deg, #1DB954 0%, #2cd267 50%, #1DB954 100%); /* Keep in green family */
background-size: 200% 100%;
animation: progress-pulse-slide 1.5s ease infinite;
}
@keyframes progress-pulse-slide {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Progress percentage text */ /* Progress percentage text */
.progress-percent { .progress-percent {
text-align: right; text-align: right;

View File

@@ -42,6 +42,18 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Restore last search type if no URL override
const savedType = localStorage.getItem('lastSearchType');
if (savedType && ['track','album','playlist','artist'].includes(savedType)) {
searchType.value = savedType;
}
// Save last selection on change
if (searchType) {
searchType.addEventListener('change', () => {
localStorage.setItem('lastSearchType', searchType.value);
});
}
// Check for URL parameters // Check for URL parameters
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q'); const query = urlParams.get('q');
@@ -341,7 +353,8 @@ document.addEventListener('DOMContentLoaded', function() {
* Extracts details from a Spotify URL * Extracts details from a Spotify URL
*/ */
function getSpotifyResourceDetails(url) { function getSpotifyResourceDetails(url) {
const regex = /spotify\.com\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)/; // Allow optional path segments (e.g. intl-fr) before resource type
const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i;
const match = url.match(regex); const match = url.match(regex);
if (match) { if (match) {

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@
<option value="%music%">%music% - Track title</option> <option value="%music%">%music% - Track title</option>
<option value="%artist%">%artist% - Track artist</option> <option value="%artist%">%artist% - Track artist</option>
<option value="%album%">%album% - Album name</option> <option value="%album%">%album% - Album name</option>
<option value="%album_artist%">%album_artist% - Album artist</option> <option value="%ar_album%">%ar_album% - Album artist</option>
<option value="%tracknum%">%tracknum% - Track number</option> <option value="%tracknum%">%tracknum% - Track number</option>
<option value="%year%">%year% - Year of release</option> <option value="%year%">%year% - Year of release</option>
</optgroup> </optgroup>
@@ -169,7 +169,7 @@
<option value="%music%">%music% - Track title</option> <option value="%music%">%music% - Track title</option>
<option value="%artist%">%artist% - Track artist</option> <option value="%artist%">%artist% - Track artist</option>
<option value="%album%">%album% - Album name</option> <option value="%album%">%album% - Album name</option>
<option value="%album_artist%">%album_artist% - Album artist</option> <option value="%ar_album%">%ar_album% - Album artist</option>
<option value="%tracknum%">%tracknum% - Track number</option> <option value="%tracknum%">%tracknum% - Track number</option>
<option value="%year%">%year% - Year of release</option> <option value="%year%">%year% - Year of release</option>
</optgroup> </optgroup>