Merge pull request #310 from spotizerr-dev/dev

Dev
This commit is contained in:
Spotizerr
2025-08-23 12:52:01 -06:00
committed by GitHub
50 changed files with 1534 additions and 905 deletions

View File

@@ -1,62 +1,36 @@
# Git
.git
.gitignore
.gitattributes
# Allowlist minimal build context
*
# Docker
docker-compose.yaml
docker-compose.yml
Dockerfile
.dockerignore
# Backend
!requirements.txt
!app.py
!routes/**
# Re-ignore caches and compiled files inside routes
routes/**/__pycache__/
routes/**/.pytest_cache/
routes/**/*.pyc
routes/**/*.pyo
# Node
node_modules
spotizerr-ui/node_modules
npm-debug.log
pnpm-lock.yaml
# Frontend: only what's needed to build
!spotizerr-ui/package.json
!spotizerr-ui/pnpm-lock.yaml
!spotizerr-ui/pnpm-workspace.yaml
!spotizerr-ui/index.html
!spotizerr-ui/vite.config.ts
!spotizerr-ui/postcss.config.mjs
!spotizerr-ui/tsconfig.json
!spotizerr-ui/tsconfig.app.json
!spotizerr-ui/tsconfig.node.json
!spotizerr-ui/src/**
!spotizerr-ui/public/**
!spotizerr-ui/scripts/**
# Exclude heavy/unnecessary frontend folders
spotizerr-ui/node_modules/**
spotizerr-ui/dist/**
spotizerr-ui/dev-dist/**
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.env
.venv
venv/
env/
.env.example
# Editor/OS
.vscode
.idea
.DS_Store
*.swp
# Application data
credentials.json
test.py
downloads/
creds/
Test.py
prgs/
flask_server.log
test.sh
routes/__pycache__/*
routes/utils/__pycache__/*
search_test.py
config/main.json
.cache
config/state/queue_state.json
output.log
queue_state.json
search_demo.py
celery_worker.log
static/js/*
# Always exclude local data/logs/tests/etc.
.data/
logs/
data
Downloads/
tests/
# Non-essential files
docs/
README.md

View File

@@ -1,9 +1,9 @@
###
### Main configuration file of the server. If you
### plan to have this only for personal use, you
### Main configuration file of the server. If you
### plan to have this only for personal use, you
### can leave the defaults as they are.
###
### If you plan on using for a server,
### If you plan on using for a server,
### see [insert docs url]
###
@@ -19,13 +19,7 @@ REDIS_PASSWORD=CHANGE_ME
# Set to true to filter out explicit content.
EXPLICIT_FILTER=false
# User and group ID for the container. Sets the owner of the downloaded files.
PUID=1000
PGID=1000
# Optional: Sets the default file permissions for newly created files within the container.
# Optional: Sets the default file permissions for newly created files within the container.
UMASK=0022
# Whether to setup file permissions on startup. May improve performance on remote/slow filesystems
@@ -51,7 +45,7 @@ DEFAULT_ADMIN_PASSWORD=admin123
# Whether to allow new users to register themselves or leave that only available for admins
DISABLE_REGISTRATION=false
# SSO Configuration
# SSO Configuration
SSO_ENABLED=true
SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback
FRONTEND_URL=http://127.0.0.1:7171

11
.readthedocs.yaml Normal file
View File

@@ -0,0 +1,11 @@
version: 2
mkdocs:
configuration: mkdocs.yml
build:
os: ubuntu-22.04
tools:
python: "3.11"
jobs:
post_install:
- pip install --upgrade pip
- pip install mkdocs mkdocs-material

View File

@@ -7,40 +7,71 @@ RUN pnpm install --frozen-lockfile
COPY spotizerr-ui/. .
RUN pnpm build
# Stage 2: Final application image
FROM python:3.12-slim
# Stage 2: Python dependencies builder (create relocatable deps dir)
FROM python:3.11-slim AS py-deps
WORKDIR /app
COPY requirements.txt .
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
RUN uv pip install --target /python -r requirements.txt
# Set an environment variable for non-interactive frontend installation
ENV DEBIAN_FRONTEND=noninteractive
# Stage 3: Fetch static ffmpeg/ffprobe binaries
FROM debian:stable-slim AS ffmpeg
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils jq \
&& rm -rf /var/lib/apt/lists/*
RUN set -euo pipefail; \
case "$TARGETARCH" in \
amd64) ARCH_SUFFIX=linux64 ;; \
arm64) ARCH_SUFFIX=linuxarm64 ;; \
*) echo "Unsupported arch: $TARGETARCH" && exit 1 ;; \
esac; \
ASSET_URL=$(curl -fsSL https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest \
| jq -r ".assets[] | select(.name | endswith(\"${ARCH_SUFFIX}-gpl.tar.xz\")) | .browser_download_url" \
| head -n1); \
if [ -z "$ASSET_URL" ]; then \
echo "Failed to resolve FFmpeg asset for arch ${ARCH_SUFFIX}" && exit 1; \
fi; \
echo "Fetching FFmpeg from: $ASSET_URL"; \
curl -fsSL -o /tmp/ffmpeg.tar.xz "$ASSET_URL"; \
tar -xJf /tmp/ffmpeg.tar.xz -C /tmp; \
mv /tmp/ffmpeg-* /ffmpeg
# Stage 4: Prepare world-writable runtime directories
FROM busybox:1.36.1-musl AS runtime-dirs
RUN mkdir -p /artifact/downloads /artifact/data/config /artifact/data/creds /artifact/data/watch /artifact/data/history /artifact/logs/tasks \
&& touch /artifact/.cache \
&& chmod -R 0777 /artifact
# Stage 5: Final application image (distroless)
FROM gcr.io/distroless/python3-debian12
LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr"
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg gosu\
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Ensure Python finds vendored site-packages and unbuffered output
ENV PYTHONPATH=/python
ENV PYTHONUNBUFFERED=1
ENV PYTHONUTF8=1
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
# Install Python dependencies
COPY requirements.txt .
# Copy application code
COPY --chown=65532:65532 . .
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
RUN uv pip install --system -r requirements.txt
# Copy compiled assets from the frontend build
COPY --from=frontend-builder --chown=65532:65532 /app/spotizerr-ui/dist ./spotizerr-ui/dist
# Copy application code (excluding UI source and TS source)
COPY . .
# Copy vendored Python dependencies
COPY --from=py-deps --chown=65532:65532 /python /python
# Copy compiled assets from previous stages
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist
# Copy static ffmpeg binaries
COPY --from=ffmpeg --chown=65532:65532 /ffmpeg/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg --chown=65532:65532 /ffmpeg/bin/ffprobe /usr/local/bin/ffprobe
# Create necessary directories with proper permissions
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
chmod -R 777 downloads data logs
# Copy pre-created world-writable runtime directories
COPY --from=runtime-dirs --chown=65532:65532 /artifact/ ./
# Make entrypoint script executable
RUN chmod +x entrypoint.sh
# Set entrypoint to our script
ENTRYPOINT ["/app/entrypoint.sh"]
# No shell or package manager available in distroless
ENTRYPOINT ["python3", "app.py"]

151
README.md
View File

@@ -27,157 +27,6 @@ If you self-host a music server with other users than yourself, you almost certa
<img width="1588" height="994" alt="image" src="https://github.com/user-attachments/assets/e34d7dbb-29e3-4d75-bcbd-0cee03fa57dc" />
</details>
## ✨ Key Features
### 🎵 **Granular download support**
- **Individual Tracks** - Download any single track
- **Complete Albums** - Download entire albums with proper metadata
- **Full Playlists** - Download complete playlists (even massive ones with 1000+ tracks)
- **Artist Discographies** - Download an artist's complete catalog with filtering options
- **Spotify URL Support** - Paste any Spotify URL directly to queue downloads
### 📱 **Modern Web Interface**
- **Progressive Web App (PWA)** - Install as a native client on mobile/desktop (installation process may vary depending on the browser/device)
- **Multiple Themes** - Light, dark, and system themes
- **Touch-friendly** - Swipe gestures and mobile-optimized controls
### 🤖 **Intelligent Monitoring**
- **Playlist Watching** - Automatically download new tracks added to Spotify playlists
- **Artist Watching** - Monitor artists for new releases and download them automatically
- **Configurable Intervals** - Set how often to check for updates
- **Manual Triggers** - Force immediate checks when needed
### ⚡ **Advanced Queue Management**
- **Concurrent Downloads** - Configure multiple simultaneous downloads
- **Real-time Updates** - Live progress updates via Server-Sent Events
- **Duplicate Prevention** - Automatically prevents duplicate downloads
- **Queue Persistence** - Downloads continue even after browser restart
- **Cancellation Support** - Cancel individual downloads or clear entire queue
### 🔧 **Extensive Configuration**
- **Quality Control** - Configure audio quality per service (limitations per account tier apply)
- **Format Options** - Convert to MP3, FLAC, AAC, OGG, OPUS, WAV, ALAC in various bitrates
- **Custom Naming** - Flexible file and folder naming patterns
- **Content Filtering** - Hide explicit content if desired
### 📊 **Comprehensive History**
- **Download Tracking** - Complete history of all downloads with metadata
- **Success Analytics** - Track success rates, failures, and skipped items
- **Search & Filter** - Find past downloads by title, artist, or status
- **Detailed Logs** - View individual track status for album/playlist downloads
- **Export Data** - Access complete metadata and external service IDs
### 👥 **Multi-User Support**
- **User Authentication** - Secure login system with JWT tokens
- **SSO Integration** - Single Sign-On with Google and GitHub
- **Admin Panel** - User management and system configuration
## 🚀 Quick Start
### Prerequisites
- Docker and Docker Compose
- Spotify account(s)
- Deezer account(s) (optional, but recommended)
- Spotify API credentials (Client ID & Secret from [Spotify Developer Dashboard](https://developer.spotify.com/dashboard))
### Installation
1. **Create project directory**
```bash
mkdir spotizerr && cd spotizerr
```
2. **Setup environment file**
```bash
# Download .env.example from the repository and create .env
# Update all variables (e.g. Redis credentials, PUID/PGID, UMASK)
```
3. **Copy docker-compose.yaml**
```bash
# Download docker-compose.yaml from the repository
```
4. **Start the application**
```bash
docker compose up -d
```
5. **Next steps**
- Before doing anything, it is recommended to go straight to [Configuration](#-configuration)
## 🔧 Configuration
### Service Accounts Setup
1. **Spotify setup**
- Spotify is very restrictive, so use the [spotizerr-auth](https://github.com/Xoconoch/spotizerr-auth) tool on a computer with the spotify client installed to simplify this part of the setup.
2. **Deezer setup (Optional but recommended for better stability, even if it's a free account)**
- Get your Deezer ARL token:
- **Chrome/Edge**: Open [Deezer](https://www.deezer.com/), press F12 → Application → Cookies → "https://www.deezer.com" → Copy "arl" value
- **Firefox**: Open [Deezer](https://www.deezer.com/), press F12 → Storage → Cookies → "https://www.deezer.com" → Copy "arl" value
- Add the ARL token in Settings → Accounts
3. **Configure Download Settings**
- Set audio quality preferences
- Configure output format and naming
- Adjust concurrent download limits
### Watch System Setup
1. **Enable Monitoring**
- Go to Settings → Watch
- Enable the watch system
- Set check intervals
2. **Add Items to Watch**
- Search for playlists or artists
- Click the "Watch" button
- New content will be automatically downloaded
## 📋 Usage Examples
### Download a Playlist
1. Search for the playlist or paste its Spotify URL
2. Click the download button
3. Monitor progress in the real-time queue
### Monitor an Artist
1. Search for the artist
2. Click "Add to Watchlist"
3. Configure which release types to monitor (albums, singles, etc.)
4. New releases will be automatically downloaded
### Bulk Download an Artist's Discography
1. Go to the artist page
2. Select release types (albums, singles, compilations)
3. Click "Download Discography"
4. All albums will be queued automatically
## 🔍 Advanced Features
### Custom File Naming
Configure how files and folders are named:
- `%artist%/%album%/%tracknum%. %title%`
- `%ar_album%/%album% (%year%)/%title%`
- Support for track numbers, artists, albums, years, and more
### Quality Settings
- **Spotify**: OGG 96k, 160k, and 320k (320k requires Premium)
- **Deezer**: MP3 128k, MP3 320k (sometimes requires Premium), and FLAC (Premium only)
- **Conversion**: Convert to any supported format with custom bitrate
### Fallback System
- Configure primary and fallback services
- Automatically switches if primary service fails
- Useful for geographic restrictions or account limits
### Real-time Mode
- **Spotify only**: Matches track length with download time for optimal timing
## 🆘 Support & Troubleshooting
### Common Issues
**Downloads not starting?**

107
app.py
View File

@@ -13,43 +13,14 @@ import redis
import socket
from urllib.parse import urlparse
# Run DB migrations as early as possible, before importing any routers that may touch DBs
try:
from routes.migrations import run_migrations_if_needed
run_migrations_if_needed()
logging.getLogger(__name__).info(
"Database migrations executed (if needed) early in startup."
)
except Exception as e:
logging.getLogger(__name__).error(
f"Database migration step failed early in startup: {e}", exc_info=True
)
sys.exit(1)
# Import route routers (to be created)
from routes.auth.credentials import router as credentials_router
from routes.auth.auth import router as auth_router
from routes.content.artist import router as artist_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.core.search import router as search_router
from routes.core.history import router as history_router
from routes.system.progress import router as prgs_router
from routes.system.config import router as config_router
# Import Celery configuration and manager
from routes.utils.celery_manager import celery_manager
from routes.utils.celery_config import REDIS_URL
# Import authentication system
from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware
# Import watch manager controls (start/stop) without triggering side effects
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
# Apply process umask from environment as early as possible
_umask_value = os.getenv("UMASK")
if _umask_value:
try:
os.umask(int(_umask_value, 8))
except Exception:
# Defer logging setup; avoid failing on invalid UMASK
pass
# Import and initialize routes (this will start the watch manager)
@@ -61,6 +32,17 @@ def setup_logging():
logs_dir = Path("logs")
logs_dir.mkdir(exist_ok=True)
# Ensure required runtime directories exist
for p in [
Path("downloads"),
Path("data/config"),
Path("data/creds"),
Path("data/watch"),
Path("data/history"),
Path("logs/tasks"),
]:
p.mkdir(parents=True, exist_ok=True)
# Set up log file paths
main_log = logs_dir / "spotizerr.log"
@@ -111,6 +93,8 @@ def setup_logging():
def check_redis_connection():
"""Check if Redis is available and accessible"""
from routes.utils.celery_config import REDIS_URL
if not REDIS_URL:
logging.error("REDIS_URL is not configured. Please check your environment.")
return False
@@ -156,6 +140,20 @@ async def lifespan(app: FastAPI):
# Startup
setup_logging()
# Run migrations before initializing services
try:
from routes.migrations import run_migrations_if_needed
run_migrations_if_needed()
logging.getLogger(__name__).info(
"Database migrations executed (if needed) early in startup."
)
except Exception as e:
logging.getLogger(__name__).error(
f"Database migration step failed early in startup: {e}", exc_info=True
)
sys.exit(1)
# Check Redis connection
if not check_redis_connection():
logging.error(
@@ -165,6 +163,8 @@ async def lifespan(app: FastAPI):
# Start Celery workers
try:
from routes.utils.celery_manager import celery_manager
celery_manager.start()
logging.info("Celery workers started successfully")
except Exception as e:
@@ -172,6 +172,8 @@ async def lifespan(app: FastAPI):
# Start Watch Manager after Celery is up
try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
start_watch_manager()
logging.info("Watch Manager initialized and registered for shutdown.")
except Exception as e:
@@ -184,12 +186,16 @@ async def lifespan(app: FastAPI):
# Shutdown
try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
stop_watch_manager()
logging.info("Watch Manager stopped")
except Exception as e:
logging.error(f"Error stopping Watch Manager: {e}")
try:
from routes.utils.celery_manager import celery_manager
celery_manager.stop()
logging.info("Celery workers stopped")
except Exception as e:
@@ -215,13 +221,30 @@ def create_app():
)
# Add authentication middleware (only if auth is enabled)
if AUTH_ENABLED:
app.add_middleware(AuthMiddleware)
logging.info("Authentication system enabled")
else:
logging.info("Authentication system disabled")
try:
from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware
if AUTH_ENABLED:
app.add_middleware(AuthMiddleware)
logging.info("Authentication system enabled")
else:
logging.info("Authentication system disabled")
except Exception as e:
logging.warning(f"Auth system initialization failed or unavailable: {e}")
# Register routers with URL prefixes
from routes.auth.auth import router as auth_router
from routes.system.config import router as config_router
from routes.core.search import router as search_router
from routes.auth.credentials import router as credentials_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.content.artist import router as artist_router
from routes.system.progress import router as prgs_router
from routes.core.history import router as history_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Include SSO router if available

View File

@@ -1,16 +1,24 @@
# HEY, YOU! READ THE DOCS BEFORE YOU DO ANYTHING!
# https://spotizerr.rtfd.io
name: spotizerr
services:
spotizerr:
image: cooldockerizer93/spotizerr
user: "1000:1000" # Spotizerr user:group ids
volumes:
- ./data:/app/data
- ./downloads:/app/downloads
- ./logs:/app/logs
# Ensure these directories and the .cache file exist and are writable by the container user
- ./data:/app/data # data directory, contains config, creds, watch, history
- ./downloads:/app/downloads # downloads directory, contains downloaded files
- ./logs:/app/logs # logs directory, contains logs
- ./.cache:/app/.cache # cache file
ports:
# Port to expose the app on
- 7171:7171
container_name: spotizerr-app
restart: unless-stopped
env_file:
# Ensure you have a .env file in the root of the project, with the correct values
- .env
depends_on:
- redis

View File

@@ -129,7 +129,7 @@ Get SSO configuration and available providers.
#### `GET /auth/sso/login/google`
Redirect to Google OAuth.
#### `GET /auth/sso/login/github`
#### `GET /auth/sso/login/github`
Redirect to GitHub OAuth.
#### `GET /auth/sso/callback/google`
@@ -168,7 +168,7 @@ Get track metadata.
```json
{
"id": "string",
"name": "string",
"name": "string",
"artists": [{"name": "string"}],
"album": {"name": "string"},
"duration_ms": 180000,
@@ -196,6 +196,8 @@ Download an entire album.
Get album metadata.
**Query Parameters:**
- `id`: Spotify album ID
- `limit`: Tracks page size (optional)
- `offset`: Tracks page offset (optional)
### Playlist Downloads
@@ -216,6 +218,7 @@ Download an entire playlist.
Get playlist metadata.
**Query Parameters:**
- `id`: Spotify playlist ID
- `include_tracks`: true to include tracks (default: false)
#### `GET /playlist/metadata`
Get detailed playlist metadata including tracks.
@@ -244,14 +247,12 @@ Download artist's discography.
}
```
#### `GET /artist/download/cancel`
**Query Parameters:**
- `task_id`: Task ID to cancel
#### `GET /artist/info`
Get artist metadata.
**Query Parameters:**
- `id`: Spotify artist ID
- `limit`: Albums page size (default: 10, min: 1)
- `offset`: Albums page offset (default: 0, min: 0)
## 📺 Watch Functionality
@@ -371,11 +372,11 @@ Search Spotify content.
### Task Monitoring
#### `GET /prgs/list`
List all tasks with optional filtering.
List tasks with pagination.
**Query Parameters:**
- `status`: Filter by status (`pending`, `running`, `completed`, `failed`)
- `download_type`: Filter by type (`track`, `album`, `playlist`)
- `limit`: Results limit
- `page`: Page number (default: 1)
- `limit`: Items per page (default: 50, max: 100)
- `active_only`: If true, only return active tasks
#### `GET /prgs/{task_id}`
Get specific task details and progress.
@@ -383,7 +384,10 @@ Get specific task details and progress.
#### `GET /prgs/updates`
Get task updates since last check.
**Query Parameters:**
- `since`: Timestamp to get updates since
- `since`: Unix timestamp (required for delta updates). If omitted, returns a paginated snapshot.
- `page`: Page number for non-active tasks (default: 1)
- `limit`: Items per page for non-active tasks (default: 20, max: 100)
- `active_only`: If true, only return active tasks
#### `GET /prgs/stream`
**Server-Sent Events (SSE)** endpoint for real-time progress updates.
@@ -448,13 +452,13 @@ Get download statistics.
#### `GET /history/search`
Search download history.
**Query Parameters:**
- `q`: Search query
- `field`: Field to search (`name`, `artist`, `url`)
- `q`: Search query (required)
- `limit`: Max results (default: 50, max: 200)
#### `GET /history/recent`
Get recent downloads.
**Query Parameters:**
- `hours`: Hours to look back (default: 24)
- `limit`: Max results (default: 20, max: 100)
#### `GET /history/failed`
Get failed downloads.
@@ -464,8 +468,7 @@ Clean up old history entries.
**Request:**
```json
{
"older_than_days": 30,
"keep_failed": true
"days_old": 30
}
```
@@ -641,4 +644,4 @@ curl -X PUT "http://localhost:7171/api/playlist/watch/37i9dQZF1DXcBWIGoYBM5M" \
---
*This documentation covers all endpoints discovered in the Spotizerr routes directory. The API is designed for high-throughput music downloading with comprehensive monitoring and management capabilities.*
*This documentation covers all endpoints discovered in the Spotizerr routes directory. The API is designed for high-throughput music downloading with comprehensive monitoring and management capabilities.*

16
docs/index.md Normal file
View File

@@ -0,0 +1,16 @@
## Spotizerr Documentation
Start with Getting started, then explore the sections below.
- [Getting started](user/getting-started.md)
- [Configuration](user/configuration.md)
- [Environment](user/environment.md)
- [Tracks](user/tracks.md)
- [Albums](user/albums.md)
- [Playlists](user/playlists.md)
- [Artists](user/artists.md)
- [Watchlist](user/watchlist.md)
- [History](user/history.md)
- [Multi-user](user/multi-user.md)
For API details, see [API](api.md).

13
docs/user/albums.md Normal file
View File

@@ -0,0 +1,13 @@
## Albums
- Open from Search or an Artist page.
- Actions:
- Download full album or any track
- Browse tracklist (order, artists, duration)
- Large albums: tracks load in pages as you scroll
- Explicit filter hides explicit tracks when enabled in Config
Endpoints:
- GET `/api/album/info?id=...&limit=50&offset=...` — album metadata + paged tracks
- GET `/api/album/download/{album_id}` — queue album download
- GET `/api/prgs/stream` — live progress via SSE

26
docs/user/artists.md Normal file
View File

@@ -0,0 +1,26 @@
## Artists
- Open from Search.
- Discography sections: Albums, Singles, Compilations, Appears On (infinite scroll)
- Download:
- Download all (queues albums by selected types)
- Download any album individually
- Watch:
- Add/remove artist to Watchlist
- Configure release types and intervals in Configuration → Watch
How to monitor an artist:
1. Search the artist and open their page
2. Click Watch
3. Configure in Configuration → Watch
How to download discography:
1. Open the artist page
2. Select release types (Albums, Singles, Compilations)
3. Click Download All; track in Queue and History
Endpoints:
- GET `/api/artist/info?id=...&limit=10&offset=...` — metadata + paged albums
- GET `/api/artist/download/{artist_id}?album_type=album,single,compilation` — queue discography
- PUT `/api/artist/watch/{artist_id}` / DELETE `/api/artist/watch/{artist_id}`
- GET `/api/artist/watch/{artist_id}/status`

View File

@@ -0,0 +1,44 @@
## Configuration
See also: [Environment variables](environment.md)
Open Configuration in the web UI. Tabs:
- General (admin)
- App version, basic info
- Downloads (admin)
- Concurrent downloads, retry behavior
- Quality/format defaults and conversion
- Real-time mode: aligns download time with track length
- Formatting (admin)
- File/folder naming patterns (examples)
- `%artist%/%album%/%tracknum%. %title%`
- `%ar_album%/%album% (%year%)/%title%`
- Accounts (admin)
- Spotify: use `spotizerr-auth` to add credentials
- Deezer ARL (optional):
- Chrome/Edge: DevTools → Application → Cookies → https://www.deezer.com → copy `arl`
- Firefox: DevTools → Storage → Cookies → https://www.deezer.com → copy `arl`
- Paste ARL in Accounts
- Select main account when multiple exist
- Watch (admin)
- Enable/disable watch system
- Set check intervals
- Manually trigger checks (artists/playlists)
- Server (admin)
- System info and advanced settings
- Profile (all users when auth is enabled)
- Change password, view role and email
Quality formats (reference):
- Spotify: OGG 96k/160k/320k (320k requires Premium)
- Deezer: MP3 128k/320k (320k may require Premium), FLAC (Premium)
- Conversion: MP3/FLAC/AAC/OGG/OPUS/WAV/ALAC with custom bitrate
Fallback system:
- Configure primary and fallback services
- Automatically switches if primary fails (useful for geo/account limits)
Notes:
- Explicit content filter applies in pages (e.g., hides explicit tracks on album/playlist views)
- Watch system must be enabled before adding items

36
docs/user/environment.md Normal file
View File

@@ -0,0 +1,36 @@
## Environment variables
Location: project `.env`. Minimal reference for server admins.
### Core
- HOST: Interface to bind (default `0.0.0.0`)
- EXPLICIT_FILTER: Filter explicit content (`true|false`, default `false`)
### Redis
- REDIS_HOST: Hostname (default `redis`)
- REDIS_PORT: Port (default `6379`)
- REDIS_DB: Database index (default `0`)
- REDIS_PASSWORD: Password
### File ownership & permissions
- UMASK: Default permissions for new files (default `0022`)
- SKIP_SET_PERMISSIONS: Skip permission fix on startup (`true|false`, default `false`)
### Multi-user & auth
- ENABLE_AUTH: Enable authentication (`true|false`, default `false`)
- JWT_SECRET: Long random string for tokens (required if auth enabled)
- JWT_EXPIRATION_HOURS: Session duration in hours (default `720`)
- DEFAULT_ADMIN_USERNAME: Seed admin username (default `admin`)
- DEFAULT_ADMIN_PASSWORD: Seed admin password (change it!)
- DISABLE_REGISTRATION: Disable public signups (`true|false`, default `false`)
### SSO
- SSO_ENABLED: Enable SSO (`true|false`)
- SSO_BASE_REDIRECT_URI: Base backend callback (e.g. `http://127.0.0.1:7171/api/auth/sso/callback`)
- FRONTEND_URL: Public UI base (e.g. `http://127.0.0.1:7171`)
- GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
- GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
### Tips
- If running behind a reverse proxy, set `FRONTEND_URL` and `SSO_BASE_REDIRECT_URI` to public URLs.
- Change `DEFAULT_ADMIN_*` on first login or disable registration and create users from the admin panel.

View File

@@ -0,0 +1,90 @@
## Getting started
### Prerequisites
- Docker and Docker Compose
- Spotify account(s)
- Deezer account (optional, recommended for FLAC)
- Spotify API `client_id` and `client_secret` (from Spotify Developer Dashboard)
Quick start (Docker Compose):
```bash
mkdir spotizerr && cd spotizerr
mkdir -p data logs downloads && touch .cache
wget https://github.com/spotizerr-dev/spotizerr/blob/main/docker-compose.yaml
# Before running this last command, check your docker compose file first, it is well-documented.
docker compose up -d
```
### Initial setup
- Open the web UI (default: `http://localhost:7171`)
- Go to Configuration → Accounts
- Use `spotizerr-auth` to register Spotify credentials quickly
Spotify account setup with spotizerr-auth:
```bash
docker run --network=host --rm -it cooldockerizer93/spotizerr-auth
```
or, if docker doesn't work:
#### Alternative installers
<details>
<summary>Linux / macOS</summary>
```bash
python3 -m venv .venv && source .venv/bin/activate && pip install spotizerr-auth
```
</details>
<details>
<summary>Windows (PowerShell)</summary>
```powershell
python -m venv .venv; .venv\Scripts\Activate.ps1; pip install spotizerr-auth
```
</details>
<details>
<summary>Windows (cmd.exe)</summary>
```cmd
python -m venv .venv && .venv\Scripts\activate && pip install spotizerr-auth
```
</details>
Then run `spotizerr-auth`.
_Note: You will have to enable the virtual environment everytime you want to register a new account._
### Registering account
- Ensure Spotify client is opened before starting
- Enter Spotizerr URL (e.g., http://localhost:7171)
- Enter Spotify API `client_id` and `client_secret` if prompted (one-time)
- Name the account + region code (e.g., US)
- Transfer playback to the temporary device when asked
- Credentials are posted to Spotizerr automatically
### Next steps
- Add Deezer ARL in Configuration → Accounts (optional, allows for FLAC availability if premium)
- Adjust Download and Formatting options
- Enable Watch system if you want automatic downloads
### Troubleshooting (quick)
- Downloads not starting: verify service credentials and API keys
- Watch not working: enable in Configuration → Watch and set intervals
- Auth issues: ensure JWT secret and SSO creds (if used); try clearing browser cache
- Queue stalling: force-refresh the page (ctrl+F5)
### Logs
```bash
docker logs spotizerr
```
- Enable Watch system if you want auto-downloads

17
docs/user/history.md Normal file
View File

@@ -0,0 +1,17 @@
## History
See all downloads and their outcomes.
- Filters
- By type (track/album/playlist) and status (completed/failed/skipped/in_progress)
- Pagination for large histories
- Drill-down
- Open an entry to view child tracks for albums/playlists
- Re-queue failures from the UI
Backend endpoints used:
- GET `/api/history?download_type=&status=&limit=&offset=`
- GET `/api/history/{task_id}` (entry)
- GET `/api/history/{task_id}/children` (child tracks)
- GET `/api/history/stats`, `/api/history/recent`, `/api/history/failed` (summaries)

28
docs/user/multi-user.md Normal file
View File

@@ -0,0 +1,28 @@
## Multi-user
Authentication is optional. When enabled:
Login/Register
- Local accounts with username/password
- First registered user becomes admin
- Public registration can be disabled
SSO (optional)
- Google and GitHub when configured
Roles
- User: can search/download, manage their profile
- Admin: access to all Configuration tabs and user management
Admin actions
- Create/delete users, change roles
- Reset user passwords
Where to find it in the UI:
- User menu (top-right) → Profile settings
- Configuration → User Management (admin)

28
docs/user/playlists.md Normal file
View File

@@ -0,0 +1,28 @@
## Playlists
Open a playlist from search.
- Download
- Download entire playlist
- Download individual tracks
- Metadata and tracks
- Loads metadata first (fast, avoids rate limits)
- Tracks load in pages as you scroll
- Watch
- Add/remove playlist to Watchlist (auto-download new additions when enabled)
How-to: download a playlist
1. Search for the playlist or paste its Spotify URL
2. Click Download
3. Monitor progress in the Queue; results appear in History
Backend endpoints used:
- GET `/api/playlist/metadata?id=...` (metadata only)
- GET `/api/playlist/tracks?id=...&limit=50&offset=...` (paged tracks)
- GET `/api/playlist/info?id=...&include_tracks=true` (full info when needed)
- GET `/api/playlist/download/{playlist_id}` (queue download)
- PUT `/api/playlist/watch/{playlist_id}` (watch)
- DELETE `/api/playlist/watch/{playlist_id}` (unwatch)
- GET `/api/playlist/watch/{playlist_id}/status` (status)

17
docs/user/tracks.md Normal file
View File

@@ -0,0 +1,17 @@
## Tracks
Find a track via search or open a track page.
- Download
- Click Download on result card or track page
- Progress visible in the Queue drawer
- Open on Spotify
- From track page, open the Spotify link
- Details shown
- Artists, album, duration, popularity
Backend endpoints used:
- GET `/api/track/info?id=...` (metadata)
- GET `/api/track/download/{track_id}` (queue download)
- GET `/api/progress/stream` (live queue updates)

18
docs/user/watchlist.md Normal file
View File

@@ -0,0 +1,18 @@
## Watchlist
Enable the watch system in Configuration → Watch first.
- Add items
- From Artist or Playlist pages, click Watch
- What it does
- Periodically checks watched items
- Queues new releases (artists) and/or newly added tracks (playlists)
- Setup
- Enable watch system and set intervals in Configuration → Watch
- Trigger a manual check if you want immediate processing
Backend endpoints used:
- Artists: PUT/DELETE/GET status under `/api/artist/watch/*`
- Playlists: PUT/DELETE/GET status under `/api/playlist/watch/*`
- Manual triggers: POST `/api/artist/watch/trigger_check` and `/api/playlist/watch/trigger_check`

View File

@@ -1,91 +0,0 @@
#!/bin/bash
set -e
# Set umask if UMASK variable is provided
if [ -n "${UMASK}" ]; then
umask "${UMASK}"
fi
# Compose Redis URLs from base variables if not explicitly provided
if [ -z "${REDIS_URL}" ]; then
REDIS_HOST=${REDIS_HOST:-redis}
REDIS_PORT=${REDIS_PORT:-6379}
REDIS_DB=${REDIS_DB:-0}
if [ -n "${REDIS_PASSWORD}" ]; then
if [ -n "${REDIS_USERNAME}" ]; then
AUTH_PART="${REDIS_USERNAME}:${REDIS_PASSWORD}@"
else
AUTH_PART=":${REDIS_PASSWORD}@"
fi
else
AUTH_PART=""
fi
export REDIS_URL="redis://${AUTH_PART}${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}"
fi
if [ -z "${REDIS_BACKEND}" ]; then
export REDIS_BACKEND="${REDIS_URL}"
fi
# Redis is now in a separate container so we don't need to start it locally
echo "Using Redis at ${REDIS_URL}"
# Check if both PUID and PGID are not set
if [ -z "${PUID}" ] && [ -z "${PGID}" ]; then
# Run as root directly
echo "Running as root user (no PUID/PGID specified)"
exec python app.py
else
# Verify both PUID and PGID are set
if [ -z "${PUID}" ] || [ -z "${PGID}" ]; then
echo "ERROR: Must supply both PUID and PGID or neither"
exit 1
fi
# Check for root user request
if [ "${PUID}" -eq 0 ] && [ "${PGID}" -eq 0 ]; then
echo "Running as root user (PUID/PGID=0)"
exec python app.py
else
# Check if the group with the specified GID already exists
if getent group "${PGID}" >/dev/null; then
# If the group exists, use its name instead of creating a new one
GROUP_NAME=$(getent group "${PGID}" | cut -d: -f1)
echo "Using existing group: ${GROUP_NAME} (GID: ${PGID})"
else
# If the group doesn't exist, create it
GROUP_NAME="appgroup"
groupadd -g "${PGID}" "${GROUP_NAME}"
echo "Created group: ${GROUP_NAME} (GID: ${PGID})"
fi
# Check if the user with the specified UID already exists
if getent passwd "${PUID}" >/dev/null; then
# If the user exists, use its name instead of creating a new one
USER_NAME=$(getent passwd "${PUID}" | cut -d: -f1)
echo "Using existing user: ${USER_NAME} (UID: ${PUID})"
else
# If the user doesn't exist, create it
USER_NAME="appuser"
useradd -u "${PUID}" -g "${GROUP_NAME}" -d /app "${USER_NAME}"
echo "Created user: ${USER_NAME} (UID: ${PUID})"
fi
# Ensure proper permissions for all app directories unless skipped via env var
if [ "${SKIP_SET_PERMISSIONS}" = "true" ] || [ "${SKIP_SET_PERMISSIONS}" = "1" ]; then
echo "SKIP_SET_PERMISSIONS is set; skipping permissions for /app/downloads /app/data /app/logs"
else
echo "Setting permissions for /app directories..."
chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/data /app/logs || true
fi
# Ensure Spotipy cache file exists and is writable (fast, local to container)
touch /app/.cache || true
chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true
# Run as specified user
echo "Starting application as ${USER_NAME}..."
exec gosu "${USER_NAME}" python app.py
fi
fi

30
mkdocs.yml Normal file
View File

@@ -0,0 +1,30 @@
site_name: Spotizerr Documentation
site_description: Straight-to-the-point docs for Spotizerr
site_dir: site
use_directory_urls: true
theme:
name: material
features:
- navigation.instant
- navigation.tracking
- content.action.edit
- search.suggest
- search.highlight
nav:
- Getting started: user/getting-started
- Configuration: user/configuration
- Environment: user/environment
- Tracks: user/tracks
- Albums: user/albums
- Playlists: user/playlists
- Artists: user/artists
- Watchlist: user/watchlist
- History: user/history
- Multi-user: user/multi-user
- API: api
markdown_extensions:
- toc:
permalink: true
- admonition
- tables
- fenced_code

View File

@@ -1,9 +1,11 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
celery==5.5.3
deezspot-spotizerr==2.7.3
deezspot-spotizerr==2.7.6
httpx==0.28.1
bcrypt==4.2.1
PyJWT==2.10.1
python-multipart==0.0.17
fastapi-sso==0.18.0
redis==5.0.7
async-timeout==4.0.3

View File

@@ -23,6 +23,22 @@ router = APIRouter()
init_credentials_db()
def _set_active_account_if_empty(service: str, name: str):
"""
Sets the newly created account as the active account in the main config
if no active account is currently set for the given service.
"""
try:
from routes.utils.celery_config import get_config_params as get_main_config_params
from routes.system.config import save_config
config = get_main_config_params()
if not config.get(service):
config[service] = name
save_config(config)
except Exception as e:
logger.warning(f"Could not set new {service.capitalize()} account '{name}' as active: {e}")
@router.get("/spotify_api_config")
@router.put("/spotify_api_config")
async def handle_spotify_api_config(request: Request, current_user: User = Depends(require_admin_from_state)):
@@ -130,18 +146,7 @@ async def handle_create_credential(service: str, name: str, request: Request, cu
# Validation is handled within create_credential utility function
result = create_credential(service, name, data)
# set as active Spotify account if none is set
if service == "spotify":
try:
from routes.utils.celery_config import get_config_params as get_main_config_params
from routes.system.config import save_config
config = get_main_config_params()
# The field is likely "spotify" (as used in frontend)
if not config.get("spotify"):
config["spotify"] = name
save_config(config)
except Exception as e:
logger.warning(f"Could not set new Spotify account '{name}' as active: {e}")
_set_active_account_if_empty(service, name)
return {
"message": f"Credential for '{name}' ({service}) created successfully.",

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Optional
from .v3_2_0 import MigrationV3_2_0
from .v3_2_1 import log_noop_migration_detected
logger = logging.getLogger(__name__)
@@ -285,7 +286,6 @@ def _update_watch_playlists_db(conn: sqlite3.Connection) -> None:
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({table_name})",
)
logger.info("Upgraded watch playlists DB to 3.2.0 base schema")
except Exception:
logger.error(
"Failed to upgrade watch playlists DB to 3.2.0 base schema", exc_info=True
@@ -348,7 +348,6 @@ def _update_watch_artists_db(conn: sqlite3.Connection) -> None:
EXPECTED_ARTIST_ALBUMS_COLUMNS,
f"artist albums ({table_name})",
)
logger.info("Upgraded watch artists DB to 3.2.0 base schema")
except Exception:
logger.error(
"Failed to upgrade watch artists DB to 3.2.0 base schema", exc_info=True
@@ -379,10 +378,10 @@ def run_migrations_if_needed():
with _safe_connect(HISTORY_DB) as history_conn:
if history_conn and not _is_history_at_least_3_2_0(history_conn):
logger.error(
"Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.2.1."
"Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.3.0."
)
raise RuntimeError(
"Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.2.1."
"Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.3.0."
)
# Watch playlists DB
@@ -413,4 +412,5 @@ def run_migrations_if_needed():
raise
else:
_ensure_creds_filesystem()
logger.info("Database migrations check completed (3.2.0 -> 3.2.1 path)")
log_noop_migration_detected()
logger.info("Database migrations check completed (3.2.0 -> 3.3.0 path)")

View File

@@ -6,7 +6,7 @@ logger = logging.getLogger(__name__)
class MigrationV3_2_0:
"""
Migration for version 3.2.0 (upgrade path 3.2.0 -> 3.2.1).
Migration for version 3.2.0 (upgrade path 3.2.0 -> 3.3.0).
- Adds per-item batch progress columns to Watch DBs to support page-by-interval processing.
- Enforces prerequisite: previous instance version must be 3.1.2 (validated by runner).
"""
@@ -21,7 +21,7 @@ class MigrationV3_2_0:
"batch_next_offset": "INTEGER DEFAULT 0",
}
# --- No-op for history/accounts in 3.2.1 ---
# --- No-op for history/accounts in 3.3.0 ---
def check_history(self, conn: sqlite3.Connection) -> bool:
return True
@@ -59,14 +59,14 @@ class MigrationV3_2_0:
f"ALTER TABLE watched_playlists ADD COLUMN {col_name} {col_type}"
)
logger.info(
f"Added column '{col_name} {col_type}' to watched_playlists for 3.2.1 batch progress."
f"Added column '{col_name} {col_type}' to watched_playlists for 3.3.0 batch progress."
)
except sqlite3.OperationalError as e:
logger.warning(
f"Could not add column '{col_name}' to watched_playlists: {e}"
)
except Exception:
logger.error("Failed to update watched_playlists for 3.2.1", exc_info=True)
logger.error("Failed to update watched_playlists for 3.3.0", exc_info=True)
# --- Watch: artists ---
@@ -90,11 +90,11 @@ class MigrationV3_2_0:
f"ALTER TABLE watched_artists ADD COLUMN {col_name} {col_type}"
)
logger.info(
f"Added column '{col_name} {col_type}' to watched_artists for 3.2.1 batch progress."
f"Added column '{col_name} {col_type}' to watched_artists for 3.3.0 batch progress."
)
except sqlite3.OperationalError as e:
logger.warning(
f"Could not add column '{col_name}' to watched_artists: {e}"
)
except Exception:
logger.error("Failed to update watched_artists for 3.2.1", exc_info=True)
logger.error("Failed to update watched_artists for 3.3.0", exc_info=True)

View File

@@ -0,0 +1,41 @@
import logging
import sqlite3
logger = logging.getLogger(__name__)
class MigrationV3_2_1:
"""
No-op migration for version 3.2.1 (upgrade path 3.2.1 -> 3.3.0).
No database schema changes are required.
"""
def check_history(self, conn: sqlite3.Connection) -> bool:
return True
def update_history(self, conn: sqlite3.Connection) -> None:
pass
def check_accounts(self, conn: sqlite3.Connection) -> bool:
return True
def update_accounts(self, conn: sqlite3.Connection) -> None:
pass
def check_watch_playlists(self, conn: sqlite3.Connection) -> bool:
return True
def update_watch_playlists(self, conn: sqlite3.Connection) -> None:
pass
def check_watch_artists(self, conn: sqlite3.Connection) -> bool:
return True
def update_watch_artists(self, conn: sqlite3.Connection) -> None:
pass
def log_noop_migration_detected() -> None:
logger.info(
"No migration performed: detected schema for 3.2.1; no changes needed for 3.2.1 -> 3.3.0."
)

File diff suppressed because it is too large Load Diff

View File

@@ -101,7 +101,7 @@ def download_album(
)
dl.download_albumspo(
link_album=url, # Spotify URL
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality, # Deezer quality
recursive_quality=recursive_quality,
recursive_download=False,
@@ -159,7 +159,7 @@ def download_album(
)
spo.download_album(
link_album=url, # Spotify URL
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=fall_quality, # Spotify quality
recursive_quality=recursive_quality,
recursive_download=False,
@@ -216,7 +216,7 @@ def download_album(
)
spo.download_album(
link_album=url,
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality,
recursive_quality=recursive_quality,
recursive_download=False,
@@ -260,7 +260,7 @@ def download_album(
)
dl.download_albumdee( # Deezer URL, download via Deezer
link_album=url,
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality,
recursive_quality=recursive_quality,
recursive_download=False,

View File

@@ -28,6 +28,7 @@ CONFIG_FILE_PATH = Path("./data/config/main.json")
DEFAULT_MAIN_CONFIG = {
"service": "spotify",
"version": "3.3.0",
"spotify": "",
"deezer": "",
"fallback": False,

View File

@@ -2,6 +2,7 @@ import subprocess
import logging
import time
import threading
import sys
# Import Celery task utilities
from .celery_config import get_config_params, MAX_CONCURRENT_DL
@@ -46,6 +47,8 @@ class CeleryManager:
# %h is replaced by celery with the actual hostname.
hostname = f"worker_{worker_name_suffix}@%h"
command = [
sys.executable,
"-m",
"celery",
"-A",
self.app_name,
@@ -73,11 +76,14 @@ class CeleryManager:
log_method = logger.info # Default log method
if error: # This is a stderr stream
if " - ERROR - " in line_stripped or " - CRITICAL - " in line_stripped:
if (
" - ERROR - " in line_stripped
or " - CRITICAL - " in line_stripped
):
log_method = logger.error
elif " - WARNING - " in line_stripped:
log_method = logger.warning
log_method(f"{log_prefix}: {line_stripped}")
elif (
self.stop_event.is_set()
@@ -151,7 +157,7 @@ class CeleryManager:
queues="utility_tasks,default", # Listen to utility and default
concurrency=5, # Increased concurrency for SSE updates and utility tasks
worker_name_suffix="utw", # Utility Worker
log_level="ERROR" # Reduce log verbosity for utility worker (only errors)
log_level="ERROR", # Reduce log verbosity for utility worker (only errors)
)
logger.info(
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"

View File

@@ -98,7 +98,7 @@ def download_playlist(
)
dl.download_playlistspo(
link_playlist=url, # Spotify URL
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality, # Deezer quality
recursive_quality=recursive_quality,
recursive_download=False,
@@ -161,7 +161,7 @@ def download_playlist(
)
spo.download_playlist(
link_playlist=url, # Spotify URL
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=fall_quality, # Spotify quality
recursive_quality=recursive_quality,
recursive_download=False,
@@ -224,7 +224,7 @@ def download_playlist(
)
spo.download_playlist(
link_playlist=url,
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality,
recursive_quality=recursive_quality,
recursive_download=False,
@@ -268,7 +268,7 @@ def download_playlist(
)
dl.download_playlistdee( # Deezer URL, download via Deezer
link_playlist=url,
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality,
recursive_quality=recursive_quality, # Usually False for playlists to get individual track qualities
recursive_download=False,

View File

@@ -94,7 +94,7 @@ def download_track(
# download_trackspo means: Spotify URL, download via Deezer
dl.download_trackspo(
link_track=url, # Spotify URL
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality, # Deezer quality
recursive_quality=recursive_quality,
recursive_download=False,
@@ -153,7 +153,7 @@ def download_track(
)
spo.download_track(
link_track=url, # Spotify URL
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=fall_quality, # Spotify quality
recursive_quality=recursive_quality,
recursive_download=False,
@@ -169,7 +169,7 @@ def download_track(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
spotify_metadata=spotify_metadata,
pad_number_width=pad_number_width,
)
print(
@@ -211,7 +211,7 @@ def download_track(
)
spo.download_track(
link_track=url,
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality,
recursive_quality=recursive_quality,
recursive_download=False,
@@ -254,7 +254,7 @@ def download_track(
)
dl.download_trackdee( # Deezer URL, download via Deezer
link_track=url,
output_dir="./downloads",
output_dir="/app/downloads",
quality_download=quality,
recursive_quality=recursive_quality,
recursive_download=False,

View File

@@ -1098,7 +1098,7 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
# Get configuration settings
output_dir = (
"./downloads" # This matches the output_dir used in download functions
"/app/downloads" # This matches the output_dir used in download functions
)
# Get all tracks for the playlist
@@ -1125,14 +1125,14 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
skipped_missing_final_path = 0
for track in tracks:
# Use final_path from deezspot summary and convert from ./downloads to ../ relative path
# Use final_path from deezspot summary and convert from /app/downloads to ../ relative path
final_path = track.get("final_path")
if not final_path:
skipped_missing_final_path += 1
continue
normalized = str(final_path).replace("\\", "/")
if normalized.startswith("./downloads/"):
relative_path = normalized.replace("./downloads/", "../", 1)
if normalized.startswith("/app/downloads/"):
relative_path = normalized.replace("/app/downloads/", "../", 1)
elif "/downloads/" in normalized.lower():
idx = normalized.lower().rfind("/downloads/")
relative_path = "../" + normalized[idx + len("/downloads/") :]

View File

@@ -1,7 +1,7 @@
{
"name": "spotizerr-ui",
"private": true,
"version": "3.2.1",
"version": "3.3.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,4 +1,7 @@
import { Link } from "@tanstack/react-router";
import { useContext, useEffect } from "react";
import { toast } from "sonner";
import { QueueContext, getStatus } from "../contexts/queue-context";
import type { AlbumType } from "../types/spotify";
interface AlbumCardProps {
@@ -7,6 +10,19 @@ interface AlbumCardProps {
}
export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
const context = useContext(QueueContext);
if (!context) throw new Error("useQueue must be used within a QueueProvider");
const { items } = context;
const queueItem = items.find(item => item.downloadType === "album" && item.spotifyId === album.id);
const status = queueItem ? getStatus(queueItem) : null;
useEffect(() => {
if (status === "queued") {
toast.success(`${album.name} queued.`);
} else if (status === "error") {
toast.error(`Failed to queue ${album.name}`);
}
}, [status, album.name]);
const imageUrl = album.images && album.images.length > 0 ? album.images[0].url : "/placeholder.jpg";
const subtitle = album.artists.map((artist) => artist.name).join(", ");
@@ -21,10 +37,26 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
e.preventDefault();
onDownload();
}}
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
title="Download album"
disabled={!!status && status !== "error"}
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
title={
status
? status === "queued"
? "Album queued"
: status === "error"
? "Download album"
: "Downloading..."
: "Download album"
}
>
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
{status
? status === "queued"
? "Queued."
: status === "error"
? <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
: "Downloading..."
: <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
}
</button>
)}
</Link>

View File

@@ -1,4 +1,7 @@
import { Link } from "@tanstack/react-router";
import { useContext, useEffect } from "react";
import { toast } from "sonner";
import { QueueContext, getStatus } from "../contexts/queue-context";
interface SearchResultCardProps {
id: string;
@@ -10,6 +13,19 @@ interface SearchResultCardProps {
}
export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownload }: SearchResultCardProps) => {
const context = useContext(QueueContext);
if (!context) throw new Error("useQueue must be used within a QueueProvider");
const { items } = context;
const queueItem = items.find(item => item.downloadType === type && item.spotifyId === id);
const status = queueItem ? getStatus(queueItem) : null;
useEffect(() => {
if (status === "queued") {
toast.success(`${name} queued.`);
} else if (status === "error") {
toast.error(`Failed to queue ${name}`);
}
}, [status]);
const getLinkPath = () => {
switch (type) {
case "track":
@@ -32,10 +48,26 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
{onDownload && (
<button
onClick={onDownload}
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 z-10"
title={`Download ${type}`}
disabled={!!status && status !== "error"}
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-100 sm:opacity-0 sm:group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
title={
status
? status === "queued"
? `${name} queued`
: status === "error"
? `Download ${type}`
: "Downloading..."
: `Download ${type}`
}
>
<img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
{status
? status === "queued"
? "Queued."
: status === "error"
? <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
: "Downloading..."
: <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
}
</button>
)}
</div>

View File

@@ -85,7 +85,7 @@ export function AccountsTab() {
onSuccess: () => {
toast.success("Account added successfully!");
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify account in UI
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify/Deezer account in UI
setIsAdding(false);
setSubmitError(null);
reset();

View File

@@ -53,7 +53,7 @@ const CONVERSION_FORMATS: Record<string, string[]> = {
// --- API Functions ---
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
const payload: any = { ...data };
const payload: Partial<DownloadSettings> = { ...data };
const { data: response } = await authApiClient.client.post("/config", payload);
return response;
};
@@ -72,7 +72,6 @@ const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credenti
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>("");
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
// Fetch watch config
const { data: watchConfig } = useQuery({
@@ -89,7 +88,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
});
const { data: deezerCredentials } = useQuery({
queryKey: ["credentials", "deezer"],
queryKey: ["credentials", "deezer"],
queryFn: () => fetchCredentials("deezer"),
staleTime: 30000,
});
@@ -98,14 +97,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
mutationFn: saveDownloadConfig,
onSuccess: () => {
toast.success("Download settings saved successfully!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["config"] });
},
onError: (error) => {
console.error("Failed to save settings", error.message);
toast.error(`Failed to save settings: ${error.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
},
});
@@ -126,12 +122,12 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
// Validation effect for watch + download method requirement
useEffect(() => {
let error = "";
// Check watch requirements
if (watchConfig?.enabled && !realTime && !fallback) {
error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled.";
}
// Check fallback account requirements
if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
@@ -139,7 +135,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
if (!deezerCredentials?.length) missingServices.push("Deezer");
error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
}
setValidationError(error);
}, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]);
@@ -180,12 +176,6 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button
type="submit"
disabled={mutation.isPending || !!validationError}
@@ -248,7 +238,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
<p className="text-sm text-content-muted dark:text-content-muted-dark">
When enabled, downloads will be organized in user-specific subdirectories (downloads/username/...)
</p>
{/* Watch validation info */}
{watchConfig?.enabled && (
<div className="p-3 bg-info/10 border border-info/20 rounded-lg">
@@ -260,7 +250,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
</p>
</div>
)}
{/* Fallback account requirements info */}
{fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
@@ -272,7 +262,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
</p>
</div>
)}
{/* Validation error display */}
{validationError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useRef } from "react";
import { useForm, type SubmitHandler } from "react-hook-form";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
@@ -80,20 +80,16 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
const queryClient = useQueryClient();
const dirInputRef = useRef<HTMLInputElement | null>(null);
const trackInputRef = useRef<HTMLInputElement | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({
mutationFn: saveFormattingConfig,
onSuccess: () => {
toast.success("Formatting settings saved!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["config"] });
},
onError: (error) => {
console.error("Failed to save formatting settings:", error.message);
toast.error(`Failed to save settings: ${error.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
},
});
@@ -131,12 +127,6 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button
type="submit"
disabled={mutation.isPending}

View File

@@ -3,7 +3,7 @@ import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "../../contexts/settings-context";
import { useEffect, useState } from "react";
import { useEffect } from "react";
// --- Type Definitions ---
interface Credential {
@@ -56,20 +56,15 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
}
}, [config, reset]);
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({
mutationFn: saveGeneralConfig,
onSuccess: () => {
toast.success("General settings saved!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["config"] });
},
onError: (e: Error) => {
console.error("Failed to save general settings:", e.message);
toast.error(`Failed to save: ${e.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
},
});
@@ -84,12 +79,6 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button
type="submit"
disabled={mutation.isPending}
@@ -103,14 +92,18 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Service Defaults</h3>
<div className="flex flex-col gap-2">
<label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">Default Service</label>
<label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">
Default Service
</label>
<select
id="service"
{...register("service")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
>
<option value="spotify">Spotify</option>
<option value="deezer" disabled>Deezer (not yet...)</option>
<option value="deezer" disabled>
Deezer (not yet...)
</option>
</select>
</div>
</div>
@@ -118,7 +111,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify Settings</h3>
<div className="flex flex-col gap-2">
<label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">Active Spotify Account</label>
<label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">
Active Spotify Account
</label>
<select
id="spotifyAccount"
{...register("spotify")}
@@ -136,7 +131,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Deezer Settings</h3>
<div className="flex flex-col gap-2">
<label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">Active Deezer Account</label>
<label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">
Active Deezer Account
</label>
<select
id="deezerAccount"
{...register("deezer")}
@@ -159,7 +156,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}>
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
</span>
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">ENV</span>
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">
ENV
</span>
</div>
</div>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
@@ -46,20 +46,16 @@ function SpotifyApiForm() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig });
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({
mutationFn: saveSpotifyApiConfig,
onSuccess: () => {
toast.success("Spotify API settings saved!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
},
onError: (e) => {
console.error("Failed to save Spotify API settings:", e.message);
toast.error(`Failed to save: ${e.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
},
});
@@ -75,12 +71,6 @@ function SpotifyApiForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button
type="submit"
disabled={mutation.isPending}
@@ -120,20 +110,16 @@ function WebhookForm() {
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
const currentUrl = watch("url");
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({
mutationFn: saveWebhookConfig,
onSuccess: () => {
// No toast needed since the function shows one
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
},
onError: (e) => {
toast.error(`Failed to save: ${e.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
},
});
@@ -157,12 +143,6 @@ function WebhookForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button
type="submit"
disabled={mutation.isPending}

View File

@@ -58,7 +58,6 @@ const saveWatchConfig = async (data: Partial<WatchSettings>) => {
export function WatchTab() {
const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>("");
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const { data: config, isLoading } = useQuery({
queryKey: ["watchConfig"],
@@ -81,7 +80,7 @@ export function WatchTab() {
const { data: deezerCredentials } = useQuery({
queryKey: ["credentials", "deezer"],
queryFn: () => fetchCredentials("deezer"),
queryFn: () => fetchCredentials("deezer"),
staleTime: 30000,
});
@@ -89,15 +88,12 @@ export function WatchTab() {
mutationFn: saveWatchConfig,
onSuccess: () => {
toast.success("Watch settings saved successfully!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
},
onError: (error: any) => {
const message = error?.response?.data?.error || error?.message || "Unknown error";
toast.error(`Failed to save settings: ${message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
console.error("Failed to save watch settings:", message);
},
});
@@ -115,12 +111,12 @@ export function WatchTab() {
// Validation effect for watch + download method requirement
useEffect(() => {
let error = "";
// Check if watch can be enabled (need download methods)
if (watchEnabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
error = "To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.";
}
// Check fallback account requirements if watch is enabled and fallback is being used
if (watchEnabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
@@ -134,7 +130,7 @@ export function WatchTab() {
if (!error && (Number.isNaN(mir) || mir < 1 || mir > 50)) {
error = "Max items per run must be between 1 and 50.";
}
setValidationError(error);
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]);
@@ -180,12 +176,6 @@ export function WatchTab() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button
type="submit"
disabled={mutation.isPending || !!validationError}
@@ -202,40 +192,38 @@ export function WatchTab() {
<label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
</div>
{/* Download requirements info */}
{downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Download methods required
</p>
<p className="text-sm text-warning font-medium mb-1">Download methods required</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.
</p>
</div>
)}
{/* Fallback account requirements info */}
{downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Fallback accounts required
</p>
<p className="text-sm text-warning font-medium mb-1">Fallback accounts required</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.
</p>
</div>
)}
{/* Validation error display */}
{validationError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error font-medium">{validationError}</p>
</div>
)}
<div className="flex flex-col gap-2">
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">Watch Poll Interval (seconds)</label>
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">
Watch Poll Interval (seconds)
</label>
<input
id="watchPollIntervalSeconds"
type="number"
@@ -243,11 +231,15 @@ export function WatchTab() {
{...register("watchPollIntervalSeconds")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">How often to check for new items in watchlist.</p>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
How often to check for new items in watchlist.
</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">Max Items Per Run</label>
<label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">
Max Items Per Run
</label>
<input
id="maxItemsPerRun"
type="number"
@@ -256,13 +248,19 @@ export function WatchTab() {
{...register("maxItemsPerRun")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Batch size per watch cycle (150).</p>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
Batch size per watch cycle (150).
</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Artist Album Groups</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark">Select which album groups to monitor for watched artists.</p>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">
Artist Album Groups
</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark">
Select which album groups to monitor for watched artists.
</p>
<div className="grid grid-cols-2 gap-4 pt-2">
{ALBUM_GROUPS.map((group) => (
<Controller

View File

@@ -0,0 +1,47 @@
import React, { useEffect, useState } from "react";
import { Toaster as SonnerToaster } from "sonner";
import { getEffectiveTheme } from "@/lib/theme";
// Centralized Toaster wrapper so we can control defaults + theme.
// Tailwind dark mode relies on .dark on <html>. Sonner auto-detects, but we can also
// explicitly set className variants for better contrast. (as needed/commented out below)
export const Toaster: React.FC = () => {
const [theme, setTheme] = useState<"light" | "dark" | "system">(getEffectiveTheme());
useEffect(() => {
const update = () => setTheme(getEffectiveTheme());
window.addEventListener("app-theme-changed", update);
window.addEventListener("storage", (e) => {
if (e.key === "theme") update();
});
return () => {
window.removeEventListener("app-theme-changed", update);
};
}, []);
return (
<SonnerToaster
position="top-center"
theme={theme}
richColors
toastOptions={{
duration: 3000,
classNames: {
// toast:
// "bg-white dark:bg-surface-secondary-dark text-content-primary dark:text-content-primary-dark border border-line dark:border-border-dark shadow-md",
title: "font-medium",
description: "text-content-secondary dark:text-content-secondary-dark",
// success: "bg-success/10 dark:bg-success/20 text-success border-success/40",
// error: "bg-error/10 dark:bg-error/20 text-error border-error/40",
// warning: "bg-warning/10 dark:bg-warning/20 text-warning border-warning/40",
// info: "bg-info/10 dark:bg-info/20 text-info border-info/40",
closeButton:
"text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark",
actionButton: "bg-primary text-white hover:bg-primary-hover",
},
}}
/>
);
};
export default Toaster;

View File

@@ -1,71 +1,80 @@
// Theme management functions
export function getTheme(): 'light' | 'dark' | 'system' {
return (localStorage.getItem('theme') as 'light' | 'dark' | 'system') || 'system';
export function getTheme(): "light" | "dark" | "system" {
return (localStorage.getItem("theme") as "light" | "dark" | "system") || "system";
}
export function setTheme(theme: 'light' | 'dark' | 'system') {
localStorage.setItem('theme', theme);
export function setTheme(theme: "light" | "dark" | "system") {
localStorage.setItem("theme", theme);
applyTheme(theme);
dispatchThemeChange();
}
export function toggleTheme() {
const currentTheme = getTheme();
let nextTheme: 'light' | 'dark' | 'system';
let nextTheme: "light" | "dark" | "system";
switch (currentTheme) {
case 'light':
nextTheme = 'dark';
case "light":
nextTheme = "dark";
break;
case 'dark':
nextTheme = 'system';
case "dark":
nextTheme = "system";
break;
default:
nextTheme = 'light';
nextTheme = "light";
break;
}
setTheme(nextTheme);
return nextTheme;
}
function applyTheme(theme: 'light' | 'dark' | 'system') {
function applyTheme(theme: "light" | "dark" | "system") {
const root = document.documentElement;
if (theme === 'system') {
if (theme === "system") {
// Use system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
} else if (theme === 'dark') {
root.classList.add('dark');
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) root.classList.add("dark");
else root.classList.remove("dark");
} else if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove('dark');
root.classList.remove("dark");
}
}
function dispatchThemeChange() {
window.dispatchEvent(new CustomEvent("app-theme-changed"));
}
export function getEffectiveTheme(): "light" | "dark" {
const stored = getTheme();
if (stored === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return stored;
}
// Dark mode detection and setup
export function setupDarkMode() {
// First, ensure we start with a clean slate
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove("dark");
const savedTheme = getTheme();
applyTheme(savedTheme);
dispatchThemeChange();
// Listen for system theme changes (only when using system theme)
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
// Only respond to system changes when we're in system mode
if (getTheme() === 'system') {
if (e.matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
if (getTheme() === "system") {
if (e.matches) document.documentElement.classList.add("dark");
else document.documentElement.classList.remove("dark");
dispatchThemeChange();
}
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
}
mediaQuery.addEventListener("change", handleSystemThemeChange);
}

View File

@@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./router";
import { AuthProvider } from "./contexts/AuthProvider";
import { Toaster } from "./components/ui/Toaster";
import { setupDarkMode } from "./lib/theme";
import "./index.css";
@@ -23,6 +24,7 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<Toaster />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>

View File

@@ -1,7 +1,7 @@
import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client";
import { QueueContext } from "../contexts/queue-context";
import { QueueContext, getStatus } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context";
import type { AlbumType, TrackType } from "../types/spotify";
import { toast } from "sonner";
@@ -24,7 +24,19 @@ export const Album = () => {
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
const { addItem, items } = context;
// Queue status for this album
const albumQueueItem = items.find(item => item.downloadType === "album" && item.spotifyId === album?.id);
const albumStatus = albumQueueItem ? getStatus(albumQueueItem) : null;
useEffect(() => {
if (albumStatus === "queued") {
toast.success(`${album?.name} queued.`);
} else if (albumStatus === "error") {
toast.error(`Failed to queue ${album?.name}`);
}
}, [albumStatus]);
const totalTracks = album?.total_tracks ?? 0;
const hasMore = tracks.length < totalTracks;
@@ -174,13 +186,27 @@ export const Album = () => {
<div className="mt-4 md:mt-6">
<button
onClick={handleDownloadAlbum}
disabled={isExplicitFilterEnabled && hasExplicitTrack}
disabled={(isExplicitFilterEnabled && hasExplicitTrack) || (!!albumQueueItem && albumStatus !== "error")}
className="w-full px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed font-semibold shadow-sm"
title={
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
isExplicitFilterEnabled && hasExplicitTrack
? "Album contains explicit tracks"
: albumStatus
? albumStatus === "queued"
? "Album queued"
: albumStatus === "error"
? "Download Full Album"
: "Downloading..."
: "Download Full Album"
}
>
Download Album
{albumStatus
? albumStatus === "queued"
? "Queued."
: albumStatus === "error"
? "Download Album"
: "Downloading..."
: "Download Album"}
</button>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
import { toast } from "sonner";
import apiClient from "../lib/api-client";
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context";
import { QueueContext, getStatus } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context";
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
import { AlbumCard } from "../components/AlbumCard";
@@ -14,6 +14,7 @@ export const Artist = () => {
const [albums, setAlbums] = useState<AlbumType[]>([]);
const [topTracks, setTopTracks] = useState<TrackType[]>([]);
const [isWatched, setIsWatched] = useState(false);
const [artistStatus, setArtistStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const context = useContext(QueueContext);
const { settings } = useSettings();
@@ -30,7 +31,14 @@ export const Artist = () => {
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
const { addItem, items } = context;
// Track queue status mapping
const trackStatuses = topTracks.reduce((acc, t) => {
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === t.id);
acc[t.id] = qi ? getStatus(qi) : null;
return acc;
}, {} as Record<string, string | null>);
const applyFilters = useCallback(
(items: AlbumType[]) => {
@@ -194,6 +202,7 @@ export const Artist = () => {
};
const handleDownloadArtist = async () => {
setArtistStatus("downloading");
if (!artistId || !artist) return;
try {
@@ -203,13 +212,16 @@ export const Artist = () => {
const response = await apiClient.get(`/artist/download/${artistId}`);
if (response.data.queued_albums?.length > 0) {
setArtistStatus("queued");
toast.success(`${artist.name} discography queued successfully!`, {
description: `${response.data.queued_albums.length} albums added to queue.`,
});
} else {
setArtistStatus(null);
toast.info("No new albums to download for this artist.");
}
} catch (error: any) {
setArtistStatus("error");
console.error("Artist download failed:", error);
toast.error("Failed to download artist", {
description: error.response?.data?.error || "An unexpected error occurred.",
@@ -274,30 +286,52 @@ export const Artist = () => {
<div className="flex gap-4 justify-center mt-4">
<button
onClick={handleDownloadArtist}
className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
disabled={artistStatus === "downloading" || artistStatus === "queued"}
className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={
artistStatus === "downloading"
? "Downloading..."
: artistStatus === "queued"
? "Queued."
: "Download All"
}
>
<FaDownload className="icon-inverse" />
<span>Download All</span>
</button>
<button
onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
? "bg-button-primary text-button-primary-text border-primary"
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
}`}
>
{isWatched ? (
<>
<FaBookmark className="icon-inverse" />
<span>Watching</span>
</>
) : (
<>
<FaRegBookmark className="icon-primary" />
<span>Watch</span>
</>
)}
{artistStatus
? artistStatus === "queued"
? "Queued."
: artistStatus === "downloading"
? "Downloading..."
: <>
<FaDownload className="icon-inverse" />
<span>Download All</span>
</>
: <>
<FaDownload className="icon-inverse" />
<span>Download All</span>
</>
}
</button>
{settings?.watch?.enabled && (
<button
onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
? "bg-button-primary text-button-primary-text border-primary"
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
}`}
>
{isWatched ? (
<>
<FaBookmark className="icon-inverse" />
<span>Watching</span>
</>
) : (
<>
<FaRegBookmark className="icon-primary" />
<span>Watch</span>
</>
)}
</button>
)}
</div>
</div>
@@ -319,9 +353,16 @@ export const Artist = () => {
</Link>
<button
onClick={() => handleDownloadTrack(track)}
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded"
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"}
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Download
{trackStatuses[track.id]
? trackStatuses[track.id] === "queued"
? "Queued."
: trackStatuses[track.id] === "error"
? "Download"
: "Downloading..."
: "Download"}
</button>
</div>
))}

View File

@@ -166,18 +166,32 @@ export const History = () => {
cell: (info) => {
const entry = info.row.original;
const isChild = "album_title" in entry;
return isChild ? (
const historyEntry = entry as HistoryEntry;
const spotifyId = historyEntry.external_ids?.spotify;
const downloadType = historyEntry.download_type;
const titleContent = isChild ? (
<span className="pl-4 text-muted-foreground"> {entry.title}</span>
) : (
<div className="flex items-center gap-2">
<span className="font-semibold">{entry.title}</span>
{(entry as HistoryEntry).children_table && (
{historyEntry.children_table && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
{(entry as HistoryEntry).total_tracks || "?"} tracks
{historyEntry.total_tracks || "?"} tracks
</span>
)}
</div>
);
if (!isChild && spotifyId && downloadType) {
return (
<a href={`/${downloadType}/${spotifyId}`} className="hover:underline">
{titleContent}
</a>
);
}
return titleContent;
},
}),
columnHelper.accessor("artists", {

View File

@@ -4,7 +4,7 @@ import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner";
import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context";
import { QueueContext, getStatus } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa";
@@ -28,7 +28,21 @@ export const Playlist = () => {
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
const { addItem, items } = context;
// Playlist queue status
const playlistQueueItem = playlistMetadata
? items.find(item => item.downloadType === "playlist" && item.spotifyId === playlistMetadata.id)
: undefined;
const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null;
useEffect(() => {
if (playlistStatus === "queued") {
toast.success(`${playlistMetadata?.name} queued.`);
} else if (playlistStatus === "error") {
toast.error(`Failed to queue ${playlistMetadata?.name}`);
}
}, [playlistStatus]);
// Load playlist metadata first
useEffect(() => {
@@ -167,6 +181,14 @@ export const Playlist = () => {
return <div className="p-8 text-center">Loading playlist...</div>;
}
// Map track download statuses
const trackStatuses = tracks.reduce((acc, { track }) => {
if (!track) return acc;
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === track.id);
acc[track.id] = qi ? getStatus(qi) : null;
return acc;
}, {} as Record<string, string | null>);
const filteredTracks = tracks.filter(({ track }) => {
if (!track) return false;
if (settings?.explicitFilter && track.explicit) return false;
@@ -209,25 +231,34 @@ export const Playlist = () => {
<div className="mt-4 md:mt-6 flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
onClick={handleDownloadPlaylist}
className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm"
disabled={!!playlistQueueItem && playlistStatus !== "error"}
className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Download All
</button>
<button
onClick={handleToggleWatch}
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
isWatched
? "bg-error hover:bg-error-hover text-button-primary-text"
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
}`}
>
<img
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
alt="Watch status"
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
/>
{isWatched ? "Unwatch" : "Watch"}
{playlistStatus
? playlistStatus === "queued"
? "Queued."
: playlistStatus === "error"
? "Download All"
: "Downloading..."
: "Download All"}
</button>
{settings?.watch?.enabled && (
<button
onClick={handleToggleWatch}
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
isWatched
? "bg-error hover:bg-error-hover text-button-primary-text"
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
}`}
>
<img
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
alt="Watch status"
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
/>
{isWatched ? "Unwatch" : "Watch"}
</button>
)}
</div>
</div>
@@ -287,10 +318,26 @@ export const Playlist = () => {
</span>
<button
onClick={() => handleDownloadTrack(track)}
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all hover:scale-105 hover:shadow-sm"
title="Download"
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"}
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed"
title={
trackStatuses[track.id]
? trackStatuses[track.id] === "queued"
? "Queued."
: trackStatuses[track.id] === "error"
? "Download"
: "Downloading..."
: "Download"
}
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
{trackStatuses[track.id]
? trackStatuses[track.id] === "queued"
? "Queued."
: trackStatuses[track.id] === "error"
? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
: "Downloading..."
: <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
}
</button>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useContext } from "react";
import apiClient from "../lib/api-client";
import type { TrackType } from "../types/spotify";
import { toast } from "sonner";
import { QueueContext } from "../contexts/queue-context";
import { QueueContext, getStatus } from "../contexts/queue-context";
import { FaSpotify, FaArrowLeft } from "react-icons/fa";
// Helper to format milliseconds to mm:ss
@@ -22,7 +22,19 @@ export const Track = () => {
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
const { addItem, items } = context;
// Track queue status
const trackQueueItem = track ? items.find(item => item.downloadType === "track" && item.spotifyId === track.id) : undefined;
const trackStatus = trackQueueItem ? getStatus(trackQueueItem) : null;
useEffect(() => {
if (trackStatus === "queued") {
toast.success(`${track?.name} queued.`);
} else if (trackStatus === "error") {
toast.error(`Failed to queue ${track?.name}`);
}
}, [trackStatus]);
useEffect(() => {
const fetchTrack = async () => {
@@ -173,9 +185,16 @@ export const Track = () => {
<div className="flex flex-col sm:flex-row items-center gap-4">
<button
onClick={handleDownloadTrack}
className="w-full sm:w-auto bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-3 px-8 rounded-full transition duration-300 shadow-lg hover:shadow-xl"
disabled={!!trackQueueItem && trackStatus !== "error"}
className="w-full sm:w-auto bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-3 px-8 rounded-full transition duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
Download
{trackStatus
? trackStatus === "queued"
? "Queued."
: trackStatus === "error"
? "Download"
: "Downloading..."
: "Download"}
</button>
<a
href={track.external_urls.spotify}