Merge branch 'dev' into buttons-no-dropdown

This commit is contained in:
Spotizerr
2025-08-23 22:01:27 -06:00
committed by GitHub
44 changed files with 1686 additions and 827 deletions

View File

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

View File

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

11
.readthedocs.yaml Normal file
View File

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

View File

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

174
README.md
View File

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

111
app.py
View File

@@ -13,6 +13,16 @@ import redis
import socket import socket
from urllib.parse import urlparse from urllib.parse import urlparse
# Define a mapping from string log levels to logging constants
LOG_LEVELS = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
"NOTSET": logging.NOTSET,
}
# Run DB migrations as early as possible, before importing any routers that may touch DBs # Run DB migrations as early as possible, before importing any routers that may touch DBs
try: try:
from routes.migrations import run_migrations_if_needed from routes.migrations import run_migrations_if_needed
@@ -27,13 +37,28 @@ except Exception as e:
) )
sys.exit(1) sys.exit(1)
# Import route routers (to be created) # Get log level from environment variable, default to INFO
log_level_str = os.getenv("LOG_LEVEL", "WARNING").upper()
log_level = LOG_LEVELS.get(log_level_str, logging.INFO)
# Apply process umask from environment as early as possible
_umask_value = os.getenv("UMASK")
if _umask_value:
try:
os.umask(int(_umask_value, 8))
except Exception:
# Defer logging setup; avoid failing on invalid UMASK
pass
# Import and initialize routes (this will start the watch manager)
from routes.auth.credentials import router as credentials_router from routes.auth.credentials import router as credentials_router
from routes.auth.auth import router as auth_router from routes.auth.auth import router as auth_router
from routes.content.artist import router as artist_router
from routes.content.album import router as album_router from routes.content.album import router as album_router
from routes.content.artist import router as artist_router
from routes.content.track import router as track_router from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router from routes.content.playlist import router as playlist_router
from routes.content.bulk_add import router as bulk_add_router
from routes.core.search import router as search_router from routes.core.search import router as search_router
from routes.core.history import router as history_router from routes.core.history import router as history_router
from routes.system.progress import router as prgs_router from routes.system.progress import router as prgs_router
@@ -51,7 +76,6 @@ from routes.auth.middleware import AuthMiddleware
# Import watch manager controls (start/stop) without triggering side effects # Import watch manager controls (start/stop) without triggering side effects
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
# Import and initialize routes (this will start the watch manager)
# Configure application-wide logging # Configure application-wide logging
@@ -61,12 +85,23 @@ def setup_logging():
logs_dir = Path("logs") logs_dir = Path("logs")
logs_dir.mkdir(exist_ok=True) logs_dir.mkdir(exist_ok=True)
# Ensure required runtime directories exist
for p in [
Path("downloads"),
Path("data/config"),
Path("data/creds"),
Path("data/watch"),
Path("data/history"),
Path("logs/tasks"),
]:
p.mkdir(parents=True, exist_ok=True)
# Set up log file paths # Set up log file paths
main_log = logs_dir / "spotizerr.log" main_log = logs_dir / "spotizerr.log"
# Configure root logger # Configure root logger
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG) root_logger.setLevel(log_level)
# Clear any existing handlers from the root logger # Clear any existing handlers from the root logger
if root_logger.hasHandlers(): if root_logger.hasHandlers():
@@ -83,12 +118,12 @@ def setup_logging():
main_log, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" main_log, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
) )
file_handler.setFormatter(log_format) file_handler.setFormatter(log_format)
file_handler.setLevel(logging.INFO) file_handler.setLevel(log_level)
# Console handler for stderr # Console handler for stderr
console_handler = logging.StreamHandler(sys.stderr) console_handler = logging.StreamHandler(sys.stderr)
console_handler.setFormatter(log_format) console_handler.setFormatter(log_format)
console_handler.setLevel(logging.INFO) console_handler.setLevel(log_level)
# Add handlers to root logger # Add handlers to root logger
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
@@ -101,16 +136,23 @@ def setup_logging():
"routes.utils.celery_manager", "routes.utils.celery_manager",
"routes.utils.celery_tasks", "routes.utils.celery_tasks",
"routes.utils.watch", "routes.utils.watch",
"uvicorn", # General Uvicorn logger
"uvicorn.access", # Uvicorn access logs
"uvicorn.error", # Uvicorn error logs
]: ]:
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
logger.setLevel(logging.INFO) logger.setLevel(log_level)
logger.propagate = True # Propagate to root logger # For uvicorn.access, we explicitly set propagate to False to prevent duplicate logging
# if access_log=False is used in uvicorn.run, and to ensure our middleware handles it.
logger.propagate = False if logger_name == "uvicorn.access" else True
logging.info("Logging system initialized") logging.info("Logging system initialized")
def check_redis_connection(): def check_redis_connection():
"""Check if Redis is available and accessible""" """Check if Redis is available and accessible"""
from routes.utils.celery_config import REDIS_URL
if not REDIS_URL: if not REDIS_URL:
logging.error("REDIS_URL is not configured. Please check your environment.") logging.error("REDIS_URL is not configured. Please check your environment.")
return False return False
@@ -156,6 +198,20 @@ async def lifespan(app: FastAPI):
# Startup # Startup
setup_logging() setup_logging()
# Run migrations before initializing services
try:
from routes.migrations import run_migrations_if_needed
run_migrations_if_needed()
logging.getLogger(__name__).info(
"Database migrations executed (if needed) early in startup."
)
except Exception as e:
logging.getLogger(__name__).error(
f"Database migration step failed early in startup: {e}", exc_info=True
)
sys.exit(1)
# Check Redis connection # Check Redis connection
if not check_redis_connection(): if not check_redis_connection():
logging.error( logging.error(
@@ -165,6 +221,8 @@ async def lifespan(app: FastAPI):
# Start Celery workers # Start Celery workers
try: try:
from routes.utils.celery_manager import celery_manager
celery_manager.start() celery_manager.start()
logging.info("Celery workers started successfully") logging.info("Celery workers started successfully")
except Exception as e: except Exception as e:
@@ -172,6 +230,8 @@ async def lifespan(app: FastAPI):
# Start Watch Manager after Celery is up # Start Watch Manager after Celery is up
try: try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
start_watch_manager() start_watch_manager()
logging.info("Watch Manager initialized and registered for shutdown.") logging.info("Watch Manager initialized and registered for shutdown.")
except Exception as e: except Exception as e:
@@ -184,12 +244,16 @@ async def lifespan(app: FastAPI):
# Shutdown # Shutdown
try: try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
stop_watch_manager() stop_watch_manager()
logging.info("Watch Manager stopped") logging.info("Watch Manager stopped")
except Exception as e: except Exception as e:
logging.error(f"Error stopping Watch Manager: {e}") logging.error(f"Error stopping Watch Manager: {e}")
try: try:
from routes.utils.celery_manager import celery_manager
celery_manager.stop() celery_manager.stop()
logging.info("Celery workers stopped") logging.info("Celery workers stopped")
except Exception as e: except Exception as e:
@@ -215,13 +279,31 @@ def create_app():
) )
# Add authentication middleware (only if auth is enabled) # Add authentication middleware (only if auth is enabled)
if AUTH_ENABLED: try:
app.add_middleware(AuthMiddleware) from routes.auth import AUTH_ENABLED
logging.info("Authentication system enabled") from routes.auth.middleware import AuthMiddleware
else:
logging.info("Authentication system disabled") if AUTH_ENABLED:
app.add_middleware(AuthMiddleware)
logging.info("Authentication system enabled")
else:
logging.info("Authentication system disabled")
except Exception as e:
logging.warning(f"Auth system initialization failed or unavailable: {e}")
# Register routers with URL prefixes # Register routers with URL prefixes
from routes.auth.auth import router as auth_router
from routes.system.config import router as config_router
from routes.core.search import router as search_router
from routes.auth.credentials import router as credentials_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.content.bulk_add import router as bulk_add_router
from routes.content.artist import router as artist_router
from routes.system.progress import router as prgs_router
from routes.core.history import router as history_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Include SSO router if available # Include SSO router if available
@@ -240,6 +322,7 @@ def create_app():
app.include_router(album_router, prefix="/api/album", tags=["album"]) app.include_router(album_router, prefix="/api/album", tags=["album"])
app.include_router(track_router, prefix="/api/track", tags=["track"]) app.include_router(track_router, prefix="/api/track", tags=["track"])
app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"]) app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"])
app.include_router(bulk_add_router, prefix="/api/bulk", tags=["bulk"])
app.include_router(artist_router, prefix="/api/artist", tags=["artist"]) app.include_router(artist_router, prefix="/api/artist", tags=["artist"])
app.include_router(prgs_router, prefix="/api/prgs", tags=["progress"]) app.include_router(prgs_router, prefix="/api/prgs", tags=["progress"])
app.include_router(history_router, prefix="/api/history", tags=["history"]) app.include_router(history_router, prefix="/api/history", tags=["history"])
@@ -363,4 +446,4 @@ if __name__ == "__main__":
except ValueError: except ValueError:
port = 7171 port = 7171
uvicorn.run(app, host=host, port=port, log_level="info", access_log=True) uvicorn.run(app, host=host, port=port, log_level=log_level_str.lower(), access_log=False)

View File

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

View File

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

16
docs/index.md Normal file
View File

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

30
mkdocs.yml Normal file
View File

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

View File

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

View File

@@ -1,7 +1,3 @@
import logging import logging
# Configure basic logging for the application if not already configured
# This remains safe to execute on import
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

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

108
routes/content/bulk_add.py Normal file
View File

@@ -0,0 +1,108 @@
import re
from typing import List, Dict, Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import logging
# Assuming these imports are available for queue management and Spotify info
from routes.utils.get_info import get_spotify_info
from routes.utils.celery_tasks import download_track, download_album, download_playlist
router = APIRouter()
logger = logging.getLogger(__name__)
class BulkAddLinksRequest(BaseModel):
links: List[str]
@router.post("/bulk-add-spotify-links")
async def bulk_add_spotify_links(request: BulkAddLinksRequest):
added_count = 0
failed_links = []
total_links = len(request.links)
for link in request.links:
# Assuming links are pre-filtered by the frontend,
# but still handle potential errors during info retrieval or unsupported types
# Extract type and ID from the link directly using regex
match = re.match(r"https://open\.spotify\.com(?:/intl-[a-z]{2})?/(track|album|playlist|artist)/([a-zA-Z0-9]+)(?:\?.*)?", link)
if not match:
logger.warning(f"Could not parse Spotify link (unexpected format after frontend filter): {link}")
failed_links.append(link)
continue
spotify_type = match.group(1)
spotify_id = match.group(2)
try:
# Get basic info to confirm existence and get name/artist
# For playlists, we might want to get full info later when adding to queue
if spotify_type == "playlist":
item_info = get_spotify_info(spotify_id, "playlist_metadata")
else:
item_info = get_spotify_info(spotify_id, spotify_type)
item_name = item_info.get("name", "Unknown Name")
artist_name = ""
if spotify_type in ["track", "album"]:
artists = item_info.get("artists", [])
if artists:
artist_name = ", ".join([a.get("name", "Unknown Artist") for a in artists])
elif spotify_type == "playlist":
owner = item_info.get("owner", {})
artist_name = owner.get("display_name", "Unknown Owner")
# Construct URL for the download task
spotify_url = f"https://open.spotify.com/{spotify_type}/{spotify_id}"
# Add to Celery queue based on type
if spotify_type == "track":
download_track.delay(
url=spotify_url,
spotify_id=spotify_id,
type=spotify_type,
name=item_name,
artist=artist_name,
download_type="track",
)
elif spotify_type == "album":
download_album.delay(
url=spotify_url,
spotify_id=spotify_id,
type=spotify_type,
name=item_name,
artist=artist_name,
download_type="album",
)
elif spotify_type == "playlist":
download_playlist.delay(
url=spotify_url,
spotify_id=spotify_id,
type=spotify_type,
name=item_name,
artist=artist_name,
download_type="playlist",
)
else:
logger.warning(f"Unsupported Spotify type for download: {spotify_type} for link: {link}")
failed_links.append(link)
continue
added_count += 1
logger.debug(f"Added {added_count+1}/{total_links} {spotify_type} '{item_name}' ({spotify_id}) to queue.")
except Exception as e:
logger.error(f"Error processing Spotify link {link}: {e}", exc_info=True)
failed_links.append(link)
message = f"Successfully added {added_count}/{total_links} links to queue."
if failed_links:
message += f" Failed to add {len(failed_links)} links."
logger.warning(f"Bulk add completed with {len(failed_links)} failures.")
else:
logger.info(f"Bulk add completed successfully. Added {added_count} links.")
return {
"message": message,
"count": added_count,
"failed_links": failed_links,
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import subprocess
import logging import logging
import time import time
import threading import threading
import os
import sys
# Import Celery task utilities # Import Celery task utilities
from .celery_config import get_config_params, MAX_CONCURRENT_DL from .celery_config import get_config_params, MAX_CONCURRENT_DL
@@ -40,12 +42,16 @@ class CeleryManager:
) )
def _get_worker_command( def _get_worker_command(
self, queues, concurrency, worker_name_suffix, log_level="INFO" self, queues, concurrency, worker_name_suffix, log_level_env=None
): ):
# Use LOG_LEVEL from environment if provided, otherwise default to INFO
log_level = log_level_env if log_level_env else os.getenv("LOG_LEVEL", "WARNING").upper()
# Use a unique worker name to avoid conflicts. # Use a unique worker name to avoid conflicts.
# %h is replaced by celery with the actual hostname. # %h is replaced by celery with the actual hostname.
hostname = f"worker_{worker_name_suffix}@%h" hostname = f"worker_{worker_name_suffix}@%h"
command = [ command = [
sys.executable,
"-m",
"celery", "celery",
"-A", "-A",
self.app_name, self.app_name,
@@ -73,11 +79,14 @@ class CeleryManager:
log_method = logger.info # Default log method log_method = logger.info # Default log method
if error: # This is a stderr stream if error: # This is a stderr stream
if " - ERROR - " in line_stripped or " - CRITICAL - " in line_stripped: if (
" - ERROR - " in line_stripped
or " - CRITICAL - " in line_stripped
):
log_method = logger.error log_method = logger.error
elif " - WARNING - " in line_stripped: elif " - WARNING - " in line_stripped:
log_method = logger.warning log_method = logger.warning
log_method(f"{log_prefix}: {line_stripped}") log_method(f"{log_prefix}: {line_stripped}")
elif ( elif (
self.stop_event.is_set() self.stop_event.is_set()
@@ -117,6 +126,7 @@ class CeleryManager:
queues="downloads", queues="downloads",
concurrency=self.concurrency, concurrency=self.concurrency,
worker_name_suffix="dlw", # Download Worker worker_name_suffix="dlw", # Download Worker
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
) )
logger.info( logger.info(
f"Starting Celery Download Worker with command: {' '.join(download_cmd)}" f"Starting Celery Download Worker with command: {' '.join(download_cmd)}"
@@ -151,7 +161,8 @@ class CeleryManager:
queues="utility_tasks,default", # Listen to utility and default queues="utility_tasks,default", # Listen to utility and default
concurrency=5, # Increased concurrency for SSE updates and utility tasks concurrency=5, # Increased concurrency for SSE updates and utility tasks
worker_name_suffix="utw", # Utility Worker worker_name_suffix="utw", # Utility Worker
log_level="ERROR" # Reduce log verbosity for utility worker (only errors) log_level_env=os.getenv("LOG_LEVEL", "ERROR").upper(),
) )
logger.info( logger.info(
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}" f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"
@@ -250,7 +261,7 @@ class CeleryManager:
# Restart only the download worker # Restart only the download worker
download_cmd = self._get_worker_command( download_cmd = self._get_worker_command(
"downloads", self.concurrency, "dlw" "downloads", self.concurrency, "dlw", log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper()
) )
logger.info( logger.info(
f"Restarting Celery Download Worker with command: {' '.join(download_cmd)}" f"Restarting Celery Download Worker with command: {' '.join(download_cmd)}"
@@ -366,10 +377,7 @@ celery_manager = CeleryManager()
# Example of how to use the manager (typically called from your main app script) # Example of how to use the manager (typically called from your main app script)
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig( # Removed logging.basicConfig as it's handled by the main app's setup_logging
level=logging.INFO,
format="%(message)s",
)
logger.info("Starting Celery Manager example...") logger.info("Starting Celery Manager example...")
celery_manager.start() celery_manager.start()
try: try:

View File

@@ -246,7 +246,7 @@ class CeleryDownloadQueueManager:
"""Initialize the Celery-based download queue manager""" """Initialize the Celery-based download queue manager"""
self.max_concurrent = MAX_CONCURRENT_DL self.max_concurrent = MAX_CONCURRENT_DL
self.paused = False self.paused = False
print( logger.info(
f"Celery Download Queue Manager initialized with max_concurrent={self.max_concurrent}" f"Celery Download Queue Manager initialized with max_concurrent={self.max_concurrent}"
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
<button <button
onClick={onDownload} onClick={onDownload}
disabled={!!status && status !== "error"} disabled={!!status && status !== "error"}
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed" className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-100 sm:opacity-0 sm:group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
title={ title={
status status
? status === "queued" ? status === "queued"

View File

@@ -53,6 +53,19 @@ function extractApiErrorMessage(error: unknown): string {
if (typeof data?.detail === "string") return data.detail; if (typeof data?.detail === "string") return data.detail;
if (typeof data?.message === "string") return data.message; if (typeof data?.message === "string") return data.message;
if (typeof data?.error === "string") return data.error; if (typeof data?.error === "string") return data.error;
// If data.error is an object, try to extract a message from it
if (typeof data?.error === "object" && data.error !== null && typeof data.error.message === "string") {
return data.error.message;
}
// If data is an object but none of the above matched, try JSON stringifying it
if (typeof data === "object" && data !== null) {
try {
return JSON.stringify(data);
} catch (e) {
// Fallback if stringify fails
return fallback;
}
}
} }
if (typeof anyErr?.message === "string") return anyErr.message; if (typeof anyErr?.message === "string") return anyErr.message;
return fallback; return fallback;
@@ -66,7 +79,6 @@ export function AccountsTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeService, setActiveService] = useState<Service>("spotify"); const [activeService, setActiveService] = useState<Service>("spotify");
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const { data: credentials, isLoading } = useQuery({ const { data: credentials, isLoading } = useQuery({
queryKey: ["credentials", activeService], queryKey: ["credentials", activeService],
@@ -85,15 +97,12 @@ export function AccountsTab() {
onSuccess: () => { onSuccess: () => {
toast.success("Account added successfully!"); toast.success("Account added successfully!");
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] }); queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify account in UI queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify/Deezer account in UI
setIsAdding(false); setIsAdding(false);
setSubmitError(null);
reset(); reset();
}, },
onError: (error) => { onError: (error) => {
const msg = extractApiErrorMessage(error); const msg = extractApiErrorMessage(error);
setSubmitError(msg);
toast.error(msg);
}, },
}); });
@@ -110,7 +119,6 @@ export function AccountsTab() {
}); });
const onSubmit: SubmitHandler<AccountFormData> = (data) => { const onSubmit: SubmitHandler<AccountFormData> = (data) => {
setSubmitError(null);
addMutation.mutate({ service: activeService, data }); addMutation.mutate({ service: activeService, data });
}; };
@@ -118,11 +126,6 @@ export function AccountsTab() {
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-line dark:border-border-dark rounded-lg mt-4 space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-line dark:border-border-dark rounded-lg mt-4 space-y-4">
<h4 className="font-semibold text-content-primary dark:text-content-primary-dark">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4> <h4 className="font-semibold text-content-primary dark:text-content-primary-dark">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4>
{submitError && (
<div className="text-error-text bg-error-muted border border-error rounded p-2 text-sm">
{submitError}
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="accountName" className="text-content-primary dark:text-content-primary-dark">Account Name</label> <label htmlFor="accountName" className="text-content-primary dark:text-content-primary-dark">Account Name</label>

View File

@@ -89,6 +89,7 @@ export function WatchTab() {
onSuccess: () => { onSuccess: () => {
toast.success("Watch settings saved successfully!"); toast.success("Watch settings saved successfully!");
queryClient.invalidateQueries({ queryKey: ["watchConfig"] }); queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate main config to refresh watch.enabled in SettingsProvider
}, },
onError: (error: any) => { onError: (error: any) => {
const message = error?.response?.data?.error || error?.message || "Unknown error"; const message = error?.response?.data?.error || error?.message || "Unknown error";

View File

@@ -1,14 +1,14 @@
import axios from "axios"; import axios from "axios";
import type { AxiosInstance } from "axios"; import type { AxiosInstance } from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type {
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
LoginResponse, LoginResponse,
AuthStatusResponse, AuthStatusResponse,
User, User,
CreateUserRequest, CreateUserRequest,
SSOStatusResponse SSOStatusResponse,
} from "@/types/auth"; } from "@/types/auth";
class AuthApiClient { class AuthApiClient {
@@ -38,7 +38,7 @@ class AuthApiClient {
} }
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error),
); );
// Response interceptor for error handling // Response interceptor for error handling
@@ -62,11 +62,11 @@ class AuthApiClient {
// Only clear token for auth-related endpoints // Only clear token for auth-related endpoints
const requestUrl = error.config?.url || ""; const requestUrl = error.config?.url || "";
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth"); const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
if (isAuthEndpoint) { if (isAuthEndpoint) {
// Clear invalid token only for auth endpoints // Clear invalid token only for auth endpoints
this.clearToken(); this.clearToken();
// Only show auth error if not during initial token check // Only show auth error if not during initial token check
if (!this.isCheckingToken) { if (!this.isCheckingToken) {
toast.error("Session Expired", { toast.error("Session Expired", {
@@ -96,11 +96,12 @@ class AuthApiClient {
description: "The server did not respond in time. Please try again later.", description: "The server did not respond in time. Please try again later.",
}); });
} else { } else {
const errorMessage = error.response?.data?.detail || const errorMessage =
error.response?.data?.error || error.response?.data?.detail ||
error.message || error.response?.data?.error ||
"An unknown error occurred."; error.message ||
"An unknown error occurred.";
// Don't show toast errors during token validation // Don't show toast errors during token validation
if (!this.isCheckingToken) { if (!this.isCheckingToken) {
toast.error("API Error", { toast.error("API Error", {
@@ -109,14 +110,14 @@ class AuthApiClient {
} }
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
} }
// Enhanced token management with storage options // Enhanced token management with storage options
setToken(token: string | null, rememberMe: boolean = true) { setToken(token: string | null, rememberMe: boolean = true) {
this.token = token; this.token = token;
if (token) { if (token) {
if (rememberMe) { if (rememberMe) {
// Store in localStorage for persistence across browser sessions // Store in localStorage for persistence across browser sessions
@@ -149,16 +150,16 @@ class AuthApiClient {
// Try localStorage first (persistent) // Try localStorage first (persistent)
let token = localStorage.getItem("auth_token"); let token = localStorage.getItem("auth_token");
let isRemembered = localStorage.getItem("auth_remember") === "true"; let isRemembered = localStorage.getItem("auth_remember") === "true";
// If not found in localStorage, try sessionStorage // If not found in localStorage, try sessionStorage
if (!token) { if (!token) {
token = sessionStorage.getItem("auth_token"); token = sessionStorage.getItem("auth_token");
isRemembered = false; isRemembered = false;
} }
if (token) { if (token) {
this.token = token; this.token = token;
console.log(`Loaded ${isRemembered ? 'persistent' : 'session'} token from storage`); console.log(`Loaded ${isRemembered ? "persistent" : "session"} token from storage`);
} }
} }
@@ -166,7 +167,7 @@ class AuthApiClient {
// Preserve the remember me preference when clearing invalid tokens // Preserve the remember me preference when clearing invalid tokens
const wasRemembered = this.isRemembered(); const wasRemembered = this.isRemembered();
this.token = null; this.token = null;
if (wasRemembered) { if (wasRemembered) {
// Keep the remember preference but remove the invalid token // Keep the remember preference but remove the invalid token
localStorage.removeItem("auth_token"); localStorage.removeItem("auth_token");
@@ -196,7 +197,7 @@ class AuthApiClient {
try { try {
this.isCheckingToken = true; this.isCheckingToken = true;
const response = await this.apiClient.get<AuthStatusResponse>("/auth/status"); const response = await this.apiClient.get<AuthStatusResponse>("/auth/status");
// If the token is valid and user is authenticated // If the token is valid and user is authenticated
if (response.data.auth_enabled && response.data.authenticated && response.data.user) { if (response.data.auth_enabled && response.data.authenticated && response.data.user) {
console.log("Stored token is valid, user authenticated"); console.log("Stored token is valid, user authenticated");
@@ -224,24 +225,24 @@ class AuthApiClient {
async login(credentials: LoginRequest, rememberMe: boolean = true): Promise<LoginResponse> { async login(credentials: LoginRequest, rememberMe: boolean = true): Promise<LoginResponse> {
const response = await this.apiClient.post<LoginResponse>("/auth/login", credentials); const response = await this.apiClient.post<LoginResponse>("/auth/login", credentials);
const loginData = response.data; const loginData = response.data;
// Store the token with remember preference // Store the token with remember preference
this.setToken(loginData.access_token, rememberMe); this.setToken(loginData.access_token, rememberMe);
toast.success("Login Successful", { toast.success("Login Successful", {
description: `Test , ${loginData.user.username}!`, description: `Welcome, ${loginData.user.username}!`,
}); });
return loginData; return loginData;
} }
async register(userData: RegisterRequest): Promise<{ message: string }> { async register(userData: RegisterRequest): Promise<{ message: string }> {
const response = await this.apiClient.post("/auth/register", userData); const response = await this.apiClient.post("/auth/register", userData);
toast.success("Registration Successful", { toast.success("Registration Successful", {
description: "Account created successfully! You can now log in.", description: "Account created successfully! You can now log in.",
}); });
return response.data; return response.data;
} }
@@ -252,9 +253,9 @@ class AuthApiClient {
// Ignore logout errors - clear token anyway // Ignore logout errors - clear token anyway
console.warn("Logout request failed:", error); console.warn("Logout request failed:", error);
} }
this.clearAllAuthData(); // Changed from this.clearToken() this.clearAllAuthData(); // Changed from this.clearToken()
toast.success("Logged Out", { toast.success("Logged Out", {
description: "You have been logged out successfully.", description: "You have been logged out successfully.",
}); });
@@ -270,11 +271,11 @@ class AuthApiClient {
current_password: currentPassword, current_password: currentPassword,
new_password: newPassword, new_password: newPassword,
}); });
toast.success("Password Changed", { toast.success("Password Changed", {
description: "Your password has been updated successfully.", description: "Your password has been updated successfully.",
}); });
return response.data; return response.data;
} }
@@ -286,31 +287,31 @@ class AuthApiClient {
async deleteUser(username: string): Promise<{ message: string }> { async deleteUser(username: string): Promise<{ message: string }> {
const response = await this.apiClient.delete(`/auth/users/${username}`); const response = await this.apiClient.delete(`/auth/users/${username}`);
toast.success("User Deleted", { toast.success("User Deleted", {
description: `User ${username} has been deleted.`, description: `User ${username} has been deleted.`,
}); });
return response.data; return response.data;
} }
async updateUserRole(username: string, role: "user" | "admin"): Promise<{ message: string }> { async updateUserRole(username: string, role: "user" | "admin"): Promise<{ message: string }> {
const response = await this.apiClient.put(`/auth/users/${username}/role`, { role }); const response = await this.apiClient.put(`/auth/users/${username}/role`, { role });
toast.success("Role Updated", { toast.success("Role Updated", {
description: `User ${username} role updated to ${role}.`, description: `User ${username} role updated to ${role}.`,
}); });
return response.data; return response.data;
} }
async createUser(userData: CreateUserRequest): Promise<{ message: string }> { async createUser(userData: CreateUserRequest): Promise<{ message: string }> {
const response = await this.apiClient.post("/auth/users/create", userData); const response = await this.apiClient.post("/auth/users/create", userData);
toast.success("User Created", { toast.success("User Created", {
description: `User ${userData.username} created successfully.`, description: `User ${userData.username} created successfully.`,
}); });
return response.data; return response.data;
} }
@@ -318,11 +319,11 @@ class AuthApiClient {
const response = await this.apiClient.put(`/auth/users/${username}/password`, { const response = await this.apiClient.put(`/auth/users/${username}/password`, {
new_password: newPassword, new_password: newPassword,
}); });
toast.success("Password Reset", { toast.success("Password Reset", {
description: `Password for ${username} has been reset successfully.`, description: `Password for ${username} has been reset successfully.`,
}); });
return response.data; return response.data;
} }
@@ -336,7 +337,7 @@ class AuthApiClient {
async handleSSOToken(token: string, rememberMe: boolean = true): Promise<User> { async handleSSOToken(token: string, rememberMe: boolean = true): Promise<User> {
// Set the token and get user info // Set the token and get user info
this.setToken(token, rememberMe); this.setToken(token, rememberMe);
// Validate the token and get user data // Validate the token and get user data
const tokenValidation = await this.validateStoredToken(); const tokenValidation = await this.validateStoredToken();
if (tokenValidation.isValid && tokenValidation.userData?.user) { if (tokenValidation.isValid && tokenValidation.userData?.user) {
@@ -374,4 +375,4 @@ class AuthApiClient {
export const authApiClient = new AuthApiClient(); export const authApiClient = new AuthApiClient();
// Export the client as default for backward compatibility // Export the client as default for backward compatibility
export default authApiClient.client; export default authApiClient.client;

View File

@@ -0,0 +1,15 @@
export interface ParsedSpotifyUrl {
type: "track" | "album" | "playlist" | "artist" | "unknown";
id: string;
}
export const parseSpotifyUrl = (url: string): ParsedSpotifyUrl => {
const match = url.match(/https:\/\/open\.spotify\.com(?:\/intl-[a-z]{2})?\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)(?:\?.*)?/);
if (match) {
return {
type: match[1] as ParsedSpotifyUrl["type"],
id: match[2],
};
}
return { type: "unknown", id: "" };
};

View File

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

View File

@@ -3,22 +3,26 @@ import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { toast } from "sonner"; import { toast } from "sonner";
import type { TrackType, AlbumType, SearchResult } from "@/types/spotify"; import type { TrackType, AlbumType, SearchResult } from "@/types/spotify";
import { parseSpotifyUrl } from "@/lib/spotify-utils";
import { QueueContext } from "@/contexts/queue-context"; import { QueueContext } from "@/contexts/queue-context";
import { SearchResultCard } from "@/components/SearchResultCard"; import { SearchResultCard } from "@/components/SearchResultCard";
import { indexRoute } from "@/router"; import { indexRoute } from "@/router";
import { Music, Disc, User, ListMusic } from "lucide-react"; import { Music, Disc, User, ListMusic } from "lucide-react";
import { authApiClient } from "@/lib/api-client";
import { useSettings } from "@/contexts/settings-context";
import { FaEye, FaDownload } from "react-icons/fa";
// Utility function to safely get properties from search results // Utility function to safely get properties from search results
const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => { const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => {
try { try {
let current = obj; let current = obj;
for (const key of path) { for (const key of path) {
if (current == null || typeof current !== 'object') { if (current == null || typeof current !== "object") {
return fallback; return fallback;
} }
current = current[key]; current = current[key];
} }
return current ?? fallback; return (current ?? fallback) as T;
} catch { } catch {
return fallback; return fallback;
} }
@@ -31,18 +35,23 @@ export const Home = () => {
const { q, type } = useSearch({ from: "/" }); const { q, type } = useSearch({ from: "/" });
const { items: allResults } = indexRoute.useLoaderData(); const { items: allResults } = indexRoute.useLoaderData();
const isLoading = useRouterState({ select: (s) => s.status === "pending" }); const isLoading = useRouterState({ select: (s) => s.status === "pending" });
const { settings } = useSettings();
const [query, setQuery] = useState(q || ""); const [query, setQuery] = useState(q || "");
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track"); const [searchType, setSearchType] = useState<
"track" | "album" | "artist" | "playlist"
>(type || "track");
const [debouncedQuery] = useDebounce(query, 500); const [debouncedQuery] = useDebounce(query, 500);
const [activeTab, setActiveTab] = useState<"search" | "bulkAdd">("search");
const [linksInput, setLinksInput] = useState("");
const [isBulkAdding, setIsBulkAdding] = useState(false);
const [isBulkWatching, setIsBulkWatching] = useState(false);
const [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]); const [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const context = useContext(QueueContext); const context = useContext(QueueContext);
const loaderRef = useRef<HTMLDivElement | null>(null); const loaderRef = useRef<HTMLDivElement | null>(null);
// Removed scroll locking on mobile empty state to avoid blocking scroll globally
useEffect(() => { useEffect(() => {
navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) }); navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) });
}, [debouncedQuery, searchType, navigate]); }, [debouncedQuery, searchType, navigate]);
@@ -56,6 +65,131 @@ export const Home = () => {
} }
const { addItem } = context; const { addItem } = context;
const handleAddBulkLinks = useCallback(async () => {
const allLinks = linksInput
.split("\n")
.map((link) => link.trim())
.filter(Boolean);
if (allLinks.length === 0) {
toast.info("No links provided to add.");
return;
}
const supportedLinks: string[] = [];
const unsupportedLinks: string[] = [];
allLinks.forEach((link) => {
const parsed = parseSpotifyUrl(link);
if (parsed.type !== "unknown") {
supportedLinks.push(link);
} else {
unsupportedLinks.push(link);
}
});
if (unsupportedLinks.length > 0) {
toast.warning("Some links are not supported and will be skipped.", {
description: `Unsupported: ${unsupportedLinks.join(", ")}`,
});
}
if (supportedLinks.length === 0) {
toast.info("No supported links to add.");
return;
}
setIsBulkAdding(true);
try {
const response = await authApiClient.client.post("/bulk/bulk-add-spotify-links", {
links: supportedLinks,
});
const { count, failed_links } = response.data;
if (failed_links && failed_links.length > 0) {
toast.warning("Bulk Add Completed with Warnings", {
description: `${count} links added. Failed to add ${failed_links.length} links: ${failed_links.join(
", "
)}`,
});
} else {
toast.success("Bulk Add Successful", {
description: `${count} links added to queue.`,
});
}
setLinksInput(""); // Clear input after successful add
} catch (error: any) {
const errorMessage = error.response?.data?.detail?.message || error.message;
const failedLinks = error.response?.data?.detail?.failed_links || [];
let description = errorMessage;
if (failedLinks.length > 0) {
description += ` Failed links: ${failedLinks.join(", ")}`;
}
toast.error("Bulk Add Failed", {
description: description,
});
if (failedLinks.length > 0) {
console.error("Failed links:", failedLinks);
}
} finally {
setIsBulkAdding(false);
}
}, [linksInput]);
const handleWatchBulkLinks = useCallback(async () => {
const links = linksInput
.split("\n")
.map((link) => link.trim())
.filter(Boolean);
if (links.length === 0) {
toast.info("No links provided to watch.");
return;
}
const supportedLinks: { type: "artist" | "playlist"; id: string }[] = [];
const unsupportedLinks: string[] = [];
links.forEach((link) => {
const parsed = parseSpotifyUrl(link);
if (parsed.type === "artist" || parsed.type === "playlist") {
supportedLinks.push({ type: parsed.type, id: parsed.id });
} else {
unsupportedLinks.push(link);
}
});
if (unsupportedLinks.length > 0) {
toast.warning("Some links are not supported for watching.", {
description: `Unsupported: ${unsupportedLinks.join(", ")}`,
});
}
if (supportedLinks.length === 0) {
toast.info("No supported links to watch.");
return;
}
setIsBulkWatching(true);
try {
const watchPromises = supportedLinks.map((item) =>
authApiClient.client.put(`/${item.type}/watch/${item.id}`)
);
await Promise.all(watchPromises);
toast.success("Bulk Watch Successful", {
description: `${supportedLinks.length} supported links added to watchlist.`,
});
setLinksInput(""); // Clear input after successful add
} catch (error: any) {
const errorMessage = error.response?.data?.detail?.message || error.message;
toast.error("Bulk Watch Failed", {
description: errorMessage,
});
} finally {
setIsBulkWatching(false);
}
}, [linksInput]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
setIsLoadingMore(true); setIsLoadingMore(true);
setTimeout(() => { setTimeout(() => {
@@ -74,7 +208,7 @@ export const Home = () => {
loadMore(); loadMore();
} }
}, },
{ threshold: 1.0 }, { threshold: 1.0 }
); );
const currentLoader = loaderRef.current; const currentLoader = loaderRef.current;
@@ -95,7 +229,7 @@ export const Home = () => {
addItem({ spotifyId: track.id, type: "track", name: track.name, artist: artistName }); addItem({ spotifyId: track.id, type: "track", name: track.name, artist: artistName });
toast.info(`Adding ${track.name} to queue...`); toast.info(`Adding ${track.name} to queue...`);
}, },
[addItem], [addItem]
); );
const handleDownloadAlbum = useCallback( const handleDownloadAlbum = useCallback(
@@ -104,53 +238,63 @@ export const Home = () => {
addItem({ spotifyId: album.id, type: "album", name: album.name, artist: artistName }); addItem({ spotifyId: album.id, type: "album", name: album.name, artist: artistName });
toast.info(`Adding ${album.name} to queue...`); toast.info(`Adding ${album.name} to queue...`);
}, },
[addItem], [addItem]
); );
const resultComponent = useMemo(() => { const resultComponent = useMemo(() => {
return ( return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{displayedResults.map((item) => { {displayedResults
// Add safety checks for essential properties .map((item) => {
if (!item || !item.id || !item.name || !item.model) { // Add safety checks for essential properties
return null; if (!item || !item.id || !item.name || !item.model) {
} return null;
}
let imageUrl; let imageUrl;
let onDownload; let onDownload: (() => void) | undefined;
let subtitle; let subtitle: string | undefined;
if (item.model === "track") { if (item.model === "track") {
imageUrl = safelyGetProperty(item, ['album', 'images', '0', 'url'], undefined); imageUrl = safelyGetProperty(item, ["album", "images", "0", "url"], undefined);
onDownload = () => handleDownloadTrack(item as TrackType); onDownload = () => handleDownloadTrack(item as TrackType);
const artists = safelyGetProperty(item, ['artists'], []); const artists = safelyGetProperty(item, ["artists"], []);
subtitle = Array.isArray(artists) ? artists.map((a: any) => safelyGetProperty(a, ['name'], 'Unknown')).join(", ") : "Unknown Artist"; subtitle = Array.isArray(artists)
} else if (item.model === "album") { ? artists
imageUrl = safelyGetProperty(item, ['images', '0', 'url'], undefined); .map((a: any) => safelyGetProperty(a, ["name"], "Unknown"))
onDownload = () => handleDownloadAlbum(item as AlbumType); .join(", ")
const artists = safelyGetProperty(item, ['artists'], []); : "Unknown Artist";
subtitle = Array.isArray(artists) ? artists.map((a: any) => safelyGetProperty(a, ['name'], 'Unknown')).join(", ") : "Unknown Artist"; } else if (item.model === "album") {
} else if (item.model === "artist") { imageUrl = safelyGetProperty(item, ["images", "0", "url"], undefined);
imageUrl = safelyGetProperty(item, ['images', '0', 'url'], undefined); onDownload = () => handleDownloadAlbum(item as AlbumType);
subtitle = "Artist"; const artists = safelyGetProperty(item, ["artists"], []);
} else if (item.model === "playlist") { subtitle = Array.isArray(artists)
imageUrl = safelyGetProperty(item, ['images', '0', 'url'], undefined); ? artists
const ownerName = safelyGetProperty(item, ['owner', 'display_name'], 'Unknown'); .map((a: any) => safelyGetProperty(a, ["name"], "Unknown"))
subtitle = `By ${ownerName}`; .join(", ")
} : "Unknown Artist";
} else if (item.model === "artist") {
imageUrl = safelyGetProperty(item, ["images", "0", "url"], undefined);
subtitle = "Artist";
} else if (item.model === "playlist") {
imageUrl = safelyGetProperty(item, ["images", "0", "url"], undefined);
const ownerName = safelyGetProperty(item, ["owner", "display_name"], "Unknown");
subtitle = `By ${ownerName}`;
}
return ( return (
<SearchResultCard <SearchResultCard
key={item.id} key={item.id}
id={item.id} id={item.id}
name={item.name} name={item.name}
type={item.model} type={item.model}
imageUrl={imageUrl} imageUrl={imageUrl}
subtitle={subtitle} subtitle={subtitle}
onDownload={onDownload} onDownload={onDownload}
/> />
); );
}).filter(Boolean)} {/* Filter out null components */} })
.filter(Boolean)}
</div> </div>
); );
}, [displayedResults, handleDownloadTrack, handleDownloadAlbum]); }, [displayedResults, handleDownloadTrack, handleDownloadAlbum]);
@@ -160,53 +304,151 @@ export const Home = () => {
<div className="text-center mb-4 md:mb-8 px-4 md:px-0"> <div className="text-center mb-4 md:mb-8 px-4 md:px-0">
<h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1> <h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1>
</div> </div>
<div className="flex flex-col sm:flex-row gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
<input {/* Tabs */}
type="text" <div className="flex justify-center mb-4 md:mb-6 px-4 md:px-0 border-b border-gray-300 dark:border-gray-700">
value={query} <button
onChange={(e) => setQuery(e.target.value)} className={`flex-1 py-2 text-center transition-colors duration-200 ${
placeholder="Search for a track, album, artist, or playlist" activeTab === "search"
className="flex-1 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" ? "border-b-2 border-green-500 text-green-500"
/> : "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
<div className="flex gap-2"> }`}
{["track", "album", "artist", "playlist"].map((typeOption) => ( onClick={() => setActiveTab("search")}
<button >
key={typeOption} Search
onClick={() => setSearchType(typeOption as "track" | "album" | "artist" | "playlist")} </button>
className={`flex items-center gap-1 p-2 rounded-md text-sm font-medium transition-colors border ${ <button
searchType === typeOption className={`flex-1 py-2 text-center transition-colors duration-200 ${
? "bg-green-600 text-white border-green-600" activeTab === "bulkAdd"
: "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600" ? "border-b-2 border-green-500 text-green-500"
}`} : "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
}`}
> onClick={() => setActiveTab("bulkAdd")}
{ >
{ Bulk Add
track: <Music size={16} />, </button>
album: <Disc size={16} />,
artist: <User size={16} />,
playlist: <ListMusic size={16} />,
}[typeOption]
}
{typeOption.charAt(0).toUpperCase() + typeOption.slice(1)}
</button>
))}
</div>
</div> </div>
<div className={`flex-1 px-4 md:px-0 pb-4 ${
// Only restrict overflow on mobile when there are results, otherwise allow normal behavior {activeTab === "search" && (
displayedResults.length > 0 ? 'overflow-y-auto md:overflow-visible' : '' <>
}`}> <div className="flex flex-col gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
{isLoading ? ( <div className="flex flex-col sm:flex-row gap-3">
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p> <input
) : ( type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a track, album, or artist"
className="flex-1 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"
/>
{/* Icon buttons for search type (larger screens) */}
<div className="hidden sm:flex gap-2 items-center">
{(["track", "album", "artist", "playlist"] as const).map((typeOption) => (
<button
key={typeOption}
onClick={() => setSearchType(typeOption)}
aria-label={`Search ${typeOption}`}
className={`flex items-center gap-1 p-2 rounded-md text-sm font-medium transition-colors border ${
searchType === typeOption
? "bg-green-600 text-white border-green-600"
: "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
{
{
track: <Music size={16} />,
album: <Disc size={16} />,
artist: <User size={16} />,
playlist: <ListMusic size={16} />,
}[typeOption]
}
<span className="hidden md:inline">
{typeOption.charAt(0).toUpperCase() + typeOption.slice(1)}
</span>
</button>
))}
</div>
{/* Select for smaller screens */}
<select
value={searchType}
onChange={(e) =>
setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")
}
className="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 sm:hidden"
>
<option value="track">Track</option>
<option value="album">Album</option>
<option value="artist">Artist</option>
<option value="playlist">Playlist</option>
</select>
</div>
</div>
<div
className={`flex-1 px-4 md:px-0 pb-4 ${
// Only restrict overflow on mobile when there are results, otherwise allow normal behavior
displayedResults.length > 0 ? "overflow-y-auto md:overflow-visible" : ""
}`}
>
{isLoading ? (
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
) : (
<> <>
{resultComponent} {resultComponent}
<div ref={loaderRef} /> <div ref={loaderRef} />
{isLoadingMore && <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>} {isLoadingMore && (
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>
)}
</> </>
)} )}
</div> </div>
</>
)}
{activeTab === "bulkAdd" && (
<div className="flex flex-col gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
<textarea
className="w-full h-60 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="Paste Spotify links here, one per line..."
value={linksInput}
onChange={(e) => setLinksInput(e.target.value)}
></textarea>
<div className="flex justify-end gap-3">
<button
onClick={() => setLinksInput("")} // Clear input
className="px-4 py-2 bg-gray-300 dark:bg-gray-700 text-content-primary dark:text-content-primary-dark rounded-md hover:bg-gray-400 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Clear
</button>
<button
onClick={handleAddBulkLinks}
disabled={isBulkAdding}
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isBulkAdding ? "Adding..." : (
<>
<FaDownload className="icon-inverse" /> Download
</>
)}
</button>
{settings?.watch?.enabled && (
<button
onClick={handleWatchBulkLinks}
disabled={isBulkWatching}
className="px-4 py-2 bg-error hover:bg-error-hover text-button-primary-text rounded-md flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="Only Spotify Artist and Playlist links are supported for watching."
>
{isBulkWatching ? "Watching..." : (
<>
<FaEye className="icon-inverse" /> Watch
</>
)}
</button>
)}
</div>
</div>
)}
</div> </div>
); );
}; };