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 # Allowlist minimal build context
.git *
.gitignore
.gitattributes
# Docker # Backend
docker-compose.yaml !requirements.txt
docker-compose.yml !app.py
Dockerfile !routes/**
.dockerignore # Re-ignore caches and compiled files inside routes
routes/**/__pycache__/
routes/**/.pytest_cache/
routes/**/*.pyc
routes/**/*.pyo
# Node # Frontend: only what's needed to build
node_modules !spotizerr-ui/package.json
spotizerr-ui/node_modules !spotizerr-ui/pnpm-lock.yaml
npm-debug.log !spotizerr-ui/pnpm-workspace.yaml
pnpm-lock.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 # Always exclude local data/logs/tests/etc.
__pycache__ .data/
*.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/*
logs/ logs/
data Downloads/
tests/ tests/
# Non-essential files
docs/
README.md

View File

@@ -1,9 +1,9 @@
### ###
### Main configuration file of the server. If you ### Main configuration file of the server. If you
### plan to have this only for personal use, you ### plan to have this only for personal use, you
### can leave the defaults as they are. ### 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] ### see [insert docs url]
### ###
@@ -19,13 +19,7 @@ REDIS_PASSWORD=CHANGE_ME
# Set to true to filter out explicit content. # Set to true to filter out explicit content.
EXPLICIT_FILTER=false EXPLICIT_FILTER=false
# Optional: Sets the default file permissions for newly created files within the container.
# 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.
UMASK=0022 UMASK=0022
# Whether to setup file permissions on startup. May improve performance on remote/slow filesystems # 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 # Whether to allow new users to register themselves or leave that only available for admins
DISABLE_REGISTRATION=false DISABLE_REGISTRATION=false
# SSO Configuration # SSO Configuration
SSO_ENABLED=true SSO_ENABLED=true
SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback
FRONTEND_URL=http://127.0.0.1:7171 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/. . COPY spotizerr-ui/. .
RUN pnpm build RUN pnpm build
# Stage 2: Final application image # Stage 2: Python dependencies builder (create relocatable deps dir)
FROM python:3.12-slim 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 # Stage 3: Fetch static ffmpeg/ffprobe binaries
ENV DEBIAN_FRONTEND=noninteractive 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" LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr"
WORKDIR /app WORKDIR /app
# Install system dependencies # Ensure Python finds vendored site-packages and unbuffered output
RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PYTHONPATH=/python
ffmpeg gosu\ ENV PYTHONUNBUFFERED=1
&& apt-get clean \ ENV PYTHONUTF8=1
&& rm -rf /var/lib/apt/lists/* ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
# Install Python dependencies # Copy application code
COPY requirements.txt . COPY --chown=65532:65532 . .
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ # Copy compiled assets from the frontend build
RUN uv pip install --system -r requirements.txt COPY --from=frontend-builder --chown=65532:65532 /app/spotizerr-ui/dist ./spotizerr-ui/dist
# Copy application code (excluding UI source and TS source) # Copy vendored Python dependencies
COPY . . COPY --from=py-deps --chown=65532:65532 /python /python
# Copy compiled assets from previous stages # Copy static ffmpeg binaries
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist 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 # Copy pre-created world-writable runtime directories
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \ COPY --from=runtime-dirs --chown=65532:65532 /artifact/ ./
chmod -R 777 downloads data logs
# Make entrypoint script executable # No shell or package manager available in distroless
RUN chmod +x entrypoint.sh ENTRYPOINT ["python3", "app.py"]
# Set entrypoint to our script
ENTRYPOINT ["/app/entrypoint.sh"]

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" /> <img width="1588" height="994" alt="image" src="https://github.com/user-attachments/assets/e34d7dbb-29e3-4d75-bcbd-0cee03fa57dc" />
</details> </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 ### Common Issues
**Downloads not starting?** **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_str = os.getenv("LOG_LEVEL", "WARNING").upper()
log_level = LOG_LEVELS.get(log_level_str, logging.INFO) 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.credentials import router as credentials_router
from routes.auth.auth import router as auth_router from routes.auth.auth import router as auth_router
from routes.content.album import router as album_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 # Import watch manager controls (start/stop) without triggering side effects
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager 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 # Configure application-wide logging
@@ -76,6 +85,17 @@ def setup_logging():
logs_dir = Path("logs") logs_dir = Path("logs")
logs_dir.mkdir(exist_ok=True) 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 # Set up log file paths
main_log = logs_dir / "spotizerr.log" main_log = logs_dir / "spotizerr.log"
@@ -131,6 +151,8 @@ def setup_logging():
def check_redis_connection(): def check_redis_connection():
"""Check if Redis is available and accessible""" """Check if Redis is available and accessible"""
from routes.utils.celery_config import REDIS_URL
if not REDIS_URL: if not REDIS_URL:
logging.error("REDIS_URL is not configured. Please check your environment.") logging.error("REDIS_URL is not configured. Please check your environment.")
return False return False
@@ -176,6 +198,20 @@ async def lifespan(app: FastAPI):
# Startup # Startup
setup_logging() 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 # Check Redis connection
if not check_redis_connection(): if not check_redis_connection():
logging.error( logging.error(
@@ -185,6 +221,8 @@ async def lifespan(app: FastAPI):
# Start Celery workers # Start Celery workers
try: try:
from routes.utils.celery_manager import celery_manager
celery_manager.start() celery_manager.start()
logging.info("Celery workers started successfully") logging.info("Celery workers started successfully")
except Exception as e: except Exception as e:
@@ -192,6 +230,8 @@ async def lifespan(app: FastAPI):
# Start Watch Manager after Celery is up # Start Watch Manager after Celery is up
try: try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
start_watch_manager() start_watch_manager()
logging.info("Watch Manager initialized and registered for shutdown.") logging.info("Watch Manager initialized and registered for shutdown.")
except Exception as e: except Exception as e:
@@ -204,12 +244,16 @@ async def lifespan(app: FastAPI):
# Shutdown # Shutdown
try: try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
stop_watch_manager() stop_watch_manager()
logging.info("Watch Manager stopped") logging.info("Watch Manager stopped")
except Exception as e: except Exception as e:
logging.error(f"Error stopping Watch Manager: {e}") logging.error(f"Error stopping Watch Manager: {e}")
try: try:
from routes.utils.celery_manager import celery_manager
celery_manager.stop() celery_manager.stop()
logging.info("Celery workers stopped") logging.info("Celery workers stopped")
except Exception as e: except Exception as e:
@@ -235,13 +279,30 @@ def create_app():
) )
# Add authentication middleware (only if auth is enabled) # Add authentication middleware (only if auth is enabled)
if AUTH_ENABLED: try:
app.add_middleware(AuthMiddleware) from routes.auth import AUTH_ENABLED
logging.info("Authentication system enabled") from routes.auth.middleware import AuthMiddleware
else:
logging.info("Authentication system disabled") 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 # 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"]) app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Include SSO router if available # 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 name: spotizerr
services: services:
spotizerr: spotizerr:
image: cooldockerizer93/spotizerr image: cooldockerizer93/spotizerr
user: "1000:1000" # Spotizerr user:group ids
volumes: volumes:
- ./data:/app/data # Ensure these directories and the .cache file exist and are writable by the container user
- ./downloads:/app/downloads - ./data:/app/data # data directory, contains config, creds, watch, history
- ./logs:/app/logs - ./downloads:/app/downloads # downloads directory, contains downloaded files
- ./logs:/app/logs # logs directory, contains logs
- ./.cache:/app/.cache # cache file
ports: ports:
# Port to expose the app on
- 7171:7171 - 7171:7171
container_name: spotizerr-app container_name: spotizerr-app
restart: unless-stopped restart: unless-stopped
env_file: env_file:
# Ensure you have a .env file in the root of the project, with the correct values
- .env - .env
depends_on: depends_on:
- redis - redis

View File

@@ -129,7 +129,7 @@ Get SSO configuration and available providers.
#### `GET /auth/sso/login/google` #### `GET /auth/sso/login/google`
Redirect to Google OAuth. Redirect to Google OAuth.
#### `GET /auth/sso/login/github` #### `GET /auth/sso/login/github`
Redirect to GitHub OAuth. Redirect to GitHub OAuth.
#### `GET /auth/sso/callback/google` #### `GET /auth/sso/callback/google`
@@ -168,7 +168,7 @@ Get track metadata.
```json ```json
{ {
"id": "string", "id": "string",
"name": "string", "name": "string",
"artists": [{"name": "string"}], "artists": [{"name": "string"}],
"album": {"name": "string"}, "album": {"name": "string"},
"duration_ms": 180000, "duration_ms": 180000,
@@ -196,6 +196,8 @@ Download an entire album.
Get album metadata. Get album metadata.
**Query Parameters:** **Query Parameters:**
- `id`: Spotify album ID - `id`: Spotify album ID
- `limit`: Tracks page size (optional)
- `offset`: Tracks page offset (optional)
### Playlist Downloads ### Playlist Downloads
@@ -216,6 +218,7 @@ Download an entire playlist.
Get playlist metadata. Get playlist metadata.
**Query Parameters:** **Query Parameters:**
- `id`: Spotify playlist ID - `id`: Spotify playlist ID
- `include_tracks`: true to include tracks (default: false)
#### `GET /playlist/metadata` #### `GET /playlist/metadata`
Get detailed playlist metadata including tracks. 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/info`
Get artist metadata. Get artist metadata.
**Query Parameters:** **Query Parameters:**
- `id`: Spotify artist ID - `id`: Spotify artist ID
- `limit`: Albums page size (default: 10, min: 1)
- `offset`: Albums page offset (default: 0, min: 0)
## 📺 Watch Functionality ## 📺 Watch Functionality
@@ -371,11 +372,11 @@ Search Spotify content.
### Task Monitoring ### Task Monitoring
#### `GET /prgs/list` #### `GET /prgs/list`
List all tasks with optional filtering. List tasks with pagination.
**Query Parameters:** **Query Parameters:**
- `status`: Filter by status (`pending`, `running`, `completed`, `failed`) - `page`: Page number (default: 1)
- `download_type`: Filter by type (`track`, `album`, `playlist`) - `limit`: Items per page (default: 50, max: 100)
- `limit`: Results limit - `active_only`: If true, only return active tasks
#### `GET /prgs/{task_id}` #### `GET /prgs/{task_id}`
Get specific task details and progress. Get specific task details and progress.
@@ -383,7 +384,10 @@ Get specific task details and progress.
#### `GET /prgs/updates` #### `GET /prgs/updates`
Get task updates since last check. Get task updates since last check.
**Query Parameters:** **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` #### `GET /prgs/stream`
**Server-Sent Events (SSE)** endpoint for real-time progress updates. **Server-Sent Events (SSE)** endpoint for real-time progress updates.
@@ -448,13 +452,13 @@ Get download statistics.
#### `GET /history/search` #### `GET /history/search`
Search download history. Search download history.
**Query Parameters:** **Query Parameters:**
- `q`: Search query - `q`: Search query (required)
- `field`: Field to search (`name`, `artist`, `url`) - `limit`: Max results (default: 50, max: 200)
#### `GET /history/recent` #### `GET /history/recent`
Get recent downloads. Get recent downloads.
**Query Parameters:** **Query Parameters:**
- `hours`: Hours to look back (default: 24) - `limit`: Max results (default: 20, max: 100)
#### `GET /history/failed` #### `GET /history/failed`
Get failed downloads. Get failed downloads.
@@ -464,8 +468,7 @@ Clean up old history entries.
**Request:** **Request:**
```json ```json
{ {
"older_than_days": 30, "days_old": 30
"keep_failed": true
} }
``` ```
@@ -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 fastapi==0.116.1
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
celery==5.5.3 celery==5.5.3
deezspot-spotizerr==2.7.3 deezspot-spotizerr==2.7.6
httpx==0.28.1 httpx==0.28.1
bcrypt==4.2.1 bcrypt==4.2.1
PyJWT==2.10.1 PyJWT==2.10.1
python-multipart==0.0.17 python-multipart==0.0.17
fastapi-sso==0.18.0 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() 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.get("/spotify_api_config")
@router.put("/spotify_api_config") @router.put("/spotify_api_config")
async def handle_spotify_api_config(request: Request, current_user: User = Depends(require_admin_from_state)): 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 # Validation is handled within create_credential utility function
result = create_credential(service, name, data) result = create_credential(service, name, data)
# set as active Spotify account if none is set _set_active_account_if_empty(service, name)
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}")
return { return {
"message": f"Credential for '{name}' ({service}) created successfully.", "message": f"Credential for '{name}' ({service}) created successfully.",

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from .v3_2_0 import MigrationV3_2_0 from .v3_2_0 import MigrationV3_2_0
from .v3_2_1 import log_noop_migration_detected
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -285,7 +286,6 @@ def _update_watch_playlists_db(conn: sqlite3.Connection) -> None:
EXPECTED_PLAYLIST_TRACKS_COLUMNS, EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({table_name})", f"playlist tracks ({table_name})",
) )
logger.info("Upgraded watch playlists DB to 3.2.0 base schema")
except Exception: except Exception:
logger.error( logger.error(
"Failed to upgrade watch playlists DB to 3.2.0 base schema", exc_info=True "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, EXPECTED_ARTIST_ALBUMS_COLUMNS,
f"artist albums ({table_name})", f"artist albums ({table_name})",
) )
logger.info("Upgraded watch artists DB to 3.2.0 base schema")
except Exception: except Exception:
logger.error( logger.error(
"Failed to upgrade watch artists DB to 3.2.0 base schema", exc_info=True "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: with _safe_connect(HISTORY_DB) as history_conn:
if history_conn and not _is_history_at_least_3_2_0(history_conn): if history_conn and not _is_history_at_least_3_2_0(history_conn):
logger.error( 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( 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 # Watch playlists DB
@@ -413,4 +412,5 @@ def run_migrations_if_needed():
raise raise
else: else:
_ensure_creds_filesystem() _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: 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. - 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). - 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", "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: def check_history(self, conn: sqlite3.Connection) -> bool:
return True return True
@@ -59,14 +59,14 @@ class MigrationV3_2_0:
f"ALTER TABLE watched_playlists ADD COLUMN {col_name} {col_type}" f"ALTER TABLE watched_playlists ADD COLUMN {col_name} {col_type}"
) )
logger.info( 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: except sqlite3.OperationalError as e:
logger.warning( logger.warning(
f"Could not add column '{col_name}' to watched_playlists: {e}" f"Could not add column '{col_name}' to watched_playlists: {e}"
) )
except Exception: 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 --- # --- Watch: artists ---
@@ -90,11 +90,11 @@ class MigrationV3_2_0:
f"ALTER TABLE watched_artists ADD COLUMN {col_name} {col_type}" f"ALTER TABLE watched_artists ADD COLUMN {col_name} {col_type}"
) )
logger.info( 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: except sqlite3.OperationalError as e:
logger.warning( logger.warning(
f"Could not add column '{col_name}' to watched_artists: {e}" f"Could not add column '{col_name}' to watched_artists: {e}"
) )
except Exception: 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( dl.download_albumspo(
link_album=url, # Spotify URL link_album=url, # Spotify URL
output_dir="./downloads", output_dir="/app/downloads",
quality_download=quality, # Deezer quality quality_download=quality, # Deezer quality
recursive_quality=recursive_quality, recursive_quality=recursive_quality,
recursive_download=False, recursive_download=False,
@@ -159,7 +159,7 @@ def download_album(
) )
spo.download_album( spo.download_album(
link_album=url, # Spotify URL link_album=url, # Spotify URL
output_dir="./downloads", output_dir="/app/downloads",
quality_download=fall_quality, # Spotify quality quality_download=fall_quality, # Spotify quality
recursive_quality=recursive_quality, recursive_quality=recursive_quality,
recursive_download=False, recursive_download=False,
@@ -216,7 +216,7 @@ def download_album(
) )
spo.download_album( spo.download_album(
link_album=url, link_album=url,
output_dir="./downloads", output_dir="/app/downloads",
quality_download=quality, quality_download=quality,
recursive_quality=recursive_quality, recursive_quality=recursive_quality,
recursive_download=False, recursive_download=False,
@@ -260,7 +260,7 @@ def download_album(
) )
dl.download_albumdee( # Deezer URL, download via Deezer dl.download_albumdee( # Deezer URL, download via Deezer
link_album=url, link_album=url,
output_dir="./downloads", output_dir="/app/downloads",
quality_download=quality, quality_download=quality,
recursive_quality=recursive_quality, recursive_quality=recursive_quality,
recursive_download=False, recursive_download=False,

View File

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

View File

@@ -3,6 +3,7 @@ import logging
import time import time
import threading import threading
import os import os
import sys
# Import Celery task utilities # Import Celery task utilities
from .celery_config import get_config_params, MAX_CONCURRENT_DL 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. # %h is replaced by celery with the actual hostname.
hostname = f"worker_{worker_name_suffix}@%h" hostname = f"worker_{worker_name_suffix}@%h"
command = [ command = [
sys.executable,
"-m",
"celery", "celery",
"-A", "-A",
self.app_name, self.app_name,
@@ -76,11 +79,14 @@ class CeleryManager:
log_method = logger.info # Default log method log_method = logger.info # Default log method
if error: # This is a stderr stream 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 log_method = logger.error
elif " - WARNING - " in line_stripped: elif " - WARNING - " in line_stripped:
log_method = logger.warning log_method = logger.warning
log_method(f"{log_prefix}: {line_stripped}") log_method(f"{log_prefix}: {line_stripped}")
elif ( elif (
self.stop_event.is_set() self.stop_event.is_set()
@@ -155,7 +161,8 @@ class CeleryManager:
queues="utility_tasks,default", # Listen to utility and default queues="utility_tasks,default", # Listen to utility and default
concurrency=5, # Increased concurrency for SSE updates and utility tasks concurrency=5, # Increased concurrency for SSE updates and utility tasks
worker_name_suffix="utw", # Utility Worker 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( logger.info(
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}" f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"

View File

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

View File

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

View File

@@ -1098,7 +1098,7 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
# Get configuration settings # Get configuration settings
output_dir = ( 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 # Get all tracks for the playlist
@@ -1125,14 +1125,14 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
skipped_missing_final_path = 0 skipped_missing_final_path = 0
for track in tracks: 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") final_path = track.get("final_path")
if not final_path: if not final_path:
skipped_missing_final_path += 1 skipped_missing_final_path += 1
continue continue
normalized = str(final_path).replace("\\", "/") normalized = str(final_path).replace("\\", "/")
if normalized.startswith("./downloads/"): if normalized.startswith("/app/downloads/"):
relative_path = normalized.replace("./downloads/", "../", 1) relative_path = normalized.replace("/app/downloads/", "../", 1)
elif "/downloads/" in normalized.lower(): elif "/downloads/" in normalized.lower():
idx = normalized.lower().rfind("/downloads/") idx = normalized.lower().rfind("/downloads/")
relative_path = "../" + normalized[idx + len("/downloads/") :] relative_path = "../" + normalized[idx + len("/downloads/") :]

View File

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

View File

@@ -49,7 +49,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
<button <button
onClick={onDownload} onClick={onDownload}
disabled={!!status && status !== "error"} 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={ title={
status status
? status === "queued" ? status === "queued"

View File

@@ -85,7 +85,7 @@ export function AccountsTab() {
onSuccess: () => { onSuccess: () => {
toast.success("Account added successfully!"); toast.success("Account added successfully!");
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] }); 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); setIsAdding(false);
setSubmitError(null); setSubmitError(null);
reset(); reset();

View File

@@ -166,18 +166,32 @@ export const History = () => {
cell: (info) => { cell: (info) => {
const entry = info.row.original; const entry = info.row.original;
const isChild = "album_title" in entry; 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> <span className="pl-4 text-muted-foreground"> {entry.title}</span>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold">{entry.title}</span> <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"> <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> </span>
)} )}
</div> </div>
); );
if (!isChild && spotifyId && downloadType) {
return (
<a href={`/${downloadType}/${spotifyId}`} className="hover:underline">
{titleContent}
</a>
);
}
return titleContent;
}, },
}), }),
columnHelper.accessor("artists", { columnHelper.accessor("artists", {