├── routes
├── utils
│ ├── __init__.py
│ ├── search.py
│ ├── get_info.py
│ ├── celery_config.py
│ ├── history_manager.py
│ ├── artist.py
│ └── track.py
├── __init__.py
├── history.py
├── search.py
├── credentials.py
├── album.py
├── track.py
├── config.py
└── prgs.py
├── static
├── html
│ ├── favicon.ico
│ ├── album.html
│ ├── track.html
│ ├── watch.html
│ ├── artist.html
│ ├── main.html
│ ├── history.html
│ └── playlist.html
├── images
│ ├── placeholder.jpg
│ ├── search.svg
│ ├── arrow-left.svg
│ ├── music.svg
│ ├── album.svg
│ ├── cross.svg
│ ├── refresh.svg
│ ├── info.svg
│ ├── skull-head.svg
│ ├── view.svg
│ ├── eye.svg
│ ├── home.svg
│ ├── plus-circle.svg
│ ├── queue.svg
│ ├── check.svg
│ ├── eye-crossed.svg
│ ├── settings.svg
│ ├── download.svg
│ ├── queue-empty.svg
│ ├── refresh-cw.svg
│ ├── history.svg
│ ├── binoculars.svg
│ └── missing.svg
└── css
│ ├── history
│ └── history.css
│ ├── main
│ ├── icons.css
│ └── main.css
│ ├── track
│ └── track.css
│ ├── album
│ └── album.css
│ └── watch
│ └── watch.css
├── .dockerignore
├── .env.example
├── tsconfig.json
├── .gitignore
├── Dockerfile
├── requirements.txt
├── .github
├── ISSUE_TEMPLATE
│ └── bug_report.md
└── workflows
│ └── docker-build.yml
├── docker-compose.yaml
├── entrypoint.sh
├── src
└── js
│ ├── history.ts
│ └── track.ts
├── app.py
└── README.md
/routes/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/html/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nor-dee/spotizerr-spotify/HEAD/static/html/favicon.ico
--------------------------------------------------------------------------------
/static/images/placeholder.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nor-dee/spotizerr-spotify/HEAD/static/images/placeholder.jpg
--------------------------------------------------------------------------------
/static/images/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/arrow-left.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/music.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/album.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/cross.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | /credentials.json
2 | /test.py
3 | /venv
4 | /downloads/
5 | /creds/
6 | /Test.py
7 | /prgs/
8 | /flask_server.log
9 | test.sh
10 | __pycache__/
11 | routes/__pycache__/*
12 | routes/utils/__pycache__/*
13 | search_test.py
14 | config/main.json
15 | .cache
16 | config/state/queue_state.json
17 | output.log
18 | queue_state.json
19 | search_demo.py
20 | celery_worker.log
21 | static/js/*
22 | logs/
23 | .env.example
24 | .env
25 | .venv
26 | data
27 |
--------------------------------------------------------------------------------
/static/images/refresh.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Docker Compose environment variables# Delete all comments of this when deploying (everything that is )
2 |
3 | # Redis connection (external or internal)
4 | REDIS_HOST=redis
5 | REDIS_PORT=6379
6 | REDIS_DB=0
7 | REDIS_PASSWORD=CHANGE_ME
8 |
9 | # Set to true to filter out explicit content
10 | EXPLICIT_FILTER=false
11 |
12 | # User ID for the container
13 | PUID=1000
14 |
15 | # Group ID for the container
16 | PGID=1000
17 |
18 | # Optional: Sets the default file permissions for newly created files within the container.
19 | UMASK=0022
--------------------------------------------------------------------------------
/static/images/info.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/static/images/skull-head.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/static/images/view.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/static/images/eye.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/static/images/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/plus-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/static/images/queue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/check.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/static/images/eye-crossed.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/static/images/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/download.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/static/images/queue-empty.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017", // Specify ECMAScript target version
4 | "module": "ES2020", // Specify module code generation
5 | "strict": true, // Enable all strict type-checking options
6 | "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules
7 | "skipLibCheck": true, // Skip type checking of declaration files
8 | "forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file.
9 | "outDir": "./static/js",
10 | "rootDir": "./src/js"
11 | },
12 | "include": [
13 | "src/js/**/*.ts",
14 | "src/js/album.ts",
15 | "src/js/artist.ts",
16 | "src/js/config.ts",
17 | "src/js/main.ts",
18 | "src/js/playlist.ts",
19 | "src/js/queue.ts",
20 | "src/js/track.ts"
21 | ],
22 | "exclude": [
23 | "node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include.
24 | ]
25 | }
--------------------------------------------------------------------------------
/static/images/refresh-cw.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/routes/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import atexit
3 |
4 | # Configure basic logging for the application if not already configured
5 | # This is a good place for it if routes are a central part of your app structure.
6 | logging.basicConfig(level=logging.INFO,
7 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 | try:
12 | from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
13 | # Start the playlist watch manager when the application/blueprint is initialized
14 | start_watch_manager()
15 | # Register the stop function to be called on application exit
16 | atexit.register(stop_watch_manager)
17 | logger.info("Playlist Watch Manager initialized and registered for shutdown.")
18 | except ImportError as e:
19 | logger.error(f"Could not import or start Playlist Watch Manager: {e}. Playlist watching will be disabled.")
20 | except Exception as e:
21 | logger.error(f"An unexpected error occurred during Playlist Watch Manager setup: {e}", exc_info=True)
22 |
23 | from .artist import artist_bp
24 | from .prgs import prgs_bp
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /credentials.json
2 | /test.py
3 | /venv
4 | /downloads/
5 | /creds/
6 | /Test.py
7 | /prgs/
8 | /flask_server.log
9 | /routes/__pycache__/
10 | routes/utils/__pycache__/
11 | test.sh
12 | __pycache__/
13 | routes/__pycache__/__init__.cpython-312.pyc
14 | routes/__pycache__/credentials.cpython-312.pyc
15 | routes/__pycache__/search.cpython-312.pyc
16 | routes/utils/__pycache__/__init__.cpython-312.pyc
17 | routes/utils/__pycache__/credentials.cpython-312.pyc
18 | routes/utils/__pycache__/search.cpython-312.pyc
19 | routes/utils/__pycache__/__init__.cpython-312.pyc
20 | routes/utils/__pycache__/credentials.cpython-312.pyc
21 | routes/utils/__pycache__/search.cpython-312.pyc
22 | routes/utils/__pycache__/credentials.cpython-312.pyc
23 | routes/utils/__pycache__/search.cpython-312.pyc
24 | routes/utils/__pycache__/__init__.cpython-312.pyc
25 | routes/utils/__pycache__/credentials.cpython-312.pyc
26 | routes/utils/__pycache__/search.cpython-312.pyc
27 | search_test.py
28 | config/main.json
29 | .cache
30 | config/state/queue_state.json
31 | output.log
32 | queue_state.json
33 | search_demo.py
34 | celery_worker.log
35 | logs/spotizerr.log
36 | /.venv
37 | static/js
38 | data
39 | logs/
40 | .env
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use an official Python runtime as a parent image
2 | FROM python:3.12-slim
3 |
4 | # Set the working directory in the container
5 | WORKDIR /app
6 |
7 | # Install system dependencies
8 | RUN apt-get update && apt-get install -y --no-install-recommends \
9 | build-essential \
10 | gosu \
11 | git \
12 | ffmpeg \
13 | nodejs \
14 | npm \
15 | && apt-get clean \
16 | && rm -rf /var/lib/apt/lists/*
17 |
18 | # Copy requirements file
19 | COPY requirements.txt .
20 |
21 | # Install Python dependencies
22 | RUN pip install --no-cache-dir -r requirements.txt
23 |
24 | # Copy application code
25 | COPY . .
26 |
27 | # Install TypeScript globally
28 | RUN npm install -g typescript
29 |
30 | # Compile TypeScript
31 | # tsc will use tsconfig.json from the current directory (/app)
32 | # It will read from /app/src/js and output to /app/static/js
33 | RUN tsc
34 |
35 | # Create necessary directories with proper permissions
36 | RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
37 | chmod -R 777 downloads data logs
38 |
39 | # Make entrypoint script executable
40 | RUN chmod +x entrypoint.sh
41 |
42 | # Set entrypoint to our script
43 | ENTRYPOINT ["/app/entrypoint.sh"]
44 |
45 | # No CMD needed as entrypoint.sh handles application startup
46 |
--------------------------------------------------------------------------------
/static/images/history.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | amqp==5.3.1
2 | annotated-types==0.7.0
3 | anyio==4.9.0
4 | billiard==4.2.1
5 | blinker==1.9.0
6 | celery==5.5.2
7 | certifi==2025.4.26
8 | charset-normalizer==3.4.2
9 | click==8.2.1
10 | click-didyoumean==0.3.1
11 | click-plugins==1.1.1
12 | click-repl==0.3.0
13 | deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again
14 | defusedxml==0.7.1
15 | fastapi==0.115.12
16 | Flask==3.1.1
17 | Flask-Celery-Helper==1.1.0
18 | flask-cors==6.0.0
19 | h11==0.16.0
20 | httptools==0.6.4
21 | idna==3.10
22 | ifaddr==0.2.0
23 | itsdangerous==2.2.0
24 | Jinja2==3.1.6
25 | kombu==5.5.3
26 | librespot==0.0.9
27 | MarkupSafe==3.0.2
28 | mutagen==1.47.0
29 | prompt_toolkit==3.0.51
30 | protobuf==3.20.1
31 | pycryptodome==3.23.0
32 | pycryptodomex==3.17
33 | pydantic==2.11.5
34 | pydantic_core==2.33.2
35 | PyOgg==0.6.14a1
36 | python-dateutil==2.9.0.post0
37 | python-dotenv==1.1.0
38 | PyYAML==6.0.2
39 | redis==6.2.0
40 | requests==2.30.0
41 | six==1.17.0
42 | sniffio==1.3.1
43 | spotipy==2.25.1
44 | spotipy_anon==1.4
45 | sse-starlette==2.3.5
46 | starlette==0.46.2
47 | tqdm==4.67.1
48 | typing-inspection==0.4.1
49 | typing_extensions==4.13.2
50 | tzdata==2025.2
51 | urllib3==2.4.0
52 | uvicorn==0.34.2
53 | uvloop==0.21.0
54 | vine==5.1.0
55 | waitress==3.0.2
56 | watchfiles==1.0.5
57 | wcwidth==0.2.13
58 | websocket-client==1.5.1
59 | websockets==15.0.1
60 | Werkzeug==3.1.3
61 | zeroconf==0.62.0
62 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Precise steps to reproduce the behavior (start from how you built your container):
15 | 1. Search for '...'
16 | 2. Download album/track/playlist 'https://open.spotify.com/...'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | *Note: Sometimes, an error is specific to an album, track or playlist, so preferrably share the specific url of the album you downloaded*
21 |
22 | **Expected behavior**
23 | A clear and concise description of what you expected to happen.
24 |
25 | **Screenshots**
26 | If applicable, add screenshots to help explain your problem.
27 |
28 | **Desktop (please complete the following information):**
29 | - OS: [e.g. iOS]
30 | - Browser [e.g. chrome, safari]
31 |
32 | **docker-compose.yaml**
33 | ```
34 | Paste it here
35 | ```
36 |
37 | **.env**
38 | ```
39 | Paste it here
40 | ```
41 |
42 | **Config**
43 |
44 | - You can either share a screenshot of the config page or, preferably, the config file (should be under `./config/main.json`, depending on where you mapped it on your docker-compose.yaml)
45 |
46 | **Logs**
47 | ```
48 | Preferably, restart the app before reproducing so you can paste the logs from the bare beginning
49 | ```
50 |
51 | **Version**
52 | Go to config page and look for the version number
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | name: spotizerr
2 |
3 | services:
4 | spotizerr:
5 | volumes:
6 | - ./data:/app/data
7 | - ./downloads:/app/downloads # <-- Change this for your music library dir
8 | - ./logs:/app/logs # <-- Volume for persistent logs
9 | ports:
10 | - 7171:7171
11 | image: cooldockerizer93/spotizerr
12 | container_name: spotizerr-app
13 | restart: unless-stopped
14 | environment:
15 | - PUID=${PUID} # Replace with your desired user ID | Remove both if you want to run as root (not recommended, might result in unreadable files)
16 | - PGID=${PGID} # Replace with your desired group ID | The user must have write permissions in the volume mapped to /app/downloads
17 | - UMASK=${UMASK} # Optional: Sets the default file permissions for newly created files within the container.
18 | - REDIS_HOST=${REDIS_HOST}
19 | - REDIS_PORT=${REDIS_PORT}
20 | - REDIS_DB=${REDIS_DB}
21 | - REDIS_PASSWORD=${REDIS_PASSWORD} # Optional, Redis AUTH password. Leave empty if not using authentication
22 | - REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}
23 | - REDIS_BACKEND=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}
24 | - EXPLICIT_FILTER=${EXPLICIT_FILTER} # Set to true to filter out explicit content
25 | depends_on:
26 | - redis
27 |
28 | redis:
29 | image: redis:alpine
30 | container_name: spotizerr-redis
31 | restart: unless-stopped
32 | environment:
33 | - REDIS_PASSWORD=${REDIS_PASSWORD}
34 | volumes:
35 | - redis-data:/data
36 | command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
37 |
38 | volumes:
39 | redis-data:
40 | driver: local
41 |
--------------------------------------------------------------------------------
/routes/history.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, jsonify, request
2 | from routes.utils.history_manager import get_history_entries
3 | import logging
4 |
5 | logger = logging.getLogger(__name__)
6 | history_bp = Blueprint('history', __name__, url_prefix='/api/history')
7 |
8 | @history_bp.route('', methods=['GET'])
9 | def get_download_history():
10 | """API endpoint to retrieve download history with pagination, sorting, and filtering."""
11 | try:
12 | limit = request.args.get('limit', 25, type=int)
13 | offset = request.args.get('offset', 0, type=int)
14 | sort_by = request.args.get('sort_by', 'timestamp_completed')
15 | sort_order = request.args.get('sort_order', 'DESC')
16 |
17 | # Basic filtering example: filter by status_final or download_type
18 | filters = {}
19 | status_filter = request.args.get('status_final')
20 | if status_filter:
21 | filters['status_final'] = status_filter
22 |
23 | type_filter = request.args.get('download_type')
24 | if type_filter:
25 | filters['download_type'] = type_filter
26 |
27 | # Add more filters as needed, e.g., by item_name (would need LIKE for partial match)
28 | # search_term = request.args.get('search')
29 | # if search_term:
30 | # filters['item_name'] = f'%{search_term}%' # This would require LIKE in get_history_entries
31 |
32 | entries, total_count = get_history_entries(limit, offset, sort_by, sort_order, filters)
33 |
34 | return jsonify({
35 | 'entries': entries,
36 | 'total_count': total_count,
37 | 'limit': limit,
38 | 'offset': offset
39 | })
40 | except Exception as e:
41 | logger.error(f"Error in /api/history endpoint: {e}", exc_info=True)
42 | return jsonify({"error": "Failed to retrieve download history"}), 500
--------------------------------------------------------------------------------
/.github/workflows/docker-build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Image
2 |
3 | on:
4 | release:
5 | types: [ published ]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | build-and-push:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v3
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v2
17 |
18 | - name: Login to Docker Hub
19 | uses: docker/login-action@v2
20 | with:
21 | username: ${{ secrets.DOCKERHUB_USERNAME }}
22 | password: ${{ secrets.DOCKERHUB_TOKEN }}
23 |
24 | # Extract metadata for Docker
25 | - name: Extract Docker metadata
26 | id: meta
27 | uses: docker/metadata-action@v4
28 | with:
29 | images: cooldockerizer93/spotizerr
30 | # Set latest tag to follow semantic versioning
31 | flavor: |
32 | latest=auto
33 | tags: |
34 | type=ref,event=branch
35 | type=ref,event=pr
36 | type=semver,pattern={{version}}
37 | type=semver,pattern={{major}}.{{minor}}
38 | # Set 'latest' tag for the most recent semver tag (following proper semver ordering)
39 | type=semver,pattern=latest,priority=1000
40 | # Keep dev tag for main/master branch
41 | type=raw,value=dev,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }}
42 |
43 | # Build and push Docker image with multiarch support
44 | - name: Build and push
45 | uses: docker/build-push-action@v4
46 | with:
47 | context: .
48 | # Specify the multiarch platforms
49 | platforms: linux/amd64,linux/arm64
50 | push: ${{ github.event_name != 'pull_request' }}
51 | tags: ${{ steps.meta.outputs.tags }}
52 | labels: ${{ steps.meta.outputs.labels }}
53 | no-cache: true
54 |
--------------------------------------------------------------------------------
/static/images/binoculars.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/static/images/missing.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/routes/utils/search.py:
--------------------------------------------------------------------------------
1 | from deezspot.easy_spoty import Spo
2 | import json
3 | from pathlib import Path
4 | import logging
5 |
6 | # Configure logger
7 | logger = logging.getLogger(__name__)
8 |
9 | def search(
10 | query: str,
11 | search_type: str,
12 | limit: int = 3,
13 | main: str = None
14 | ) -> dict:
15 | logger.info(f"Search requested: query='{query}', type={search_type}, limit={limit}, main={main}")
16 |
17 | # If main account is specified, load client ID and secret from the account's search.json
18 | client_id = None
19 | client_secret = None
20 |
21 | if main:
22 | search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
23 | logger.debug(f"Looking for credentials at: {search_creds_path}")
24 |
25 | if search_creds_path.exists():
26 | try:
27 | with open(search_creds_path, 'r') as f:
28 | search_creds = json.load(f)
29 | client_id = search_creds.get('client_id')
30 | client_secret = search_creds.get('client_secret')
31 | logger.debug(f"Credentials loaded successfully for account: {main}")
32 | except Exception as e:
33 | logger.error(f"Error loading search credentials: {e}")
34 | print(f"Error loading search credentials: {e}")
35 | else:
36 | logger.warning(f"Credentials file not found at: {search_creds_path}")
37 |
38 | # Initialize the Spotify client with credentials (if available)
39 | if client_id and client_secret:
40 | logger.debug("Initializing Spotify client with account credentials")
41 | Spo.__init__(client_id, client_secret)
42 | else:
43 | logger.debug("Using default Spotify client credentials")
44 |
45 | # Perform the Spotify search
46 | logger.debug(f"Executing Spotify search with query='{query}', type={search_type}")
47 | try:
48 | spotify_response = Spo.search(
49 | query=query,
50 | search_type=search_type,
51 | limit=limit,
52 | client_id=client_id,
53 | client_secret=client_secret
54 | )
55 | logger.info(f"Search completed successfully")
56 | return spotify_response
57 | except Exception as e:
58 | logger.error(f"Error during Spotify search: {e}")
59 | raise
60 |
--------------------------------------------------------------------------------
/routes/search.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, jsonify, request
2 | import logging
3 | from routes.utils.search import search # Corrected import
4 | from routes.config import get_config # Import get_config function
5 |
6 | search_bp = Blueprint('search', __name__)
7 |
8 | @search_bp.route('/search', methods=['GET'])
9 | def handle_search():
10 | try:
11 | # Get query parameters
12 | query = request.args.get('q', '')
13 | search_type = request.args.get('search_type', '')
14 | limit = int(request.args.get('limit', 10))
15 | main = request.args.get('main', '') # Get the main parameter for account selection
16 |
17 | # If main parameter is not provided in the request, get it from config
18 | if not main:
19 | config = get_config()
20 | if config and 'spotify' in config:
21 | main = config['spotify']
22 | print(f"Using main from config: {main}")
23 |
24 |
25 | # Validate parameters
26 | if not query:
27 | return jsonify({'error': 'Missing search query'}), 400
28 |
29 | valid_types = ['track', 'album', 'artist', 'playlist', 'episode']
30 | if search_type not in valid_types:
31 | return jsonify({'error': 'Invalid search type'}), 400
32 |
33 | # Perform the search with corrected parameter name
34 | raw_results = search(
35 | query=query,
36 | search_type=search_type, # Fixed parameter name
37 | limit=limit,
38 | main=main # Pass the main parameter
39 | )
40 |
41 |
42 | # Extract items from the appropriate section of the response based on search_type
43 | items = []
44 | if raw_results and search_type + 's' in raw_results:
45 | type_key = search_type + 's'
46 | items = raw_results[type_key].get('items', [])
47 | elif raw_results and search_type in raw_results:
48 |
49 | items = raw_results[search_type].get('items', [])
50 |
51 |
52 | # Return both the items array and the full data for debugging
53 | return jsonify({
54 | 'items': items,
55 | 'data': raw_results, # Include full data for debugging
56 | 'error': None
57 | })
58 |
59 | except ValueError as e:
60 | print(f"ValueError in search: {str(e)}")
61 | return jsonify({'error': str(e)}), 400
62 | except Exception as e:
63 | import traceback
64 | print(f"Exception in search: {str(e)}")
65 | print(traceback.format_exc())
66 | return jsonify({'error': f'Internal server error: {str(e)}'}), 500
--------------------------------------------------------------------------------
/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Set umask if UMASK variable is provided
5 | if [ -n "${UMASK}" ]; then
6 | umask "${UMASK}"
7 | fi
8 |
9 | # Redis is now in a separate container so we don't need to start it locally
10 | echo "Using Redis at ${REDIS_URL}"
11 |
12 | # Check if both PUID and PGID are not set
13 | if [ -z "${PUID}" ] && [ -z "${PGID}" ]; then
14 | # Run as root directly
15 | echo "Running as root user (no PUID/PGID specified)"
16 | exec python app.py
17 | else
18 | # Verify both PUID and PGID are set
19 | if [ -z "${PUID}" ] || [ -z "${PGID}" ]; then
20 | echo "ERROR: Must supply both PUID and PGID or neither"
21 | exit 1
22 | fi
23 |
24 | # Check for root user request
25 | if [ "${PUID}" -eq 0 ] && [ "${PGID}" -eq 0 ]; then
26 | echo "Running as root user (PUID/PGID=0)"
27 | exec python app.py
28 | else
29 | # Check if the group with the specified GID already exists
30 | if getent group "${PGID}" >/dev/null; then
31 | # If the group exists, use its name instead of creating a new one
32 | GROUP_NAME=$(getent group "${PGID}" | cut -d: -f1)
33 | echo "Using existing group: ${GROUP_NAME} (GID: ${PGID})"
34 | else
35 | # If the group doesn't exist, create it
36 | GROUP_NAME="appgroup"
37 | groupadd -g "${PGID}" "${GROUP_NAME}"
38 | echo "Created group: ${GROUP_NAME} (GID: ${PGID})"
39 | fi
40 |
41 | # Check if the user with the specified UID already exists
42 | if getent passwd "${PUID}" >/dev/null; then
43 | # If the user exists, use its name instead of creating a new one
44 | USER_NAME=$(getent passwd "${PUID}" | cut -d: -f1)
45 | echo "Using existing user: ${USER_NAME} (UID: ${PUID})"
46 | else
47 | # If the user doesn't exist, create it
48 | USER_NAME="appuser"
49 | useradd -u "${PUID}" -g "${GROUP_NAME}" -d /app "${USER_NAME}"
50 | echo "Created user: ${USER_NAME} (UID: ${PUID})"
51 | fi
52 |
53 | # Ensure proper permissions for all app directories
54 | echo "Setting permissions for /app directories..."
55 | chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/config /app/creds /app/logs /app/cache || true
56 | # Ensure Spotipy cache file exists and is writable
57 | touch /app/.cache || true
58 | chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true
59 |
60 | # Run as specified user
61 | echo "Starting application as ${USER_NAME}..."
62 | exec gosu "${USER_NAME}" python app.py
63 | fi
64 | fi
--------------------------------------------------------------------------------
/routes/utils/get_info.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 |
3 | from deezspot.easy_spoty import Spo
4 | import json
5 | from pathlib import Path
6 | from routes.utils.celery_queue_manager import get_config_params
7 |
8 | # We'll rely on get_config_params() instead of directly loading the config file
9 |
10 | def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None):
11 | """
12 | Get info from Spotify API using the default Spotify account configured in main.json
13 |
14 | Args:
15 | spotify_id: The Spotify ID of the entity
16 | spotify_type: The type of entity (track, album, playlist, artist)
17 | limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist".
18 | offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist".
19 |
20 | Returns:
21 | Dictionary with the entity information
22 | """
23 | client_id = None
24 | client_secret = None
25 |
26 | # Get config parameters including Spotify account
27 | config_params = get_config_params()
28 | main = config_params.get('spotify', '')
29 |
30 | if not main:
31 | raise ValueError("No Spotify account configured in settings")
32 |
33 | if spotify_id:
34 | search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
35 | if search_creds_path.exists():
36 | try:
37 | with open(search_creds_path, 'r') as f:
38 | search_creds = json.load(f)
39 | client_id = search_creds.get('client_id')
40 | client_secret = search_creds.get('client_secret')
41 | except Exception as e:
42 | print(f"Error loading search credentials: {e}")
43 |
44 | # Initialize the Spotify client with credentials (if available)
45 | if client_id and client_secret:
46 | Spo.__init__(client_id, client_secret)
47 | else:
48 | raise ValueError("No Spotify credentials found")
49 | if spotify_type == "track":
50 | return Spo.get_track(spotify_id)
51 | elif spotify_type == "album":
52 | return Spo.get_album(spotify_id)
53 | elif spotify_type == "playlist":
54 | return Spo.get_playlist(spotify_id)
55 | elif spotify_type == "artist_discography":
56 | if limit is not None and offset is not None:
57 | return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset)
58 | elif limit is not None:
59 | return Spo.get_artist_discography(spotify_id, limit=limit)
60 | elif offset is not None:
61 | return Spo.get_artist_discography(spotify_id, offset=offset)
62 | else:
63 | return Spo.get_artist_discography(spotify_id)
64 | elif spotify_type == "artist":
65 | return Spo.get_artist(spotify_id)
66 | elif spotify_type == "episode":
67 | return Spo.get_episode(spotify_id)
68 | else:
69 | raise ValueError(f"Unsupported Spotify type: {spotify_type}")
70 |
--------------------------------------------------------------------------------
/static/css/history/history.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: sans-serif;
3 | margin: 0;
4 | background-color: #121212;
5 | color: #e0e0e0;
6 | }
7 |
8 | .container {
9 | padding: 20px;
10 | max-width: 1200px;
11 | margin: auto;
12 | }
13 |
14 | h1 {
15 | color: #1DB954; /* Spotify Green */
16 | text-align: center;
17 | }
18 |
19 | table {
20 | width: 100%;
21 | border-collapse: collapse;
22 | margin-top: 20px;
23 | background-color: #1e1e1e;
24 | }
25 |
26 | th, td {
27 | border: 1px solid #333;
28 | padding: 10px 12px;
29 | text-align: left;
30 | }
31 |
32 | th {
33 | background-color: #282828;
34 | cursor: pointer;
35 | }
36 |
37 | tr:nth-child(even) {
38 | background-color: #222;
39 | }
40 |
41 | .pagination {
42 | margin-top: 20px;
43 | text-align: center;
44 | }
45 |
46 | .pagination button, .pagination select {
47 | padding: 8px 12px;
48 | margin: 0 5px;
49 | background-color: #1DB954;
50 | color: white;
51 | border: none;
52 | border-radius: 4px;
53 | cursor: pointer;
54 | }
55 |
56 | .pagination button:disabled {
57 | background-color: #555;
58 | cursor: not-allowed;
59 | }
60 |
61 | .filters {
62 | margin-bottom: 20px;
63 | display: flex;
64 | gap: 15px;
65 | align-items: center;
66 | }
67 |
68 | .filters label, .filters select, .filters input {
69 | margin-right: 5px;
70 | }
71 |
72 | .filters select, .filters input {
73 | padding: 8px;
74 | background-color: #282828;
75 | color: #e0e0e0;
76 | border: 1px solid #333;
77 | border-radius: 4px;
78 | }
79 |
80 | .status-COMPLETED { color: #1DB954; font-weight: bold; }
81 | .status-ERROR { color: #FF4136; font-weight: bold; }
82 | .status-CANCELLED { color: #AAAAAA; }
83 |
84 | .error-message-toggle {
85 | cursor: pointer;
86 | color: #FF4136; /* Red for error indicator */
87 | text-decoration: underline;
88 | }
89 |
90 | .error-details {
91 | display: none; /* Hidden by default */
92 | white-space: pre-wrap; /* Preserve formatting */
93 | background-color: #303030;
94 | padding: 5px;
95 | margin-top: 5px;
96 | border-radius: 3px;
97 | font-size: 0.9em;
98 | }
99 |
100 | /* Styling for the Details icon button in the table */
101 | .details-btn {
102 | background-color: transparent; /* Or a subtle color like #282828 */
103 | border: none;
104 | border-radius: 50%; /* Make it circular */
105 | padding: 5px; /* Adjust padding to control size */
106 | cursor: pointer;
107 | display: inline-flex; /* Important for aligning the image */
108 | align-items: center;
109 | justify-content: center;
110 | transition: background-color 0.2s ease;
111 | }
112 |
113 | .details-btn img {
114 | width: 16px; /* Icon size */
115 | height: 16px;
116 | filter: invert(1); /* Make icon white if it's dark, adjust if needed */
117 | }
118 |
119 | .details-btn:hover {
120 | background-color: #333; /* Darker on hover */
121 | }
--------------------------------------------------------------------------------
/static/html/album.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Album Viewer - Spotizerr
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
35 |
36 |
40 |
41 |
42 |
45 |
Error loading album
46 |
47 |
48 |
49 |
50 |
51 |
52 |
55 |
56 |
57 |
58 |
59 |
60 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/static/html/track.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Track Viewer - Spotizerr
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
39 |
40 |
41 |
44 |
Error loading track
45 |
46 |
47 |
48 |
49 |
50 |
51 |
54 |
55 |
56 |
57 |
58 |
59 |
68 |
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/static/html/watch.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Watched Items - Spotizerr
7 |
8 |
9 |
10 |
11 |
12 |
18 |
19 |
20 |
21 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
Loading watched items...
34 |
35 |
36 |
37 |
38 |
 }})
39 |
Nothing to see here yet!
40 |
Start watching artists or playlists, and they'll appear here.
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/static/html/artist.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Artist Viewer - Spotizerr
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
39 |
40 |
41 |
45 |
46 |
47 |
50 |
Error loading artist info
51 |
52 |
53 |
54 |
55 |
56 |
57 |
60 |
61 |
62 |
63 |
64 |
65 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/static/html/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Spotizerr
7 |
8 |
9 |
10 |
11 |
12 |
18 |
19 |
20 |
21 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
 }})
50 |
Search for music
51 |
Find and download your favorite tracks, albums, playlists or artists
52 |
53 |
54 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/static/html/history.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Download History
7 |
8 |
9 |
10 |
11 |
12 |
13 |
19 |
20 |
21 |
22 |
Download History
23 |
24 |
25 |
26 |
32 |
33 |
34 |
41 |
42 |
43 |
44 |
45 |
46 | | Name |
47 | Artist |
48 | Type |
49 | Status |
50 | Date Added |
51 | Date Completed/Ended |
52 | Details |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/static/html/playlist.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Playlist Viewer - Spotizerr
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
47 |
48 |
52 |
53 |
54 |
57 |
Error loading playlist
58 |
59 |
60 |
61 |
62 |
63 |
64 |
67 |
68 |
69 |
70 |
71 |
72 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/routes/credentials.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, request, jsonify
2 | from routes.utils.credentials import (
3 | get_credential,
4 | list_credentials,
5 | create_credential,
6 | delete_credential,
7 | edit_credential
8 | )
9 | from pathlib import Path
10 |
11 | credentials_bp = Blueprint('credentials', __name__)
12 |
13 | @credentials_bp.route('/', methods=['GET'])
14 | def handle_list_credentials(service):
15 | try:
16 | return jsonify(list_credentials(service))
17 | except ValueError as e:
18 | return jsonify({"error": str(e)}), 400
19 | except Exception as e:
20 | return jsonify({"error": str(e)}), 500
21 |
22 | @credentials_bp.route('//', methods=['GET', 'POST', 'PUT', 'DELETE'])
23 | def handle_single_credential(service, name):
24 | try:
25 | # Get credential type from query parameters, default to 'credentials'
26 | cred_type = request.args.get('type', 'credentials')
27 | if cred_type not in ['credentials', 'search']:
28 | return jsonify({"error": "Invalid credential type. Must be 'credentials' or 'search'"}), 400
29 |
30 | if request.method == 'GET':
31 | return jsonify(get_credential(service, name, cred_type))
32 |
33 | elif request.method == 'POST':
34 | data = request.get_json()
35 | create_credential(service, name, data, cred_type)
36 | return jsonify({"message": f"{cred_type.capitalize()} credential created successfully"}), 201
37 |
38 | elif request.method == 'PUT':
39 | data = request.get_json()
40 | edit_credential(service, name, data, cred_type)
41 | return jsonify({"message": f"{cred_type.capitalize()} credential updated successfully"})
42 |
43 | elif request.method == 'DELETE':
44 | delete_credential(service, name, cred_type if cred_type != 'credentials' else None)
45 | return jsonify({"message": f"{cred_type.capitalize()} credential deleted successfully"})
46 |
47 | except (ValueError, FileNotFoundError, FileExistsError) as e:
48 | status_code = 400
49 | if isinstance(e, FileNotFoundError):
50 | status_code = 404
51 | elif isinstance(e, FileExistsError):
52 | status_code = 409
53 | return jsonify({"error": str(e)}), status_code
54 | except Exception as e:
55 | return jsonify({"error": str(e)}), 500
56 |
57 | @credentials_bp.route('/search//', methods=['GET', 'POST', 'PUT'])
58 | def handle_search_credential(service, name):
59 | """Special route specifically for search credentials"""
60 | try:
61 | if request.method == 'GET':
62 | return jsonify(get_credential(service, name, 'search'))
63 |
64 | elif request.method in ['POST', 'PUT']:
65 | data = request.get_json()
66 |
67 | # Validate required fields
68 | if not data.get('client_id') or not data.get('client_secret'):
69 | return jsonify({"error": "Both client_id and client_secret are required"}), 400
70 |
71 | # For POST, first check if the credentials directory exists
72 | if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')):
73 | return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404
74 |
75 | # Create or update search credentials
76 | method_func = create_credential if request.method == 'POST' else edit_credential
77 | method_func(service, name, data, 'search')
78 |
79 | action = "created" if request.method == 'POST' else "updated"
80 | return jsonify({"message": f"Search credentials {action} successfully"})
81 |
82 | except (ValueError, FileNotFoundError) as e:
83 | status_code = 400 if isinstance(e, ValueError) else 404
84 | return jsonify({"error": str(e)}), status_code
85 | except Exception as e:
86 | return jsonify({"error": str(e)}), 500
87 |
88 | @credentials_bp.route('/all/', methods=['GET'])
89 | def handle_all_credentials(service):
90 | try:
91 | credentials = []
92 | for name in list_credentials(service):
93 | # For each credential, get both the main credentials and search credentials if they exist
94 | cred_data = {
95 | "name": name,
96 | "credentials": get_credential(service, name, 'credentials')
97 | }
98 |
99 | # For Spotify accounts, also try to get search credentials
100 | if service == 'spotify':
101 | try:
102 | search_creds = get_credential(service, name, 'search')
103 | if search_creds: # Only add if not empty
104 | cred_data["search"] = search_creds
105 | except:
106 | pass # Ignore errors if search.json doesn't exist
107 |
108 | credentials.append(cred_data)
109 |
110 | return jsonify(credentials)
111 | except (ValueError, FileNotFoundError) as e:
112 | status_code = 400 if isinstance(e, ValueError) else 404
113 | return jsonify({"error": str(e)}), status_code
114 | except Exception as e:
115 | return jsonify({"error": str(e)}), 500
--------------------------------------------------------------------------------
/routes/album.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, Response, request
2 | import json
3 | import os
4 | import traceback
5 | import uuid
6 | import time
7 | from routes.utils.celery_queue_manager import download_queue_manager
8 | from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
9 | from routes.utils.get_info import get_spotify_info
10 |
11 | album_bp = Blueprint('album', __name__)
12 |
13 | @album_bp.route('/download/', methods=['GET'])
14 | def handle_download(album_id):
15 | # Retrieve essential parameters from the request.
16 | # name = request.args.get('name')
17 | # artist = request.args.get('artist')
18 |
19 | # Construct the URL from album_id
20 | url = f"https://open.spotify.com/album/{album_id}"
21 |
22 | # Fetch metadata from Spotify
23 | try:
24 | album_info = get_spotify_info(album_id, "album")
25 | if not album_info or not album_info.get('name') or not album_info.get('artists'):
26 | return Response(
27 | json.dumps({"error": f"Could not retrieve metadata for album ID: {album_id}"}),
28 | status=404,
29 | mimetype='application/json'
30 | )
31 |
32 | name_from_spotify = album_info.get('name')
33 | artist_from_spotify = album_info['artists'][0].get('name') if album_info['artists'] else "Unknown Artist"
34 |
35 | except Exception as e:
36 | return Response(
37 | json.dumps({"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"}),
38 | status=500,
39 | mimetype='application/json'
40 | )
41 |
42 | # Validate required parameters
43 | if not url:
44 | return Response(
45 | json.dumps({"error": "Missing required parameter: url"}),
46 | status=400,
47 | mimetype='application/json'
48 | )
49 |
50 | # Add the task to the queue with only essential parameters
51 | # The queue manager will now handle all config parameters
52 | # Include full original request URL in metadata
53 | orig_params = request.args.to_dict()
54 | orig_params["original_url"] = request.url
55 | try:
56 | task_id = download_queue_manager.add_task({
57 | "download_type": "album",
58 | "url": url,
59 | "name": name_from_spotify,
60 | "artist": artist_from_spotify,
61 | "orig_request": orig_params
62 | })
63 | except Exception as e:
64 | # Generic error handling for other issues during task submission
65 | # Create an error task ID if add_task itself fails before returning an ID
66 | error_task_id = str(uuid.uuid4())
67 |
68 | store_task_info(error_task_id, {
69 | "download_type": "album",
70 | "url": url,
71 | "name": name_from_spotify,
72 | "artist": artist_from_spotify,
73 | "original_request": orig_params,
74 | "created_at": time.time(),
75 | "is_submission_error_task": True
76 | })
77 | store_task_status(error_task_id, {
78 | "status": ProgressState.ERROR,
79 | "error": f"Failed to queue album download: {str(e)}",
80 | "timestamp": time.time()
81 | })
82 | return Response(
83 | json.dumps({"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id}),
84 | status=500,
85 | mimetype='application/json'
86 | )
87 |
88 | return Response(
89 | json.dumps({"prg_file": task_id}),
90 | status=202,
91 | mimetype='application/json'
92 | )
93 |
94 | @album_bp.route('/download/cancel', methods=['GET'])
95 | def cancel_download():
96 | """
97 | Cancel a running download process by its prg file name.
98 | """
99 | prg_file = request.args.get('prg_file')
100 | if not prg_file:
101 | return Response(
102 | json.dumps({"error": "Missing process id (prg_file) parameter"}),
103 | status=400,
104 | mimetype='application/json'
105 | )
106 |
107 | # Use the queue manager's cancellation method.
108 | result = download_queue_manager.cancel_task(prg_file)
109 | status_code = 200 if result.get("status") == "cancelled" else 404
110 |
111 | return Response(
112 | json.dumps(result),
113 | status=status_code,
114 | mimetype='application/json'
115 | )
116 |
117 | @album_bp.route('/info', methods=['GET'])
118 | def get_album_info():
119 | """
120 | Retrieve Spotify album metadata given a Spotify album ID.
121 | Expects a query parameter 'id' that contains the Spotify album ID.
122 | """
123 | spotify_id = request.args.get('id')
124 |
125 | if not spotify_id:
126 | return Response(
127 | json.dumps({"error": "Missing parameter: id"}),
128 | status=400,
129 | mimetype='application/json'
130 | )
131 |
132 | try:
133 | # Import and use the get_spotify_info function from the utility module.
134 | from routes.utils.get_info import get_spotify_info
135 | album_info = get_spotify_info(spotify_id, "album")
136 | return Response(
137 | json.dumps(album_info),
138 | status=200,
139 | mimetype='application/json'
140 | )
141 | except Exception as e:
142 | error_data = {
143 | "error": str(e),
144 | "traceback": traceback.format_exc()
145 | }
146 | return Response(
147 | json.dumps(error_data),
148 | status=500,
149 | mimetype='application/json'
150 | )
151 |
--------------------------------------------------------------------------------
/static/css/main/icons.css:
--------------------------------------------------------------------------------
1 | /* ICON STYLES */
2 | .settings-icon img,
3 | #queueIcon img {
4 | width: 24px;
5 | height: 24px;
6 | vertical-align: middle;
7 | filter: invert(1);
8 | transition: opacity 0.3s;
9 | }
10 |
11 | .settings-icon:hover img,
12 | #queueIcon:hover img {
13 | opacity: 0.8;
14 | }
15 |
16 | #queueIcon {
17 | background: none;
18 | border: none;
19 | cursor: pointer;
20 | padding: 4px;
21 | }
22 |
23 | /* Style for the skull icon in the Cancel all button */
24 | .skull-icon {
25 | width: 16px;
26 | height: 16px;
27 | margin-right: 8px;
28 | vertical-align: middle;
29 | filter: brightness(0) invert(1); /* Makes icon white */
30 | transition: transform 0.3s ease;
31 | }
32 |
33 | #cancelAllBtn:hover .skull-icon {
34 | transform: rotate(-10deg) scale(1.2);
35 | animation: skullShake 0.5s infinite alternate;
36 | }
37 |
38 | @keyframes skullShake {
39 | 0% { transform: rotate(-5deg); }
40 | 100% { transform: rotate(5deg); }
41 | }
42 |
43 | /* Style for the X that appears when the queue is visible */
44 | .queue-x {
45 | font-size: 28px;
46 | font-weight: bold;
47 | color: white;
48 | line-height: 24px;
49 | display: inline-block;
50 | transform: translateY(-2px);
51 | }
52 |
53 | /* Queue icon with red tint when X is active */
54 | .queue-icon-active {
55 | background-color: #d13838 !important; /* Red background for active state */
56 | transition: background-color 0.3s ease;
57 | }
58 |
59 | .queue-icon-active:hover {
60 | background-color: #e04c4c !important; /* Lighter red on hover */
61 | }
62 |
63 | .download-icon,
64 | .type-icon,
65 | .toggle-chevron {
66 | width: 16px;
67 | height: 16px;
68 | vertical-align: middle;
69 | margin-right: 6px;
70 | }
71 |
72 | .toggle-chevron {
73 | transition: transform 0.2s ease;
74 | }
75 |
76 | .option-btn .type-icon {
77 | width: 18px;
78 | height: 18px;
79 | margin-right: 0.3rem;
80 | }
81 |
82 | /* Container for Title and Buttons */
83 | .title-and-view {
84 | display: flex;
85 | align-items: center;
86 | justify-content: space-between;
87 | padding-right: 1rem; /* Extra right padding so buttons don't touch the edge */
88 | }
89 |
90 | /* Container for the buttons next to the title */
91 | .title-buttons {
92 | display: flex;
93 | align-items: center;
94 | }
95 |
96 | /* Small Download Button Styles */
97 | .download-btn-small {
98 | background-color: #1db954; /* White background */
99 | border: none;
100 | border-radius: 50%; /* Circular shape */
101 | padding: 6px; /* Adjust padding for desired size */
102 | cursor: pointer;
103 | display: inline-flex;
104 | align-items: center;
105 | justify-content: center;
106 | transition: background-color 0.3s ease, transform 0.2s ease;
107 | margin-left: 8px; /* Space between adjacent buttons */
108 | }
109 |
110 | .download-btn-small img {
111 | width: 20px; /* Slightly bigger icon */
112 | height: 20px;
113 | filter: brightness(0) invert(1); /* Makes the icon white */
114 | }
115 |
116 | .download-btn-small:hover {
117 | background-color: #1db954b4; /* Light gray on hover */
118 | transform: translateY(-1px);
119 | }
120 |
121 | /* View Button Styles (unchanged) */
122 | .view-btn {
123 | background-color: #1db954;
124 | border: none;
125 | border-radius: 50%;
126 | padding: 6px;
127 | cursor: pointer;
128 | display: inline-flex;
129 | align-items: center;
130 | justify-content: center;
131 | transition: background-color 0.3s ease, transform 0.2s ease;
132 | margin-left: 8px;
133 | }
134 |
135 | .view-btn img {
136 | width: 20px;
137 | height: 20px;
138 | filter: brightness(0) invert(1);
139 | }
140 |
141 | .view-btn:hover {
142 | background-color: #1db954b0;
143 | transform: translateY(-1px);
144 | }
145 |
146 | /* Mobile Compatibility Tweaks */
147 | @media (max-width: 600px) {
148 | .view-btn,
149 | .download-btn-small {
150 | padding: 6px 10px;
151 | font-size: 13px;
152 | margin: 4px;
153 | }
154 | }
155 |
156 |
157 | /* Mobile compatibility tweaks */
158 | @media (max-width: 600px) {
159 | .view-btn {
160 | padding: 6px 10px; /* Slightly larger padding on mobile for easier tap targets */
161 | font-size: 13px; /* Ensure readability on smaller screens */
162 | margin: 4px; /* Reduce margins to better fit mobile layouts */
163 | }
164 | }
165 |
166 | /* Positioning for floating action buttons */
167 | /* Base .floating-icon style is now in base.css */
168 |
169 | /* Left-aligned buttons (Home, Settings, Back, History) */
170 | .home-btn, .settings-icon, .back-button, .history-nav-btn {
171 | left: 20px;
172 | }
173 |
174 | .settings-icon { /* Covers config, main */
175 | bottom: 20px;
176 | }
177 |
178 | .home-btn { /* Covers album, artist, playlist, track, watch, history */
179 | bottom: 20px;
180 | }
181 |
182 | .back-button { /* Specific to config page */
183 | bottom: 20px;
184 | }
185 |
186 | /* New History button specific positioning - above other left buttons */
187 | .history-nav-btn {
188 | bottom: 80px; /* Positioned 60px above the buttons at 20px (48px button height + 12px margin) */
189 | }
190 |
191 |
192 | /* Right-aligned buttons (Queue, Watch) */
193 | .queue-icon, .watch-nav-btn {
194 | right: 20px;
195 | z-index: 1002; /* Ensure these are above the sidebar (z-index: 1001) and other FABs (z-index: 1000) */
196 | }
197 |
198 | .queue-icon {
199 | bottom: 20px;
200 | }
201 |
202 | /* Watch button specific positioning - above Queue */
203 | .watch-nav-btn {
204 | bottom: 80px; /* Positioned 60px above the queue button (48px button height + 12px margin) */
205 | }
206 |
--------------------------------------------------------------------------------
/routes/utils/celery_config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import logging
4 | from pathlib import Path
5 |
6 | # Configure logging
7 | logger = logging.getLogger(__name__)
8 |
9 | # Redis configuration - read from environment variables
10 | REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
11 | REDIS_PORT = os.getenv('REDIS_PORT', '6379')
12 | REDIS_DB = os.getenv('REDIS_DB', '0')
13 | # Optional Redis password
14 | REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '')
15 | # Build default URL with password if provided
16 | _password_part = f":{REDIS_PASSWORD}@" if REDIS_PASSWORD else ""
17 | default_redis_url = f"redis://{_password_part}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
18 | REDIS_URL = os.getenv('REDIS_URL', default_redis_url)
19 | REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL)
20 |
21 | # Log Redis connection details
22 | logger.info(f"Redis configuration: REDIS_URL={REDIS_URL}, REDIS_BACKEND={REDIS_BACKEND}")
23 |
24 | # Config path
25 | CONFIG_PATH = './data/config/main.json'
26 |
27 | def get_config_params():
28 | """
29 | Get configuration parameters from the config file.
30 |
31 | Returns:
32 | dict: A dictionary containing configuration parameters
33 | """
34 | try:
35 | if not Path(CONFIG_PATH).exists():
36 | return {
37 | 'service': 'spotify',
38 | 'spotify': '',
39 | 'deezer': '',
40 | 'fallback': False,
41 | 'spotifyQuality': 'NORMAL',
42 | 'deezerQuality': 'MP3_128',
43 | 'realTime': False,
44 | 'customDirFormat': '%ar_album%/%album%',
45 | 'customTrackFormat': '%tracknum%. %music%',
46 | 'tracknum_padding': True,
47 | 'maxConcurrentDownloads': 3,
48 | 'maxRetries': 3,
49 | 'retryDelaySeconds': 5,
50 | 'retry_delay_increase': 5
51 | }
52 |
53 | with open(CONFIG_PATH, 'r') as f:
54 | config = json.load(f)
55 |
56 | # Set defaults for missing values
57 | defaults = {
58 | 'service': 'spotify',
59 | 'spotify': '',
60 | 'deezer': '',
61 | 'fallback': False,
62 | 'spotifyQuality': 'NORMAL',
63 | 'deezerQuality': 'MP3_128',
64 | 'realTime': False,
65 | 'customDirFormat': '%ar_album%/%album%',
66 | 'customTrackFormat': '%tracknum%. %music%',
67 | 'tracknum_padding': True,
68 | 'maxConcurrentDownloads': 3,
69 | 'maxRetries': 3,
70 | 'retryDelaySeconds': 5,
71 | 'retry_delay_increase': 5
72 | }
73 |
74 | for key, value in defaults.items():
75 | if key not in config:
76 | config[key] = value
77 |
78 | return config
79 | except Exception as e:
80 | logger.error(f"Error reading config: {e}")
81 | # Return defaults if config read fails
82 | return {
83 | 'service': 'spotify',
84 | 'spotify': '',
85 | 'deezer': '',
86 | 'fallback': False,
87 | 'spotifyQuality': 'NORMAL',
88 | 'deezerQuality': 'MP3_128',
89 | 'realTime': False,
90 | 'customDirFormat': '%ar_album%/%album%',
91 | 'customTrackFormat': '%tracknum%. %music%',
92 | 'tracknum_padding': True,
93 | 'maxConcurrentDownloads': 3,
94 | 'maxRetries': 3,
95 | 'retryDelaySeconds': 5,
96 | 'retry_delay_increase': 5
97 | }
98 |
99 | # Load configuration values we need for Celery
100 | config = get_config_params()
101 | MAX_CONCURRENT_DL = config.get('maxConcurrentDownloads', 3)
102 | MAX_RETRIES = config.get('maxRetries', 3)
103 | RETRY_DELAY = config.get('retryDelaySeconds', 5)
104 | RETRY_DELAY_INCREASE = config.get('retry_delay_increase', 5)
105 |
106 | # Define task queues
107 | task_queues = {
108 | 'default': {
109 | 'exchange': 'default',
110 | 'routing_key': 'default',
111 | },
112 | 'downloads': {
113 | 'exchange': 'downloads',
114 | 'routing_key': 'downloads',
115 | }
116 | }
117 |
118 | # Set default queue
119 | task_default_queue = 'downloads'
120 | task_default_exchange = 'downloads'
121 | task_default_routing_key = 'downloads'
122 |
123 | # Celery task settings
124 | task_serializer = 'json'
125 | accept_content = ['json']
126 | result_serializer = 'json'
127 | enable_utc = True
128 |
129 | # Configure worker concurrency based on MAX_CONCURRENT_DL
130 | worker_concurrency = MAX_CONCURRENT_DL
131 |
132 | # Configure task rate limiting - these are per-minute limits
133 | task_annotations = {
134 | 'routes.utils.celery_tasks.download_track': {
135 | 'rate_limit': f'{MAX_CONCURRENT_DL}/m',
136 | },
137 | 'routes.utils.celery_tasks.download_album': {
138 | 'rate_limit': f'{MAX_CONCURRENT_DL}/m',
139 | },
140 | 'routes.utils.celery_tasks.download_playlist': {
141 | 'rate_limit': f'{MAX_CONCURRENT_DL}/m',
142 | }
143 | }
144 |
145 | # Configure retry settings
146 | task_default_retry_delay = RETRY_DELAY # seconds
147 | task_max_retries = MAX_RETRIES
148 |
149 | # Task result settings
150 | task_track_started = True
151 | result_expires = 60 * 60 * 24 * 7 # 7 days
152 |
153 | # Configure visibility timeout for task messages
154 | broker_transport_options = {
155 | 'visibility_timeout': 3600, # 1 hour
156 | 'fanout_prefix': True,
157 | 'fanout_patterns': True,
158 | 'priority_steps': [0, 3, 6, 9],
159 | }
160 |
161 | # Important broker connection settings
162 | broker_connection_retry = True
163 | broker_connection_retry_on_startup = True
164 | broker_connection_max_retries = 10
165 | broker_pool_limit = 10
166 | worker_prefetch_multiplier = 1 # Process one task at a time per worker
167 | worker_max_tasks_per_child = 100 # Restart worker after 100 tasks
168 | worker_disable_rate_limits = False
--------------------------------------------------------------------------------
/routes/track.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, Response, request
2 | import os
3 | import json
4 | import traceback
5 | import uuid # For generating error task IDs
6 | import time # For timestamps
7 | from routes.utils.celery_queue_manager import download_queue_manager
8 | from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation
9 | from urllib.parse import urlparse # for URL validation
10 | from routes.utils.get_info import get_spotify_info # Added import
11 |
12 | track_bp = Blueprint('track', __name__)
13 |
14 | @track_bp.route('/download/', methods=['GET'])
15 | def handle_download(track_id):
16 | # Retrieve essential parameters from the request.
17 | # name = request.args.get('name') # Removed
18 | # artist = request.args.get('artist') # Removed
19 | orig_params = request.args.to_dict()
20 |
21 | # Construct the URL from track_id
22 | url = f"https://open.spotify.com/track/{track_id}"
23 | orig_params["original_url"] = url # Update original_url to the constructed one
24 |
25 | # Fetch metadata from Spotify
26 | try:
27 | track_info = get_spotify_info(track_id, "track")
28 | if not track_info or not track_info.get('name') or not track_info.get('artists'):
29 | return Response(
30 | json.dumps({"error": f"Could not retrieve metadata for track ID: {track_id}"}),
31 | status=404,
32 | mimetype='application/json'
33 | )
34 |
35 | name_from_spotify = track_info.get('name')
36 | artist_from_spotify = track_info['artists'][0].get('name') if track_info['artists'] else "Unknown Artist"
37 |
38 | except Exception as e:
39 | return Response(
40 | json.dumps({"error": f"Failed to fetch metadata for track {track_id}: {str(e)}"}),
41 | status=500,
42 | mimetype='application/json'
43 | )
44 |
45 | # Validate required parameters
46 | if not url:
47 | return Response(
48 | json.dumps({"error": "Missing required parameter: url", "original_url": url}),
49 | status=400,
50 | mimetype='application/json'
51 | )
52 | # Validate URL domain
53 | parsed = urlparse(url)
54 | host = parsed.netloc.lower()
55 | if not (host.endswith('deezer.com') or host.endswith('open.spotify.com') or host.endswith('spotify.com')):
56 | return Response(
57 | json.dumps({"error": f"Invalid Link {url} :(", "original_url": url}),
58 | status=400,
59 | mimetype='application/json'
60 | )
61 |
62 | try:
63 | task_id = download_queue_manager.add_task({
64 | "download_type": "track",
65 | "url": url,
66 | "name": name_from_spotify, # Use fetched name
67 | "artist": artist_from_spotify, # Use fetched artist
68 | "orig_request": orig_params
69 | })
70 | # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
71 | except Exception as e:
72 | # Generic error handling for other issues during task submission
73 | error_task_id = str(uuid.uuid4())
74 | store_task_info(error_task_id, {
75 | "download_type": "track",
76 | "url": url,
77 | "name": name_from_spotify, # Use fetched name
78 | "artist": artist_from_spotify, # Use fetched artist
79 | "original_request": orig_params,
80 | "created_at": time.time(),
81 | "is_submission_error_task": True
82 | })
83 | store_task_status(error_task_id, {
84 | "status": ProgressState.ERROR,
85 | "error": f"Failed to queue track download: {str(e)}",
86 | "timestamp": time.time()
87 | })
88 | return Response(
89 | json.dumps({"error": f"Failed to queue track download: {str(e)}", "task_id": error_task_id}),
90 | status=500,
91 | mimetype='application/json'
92 | )
93 |
94 | return Response(
95 | json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
96 | status=202,
97 | mimetype='application/json'
98 | )
99 |
100 | @track_bp.route('/download/cancel', methods=['GET'])
101 | def cancel_download():
102 | """
103 | Cancel a running track download process by its process id (prg file name).
104 | """
105 | prg_file = request.args.get('prg_file')
106 | if not prg_file:
107 | return Response(
108 | json.dumps({"error": "Missing process id (prg_file) parameter"}),
109 | status=400,
110 | mimetype='application/json'
111 | )
112 |
113 | # Use the queue manager's cancellation method.
114 | result = download_queue_manager.cancel_task(prg_file)
115 | status_code = 200 if result.get("status") == "cancelled" else 404
116 |
117 | return Response(
118 | json.dumps(result),
119 | status=status_code,
120 | mimetype='application/json'
121 | )
122 |
123 | @track_bp.route('/info', methods=['GET'])
124 | def get_track_info():
125 | """
126 | Retrieve Spotify track metadata given a Spotify track ID.
127 | Expects a query parameter 'id' that contains the Spotify track ID.
128 | """
129 | spotify_id = request.args.get('id')
130 |
131 | if not spotify_id:
132 | return Response(
133 | json.dumps({"error": "Missing parameter: id"}),
134 | status=400,
135 | mimetype='application/json'
136 | )
137 |
138 | try:
139 | # Import and use the get_spotify_info function from the utility module.
140 | from routes.utils.get_info import get_spotify_info
141 | track_info = get_spotify_info(spotify_id, "track")
142 | return Response(
143 | json.dumps(track_info),
144 | status=200,
145 | mimetype='application/json'
146 | )
147 | except Exception as e:
148 | error_data = {
149 | "error": str(e),
150 | "traceback": traceback.format_exc()
151 | }
152 | return Response(
153 | json.dumps(error_data),
154 | status=500,
155 | mimetype='application/json'
156 | )
157 |
--------------------------------------------------------------------------------
/static/css/main/main.css:
--------------------------------------------------------------------------------
1 | /* GENERAL STYLING & UTILITIES */
2 | * {
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
7 | }
8 |
9 | body {
10 | /* Use a subtle dark gradient for a modern feel */
11 | background: linear-gradient(135deg, #121212, #1e1e1e);
12 | color: #ffffff;
13 | min-height: 100vh;
14 | }
15 |
16 | /* Main container for page content */
17 | .container {
18 | max-width: 1200px;
19 | margin: 0 auto;
20 | padding: 20px;
21 | position: relative;
22 | z-index: 1;
23 | }
24 |
25 | /* LOADING & ERROR STATES */
26 | .loading,
27 | .error,
28 | .success {
29 | width: 100%;
30 | text-align: center;
31 | font-size: 1rem;
32 | padding: 1rem;
33 | position: fixed;
34 | bottom: 20px;
35 | left: 50%;
36 | transform: translateX(-50%);
37 | z-index: 9999;
38 | border-radius: 8px;
39 | max-width: 80%;
40 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
41 | }
42 |
43 | .error {
44 | color: #fff;
45 | background-color: rgba(192, 57, 43, 0.9);
46 | }
47 |
48 | .success {
49 | color: #fff;
50 | background-color: rgba(46, 204, 113, 0.9);
51 | }
52 |
53 | /* Main search page specific styles */
54 |
55 | /* Search header improvements */
56 | .search-header {
57 | display: flex;
58 | align-items: center;
59 | gap: 10px;
60 | margin-bottom: 30px;
61 | position: sticky;
62 | top: 0;
63 | background: rgba(18, 18, 18, 0.95);
64 | backdrop-filter: blur(10px);
65 | padding: 20px 0;
66 | z-index: 100;
67 | border-bottom: 1px solid var(--color-border);
68 | }
69 |
70 | .search-input-container {
71 | display: flex;
72 | flex: 1;
73 | gap: 10px;
74 | }
75 |
76 | .search-input {
77 | flex: 1;
78 | padding: 12px 20px;
79 | border: none;
80 | border-radius: 25px;
81 | background: var(--color-surface);
82 | color: var(--color-text-primary);
83 | font-size: 16px;
84 | outline: none;
85 | transition: background-color 0.3s ease, box-shadow 0.3s ease;
86 | }
87 |
88 | .search-input:focus {
89 | background: var(--color-surface-hover);
90 | box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3);
91 | }
92 |
93 | .search-type {
94 | padding: 12px 15px;
95 | background: var(--color-surface);
96 | border: none;
97 | border-radius: 25px;
98 | color: var(--color-text-primary);
99 | cursor: pointer;
100 | transition: background-color 0.3s ease;
101 | min-width: 100px;
102 | }
103 |
104 | .search-type:hover,
105 | .search-type:focus {
106 | background: var(--color-surface-hover);
107 | }
108 |
109 | .search-button {
110 | padding: 12px 25px;
111 | background-color: var(--color-primary);
112 | border: none;
113 | border-radius: 25px;
114 | color: white;
115 | font-weight: 600;
116 | cursor: pointer;
117 | transition: background-color 0.3s ease, transform 0.2s ease;
118 | display: flex;
119 | align-items: center;
120 | gap: 8px;
121 | }
122 |
123 | .search-button img {
124 | width: 18px;
125 | height: 18px;
126 | filter: brightness(0) invert(1);
127 | }
128 |
129 | .search-button:hover {
130 | background-color: var(--color-primary-hover);
131 | transform: translateY(-2px);
132 | }
133 |
134 | /* Empty state styles */
135 | .empty-state {
136 | display: flex;
137 | flex-direction: column;
138 | align-items: center;
139 | justify-content: center;
140 | height: 60vh;
141 | text-align: center;
142 | }
143 |
144 | .empty-state-content {
145 | max-width: 450px;
146 | }
147 |
148 | .empty-state-icon {
149 | width: 80px;
150 | height: 80px;
151 | margin-bottom: 1.5rem;
152 | opacity: 0.7;
153 | }
154 |
155 | .empty-state h2 {
156 | font-size: 1.75rem;
157 | margin-bottom: 1rem;
158 | background: linear-gradient(90deg, var(--color-primary), #2ecc71);
159 | -webkit-background-clip: text;
160 | -webkit-text-fill-color: transparent;
161 | background-clip: text;
162 | }
163 |
164 | .empty-state p {
165 | color: var(--color-text-secondary);
166 | font-size: 1rem;
167 | line-height: 1.5;
168 | }
169 |
170 | /* Results grid improvement */
171 | .results-grid {
172 | display: grid;
173 | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
174 | gap: 20px;
175 | margin-top: 20px;
176 | }
177 |
178 | /* Result card style */
179 | .result-card {
180 | background: var(--color-surface);
181 | border-radius: var(--radius-md);
182 | overflow: hidden;
183 | display: flex;
184 | flex-direction: column;
185 | transition: transform 0.2s ease, box-shadow 0.2s ease;
186 | box-shadow: var(--shadow-sm);
187 | height: 100%;
188 | }
189 |
190 | .result-card:hover {
191 | transform: translateY(-5px);
192 | box-shadow: var(--shadow-md);
193 | }
194 |
195 | /* Album art styling */
196 | .album-art-wrapper {
197 | position: relative;
198 | width: 100%;
199 | overflow: hidden;
200 | }
201 |
202 | .album-art-wrapper::before {
203 | content: "";
204 | display: block;
205 | padding-top: 100%;
206 | }
207 |
208 | .album-art {
209 | position: absolute;
210 | top: 0;
211 | left: 0;
212 | width: 100%;
213 | height: 100%;
214 | object-fit: cover;
215 | transition: transform 0.3s ease;
216 | }
217 |
218 | .result-card:hover .album-art {
219 | transform: scale(1.05);
220 | }
221 |
222 | /* Track title and details */
223 | .track-title {
224 | padding: 1rem 1rem 0.5rem;
225 | font-size: 1rem;
226 | font-weight: 600;
227 | color: var(--color-text-primary);
228 | white-space: nowrap;
229 | overflow: hidden;
230 | text-overflow: ellipsis;
231 | }
232 |
233 | .track-artist {
234 | padding: 0 1rem;
235 | font-size: 0.9rem;
236 | color: var(--color-text-secondary);
237 | white-space: nowrap;
238 | overflow: hidden;
239 | text-overflow: ellipsis;
240 | margin-bottom: 0.75rem;
241 | }
242 |
243 | .track-details {
244 | padding: 0.75rem 1rem;
245 | font-size: 0.85rem;
246 | color: var(--color-text-tertiary);
247 | border-top: 1px solid var(--color-border);
248 | display: flex;
249 | justify-content: space-between;
250 | align-items: center;
251 | margin-top: auto;
252 | }
253 |
254 | /* Download button within result cards */
255 | .download-btn {
256 | margin: 0 1rem 1rem;
257 | max-width: calc(100% - 2rem); /* Ensure button doesn't overflow container */
258 | width: auto; /* Allow button to shrink if needed */
259 | font-size: 0.9rem; /* Slightly smaller font size */
260 | padding: 0.6rem 1rem; /* Reduce padding slightly */
261 | overflow: hidden; /* Hide overflow */
262 | text-overflow: ellipsis; /* Add ellipsis for long text */
263 | white-space: nowrap; /* Prevent wrapping */
264 | }
265 |
266 | /* Mobile responsiveness */
267 | @media (max-width: 768px) {
268 | .search-header {
269 | flex-wrap: wrap;
270 | padding: 15px 0;
271 | gap: 12px;
272 | }
273 |
274 | .search-input-container {
275 | flex: 1 1 100%;
276 | order: 1;
277 | }
278 |
279 | .search-button {
280 | order: 2;
281 | flex: 1;
282 | }
283 |
284 | .results-grid {
285 | grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
286 | gap: 15px;
287 | }
288 |
289 | /* Smaller download button for mobile */
290 | .download-btn {
291 | padding: 0.5rem 0.8rem;
292 | font-size: 0.85rem;
293 | }
294 | }
295 |
296 | @media (max-width: 480px) {
297 | .search-header {
298 | padding: 10px 0;
299 | }
300 |
301 | .search-type {
302 | min-width: 80px;
303 | padding: 12px 10px;
304 | }
305 |
306 | .search-button {
307 | padding: 12px 15px;
308 | }
309 |
310 | .results-grid {
311 | grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
312 | gap: 12px;
313 | }
314 |
315 | .track-title, .track-artist {
316 | font-size: 0.9rem;
317 | }
318 |
319 | .track-details {
320 | font-size: 0.8rem;
321 | }
322 |
323 | /* Even smaller download button for very small screens */
324 | .download-btn {
325 | padding: 0.4rem 0.7rem;
326 | font-size: 0.8rem;
327 | margin: 0 0.8rem 0.8rem;
328 | max-width: calc(100% - 1.6rem);
329 | }
330 |
331 | .empty-state h2 {
332 | font-size: 1.5rem;
333 | }
334 |
335 | .empty-state p {
336 | font-size: 0.9rem;
337 | }
338 | }
339 |
--------------------------------------------------------------------------------
/src/js/history.ts:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null;
3 | const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null;
4 | const nextButton = document.getElementById('next-page') as HTMLButtonElement | null;
5 | const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null;
6 | const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null;
7 | const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null;
8 | const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null;
9 |
10 | let currentPage = 1;
11 | let limit = 25;
12 | let totalEntries = 0;
13 | let currentSortBy = 'timestamp_completed';
14 | let currentSortOrder = 'DESC';
15 |
16 | async function fetchHistory(page = 1) {
17 | if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) {
18 | console.error('One or more critical UI elements are missing for history page.');
19 | return;
20 | }
21 |
22 | const offset = (page - 1) * limit;
23 | let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`;
24 |
25 | const statusVal = statusFilter.value;
26 | if (statusVal) {
27 | apiUrl += `&status_final=${statusVal}`;
28 | }
29 | const typeVal = typeFilter.value;
30 | if (typeVal) {
31 | apiUrl += `&download_type=${typeVal}`;
32 | }
33 |
34 | try {
35 | const response = await fetch(apiUrl);
36 | if (!response.ok) {
37 | throw new Error(`HTTP error! status: ${response.status}`);
38 | }
39 | const data = await response.json();
40 | renderHistory(data.entries);
41 | totalEntries = data.total_count;
42 | currentPage = Math.floor(offset / limit) + 1;
43 | updatePagination();
44 | } catch (error) {
45 | console.error('Error fetching history:', error);
46 | if (historyTableBody) {
47 | historyTableBody.innerHTML = '| Error loading history. |
';
48 | }
49 | }
50 | }
51 |
52 | function renderHistory(entries: any[]) {
53 | if (!historyTableBody) return;
54 |
55 | historyTableBody.innerHTML = ''; // Clear existing rows
56 | if (!entries || entries.length === 0) {
57 | historyTableBody.innerHTML = '| No history entries found. |
';
58 | return;
59 | }
60 |
61 | entries.forEach(entry => {
62 | const row = historyTableBody.insertRow();
63 | row.insertCell().textContent = entry.item_name || 'N/A';
64 | row.insertCell().textContent = entry.item_artist || 'N/A';
65 | row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
66 |
67 | const statusCell = row.insertCell();
68 | statusCell.textContent = entry.status_final || 'N/A';
69 | statusCell.className = `status-${entry.status_final}`;
70 |
71 | row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A';
72 | row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A';
73 |
74 | const detailsCell = row.insertCell();
75 | const detailsButton = document.createElement('button');
76 | detailsButton.innerHTML = `
`;
77 | detailsButton.className = 'details-btn btn-icon';
78 | detailsButton.title = 'Show Details';
79 | detailsButton.onclick = () => showDetailsModal(entry);
80 | detailsCell.appendChild(detailsButton);
81 |
82 | if (entry.status_final === 'ERROR' && entry.error_message) {
83 | const errorSpan = document.createElement('span');
84 | errorSpan.textContent = ' (Show Error)';
85 | errorSpan.className = 'error-message-toggle';
86 | errorSpan.style.marginLeft = '5px';
87 | errorSpan.onclick = (e) => {
88 | e.stopPropagation(); // Prevent click on row if any
89 | let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null;
90 | if (!errorDetailsDiv) {
91 | errorDetailsDiv = document.createElement('div');
92 | errorDetailsDiv.className = 'error-details';
93 | const newCell = row.insertCell(); // This will append to the end of the row
94 | newCell.colSpan = 7; // Span across all columns
95 | newCell.appendChild(errorDetailsDiv);
96 | // Visually, this new cell will be after the 'Details' button cell.
97 | // To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed.
98 | }
99 | errorDetailsDiv.textContent = entry.error_message;
100 | // Toggle display by directly manipulating the style of the details div
101 | errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none';
102 | };
103 | statusCell.appendChild(errorSpan);
104 | }
105 | });
106 | }
107 |
108 | function updatePagination() {
109 | if (!pageInfo || !prevButton || !nextButton) return;
110 |
111 | const totalPages = Math.ceil(totalEntries / limit) || 1;
112 | pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
113 | prevButton.disabled = currentPage === 1;
114 | nextButton.disabled = currentPage === totalPages;
115 | }
116 |
117 | function showDetailsModal(entry: any) {
118 | const details = `Task ID: ${entry.task_id}\n` +
119 | `Type: ${entry.download_type}\n` +
120 | `Name: ${entry.item_name}\n` +
121 | `Artist: ${entry.item_artist}\n` +
122 | `Album: ${entry.item_album || 'N/A'}\n` +
123 | `URL: ${entry.item_url}\n` +
124 | `Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
125 | `Status: ${entry.status_final}\n` +
126 | `Error: ${entry.error_message || 'None'}\n` +
127 | `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
128 | `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` +
129 | `Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` +
130 | `Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`;
131 | alert(details);
132 | }
133 |
134 | document.querySelectorAll('th[data-sort]').forEach(headerCell => {
135 | headerCell.addEventListener('click', () => {
136 | const sortField = (headerCell as HTMLElement).dataset.sort;
137 | if (!sortField) return;
138 |
139 | if (currentSortBy === sortField) {
140 | currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
141 | } else {
142 | currentSortBy = sortField;
143 | currentSortOrder = 'DESC';
144 | }
145 | fetchHistory(1);
146 | });
147 | });
148 |
149 | prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1));
150 | nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1));
151 | limitSelect?.addEventListener('change', (e) => {
152 | limit = parseInt((e.target as HTMLSelectElement).value, 10);
153 | fetchHistory(1);
154 | });
155 | statusFilter?.addEventListener('change', () => fetchHistory(1));
156 | typeFilter?.addEventListener('change', () => fetchHistory(1));
157 |
158 | // Initial fetch
159 | fetchHistory();
160 | });
--------------------------------------------------------------------------------
/static/css/track/track.css:
--------------------------------------------------------------------------------
1 | /* Base Styles */
2 | * {
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
7 | }
8 |
9 | body {
10 | background: linear-gradient(135deg, #121212, #1e1e1e);
11 | color: #ffffff;
12 | min-height: 100vh;
13 | line-height: 1.4;
14 | }
15 |
16 | /* App Container */
17 | #app {
18 | max-width: 1200px;
19 | margin: 0 auto;
20 | padding: 20px;
21 | position: relative;
22 | z-index: 1;
23 | }
24 |
25 | /* Track Header:
26 | We assume an HTML structure like:
27 |
34 | */
35 | #track-header {
36 | display: flex;
37 | align-items: center;
38 | justify-content: space-between;
39 | gap: 20px;
40 | margin-bottom: 2rem;
41 | padding-bottom: 1.5rem;
42 | border-bottom: 1px solid #2a2a2a;
43 | flex-wrap: wrap;
44 | }
45 |
46 | /* Album Image */
47 | #track-album-image {
48 | width: 200px;
49 | height: 200px;
50 | object-fit: cover;
51 | border-radius: 8px;
52 | flex-shrink: 0;
53 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
54 | transition: transform 0.3s ease;
55 | }
56 | #track-album-image:hover {
57 | transform: scale(1.02);
58 | }
59 |
60 | /* Track Info */
61 | #track-info {
62 | flex: 1;
63 | min-width: 0;
64 | /* For mobile, the text block can wrap if needed */
65 | }
66 |
67 | /* Track Text Elements */
68 | #track-name {
69 | font-size: 2.5rem;
70 | margin-bottom: 0.5rem;
71 | background: linear-gradient(90deg, #1db954, #17a44b);
72 | -webkit-background-clip: text;
73 | -webkit-text-fill-color: transparent;
74 | }
75 |
76 | #track-artist,
77 | #track-album,
78 | #track-duration,
79 | #track-explicit {
80 | font-size: 1.1rem;
81 | color: #b3b3b3;
82 | margin-bottom: 0.5rem;
83 | }
84 |
85 | /* Download Button */
86 | .download-btn {
87 | background-color: #1db954;
88 | border: none;
89 | border-radius: 4px;
90 | padding: 0.6rem;
91 | cursor: pointer;
92 | transition: background-color 0.2s ease, transform 0.2s ease;
93 | display: inline-flex;
94 | align-items: center;
95 | justify-content: center;
96 | }
97 |
98 | /* Download Button Icon */
99 | .download-btn img {
100 | width: 20px;
101 | height: 20px;
102 | filter: brightness(0) invert(1);
103 | display: block;
104 | }
105 |
106 | /* Hover and Active States */
107 | .download-btn:hover {
108 | background-color: #17a44b;
109 | transform: scale(1.05);
110 | }
111 | .download-btn:active {
112 | transform: scale(0.98);
113 | }
114 |
115 | /* Home Button Styling */
116 | .home-btn {
117 | background-color: transparent;
118 | border: none;
119 | cursor: pointer;
120 | padding: 0;
121 | }
122 | .home-btn img {
123 | width: 32px;
124 | height: 32px;
125 | filter: invert(1);
126 | transition: transform 0.2s ease;
127 | }
128 | .home-btn:hover img {
129 | transform: scale(1.05);
130 | }
131 | .home-btn:active img {
132 | transform: scale(0.98);
133 | }
134 |
135 | /* Loading and Error Messages */
136 | #loading,
137 | #error {
138 | width: 100%;
139 | text-align: center;
140 | font-size: 1rem;
141 | padding: 1rem;
142 | }
143 | #error {
144 | color: #c0392b;
145 | }
146 |
147 | /* Utility class to hide elements */
148 | .hidden {
149 | display: none !important;
150 | }
151 |
152 | /* Responsive Styles for Tablets and Smaller Devices */
153 | @media (max-width: 768px) {
154 | #app {
155 | padding: 15px;
156 | }
157 | #track-header {
158 | flex-direction: column;
159 | align-items: center;
160 | text-align: center;
161 | }
162 | #track-album-image {
163 | width: 180px;
164 | height: 180px;
165 | }
166 | #track-name {
167 | font-size: 2rem;
168 | }
169 | #track-artist,
170 | #track-album,
171 | #track-duration,
172 | #track-explicit {
173 | font-size: 1rem;
174 | }
175 | .download-btn {
176 | padding: 0.5rem;
177 | margin-top: 0.8rem;
178 | }
179 | }
180 |
181 | /* Responsive Styles for Mobile Phones */
182 | @media (max-width: 480px) {
183 | #track-album-image {
184 | width: 150px;
185 | height: 150px;
186 | }
187 | #track-name {
188 | font-size: 1.75rem;
189 | }
190 | #track-artist,
191 | #track-album,
192 | #track-duration,
193 | #track-explicit {
194 | font-size: 0.9rem;
195 | }
196 | .download-btn {
197 | padding: 0.5rem;
198 | margin-top: 0.8rem;
199 | }
200 | }
201 |
202 | /* Prevent anchor links from appearing all blue */
203 | a {
204 | color: inherit;
205 | text-decoration: none;
206 | transition: color 0.2s ease;
207 | }
208 | a:hover,
209 | a:focus {
210 | color: #1db954;
211 | text-decoration: underline;
212 | }
213 | /* Ensure the header lays out its children with space-between */
214 | #track-header {
215 | display: flex;
216 | align-items: center;
217 | justify-content: space-between;
218 | gap: 20px;
219 | margin-bottom: 2rem;
220 | padding-bottom: 1.5rem;
221 | border-bottom: 1px solid #2a2a2a;
222 | flex-wrap: wrap;
223 | }
224 |
225 | /* (Optional) If you need to style the download button specifically: */
226 | .download-btn {
227 | background-color: #1db954;
228 | border: none;
229 | border-radius: 4px;
230 | padding: 0.6rem;
231 | cursor: pointer;
232 | transition: background-color 0.2s ease, transform 0.2s ease;
233 | display: inline-flex;
234 | align-items: center;
235 | justify-content: center;
236 | }
237 |
238 | /* Style the download button's icon */
239 | .download-btn img {
240 | width: 20px;
241 | height: 20px;
242 | filter: brightness(0) invert(1);
243 | display: block;
244 | }
245 |
246 | /* Hover and active states */
247 | .download-btn:hover {
248 | background-color: #17a44b;
249 | transform: scale(1.05);
250 | }
251 | .download-btn:active {
252 | transform: scale(0.98);
253 | }
254 |
255 | /* Responsive adjustments remain as before */
256 | @media (max-width: 768px) {
257 | #track-header {
258 | flex-direction: column;
259 | align-items: center;
260 | text-align: center;
261 | }
262 | }
263 |
264 | /* Track page specific styles */
265 |
266 | /* Track details formatting */
267 | .track-details {
268 | display: flex;
269 | gap: 1rem;
270 | margin-top: 0.5rem;
271 | color: var(--color-text-secondary);
272 | font-size: 0.9rem;
273 | }
274 |
275 | .track-detail-item {
276 | display: flex;
277 | align-items: center;
278 | }
279 |
280 | /* Make explicit tag stand out if needed */
281 | #track-explicit:not(:empty) {
282 | background-color: rgba(255, 255, 255, 0.1);
283 | padding: 0.2rem 0.5rem;
284 | border-radius: var(--radius-sm);
285 | font-size: 0.8rem;
286 | text-transform: uppercase;
287 | letter-spacing: 0.05em;
288 | }
289 |
290 | /* Loading indicator animation */
291 | .loading-indicator {
292 | display: inline-block;
293 | position: relative;
294 | width: 80px;
295 | height: 80px;
296 | }
297 |
298 | .loading-indicator:after {
299 | content: " ";
300 | display: block;
301 | width: 40px;
302 | height: 40px;
303 | margin: 8px;
304 | border-radius: 50%;
305 | border: 4px solid var(--color-primary);
306 | border-color: var(--color-primary) transparent var(--color-primary) transparent;
307 | animation: loading-rotation 1.2s linear infinite;
308 | }
309 |
310 | @keyframes loading-rotation {
311 | 0% {
312 | transform: rotate(0deg);
313 | }
314 | 100% {
315 | transform: rotate(360deg);
316 | }
317 | }
318 |
319 | /* Modern gradient for the track name */
320 | #track-name a {
321 | background: linear-gradient(90deg, var(--color-primary), #2ecc71);
322 | -webkit-background-clip: text;
323 | -webkit-text-fill-color: transparent;
324 | background-clip: text;
325 | display: inline-block;
326 | }
327 |
328 | /* Ensure proper spacing for album and artist links */
329 | #track-artist a,
330 | #track-album a {
331 | transition: color 0.2s ease, text-decoration 0.2s ease;
332 | }
333 |
334 | #track-artist a:hover,
335 | #track-album a:hover {
336 | color: var(--color-primary);
337 | }
338 |
339 | /* Mobile-specific adjustments */
340 | @media (max-width: 768px) {
341 | .track-details {
342 | flex-direction: column;
343 | gap: 0.25rem;
344 | margin-bottom: 1rem;
345 | }
346 |
347 | #track-name a {
348 | font-size: 1.75rem;
349 | }
350 | }
351 |
352 | @media (max-width: 480px) {
353 | #track-name a {
354 | font-size: 1.5rem;
355 | }
356 |
357 | .track-details {
358 | margin-bottom: 1.5rem;
359 | }
360 | }
361 |
--------------------------------------------------------------------------------
/routes/config.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, jsonify, request
2 | import json
3 | from pathlib import Path
4 | import logging
5 | import threading
6 | import time
7 | import os
8 |
9 | config_bp = Blueprint('config_bp', __name__)
10 | CONFIG_PATH = Path('./data/config/main.json')
11 | CONFIG_PATH_WATCH = Path('./data/config/watch.json')
12 |
13 | # Flag for config change notifications
14 | config_changed = False
15 | last_config = {}
16 |
17 | # Define parameters that should trigger notification when changed
18 | NOTIFY_PARAMETERS = [
19 | 'maxConcurrentDownloads',
20 | 'service',
21 | 'fallback',
22 | 'spotifyQuality',
23 | 'deezerQuality'
24 | ]
25 |
26 | def get_config():
27 | try:
28 | if not CONFIG_PATH.exists():
29 | CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
30 | CONFIG_PATH.write_text('{}')
31 | return {}
32 |
33 | with open(CONFIG_PATH, 'r') as f:
34 | return json.load(f)
35 | except Exception as e:
36 | logging.error(f"Error reading config: {str(e)}")
37 | return None
38 |
39 | def save_config(config_data):
40 | """Save config and track changes to important parameters"""
41 | global config_changed, last_config
42 |
43 | try:
44 | # Load current config for comparison
45 | current_config = get_config() or {}
46 |
47 | # Check if any notify parameters changed
48 | for param in NOTIFY_PARAMETERS:
49 | if param in config_data:
50 | if param not in current_config or config_data[param] != current_config.get(param):
51 | config_changed = True
52 | logging.info(f"Config parameter '{param}' changed from '{current_config.get(param)}' to '{config_data[param]}'")
53 |
54 | # Save last known config
55 | last_config = config_data.copy()
56 |
57 | # Write the config file
58 | CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
59 | with open(CONFIG_PATH, 'w') as f:
60 | json.dump(config_data, f, indent=2)
61 |
62 | return True
63 | except Exception as e:
64 | logging.error(f"Error saving config: {str(e)}")
65 | return False
66 |
67 | def get_watch_config():
68 | """Reads watch.json and returns its content or defaults."""
69 | try:
70 | if not CONFIG_PATH_WATCH.exists():
71 | CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True)
72 | # Default watch config
73 | defaults = {
74 | 'enabled': False,
75 | 'watchedArtistAlbumGroup': ["album", "single"],
76 | 'watchPollIntervalSeconds': 3600
77 | }
78 | CONFIG_PATH_WATCH.write_text(json.dumps(defaults, indent=2))
79 | return defaults
80 | with open(CONFIG_PATH_WATCH, 'r') as f:
81 | return json.load(f)
82 | except Exception as e:
83 | logging.error(f"Error reading watch config: {str(e)}")
84 | # Return defaults on error to prevent crashes
85 | return {
86 | 'enabled': False,
87 | 'watchedArtistAlbumGroup': ["album", "single"],
88 | 'watchPollIntervalSeconds': 3600
89 | }
90 |
91 | def save_watch_config(watch_config_data):
92 | """Saves data to watch.json."""
93 | try:
94 | CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True)
95 | with open(CONFIG_PATH_WATCH, 'w') as f:
96 | json.dump(watch_config_data, f, indent=2)
97 | return True
98 | except Exception as e:
99 | logging.error(f"Error saving watch config: {str(e)}")
100 | return False
101 |
102 | @config_bp.route('/config', methods=['GET'])
103 | def handle_config():
104 | config = get_config()
105 | if config is None:
106 | return jsonify({"error": "Could not read config file"}), 500
107 |
108 | # Create config/state directory
109 | Path('./data/config/state').mkdir(parents=True, exist_ok=True)
110 |
111 | # Set default values for any missing config options
112 | defaults = {
113 | 'service': 'spotify', # Default service is Spotify
114 | 'fallback': False,
115 | 'spotifyQuality': 'NORMAL',
116 | 'deezerQuality': 'MP3_128',
117 | 'realTime': False,
118 | 'customDirFormat': '%ar_album%/%album%',
119 | 'customTrackFormat': '%tracknum%. %music%',
120 | 'maxConcurrentDownloads': 3,
121 | 'maxRetries': 3,
122 | 'retryDelaySeconds': 5,
123 | 'retry_delay_increase': 5,
124 | 'tracknum_padding': True
125 | }
126 |
127 | # Populate defaults for any missing keys
128 | for key, default_value in defaults.items():
129 | if key not in config:
130 | config[key] = default_value
131 |
132 | # Get explicit filter setting from environment variable
133 | explicit_filter_env = os.environ.get('EXPLICIT_FILTER', 'false').lower()
134 | config['explicitFilter'] = explicit_filter_env in ('true', '1', 'yes', 'on')
135 |
136 | return jsonify(config)
137 |
138 | @config_bp.route('/config', methods=['POST', 'PUT'])
139 | def update_config():
140 | try:
141 | new_config = request.get_json()
142 | if not isinstance(new_config, dict):
143 | return jsonify({"error": "Invalid config format"}), 400
144 |
145 | # Get existing config to preserve environment-controlled values
146 | existing_config = get_config() or {}
147 |
148 | # Preserve the explicitFilter setting from environment
149 | explicit_filter_env = os.environ.get('EXPLICIT_FILTER', 'false').lower()
150 | new_config['explicitFilter'] = explicit_filter_env in ('true', '1', 'yes', 'on')
151 |
152 | if not save_config(new_config):
153 | return jsonify({"error": "Failed to save config"}), 500
154 |
155 | # Return the updated config
156 | updated_config_values = get_config()
157 | if updated_config_values is None:
158 | # This case should ideally not be reached if save_config succeeded
159 | # and get_config handles errors by returning a default or None.
160 | return jsonify({"error": "Failed to retrieve configuration after saving"}), 500
161 |
162 | return jsonify(updated_config_values)
163 | except json.JSONDecodeError:
164 | return jsonify({"error": "Invalid JSON data"}), 400
165 | except Exception as e:
166 | logging.error(f"Error updating config: {str(e)}")
167 | return jsonify({"error": "Failed to update config"}), 500
168 |
169 | @config_bp.route('/config/check', methods=['GET'])
170 | def check_config_changes():
171 | """
172 | Check if config has changed since last check
173 | Returns: Status of config changes
174 | """
175 | global config_changed
176 |
177 | # Get current state
178 | has_changed = config_changed
179 |
180 | # Reset flag after checking
181 | if has_changed:
182 | config_changed = False
183 |
184 | return jsonify({
185 | "changed": has_changed,
186 | "last_config": last_config
187 | })
188 |
189 | @config_bp.route('/config/watch', methods=['GET'])
190 | def handle_watch_config():
191 | watch_config = get_watch_config()
192 | # Ensure defaults are applied if file was corrupted or missing fields
193 | defaults = {
194 | 'enabled': False,
195 | 'watchedArtistAlbumGroup': ["album", "single"],
196 | 'watchPollIntervalSeconds': 3600
197 | }
198 | for key, default_value in defaults.items():
199 | if key not in watch_config:
200 | watch_config[key] = default_value
201 |
202 | return jsonify(watch_config)
203 |
204 | @config_bp.route('/config/watch', methods=['POST', 'PUT'])
205 | def update_watch_config():
206 | try:
207 | new_watch_config = request.get_json()
208 | if not isinstance(new_watch_config, dict):
209 | return jsonify({"error": "Invalid watch config format"}), 400
210 |
211 | if not save_watch_config(new_watch_config):
212 | return jsonify({"error": "Failed to save watch config"}), 500
213 |
214 | updated_watch_config_values = get_watch_config()
215 | if updated_watch_config_values is None:
216 | return jsonify({"error": "Failed to retrieve watch configuration after saving"}), 500
217 |
218 | return jsonify(updated_watch_config_values)
219 | except json.JSONDecodeError:
220 | return jsonify({"error": "Invalid JSON data for watch config"}), 400
221 | except Exception as e:
222 | logging.error(f"Error updating watch config: {str(e)}")
223 | return jsonify({"error": "Failed to update watch config"}), 500
--------------------------------------------------------------------------------
/src/js/track.ts:
--------------------------------------------------------------------------------
1 | // Import the downloadQueue singleton from your working queue.js implementation.
2 | import { downloadQueue } from './queue.js';
3 |
4 | document.addEventListener('DOMContentLoaded', () => {
5 | // Parse track ID from URL. Expecting URL in the form /track/{id}
6 | const pathSegments = window.location.pathname.split('/');
7 | const trackId = pathSegments[pathSegments.indexOf('track') + 1];
8 |
9 | if (!trackId) {
10 | showError('No track ID provided.');
11 | return;
12 | }
13 |
14 | // Fetch track info directly
15 | fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
16 | .then(response => {
17 | if (!response.ok) throw new Error('Network response was not ok');
18 | return response.json();
19 | })
20 | .then(data => renderTrack(data))
21 | .catch(error => {
22 | console.error('Error:', error);
23 | showError('Error loading track');
24 | });
25 |
26 | // Attach event listener to the queue icon to toggle the download queue
27 | const queueIcon = document.getElementById('queueIcon');
28 | if (queueIcon) {
29 | queueIcon.addEventListener('click', () => {
30 | downloadQueue.toggleVisibility();
31 | });
32 | }
33 | });
34 |
35 | /**
36 | * Renders the track header information.
37 | */
38 | function renderTrack(track: any) {
39 | // Hide the loading and error messages.
40 | const loadingEl = document.getElementById('loading');
41 | if (loadingEl) loadingEl.classList.add('hidden');
42 | const errorEl = document.getElementById('error');
43 | if (errorEl) errorEl.classList.add('hidden');
44 |
45 | // Check if track is explicit and if explicit filter is enabled
46 | if (track.explicit && downloadQueue.isExplicitFilterEnabled()) {
47 | // Show placeholder for explicit content
48 | const loadingElExplicit = document.getElementById('loading');
49 | if (loadingElExplicit) loadingElExplicit.classList.add('hidden');
50 |
51 | const placeholderContent = `
52 |
53 |
Explicit Content Filtered
54 |
This track contains explicit content and has been filtered based on your settings.
55 |
The explicit content filter is controlled by environment variables.
56 |
57 | `;
58 |
59 | const contentContainer = document.getElementById('track-header');
60 | if (contentContainer) {
61 | contentContainer.innerHTML = placeholderContent;
62 | contentContainer.classList.remove('hidden');
63 | }
64 |
65 | return; // Stop rendering the actual track content
66 | }
67 |
68 | // Update track information fields.
69 | const trackNameEl = document.getElementById('track-name');
70 | if (trackNameEl) {
71 | trackNameEl.innerHTML =
72 | `${track.name || 'Unknown Track'}`;
73 | }
74 |
75 | const trackArtistEl = document.getElementById('track-artist');
76 | if (trackArtistEl) {
77 | trackArtistEl.innerHTML =
78 | `By ${track.artists?.map((a: any) =>
79 | `${a?.name || 'Unknown Artist'}`
80 | ).join(', ') || 'Unknown Artist'}`;
81 | }
82 |
83 | const trackAlbumEl = document.getElementById('track-album');
84 | if (trackAlbumEl) {
85 | trackAlbumEl.innerHTML =
86 | `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`;
87 | }
88 |
89 | const trackDurationEl = document.getElementById('track-duration');
90 | if (trackDurationEl) {
91 | trackDurationEl.textContent =
92 | `Duration: ${msToTime(track.duration_ms || 0)}`;
93 | }
94 |
95 | const trackExplicitEl = document.getElementById('track-explicit');
96 | if (trackExplicitEl) {
97 | trackExplicitEl.textContent =
98 | track.explicit ? 'Explicit' : 'Clean';
99 | }
100 |
101 | const imageUrl = (track.album?.images && track.album.images[0])
102 | ? track.album.images[0].url
103 | : '/static/images/placeholder.jpg';
104 | const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement;
105 | if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl;
106 |
107 | // --- Insert Home Button (if not already present) ---
108 | let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
109 | if (!homeButton) {
110 | homeButton = document.createElement('button');
111 | homeButton.id = 'homeButton';
112 | homeButton.className = 'home-btn';
113 | homeButton.innerHTML = `
`;
114 | // Prepend the home button into the header.
115 | const trackHeader = document.getElementById('track-header');
116 | if (trackHeader) {
117 | trackHeader.insertBefore(homeButton, trackHeader.firstChild);
118 | }
119 | }
120 | homeButton.addEventListener('click', () => {
121 | window.location.href = window.location.origin;
122 | });
123 |
124 | // --- Move the Download Button from #actions into #track-header ---
125 | let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement;
126 | if (downloadBtn) {
127 | // Remove the parent container (#actions) if needed.
128 | const actionsContainer = document.getElementById('actions');
129 | if (actionsContainer) {
130 | actionsContainer.parentNode?.removeChild(actionsContainer);
131 | }
132 | // Set the inner HTML to use the download.svg icon.
133 | downloadBtn.innerHTML = `
`;
134 | // Append the download button to the track header so it appears at the right.
135 | const trackHeader = document.getElementById('track-header');
136 | if (trackHeader) {
137 | trackHeader.appendChild(downloadBtn);
138 | }
139 | }
140 |
141 | if (downloadBtn) {
142 | downloadBtn.addEventListener('click', () => {
143 | downloadBtn.disabled = true;
144 | downloadBtn.innerHTML = `Queueing...`;
145 |
146 | const trackUrl = track.external_urls?.spotify || '';
147 | if (!trackUrl) {
148 | showError('Missing track URL');
149 | downloadBtn.disabled = false;
150 | downloadBtn.innerHTML = `
`;
151 | return;
152 | }
153 | const trackIdToDownload = track.id || '';
154 | if (!trackIdToDownload) {
155 | showError('Missing track ID for download');
156 | downloadBtn.disabled = false;
157 | downloadBtn.innerHTML = `
`;
158 | return;
159 | }
160 |
161 | // Use the centralized downloadQueue.download method
162 | downloadQueue.download(trackIdToDownload, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name })
163 | .then(() => {
164 | downloadBtn.innerHTML = `Queued!`;
165 | // Make the queue visible to show the download
166 | downloadQueue.toggleVisibility(true);
167 | })
168 | .catch((err: any) => {
169 | showError('Failed to queue track download: ' + (err?.message || 'Unknown error'));
170 | downloadBtn.disabled = false;
171 | downloadBtn.innerHTML = `
`;
172 | });
173 | });
174 | }
175 |
176 | // Reveal the header now that track info is loaded.
177 | const trackHeaderEl = document.getElementById('track-header');
178 | if (trackHeaderEl) trackHeaderEl.classList.remove('hidden');
179 | }
180 |
181 | /**
182 | * Converts milliseconds to minutes:seconds.
183 | */
184 | function msToTime(duration: number) {
185 | if (!duration || isNaN(duration)) return '0:00';
186 |
187 | const minutes = Math.floor(duration / 60000);
188 | const seconds = Math.floor((duration % 60000) / 1000);
189 | return `${minutes}:${seconds.toString().padStart(2, '0')}`;
190 | }
191 |
192 | /**
193 | * Displays an error message in the UI.
194 | */
195 | function showError(message: string) {
196 | const errorEl = document.getElementById('error');
197 | if (errorEl) {
198 | errorEl.textContent = message || 'An error occurred';
199 | errorEl.classList.remove('hidden');
200 | }
201 | }
202 |
203 | /**
204 | * Starts the download process by calling the centralized downloadQueue method
205 | */
206 | async function startDownload(itemId: string, type: string, item: any) {
207 | if (!itemId || !type) {
208 | showError('Missing ID or type for download');
209 | return;
210 | }
211 |
212 | try {
213 | // Use the centralized downloadQueue.download method
214 | await downloadQueue.download(itemId, type, item);
215 |
216 | // Make the queue visible after queueing
217 | downloadQueue.toggleVisibility(true);
218 | } catch (error: any) {
219 | showError('Download failed: ' + (error?.message || 'Unknown error'));
220 | throw error;
221 | }
222 | }
223 |
--------------------------------------------------------------------------------
/static/css/album/album.css:
--------------------------------------------------------------------------------
1 | /* Base Styles */
2 | * {
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
7 | }
8 |
9 | body {
10 | background: linear-gradient(135deg, #121212, #1e1e1e);
11 | color: #ffffff;
12 | min-height: 100vh;
13 | line-height: 1.4;
14 | }
15 |
16 | /* Main App Container */
17 | #app {
18 | max-width: 1200px;
19 | margin: 0 auto;
20 | padding: 20px;
21 | position: relative;
22 | z-index: 1;
23 | }
24 |
25 | /* Album Header */
26 | #album-header {
27 | display: flex;
28 | gap: 20px;
29 | margin-bottom: 2rem;
30 | align-items: center;
31 | padding-bottom: 1.5rem;
32 | border-bottom: 1px solid #2a2a2a;
33 | flex-wrap: wrap;
34 | transition: all 0.3s ease;
35 | }
36 |
37 | #album-image {
38 | width: 200px;
39 | height: 200px;
40 | object-fit: cover;
41 | border-radius: 8px;
42 | flex-shrink: 0;
43 | box-shadow: 0 4px 8px rgba(0,0,0,0.3);
44 | transition: transform 0.3s ease;
45 | }
46 | #album-image:hover {
47 | transform: scale(1.02);
48 | }
49 |
50 | #album-info {
51 | flex: 1;
52 | min-width: 0;
53 | }
54 |
55 | #album-name {
56 | font-size: 2.5rem;
57 | margin-bottom: 0.5rem;
58 | background: linear-gradient(90deg, #1db954, #17a44b);
59 | -webkit-background-clip: text;
60 | -webkit-text-fill-color: transparent;
61 | }
62 |
63 | #album-artist,
64 | #album-stats {
65 | font-size: 1.1rem;
66 | color: #b3b3b3;
67 | margin-bottom: 0.5rem;
68 | }
69 |
70 | #album-copyright {
71 | font-size: 0.9rem;
72 | color: #b3b3b3;
73 | opacity: 0.8;
74 | margin-bottom: 0.5rem;
75 | }
76 |
77 | /* Tracks Container */
78 | #tracks-container {
79 | margin-top: 2rem;
80 | }
81 |
82 | #tracks-container h2 {
83 | font-size: 1.75rem;
84 | margin-bottom: 1rem;
85 | border-bottom: 1px solid #2a2a2a;
86 | padding-bottom: 0.5rem;
87 | }
88 |
89 | /* Tracks List */
90 | #tracks-list {
91 | display: flex;
92 | flex-direction: column;
93 | gap: 1rem;
94 | }
95 |
96 | /* Individual Track Styling */
97 | .track {
98 | display: grid;
99 | grid-template-columns: 40px 1fr auto auto;
100 | align-items: center;
101 | padding: 0.75rem 1rem;
102 | border-radius: var(--radius-sm);
103 | background-color: var(--color-surface);
104 | margin-bottom: 0.5rem;
105 | transition: background-color 0.2s ease;
106 | }
107 |
108 | .track:hover {
109 | background-color: var(--color-surface-hover);
110 | }
111 |
112 | .track-number {
113 | display: flex;
114 | align-items: center;
115 | justify-content: center;
116 | font-size: 0.9rem;
117 | color: var(--color-text-secondary);
118 | width: 24px;
119 | }
120 |
121 | .track-info {
122 | padding: 0 1rem;
123 | flex: 1;
124 | min-width: 0;
125 | }
126 |
127 | .track-name {
128 | font-weight: 500;
129 | margin-bottom: 0.25rem;
130 | white-space: nowrap;
131 | overflow: hidden;
132 | text-overflow: ellipsis;
133 | }
134 |
135 | .track-artist {
136 | font-size: 0.9rem;
137 | color: var(--color-text-secondary);
138 | }
139 |
140 | .track-duration {
141 | color: var(--color-text-tertiary);
142 | font-size: 0.9rem;
143 | margin-right: 1rem;
144 | }
145 |
146 | /* Loading and Error States */
147 | .loading,
148 | .error {
149 | width: 100%;
150 | text-align: center;
151 | font-size: 1rem;
152 | padding: 1rem;
153 | }
154 |
155 | .error {
156 | color: #c0392b;
157 | }
158 |
159 | /* Utility Classes */
160 | .hidden {
161 | display: none !important;
162 | }
163 |
164 | /* Unified Download Button Base Style */
165 | .download-btn {
166 | background-color: #1db954;
167 | color: #fff;
168 | border: none;
169 | border-radius: 4px;
170 | padding: 0.6rem 1.2rem;
171 | font-size: 1rem;
172 | cursor: pointer;
173 | transition: background-color 0.2s ease, transform 0.2s ease;
174 | display: inline-flex;
175 | align-items: center;
176 | justify-content: center;
177 | margin: 0.5rem;
178 | }
179 |
180 | .download-btn:hover {
181 | background-color: #17a44b;
182 | }
183 |
184 | .download-btn:active {
185 | transform: scale(0.98);
186 | }
187 |
188 | /* Circular Variant for Compact Areas */
189 | .download-btn--circle {
190 | width: 32px;
191 | height: 32px;
192 | padding: 0;
193 | border-radius: 50%;
194 | font-size: 0; /* Hide any text */
195 | background-color: #1db954;
196 | border: none;
197 | display: inline-flex;
198 | align-items: center;
199 | justify-content: center;
200 | cursor: pointer;
201 | transition: background-color 0.2s ease, transform 0.2s ease;
202 | margin: 0.5rem;
203 | }
204 |
205 | .download-btn--circle img {
206 | width: 20px;
207 | height: 20px;
208 | filter: brightness(0) invert(1);
209 | display: block;
210 | }
211 |
212 | .download-btn--circle:hover {
213 | background-color: #17a44b;
214 | transform: scale(1.05);
215 | }
216 |
217 | .download-btn--circle:active {
218 | transform: scale(0.98);
219 | }
220 |
221 | /* Home Button Styling */
222 | .home-btn {
223 | background-color: transparent;
224 | border: none;
225 | cursor: pointer;
226 | margin-right: 1rem;
227 | padding: 0;
228 | }
229 |
230 | .home-btn img {
231 | width: 32px;
232 | height: 32px;
233 | filter: invert(1);
234 | transition: transform 0.2s ease;
235 | }
236 |
237 | .home-btn:hover img {
238 | transform: scale(1.05);
239 | }
240 |
241 | .home-btn:active img {
242 | transform: scale(0.98);
243 | }
244 |
245 | /* Queue Toggle Button */
246 | .queue-toggle {
247 | background: #1db954;
248 | color: #fff;
249 | border: none;
250 | border-radius: 50%;
251 | width: 56px;
252 | height: 56px;
253 | cursor: pointer;
254 | font-size: 1rem;
255 | font-weight: bold;
256 | box-shadow: 0 2px 8px rgba(0,0,0,0.3);
257 | transition: background-color 0.3s ease, transform 0.2s ease;
258 | z-index: 1002;
259 | /* Remove any fixed positioning by default for mobile; fixed positioning remains for larger screens */
260 | }
261 |
262 | /* Actions Container for Small Screens */
263 | #album-actions {
264 | display: flex;
265 | width: 100%;
266 | justify-content: space-between;
267 | align-items: center;
268 | margin-top: 1rem;
269 | }
270 |
271 | /* Responsive Styles */
272 |
273 | /* Medium Devices (Tablets) */
274 | @media (max-width: 768px) {
275 | #album-header {
276 | flex-direction: column;
277 | align-items: center;
278 | text-align: center;
279 | }
280 |
281 | #album-image {
282 | width: 180px;
283 | height: 180px;
284 | margin-bottom: 1rem;
285 | }
286 |
287 | #album-name {
288 | font-size: 2rem;
289 | }
290 |
291 | #album-artist,
292 | #album-stats {
293 | font-size: 1rem;
294 | }
295 |
296 | .track {
297 | grid-template-columns: 30px 1fr auto auto;
298 | padding: 0.6rem 0.8rem;
299 | }
300 |
301 | .track-duration {
302 | margin-right: 0.5rem;
303 | }
304 | }
305 |
306 | /* Small Devices (Mobile Phones) */
307 | @media (max-width: 480px) {
308 | #app {
309 | padding: 10px;
310 | }
311 |
312 | #album-header {
313 | flex-direction: column;
314 | align-items: center;
315 | text-align: center;
316 | }
317 |
318 | /* Center the album cover */
319 | #album-image {
320 | margin: 0 auto;
321 | }
322 |
323 | #album-name {
324 | font-size: 1.75rem;
325 | }
326 |
327 | #album-artist,
328 | #album-stats,
329 | #album-copyright {
330 | font-size: 0.9rem;
331 | }
332 |
333 | .track {
334 | grid-template-columns: 30px 1fr auto;
335 | }
336 |
337 | .track-info {
338 | padding: 0 0.5rem;
339 | }
340 |
341 | .track-name, .track-artist {
342 | max-width: 200px;
343 | }
344 |
345 | .section-title {
346 | font-size: 1.25rem;
347 | }
348 |
349 | /* Ensure the actions container lays out buttons properly */
350 | #album-actions {
351 | flex-direction: row;
352 | justify-content: space-between;
353 | }
354 |
355 | /* Remove extra margins from the queue toggle */
356 | .queue-toggle {
357 | position: static;
358 | margin: 0;
359 | }
360 | }
361 |
362 | /* Prevent anchor links from appearing all blue */
363 | a {
364 | color: inherit;
365 | text-decoration: none;
366 | transition: color 0.2s ease;
367 | }
368 |
369 | a:hover,
370 | a:focus {
371 | color: #1db954;
372 | text-decoration: underline;
373 | }
374 |
375 | /* (Optional) Override for circular download button pseudo-element */
376 | .download-btn--circle::before {
377 | content: none;
378 | }
379 |
380 | /* Album page specific styles */
381 |
382 | /* Add some context styles for the album copyright */
383 | .album-copyright {
384 | font-size: 0.85rem;
385 | color: var(--color-text-tertiary);
386 | margin-top: 0.5rem;
387 | font-style: italic;
388 | }
389 |
390 | /* Section title styling */
391 | .section-title {
392 | font-size: 1.5rem;
393 | margin-bottom: 1rem;
394 | position: relative;
395 | padding-bottom: 0.5rem;
396 | }
397 |
398 | .section-title:after {
399 | content: '';
400 | position: absolute;
401 | left: 0;
402 | bottom: 0;
403 | width: 50px;
404 | height: 2px;
405 | background-color: var(--color-primary);
406 | }
407 |
--------------------------------------------------------------------------------
/routes/prgs.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, abort, jsonify, Response, stream_with_context, request
2 | import os
3 | import json
4 | import logging
5 | import time
6 |
7 | from routes.utils.celery_tasks import (
8 | get_task_info,
9 | get_task_status,
10 | get_last_task_status,
11 | get_all_tasks,
12 | cancel_task,
13 | retry_task,
14 | ProgressState,
15 | redis_client
16 | )
17 |
18 | # Configure logging
19 | logger = logging.getLogger(__name__)
20 |
21 | prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs')
22 |
23 | # (Old .prg file system removed. Using new task system only.)
24 |
25 | @prgs_bp.route('/', methods=['GET'])
26 | def get_prg_file(task_id):
27 | """
28 | Return a JSON object with the resource type, its name (title),
29 | the last progress update, and, if available, the original request parameters.
30 |
31 | This function works with both the old PRG file system (for backward compatibility)
32 | and the new task ID based system.
33 |
34 | Args:
35 | task_id: Either a task UUID from Celery or a PRG filename from the old system
36 | """
37 | # Only support new task IDs
38 | task_info = get_task_info(task_id)
39 | if not task_info:
40 | abort(404, "Task not found")
41 |
42 | # Dynamically construct original_url
43 | dynamic_original_url = ""
44 | download_type = task_info.get("download_type")
45 | # The 'url' field in task_info stores the Spotify/Deezer URL of the item
46 | # e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId
47 | item_url = task_info.get("url")
48 |
49 | if download_type and item_url:
50 | try:
51 | # Extract the ID from the item_url (last part of the path)
52 | item_id = item_url.split('/')[-1]
53 | if item_id: # Ensure item_id is not empty
54 | base_url = request.host_url.rstrip('/')
55 | dynamic_original_url = f"{base_url}/api/{download_type}/download/{item_id}"
56 | else:
57 | logger.warning(f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url.")
58 | original_request_obj = task_info.get("original_request", {})
59 | dynamic_original_url = original_request_obj.get("original_url", "")
60 | except Exception as e:
61 | logger.error(f"Error constructing dynamic original_url for task {task_id}: {e}", exc_info=True)
62 | original_request_obj = task_info.get("original_request", {})
63 | dynamic_original_url = original_request_obj.get("original_url", "") # Fallback on any error
64 | else:
65 | logger.warning(f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url.")
66 | original_request_obj = task_info.get("original_request", {})
67 | dynamic_original_url = original_request_obj.get("original_url", "")
68 |
69 | last_status = get_last_task_status(task_id)
70 | status_count = len(get_task_status(task_id))
71 | response = {
72 | "original_url": dynamic_original_url,
73 | "last_line": last_status,
74 | "timestamp": time.time(),
75 | "task_id": task_id,
76 | "status_count": status_count
77 | }
78 | return jsonify(response)
79 |
80 |
81 | @prgs_bp.route('/delete/', methods=['DELETE'])
82 | def delete_prg_file(task_id):
83 | """
84 | Delete a task's information and history.
85 | Works with both the old PRG file system and the new task ID based system.
86 |
87 | Args:
88 | task_id: Either a task UUID from Celery or a PRG filename from the old system
89 | """
90 | # Only support new task IDs
91 | task_info = get_task_info(task_id)
92 | if not task_info:
93 | abort(404, "Task not found")
94 | cancel_task(task_id)
95 | from routes.utils.celery_tasks import redis_client
96 | redis_client.delete(f"task:{task_id}:info")
97 | redis_client.delete(f"task:{task_id}:status")
98 | return {'message': f'Task {task_id} deleted successfully'}, 200
99 |
100 |
101 | @prgs_bp.route('/list', methods=['GET'])
102 | def list_prg_files():
103 | """
104 | Retrieve a list of all tasks in the system.
105 | Returns a detailed list of task objects including status and metadata.
106 | """
107 | try:
108 | tasks = get_all_tasks() # This already gets summary data
109 | detailed_tasks = []
110 | for task_summary in tasks:
111 | task_id = task_summary.get("task_id")
112 | if not task_id:
113 | continue
114 |
115 | task_info = get_task_info(task_id)
116 | last_status = get_last_task_status(task_id)
117 |
118 | if task_info and last_status:
119 | detailed_tasks.append({
120 | "task_id": task_id,
121 | "type": task_info.get("type", task_summary.get("type", "unknown")),
122 | "name": task_info.get("name", task_summary.get("name", "Unknown")),
123 | "artist": task_info.get("artist", task_summary.get("artist", "")),
124 | "download_type": task_info.get("download_type", task_summary.get("download_type", "unknown")),
125 | "status": last_status.get("status", "unknown"), # Keep summary status for quick access
126 | "last_status_obj": last_status, # Full last status object
127 | "original_request": task_info.get("original_request", {}),
128 | "created_at": task_info.get("created_at", 0),
129 | "timestamp": last_status.get("timestamp", task_info.get("created_at", 0))
130 | })
131 | elif task_info: # If last_status is somehow missing, still provide some info
132 | detailed_tasks.append({
133 | "task_id": task_id,
134 | "type": task_info.get("type", "unknown"),
135 | "name": task_info.get("name", "Unknown"),
136 | "artist": task_info.get("artist", ""),
137 | "download_type": task_info.get("download_type", "unknown"),
138 | "status": "unknown",
139 | "last_status_obj": None,
140 | "original_request": task_info.get("original_request", {}),
141 | "created_at": task_info.get("created_at", 0),
142 | "timestamp": task_info.get("created_at", 0)
143 | })
144 |
145 | # Sort tasks by creation time (newest first, or by timestamp if creation time is missing)
146 | detailed_tasks.sort(key=lambda x: x.get('timestamp', x.get('created_at', 0)), reverse=True)
147 |
148 | return jsonify(detailed_tasks)
149 | except Exception as e:
150 | logger.error(f"Error in /api/prgs/list: {e}", exc_info=True)
151 | return jsonify({"error": "Failed to retrieve task list"}), 500
152 |
153 |
154 | @prgs_bp.route('/retry/', methods=['POST'])
155 | def retry_task_endpoint(task_id):
156 | """
157 | Retry a failed task.
158 |
159 | Args:
160 | task_id: The ID of the task to retry
161 | """
162 | try:
163 | # First check if this is a task ID in the new system
164 | task_info = get_task_info(task_id)
165 |
166 | if task_info:
167 | # This is a task ID in the new system
168 | result = retry_task(task_id)
169 | return jsonify(result)
170 |
171 | # If not found in new system, we need to handle the old system retry
172 | # For now, return an error as we're transitioning to the new system
173 | return jsonify({
174 | "status": "error",
175 | "message": "Retry for old system is not supported in the new API. Please use the new task ID format."
176 | }), 400
177 | except Exception as e:
178 | abort(500, f"An error occurred: {e}")
179 |
180 |
181 | @prgs_bp.route('/cancel/', methods=['POST'])
182 | def cancel_task_endpoint(task_id):
183 | """
184 | Cancel a running or queued task.
185 |
186 | Args:
187 | task_id: The ID of the task to cancel
188 | """
189 | try:
190 | # First check if this is a task ID in the new system
191 | task_info = get_task_info(task_id)
192 |
193 | if task_info:
194 | # This is a task ID in the new system
195 | result = cancel_task(task_id)
196 | return jsonify(result)
197 |
198 | # If not found in new system, we need to handle the old system cancellation
199 | # For now, return an error as we're transitioning to the new system
200 | return jsonify({
201 | "status": "error",
202 | "message": "Cancellation for old system is not supported in the new API. Please use the new task ID format."
203 | }), 400
204 | except Exception as e:
205 | abort(500, f"An error occurred: {e}")
206 |
--------------------------------------------------------------------------------
/static/css/watch/watch.css:
--------------------------------------------------------------------------------
1 | /* static/css/watch/watch.css */
2 |
3 | /* General styles for the watch page, similar to main.css */
4 | body {
5 | font-family: var(--font-family-sans-serif);
6 | background-color: var(--background-color);
7 | color: white;
8 | margin: 0;
9 | padding: 0;
10 | }
11 |
12 | .app-container {
13 | max-width: 1200px;
14 | margin: 0 auto;
15 | padding: 20px;
16 | }
17 |
18 | .watch-header {
19 | display: flex;
20 | justify-content: space-between;
21 | align-items: center;
22 | margin-bottom: 30px;
23 | padding-bottom: 15px;
24 | border-bottom: 1px solid var(--border-color-soft);
25 | }
26 |
27 | .watch-header h1 {
28 | color: white;
29 | font-size: 2em;
30 | margin: 0;
31 | }
32 |
33 | .check-all-btn {
34 | padding: 10px 15px;
35 | font-size: 0.9em;
36 | display: flex;
37 | align-items: center;
38 | gap: 8px; /* Space between icon and text */
39 | background-color: var(--color-accent-green); /* Green background */
40 | color: white; /* Ensure text is white for contrast */
41 | border: none; /* Remove default border */
42 | }
43 |
44 | .check-all-btn:hover {
45 | background-color: var(--color-accent-green-dark); /* Darker green on hover */
46 | }
47 |
48 | .check-all-btn img {
49 | width: 18px; /* Slightly larger for header button */
50 | height: 18px;
51 | filter: brightness(0) invert(1); /* Ensure header icon is white */
52 | }
53 |
54 | .back-to-search-btn {
55 | padding: 10px 20px;
56 | font-size: 0.9em;
57 | }
58 |
59 | /* Styling for the grid of watched items, similar to results-grid */
60 | .results-grid {
61 | display: grid;
62 | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Responsive grid */
63 | gap: 20px;
64 | padding: 0;
65 | }
66 |
67 | /* Individual watched item card styling, inspired by result-card from main.css */
68 | .watched-item-card {
69 | background-color: var(--color-surface);
70 | border-radius: var(--border-radius-medium);
71 | padding: 15px;
72 | box-shadow: var(--shadow-soft);
73 | display: flex;
74 | flex-direction: column;
75 | align-items: center;
76 | text-align: center;
77 | transition: transform 0.2s ease, box-shadow 0.2s ease;
78 | cursor: pointer;
79 | position: relative;
80 | }
81 |
82 | .watched-item-card:hover {
83 | transform: translateY(-5px);
84 | box-shadow: var(--shadow-medium);
85 | border-top: 1px solid var(--border-color-soft);
86 | }
87 |
88 | .item-art-wrapper {
89 | width: 100%;
90 | padding-bottom: 100%; /* 1:1 Aspect Ratio */
91 | position: relative;
92 | margin-bottom: 15px;
93 | border-radius: var(--border-radius-soft);
94 | overflow: hidden;
95 | }
96 |
97 | .item-art {
98 | position: absolute;
99 | top: 0;
100 | left: 0;
101 | width: 100%;
102 | height: 100%;
103 | object-fit: cover; /* Cover the area, cropping if necessary */
104 | }
105 |
106 | .item-name {
107 | font-size: 1.1em;
108 | font-weight: bold;
109 | color: white;
110 | margin-bottom: 5px;
111 | display: -webkit-box;
112 | -webkit-line-clamp: 2; /* Limit to 2 lines */
113 | -webkit-box-orient: vertical;
114 | line-clamp: 2;
115 | overflow: hidden;
116 | text-overflow: ellipsis;
117 | min-height: 2.4em; /* Reserve space for two lines */
118 | }
119 |
120 | .item-details {
121 | font-size: 0.9em;
122 | color: white;
123 | margin-bottom: 10px;
124 | line-height: 1.4;
125 | width: 100%; /* Ensure it takes full width for centering/alignment */
126 | }
127 |
128 | .item-details span {
129 | display: block; /* Each detail on a new line */
130 | margin-bottom: 3px;
131 | }
132 |
133 | .item-type-badge {
134 | display: inline-block;
135 | padding: 3px 8px;
136 | font-size: 0.75em;
137 | font-weight: bold;
138 | border-radius: var(--border-radius-small);
139 | margin-bottom: 10px;
140 | text-transform: uppercase;
141 | }
142 |
143 | .item-type-badge.artist {
144 | background-color: var(--color-accent-blue-bg);
145 | color: var(--color-accent-blue-text);
146 | }
147 |
148 | .item-type-badge.playlist {
149 | background-color: var(--color-accent-green-bg);
150 | color: var(--color-accent-green-text);
151 | }
152 |
153 | /* Action buttons (e.g., Go to item, Unwatch) */
154 | .item-actions {
155 | margin-top: auto;
156 | width: 100%;
157 | display: flex;
158 | justify-content: space-between;
159 | align-items: center;
160 | padding-top: 10px;
161 | border-top: 1px solid var(--border-color-soft);
162 | }
163 |
164 | .item-actions .btn-icon {
165 | padding: 0;
166 | width: 36px;
167 | height: 36px;
168 | border-radius: 50%;
169 | display: flex;
170 | align-items: center;
171 | justify-content: center;
172 | font-size: 0;
173 | border: none;
174 | }
175 |
176 | .item-actions .check-item-now-btn {
177 | background-color: var(--color-accent-green);
178 | }
179 |
180 | .item-actions .check-item-now-btn:hover {
181 | background-color: var(--color-accent-green-dark);
182 | }
183 |
184 | .item-actions .check-item-now-btn img,
185 | .item-actions .unwatch-item-btn img {
186 | width: 16px;
187 | height: 16px;
188 | filter: brightness(0) invert(1);
189 | }
190 |
191 | .item-actions .unwatch-item-btn {
192 | background-color: var(--color-error);
193 | color: white;
194 | }
195 |
196 | .item-actions .unwatch-item-btn:hover {
197 | background-color: #a52a2a;
198 | }
199 |
200 | /* Loading and Empty State - reuse from main.css if possible or define here */
201 | .loading,
202 | .empty-state {
203 | display: flex;
204 | flex-direction: column;
205 | align-items: center;
206 | justify-content: center;
207 | text-align: center;
208 | padding: 40px 20px;
209 | color: var(--text-color-muted);
210 | width: 100%;
211 | }
212 |
213 | .loading.hidden,
214 | .empty-state.hidden {
215 | display: none;
216 | }
217 |
218 | .loading-indicator {
219 | font-size: 1.2em;
220 | margin-bottom: 10px;
221 | color: white;
222 | }
223 |
224 | .empty-state-content {
225 | max-width: 400px;
226 | }
227 |
228 | .empty-state-icon {
229 | width: 80px;
230 | height: 80px;
231 | margin-bottom: 20px;
232 | opacity: 0.7;
233 | filter: brightness(0) invert(1); /* Added to make icon white */
234 | }
235 |
236 | .empty-state h2 {
237 | font-size: 1.5em;
238 | color: white;
239 | margin-bottom: 10px;
240 | }
241 |
242 | .empty-state p {
243 | font-size: 1em;
244 | line-height: 1.5;
245 | color: white;
246 | }
247 |
248 | /* Ensure floating icons from base.css are not obscured or mispositioned */
249 | /* No specific overrides needed if base.css handles them well */
250 |
251 | /* Responsive adjustments if needed */
252 | @media (max-width: 768px) {
253 | .results-grid {
254 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
255 | }
256 | .watch-header h1 {
257 | font-size: 1.5em;
258 | }
259 | .watched-group-header {
260 | font-size: 1.5rem;
261 | }
262 | }
263 |
264 | @media (max-width: 480px) {
265 | .results-grid {
266 | grid-template-columns: 1fr; /* Single column on very small screens */
267 | }
268 | .watched-item-card {
269 | padding: 10px;
270 | }
271 | .item-name {
272 | font-size: 1em;
273 | }
274 | .item-details {
275 | font-size: 0.8em;
276 | }
277 | }
278 |
279 | .watched-items-group {
280 | margin-bottom: 2rem; /* Space between groups */
281 | }
282 |
283 | .watched-group-header {
284 | font-size: 1.8rem;
285 | color: var(--color-text-primary);
286 | margin-bottom: 1rem;
287 | padding-bottom: 0.5rem;
288 | border-bottom: 1px solid var(--color-border);
289 | }
290 |
291 | .empty-group-message {
292 | color: var(--color-text-secondary);
293 | padding: 1rem;
294 | text-align: center;
295 | font-style: italic;
296 | }
297 |
298 | /* Ensure the main watchedItemsContainer still behaves like a grid if there are few items */
299 | #watchedItemsContainer:not(:has(.watched-items-group)) {
300 | display: grid;
301 | /* Assuming results-grid styles are already defined elsewhere,
302 | or copy relevant grid styles here if needed */
303 | }
304 |
305 | /* Notification Toast Styles */
306 | #notificationArea {
307 | position: fixed;
308 | bottom: 20px;
309 | left: 50%; /* Center horizontally */
310 | transform: translateX(-50%); /* Adjust for exact centering */
311 | z-index: 2000;
312 | display: flex;
313 | flex-direction: column-reverse;
314 | gap: 10px;
315 | width: auto; /* Allow width to be determined by content */
316 | max-width: 90%; /* Prevent it from being too wide on large screens */
317 | }
318 |
319 | .notification-toast {
320 | padding: 12px 20px;
321 | border-radius: var(--border-radius-medium);
322 | color: white; /* Default text color to white */
323 | font-size: 0.9em;
324 | box-shadow: var(--shadow-strong);
325 | opacity: 1;
326 | transition: opacity 0.5s ease, transform 0.5s ease;
327 | transform: translateX(0); /* Keep this for the hide animation */
328 | text-align: center; /* Center text within the toast */
329 | }
330 |
331 | .notification-toast.success {
332 | background-color: var(--color-success); /* Use existing success color */
333 | /* color: var(--color-accent-green-text); REMOVE - use white */
334 | /* border: 1px solid var(--color-accent-green-text); REMOVE */
335 | }
336 |
337 | .notification-toast.error {
338 | background-color: var(--color-error); /* Use existing error color */
339 | /* color: var(--color-accent-red-text); REMOVE - use white */
340 | /* border: 1px solid var(--color-accent-red-text); REMOVE */
341 | }
342 |
343 | .notification-toast.hide {
344 | opacity: 0;
345 | transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */
346 | }
347 |
348 | @keyframes spin-counter-clockwise {
349 | from {
350 | transform: rotate(0deg);
351 | }
352 | to {
353 | transform: rotate(-360deg);
354 | }
355 | }
356 |
357 | .spin-counter-clockwise {
358 | animation: spin-counter-clockwise 1s linear infinite;
359 | }
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, request, send_from_directory, render_template
2 | from flask_cors import CORS
3 | from routes.search import search_bp
4 | from routes.credentials import credentials_bp
5 | from routes.album import album_bp
6 | from routes.track import track_bp
7 | from routes.playlist import playlist_bp
8 | from routes.prgs import prgs_bp
9 | from routes.config import config_bp
10 | from routes.artist import artist_bp
11 | from routes.history import history_bp
12 | import logging
13 | import logging.handlers
14 | import time
15 | from pathlib import Path
16 | import os
17 | import atexit
18 | import sys
19 | import redis
20 | import socket
21 | from urllib.parse import urlparse
22 |
23 | # Import Celery configuration and manager
24 | from routes.utils.celery_tasks import celery_app
25 | from routes.utils.celery_manager import celery_manager
26 | from routes.utils.celery_config import REDIS_URL
27 |
28 | # Configure application-wide logging
29 | def setup_logging():
30 | """Configure application-wide logging with rotation"""
31 | # Create logs directory if it doesn't exist
32 | logs_dir = Path('logs')
33 | logs_dir.mkdir(exist_ok=True)
34 |
35 | # Set up log file paths
36 | main_log = logs_dir / 'spotizerr.log'
37 |
38 | # Configure root logger
39 | root_logger = logging.getLogger()
40 | root_logger.setLevel(logging.INFO)
41 |
42 | # Clear any existing handlers from the root logger
43 | if root_logger.hasHandlers():
44 | root_logger.handlers.clear()
45 |
46 | # Log formatting
47 | log_format = logging.Formatter(
48 | '%(asctime)s [%(processName)s:%(threadName)s] [%(name)s] [%(levelname)s] - %(message)s',
49 | datefmt='%Y-%m-%d %H:%M:%S'
50 | )
51 |
52 | # File handler with rotation (10 MB max, keep 5 backups)
53 | file_handler = logging.handlers.RotatingFileHandler(
54 | main_log, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8'
55 | )
56 | file_handler.setFormatter(log_format)
57 | file_handler.setLevel(logging.INFO)
58 |
59 | # Console handler for stderr
60 | console_handler = logging.StreamHandler(sys.stderr)
61 | console_handler.setFormatter(log_format)
62 | console_handler.setLevel(logging.INFO)
63 |
64 | # Add handlers to root logger
65 | root_logger.addHandler(file_handler)
66 | root_logger.addHandler(console_handler)
67 |
68 | # Set up specific loggers
69 | for logger_name in ['werkzeug', 'celery', 'routes', 'flask', 'waitress']:
70 | module_logger = logging.getLogger(logger_name)
71 | module_logger.setLevel(logging.INFO)
72 | # Handlers are inherited from root logger
73 |
74 | # Enable propagation for all loggers
75 | logging.getLogger('celery').propagate = True
76 |
77 | # Notify successful setup
78 | root_logger.info("Logging system initialized")
79 |
80 | # Return the main file handler for permissions adjustment
81 | return file_handler
82 |
83 | def check_redis_connection():
84 | """Check if Redis is reachable and retry with exponential backoff if not"""
85 | max_retries = 5
86 | retry_count = 0
87 | retry_delay = 1 # start with 1 second
88 |
89 | # Extract host and port from REDIS_URL
90 | redis_host = "redis" # default
91 | redis_port = 6379 # default
92 |
93 | # Parse from REDIS_URL if possible
94 | if REDIS_URL:
95 | # parse hostname and port (handles optional auth)
96 | try:
97 | parsed = urlparse(REDIS_URL)
98 | if parsed.hostname:
99 | redis_host = parsed.hostname
100 | if parsed.port:
101 | redis_port = parsed.port
102 | except Exception:
103 | pass
104 |
105 | # Log Redis connection details
106 | logging.info(f"Checking Redis connection to {redis_host}:{redis_port}")
107 |
108 | while retry_count < max_retries:
109 | try:
110 | # First try socket connection to check if Redis port is open
111 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
112 | sock.settimeout(2)
113 | result = sock.connect_ex((redis_host, redis_port))
114 | sock.close()
115 |
116 | if result != 0:
117 | raise ConnectionError(f"Cannot connect to Redis at {redis_host}:{redis_port}")
118 |
119 | # If socket connection successful, try Redis ping
120 | r = redis.Redis.from_url(REDIS_URL)
121 | r.ping()
122 | logging.info("Successfully connected to Redis")
123 | return True
124 | except Exception as e:
125 | retry_count += 1
126 | if retry_count >= max_retries:
127 | logging.error(f"Failed to connect to Redis after {max_retries} attempts: {e}")
128 | logging.error(f"Make sure Redis is running at {redis_host}:{redis_port}")
129 | return False
130 |
131 | logging.warning(f"Redis connection attempt {retry_count} failed: {e}")
132 | logging.info(f"Retrying in {retry_delay} seconds...")
133 | time.sleep(retry_delay)
134 | retry_delay *= 2 # exponential backoff
135 |
136 | return False
137 |
138 | def create_app():
139 | app = Flask(__name__, template_folder='static/html')
140 |
141 | # Set up CORS
142 | CORS(app)
143 |
144 | # Register blueprints
145 | app.register_blueprint(config_bp, url_prefix='/api')
146 | app.register_blueprint(search_bp, url_prefix='/api')
147 | app.register_blueprint(credentials_bp, url_prefix='/api/credentials')
148 | app.register_blueprint(album_bp, url_prefix='/api/album')
149 | app.register_blueprint(track_bp, url_prefix='/api/track')
150 | app.register_blueprint(playlist_bp, url_prefix='/api/playlist')
151 | app.register_blueprint(artist_bp, url_prefix='/api/artist')
152 | app.register_blueprint(prgs_bp, url_prefix='/api/prgs')
153 | app.register_blueprint(history_bp, url_prefix='/api/history')
154 |
155 | # Serve frontend
156 | @app.route('/')
157 | def serve_index():
158 | return render_template('main.html')
159 |
160 | # Config page route
161 | @app.route('/config')
162 | def serve_config():
163 | return render_template('config.html')
164 |
165 | # New route: Serve watch.html under /watchlist
166 | @app.route('/watchlist')
167 | def serve_watchlist():
168 | return render_template('watch.html')
169 |
170 | # New route: Serve playlist.html under /playlist/
171 | @app.route('/playlist/')
172 | def serve_playlist(id):
173 | # The id parameter is captured, but you can use it as needed.
174 | return render_template('playlist.html')
175 |
176 | @app.route('/album/')
177 | def serve_album(id):
178 | # The id parameter is captured, but you can use it as needed.
179 | return render_template('album.html')
180 |
181 | @app.route('/track/')
182 | def serve_track(id):
183 | # The id parameter is captured, but you can use it as needed.
184 | return render_template('track.html')
185 |
186 | @app.route('/artist/')
187 | def serve_artist(id):
188 | # The id parameter is captured, but you can use it as needed.
189 | return render_template('artist.html')
190 |
191 | @app.route('/history')
192 | def serve_history_page():
193 | return render_template('history.html')
194 |
195 | @app.route('/static/')
196 | def serve_static(path):
197 | return send_from_directory('static', path)
198 |
199 | # Serve favicon.ico from the same directory as index.html (templates)
200 | @app.route('/favicon.ico')
201 | def serve_favicon():
202 | return send_from_directory('static/html', 'favicon.ico')
203 |
204 | # Add request logging middleware
205 | @app.before_request
206 | def log_request():
207 | request.start_time = time.time()
208 | app.logger.debug(f"Request: {request.method} {request.path}")
209 |
210 | @app.after_request
211 | def log_response(response):
212 | if hasattr(request, 'start_time'):
213 | duration = round((time.time() - request.start_time) * 1000, 2)
214 | app.logger.debug(f"Response: {response.status} | Duration: {duration}ms")
215 | return response
216 |
217 | # Error logging
218 | @app.errorhandler(Exception)
219 | def handle_exception(e):
220 | app.logger.error(f"Server error: {str(e)}", exc_info=True)
221 | return "Internal Server Error", 500
222 |
223 | return app
224 |
225 | def start_celery_workers():
226 | """Start Celery workers with dynamic configuration"""
227 | logging.info("Starting Celery workers with dynamic configuration")
228 | celery_manager.start()
229 |
230 | # Register shutdown handler
231 | atexit.register(celery_manager.stop)
232 |
233 | if __name__ == '__main__':
234 | # Configure application logging
235 | log_handler = setup_logging()
236 |
237 | # Set file permissions for log files if needed
238 | try:
239 | os.chmod(log_handler.baseFilename, 0o666)
240 | except:
241 | logging.warning("Could not set permissions on log file")
242 |
243 | # Log application startup
244 | logging.info("=== Spotizerr Application Starting ===")
245 |
246 | # Check Redis connection before starting workers
247 | if check_redis_connection():
248 | # Start Watch Manager
249 | from routes.utils.watch.manager import start_watch_manager
250 | start_watch_manager()
251 |
252 | # Start Celery workers
253 | start_celery_workers()
254 |
255 | # Create and start Flask app
256 | app = create_app()
257 | logging.info("Starting Flask server on port 7171")
258 | from waitress import serve
259 | serve(app, host='0.0.0.0', port=7171)
260 | else:
261 | logging.error("Cannot start application: Redis connection failed")
262 | sys.exit(1)
263 |
--------------------------------------------------------------------------------
/routes/utils/history_manager.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | import json
3 | import time
4 | import logging
5 | from pathlib import Path
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 | HISTORY_DIR = Path('./data/history')
10 | HISTORY_DB_FILE = HISTORY_DIR / 'download_history.db'
11 |
12 | def init_history_db():
13 | """Initializes the download history database and creates the table if it doesn't exist."""
14 | try:
15 | HISTORY_DIR.mkdir(parents=True, exist_ok=True)
16 | conn = sqlite3.connect(HISTORY_DB_FILE)
17 | cursor = conn.cursor()
18 | cursor.execute("""
19 | CREATE TABLE IF NOT EXISTS download_history (
20 | task_id TEXT PRIMARY KEY,
21 | download_type TEXT,
22 | item_name TEXT,
23 | item_artist TEXT,
24 | item_album TEXT,
25 | item_url TEXT,
26 | spotify_id TEXT,
27 | status_final TEXT, -- 'COMPLETED', 'ERROR', 'CANCELLED'
28 | error_message TEXT,
29 | timestamp_added REAL,
30 | timestamp_completed REAL,
31 | original_request_json TEXT,
32 | last_status_obj_json TEXT
33 | )
34 | """)
35 | conn.commit()
36 | logger.info(f"Download history database initialized at {HISTORY_DB_FILE}")
37 | except sqlite3.Error as e:
38 | logger.error(f"Error initializing download history database: {e}", exc_info=True)
39 | finally:
40 | if conn:
41 | conn.close()
42 |
43 | def add_entry_to_history(history_data: dict):
44 | """Adds or replaces an entry in the download_history table.
45 |
46 | Args:
47 | history_data (dict): A dictionary containing the data for the history entry.
48 | Expected keys match the table columns.
49 | """
50 | required_keys = [
51 | 'task_id', 'download_type', 'item_name', 'item_artist', 'item_album',
52 | 'item_url', 'spotify_id', 'status_final', 'error_message',
53 | 'timestamp_added', 'timestamp_completed', 'original_request_json',
54 | 'last_status_obj_json'
55 | ]
56 | # Ensure all keys are present, filling with None if not
57 | for key in required_keys:
58 | history_data.setdefault(key, None)
59 |
60 | conn = None
61 | try:
62 | conn = sqlite3.connect(HISTORY_DB_FILE)
63 | cursor = conn.cursor()
64 | cursor.execute("""
65 | INSERT OR REPLACE INTO download_history (
66 | task_id, download_type, item_name, item_artist, item_album,
67 | item_url, spotify_id, status_final, error_message,
68 | timestamp_added, timestamp_completed, original_request_json,
69 | last_status_obj_json
70 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
71 | """, (
72 | history_data['task_id'], history_data['download_type'], history_data['item_name'],
73 | history_data['item_artist'], history_data['item_album'], history_data['item_url'],
74 | history_data['spotify_id'], history_data['status_final'], history_data['error_message'],
75 | history_data['timestamp_added'], history_data['timestamp_completed'],
76 | history_data['original_request_json'], history_data['last_status_obj_json']
77 | ))
78 | conn.commit()
79 | logger.info(f"Added/Updated history for task_id: {history_data['task_id']}, status: {history_data['status_final']}")
80 | except sqlite3.Error as e:
81 | logger.error(f"Error adding entry to download history for task_id {history_data.get('task_id')}: {e}", exc_info=True)
82 | except Exception as e:
83 | logger.error(f"Unexpected error adding to history for task_id {history_data.get('task_id')}: {e}", exc_info=True)
84 | finally:
85 | if conn:
86 | conn.close()
87 |
88 | def get_history_entries(limit=25, offset=0, sort_by='timestamp_completed', sort_order='DESC', filters=None):
89 | """Retrieves entries from the download_history table with pagination, sorting, and filtering.
90 |
91 | Args:
92 | limit (int): Maximum number of entries to return.
93 | offset (int): Number of entries to skip (for pagination).
94 | sort_by (str): Column name to sort by.
95 | sort_order (str): 'ASC' or 'DESC'.
96 | filters (dict, optional): A dictionary of column_name: value to filter by.
97 | Currently supports exact matches.
98 |
99 | Returns:
100 | tuple: (list of history entries as dicts, total_count of matching entries)
101 | """
102 | conn = None
103 | try:
104 | conn = sqlite3.connect(HISTORY_DB_FILE)
105 | conn.row_factory = sqlite3.Row # Access columns by name
106 | cursor = conn.cursor()
107 |
108 | base_query = "FROM download_history"
109 | count_query = "SELECT COUNT(*) " + base_query
110 | select_query = "SELECT * " + base_query
111 |
112 | where_clauses = []
113 | params = []
114 |
115 | if filters:
116 | for column, value in filters.items():
117 | # Basic security: ensure column is a valid one (alphanumeric + underscore)
118 | if column.replace('_', '').isalnum():
119 | where_clauses.append(f"{column} = ?")
120 | params.append(value)
121 |
122 | if where_clauses:
123 | where_sql = " WHERE " + " AND ".join(where_clauses)
124 | count_query += where_sql
125 | select_query += where_sql
126 |
127 | # Get total count for pagination
128 | cursor.execute(count_query, params)
129 | total_count = cursor.fetchone()[0]
130 |
131 | # Validate sort_by and sort_order to prevent SQL injection
132 | valid_sort_columns = [
133 | 'task_id', 'download_type', 'item_name', 'item_artist', 'item_album',
134 | 'item_url', 'status_final', 'timestamp_added', 'timestamp_completed'
135 | ]
136 | if sort_by not in valid_sort_columns:
137 | sort_by = 'timestamp_completed' # Default sort
138 |
139 | sort_order_upper = sort_order.upper()
140 | if sort_order_upper not in ['ASC', 'DESC']:
141 | sort_order_upper = 'DESC'
142 |
143 | select_query += f" ORDER BY {sort_by} {sort_order_upper} LIMIT ? OFFSET ?"
144 | params.extend([limit, offset])
145 |
146 | cursor.execute(select_query, params)
147 | rows = cursor.fetchall()
148 |
149 | # Convert rows to list of dicts
150 | entries = [dict(row) for row in rows]
151 | return entries, total_count
152 |
153 | except sqlite3.Error as e:
154 | logger.error(f"Error retrieving history entries: {e}", exc_info=True)
155 | return [], 0
156 | finally:
157 | if conn:
158 | conn.close()
159 |
160 | if __name__ == '__main__':
161 | # For testing purposes
162 | logging.basicConfig(level=logging.INFO)
163 | init_history_db()
164 |
165 | sample_data_complete = {
166 | 'task_id': 'test_task_123',
167 | 'download_type': 'track',
168 | 'item_name': 'Test Song',
169 | 'item_artist': 'Test Artist',
170 | 'item_album': 'Test Album',
171 | 'item_url': 'http://spotify.com/track/123',
172 | 'spotify_id': '123',
173 | 'status_final': 'COMPLETED',
174 | 'error_message': None,
175 | 'timestamp_added': time.time() - 3600,
176 | 'timestamp_completed': time.time(),
177 | 'original_request_json': json.dumps({'param1': 'value1'}),
178 | 'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished!'})
179 | }
180 | add_entry_to_history(sample_data_complete)
181 |
182 | sample_data_error = {
183 | 'task_id': 'test_task_456',
184 | 'download_type': 'album',
185 | 'item_name': 'Another Album',
186 | 'item_artist': 'Another Artist',
187 | 'item_album': 'Another Album', # For albums, item_name and item_album are often the same
188 | 'item_url': 'http://spotify.com/album/456',
189 | 'spotify_id': '456',
190 | 'status_final': 'ERROR',
191 | 'error_message': 'Download failed due to network issue.',
192 | 'timestamp_added': time.time() - 7200,
193 | 'timestamp_completed': time.time() - 60,
194 | 'original_request_json': json.dumps({'param2': 'value2'}),
195 | 'last_status_obj_json': json.dumps({'status': 'error', 'error': 'Network issue'})
196 | }
197 | add_entry_to_history(sample_data_error)
198 |
199 | # Test updating an entry
200 | updated_data_complete = {
201 | 'task_id': 'test_task_123',
202 | 'download_type': 'track',
203 | 'item_name': 'Test Song (Updated)',
204 | 'item_artist': 'Test Artist',
205 | 'item_album': 'Test Album II',
206 | 'item_url': 'http://spotify.com/track/123',
207 | 'spotify_id': '123',
208 | 'status_final': 'COMPLETED',
209 | 'error_message': None,
210 | 'timestamp_added': time.time() - 3600,
211 | 'timestamp_completed': time.time() + 100, # Updated completion time
212 | 'original_request_json': json.dumps({'param1': 'value1', 'new_param': 'added'}),
213 | 'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished! With update.'})
214 | }
215 | add_entry_to_history(updated_data_complete)
216 |
217 | print(f"Test entries added/updated in {HISTORY_DB_FILE}")
218 |
219 | print("\nFetching all history entries (default sort):")
220 | entries, total = get_history_entries(limit=5)
221 | print(f"Total entries: {total}")
222 | for entry in entries:
223 | print(entry)
224 |
225 | print("\nFetching history entries (sorted by item_name ASC, limit 2, offset 1):")
226 | entries_sorted, total_sorted = get_history_entries(limit=2, offset=1, sort_by='item_name', sort_order='ASC')
227 | print(f"Total entries (should be same as above): {total_sorted}")
228 | for entry in entries_sorted:
229 | print(entry)
230 |
231 | print("\nFetching history entries with filter (status_final = COMPLETED):")
232 | entries_filtered, total_filtered = get_history_entries(filters={'status_final': 'COMPLETED'})
233 | print(f"Total COMPLETED entries: {total_filtered}")
234 | for entry in entries_filtered:
235 | print(entry)
--------------------------------------------------------------------------------
/routes/utils/artist.py:
--------------------------------------------------------------------------------
1 | import json
2 | import traceback
3 | from pathlib import Path
4 | import os
5 | import logging
6 | from flask import Blueprint, Response, request, url_for
7 | from routes.utils.celery_queue_manager import download_queue_manager, get_config_params
8 | from routes.utils.get_info import get_spotify_info
9 | from routes.utils.celery_tasks import get_last_task_status, ProgressState
10 |
11 | from deezspot.easy_spoty import Spo
12 | from deezspot.libutils.utils import get_ids, link_is_valid
13 |
14 | # Configure logging
15 | logger = logging.getLogger(__name__)
16 |
17 | def log_json(message_dict):
18 | """Helper function to output a JSON-formatted log message."""
19 | print(json.dumps(message_dict))
20 |
21 |
22 | def get_artist_discography(url, main, album_type='album,single,compilation,appears_on', progress_callback=None):
23 | """
24 | Validate the URL, extract the artist ID, and retrieve the discography.
25 | """
26 | if not url:
27 | log_json({"status": "error", "message": "No artist URL provided."})
28 | raise ValueError("No artist URL provided.")
29 |
30 | # This will raise an exception if the link is invalid.
31 | link_is_valid(link=url)
32 |
33 | # Initialize Spotify API with credentials
34 | spotify_client_id = None
35 | spotify_client_secret = None
36 | search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
37 | if search_creds_path.exists():
38 | try:
39 | with open(search_creds_path, 'r') as f:
40 | search_creds = json.load(f)
41 | spotify_client_id = search_creds.get('client_id')
42 | spotify_client_secret = search_creds.get('client_secret')
43 | except Exception as e:
44 | log_json({"status": "error", "message": f"Error loading Spotify search credentials: {e}"})
45 | raise
46 |
47 | # Initialize the Spotify client with credentials
48 | if spotify_client_id and spotify_client_secret:
49 | Spo.__init__(spotify_client_id, spotify_client_secret)
50 | else:
51 | raise ValueError("No Spotify credentials found")
52 |
53 | try:
54 | artist_id = get_ids(url)
55 | except Exception as id_error:
56 | msg = f"Failed to extract artist ID from URL: {id_error}"
57 | log_json({"status": "error", "message": msg})
58 | raise ValueError(msg)
59 |
60 | try:
61 | discography = Spo.get_artist(artist_id, album_type=album_type)
62 | return discography
63 | except Exception as fetch_error:
64 | msg = f"An error occurred while fetching the discography: {fetch_error}"
65 | log_json({"status": "error", "message": msg})
66 | raise
67 |
68 |
69 | def download_artist_albums(url, album_type="album,single,compilation", request_args=None):
70 | """
71 | Download albums by an artist, filtered by album types.
72 |
73 | Args:
74 | url (str): Spotify artist URL
75 | album_type (str): Comma-separated list of album types to download
76 | (album, single, compilation, appears_on)
77 | request_args (dict): Original request arguments for tracking
78 |
79 | Returns:
80 | tuple: (list of successfully queued albums, list of duplicate albums)
81 | """
82 | if not url:
83 | raise ValueError("Missing required parameter: url")
84 |
85 | # Extract artist ID from URL
86 | artist_id = url.split('/')[-1]
87 | if '?' in artist_id:
88 | artist_id = artist_id.split('?')[0]
89 |
90 | logger.info(f"Fetching artist info for ID: {artist_id}")
91 |
92 | # Detect URL source (only Spotify is supported for artists)
93 | is_spotify_url = 'open.spotify.com' in url.lower()
94 | is_deezer_url = 'deezer.com' in url.lower()
95 |
96 | # Artist functionality only works with Spotify URLs currently
97 | if not is_spotify_url:
98 | error_msg = "Invalid URL: Artist functionality only supports open.spotify.com URLs"
99 | logger.error(error_msg)
100 | raise ValueError(error_msg)
101 |
102 | # Get artist info with albums
103 | artist_data = get_spotify_info(artist_id, "artist")
104 |
105 | # Debug logging to inspect the structure of artist_data
106 | logger.debug(f"Artist data structure has keys: {list(artist_data.keys() if isinstance(artist_data, dict) else [])}")
107 |
108 | if not artist_data or 'items' not in artist_data:
109 | raise ValueError(f"Failed to retrieve artist data or no albums found for artist ID {artist_id}")
110 |
111 | # Parse the album types to filter by
112 | allowed_types = [t.strip().lower() for t in album_type.split(",")]
113 | logger.info(f"Filtering albums by types: {allowed_types}")
114 |
115 | # Get artist name from the first album
116 | artist_name = ""
117 | if artist_data.get('items') and len(artist_data['items']) > 0:
118 | first_album = artist_data['items'][0]
119 | if first_album.get('artists') and len(first_album['artists']) > 0:
120 | artist_name = first_album['artists'][0].get('name', '')
121 |
122 | # Filter albums by the specified types
123 | filtered_albums = []
124 | for album in artist_data.get('items', []):
125 | album_type_value = album.get('album_type', '').lower()
126 | album_group_value = album.get('album_group', '').lower()
127 |
128 | # Apply filtering logic based on album_type and album_group
129 | if (('album' in allowed_types and album_type_value == 'album' and album_group_value == 'album') or
130 | ('single' in allowed_types and album_type_value == 'single' and album_group_value == 'single') or
131 | ('compilation' in allowed_types and album_type_value == 'compilation') or
132 | ('appears_on' in allowed_types and album_group_value == 'appears_on')):
133 | filtered_albums.append(album)
134 |
135 | if not filtered_albums:
136 | logger.warning(f"No albums match the specified types: {album_type}")
137 | return [], []
138 |
139 | # Queue each album as a separate download task
140 | album_task_ids = []
141 | successfully_queued_albums = []
142 | duplicate_albums = [] # To store info about albums that were duplicates
143 |
144 | for album in filtered_albums:
145 | # Add detailed logging to inspect each album's structure and URLs
146 | logger.debug(f"Processing album: {album.get('name', 'Unknown')}")
147 | logger.debug(f"Album structure has keys: {list(album.keys())}")
148 |
149 | external_urls = album.get('external_urls', {})
150 | logger.debug(f"Album external_urls: {external_urls}")
151 |
152 | album_url = external_urls.get('spotify', '')
153 | album_name = album.get('name', 'Unknown Album')
154 | album_artists = album.get('artists', [])
155 | album_artist = album_artists[0].get('name', 'Unknown Artist') if album_artists else 'Unknown Artist'
156 |
157 | logger.debug(f"Extracted album URL: {album_url}")
158 |
159 | if not album_url:
160 | logger.warning(f"Skipping album without URL: {album_name}")
161 | continue
162 |
163 | # Create album-specific request args instead of using original artist request
164 | album_request_args = {
165 | "url": album_url,
166 | "name": album_name,
167 | "artist": album_artist,
168 | "type": "album",
169 | # URL source will be automatically detected in the download functions
170 | "parent_artist_url": url,
171 | "parent_request_type": "artist"
172 | }
173 |
174 | # Include original download URL for this album task
175 | album_request_args["original_url"] = url_for('album.handle_download', url=album_url, _external=True)
176 |
177 | # Create task for this album
178 | task_data = {
179 | "download_type": "album",
180 | "type": "album", # Type for the download task
181 | "url": album_url, # Important: use the album URL, not artist URL
182 | "retry_url": album_url, # Use album URL for retry logic, not artist URL
183 | "name": album_name,
184 | "artist": album_artist,
185 | "orig_request": album_request_args # Store album-specific request params
186 | }
187 |
188 | # Debug log the task data being sent to the queue
189 | logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}")
190 |
191 | try:
192 | task_id = download_queue_manager.add_task(task_data)
193 |
194 | # Check the status of the newly added task to see if it was marked as a duplicate error
195 | last_status = get_last_task_status(task_id)
196 |
197 | if last_status and last_status.get("status") == ProgressState.ERROR and last_status.get("existing_task_id"):
198 | logger.warning(f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}")
199 | duplicate_albums.append({
200 | "name": album_name,
201 | "artist": album_artist,
202 | "url": album_url,
203 | "error_task_id": task_id, # This is the ID of the task marked as a duplicate error
204 | "existing_task_id": last_status.get("existing_task_id"),
205 | "message": last_status.get("message", "Duplicate download attempt.")
206 | })
207 | else:
208 | # If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task)
209 | # We only add to successfully_queued_albums if it wasn't a duplicate error from add_task
210 | # Other errors from add_task (like submission failure) would also result in an error status for task_id
211 | # but won't have 'existing_task_id'. The client can check the status of this task_id.
212 | album_task_ids.append(task_id) # Keep track of all task_ids returned by add_task
213 | successfully_queued_albums.append({
214 | "name": album_name,
215 | "artist": album_artist,
216 | "url": album_url,
217 | "task_id": task_id
218 | })
219 | logger.info(f"Queued album download: {album_name} ({task_id})")
220 | except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now)
221 | logger.error(f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}")
222 | # Optionally, collect these errors. For now, just logging and continuing.
223 |
224 | logger.info(f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found.")
225 | return successfully_queued_albums, duplicate_albums
226 |
--------------------------------------------------------------------------------
/routes/utils/track.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import traceback
4 | from deezspot.spotloader import SpoLogin
5 | from deezspot.deezloader import DeeLogin
6 | from pathlib import Path
7 |
8 | def download_track(
9 | url,
10 | main,
11 | fallback=None,
12 | quality=None,
13 | fall_quality=None,
14 | real_time=False,
15 | custom_dir_format="%ar_album%/%album%/%copyright%",
16 | custom_track_format="%tracknum%. %music% - %artist%",
17 | pad_tracks=True,
18 | initial_retry_delay=5,
19 | retry_delay_increase=5,
20 | max_retries=3,
21 | progress_callback=None
22 | ):
23 | try:
24 | # Detect URL source (Spotify or Deezer) from URL
25 | is_spotify_url = 'open.spotify.com' in url.lower()
26 | is_deezer_url = 'deezer.com' in url.lower()
27 |
28 | # Determine service exclusively from URL
29 | if is_spotify_url:
30 | service = 'spotify'
31 | elif is_deezer_url:
32 | service = 'deezer'
33 | else:
34 | # If URL can't be detected, raise an error
35 | error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com"
36 | print(f"ERROR: {error_msg}")
37 | raise ValueError(error_msg)
38 |
39 | print(f"DEBUG: track.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}")
40 | print(f"DEBUG: track.py - Service determined from URL: {service}")
41 | print(f"DEBUG: track.py - Credentials: main={main}, fallback={fallback}")
42 |
43 | # Load Spotify client credentials if available
44 | spotify_client_id = None
45 | spotify_client_secret = None
46 |
47 | # Smartly determine where to look for Spotify search credentials
48 | if service == 'spotify' and fallback:
49 | # If fallback is enabled, use the fallback account for Spotify search credentials
50 | search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
51 | print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
52 | else:
53 | # Otherwise use the main account for Spotify search credentials
54 | search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
55 | print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
56 |
57 | if search_creds_path.exists():
58 | try:
59 | with open(search_creds_path, 'r') as f:
60 | search_creds = json.load(f)
61 | spotify_client_id = search_creds.get('client_id')
62 | spotify_client_secret = search_creds.get('client_secret')
63 | print(f"DEBUG: Loaded Spotify client credentials successfully")
64 | except Exception as e:
65 | print(f"Error loading Spotify search credentials: {e}")
66 |
67 | # For Spotify URLs: check if fallback is enabled, if so use the fallback logic,
68 | # otherwise download directly from Spotify
69 | if service == 'spotify':
70 | if fallback:
71 | if quality is None:
72 | quality = 'FLAC'
73 | if fall_quality is None:
74 | fall_quality = 'HIGH'
75 |
76 | # First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials)
77 | deezer_error = None
78 | try:
79 | deezer_creds_dir = os.path.join('./data/creds/deezer', main)
80 | deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
81 |
82 | # DEBUG: Print Deezer credential paths being used
83 | print(f"DEBUG: Looking for Deezer credentials at:")
84 | print(f"DEBUG: deezer_creds_dir = {deezer_creds_dir}")
85 | print(f"DEBUG: deezer_creds_path = {deezer_creds_path}")
86 | print(f"DEBUG: Directory exists = {os.path.exists(deezer_creds_dir)}")
87 | print(f"DEBUG: Credentials file exists = {os.path.exists(deezer_creds_path)}")
88 |
89 | # List available directories to compare
90 | print(f"DEBUG: Available Deezer credential directories:")
91 | for dir_name in os.listdir('./data/creds/deezer'):
92 | print(f"DEBUG: ./data/creds/deezer/{dir_name}")
93 |
94 | with open(deezer_creds_path, 'r') as f:
95 | deezer_creds = json.load(f)
96 | dl = DeeLogin(
97 | arl=deezer_creds.get('arl', ''),
98 | spotify_client_id=spotify_client_id,
99 | spotify_client_secret=spotify_client_secret,
100 | progress_callback=progress_callback
101 | )
102 | dl.download_trackspo(
103 | link_track=url,
104 | output_dir="./downloads",
105 | quality_download=quality,
106 | recursive_quality=False,
107 | recursive_download=False,
108 | not_interface=False,
109 | method_save=1,
110 | custom_dir_format=custom_dir_format,
111 | custom_track_format=custom_track_format,
112 | initial_retry_delay=initial_retry_delay,
113 | retry_delay_increase=retry_delay_increase,
114 | max_retries=max_retries
115 | )
116 | except Exception as e:
117 | deezer_error = e
118 | # Immediately report the Deezer error
119 | print(f"ERROR: Deezer download attempt failed: {e}")
120 | traceback.print_exc()
121 | print("Attempting Spotify fallback...")
122 |
123 | # If the first attempt fails, use the fallback Spotify credentials
124 | try:
125 | spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
126 | spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
127 |
128 | # We've already loaded the Spotify client credentials above based on fallback
129 |
130 | spo = SpoLogin(
131 | credentials_path=spo_creds_path,
132 | spotify_client_id=spotify_client_id,
133 | spotify_client_secret=spotify_client_secret,
134 | progress_callback=progress_callback
135 | )
136 | spo.download_track(
137 | link_track=url,
138 | output_dir="./downloads",
139 | quality_download=fall_quality,
140 | recursive_quality=False,
141 | recursive_download=False,
142 | not_interface=False,
143 | method_save=1,
144 | real_time_dl=real_time,
145 | custom_dir_format=custom_dir_format,
146 | custom_track_format=custom_track_format,
147 | pad_tracks=pad_tracks,
148 | initial_retry_delay=initial_retry_delay,
149 | retry_delay_increase=retry_delay_increase,
150 | max_retries=max_retries
151 | )
152 | except Exception as e2:
153 | # If fallback also fails, raise an error indicating both attempts failed
154 | raise RuntimeError(
155 | f"Both main (Deezer) and fallback (Spotify) attempts failed. "
156 | f"Deezer error: {deezer_error}, Spotify error: {e2}"
157 | ) from e2
158 | else:
159 | # Directly use Spotify main account
160 | if quality is None:
161 | quality = 'HIGH'
162 | creds_dir = os.path.join('./data/creds/spotify', main)
163 | credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
164 | spo = SpoLogin(
165 | credentials_path=credentials_path,
166 | spotify_client_id=spotify_client_id,
167 | spotify_client_secret=spotify_client_secret,
168 | progress_callback=progress_callback
169 | )
170 | spo.download_track(
171 | link_track=url,
172 | output_dir="./downloads",
173 | quality_download=quality,
174 | recursive_quality=False,
175 | recursive_download=False,
176 | not_interface=False,
177 | method_save=1,
178 | real_time_dl=real_time,
179 | custom_dir_format=custom_dir_format,
180 | custom_track_format=custom_track_format,
181 | pad_tracks=pad_tracks,
182 | initial_retry_delay=initial_retry_delay,
183 | retry_delay_increase=retry_delay_increase,
184 | max_retries=max_retries
185 | )
186 | # For Deezer URLs: download directly from Deezer
187 | elif service == 'deezer':
188 | if quality is None:
189 | quality = 'FLAC'
190 | # Deezer download logic remains unchanged, with the custom formatting parameters passed along.
191 | creds_dir = os.path.join('./data/creds/deezer', main)
192 | creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
193 | with open(creds_path, 'r') as f:
194 | creds = json.load(f)
195 | dl = DeeLogin(
196 | arl=creds.get('arl', ''),
197 | spotify_client_id=spotify_client_id,
198 | spotify_client_secret=spotify_client_secret,
199 | progress_callback=progress_callback
200 | )
201 | dl.download_trackdee(
202 | link_track=url,
203 | output_dir="./downloads",
204 | quality_download=quality,
205 | recursive_quality=False,
206 | recursive_download=False,
207 | method_save=1,
208 | custom_dir_format=custom_dir_format,
209 | custom_track_format=custom_track_format,
210 | pad_tracks=pad_tracks,
211 | initial_retry_delay=initial_retry_delay,
212 | retry_delay_increase=retry_delay_increase,
213 | max_retries=max_retries
214 | )
215 | else:
216 | raise ValueError(f"Unsupported service: {service}")
217 | except Exception as e:
218 | traceback.print_exc()
219 | raise
220 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SUPPORT YOUR ARTISTS
2 |
3 | As of 2025, Spotify pays an average of $0.005 per stream to the artist. That means that if you give the equivalent of $5 directly to them (like merch, buying cds, or just donating), you can """ethically""" listen to them a total of 1000 times. Of course, nobody thinks spotify payment is fair, so preferably you should give more, but $5 is the bare minimum. Big names prolly don't need those $5 dollars, but it might be _the_ difference between going out of business or not for that indie rock band you like.
4 |
5 | # Spotizerr
6 |
7 | Music downloader which combines the best of two worlds: Spotify's catalog and Deezer's quality. Search for a track using Spotify search api, click download and, depending on your preferences, it will download directly from Spotify or firstly try to download from Deezer, if it fails, it'll fallback to Spotify.
8 |
9 | ## Desktop interface
10 | 
11 |
12 | 
13 |
14 | 
15 |
16 | ## Mobile interface
17 |
18 |  
19 |
20 | ## Features
21 |
22 | - Browse through artist, albums, playlists and tracks and jump between them
23 | - Dual-service integration (Spotify & Deezer)
24 | - Direct URL downloads for Spotify tracks/albums/playlists/artists
25 | - Search using spotify's catalog
26 | - Credential management system
27 | - Download queue with real-time progress
28 | - Service fallback system when downloading*
29 | - Real time downloading**
30 | - Quality selector***
31 | - Customizable track number padding (01. Track or 1. Track)
32 | - Customizable retry parameters (max attempts, delay, increase per retry)
33 |
34 | *It will first try to download each track from Deezer and only if it fails, will grab it from Spotify
35 | **Only for spotify. For each track, it matches its length with the time it takes to download it
36 | ***Restrictions per account tier apply (see
37 |
38 | ## Prerequisites
39 |
40 | - Docker, duh
41 | - Spotify credentials (see [Spotify Credentials Setup](#spotify-credentials-setup))
42 | - Spotify client ID and client secret (see [Spotify Developer Setup](#spotify-developer-setup))
43 | - Deezer ARL token (see [Deezer ARL Setup](#deezer-arl-setup))
44 |
45 | ## Installation
46 |
47 | 1. Create project directory:
48 | ```bash
49 | mkdir spotizerr && cd spotizerr
50 | ```
51 |
52 | 2. Copy the `.env` file from this repo and update all variables (e.g. Redis credentials, PUID/PGID, UMASK).
53 | 3. Copy `docker-compose.yml` from this repo.
54 | 4. Create required directories:
55 | ```bash
56 | mkdir -p data/creds data/config data/watch data/history downloads logs/tasks .cache
57 | ```
58 | 5. Launch containers:
59 | ```bash
60 | docker compose up -d
61 | ```
62 | _Note: an UnRaid template is available in the file spotizerr.xml_
63 |
64 | Access at: `http://localhost:7171`
65 |
66 | ## Configuration
67 |
68 | ### Initial Setup
69 | 1. Access settings via the gear icon
70 | 2. Switch between service tabs (Spotify/Deezer)
71 | 3. Enter credentials using the form
72 | 4. Configure active accounts in settings
73 |
74 | _Note: If you want Spotify-only mode, just keep "Download fallback" setting disabled and don't bother adding Deezer credentials. Deezer-only mode is not, and will not be supported since there already is a much better tool for that called "Deemix"_
75 |
76 | ### Spotify Credentials Setup
77 |
78 | First create a Spotify credentials file using the 3rd-party `librespot-auth` tool, this step has to be done in a PC/Laptop that has the Spotify desktop app installed.
79 |
80 | ---
81 | #### For Linux (using Docker)
82 | 1. Clone the `librespot-auth` repository:
83 | ```shell
84 | git clone --depth 1 https://github.com/dspearson/librespot-auth.git
85 | ```
86 |
87 | 2. Build the repository using the Rust Docker image:
88 | ```shell
89 | docker run --rm -v "$(pwd)/librespot-auth":/app -w /app rust:latest cargo build --release
90 | ```
91 |
92 | 3. Run the built binary:
93 | ```shell
94 | ./librespot-auth/target/release/librespot-auth --name "mySpotifyAccount1" --class=computer
95 | ```
96 |
97 | ---
98 |
99 | #### For Windows (using Docker)
100 |
101 | 1. Clone the `librespot-auth` repository:
102 | ```shell
103 | git clone --depth 1 https://github.com/dspearson/librespot-auth.git
104 | ```
105 |
106 | 2. Build the repository using a windows-targeted Rust Docker image ([why a different image?](https://github.com/jscharnitzke/rust-build-windows)):
107 | ```shell
108 | docker run --rm -v "${pwd}/librespot-auth:/app" -w "/app" jscharnitzke/rust-build-windows --release
109 | ```
110 |
111 | 3. Run the built binary:
112 | ```shell
113 | .\librespot-auth\target\x86_64-pc-windows-gnu\release\librespot-auth.exe --name "mySpotifyAccount1" --class=computer
114 | ```
115 | ---
116 |
117 | #### For Apple Silicon (macOS)
118 | 1. Clone the `librespot-auth` repository:
119 | ```shell
120 | git clone --depth 1 https://github.com/dspearson/librespot-auth.git
121 | ```
122 |
123 | 2. Install Rust using Homebrew:
124 | ```shell
125 | brew install rustup
126 | brew install rust
127 | ```
128 |
129 | 3. Build `librespot-auth` for Apple Silicon:
130 | ```shell
131 | cd librespot-auth
132 | cargo build --target=aarch64-apple-darwin --release
133 | ```
134 |
135 | 4. Run the built binary:
136 | ```shell
137 | ./target/aarch64-apple-darwin/release/librespot-auth --name "mySpotifyAccount1" --class=computer
138 | ```
139 | ---
140 |
141 | - Now open the Spotify app
142 | - Click on the "Connect to a device" icon
143 | - Under the "Select Another Device" section, click "mySpotifyAccount1"
144 | - This utility will create a `credentials.json` file
145 |
146 | This file has the following format:
147 |
148 | ```
149 | {"username": "string" "auth_type": 1 "auth_data": "string"}
150 | ```
151 |
152 | The important ones are the "username" and "auth_data" parameters, these match the "username" and "credentials" sections respectively when adding/editing spotify credentials in Spotizerr.
153 |
154 | In the terminal, you can directly print these parameters using jq:
155 |
156 | ```
157 | jq -r '.username, .auth_data' credentials.json
158 | ```
159 |
160 | ### Spotify Developer Setup
161 |
162 | In order for searching to work, you need to set up your own Spotify Developer application:
163 |
164 | 1. Visit the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
165 | 2. Log in with your Spotify account
166 | 3. Click "Create app"
167 | 4. Fill in:
168 | - App name (e.g., "My Spotizerr App")
169 | - App description
170 | - Redirect URI: `http://127.0.0.1:7171/callback` (or your custom domain if exposed)
171 | - Check the Developer Terms agreement box
172 | 5. Click "Create"
173 | 6. On your app page, note your "Client ID"
174 | 7. Click "Show client secret" to reveal your "Client Secret"
175 | 8. Add these credentials in Spotizerr's settings page under the Spotify service section
176 |
177 | ### Deezer ARL Setup
178 |
179 | #### Chrome-based browsers
180 |
181 | Open the [web player](https://www.deezer.com/)
182 |
183 | There, press F12 and select "Application"
184 |
185 | 
186 |
187 | Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text.
188 |
189 | 
190 |
191 | Copy that value and paste it into the correspondant setting in Spotizerr
192 |
193 | #### Firefox-based browsers
194 |
195 | Open the [web player](https://www.deezer.com/)
196 |
197 | There, press F12 and select "Storage"
198 |
199 | 
200 |
201 | Click the cookies host "https://www.deezer.com" and find the "arl" cookie.
202 |
203 | 
204 |
205 | Copy that value and paste it into the correspondant setting in Spotizerr
206 |
207 | ## Usage
208 |
209 | ### Basic Operations
210 | 1. **Search**:
211 | - Enter query in search bar
212 | - Select result type (Track/Album/Playlist/Artist)
213 | - Click search button or press Enter
214 |
215 | 2. **Download**:
216 | - Click download button on any result
217 | - For artists, you can select a specific subset of albums you want to download
218 | - Monitor progress in queue sidebar
219 |
220 | 3. **Direct URLs**:
221 | - Paste Spotify URLs directly into search
222 | - Supports tracks, albums, playlists and artists (this will download the whole discogrpahy, you've been warned)
223 |
224 | ### Advanced Features
225 | - **Fallback System**:
226 | - Enable in settings
227 | - Uses Deezer as primary when downloading with Spotify fallback
228 |
229 | - **Multiple Accounts**:
230 | - Manage credentials in settings
231 | - Switch active accounts per service
232 |
233 | - **Quality selector**
234 | - For spotify: OGG 96k, 160k and 320k (premium only)
235 | - For deezer: MP3 128k, MP3 320k (sometimes premium, it varies) and FLAC (premium only)
236 |
237 | - **Customizable formatting**:
238 | - Track number padding (01. Track or 1. Track)
239 | - Adjust retry parameters (max attempts, delay, delay increase)
240 |
241 | ### Environment Variables
242 |
243 | Define your variables in the `.env` file in the project root:
244 | ```dotenv
245 | REDIS_HOST=redis # Redis host name
246 | REDIS_PORT=6379 # Redis port number
247 | REDIS_DB=0 # Redis DB index
248 | REDIS_PASSWORD=CHANGE_ME # Redis AUTH password
249 | EXPLICIT_FILTER=false # Filter explicit content
250 | PUID=1000 # Container user ID
251 | PGID=1000 # Container group ID
252 | UMASK=0022 # Default file permission mask
253 | ```
254 |
255 | ## Troubleshooting
256 |
257 | **Common Issues**:
258 | - "No accounts available" error: Add credentials in settings
259 | - Download failures: Check credential validity
260 | - Queue stalls: Verify service connectivity
261 | - Audiokey related: Spotify rate limit, let it cooldown about 30 seconds and click retry
262 | - API errors: Ensure your Spotify client ID and client secret are correctly entered
263 |
264 | **Log Locations**:
265 | - Application Logs: `docker logs spotizerr` (for main app and Celery workers)
266 | - Individual Task Logs: `./logs/tasks/` (inside the container, maps to your volume)
267 | - Credentials: `./data/creds/`
268 | - Configuration Files: `./data/config/`
269 | - Downloaded Music: `./downloads/`
270 | - Watch Feature Database: `./data/watch/`
271 | - Download History Database: `./data/history/`
272 | - Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is set to `/app/cache/.cache` and mapped)
273 |
274 | ## Notes
275 |
276 | - This app has no way of authentication, if you plan on exposing it, put a security layer on top of it (such as cloudflare tunnel, authelia or just leave it accessible only through a vpn)
277 | - Credentials are stored in plaintext - secure your installation
278 | - Downloaded files retain original metadata
279 | - Service limitations apply based on account types
280 |
281 | # Acknowledgements
282 |
283 | - This project is based on the amazing [deezspot library](https://github.com/jakiepari/deezspot), although their creators are in no way related with Spotizerr, they still deserve credit
284 |
--------------------------------------------------------------------------------