@@ -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
|
||||
|
||||
16
.env.example
16
.env.example
@@ -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
11
.readthedocs.yaml
Normal 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
|
||||
81
Dockerfile
81
Dockerfile
@@ -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
151
README.md
@@ -27,157 +27,6 @@ If you self-host a music server with other users than yourself, you almost certa
|
||||
<img width="1588" height="994" alt="image" src="https://github.com/user-attachments/assets/e34d7dbb-29e3-4d75-bcbd-0cee03fa57dc" />
|
||||
</details>
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 🎵 **Granular download support**
|
||||
- **Individual Tracks** - Download any single track
|
||||
- **Complete Albums** - Download entire albums with proper metadata
|
||||
- **Full Playlists** - Download complete playlists (even massive ones with 1000+ tracks)
|
||||
- **Artist Discographies** - Download an artist's complete catalog with filtering options
|
||||
- **Spotify URL Support** - Paste any Spotify URL directly to queue downloads
|
||||
|
||||
### 📱 **Modern Web Interface**
|
||||
- **Progressive Web App (PWA)** - Install as a native client on mobile/desktop (installation process may vary depending on the browser/device)
|
||||
- **Multiple Themes** - Light, dark, and system themes
|
||||
- **Touch-friendly** - Swipe gestures and mobile-optimized controls
|
||||
|
||||
### 🤖 **Intelligent Monitoring**
|
||||
- **Playlist Watching** - Automatically download new tracks added to Spotify playlists
|
||||
- **Artist Watching** - Monitor artists for new releases and download them automatically
|
||||
- **Configurable Intervals** - Set how often to check for updates
|
||||
- **Manual Triggers** - Force immediate checks when needed
|
||||
|
||||
### ⚡ **Advanced Queue Management**
|
||||
- **Concurrent Downloads** - Configure multiple simultaneous downloads
|
||||
- **Real-time Updates** - Live progress updates via Server-Sent Events
|
||||
- **Duplicate Prevention** - Automatically prevents duplicate downloads
|
||||
- **Queue Persistence** - Downloads continue even after browser restart
|
||||
- **Cancellation Support** - Cancel individual downloads or clear entire queue
|
||||
|
||||
### 🔧 **Extensive Configuration**
|
||||
- **Quality Control** - Configure audio quality per service (limitations per account tier apply)
|
||||
- **Format Options** - Convert to MP3, FLAC, AAC, OGG, OPUS, WAV, ALAC in various bitrates
|
||||
- **Custom Naming** - Flexible file and folder naming patterns
|
||||
- **Content Filtering** - Hide explicit content if desired
|
||||
|
||||
### 📊 **Comprehensive History**
|
||||
- **Download Tracking** - Complete history of all downloads with metadata
|
||||
- **Success Analytics** - Track success rates, failures, and skipped items
|
||||
- **Search & Filter** - Find past downloads by title, artist, or status
|
||||
- **Detailed Logs** - View individual track status for album/playlist downloads
|
||||
- **Export Data** - Access complete metadata and external service IDs
|
||||
|
||||
### 👥 **Multi-User Support**
|
||||
- **User Authentication** - Secure login system with JWT tokens
|
||||
- **SSO Integration** - Single Sign-On with Google and GitHub
|
||||
- **Admin Panel** - User management and system configuration
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Spotify account(s)
|
||||
- Deezer account(s) (optional, but recommended)
|
||||
- Spotify API credentials (Client ID & Secret from [Spotify Developer Dashboard](https://developer.spotify.com/dashboard))
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Create project directory**
|
||||
```bash
|
||||
mkdir spotizerr && cd spotizerr
|
||||
```
|
||||
|
||||
2. **Setup environment file**
|
||||
```bash
|
||||
# Download .env.example from the repository and create .env
|
||||
# Update all variables (e.g. Redis credentials, PUID/PGID, UMASK)
|
||||
```
|
||||
|
||||
3. **Copy docker-compose.yaml**
|
||||
```bash
|
||||
# Download docker-compose.yaml from the repository
|
||||
```
|
||||
|
||||
4. **Start the application**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Next steps**
|
||||
- Before doing anything, it is recommended to go straight to [Configuration](#-configuration)
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Service Accounts Setup
|
||||
|
||||
1. **Spotify setup**
|
||||
- Spotify is very restrictive, so use the [spotizerr-auth](https://github.com/Xoconoch/spotizerr-auth) tool on a computer with the spotify client installed to simplify this part of the setup.
|
||||
|
||||
2. **Deezer setup (Optional but recommended for better stability, even if it's a free account)**
|
||||
- Get your Deezer ARL token:
|
||||
- **Chrome/Edge**: Open [Deezer](https://www.deezer.com/), press F12 → Application → Cookies → "https://www.deezer.com" → Copy "arl" value
|
||||
- **Firefox**: Open [Deezer](https://www.deezer.com/), press F12 → Storage → Cookies → "https://www.deezer.com" → Copy "arl" value
|
||||
- Add the ARL token in Settings → Accounts
|
||||
|
||||
3. **Configure Download Settings**
|
||||
- Set audio quality preferences
|
||||
- Configure output format and naming
|
||||
- Adjust concurrent download limits
|
||||
|
||||
### Watch System Setup
|
||||
|
||||
1. **Enable Monitoring**
|
||||
- Go to Settings → Watch
|
||||
- Enable the watch system
|
||||
- Set check intervals
|
||||
|
||||
2. **Add Items to Watch**
|
||||
- Search for playlists or artists
|
||||
- Click the "Watch" button
|
||||
- New content will be automatically downloaded
|
||||
|
||||
## 📋 Usage Examples
|
||||
|
||||
### Download a Playlist
|
||||
1. Search for the playlist or paste its Spotify URL
|
||||
2. Click the download button
|
||||
3. Monitor progress in the real-time queue
|
||||
|
||||
### Monitor an Artist
|
||||
1. Search for the artist
|
||||
2. Click "Add to Watchlist"
|
||||
3. Configure which release types to monitor (albums, singles, etc.)
|
||||
4. New releases will be automatically downloaded
|
||||
|
||||
### Bulk Download an Artist's Discography
|
||||
1. Go to the artist page
|
||||
2. Select release types (albums, singles, compilations)
|
||||
3. Click "Download Discography"
|
||||
4. All albums will be queued automatically
|
||||
|
||||
## 🔍 Advanced Features
|
||||
|
||||
### Custom File Naming
|
||||
Configure how files and folders are named:
|
||||
- `%artist%/%album%/%tracknum%. %title%`
|
||||
- `%ar_album%/%album% (%year%)/%title%`
|
||||
- Support for track numbers, artists, albums, years, and more
|
||||
|
||||
### Quality Settings
|
||||
- **Spotify**: OGG 96k, 160k, and 320k (320k requires Premium)
|
||||
- **Deezer**: MP3 128k, MP3 320k (sometimes requires Premium), and FLAC (Premium only)
|
||||
- **Conversion**: Convert to any supported format with custom bitrate
|
||||
|
||||
### Fallback System
|
||||
- Configure primary and fallback services
|
||||
- Automatically switches if primary service fails
|
||||
- Useful for geographic restrictions or account limits
|
||||
|
||||
### Real-time Mode
|
||||
- **Spotify only**: Matches track length with download time for optimal timing
|
||||
|
||||
## 🆘 Support & Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Downloads not starting?**
|
||||
|
||||
107
app.py
107
app.py
@@ -13,43 +13,14 @@ import redis
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Run DB migrations as early as possible, before importing any routers that may touch DBs
|
||||
try:
|
||||
from routes.migrations import run_migrations_if_needed
|
||||
|
||||
run_migrations_if_needed()
|
||||
logging.getLogger(__name__).info(
|
||||
"Database migrations executed (if needed) early in startup."
|
||||
)
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(
|
||||
f"Database migration step failed early in startup: {e}", exc_info=True
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Import route routers (to be created)
|
||||
from routes.auth.credentials import router as credentials_router
|
||||
from routes.auth.auth import router as auth_router
|
||||
from routes.content.artist import router as artist_router
|
||||
from routes.content.album import router as album_router
|
||||
from routes.content.track import router as track_router
|
||||
from routes.content.playlist import router as playlist_router
|
||||
from routes.core.search import router as search_router
|
||||
from routes.core.history import router as history_router
|
||||
from routes.system.progress import router as prgs_router
|
||||
from routes.system.config import router as config_router
|
||||
|
||||
|
||||
# Import Celery configuration and manager
|
||||
from routes.utils.celery_manager import celery_manager
|
||||
from routes.utils.celery_config import REDIS_URL
|
||||
|
||||
# Import authentication system
|
||||
from routes.auth import AUTH_ENABLED
|
||||
from routes.auth.middleware import AuthMiddleware
|
||||
|
||||
# Import watch manager controls (start/stop) without triggering side effects
|
||||
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
|
||||
# Apply process umask from environment as early as possible
|
||||
_umask_value = os.getenv("UMASK")
|
||||
if _umask_value:
|
||||
try:
|
||||
os.umask(int(_umask_value, 8))
|
||||
except Exception:
|
||||
# Defer logging setup; avoid failing on invalid UMASK
|
||||
pass
|
||||
|
||||
# Import and initialize routes (this will start the watch manager)
|
||||
|
||||
@@ -61,6 +32,17 @@ def setup_logging():
|
||||
logs_dir = Path("logs")
|
||||
logs_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Ensure required runtime directories exist
|
||||
for p in [
|
||||
Path("downloads"),
|
||||
Path("data/config"),
|
||||
Path("data/creds"),
|
||||
Path("data/watch"),
|
||||
Path("data/history"),
|
||||
Path("logs/tasks"),
|
||||
]:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set up log file paths
|
||||
main_log = logs_dir / "spotizerr.log"
|
||||
|
||||
@@ -111,6 +93,8 @@ def setup_logging():
|
||||
|
||||
def check_redis_connection():
|
||||
"""Check if Redis is available and accessible"""
|
||||
from routes.utils.celery_config import REDIS_URL
|
||||
|
||||
if not REDIS_URL:
|
||||
logging.error("REDIS_URL is not configured. Please check your environment.")
|
||||
return False
|
||||
@@ -156,6 +140,20 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
setup_logging()
|
||||
|
||||
# Run migrations before initializing services
|
||||
try:
|
||||
from routes.migrations import run_migrations_if_needed
|
||||
|
||||
run_migrations_if_needed()
|
||||
logging.getLogger(__name__).info(
|
||||
"Database migrations executed (if needed) early in startup."
|
||||
)
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(
|
||||
f"Database migration step failed early in startup: {e}", exc_info=True
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Check Redis connection
|
||||
if not check_redis_connection():
|
||||
logging.error(
|
||||
@@ -165,6 +163,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Start Celery workers
|
||||
try:
|
||||
from routes.utils.celery_manager import celery_manager
|
||||
|
||||
celery_manager.start()
|
||||
logging.info("Celery workers started successfully")
|
||||
except Exception as e:
|
||||
@@ -172,6 +172,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Start Watch Manager after Celery is up
|
||||
try:
|
||||
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
|
||||
|
||||
start_watch_manager()
|
||||
logging.info("Watch Manager initialized and registered for shutdown.")
|
||||
except Exception as e:
|
||||
@@ -184,12 +186,16 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
try:
|
||||
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
|
||||
|
||||
stop_watch_manager()
|
||||
logging.info("Watch Manager stopped")
|
||||
except Exception as e:
|
||||
logging.error(f"Error stopping Watch Manager: {e}")
|
||||
|
||||
try:
|
||||
from routes.utils.celery_manager import celery_manager
|
||||
|
||||
celery_manager.stop()
|
||||
logging.info("Celery workers stopped")
|
||||
except Exception as e:
|
||||
@@ -215,13 +221,30 @@ def create_app():
|
||||
)
|
||||
|
||||
# Add authentication middleware (only if auth is enabled)
|
||||
if AUTH_ENABLED:
|
||||
app.add_middleware(AuthMiddleware)
|
||||
logging.info("Authentication system enabled")
|
||||
else:
|
||||
logging.info("Authentication system disabled")
|
||||
try:
|
||||
from routes.auth import AUTH_ENABLED
|
||||
from routes.auth.middleware import AuthMiddleware
|
||||
|
||||
if AUTH_ENABLED:
|
||||
app.add_middleware(AuthMiddleware)
|
||||
logging.info("Authentication system enabled")
|
||||
else:
|
||||
logging.info("Authentication system disabled")
|
||||
except Exception as e:
|
||||
logging.warning(f"Auth system initialization failed or unavailable: {e}")
|
||||
|
||||
# Register routers with URL prefixes
|
||||
from routes.auth.auth import router as auth_router
|
||||
from routes.system.config import router as config_router
|
||||
from routes.core.search import router as search_router
|
||||
from routes.auth.credentials import router as credentials_router
|
||||
from routes.content.album import router as album_router
|
||||
from routes.content.track import router as track_router
|
||||
from routes.content.playlist import router as playlist_router
|
||||
from routes.content.artist import router as artist_router
|
||||
from routes.system.progress import router as prgs_router
|
||||
from routes.core.history import router as history_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
|
||||
# Include SSO router if available
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
16
docs/index.md
Normal 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
13
docs/user/albums.md
Normal 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
26
docs/user/artists.md
Normal 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`
|
||||
44
docs/user/configuration.md
Normal file
44
docs/user/configuration.md
Normal 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
36
docs/user/environment.md
Normal 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.
|
||||
90
docs/user/getting-started.md
Normal file
90
docs/user/getting-started.md
Normal 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
17
docs/user/history.md
Normal 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
28
docs/user/multi-user.md
Normal 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
28
docs/user/playlists.md
Normal 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
17
docs/user/tracks.md
Normal 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
18
docs/user/watchlist.md
Normal 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`
|
||||
@@ -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
30
mkdocs.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
41
routes/migrations/v3_2_1.py
Normal file
41
routes/migrations/v3_2_1.py
Normal 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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import subprocess
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
import sys
|
||||
|
||||
# Import Celery task utilities
|
||||
from .celery_config import get_config_params, MAX_CONCURRENT_DL
|
||||
@@ -46,6 +47,8 @@ class CeleryManager:
|
||||
# %h is replaced by celery with the actual hostname.
|
||||
hostname = f"worker_{worker_name_suffix}@%h"
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
self.app_name,
|
||||
@@ -73,11 +76,14 @@ class CeleryManager:
|
||||
log_method = logger.info # Default log method
|
||||
|
||||
if error: # This is a stderr stream
|
||||
if " - ERROR - " in line_stripped or " - CRITICAL - " in line_stripped:
|
||||
if (
|
||||
" - ERROR - " in line_stripped
|
||||
or " - CRITICAL - " in line_stripped
|
||||
):
|
||||
log_method = logger.error
|
||||
elif " - WARNING - " in line_stripped:
|
||||
log_method = logger.warning
|
||||
|
||||
|
||||
log_method(f"{log_prefix}: {line_stripped}")
|
||||
elif (
|
||||
self.stop_event.is_set()
|
||||
@@ -151,7 +157,7 @@ class CeleryManager:
|
||||
queues="utility_tasks,default", # Listen to utility and default
|
||||
concurrency=5, # Increased concurrency for SSE updates and utility tasks
|
||||
worker_name_suffix="utw", # Utility Worker
|
||||
log_level="ERROR" # Reduce log verbosity for utility worker (only errors)
|
||||
log_level="ERROR", # Reduce log verbosity for utility worker (only errors)
|
||||
)
|
||||
logger.info(
|
||||
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/") :]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spotizerr-ui",
|
||||
"private": true,
|
||||
"version": "3.2.1",
|
||||
"version": "3.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||
import type { AlbumType } from "../types/spotify";
|
||||
|
||||
interface AlbumCardProps {
|
||||
@@ -7,6 +10,19 @@ interface AlbumCardProps {
|
||||
}
|
||||
|
||||
export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
|
||||
const context = useContext(QueueContext);
|
||||
if (!context) throw new Error("useQueue must be used within a QueueProvider");
|
||||
const { items } = context;
|
||||
const queueItem = items.find(item => item.downloadType === "album" && item.spotifyId === album.id);
|
||||
const status = queueItem ? getStatus(queueItem) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "queued") {
|
||||
toast.success(`${album.name} queued.`);
|
||||
} else if (status === "error") {
|
||||
toast.error(`Failed to queue ${album.name}`);
|
||||
}
|
||||
}, [status, album.name]);
|
||||
const imageUrl = album.images && album.images.length > 0 ? album.images[0].url : "/placeholder.jpg";
|
||||
const subtitle = album.artists.map((artist) => artist.name).join(", ");
|
||||
|
||||
@@ -21,10 +37,26 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
|
||||
e.preventDefault();
|
||||
onDownload();
|
||||
}}
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
title="Download album"
|
||||
disabled={!!status && status !== "error"}
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
status
|
||||
? status === "queued"
|
||||
? "Album queued"
|
||||
: status === "error"
|
||||
? "Download album"
|
||||
: "Downloading..."
|
||||
: "Download album"
|
||||
}
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
|
||||
{status
|
||||
? status === "queued"
|
||||
? "Queued."
|
||||
: status === "error"
|
||||
? <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
|
||||
: "Downloading..."
|
||||
: <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||
|
||||
interface SearchResultCardProps {
|
||||
id: string;
|
||||
@@ -10,6 +13,19 @@ interface SearchResultCardProps {
|
||||
}
|
||||
|
||||
export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownload }: SearchResultCardProps) => {
|
||||
const context = useContext(QueueContext);
|
||||
if (!context) throw new Error("useQueue must be used within a QueueProvider");
|
||||
const { items } = context;
|
||||
const queueItem = items.find(item => item.downloadType === type && item.spotifyId === id);
|
||||
const status = queueItem ? getStatus(queueItem) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "queued") {
|
||||
toast.success(`${name} queued.`);
|
||||
} else if (status === "error") {
|
||||
toast.error(`Failed to queue ${name}`);
|
||||
}
|
||||
}, [status]);
|
||||
const getLinkPath = () => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
@@ -32,10 +48,26 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 z-10"
|
||||
title={`Download ${type}`}
|
||||
disabled={!!status && status !== "error"}
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-100 sm:opacity-0 sm:group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
status
|
||||
? status === "queued"
|
||||
? `${name} queued`
|
||||
: status === "error"
|
||||
? `Download ${type}`
|
||||
: "Downloading..."
|
||||
: `Download ${type}`
|
||||
}
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
|
||||
{status
|
||||
? status === "queued"
|
||||
? "Queued."
|
||||
: status === "error"
|
||||
? <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
|
||||
: "Downloading..."
|
||||
: <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -53,7 +53,7 @@ const CONVERSION_FORMATS: Record<string, string[]> = {
|
||||
|
||||
// --- API Functions ---
|
||||
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
|
||||
const payload: any = { ...data };
|
||||
const payload: Partial<DownloadSettings> = { ...data };
|
||||
const { data: response } = await authApiClient.client.post("/config", payload);
|
||||
return response;
|
||||
};
|
||||
@@ -72,7 +72,6 @@ const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credenti
|
||||
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [validationError, setValidationError] = useState<string>("");
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
// Fetch watch config
|
||||
const { data: watchConfig } = useQuery({
|
||||
@@ -89,7 +88,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
});
|
||||
|
||||
const { data: deezerCredentials } = useQuery({
|
||||
queryKey: ["credentials", "deezer"],
|
||||
queryKey: ["credentials", "deezer"],
|
||||
queryFn: () => fetchCredentials("deezer"),
|
||||
staleTime: 30000,
|
||||
});
|
||||
@@ -98,14 +97,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
mutationFn: saveDownloadConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Download settings saved successfully!");
|
||||
setSaveStatus("success");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to save settings", error.message);
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,12 +122,12 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
// Validation effect for watch + download method requirement
|
||||
useEffect(() => {
|
||||
let error = "";
|
||||
|
||||
|
||||
// Check watch requirements
|
||||
if (watchConfig?.enabled && !realTime && !fallback) {
|
||||
error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled.";
|
||||
}
|
||||
|
||||
|
||||
// Check fallback account requirements
|
||||
if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
|
||||
const missingServices: string[] = [];
|
||||
@@ -139,7 +135,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
if (!deezerCredentials?.length) missingServices.push("Deezer");
|
||||
error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
|
||||
}
|
||||
|
||||
|
||||
setValidationError(error);
|
||||
}, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]);
|
||||
|
||||
@@ -180,12 +176,6 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus === "success" && (
|
||||
<span className="text-success text-sm">Saved</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || !!validationError}
|
||||
@@ -248,7 +238,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
When enabled, downloads will be organized in user-specific subdirectories (downloads/username/...)
|
||||
</p>
|
||||
|
||||
|
||||
{/* Watch validation info */}
|
||||
{watchConfig?.enabled && (
|
||||
<div className="p-3 bg-info/10 border border-info/20 rounded-lg">
|
||||
@@ -260,7 +250,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Fallback account requirements info */}
|
||||
{fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
|
||||
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
||||
@@ -272,7 +262,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Validation error display */}
|
||||
{validationError && (
|
||||
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import { authApiClient } from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
@@ -80,20 +80,16 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const dirInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const trackInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveFormattingConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Formatting settings saved!");
|
||||
setSaveStatus("success");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to save formatting settings:", error.message);
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,12 +127,6 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus === "success" && (
|
||||
<span className="text-success text-sm">Saved</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authApiClient } from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "../../contexts/settings-context";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface Credential {
|
||||
@@ -56,20 +56,15 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
}
|
||||
}, [config, reset]);
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveGeneralConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("General settings saved!");
|
||||
setSaveStatus("success");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
console.error("Failed to save general settings:", e.message);
|
||||
toast.error(`Failed to save: ${e.message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,12 +79,6 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus === "success" && (
|
||||
<span className="text-success text-sm">Saved</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
@@ -103,14 +92,18 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Service Defaults</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">Default Service</label>
|
||||
<label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">
|
||||
Default Service
|
||||
</label>
|
||||
<select
|
||||
id="service"
|
||||
{...register("service")}
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="spotify">Spotify</option>
|
||||
<option value="deezer" disabled>Deezer (not yet...)</option>
|
||||
<option value="deezer" disabled>
|
||||
Deezer (not yet...)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +111,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify Settings</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">Active Spotify Account</label>
|
||||
<label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">
|
||||
Active Spotify Account
|
||||
</label>
|
||||
<select
|
||||
id="spotifyAccount"
|
||||
{...register("spotify")}
|
||||
@@ -136,7 +131,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Deezer Settings</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">Active Deezer Account</label>
|
||||
<label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">
|
||||
Active Deezer Account
|
||||
</label>
|
||||
<select
|
||||
id="deezerAccount"
|
||||
{...register("deezer")}
|
||||
@@ -159,7 +156,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}>
|
||||
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">ENV</span>
|
||||
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">
|
||||
ENV
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { authApiClient } from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
@@ -46,20 +46,16 @@ function SpotifyApiForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig });
|
||||
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveSpotifyApiConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Spotify API settings saved!");
|
||||
setSaveStatus("success");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error("Failed to save Spotify API settings:", e.message);
|
||||
toast.error(`Failed to save: ${e.message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,12 +71,6 @@ function SpotifyApiForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus === "success" && (
|
||||
<span className="text-success text-sm">Saved</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
@@ -120,20 +110,16 @@ function WebhookForm() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
|
||||
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
|
||||
const currentUrl = watch("url");
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveWebhookConfig,
|
||||
onSuccess: () => {
|
||||
// No toast needed since the function shows one
|
||||
setSaveStatus("success");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(`Failed to save: ${e.message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,12 +143,6 @@ function WebhookForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus === "success" && (
|
||||
<span className="text-success text-sm">Saved</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
|
||||
@@ -58,7 +58,6 @@ const saveWatchConfig = async (data: Partial<WatchSettings>) => {
|
||||
export function WatchTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [validationError, setValidationError] = useState<string>("");
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["watchConfig"],
|
||||
@@ -81,7 +80,7 @@ export function WatchTab() {
|
||||
|
||||
const { data: deezerCredentials } = useQuery({
|
||||
queryKey: ["credentials", "deezer"],
|
||||
queryFn: () => fetchCredentials("deezer"),
|
||||
queryFn: () => fetchCredentials("deezer"),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
@@ -89,15 +88,12 @@ export function WatchTab() {
|
||||
mutationFn: saveWatchConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Watch settings saved successfully!");
|
||||
setSaveStatus("success");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.error || error?.message || "Unknown error";
|
||||
toast.error(`Failed to save settings: ${message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
console.error("Failed to save watch settings:", message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,12 +111,12 @@ export function WatchTab() {
|
||||
// Validation effect for watch + download method requirement
|
||||
useEffect(() => {
|
||||
let error = "";
|
||||
|
||||
|
||||
// Check if watch can be enabled (need download methods)
|
||||
if (watchEnabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
|
||||
error = "To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.";
|
||||
}
|
||||
|
||||
|
||||
// Check fallback account requirements if watch is enabled and fallback is being used
|
||||
if (watchEnabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
|
||||
const missingServices: string[] = [];
|
||||
@@ -134,7 +130,7 @@ export function WatchTab() {
|
||||
if (!error && (Number.isNaN(mir) || mir < 1 || mir > 50)) {
|
||||
error = "Max items per run must be between 1 and 50.";
|
||||
}
|
||||
|
||||
|
||||
setValidationError(error);
|
||||
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]);
|
||||
|
||||
@@ -180,12 +176,6 @@ export function WatchTab() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveStatus === "success" && (
|
||||
<span className="text-success text-sm">Saved</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || !!validationError}
|
||||
@@ -202,40 +192,38 @@ export function WatchTab() {
|
||||
<label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
|
||||
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Download requirements info */}
|
||||
{downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
|
||||
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
||||
<p className="text-sm text-warning font-medium mb-1">
|
||||
Download methods required
|
||||
</p>
|
||||
<p className="text-sm text-warning font-medium mb-1">Download methods required</p>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark">
|
||||
To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Fallback account requirements info */}
|
||||
{downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
|
||||
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
||||
<p className="text-sm text-warning font-medium mb-1">
|
||||
Fallback accounts required
|
||||
</p>
|
||||
<p className="text-sm text-warning font-medium mb-1">Fallback accounts required</p>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark">
|
||||
Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Validation error display */}
|
||||
{validationError && (
|
||||
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
||||
<p className="text-sm text-error font-medium">{validationError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">Watch Poll Interval (seconds)</label>
|
||||
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">
|
||||
Watch Poll Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
id="watchPollIntervalSeconds"
|
||||
type="number"
|
||||
@@ -243,11 +231,15 @@ export function WatchTab() {
|
||||
{...register("watchPollIntervalSeconds")}
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">How often to check for new items in watchlist.</p>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
How often to check for new items in watchlist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">Max Items Per Run</label>
|
||||
<label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">
|
||||
Max Items Per Run
|
||||
</label>
|
||||
<input
|
||||
id="maxItemsPerRun"
|
||||
type="number"
|
||||
@@ -256,13 +248,19 @@ export function WatchTab() {
|
||||
{...register("maxItemsPerRun")}
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Batch size per watch cycle (1–50).</p>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
Batch size per watch cycle (1–50).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Artist Album Groups</h3>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">Select which album groups to monitor for watched artists.</p>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">
|
||||
Artist Album Groups
|
||||
</h3>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
Select which album groups to monitor for watched artists.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{ALBUM_GROUPS.map((group) => (
|
||||
<Controller
|
||||
|
||||
47
spotizerr-ui/src/components/ui/Toaster.tsx
Normal file
47
spotizerr-ui/src/components/ui/Toaster.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Toaster as SonnerToaster } from "sonner";
|
||||
import { getEffectiveTheme } from "@/lib/theme";
|
||||
|
||||
// Centralized Toaster wrapper so we can control defaults + theme.
|
||||
// Tailwind dark mode relies on .dark on <html>. Sonner auto-detects, but we can also
|
||||
// explicitly set className variants for better contrast. (as needed/commented out below)
|
||||
export const Toaster: React.FC = () => {
|
||||
const [theme, setTheme] = useState<"light" | "dark" | "system">(getEffectiveTheme());
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setTheme(getEffectiveTheme());
|
||||
window.addEventListener("app-theme-changed", update);
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "theme") update();
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("app-theme-changed", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-center"
|
||||
theme={theme}
|
||||
richColors
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
classNames: {
|
||||
// toast:
|
||||
// "bg-white dark:bg-surface-secondary-dark text-content-primary dark:text-content-primary-dark border border-line dark:border-border-dark shadow-md",
|
||||
title: "font-medium",
|
||||
description: "text-content-secondary dark:text-content-secondary-dark",
|
||||
// success: "bg-success/10 dark:bg-success/20 text-success border-success/40",
|
||||
// error: "bg-error/10 dark:bg-error/20 text-error border-error/40",
|
||||
// warning: "bg-warning/10 dark:bg-warning/20 text-warning border-warning/40",
|
||||
// info: "bg-info/10 dark:bg-info/20 text-info border-info/40",
|
||||
closeButton:
|
||||
"text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark",
|
||||
actionButton: "bg-primary text-white hover:bg-primary-hover",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toaster;
|
||||
@@ -1,71 +1,80 @@
|
||||
// Theme management functions
|
||||
export function getTheme(): 'light' | 'dark' | 'system' {
|
||||
return (localStorage.getItem('theme') as 'light' | 'dark' | 'system') || 'system';
|
||||
export function getTheme(): "light" | "dark" | "system" {
|
||||
return (localStorage.getItem("theme") as "light" | "dark" | "system") || "system";
|
||||
}
|
||||
|
||||
export function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
localStorage.setItem('theme', theme);
|
||||
export function setTheme(theme: "light" | "dark" | "system") {
|
||||
localStorage.setItem("theme", theme);
|
||||
applyTheme(theme);
|
||||
dispatchThemeChange();
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = getTheme();
|
||||
let nextTheme: 'light' | 'dark' | 'system';
|
||||
|
||||
let nextTheme: "light" | "dark" | "system";
|
||||
|
||||
switch (currentTheme) {
|
||||
case 'light':
|
||||
nextTheme = 'dark';
|
||||
case "light":
|
||||
nextTheme = "dark";
|
||||
break;
|
||||
case 'dark':
|
||||
nextTheme = 'system';
|
||||
case "dark":
|
||||
nextTheme = "system";
|
||||
break;
|
||||
default:
|
||||
nextTheme = 'light';
|
||||
nextTheme = "light";
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
setTheme(nextTheme);
|
||||
return nextTheme;
|
||||
}
|
||||
|
||||
function applyTheme(theme: 'light' | 'dark' | 'system') {
|
||||
function applyTheme(theme: "light" | "dark" | "system") {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'system') {
|
||||
|
||||
if (theme === "system") {
|
||||
// Use system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (prefersDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
} else if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (prefersDark) root.classList.add("dark");
|
||||
else root.classList.remove("dark");
|
||||
} else if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchThemeChange() {
|
||||
window.dispatchEvent(new CustomEvent("app-theme-changed"));
|
||||
}
|
||||
|
||||
export function getEffectiveTheme(): "light" | "dark" {
|
||||
const stored = getTheme();
|
||||
if (stored === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Dark mode detection and setup
|
||||
export function setupDarkMode() {
|
||||
// First, ensure we start with a clean slate
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
document.documentElement.classList.remove("dark");
|
||||
|
||||
const savedTheme = getTheme();
|
||||
applyTheme(savedTheme);
|
||||
|
||||
dispatchThemeChange();
|
||||
|
||||
// Listen for system theme changes (only when using system theme)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
// Only respond to system changes when we're in system mode
|
||||
if (getTheme() === 'system') {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
if (getTheme() === "system") {
|
||||
if (e.matches) document.documentElement.classList.add("dark");
|
||||
else document.documentElement.classList.remove("dark");
|
||||
dispatchThemeChange();
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { router } from "./router";
|
||||
import { AuthProvider } from "./contexts/AuthProvider";
|
||||
import { Toaster } from "./components/ui/Toaster";
|
||||
import { setupDarkMode } from "./lib/theme";
|
||||
import "./index.css";
|
||||
|
||||
@@ -23,6 +24,7 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import type { AlbumType, TrackType } from "../types/spotify";
|
||||
import { toast } from "sonner";
|
||||
@@ -24,7 +24,19 @@ export const Album = () => {
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
const { addItem, items } = context;
|
||||
|
||||
// Queue status for this album
|
||||
const albumQueueItem = items.find(item => item.downloadType === "album" && item.spotifyId === album?.id);
|
||||
const albumStatus = albumQueueItem ? getStatus(albumQueueItem) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (albumStatus === "queued") {
|
||||
toast.success(`${album?.name} queued.`);
|
||||
} else if (albumStatus === "error") {
|
||||
toast.error(`Failed to queue ${album?.name}`);
|
||||
}
|
||||
}, [albumStatus]);
|
||||
|
||||
const totalTracks = album?.total_tracks ?? 0;
|
||||
const hasMore = tracks.length < totalTracks;
|
||||
@@ -174,13 +186,27 @@ export const Album = () => {
|
||||
<div className="mt-4 md:mt-6">
|
||||
<button
|
||||
onClick={handleDownloadAlbum}
|
||||
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
||||
disabled={(isExplicitFilterEnabled && hasExplicitTrack) || (!!albumQueueItem && albumStatus !== "error")}
|
||||
className="w-full px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed font-semibold shadow-sm"
|
||||
title={
|
||||
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
|
||||
isExplicitFilterEnabled && hasExplicitTrack
|
||||
? "Album contains explicit tracks"
|
||||
: albumStatus
|
||||
? albumStatus === "queued"
|
||||
? "Album queued"
|
||||
: albumStatus === "error"
|
||||
? "Download Full Album"
|
||||
: "Downloading..."
|
||||
: "Download Full Album"
|
||||
}
|
||||
>
|
||||
Download Album
|
||||
{albumStatus
|
||||
? albumStatus === "queued"
|
||||
? "Queued."
|
||||
: albumStatus === "error"
|
||||
? "Download Album"
|
||||
: "Downloading..."
|
||||
: "Download Album"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import apiClient from "../lib/api-client";
|
||||
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
|
||||
import { AlbumCard } from "../components/AlbumCard";
|
||||
@@ -14,6 +14,7 @@ export const Artist = () => {
|
||||
const [albums, setAlbums] = useState<AlbumType[]>([]);
|
||||
const [topTracks, setTopTracks] = useState<TrackType[]>([]);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [artistStatus, setArtistStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const context = useContext(QueueContext);
|
||||
const { settings } = useSettings();
|
||||
@@ -30,7 +31,14 @@ export const Artist = () => {
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
const { addItem, items } = context;
|
||||
|
||||
// Track queue status mapping
|
||||
const trackStatuses = topTracks.reduce((acc, t) => {
|
||||
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === t.id);
|
||||
acc[t.id] = qi ? getStatus(qi) : null;
|
||||
return acc;
|
||||
}, {} as Record<string, string | null>);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(items: AlbumType[]) => {
|
||||
@@ -194,6 +202,7 @@ export const Artist = () => {
|
||||
};
|
||||
|
||||
const handleDownloadArtist = async () => {
|
||||
setArtistStatus("downloading");
|
||||
if (!artistId || !artist) return;
|
||||
|
||||
try {
|
||||
@@ -203,13 +212,16 @@ export const Artist = () => {
|
||||
const response = await apiClient.get(`/artist/download/${artistId}`);
|
||||
|
||||
if (response.data.queued_albums?.length > 0) {
|
||||
setArtistStatus("queued");
|
||||
toast.success(`${artist.name} discography queued successfully!`, {
|
||||
description: `${response.data.queued_albums.length} albums added to queue.`,
|
||||
});
|
||||
} else {
|
||||
setArtistStatus(null);
|
||||
toast.info("No new albums to download for this artist.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
setArtistStatus("error");
|
||||
console.error("Artist download failed:", error);
|
||||
toast.error("Failed to download artist", {
|
||||
description: error.response?.data?.error || "An unexpected error occurred.",
|
||||
@@ -274,30 +286,52 @@ export const Artist = () => {
|
||||
<div className="flex gap-4 justify-center mt-4">
|
||||
<button
|
||||
onClick={handleDownloadArtist}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
|
||||
disabled={artistStatus === "downloading" || artistStatus === "queued"}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
artistStatus === "downloading"
|
||||
? "Downloading..."
|
||||
: artistStatus === "queued"
|
||||
? "Queued."
|
||||
: "Download All"
|
||||
}
|
||||
>
|
||||
<FaDownload className="icon-inverse" />
|
||||
<span>Download All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
|
||||
? "bg-button-primary text-button-primary-text border-primary"
|
||||
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
{isWatched ? (
|
||||
<>
|
||||
<FaBookmark className="icon-inverse" />
|
||||
<span>Watching</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegBookmark className="icon-primary" />
|
||||
<span>Watch</span>
|
||||
</>
|
||||
)}
|
||||
{artistStatus
|
||||
? artistStatus === "queued"
|
||||
? "Queued."
|
||||
: artistStatus === "downloading"
|
||||
? "Downloading..."
|
||||
: <>
|
||||
<FaDownload className="icon-inverse" />
|
||||
<span>Download All</span>
|
||||
</>
|
||||
: <>
|
||||
<FaDownload className="icon-inverse" />
|
||||
<span>Download All</span>
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
{settings?.watch?.enabled && (
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
|
||||
? "bg-button-primary text-button-primary-text border-primary"
|
||||
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
{isWatched ? (
|
||||
<>
|
||||
<FaBookmark className="icon-inverse" />
|
||||
<span>Watching</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegBookmark className="icon-primary" />
|
||||
<span>Watch</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,9 +353,16 @@ export const Artist = () => {
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded"
|
||||
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"}
|
||||
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Download
|
||||
{trackStatuses[track.id]
|
||||
? trackStatuses[track.id] === "queued"
|
||||
? "Queued."
|
||||
: trackStatuses[track.id] === "error"
|
||||
? "Download"
|
||||
: "Downloading..."
|
||||
: "Download"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -4,7 +4,7 @@ import apiClient from "../lib/api-client";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import { toast } from "sonner";
|
||||
import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||
import { FaArrowLeft } from "react-icons/fa";
|
||||
|
||||
|
||||
@@ -28,7 +28,21 @@ export const Playlist = () => {
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
const { addItem, items } = context;
|
||||
|
||||
// Playlist queue status
|
||||
const playlistQueueItem = playlistMetadata
|
||||
? items.find(item => item.downloadType === "playlist" && item.spotifyId === playlistMetadata.id)
|
||||
: undefined;
|
||||
const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (playlistStatus === "queued") {
|
||||
toast.success(`${playlistMetadata?.name} queued.`);
|
||||
} else if (playlistStatus === "error") {
|
||||
toast.error(`Failed to queue ${playlistMetadata?.name}`);
|
||||
}
|
||||
}, [playlistStatus]);
|
||||
|
||||
// Load playlist metadata first
|
||||
useEffect(() => {
|
||||
@@ -167,6 +181,14 @@ export const Playlist = () => {
|
||||
return <div className="p-8 text-center">Loading playlist...</div>;
|
||||
}
|
||||
|
||||
// Map track download statuses
|
||||
const trackStatuses = tracks.reduce((acc, { track }) => {
|
||||
if (!track) return acc;
|
||||
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === track.id);
|
||||
acc[track.id] = qi ? getStatus(qi) : null;
|
||||
return acc;
|
||||
}, {} as Record<string, string | null>);
|
||||
|
||||
const filteredTracks = tracks.filter(({ track }) => {
|
||||
if (!track) return false;
|
||||
if (settings?.explicitFilter && track.explicit) return false;
|
||||
@@ -209,25 +231,34 @@ export const Playlist = () => {
|
||||
<div className="mt-4 md:mt-6 flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm"
|
||||
disabled={!!playlistQueueItem && playlistStatus !== "error"}
|
||||
className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Download All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
|
||||
isWatched
|
||||
? "bg-error hover:bg-error-hover text-button-primary-text"
|
||||
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
|
||||
alt="Watch status"
|
||||
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
|
||||
/>
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
{playlistStatus
|
||||
? playlistStatus === "queued"
|
||||
? "Queued."
|
||||
: playlistStatus === "error"
|
||||
? "Download All"
|
||||
: "Downloading..."
|
||||
: "Download All"}
|
||||
</button>
|
||||
{settings?.watch?.enabled && (
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
|
||||
isWatched
|
||||
? "bg-error hover:bg-error-hover text-button-primary-text"
|
||||
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
|
||||
alt="Watch status"
|
||||
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
|
||||
/>
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -287,10 +318,26 @@ export const Playlist = () => {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all hover:scale-105 hover:shadow-sm"
|
||||
title="Download"
|
||||
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"}
|
||||
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
trackStatuses[track.id]
|
||||
? trackStatuses[track.id] === "queued"
|
||||
? "Queued."
|
||||
: trackStatuses[track.id] === "error"
|
||||
? "Download"
|
||||
: "Downloading..."
|
||||
: "Download"
|
||||
}
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
|
||||
{trackStatuses[track.id]
|
||||
? trackStatuses[track.id] === "queued"
|
||||
? "Queued."
|
||||
: trackStatuses[track.id] === "error"
|
||||
? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
|
||||
: "Downloading..."
|
||||
: <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useContext } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import type { TrackType } from "../types/spotify";
|
||||
import { toast } from "sonner";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||
import { FaSpotify, FaArrowLeft } from "react-icons/fa";
|
||||
|
||||
// Helper to format milliseconds to mm:ss
|
||||
@@ -22,7 +22,19 @@ export const Track = () => {
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
const { addItem, items } = context;
|
||||
|
||||
// Track queue status
|
||||
const trackQueueItem = track ? items.find(item => item.downloadType === "track" && item.spotifyId === track.id) : undefined;
|
||||
const trackStatus = trackQueueItem ? getStatus(trackQueueItem) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (trackStatus === "queued") {
|
||||
toast.success(`${track?.name} queued.`);
|
||||
} else if (trackStatus === "error") {
|
||||
toast.error(`Failed to queue ${track?.name}`);
|
||||
}
|
||||
}, [trackStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrack = async () => {
|
||||
@@ -173,9 +185,16 @@ export const Track = () => {
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<button
|
||||
onClick={handleDownloadTrack}
|
||||
className="w-full sm:w-auto bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-3 px-8 rounded-full transition duration-300 shadow-lg hover:shadow-xl"
|
||||
disabled={!!trackQueueItem && trackStatus !== "error"}
|
||||
className="w-full sm:w-auto bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-3 px-8 rounded-full transition duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Download
|
||||
{trackStatus
|
||||
? trackStatus === "queued"
|
||||
? "Queued."
|
||||
: trackStatus === "error"
|
||||
? "Download"
|
||||
: "Downloading..."
|
||||
: "Download"}
|
||||
</button>
|
||||
<a
|
||||
href={track.external_urls.spotify}
|
||||
|
||||
Reference in New Issue
Block a user