├── .dockerignore ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── docker-build.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── builds ├── dev.build.sh └── latest.build.sh ├── docker-compose.yaml ├── entrypoint.sh ├── requirements.txt ├── routes ├── __init__.py ├── album.py ├── artist.py ├── config.py ├── credentials.py ├── history.py ├── playlist.py ├── prgs.py ├── search.py ├── track.py └── utils │ ├── __init__.py │ ├── album.py │ ├── artist.py │ ├── celery_config.py │ ├── celery_manager.py │ ├── celery_queue_manager.py │ ├── celery_tasks.py │ ├── credentials.py │ ├── get_info.py │ ├── history_manager.py │ ├── playlist.py │ ├── search.py │ ├── track.py │ └── watch │ ├── db.py │ └── manager.py ├── src └── js │ ├── album.ts │ ├── artist.ts │ ├── config.ts │ ├── history.ts │ ├── main.ts │ ├── playlist.ts │ ├── queue.ts │ ├── track.ts │ └── watch.ts ├── static ├── css │ ├── album │ │ └── album.css │ ├── artist │ │ └── artist.css │ ├── config │ │ └── config.css │ ├── history │ │ └── history.css │ ├── main │ │ ├── base.css │ │ ├── icons.css │ │ └── main.css │ ├── playlist │ │ └── playlist.css │ ├── queue │ │ └── queue.css │ ├── track │ │ └── track.css │ └── watch │ │ └── watch.css ├── html │ ├── album.html │ ├── artist.html │ ├── config.html │ ├── favicon.ico │ ├── history.html │ ├── main.html │ ├── playlist.html │ ├── track.html │ └── watch.html └── images │ ├── album.svg │ ├── arrow-left.svg │ ├── binoculars.svg │ ├── check.svg │ ├── cross.svg │ ├── download.svg │ ├── eye-crossed.svg │ ├── eye.svg │ ├── history.svg │ ├── home.svg │ ├── info.svg │ ├── missing.svg │ ├── music.svg │ ├── placeholder.jpg │ ├── plus-circle.svg │ ├── queue-empty.svg │ ├── queue.svg │ ├── refresh-cw.svg │ ├── refresh.svg │ ├── search.svg │ ├── settings.svg │ ├── skull-head.svg │ └── view.svg └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | ![image](https://github.com/user-attachments/assets/8093085d-cad3-4cba-9a0d-1ad6cae63e4f) 11 | 12 | ![image](https://github.com/user-attachments/assets/ac5daa0f-769f-43b0-b78a-8db343219861) 13 | 14 | ![image](https://github.com/user-attachments/assets/fb8b2295-f6b6-412f-87da-69f63b56247c) 15 | 16 | ## Mobile interface 17 | 18 | ![Screen Shot 2025-03-15 at 21 02 27](https://github.com/user-attachments/assets/cee9318e-9451-4a43-9e24-20e05f4abc5b) ![Screen Shot 2025-03-15 at 21 02 45](https://github.com/user-attachments/assets/d5801795-ba31-4589-a82d-d208f1ea6d62) 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. Setup a `.env` file following the `.env.example` 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. Launch containers: 55 | ```bash 56 | docker compose up -d 57 | ``` 58 | _Note: an UnRaid template is available in the file spotizerr.xml_ 59 | 60 | Access at: `http://localhost:7171` 61 | 62 | ## Configuration 63 | 64 | ### Initial Setup 65 | 1. Access settings via the gear icon 66 | 2. Switch between service tabs (Spotify/Deezer) 67 | 3. Enter credentials using the form 68 | 4. Configure active accounts in settings 69 | 70 | _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"_ 71 | 72 | ### Spotify Credentials Setup 73 | 74 | 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. 75 | 76 | --- 77 | #### For Linux (using Docker) 78 | 1. Clone the `librespot-auth` repository: 79 | ```shell 80 | git clone --depth 1 https://github.com/dspearson/librespot-auth.git 81 | ``` 82 | 83 | 2. Build the repository using the Rust Docker image: 84 | ```shell 85 | docker run --rm -v "$(pwd)/librespot-auth":/app -w /app rust:latest cargo build --release 86 | ``` 87 | 88 | 3. Run the built binary: 89 | ```shell 90 | ./librespot-auth/target/release/librespot-auth --name "mySpotifyAccount1" --class=computer 91 | ``` 92 | 93 | --- 94 | 95 | #### For Windows (using Docker) 96 | 97 | 1. Clone the `librespot-auth` repository: 98 | ```shell 99 | git clone --depth 1 https://github.com/dspearson/librespot-auth.git 100 | ``` 101 | 102 | 2. Build the repository using a windows-targeted Rust Docker image ([why a different image?](https://github.com/jscharnitzke/rust-build-windows)): 103 | ```shell 104 | docker run --rm -v "${pwd}/librespot-auth:/app" -w "/app" jscharnitzke/rust-build-windows --release 105 | ``` 106 | 107 | 3. Run the built binary: 108 | ```shell 109 | .\librespot-auth\target\x86_64-pc-windows-gnu\release\librespot-auth.exe --name "mySpotifyAccount1" --class=computer 110 | ``` 111 | --- 112 | 113 | #### For Apple Silicon (macOS) 114 | 1. Clone the `librespot-auth` repository: 115 | ```shell 116 | git clone --depth 1 https://github.com/dspearson/librespot-auth.git 117 | ``` 118 | 119 | 2. Install Rust using Homebrew: 120 | ```shell 121 | brew install rustup 122 | brew install rust 123 | ``` 124 | 125 | 3. Build `librespot-auth` for Apple Silicon: 126 | ```shell 127 | cd librespot-auth 128 | cargo build --target=aarch64-apple-darwin --release 129 | ``` 130 | 131 | 4. Run the built binary: 132 | ```shell 133 | ./target/aarch64-apple-darwin/release/librespot-auth --name "mySpotifyAccount1" --class=computer 134 | ``` 135 | --- 136 | 137 | - Now open the Spotify app 138 | - Click on the "Connect to a device" icon 139 | - Under the "Select Another Device" section, click "mySpotifyAccount1" 140 | - This utility will create a `credentials.json` file 141 | 142 | This file has the following format: 143 | 144 | ``` 145 | {"username": "long text" "auth_type": 1 "auth_data": "even longer text"} 146 | ``` 147 | 148 | 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. 149 | 150 | In the terminal, you can directly print these parameters using jq: 151 | 152 | ``` 153 | jq -r '.username, .auth_data' credentials.json 154 | ``` 155 | 156 | ### Spotify Developer Setup 157 | 158 | In order for searching to work, you need to set up your own Spotify Developer application: 159 | 160 | 1. Visit the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) 161 | 2. Log in with your Spotify account 162 | 3. Click "Create app" 163 | 4. Fill in: 164 | - App name (e.g., "My Spotizerr App") 165 | - App description 166 | - Redirect URI: `http://127.0.0.1:7171/callback` (or your custom domain if exposed) 167 | - Check the Developer Terms agreement box 168 | 5. Click "Create" 169 | 6. On your app page, note your "Client ID" 170 | 7. Click "Show client secret" to reveal your "Client Secret" 171 | 8. Add these credentials in Spotizerr's settings page under the Spotify service section 172 | 173 | ### Deezer ARL Setup 174 | 175 | #### Chrome-based browsers 176 | 177 | Open the [web player](https://www.deezer.com/) 178 | 179 | There, press F12 and select "Application" 180 | 181 | ![image](https://github.com/user-attachments/assets/22e61d91-50b4-48f2-bba7-28ef45b45ee5) 182 | 183 | Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text. 184 | 185 | ![image](https://github.com/user-attachments/assets/75a67906-596e-42a0-beb0-540f2748b16e) 186 | 187 | Copy that value and paste it into the correspondant setting in Spotizerr 188 | 189 | #### Firefox-based browsers 190 | 191 | Open the [web player](https://www.deezer.com/) 192 | 193 | There, press F12 and select "Storage" 194 | 195 | ![image](https://github.com/user-attachments/assets/601be3fb-1ec9-44d9-be4f-28b1d853df2f) 196 | 197 | Click the cookies host "https://www.deezer.com" and find the "arl" cookie. 198 | 199 | ![image](https://github.com/user-attachments/assets/ef8ea256-2c13-4780-ae9f-71527466df56) 200 | 201 | Copy that value and paste it into the correspondant setting in Spotizerr 202 | 203 | ## Usage 204 | 205 | ### Basic Operations 206 | 1. **Search**: 207 | - Enter query in search bar 208 | - Select result type (Track/Album/Playlist/Artist) 209 | - Click search button or press Enter 210 | 211 | 2. **Download**: 212 | - Click download button on any result 213 | - For artists, you can select a specific subset of albums you want to download 214 | - Monitor progress in queue sidebar 215 | 216 | 3. **Direct URLs**: 217 | - Paste Spotify URLs directly into search 218 | - Supports tracks, albums, playlists and artists (this will download the whole discogrpahy, you've been warned) 219 | 220 | ### Advanced Features 221 | - **Fallback System**: 222 | - Enable in settings 223 | - Uses Deezer as primary when downloading with Spotify fallback 224 | 225 | - **Multiple Accounts**: 226 | - Manage credentials in settings 227 | - Switch active accounts per service 228 | 229 | - **Quality selector** 230 | - For spotify: OGG 96k, 160k and 320k (premium only) 231 | - For deezer: MP3 128k, MP3 320k (sometimes premium, it varies) and FLAC (premium only) 232 | 233 | - **Customizable formatting**: 234 | - Track number padding (01. Track or 1. Track) 235 | - Adjust retry parameters (max attempts, delay, delay increase) 236 | 237 | - **Watching artits/playlists** 238 | - Start watching a spotify playlist and its tracks will be downloaded dynamically as it updates. 239 | - Start watching a spotify artist and their albums will be automatically downloaded, never miss a release! 240 | 241 | ## Troubleshooting 242 | 243 | **Common Issues**: 244 | - "No accounts available" error: Add credentials in settings 245 | - Download failures: Check credential validity 246 | - Queue stalls: Verify service connectivity 247 | - Audiokey related: Spotify rate limit, let it cooldown about 30 seconds and click retry 248 | - API errors: Ensure your Spotify client ID and client secret are correctly entered 249 | 250 | **Log Locations**: 251 | - Application Logs: `docker logs spotizerr` (for main app and Celery workers) 252 | - Individual Task Logs: `./logs/tasks/` (inside the container, maps to your volume) 253 | - Credentials: `./data/creds/` 254 | - Configuration Files: `./data/config/` 255 | - Downloaded Music: `./downloads/` 256 | - Watch Feature Database: `./data/watch/` 257 | - Download History Database: `./data/history/` 258 | - Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is set to `/app/cache/.cache` and mapped) 259 | 260 | ## Notes 261 | 262 | - 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) 263 | - Credentials are stored in plaintext - secure your installation 264 | - Downloaded files retain original metadata 265 | - Service limitations apply based on account types 266 | 267 | # Acknowledgements 268 | 269 | - 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 270 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /builds/dev.build.sh: -------------------------------------------------------------------------------- 1 | docker buildx build --push --platform linux/amd64,linux/arm64 --build-arg CACHE_BUST=$(date +%s) --tag cooldockerizer93/spotizerr:dev . -------------------------------------------------------------------------------- /builds/latest.build.sh: -------------------------------------------------------------------------------- 1 | docker buildx build --push --platform linux/amd64,linux/arm64 --build-arg CACHE_BUST=$(date +%s) --tag cooldockerizer93/spotizerr:latest . 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/data /app/logs || 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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # Import the centralized config getters that handle file creation and defaults 10 | from routes.utils.celery_config import get_config_params as get_main_config_params, DEFAULT_MAIN_CONFIG, CONFIG_FILE_PATH as MAIN_CONFIG_FILE_PATH 11 | from routes.utils.watch.manager import get_watch_config as get_watch_manager_config, DEFAULT_WATCH_CONFIG, CONFIG_FILE_PATH as WATCH_CONFIG_FILE_PATH 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | config_bp = Blueprint('config', __name__) 16 | 17 | # Path to main config file (consistent with celery_config.py) 18 | # CONFIG_PATH = Path('./data/config/main.json') # Defined as MAIN_CONFIG_FILE_PATH from import 19 | # Path to watch config file (consistent with watch/manager.py) 20 | # WATCH_CONFIG_PATH = Path('./data/config/watch.json') # Defined as WATCH_CONFIG_FILE_PATH from import 21 | 22 | # Flag for config change notifications 23 | config_changed = False 24 | last_config = {} 25 | 26 | # Define parameters that should trigger notification when changed 27 | NOTIFY_PARAMETERS = [ 28 | 'maxConcurrentDownloads', 29 | 'service', 30 | 'fallback', 31 | 'spotifyQuality', 32 | 'deezerQuality' 33 | ] 34 | 35 | # Helper to get main config (uses the one from celery_config) 36 | def get_config(): 37 | """Retrieves the main configuration, creating it with defaults if necessary.""" 38 | return get_main_config_params() 39 | 40 | # Helper to save main config 41 | def save_config(config_data): 42 | """Saves the main configuration data to main.json.""" 43 | try: 44 | MAIN_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) 45 | # Ensure all default keys are present before saving, merging if necessary 46 | current_defaults = DEFAULT_MAIN_CONFIG.copy() 47 | # Overlay provided data on defaults to ensure all keys are there. 48 | # This might not be ideal if user explicitly wants to remove a key, 49 | # but for this setup, ensuring defaults is safer. 50 | # A better approach for full PUT might be to replace entirely, 51 | # but for ensuring defaults, this is okay. 52 | # Let's assume config_data is what the user intends fully. 53 | # We'll rely on get_config_params to have already populated defaults if the file was new. 54 | # When saving, we should just save what's given, after ensuring it has necessary structure. 55 | 56 | # Merge with defaults to ensure all keys are present 57 | # This ensures that if a user POSTs partial data, it's merged with existing/default structure 58 | 59 | # Load current or default config 60 | existing_config = {} 61 | if MAIN_CONFIG_FILE_PATH.exists(): 62 | with open(MAIN_CONFIG_FILE_PATH, 'r') as f_read: 63 | existing_config = json.load(f_read) 64 | else: # Should be rare if get_config_params was called 65 | existing_config = DEFAULT_MAIN_CONFIG.copy() 66 | 67 | # Update with new data 68 | for key, value in config_data.items(): 69 | existing_config[key] = value 70 | 71 | # Ensure all default keys are still there 72 | for default_key, default_value in DEFAULT_MAIN_CONFIG.items(): 73 | if default_key not in existing_config: 74 | existing_config[default_key] = default_value 75 | 76 | with open(MAIN_CONFIG_FILE_PATH, 'w') as f: 77 | json.dump(existing_config, f, indent=4) 78 | logger.info(f"Main configuration saved to {MAIN_CONFIG_FILE_PATH}") 79 | return True, None 80 | except Exception as e: 81 | logger.error(f"Error saving main configuration: {e}", exc_info=True) 82 | return False, str(e) 83 | 84 | # Helper to get watch config (uses the one from watch/manager.py) 85 | def get_watch_config_http(): # Renamed to avoid conflict with the imported get_watch_config 86 | """Retrieves the watch configuration, creating it with defaults if necessary.""" 87 | return get_watch_manager_config() 88 | 89 | # Helper to save watch config 90 | def save_watch_config_http(watch_config_data): # Renamed 91 | """Saves the watch configuration data to watch.json.""" 92 | try: 93 | WATCH_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) 94 | 95 | # Similar logic to save_config: merge with defaults/existing 96 | existing_config = {} 97 | if WATCH_CONFIG_FILE_PATH.exists(): 98 | with open(WATCH_CONFIG_FILE_PATH, 'r') as f_read: 99 | existing_config = json.load(f_read) 100 | else: # Should be rare if get_watch_manager_config was called 101 | existing_config = DEFAULT_WATCH_CONFIG.copy() 102 | 103 | for key, value in watch_config_data.items(): 104 | existing_config[key] = value 105 | 106 | for default_key, default_value in DEFAULT_WATCH_CONFIG.items(): 107 | if default_key not in existing_config: 108 | existing_config[default_key] = default_value 109 | 110 | with open(WATCH_CONFIG_FILE_PATH, 'w') as f: 111 | json.dump(existing_config, f, indent=4) 112 | logger.info(f"Watch configuration saved to {WATCH_CONFIG_FILE_PATH}") 113 | return True, None 114 | except Exception as e: 115 | logger.error(f"Error saving watch configuration: {e}", exc_info=True) 116 | return False, str(e) 117 | 118 | @config_bp.route('/config', methods=['GET']) 119 | def handle_config(): 120 | """Handles GET requests for the main configuration.""" 121 | try: 122 | config = get_config() 123 | return jsonify(config) 124 | except Exception as e: 125 | logger.error(f"Error in GET /config: {e}", exc_info=True) 126 | return jsonify({"error": "Failed to retrieve configuration", "details": str(e)}), 500 127 | 128 | @config_bp.route('/config', methods=['POST', 'PUT']) 129 | def update_config(): 130 | """Handles POST/PUT requests to update the main configuration.""" 131 | try: 132 | new_config = request.get_json() 133 | if not isinstance(new_config, dict): 134 | return jsonify({"error": "Invalid config format"}), 400 135 | 136 | # Get existing config to preserve environment-controlled values 137 | existing_config = get_config() or {} 138 | 139 | # Preserve the explicitFilter setting from environment 140 | explicit_filter_env = os.environ.get('EXPLICIT_FILTER', 'false').lower() 141 | new_config['explicitFilter'] = explicit_filter_env in ('true', '1', 'yes', 'on') 142 | 143 | success, error_msg = save_config(new_config) 144 | if success: 145 | # Return the updated config 146 | updated_config_values = get_config() 147 | if updated_config_values is None: 148 | # This case should ideally not be reached if save_config succeeded 149 | # and get_config handles errors by returning a default or None. 150 | return jsonify({"error": "Failed to retrieve configuration after saving"}), 500 151 | 152 | return jsonify(updated_config_values) 153 | else: 154 | return jsonify({"error": "Failed to update configuration", "details": error_msg}), 500 155 | except json.JSONDecodeError: 156 | return jsonify({"error": "Invalid JSON data"}), 400 157 | except Exception as e: 158 | logger.error(f"Error in POST/PUT /config: {e}", exc_info=True) 159 | return jsonify({"error": "Failed to update configuration", "details": str(e)}), 500 160 | 161 | @config_bp.route('/config/check', methods=['GET']) 162 | def check_config_changes(): 163 | # This endpoint seems more related to dynamically checking if config changed 164 | # on disk, which might not be necessary if settings are applied on restart 165 | # or by a dedicated manager. For now, just return current config. 166 | try: 167 | config = get_config() 168 | return jsonify({ 169 | "message": "Current configuration retrieved.", 170 | "config": config 171 | }) 172 | except Exception as e: 173 | logger.error(f"Error in GET /config/check: {e}", exc_info=True) 174 | return jsonify({"error": "Failed to check configuration", "details": str(e)}), 500 175 | 176 | @config_bp.route('/config/watch', methods=['GET']) 177 | def handle_watch_config(): 178 | """Handles GET requests for the watch configuration.""" 179 | try: 180 | watch_config = get_watch_config_http() 181 | return jsonify(watch_config) 182 | except Exception as e: 183 | logger.error(f"Error in GET /config/watch: {e}", exc_info=True) 184 | return jsonify({"error": "Failed to retrieve watch configuration", "details": str(e)}), 500 185 | 186 | @config_bp.route('/config/watch', methods=['POST', 'PUT']) 187 | def update_watch_config(): 188 | """Handles POST/PUT requests to update the watch configuration.""" 189 | try: 190 | new_watch_config = request.get_json() 191 | if not isinstance(new_watch_config, dict): 192 | return jsonify({"error": "Invalid watch config format"}), 400 193 | 194 | success, error_msg = save_watch_config_http(new_watch_config) 195 | if success: 196 | return jsonify({"message": "Watch configuration updated successfully"}), 200 197 | else: 198 | return jsonify({"error": "Failed to update watch configuration", "details": error_msg}), 500 199 | except json.JSONDecodeError: 200 | return jsonify({"error": "Invalid JSON data for watch config"}), 400 201 | except Exception as e: 202 | logger.error(f"Error in POST/PUT /config/watch: {e}", exc_info=True) 203 | return jsonify({"error": "Failed to update watch configuration", "details": str(e)}), 500 -------------------------------------------------------------------------------- /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/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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /routes/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xoconoch/spotizerr/f5f293ece714863819d8ff4786c190fabd1fac14/routes/utils/__init__.py -------------------------------------------------------------------------------- /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/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_FILE_PATH = Path('./data/config/main.json') 26 | 27 | DEFAULT_MAIN_CONFIG = { 28 | 'service': 'spotify', 29 | 'spotify': '', 30 | 'deezer': '', 31 | 'fallback': False, 32 | 'spotifyQuality': 'NORMAL', 33 | 'deezerQuality': 'MP3_128', 34 | 'realTime': False, 35 | 'customDirFormat': '%ar_album%/%album%', 36 | 'customTrackFormat': '%tracknum%. %music%', 37 | 'tracknum_padding': True, 38 | 'maxConcurrentDownloads': 3, 39 | 'maxRetries': 3, 40 | 'retryDelaySeconds': 5, 41 | 'retry_delay_increase': 5 42 | } 43 | 44 | def get_config_params(): 45 | """ 46 | Get configuration parameters from the config file. 47 | Creates the file with defaults if it doesn't exist. 48 | Ensures all default keys are present in the loaded config. 49 | 50 | Returns: 51 | dict: A dictionary containing configuration parameters 52 | """ 53 | try: 54 | # Ensure ./data/config directory exists 55 | CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) 56 | 57 | if not CONFIG_FILE_PATH.exists(): 58 | logger.info(f"{CONFIG_FILE_PATH} not found. Creating with default values.") 59 | with open(CONFIG_FILE_PATH, 'w') as f: 60 | json.dump(DEFAULT_MAIN_CONFIG, f, indent=4) 61 | return DEFAULT_MAIN_CONFIG.copy() # Return a copy of defaults 62 | 63 | with open(CONFIG_FILE_PATH, 'r') as f: 64 | config = json.load(f) 65 | 66 | # Ensure all default keys are present in the loaded config 67 | updated = False 68 | for key, value in DEFAULT_MAIN_CONFIG.items(): 69 | if key not in config: 70 | config[key] = value 71 | updated = True 72 | 73 | if updated: 74 | logger.info(f"Configuration at {CONFIG_FILE_PATH} was missing some default keys. Updated with defaults.") 75 | with open(CONFIG_FILE_PATH, 'w') as f: 76 | json.dump(config, f, indent=4) 77 | 78 | return config 79 | except Exception as e: 80 | logger.error(f"Error reading or creating config at {CONFIG_FILE_PATH}: {e}", exc_info=True) 81 | # Return defaults if config read/create fails 82 | return DEFAULT_MAIN_CONFIG.copy() 83 | 84 | # Load configuration values we need for Celery 85 | config_params_values = get_config_params() # Renamed to avoid conflict with module name 86 | MAX_CONCURRENT_DL = config_params_values.get('maxConcurrentDownloads', 3) 87 | MAX_RETRIES = config_params_values.get('maxRetries', 3) 88 | RETRY_DELAY = config_params_values.get('retryDelaySeconds', 5) 89 | RETRY_DELAY_INCREASE = config_params_values.get('retry_delay_increase', 5) 90 | 91 | # Define task queues 92 | task_queues = { 93 | 'default': { 94 | 'exchange': 'default', 95 | 'routing_key': 'default', 96 | }, 97 | 'downloads': { 98 | 'exchange': 'downloads', 99 | 'routing_key': 'downloads', 100 | } 101 | } 102 | 103 | # Set default queue 104 | task_default_queue = 'downloads' 105 | task_default_exchange = 'downloads' 106 | task_default_routing_key = 'downloads' 107 | 108 | # Celery task settings 109 | task_serializer = 'json' 110 | accept_content = ['json'] 111 | result_serializer = 'json' 112 | enable_utc = True 113 | 114 | # Configure worker concurrency based on MAX_CONCURRENT_DL 115 | worker_concurrency = MAX_CONCURRENT_DL 116 | 117 | # Configure task rate limiting - these are per-minute limits 118 | task_annotations = { 119 | 'routes.utils.celery_tasks.download_track': { 120 | 'rate_limit': f'{MAX_CONCURRENT_DL}/m', 121 | }, 122 | 'routes.utils.celery_tasks.download_album': { 123 | 'rate_limit': f'{MAX_CONCURRENT_DL}/m', 124 | }, 125 | 'routes.utils.celery_tasks.download_playlist': { 126 | 'rate_limit': f'{MAX_CONCURRENT_DL}/m', 127 | } 128 | } 129 | 130 | # Configure retry settings 131 | task_default_retry_delay = RETRY_DELAY # seconds 132 | task_max_retries = MAX_RETRIES 133 | 134 | # Task result settings 135 | task_track_started = True 136 | result_expires = 60 * 60 * 24 * 7 # 7 days 137 | 138 | # Configure visibility timeout for task messages 139 | broker_transport_options = { 140 | 'visibility_timeout': 3600, # 1 hour 141 | 'fanout_prefix': True, 142 | 'fanout_patterns': True, 143 | 'priority_steps': [0, 3, 6, 9], 144 | } 145 | 146 | # Important broker connection settings 147 | broker_connection_retry = True 148 | broker_connection_retry_on_startup = True 149 | broker_connection_max_retries = 10 150 | broker_pool_limit = 10 151 | worker_prefetch_multiplier = 1 # Process one task at a time per worker 152 | worker_max_tasks_per_child = 100 # Restart worker after 100 tasks 153 | worker_disable_rate_limits = False -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 = `Details`; 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 | }); -------------------------------------------------------------------------------- /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 = `Home`; 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 = `Download`; 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 = `Download`; 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 = `Download`; 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 = `Download`; 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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |
28 | 29 |
30 | ... (track details: name, artist, album, duration, explicit) 31 |
32 | 33 |
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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 |
43 |
Loading...
44 |
45 | 46 |
47 | 48 | 49 | 50 | History 51 | 52 | 55 | 56 | 57 | Watchlist 58 | 59 | 60 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /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 |
48 |
Loading...
49 |
50 | 51 |
52 | 53 | 54 | 55 | History 56 | 57 | 60 | 61 | 62 | Watchlist 63 | 64 | 65 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /static/html/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xoconoch/spotizerr/f5f293ece714863819d8ff4786c190fabd1fac14/static/html/favicon.ico -------------------------------------------------------------------------------- /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 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
NameArtistTypeStatusDate AddedDate Completed/EndedDetails
59 | 70 |
71 | 72 | 73 | 74 | Home 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /static/html/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Spotizerr 7 | 8 | 9 | 10 | 11 | 12 | 18 | 19 | 20 |
21 |
22 |
23 | 29 | 35 |
36 | 37 | 41 |
42 | 43 | 44 |
45 | 46 | 47 |
48 |
49 | Music 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 | History 64 | 65 | 66 | Settings 67 | 68 | 69 | 70 | Watchlist 71 | 72 | 73 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /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 |
55 |
Loading...
56 |
57 | 58 |
59 | 60 | 61 | 62 | History 63 | 64 | 67 | 68 | 69 | Watchlist 70 | 71 | 72 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /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 |
42 |
Loading...
43 |
44 | 45 |
46 | 47 | 48 | 49 | History 50 | 51 | 54 | 55 | 56 | Watchlist 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 |
22 |

Watched Artists & Playlists

23 | 26 |
27 | 28 |
29 | 30 |
31 | 32 | 35 | 36 | 43 |
44 | 45 | 46 | 47 | History 48 | 49 | 50 | Home 51 | 52 | 53 | 54 | Watchlist 55 | 56 | 57 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /static/images/album.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/images/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/binoculars.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | binoculars-filled 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /static/images/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /static/images/eye-crossed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/images/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/history.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/images/missing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ic_fluent_missing_metadata_24_filled 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /static/images/music.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/images/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xoconoch/spotizerr/f5f293ece714863819d8ff4786c190fabd1fac14/static/images/placeholder.jpg -------------------------------------------------------------------------------- /static/images/plus-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/queue-empty.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /static/images/queue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/images/refresh-cw.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/images/skull-head.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/images/view.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 | } --------------------------------------------------------------------------------