Merge branch 'dev' into my-playlists-3

This commit is contained in:
Phlogi
2025-08-23 20:59:55 +02:00
committed by GitHub
37 changed files with 1113 additions and 658 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?**

75
app.py
View File

@@ -41,7 +41,17 @@ except Exception as e:
log_level_str = os.getenv("LOG_LEVEL", "WARNING").upper()
log_level = LOG_LEVELS.get(log_level_str, logging.INFO)
# Import route routers (to be created)
# 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)
from routes.auth.credentials import router as credentials_router
from routes.auth.auth import router as auth_router
from routes.content.album import router as album_router
@@ -66,7 +76,6 @@ 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
# Import and initialize routes (this will start the watch manager)
# Configure application-wide logging
@@ -76,6 +85,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"
@@ -131,6 +151,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
@@ -176,6 +198,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(
@@ -185,6 +221,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:
@@ -192,6 +230,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:
@@ -204,12 +244,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:
@@ -235,13 +279,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

@@ -3,6 +3,7 @@ import logging
import time
import threading
import os
import sys
# Import Celery task utilities
from .celery_config import get_config_params, MAX_CONCURRENT_DL
@@ -49,6 +50,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,
@@ -76,11 +79,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()
@@ -155,7 +161,8 @@ 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_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
log_level_env=os.getenv("LOG_LEVEL", "ERROR").upper(),
)
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

@@ -49,7 +49,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
<button
onClick={onDownload}
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 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
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"

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

@@ -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", {