Merge pull request #213 from Xoconoch/dev

3.0
This commit is contained in:
Xoconoch
2025-08-07 20:45:27 -06:00
committed by GitHub
100 changed files with 18169 additions and 4366 deletions

View File

@@ -4,6 +4,8 @@
.gitattributes .gitattributes
# Docker # Docker
docker-compose.yaml
docker-compose.yml
Dockerfile Dockerfile
.dockerignore .dockerignore
@@ -54,3 +56,7 @@ static/js/*
logs/ logs/
data data
tests/ tests/
# Non-essential files
docs/
README.md

View File

@@ -1,19 +1,63 @@
# Docker Compose environment variables# Delete all comments of this when deploying (everything that is ) ###
### Main configuration file of the server. If you
### plan to have this only for personal use, you
### can leave the defaults as they are.
###
### If you plan on using for a server,
### see [insert docs url]
###
# Redis connection (external or internal) # Interface to bind to. Unless you know what you're doing, don't change this
HOST=0.0.0.0
# Redis connection (external or internal).
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_DB=0 REDIS_DB=0
REDIS_PASSWORD=CHANGE_ME 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
# User ID for the container
PUID=1000
# Group ID for the container
# User and group ID for the container. Sets the owner of the downloaded files.
PUID=1000
PGID=1000 PGID=1000
# Optional: Sets the default file permissions for newly created files within the container. # Optional: Sets the default file permissions for newly created files within the container.
UMASK=0022 UMASK=0022
###
### Multi-user settings, disabled by default.
###
# Enable authentication (i.e. multi-user mode).
ENABLE_AUTH=false
# Basic Authentication settings.
JWT_SECRET=long-random-text
# How much a session persists, in hours. 720h = 30 days.
JWT_EXPIRATION_HOURS=720
# Default admins creds, please change the password or delete this account after you create your own
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123
# Whether to allow new users to register themselves or leave that only available for admins
DISABLE_REGISTRATION=false
# SSO Configuration
SSO_ENABLED=true
SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback
FRONTEND_URL=http://127.0.0.1:7171
# Google SSO (get from Google Cloud Console)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# GitHub SSO (get from GitHub Developer Settings)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

4
.gitignore vendored
View File

@@ -38,4 +38,6 @@ static/js
data data
logs/ logs/
.env .env
Test.py Test.py
spotizerr-ui/dev-dist
celerybeat-schedule

299
README.md Executable file → Normal file
View File

@@ -1,164 +1,249 @@
# SUPPORT YOUR ARTISTS # SUPPORT YOUR ARTISTS
As of 2025, Spotify pays an average of $0.005 per stream to the artist. That means that if you give the equivalent of $5 directly to them (like merch, buying cds, or just donating), you can """ethically""" listen to them a total of 1000 times. Of course, nobody thinks spotify payment is fair, so preferably you should give more, but $5 is the bare minimum. Big names prolly don't need those $5 dollars, but it might be _the_ difference between going out of business or not for that indie rock band you like. As of 2025, Spotify pays an average of $0.005 per stream to the artist. That means that if you give the equivalent of $5 directly to them (like merch, buying CDs, or just donating), you can """ethically""" listen to them a total of 1000 times. Of course, nobody thinks Spotify payment is fair, so preferably you should give more, but $5 is the bare minimum. Big names probably don't need those $5 dollars, but it might be _the_ difference between going out of business or not for that indie rock band you like.
# Spotizerr # Spotizerr
Music downloader which combines the best of two worlds: Spotify's catalog and Deezer's quality. Search for a track using Spotify search api, click download and, depending on your preferences, it will download directly from Spotify or firstly try to download from Deezer, if it fails, it'll fallback to Spotify. A self-hosted music download manager with a lossless twist. Download everything from Spotify, and if it happens to also be on Deezer, download from there so you get those tasty FLACs.
## Desktop interface ## Why?
![image](https://github.com/user-attachments/assets/8093085d-cad3-4cba-9a0d-1ad6cae63e4f)
![image](https://github.com/user-attachments/assets/ac5daa0f-769f-43b0-b78a-8db343219861) If you self-host a music server with other users than yourself, you almost certainly have realized that the process of adding requested items to the library is not without its friction. No matter how automated your flow is, unless your users are tech-savvy enough to do it themselves, chances are the process always needs some type of manual intervention from you, be it to rip the CDs yourself, tag some random files from youtube, etc. No more! Spotizerr allows for your users to access a nice little frontend where they can add whatever they want to the library without bothering you. What's that? You want some screenshots? Sure, why not:
![image](https://github.com/user-attachments/assets/fb8b2295-f6b6-412f-87da-69f63b56247c) <details>
<summary>Main page</summary>
<img width="393" height="743" alt="image" src="https://github.com/user-attachments/assets/f60e6c51-2ab2-4c4f-8572-a4c43e781758" />
</details>
<details>
<summary>Search results</summary>
<img width="385" height="740" alt="image" src="https://github.com/user-attachments/assets/0208e063-092e-4538-b092-5b1ede57fc58" />
</details>
<details>
<summary>Track view</summary>
<img width="1632" height="946" alt="image" src="https://github.com/user-attachments/assets/7a2f8240-a3ab-4b71-a772-f983d6bfd691" />
</details>
<details>
<summary>Download history</summary>
<img width="1588" height="994" alt="image" src="https://github.com/user-attachments/assets/e34d7dbb-29e3-4d75-bcbd-0cee03fa57dc" />
</details>
## Mobile interface ## ✨ Key Features
![Screen Shot 2025-03-15 at 21 02 27](https://github.com/user-attachments/assets/cee9318e-9451-4a43-9e24-20e05f4abc5b) ![Screen Shot 2025-03-15 at 21 02 45](https://github.com/user-attachments/assets/d5801795-ba31-4589-a82d-d208f1ea6d62) ### 🎵 **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
## Features ### 📱 **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
- Browse through artist, albums, playlists and tracks and jump between them ### 🤖 **Intelligent Monitoring**
- Dual-service integration (Spotify & Deezer) - **Playlist Watching** - Automatically download new tracks added to Spotify playlists
- Direct URL downloads for Spotify tracks/albums/playlists/artists - **Artist Watching** - Monitor artists for new releases and download them automatically
- Search using spotify's catalog - **Configurable Intervals** - Set how often to check for updates
- Credential management system - **Manual Triggers** - Force immediate checks when needed
- Download queue with real-time progress
- Service fallback system when downloading*
- Real time downloading**
- Quality selector***
- Customizable track number padding (01. Track or 1. Track)
- Customizable retry parameters (max attempts, delay, increase per retry)
*It will first try to download each track from Deezer and only if it fails, will grab it from Spotify ### ⚡ **Advanced Queue Management**
**Only for spotify. For each track, it matches its length with the time it takes to download it - **Concurrent Downloads** - Configure multiple simultaneous downloads
***Restrictions per account tier apply (see - **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
## Prerequisites ### 🔧 **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
- Docker, duh ### 📊 **Comprehensive History**
- Spotify credentials (see [Spotify Credentials Setup](#spotify-credentials-setup)) - **Download Tracking** - Complete history of all downloads with metadata
- Spotify client ID and client secret (see [Spotify Developer Setup](#spotify-developer-setup)) - **Success Analytics** - Track success rates, failures, and skipped items
- Deezer ARL token (see [Deezer ARL Setup](#deezer-arl-setup)) - **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
## Installation ### 👥 **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
1. Create project directory: ## 🚀 Quick Start
```bash
mkdir spotizerr && cd spotizerr
```
2. Setup a `.env` file following the `.env.example` file from this repo and update all variables (e.g. Redis credentials, PUID/PGID, UMASK). ### Prerequisites
3. Copy `docker-compose.yml` from this repo. - Docker and Docker Compose
4. Launch containers: - Spotify account(s)
```bash - Deezer account(s) (optional, but recommended)
docker compose up -d - Spotify API credentials (Client ID & Secret from [Spotify Developer Dashboard](https://developer.spotify.com/dasboard))
```
_Note: an UnRaid template is available in the file spotizerr.xml_
Access at: `http://localhost:7171` ### Installation
## Configuration 1. **Create project directory**
```bash
mkdir spotizerr && cd spotizerr
```
### Spotify Setup 2. **Setup environment file**
```bash
# Download .env.example from the repository and create .env
# Update all variables (e.g. Redis credentials, PUID/PGID, UMASK)
```
Spotify is VERY petty, so, in order to simplify the process, another tool was created to perform this part of the setup; see [spotizerr-auth](https://github.com/Xoconoch/spotizerr-auth) 3. **Copy docker-compose.yaml**
```bash
# Download docker-compose.yaml from the repository
```
### Deezer ARL Setup 4. **Start the application**
```bash
docker compose up -d
```
#### Chrome-based browsers 5. **Next steps**
- Before doing anything, it is recommended to go straight to [Configuration](#-configuration)
Open the [web player](https://www.deezer.com/) ## 🔧 Configuration
There, press F12 and select "Application" ### Service Accounts Setup
![image](https://github.com/user-attachments/assets/22e61d91-50b4-48f2-bba7-28ef45b45ee5) 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.
Expand Cookies section and select the "https://www.deezer.com". Find the "arl" cookie and double-click the "Cookie Value" tab's text. 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
![image](https://github.com/user-attachments/assets/75a67906-596e-42a0-beb0-540f2748b16e) 3. **Configure Download Settings**
- Set audio quality preferences
- Configure output format and naming
- Adjust concurrent download limits
Copy that value and paste it into the correspondant setting in Spotizerr ### Watch System Setup
#### Firefox-based browsers 1. **Enable Monitoring**
- Go to Settings → Watch
- Enable the watch system
- Set check intervals
Open the [web player](https://www.deezer.com/) 2. **Add Items to Watch**
- Search for playlists or artists
- Click the "Watch" button
- New content will be automatically downloaded
There, press F12 and select "Storage" ## 📋 Usage Examples
![image](https://github.com/user-attachments/assets/601be3fb-1ec9-44d9-be4f-28b1d853df2f) ### 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
Click the cookies host "https://www.deezer.com" and find the "arl" cookie. ### 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
![image](https://github.com/user-attachments/assets/ef8ea256-2c13-4780-ae9f-71527466df56) ### 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
Copy that value and paste it into the correspondant setting in Spotizerr ## 🔍 Advanced Features
## Usage ### 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
### Basic Operations ### Quality Settings
1. **Search**: - **Spotify**: OGG 96k, 160k, and 320k (320k requires Premium)
- Enter query in search bar - **Deezer**: MP3 128k, MP3 320k (sometimes requires Premium), and FLAC (Premium only)
- Select result type (Track/Album/Playlist/Artist) - **Conversion**: Convert to any supported format with custom bitrate
- Click search button or press Enter
2. **Download**: ### Fallback System
- Click download button on any result - Configure primary and fallback services
- For artists, you can select a specific subset of albums you want to download - Automatically switches if primary service fails
- Monitor progress in queue sidebar - Useful for geographic restrictions or account limits
3. **Direct URLs**: ### Real-time Mode
- Paste Spotify URLs directly into search - **Spotify only**: Matches track length with download time for optimal timing
- Supports tracks, albums, playlists and artists (this will download the whole discogrpahy, you've been warned)
### Advanced Features ## 🆘 Support & Troubleshooting
- **Fallback System**:
- Enable in settings
- Uses Deezer as primary when downloading with Spotify fallback
- **Multiple Accounts**: ### Common Issues
- Manage credentials in settings
- Switch active accounts per service
- **Quality selector** **Downloads not starting?**
- For spotify: OGG 96k, 160k and 320k (premium only) - Check that service accounts are configured correctly
- For deezer: MP3 128k, MP3 320k (sometimes premium, it varies) and FLAC (premium only) - Verify API credentials are valid
- Ensure sufficient storage space
- **Customizable formatting**:
- Track number padding (01. Track or 1. Track)
- Adjust retry parameters (max attempts, delay, delay increase)
- **Watching artits/playlists**
- Start watching a spotify playlist and its tracks will be downloaded dynamically as it updates.
- Start watching a spotify artist and their albums will be automatically downloaded, never miss a release!
## Troubleshooting
**Common Issues**:
- "No accounts available" error: Add credentials in settings - "No accounts available" error: Add credentials in settings
- Download failures: Check credential validity
- Queue stalls: Verify service connectivity
- Audiokey related: Spotify rate limit, let it cooldown about 30 seconds and click retry
- API errors: Ensure your Spotify client ID and client secret are correctly entered
**Log Locations**: **Download failures?**
- Application Logs: `docker logs spotizerr` (for main app and Celery workers) - Check credential validity and account status
- Individual Task Logs: `./logs/tasks/` (inside the container, maps to your volume) - Audiokey related errors: Spotify rate limit, wait ~30 seconds and retry
- API errors: Ensure Spotify Client ID and Secret are correct
**Watch system not working?**
- Enable the watch system in settings
- Check watch intervals aren't too frequent
- Verify items are properly added to watchlist
**Authentication problems?**
- Check JWT secret is set
- Verify SSO credentials if using
- Clear browser cache and cookies
**Queue stalling?**
- Verify service connectivity
- Check for network issues
### Logs
Access logs via Docker:
```bash
docker logs spotizerr
```
**Log Locations:**
- Application Logs: `docker logs spotizerr` (main app and Celery workers)
- Individual Task Logs: `./logs/tasks/` (inside container, maps to your volume)
- Credentials: `./data/creds/` - Credentials: `./data/creds/`
- Configuration Files: `./data/config/` - Configuration Files: `./data/config/`
- Downloaded Music: `./downloads/` - Downloaded Music: `./downloads/`
- Watch Feature Database: `./data/watch/` - Watch Feature Database: `./data/watch/`
- Download History Database: `./data/history/` - Download History Database: `./data/history/`
- Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is set to `/app/cache/.cache` and mapped) - Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is mapped)
## Notes ## 🤝 Contributing
- This app has no way of authentication, if you plan on exposing it, put a security layer on top of it (such as cloudflare tunnel, authelia or just leave it accessible only through a vpn) 1. Fork the repository
- Credentials are stored in plaintext - secure your installation 2. Create a feature branch
3. Make your changes
4. Submit a pull request
## 📄 License
This project is licensed under the GPL yada yada, see [LICENSE](LICENSE) file for details.
## ⚠️ Important Notes
- **Credentials stored in plaintext** - Secure your installation appropriately
- **Service limitations apply** - Account tier restrictions and geographic limitations
### Legal Disclaimer
This software is for educational purposes and personal use only. Ensure you comply with the terms of service of Spotify, Deezer, and any other services you use. Respect copyright laws and only download content you have the right to access.
### File Handling
- Downloaded files retain original metadata - Downloaded files retain original metadata
- Service limitations apply based on account types - Service limitations apply based on account types
# Acknowledgements ## 🙏 Acknowledgements
- This project was inspired by the amazing [deezspot library](https://github.com/jakiepari/deezspot), although their creators are in no way related with Spotizerr, they still deserve credit. This project was inspired by the amazing [deezspot library](https://github.com/jakiepari/deezspot). Although their creators are in no way related to Spotizerr, they still deserve credit for their excellent work.

344
app.py
View File

@@ -1,14 +1,8 @@
from flask import Flask, request, send_from_directory from fastapi import FastAPI, Request, HTTPException
from flask_cors import CORS from fastapi.middleware.cors import CORSMiddleware
from routes.search import search_bp from fastapi.staticfiles import StaticFiles
from routes.credentials import credentials_bp from fastapi.responses import FileResponse
from routes.album import album_bp from contextlib import asynccontextmanager
from routes.track import track_bp
from routes.playlist import playlist_bp
from routes.prgs import prgs_bp
from routes.config import config_bp
from routes.artist import artist_bp
from routes.history import history_bp
import logging import logging
import logging.handlers import logging.handlers
import time import time
@@ -20,10 +14,29 @@ import redis
import socket import socket
from urllib.parse import urlparse from urllib.parse import urlparse
# Import route routers (to be created)
from routes.auth.credentials import router as credentials_router
from routes.auth.auth import router as auth_router
from routes.content.artist import router as artist_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.core.search import router as search_router
from routes.core.history import router as history_router
from routes.system.progress import router as prgs_router
from routes.system.config import router as config_router
# Import Celery configuration and manager # Import Celery configuration and manager
from routes.utils.celery_manager import celery_manager from routes.utils.celery_manager import celery_manager
from routes.utils.celery_config import REDIS_URL from routes.utils.celery_config import REDIS_URL
from routes.utils.history_manager import init_history_db
# Import authentication system
from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware
# Import and initialize routes (this will start the watch manager)
import routes
# Configure application-wide logging # Configure application-wide logging
@@ -67,178 +80,199 @@ def setup_logging():
root_logger.addHandler(console_handler) root_logger.addHandler(console_handler)
# Set up specific loggers # Set up specific loggers
for logger_name in ["werkzeug", "celery", "routes", "flask", "waitress"]: for logger_name in [
module_logger = logging.getLogger(logger_name) "routes",
module_logger.setLevel(logging.INFO) "routes.utils",
# Handlers are inherited from root logger "routes.utils.celery_manager",
"routes.utils.celery_tasks",
"routes.utils.watch",
]:
logger = logging.getLogger(logger_name)
logger.setLevel(logging.INFO)
logger.propagate = True # Propagate to root logger
# Enable propagation for all loggers logging.info("Logging system initialized")
logging.getLogger("celery").propagate = True
# Notify successful setup
root_logger.info("Logging system initialized")
# Return the main file handler for permissions adjustment
return file_handler
def check_redis_connection(): def check_redis_connection():
"""Check if Redis is reachable and retry with exponential backoff if not""" """Check if Redis is available and accessible"""
max_retries = 5 if not REDIS_URL:
retry_count = 0 logging.error("REDIS_URL is not configured. Please check your environment.")
retry_delay = 1 # start with 1 second return False
# Extract host and port from REDIS_URL try:
redis_host = "redis" # default # Parse Redis URL
redis_port = 6379 # default parsed_url = urlparse(REDIS_URL)
host = parsed_url.hostname or "localhost"
port = parsed_url.port or 6379
# Parse from REDIS_URL if possible logging.info(f"Testing Redis connection to {host}:{port}...")
if REDIS_URL:
# parse hostname and port (handles optional auth)
try:
parsed = urlparse(REDIS_URL)
if parsed.hostname:
redis_host = parsed.hostname
if parsed.port:
redis_port = parsed.port
except Exception:
pass
# Log Redis connection details # Test socket connection first
logging.info(f"Checking Redis connection to {redis_host}:{redis_port}") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
result = sock.connect_ex((host, port))
sock.close()
while retry_count < max_retries: if result != 0:
try: logging.error(f"Cannot connect to Redis at {host}:{port}")
# First try socket connection to check if Redis port is open return False
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex((redis_host, redis_port))
sock.close()
if result != 0: # Test Redis client connection
raise ConnectionError( r = redis.from_url(REDIS_URL, socket_connect_timeout=5, socket_timeout=5)
f"Cannot connect to Redis at {redis_host}:{redis_port}" r.ping()
) logging.info("Redis connection successful")
return True
# If socket connection successful, try Redis ping except redis.ConnectionError as e:
r = redis.Redis.from_url(REDIS_URL) logging.error(f"Redis connection error: {e}")
r.ping() return False
logging.info("Successfully connected to Redis") except redis.TimeoutError as e:
return True logging.error(f"Redis timeout error: {e}")
except Exception as e: return False
retry_count += 1 except Exception as e:
if retry_count >= max_retries: logging.error(f"Unexpected error checking Redis connection: {e}")
logging.error( return False
f"Failed to connect to Redis after {max_retries} attempts: {e}"
)
logging.error(
f"Make sure Redis is running at {redis_host}:{redis_port}"
)
return False
logging.warning(f"Redis connection attempt {retry_count} failed: {e}")
logging.info(f"Retrying in {retry_delay} seconds...")
time.sleep(retry_delay)
retry_delay *= 2 # exponential backoff
return False @asynccontextmanager
async def lifespan(app: FastAPI):
"""Handle application startup and shutdown"""
# Startup
setup_logging()
# Check Redis connection
if not check_redis_connection():
logging.error("Failed to connect to Redis. Please ensure Redis is running and accessible.")
# Don't exit, but warn - some functionality may not work
# Start Celery workers
try:
celery_manager.start()
logging.info("Celery workers started successfully")
except Exception as e:
logging.error(f"Failed to start Celery workers: {e}")
yield
# Shutdown
try:
celery_manager.stop()
logging.info("Celery workers stopped")
except Exception as e:
logging.error(f"Error stopping Celery workers: {e}")
def create_app(): def create_app():
app = Flask(__name__, static_folder="spotizerr-ui/dist", static_url_path="/") app = FastAPI(
title="Spotizerr API",
description="Music download service API",
version="3.0.0",
lifespan=lifespan,
redirect_slashes=True # Enable automatic trailing slash redirects
)
# Set up CORS # Set up CORS
CORS(app) app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize databases # Add authentication middleware (only if auth is enabled)
init_history_db() if AUTH_ENABLED:
app.add_middleware(AuthMiddleware)
logging.info("Authentication system enabled")
else:
logging.info("Authentication system disabled")
# Register blueprints # Register routers with URL prefixes
app.register_blueprint(config_bp, url_prefix="/api") app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.register_blueprint(search_bp, url_prefix="/api")
app.register_blueprint(credentials_bp, url_prefix="/api/credentials") # Include SSO router if available
app.register_blueprint(album_bp, url_prefix="/api/album") try:
app.register_blueprint(track_bp, url_prefix="/api/track") from routes.auth.sso import router as sso_router
app.register_blueprint(playlist_bp, url_prefix="/api/playlist") app.include_router(sso_router, prefix="/api/auth", tags=["sso"])
app.register_blueprint(artist_bp, url_prefix="/api/artist") logging.info("SSO functionality enabled")
app.register_blueprint(prgs_bp, url_prefix="/api/prgs") except ImportError as e:
app.register_blueprint(history_bp, url_prefix="/api/history") logging.warning(f"SSO functionality not available: {e}")
app.include_router(config_router, prefix="/api/config", tags=["config"])
# Serve React App app.include_router(search_router, prefix="/api/search", tags=["search"])
@app.route("/", defaults={"path": ""}) app.include_router(credentials_router, prefix="/api/credentials", tags=["credentials"])
@app.route("/<path:path>") app.include_router(album_router, prefix="/api/album", tags=["album"])
def serve_react_app(path): app.include_router(track_router, prefix="/api/track", tags=["track"])
if path != "" and os.path.exists(os.path.join(app.static_folder, path)): app.include_router(playlist_router, prefix="/api/playlist", tags=["playlist"])
return send_from_directory(app.static_folder, path) app.include_router(artist_router, prefix="/api/artist", tags=["artist"])
else: app.include_router(prgs_router, prefix="/api/prgs", tags=["progress"])
return send_from_directory(app.static_folder, "index.html") app.include_router(history_router, prefix="/api/history", tags=["history"])
# Add request logging middleware # Add request logging middleware
@app.before_request @app.middleware("http")
def log_request(): async def log_requests(request: Request, call_next):
request.start_time = time.time() start_time = time.time()
app.logger.debug(f"Request: {request.method} {request.path}")
# Log request
logger = logging.getLogger("uvicorn.access")
logger.debug(f"Request: {request.method} {request.url.path}")
try:
response = await call_next(request)
# Log response
duration = round((time.time() - start_time) * 1000, 2)
logger.debug(f"Response: {response.status_code} | Duration: {duration}ms")
return response
except Exception as e:
# Log errors
logger.error(f"Server error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal Server Error")
@app.after_request # Mount static files for React app
def log_response(response): if os.path.exists("spotizerr-ui/dist"):
if hasattr(request, "start_time"): app.mount("/static", StaticFiles(directory="spotizerr-ui/dist"), name="static")
duration = round((time.time() - request.start_time) * 1000, 2)
app.logger.debug(f"Response: {response.status} | Duration: {duration}ms") # Serve React App - catch-all route for SPA (but not for API routes)
return response @app.get("/{full_path:path}")
async def serve_react_app(full_path: str):
# Error logging """Serve React app with fallback to index.html for SPA routing"""
@app.errorhandler(Exception) static_dir = "spotizerr-ui/dist"
def handle_exception(e):
app.logger.error(f"Server error: {str(e)}", exc_info=True) # Don't serve React app for API routes (more specific check)
return "Internal Server Error", 500 if full_path.startswith("api") or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found")
# If it's a file that exists, serve it
if full_path and os.path.exists(os.path.join(static_dir, full_path)):
return FileResponse(os.path.join(static_dir, full_path))
else:
# Fallback to index.html for SPA routing
return FileResponse(os.path.join(static_dir, "index.html"))
else:
logging.warning("React app build directory not found at spotizerr-ui/dist")
return app return app
def start_celery_workers(): def start_celery_workers():
"""Start Celery workers with dynamic configuration""" """Start Celery workers with dynamic configuration"""
logging.info("Starting Celery workers with dynamic configuration") # This function is now handled by the lifespan context manager
celery_manager.start() # and the celery_manager.start() call
pass
# Register shutdown handler
atexit.register(celery_manager.stop)
if __name__ == "__main__": if __name__ == "__main__":
# Configure application logging import uvicorn
log_handler = setup_logging()
# Set permissions for log file
try:
if os.name != "nt": # Not Windows
os.chmod(log_handler.baseFilename, 0o666)
except Exception as e:
logging.warning(f"Could not set permissions on log file: {e}")
# Check Redis connection before starting
if not check_redis_connection():
logging.error("Exiting: Could not establish Redis connection.")
sys.exit(1)
# Start Celery workers in a separate thread
start_celery_workers()
# Clean up Celery workers on exit
atexit.register(celery_manager.stop)
# Create Flask app
app = create_app() app = create_app()
# Get host and port from environment variables or use defaults # Run with uvicorn
host = os.environ.get("HOST", "0.0.0.0") uvicorn.run(
port = int(os.environ.get("PORT", 7171)) app,
host="${HOST:-0.0.0.0}",
# Use Flask's built-in server for development port=7171,
# logging.info(f"Starting Flask development server on http://{host}:{port}") log_level="info",
# app.run(host=host, port=port, debug=True) access_log=True
)
# The following uses Waitress, a production-ready server.
# To use it, comment out the app.run() line above and uncomment the lines below.
logging.info(f"Starting server with Waitress on http://{host}:{port}")
from waitress import serve
serve(app, host=host, port=port)

Binary file not shown.

View File

@@ -25,6 +25,20 @@ services:
- REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB} - REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}
- REDIS_BACKEND=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB} - REDIS_BACKEND=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}
- EXPLICIT_FILTER=${EXPLICIT_FILTER} # Set to true to filter out explicit content - EXPLICIT_FILTER=${EXPLICIT_FILTER} # Set to true to filter out explicit content
- ENABLE_AUTH=${ENABLE_AUTH} # Set to true to enable authentication
- JWT_SECRET=${JWT_SECRET} # Set to a random string for production
- JWT_EXPIRATION_HOURS=${JWT_EXPIRATION_HOURS} # Set to 24 for 24 hours
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME} # Set to admin
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD} # Set to admin123
- SSO_ENABLED=${SSO_ENABLED} # Set to true to enable SSO
- SSO_BASE_REDIRECT_URI=${SSO_BASE_REDIRECT_URI} # Set to http://127.0.0.1:7171/api/auth/sso/callback
- FRONTEND_URL=${FRONTEND_URL} # Frontend URL for SSO redirects
- DISABLE_REGISTRATION=${DISABLE_REGISTRATION} # Set to true to disable registration
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} # Google SSO client ID
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} # Google SSO client secret
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} # GitHub SSO client ID
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} # GitHub SSO client secret
depends_on: depends_on:
- redis - redis

644
docs/API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,644 @@
# Spotizerr API Documentation
A comprehensive music download service API built with FastAPI that supports Spotify content downloading, playlist/artist watching, and user authentication.
## 🚀 Base URL
```
http://localhost:7171/api
```
## 🔐 Authentication
### Authentication System
- **Type**: JWT-based authentication with optional SSO (Google/GitHub)
- **Token**: Bearer token in Authorization header
- **When Disabled**: System user with admin privileges automatically applied
### Auth Status
Check authentication configuration and current user status.
#### `GET /auth/status`
**Response:**
```json
{
"auth_enabled": true,
"authenticated": false,
"user": null,
"registration_enabled": true,
"sso_enabled": true,
"sso_providers": ["google", "github"]
}
```
### Login & Registration
#### `POST /auth/login`
**Request:**
```json
{
"username": "string",
"password": "string"
}
```
**Response:**
```json
{
"access_token": "jwt-token",
"token_type": "bearer",
"user": {
"username": "string",
"email": "string",
"role": "user|admin",
"created_at": "2024-01-01T00:00:00",
"last_login": "2024-01-01T00:00:00",
"sso_provider": null,
"is_sso_user": false
}
}
```
#### `POST /auth/register`
**Request:**
```json
{
"username": "string",
"password": "string",
"email": "string"
}
```
#### `POST /auth/logout`
Logs out the current user.
### User Management (Admin Only)
#### `GET /auth/users`
List all users.
#### `POST /auth/users/create`
**Request:**
```json
{
"username": "string",
"password": "string",
"email": "string",
"role": "user|admin"
}
```
#### `DELETE /auth/users/{username}`
Delete a user.
#### `PUT /auth/users/{username}/role`
**Request:**
```json
{
"role": "user|admin"
}
```
#### `PUT /auth/users/{username}/password`
Admin password reset.
**Request:**
```json
{
"new_password": "string"
}
```
### Profile Management
#### `GET /auth/profile`
Get current user profile.
#### `PUT /auth/profile/password`
Change own password.
**Request:**
```json
{
"current_password": "string",
"new_password": "string"
}
```
### SSO Authentication
#### `GET /auth/sso/status`
Get SSO configuration and available providers.
#### `GET /auth/sso/login/google`
Redirect to Google OAuth.
#### `GET /auth/sso/login/github`
Redirect to GitHub OAuth.
#### `GET /auth/sso/callback/google`
Google OAuth callback.
#### `GET /auth/sso/callback/github`
GitHub OAuth callback.
#### `POST /auth/sso/unlink/{provider}`
Unlink SSO provider from account.
## 🎵 Content Download Endpoints
### Track Downloads
#### `GET /track/download/{track_id}`
Download a single track.
**Response:**
```json
{
"task_id": "uuid"
}
```
**Status Code:** 202 (Accepted)
#### `GET /track/download/cancel`
**Query Parameters:**
- `task_id`: Task ID to cancel
#### `GET /track/info`
Get track metadata.
**Query Parameters:**
- `id`: Spotify track ID
**Response:**
```json
{
"id": "string",
"name": "string",
"artists": [{"name": "string"}],
"album": {"name": "string"},
"duration_ms": 180000,
"explicit": false,
"external_urls": {"spotify": "url"}
}
```
### Album Downloads
#### `GET /album/download/{album_id}`
Download an entire album.
**Response:**
```json
{
"task_id": "uuid"
}
```
#### `GET /album/download/cancel`
**Query Parameters:**
- `task_id`: Task ID to cancel
#### `GET /album/info`
Get album metadata.
**Query Parameters:**
- `id`: Spotify album ID
### Playlist Downloads
#### `GET /playlist/download/{playlist_id}`
Download an entire playlist.
**Response:**
```json
{
"task_id": "uuid"
}
```
#### `GET /playlist/download/cancel`
**Query Parameters:**
- `task_id`: Task ID to cancel
#### `GET /playlist/info`
Get playlist metadata.
**Query Parameters:**
- `id`: Spotify playlist ID
#### `GET /playlist/metadata`
Get detailed playlist metadata including tracks.
**Query Parameters:**
- `id`: Spotify playlist ID
#### `GET /playlist/tracks`
Get playlist tracks.
**Query Parameters:**
- `id`: Spotify playlist ID
### Artist Downloads
#### `GET /artist/download/{artist_id}`
Download artist's discography.
**Query Parameters:**
- `album_type`: Comma-separated values (`album,single,compilation,appears_on`)
**Response:**
```json
{
"status": "complete",
"message": "Artist discography processing initiated. X albums queued.",
"queued_albums": ["task_id1", "task_id2"],
"duplicate_albums": ["existing_task_id"]
}
```
#### `GET /artist/download/cancel`
**Query Parameters:**
- `task_id`: Task ID to cancel
#### `GET /artist/info`
Get artist metadata.
**Query Parameters:**
- `id`: Spotify artist ID
## 📺 Watch Functionality
Monitor playlists and artists for new content and automatically download updates.
### Playlist Watching
#### `PUT /playlist/watch/{playlist_spotify_id}`
Add playlist to watch list.
**Request:**
```json
{
"watch_new_additions": true,
"download_existing": false
}
```
#### `GET /playlist/watch/{playlist_spotify_id}/status`
Get playlist watch status.
#### `DELETE /playlist/watch/{playlist_spotify_id}`
Remove playlist from watch list.
#### `POST /playlist/watch/{playlist_spotify_id}/tracks`
Add specific tracks to watch for a playlist.
**Request:**
```json
{
"track_ids": ["track_id1", "track_id2"]
}
```
#### `DELETE /playlist/watch/{playlist_spotify_id}/tracks`
Remove specific tracks from watch.
**Request:**
```json
{
"track_ids": ["track_id1", "track_id2"]
}
```
#### `GET /playlist/watch/list`
List all watched playlists.
#### `POST /playlist/watch/trigger_check`
Manually trigger watch check for all playlists.
#### `POST /playlist/watch/trigger_check/{playlist_spotify_id}`
Manually trigger watch check for specific playlist.
### Artist Watching
#### `PUT /artist/watch/{artist_spotify_id}`
Add artist to watch list.
**Request:**
```json
{
"watch_new_releases": true,
"album_types": ["album", "single"]
}
```
#### `GET /artist/watch/{artist_spotify_id}/status`
Get artist watch status.
#### `DELETE /artist/watch/{artist_spotify_id}`
Remove artist from watch list.
#### `POST /artist/watch/{artist_spotify_id}/albums`
Add specific albums to watch for an artist.
**Request:**
```json
{
"album_ids": ["album_id1", "album_id2"]
}
```
#### `DELETE /artist/watch/{artist_spotify_id}/albums`
Remove specific albums from watch.
#### `GET /artist/watch/list`
List all watched artists.
#### `POST /artist/watch/trigger_check`
Manually trigger watch check for all artists.
#### `POST /artist/watch/trigger_check/{artist_spotify_id}`
Manually trigger watch check for specific artist.
## 🔍 Search
#### `GET /search/`
Search Spotify content.
**Query Parameters:**
- `q`: Search query (required)
- `search_type` or `type`: Content type (`track`, `album`, `artist`, `playlist`, `episode`, `show`)
- `limit`: Results limit (default: 20)
- `main`: Account context
**Response:**
```json
{
"items": [
{
"id": "string",
"name": "string",
"type": "track",
"artists": [{"name": "string"}],
"external_urls": {"spotify": "url"}
}
]
}
```
## 📊 Progress & Task Management
### Task Monitoring
#### `GET /prgs/list`
List all tasks with optional filtering.
**Query Parameters:**
- `status`: Filter by status (`pending`, `running`, `completed`, `failed`)
- `download_type`: Filter by type (`track`, `album`, `playlist`)
- `limit`: Results limit
#### `GET /prgs/{task_id}`
Get specific task details and progress.
#### `GET /prgs/updates`
Get task updates since last check.
**Query Parameters:**
- `since`: Timestamp to get updates since
#### `GET /prgs/stream`
**Server-Sent Events (SSE)** endpoint for real-time progress updates.
**Response:** Continuous stream of task updates.
### Task Control
#### `POST /prgs/cancel/{task_id}`
Cancel a specific task.
#### `POST /prgs/cancel/all`
Cancel all running tasks.
#### `DELETE /prgs/delete/{task_id}`
Delete completed/failed task from history.
## 📜 Download History
### History Retrieval
#### `GET /history/`
Get download history with pagination.
**Query Parameters:**
- `limit`: Max records (default: 100, max: 500)
- `offset`: Records to skip (default: 0)
- `download_type`: Filter by type (`track`, `album`, `playlist`)
- `status`: Filter by status (`completed`, `failed`, `skipped`, `in_progress`)
**Response:**
```json
{
"downloads": [
{
"task_id": "uuid",
"download_type": "track",
"url": "spotify_url",
"name": "Track Name",
"artist": "Artist Name",
"status": "completed",
"created_at": "2024-01-01T00:00:00",
"completed_at": "2024-01-01T00:05:00",
"file_path": "/path/to/file.mp3"
}
],
"pagination": {
"limit": 100,
"offset": 0,
"returned_count": 50
}
}
```
#### `GET /history/{task_id}`
Get specific download history entry.
#### `GET /history/{task_id}/children`
Get child tasks (for album/playlist downloads).
#### `GET /history/stats`
Get download statistics.
#### `GET /history/search`
Search download history.
**Query Parameters:**
- `q`: Search query
- `field`: Field to search (`name`, `artist`, `url`)
#### `GET /history/recent`
Get recent downloads.
**Query Parameters:**
- `hours`: Hours to look back (default: 24)
#### `GET /history/failed`
Get failed downloads.
#### `POST /history/cleanup`
Clean up old history entries.
**Request:**
```json
{
"older_than_days": 30,
"keep_failed": true
}
```
## ⚙️ System Configuration
### Configuration Management
#### `GET /config/`
Get current system configuration.
#### `POST /config/` / `PUT /config/`
Update system configuration.
**Request:**
```json
{
"maxConcurrentDownloads": 3,
"service": "spotify",
"fallback": true,
"spotifyQuality": "high",
"deezerQuality": "flac",
"realTime": true,
"downloadPath": "/downloads",
"fileFormat": "mp3"
}
```
#### `GET /config/check`
Validate current configuration.
#### `POST /config/validate`
Validate provided configuration.
### Watch Configuration
#### `GET /config/watch`
Get watch system configuration.
#### `POST /config/watch` / `PUT /config/watch`
Update watch configuration.
**Request:**
```json
{
"enabled": true,
"check_interval": 3600,
"max_concurrent_checks": 2,
"retry_failed_after": 1800
}
```
#### `POST /config/watch/validate`
Validate watch configuration.
## 🔑 Credentials Management
### Service Credentials
#### `GET /credentials/{service}`
List credentials for service (`spotify` or `deezer`).
#### `GET /credentials/{service}/{name}`
Get specific credential set.
#### `POST /credentials/{service}/{name}`
Create new credential set.
**Request:**
```json
{
"client_id": "string",
"client_secret": "string"
}
```
#### `PUT /credentials/{service}/{name}`
Update credential set.
#### `DELETE /credentials/{service}/{name}`
Delete credential set.
#### `GET /credentials/all/{service}`
Get all credentials for service.
### Spotify API Configuration
#### `GET /credentials/spotify_api_config`
Get Spotify API configuration.
#### `PUT /credentials/spotify_api_config`
Update Spotify API configuration.
#### `GET /credentials/markets`
Get available Spotify markets.
## 🚨 Error Handling
### HTTP Status Codes
- **200**: Success
- **202**: Accepted (async operations)
- **400**: Bad Request (validation errors)
- **401**: Unauthorized (auth required)
- **403**: Forbidden (insufficient permissions)
- **404**: Not Found
- **409**: Conflict (duplicate downloads)
- **500**: Internal Server Error
### Error Response Format
```json
{
"error": "Error description",
"details": "Additional details",
"traceback": "Stack trace (dev mode)"
}
```
### Common Error Scenarios
- **Duplicate Downloads**: 409 status with existing task ID
- **Missing Spotify Metadata**: 404 when track/album/playlist not found
- **Invalid Credentials**: Authentication errors when service credentials are wrong
- **Rate Limiting**: Temporary failures when hitting Spotify API limits
## 💡 Usage Examples
### Download a Track
```bash
curl -X GET "http://localhost:7171/api/track/download/4iV5W9uYEdYUVa79Axb7Rh" \
-H "Authorization: Bearer your-jwt-token"
```
### Search for Music
```bash
curl -X GET "http://localhost:7171/api/search/?q=bohemian%20rhapsody&search_type=track" \
-H "Authorization: Bearer your-jwt-token"
```
### Monitor Progress with SSE
```javascript
const eventSource = new EventSource('/api/prgs/stream');
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('Progress update:', data);
};
```
### Add Playlist to Watch
```bash
curl -X PUT "http://localhost:7171/api/playlist/watch/37i9dQZF1DXcBWIGoYBM5M" \
-H "Authorization: Bearer your-jwt-token" \
-H "Content-Type: application/json" \
-d '{"watch_new_additions": true, "download_existing": false}'
```
## 🔧 Development Notes
### Authentication Middleware
- Routes are protected by `require_auth_from_state` dependency
- Admin routes use `require_admin_from_state` dependency
- When auth is disabled, system returns mock admin user
### Task System
- All downloads are async using Celery
- Progress tracked via Redis
- Real-time updates via SSE
- Task cancellation supported
### File Structure
- Downloads stored in configurable directory
- Metadata stored in JSON files
- History persisted in database
### Rate Limiting
- Spotify API rate limits respected
- Concurrent download limits configurable
- Retry logic for failed requests
---
*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.*

View File

@@ -1,5 +1,9 @@
waitress==3.0.2 fastapi==0.115.6
uvicorn[standard]==0.32.1
celery==5.5.3 celery==5.5.3
Flask==3.1.1 deezspot-spotizerr==2.2.2
flask_cors==6.0.0 httpx==0.28.1
deezspot-spotizerr==2.0.3 bcrypt==4.2.1
PyJWT==2.10.1
python-multipart==0.0.17
fastapi-sso==0.18.0

295
routes/auth/__init__.py Normal file
View File

@@ -0,0 +1,295 @@
import os
import json
import bcrypt
import jwt
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
# Configuration
AUTH_ENABLED = os.getenv("ENABLE_AUTH", "false").lower() in ("true", "1", "yes", "on")
DISABLE_REGISTRATION = os.getenv("DISABLE_REGISTRATION", "false").lower() in ("true", "1", "yes", "on")
JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", "24"))
# Paths
USERS_DIR = Path("./data/users")
USERS_FILE = USERS_DIR / "users.json"
class User:
def __init__(self, username: str, email: str = None, role: str = "user", created_at: str = None, last_login: str = None, sso_provider: str = None, sso_id: str = None):
self.username = username
self.email = email
self.role = role
self.created_at = created_at or datetime.utcnow().isoformat()
self.last_login = last_login
self.sso_provider = sso_provider
self.sso_id = sso_id
def to_dict(self) -> Dict[str, Any]:
return {
"username": self.username,
"email": self.email,
"role": self.role,
"created_at": self.created_at,
"last_login": self.last_login,
"sso_provider": self.sso_provider,
"sso_id": self.sso_id
}
def to_public_dict(self) -> Dict[str, Any]:
"""Return user data without sensitive information"""
return {
"username": self.username,
"email": self.email,
"role": self.role,
"created_at": self.created_at,
"last_login": self.last_login,
"sso_provider": self.sso_provider,
"is_sso_user": self.sso_provider is not None
}
class UserManager:
def __init__(self):
self.ensure_users_file()
def ensure_users_file(self):
"""Ensure users directory and file exist"""
USERS_DIR.mkdir(parents=True, exist_ok=True)
if not USERS_FILE.exists():
with open(USERS_FILE, 'w') as f:
json.dump({}, f, indent=2)
logger.info(f"Created users file at {USERS_FILE}")
def load_users(self) -> Dict[str, Dict]:
"""Load users from file"""
try:
with open(USERS_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading users: {e}")
return {}
def save_users(self, users: Dict[str, Dict]):
"""Save users to file"""
try:
with open(USERS_FILE, 'w') as f:
json.dump(users, f, indent=2)
except Exception as e:
logger.error(f"Error saving users: {e}")
raise
def hash_password(self, password: str) -> str:
"""Hash password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(self, password: str, hashed: str) -> bool:
"""Verify password against hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
def create_user(self, username: str, password: str = None, email: str = None, role: str = "user", sso_provider: str = None, sso_id: str = None) -> tuple[bool, str]:
"""Create a new user (traditional or SSO)"""
users = self.load_users()
if username in users:
return False, "Username already exists"
# For SSO users, password is None
hashed_password = self.hash_password(password) if password else None
user = User(username=username, email=email, role=role, sso_provider=sso_provider, sso_id=sso_id)
users[username] = {
**user.to_dict(),
"password_hash": hashed_password
}
self.save_users(users)
logger.info(f"Created user: {username} (SSO: {sso_provider or 'No'})")
return True, "User created successfully"
def authenticate_user(self, username: str, password: str) -> Optional[User]:
"""Authenticate user and return User object if successful"""
users = self.load_users()
if username not in users:
return None
user_data = users[username]
if not self.verify_password(password, user_data["password_hash"]):
return None
# Update last login
user_data["last_login"] = datetime.utcnow().isoformat()
users[username] = user_data
self.save_users(users)
return User(**{k: v for k, v in user_data.items() if k != "password_hash"})
def get_user(self, username: str) -> Optional[User]:
"""Get user by username"""
users = self.load_users()
if username not in users:
return None
user_data = users[username]
return User(**{k: v for k, v in user_data.items() if k != "password_hash"})
def list_users(self) -> list[User]:
"""List all users"""
users = self.load_users()
return [User(**{k: v for k, v in user_data.items() if k != "password_hash"})
for user_data in users.values()]
def delete_user(self, username: str) -> tuple[bool, str]:
"""Delete a user"""
users = self.load_users()
if username not in users:
return False, "User not found"
del users[username]
self.save_users(users)
logger.info(f"Deleted user: {username}")
return True, "User deleted successfully"
def update_user_role(self, username: str, role: str) -> tuple[bool, str]:
"""Update user role"""
users = self.load_users()
if username not in users:
return False, "User not found"
users[username]["role"] = role
self.save_users(users)
logger.info(f"Updated role for user {username} to {role}")
return True, "User role updated successfully"
def change_password(self, username: str, current_password: str, new_password: str) -> tuple[bool, str]:
"""Change user password after validating current password"""
users = self.load_users()
if username not in users:
return False, "User not found"
user_data = users[username]
# Check if user is SSO user
if user_data.get("sso_provider"):
return False, f"Cannot change password for SSO user. Please change your password through {user_data['sso_provider']}."
# Check if user has a password hash
if not user_data.get("password_hash"):
return False, "Cannot change password for SSO user"
# Verify current password
if not self.verify_password(current_password, user_data["password_hash"]):
return False, "Current password is incorrect"
# Validate new password
if len(new_password) < 6:
return False, "New password must be at least 6 characters long"
if current_password == new_password:
return False, "New password must be different from current password"
# Update password
users[username]["password_hash"] = self.hash_password(new_password)
self.save_users(users)
logger.info(f"Password changed for user: {username}")
return True, "Password changed successfully"
def admin_reset_password(self, username: str, new_password: str) -> tuple[bool, str]:
"""Admin reset user password (no current password verification required)"""
users = self.load_users()
if username not in users:
return False, "User not found"
user_data = users[username]
# Check if user is SSO user
if user_data.get("sso_provider"):
return False, f"Cannot reset password for SSO user. User manages password through {user_data['sso_provider']}."
# Check if user has a password hash (should exist for non-SSO users)
if not user_data.get("password_hash"):
return False, "Cannot reset password for SSO user"
# Validate new password
if len(new_password) < 6:
return False, "New password must be at least 6 characters long"
# Update password
users[username]["password_hash"] = self.hash_password(new_password)
self.save_users(users)
logger.info(f"Password reset by admin for user: {username}")
return True, "Password reset successfully"
class TokenManager:
@staticmethod
def create_token(user: User) -> str:
"""Create JWT token for user"""
payload = {
"username": user.username,
"role": user.role,
"exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS),
"iat": datetime.utcnow()
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
@staticmethod
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""Verify JWT token and return payload"""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# Global instances
user_manager = UserManager()
token_manager = TokenManager()
def create_default_admin():
"""Create default admin user if no users exist"""
if not AUTH_ENABLED:
return
users = user_manager.load_users()
if not users:
default_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
default_password = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123")
success, message = user_manager.create_user(
username=default_username,
password=default_password,
role="admin"
)
if success:
logger.info(f"Created default admin user: {default_username}")
logger.warning(f"Default admin password is: {default_password}")
logger.warning("Please change the default admin password immediately!")
else:
logger.error(f"Failed to create default admin: {message}")
# Initialize default admin on import
create_default_admin()
# SSO functionality will be imported separately to avoid circular imports
SSO_AVAILABLE = True

372
routes/auth/auth.py Normal file
View File

@@ -0,0 +1,372 @@
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional, List
import logging
from . import AUTH_ENABLED, DISABLE_REGISTRATION, user_manager, token_manager, User
logger = logging.getLogger(__name__)
router = APIRouter()
security = HTTPBearer(auto_error=False)
# Include SSO sub-router
try:
from .sso import router as sso_router
router.include_router(sso_router, tags=["sso"])
logging.info("SSO sub-router included in auth router")
except ImportError as e:
logging.warning(f"SSO functionality not available: {e}")
# Pydantic models for request/response
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
class CreateUserRequest(BaseModel):
"""Admin-only request to create users when registration is disabled"""
username: str
password: str
email: Optional[str] = None
role: str = "user"
class RoleUpdateRequest(BaseModel):
"""Request to update user role"""
role: str
class PasswordChangeRequest(BaseModel):
"""Request to change user password"""
current_password: str
new_password: str
class AdminPasswordResetRequest(BaseModel):
"""Request for admin to reset user password"""
new_password: str
class UserResponse(BaseModel):
username: str
email: Optional[str]
role: str
created_at: str
last_login: Optional[str]
sso_provider: Optional[str] = None
is_sso_user: bool = False
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserResponse
class MessageResponse(BaseModel):
message: str
class AuthStatusResponse(BaseModel):
auth_enabled: bool
authenticated: bool = False
user: Optional[UserResponse] = None
registration_enabled: bool = True
sso_enabled: bool = False
sso_providers: List[str] = []
# Dependency to get current user
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> Optional[User]:
"""Get current user from JWT token"""
if not AUTH_ENABLED:
# When auth is disabled, return a mock admin user
return User(username="system", role="admin")
if not credentials:
return None
payload = token_manager.verify_token(credentials.credentials)
if not payload:
return None
user = user_manager.get_user(payload["username"])
return user
async def require_auth(current_user: User = Depends(get_current_user)) -> User:
"""Require authentication - raises HTTPException if not authenticated"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
if not current_user:
raise HTTPException(
status_code=401,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
async def require_admin(current_user: User = Depends(require_auth)) -> User:
"""Require admin role - raises HTTPException if not admin"""
if current_user.role != "admin":
raise HTTPException(
status_code=403,
detail="Admin access required"
)
return current_user
# Authentication endpoints
@router.get("/status", response_model=AuthStatusResponse)
async def auth_status(current_user: Optional[User] = Depends(get_current_user)):
"""Get authentication status"""
# Check if SSO is enabled and get available providers
sso_enabled = False
sso_providers = []
try:
from . import sso
sso_enabled = sso.SSO_ENABLED and AUTH_ENABLED
if sso.google_sso:
sso_providers.append("google")
if sso.github_sso:
sso_providers.append("github")
except ImportError:
pass # SSO module not available
return AuthStatusResponse(
auth_enabled=AUTH_ENABLED,
authenticated=current_user is not None,
user=UserResponse(**current_user.to_public_dict()) if current_user else None,
registration_enabled=AUTH_ENABLED and not DISABLE_REGISTRATION,
sso_enabled=sso_enabled,
sso_providers=sso_providers
)
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""Authenticate user and return access token"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
user = user_manager.authenticate_user(request.username, request.password)
if not user:
raise HTTPException(
status_code=401,
detail="Invalid username or password"
)
access_token = token_manager.create_token(user)
return LoginResponse(
access_token=access_token,
user=UserResponse(**user.to_public_dict())
)
@router.post("/register", response_model=MessageResponse)
async def register(request: RegisterRequest):
"""Register a new user"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
if DISABLE_REGISTRATION:
raise HTTPException(
status_code=403,
detail="Public registration is disabled. Contact an administrator to create an account."
)
# Check if this is the first user (should be admin)
existing_users = user_manager.list_users()
role = "admin" if len(existing_users) == 0 else "user"
success, message = user_manager.create_user(
username=request.username,
password=request.password,
email=request.email,
role=role
)
if not success:
raise HTTPException(status_code=400, detail=message)
return MessageResponse(message=message)
@router.post("/logout", response_model=MessageResponse)
async def logout():
"""Logout user (client should delete token)"""
return MessageResponse(message="Logged out successfully")
# User management endpoints (admin only)
@router.get("/users", response_model=List[UserResponse])
async def list_users(current_user: User = Depends(require_admin)):
"""List all users (admin only)"""
users = user_manager.list_users()
return [UserResponse(**user.to_public_dict()) for user in users]
@router.delete("/users/{username}", response_model=MessageResponse)
async def delete_user(username: str, current_user: User = Depends(require_admin)):
"""Delete a user (admin only)"""
if username == current_user.username:
raise HTTPException(
status_code=400,
detail="Cannot delete your own account"
)
success, message = user_manager.delete_user(username)
if not success:
raise HTTPException(status_code=404, detail=message)
return MessageResponse(message=message)
@router.put("/users/{username}/role", response_model=MessageResponse)
async def update_user_role(
username: str,
request: RoleUpdateRequest,
current_user: User = Depends(require_admin)
):
"""Update user role (admin only)"""
if request.role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail="Role must be 'user' or 'admin'"
)
if username == current_user.username:
raise HTTPException(
status_code=400,
detail="Cannot change your own role"
)
success, message = user_manager.update_user_role(username, request.role)
if not success:
raise HTTPException(status_code=404, detail=message)
return MessageResponse(message=message)
@router.post("/users/create", response_model=MessageResponse)
async def create_user_admin(request: CreateUserRequest, current_user: User = Depends(require_admin)):
"""Create a new user (admin only) - for use when registration is disabled"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
# Validate role
if request.role not in ["user", "admin"]:
raise HTTPException(
status_code=400,
detail="Role must be 'user' or 'admin'"
)
success, message = user_manager.create_user(
username=request.username,
password=request.password,
email=request.email,
role=request.role
)
if not success:
raise HTTPException(status_code=400, detail=message)
return MessageResponse(message=message)
# Profile endpoints
@router.get("/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(require_auth)):
"""Get current user profile"""
return UserResponse(**current_user.to_public_dict())
@router.put("/profile/password", response_model=MessageResponse)
async def change_password(
request: PasswordChangeRequest,
current_user: User = Depends(require_auth)
):
"""Change current user's password"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
success, message = user_manager.change_password(
username=current_user.username,
current_password=request.current_password,
new_password=request.new_password
)
if not success:
# Determine appropriate HTTP status code based on error message
if "Current password is incorrect" in message:
status_code = 401
elif "User not found" in message:
status_code = 404
else:
status_code = 400
raise HTTPException(status_code=status_code, detail=message)
return MessageResponse(message=message)
@router.put("/users/{username}/password", response_model=MessageResponse)
async def admin_reset_password(
username: str,
request: AdminPasswordResetRequest,
current_user: User = Depends(require_admin)
):
"""Admin reset user password (admin only)"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=400,
detail="Authentication is disabled"
)
success, message = user_manager.admin_reset_password(
username=username,
new_password=request.new_password
)
if not success:
# Determine appropriate HTTP status code based on error message
if "User not found" in message:
status_code = 404
else:
status_code = 400
raise HTTPException(status_code=status_code, detail=message)
return MessageResponse(message=message)
# Note: SSO routes are included in the main app, not here to avoid circular imports

295
routes/auth/credentials.py Executable file
View File

@@ -0,0 +1,295 @@
from fastapi import APIRouter, HTTPException, Request, Depends
import json
import logging
from routes.utils.credentials import (
get_credential,
list_credentials,
create_credential,
delete_credential,
edit_credential,
init_credentials_db,
# Import new utility functions for global Spotify API creds
_get_global_spotify_api_creds,
save_global_spotify_api_creds,
)
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
# Initialize the database and tables when the router is loaded
init_credentials_db()
@router.get("/spotify_api_config")
@router.put("/spotify_api_config")
async def handle_spotify_api_config(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Handles GET and PUT requests for the global Spotify API client_id and client_secret."""
try:
if request.method == "GET":
client_id, client_secret = _get_global_spotify_api_creds()
if client_id is not None and client_secret is not None:
return {"client_id": client_id, "client_secret": client_secret}
else:
# If search.json exists but is empty/incomplete, or doesn't exist
return {
"warning": "Global Spotify API credentials are not fully configured or file is missing.",
"client_id": client_id or "",
"client_secret": client_secret or "",
}
elif request.method == "PUT":
data = await request.json()
if not data or "client_id" not in data or "client_secret" not in data:
raise HTTPException(
status_code=400,
detail={"error": "Request body must contain 'client_id' and 'client_secret'"}
)
client_id = data["client_id"]
client_secret = data["client_secret"]
if not isinstance(client_id, str) or not isinstance(client_secret, str):
raise HTTPException(
status_code=400,
detail={"error": "'client_id' and 'client_secret' must be strings"}
)
if save_global_spotify_api_creds(client_id, client_secret):
return {"message": "Global Spotify API credentials updated successfully."}
else:
raise HTTPException(
status_code=500,
detail={"error": "Failed to save global Spotify API credentials."}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in /spotify_api_config: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
@router.get("/{service}")
async def handle_list_credentials(service: str, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
status_code=400,
detail={"error": "Invalid service. Must be 'spotify' or 'deezer'"}
)
return list_credentials(service)
except ValueError as e: # Should not happen with service check above
raise HTTPException(status_code=400, detail={"error": str(e)})
except Exception as e:
logger.error(f"Error listing credentials for {service}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
@router.get("/{service}/{name}")
async def handle_get_credential(service: str, name: str, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
status_code=400,
detail={"error": "Invalid service. Must be 'spotify' or 'deezer'"}
)
# get_credential for Spotify now only returns region and blob_file_path
return get_credential(service, name)
except (ValueError, FileNotFoundError, FileExistsError) as e:
status_code = 400
if isinstance(e, FileNotFoundError):
status_code = 404
elif isinstance(e, FileExistsError):
status_code = 409
logger.warning(f"Client error in /{service}/{name}: {str(e)}")
raise HTTPException(status_code=status_code, detail={"error": str(e)})
except Exception as e:
logger.error(f"Server error in /{service}/{name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
@router.post("/{service}/{name}")
async def handle_create_credential(service: str, name: str, request: Request, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
status_code=400,
detail={"error": "Invalid service. Must be 'spotify' or 'deezer'"}
)
data = await request.json()
if not data:
raise HTTPException(status_code=400, detail={"error": "Request body cannot be empty."})
# create_credential for Spotify now expects 'region' and 'blob_content'
# For Deezer, it expects 'arl' and 'region'
# Validation is handled within create_credential utility function
result = create_credential(service, name, data)
return {
"message": f"Credential for '{name}' ({service}) created successfully.",
"details": result,
}
except (ValueError, FileNotFoundError, FileExistsError) as e:
status_code = 400
if isinstance(e, FileNotFoundError):
status_code = 404
elif isinstance(e, FileExistsError):
status_code = 409
logger.warning(f"Client error in /{service}/{name}: {str(e)}")
raise HTTPException(status_code=status_code, detail={"error": str(e)})
except Exception as e:
logger.error(f"Server error in /{service}/{name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
@router.put("/{service}/{name}")
async def handle_update_credential(service: str, name: str, request: Request, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
status_code=400,
detail={"error": "Invalid service. Must be 'spotify' or 'deezer'"}
)
data = await request.json()
if not data:
raise HTTPException(status_code=400, detail={"error": "Request body cannot be empty."})
# edit_credential for Spotify now handles updates to 'region', 'blob_content'
# For Deezer, 'arl', 'region'
result = edit_credential(service, name, data)
return {
"message": f"Credential for '{name}' ({service}) updated successfully.",
"details": result,
}
except (ValueError, FileNotFoundError, FileExistsError) as e:
status_code = 400
if isinstance(e, FileNotFoundError):
status_code = 404
elif isinstance(e, FileExistsError):
status_code = 409
logger.warning(f"Client error in /{service}/{name}: {str(e)}")
raise HTTPException(status_code=status_code, detail={"error": str(e)})
except Exception as e:
logger.error(f"Server error in /{service}/{name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
@router.delete("/{service}/{name}")
async def handle_delete_credential(service: str, name: str, current_user: User = Depends(require_admin_from_state)):
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
status_code=400,
detail={"error": "Invalid service. Must be 'spotify' or 'deezer'"}
)
# delete_credential for Spotify also handles deleting the blob directory
result = delete_credential(service, name)
return {
"message": f"Credential for '{name}' ({service}) deleted successfully.",
"details": result,
}
except (ValueError, FileNotFoundError, FileExistsError) as e:
status_code = 400
if isinstance(e, FileNotFoundError):
status_code = 404
elif isinstance(e, FileExistsError):
status_code = 409
logger.warning(f"Client error in /{service}/{name}: {str(e)}")
raise HTTPException(status_code=status_code, detail={"error": str(e)})
except Exception as e:
logger.error(f"Server error in /{service}/{name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
# The '/search/<service>/<name>' route is now obsolete for Spotify and has been removed.
@router.get("/all/{service}")
async def handle_all_credentials(service: str, current_user: User = Depends(require_admin_from_state)):
"""Lists all credentials for a given service. For Spotify, API keys are global and not listed per account."""
try:
if service not in ["spotify", "deezer"]:
raise HTTPException(
status_code=400,
detail={"error": "Invalid service. Must be 'spotify' or 'deezer'"}
)
credentials_list = []
account_names = list_credentials(service) # This lists names from DB
for name in account_names:
try:
# get_credential for Spotify returns region and blob_file_path.
# For Deezer, it returns arl and region.
account_data = get_credential(service, name)
# We don't add global Spotify API keys here as they are separate
credentials_list.append({"name": name, "details": account_data})
except FileNotFoundError:
logger.warning(
f"Credential name '{name}' listed for service '{service}' but not found by get_credential. Skipping."
)
except Exception as e_inner:
logger.error(
f"Error fetching details for credential '{name}' ({service}): {e_inner}",
exc_info=True,
)
credentials_list.append(
{
"name": name,
"error": f"Could not retrieve details: {str(e_inner)}",
}
)
return credentials_list
except Exception as e:
logger.error(f"Error in /all/{service}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})
@router.get("/markets")
async def handle_markets(current_user: User = Depends(require_admin_from_state)):
"""
Returns a list of unique market regions for Deezer and Spotify accounts.
"""
try:
deezer_regions = set()
spotify_regions = set()
# Process Deezer accounts
deezer_account_names = list_credentials("deezer")
for name in deezer_account_names:
try:
account_data = get_credential("deezer", name)
if account_data and "region" in account_data and account_data["region"]:
deezer_regions.add(account_data["region"])
except Exception as e:
logger.warning(
f"Could not retrieve region for deezer account {name}: {e}"
)
# Process Spotify accounts
spotify_account_names = list_credentials("spotify")
for name in spotify_account_names:
try:
account_data = get_credential("spotify", name)
if account_data and "region" in account_data and account_data["region"]:
spotify_regions.add(account_data["region"])
except Exception as e:
logger.warning(
f"Could not retrieve region for spotify account {name}: {e}"
)
return {
"deezer": sorted(list(deezer_regions)),
"spotify": sorted(list(spotify_regions)),
}
except Exception as e:
logger.error(f"Error in /markets: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"An unexpected error occurred: {str(e)}"})

181
routes/auth/middleware.py Normal file
View File

@@ -0,0 +1,181 @@
from fastapi import HTTPException, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from typing import Callable, List, Optional
import logging
from . import AUTH_ENABLED, token_manager, user_manager, User
logger = logging.getLogger(__name__)
class AuthMiddleware(BaseHTTPMiddleware):
"""
Authentication middleware that enforces strict access control.
Philosophy:
- Nothing should be accessible to non-users (except auth endpoints)
- Everything but config/credentials should be accessible to users
- Everything should be accessible to admins
"""
def __init__(
self,
app,
protected_paths: Optional[List[str]] = None,
public_paths: Optional[List[str]] = None
):
super().__init__(app)
# Minimal public paths - only auth-related endpoints and static assets
self.public_paths = public_paths or [
"/api/auth/status",
"/api/auth/login",
"/api/auth/register",
"/api/auth/logout",
"/api/auth/sso", # All SSO endpoints
"/static",
"/favicon.ico"
]
# Admin-only paths (sensitive operations)
self.admin_only_paths = [
"/api/credentials", # All credential management
"/api/config", # All configuration management
]
# All other /api paths require at least user authentication
# This will be enforced in the dispatch method
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""Process request with strict authentication"""
# If auth is disabled, allow all requests
if not AUTH_ENABLED:
return await call_next(request)
path = request.url.path
# Check if path is public (always allow)
if self._is_public_path(path):
return await call_next(request)
# For all other /api paths, require authentication
if path.startswith("/api"):
auth_result = await self._authenticate_request(request)
if not auth_result:
return JSONResponse(
status_code=401,
content={
"detail": "Authentication required",
"auth_enabled": True
},
headers={"WWW-Authenticate": "Bearer"}
)
# Check if admin access is required
if self._requires_admin_access(path):
if auth_result.role != "admin":
return JSONResponse(
status_code=403,
content={
"detail": "Admin access required"
}
)
# Add user to request state for use in route handlers
request.state.current_user = auth_result
return await call_next(request)
def _is_public_path(self, path: str) -> bool:
"""Check if path is in public paths list"""
# Special case for exact root path
if path == "/":
return True
for public_path in self.public_paths:
if path.startswith(public_path):
return True
return False
def _requires_admin_access(self, path: str) -> bool:
"""Check if path requires admin role"""
for admin_path in self.admin_only_paths:
if path.startswith(admin_path):
return True
return False
async def _authenticate_request(self, request: Request) -> Optional[User]:
"""Authenticate request and return user if valid"""
try:
token = None
# First try to get token from authorization header
authorization = request.headers.get("authorization")
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ", 1)[1]
# If no header token and this is an SSE endpoint, check query parameters
if not token and request.url.path.endswith("/stream"):
token = request.query_params.get("token")
if not token:
return None
# Verify token
payload = token_manager.verify_token(token)
if not payload:
return None
# Get user from payload
user = user_manager.get_user(payload["username"])
return user
except Exception as e:
logger.error(f"Authentication error: {e}")
return None
# Dependency function to get current user from request state
async def get_current_user_from_state(request: Request) -> Optional[User]:
"""Get current user from request state (set by middleware)"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
return getattr(request.state, 'current_user', None)
# Dependency function to require authentication
async def require_auth_from_state(request: Request) -> User:
"""Require authentication using request state"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
user = getattr(request.state, 'current_user', None)
if not user:
raise HTTPException(
status_code=401,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"}
)
return user
# Dependency function to require admin role
async def require_admin_from_state(request: Request) -> User:
"""Require admin role using request state"""
if not AUTH_ENABLED:
return User(username="system", role="admin")
user = await require_auth_from_state(request)
if user.role != "admin":
raise HTTPException(
status_code=403,
detail="Admin access required"
)
return user

310
routes/auth/sso.py Normal file
View File

@@ -0,0 +1,310 @@
"""
SSO (Single Sign-On) implementation for Google and GitHub authentication
"""
import os
import logging
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import RedirectResponse
from fastapi_sso.sso.google import GoogleSSO
from fastapi_sso.sso.github import GithubSSO
from fastapi_sso.sso.base import OpenID
from pydantic import BaseModel
from . import user_manager, token_manager, User, AUTH_ENABLED, DISABLE_REGISTRATION
logger = logging.getLogger(__name__)
router = APIRouter()
# SSO Configuration
SSO_ENABLED = os.getenv("SSO_ENABLED", "true").lower() in ("true", "1", "yes", "on")
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
SSO_BASE_REDIRECT_URI = os.getenv("SSO_BASE_REDIRECT_URI", "http://localhost:7171/api/auth/sso/callback")
# Initialize SSO providers
google_sso = None
github_sso = None
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
google_sso = GoogleSSO(
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
redirect_uri=f"{SSO_BASE_REDIRECT_URI}/google",
allow_insecure_http=True, # Set to False in production with HTTPS
)
if GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET:
github_sso = GithubSSO(
client_id=GITHUB_CLIENT_ID,
client_secret=GITHUB_CLIENT_SECRET,
redirect_uri=f"{SSO_BASE_REDIRECT_URI}/github",
allow_insecure_http=True, # Set to False in production with HTTPS
)
class MessageResponse(BaseModel):
message: str
class SSOProvider(BaseModel):
name: str
display_name: str
enabled: bool
login_url: Optional[str] = None
class SSOStatusResponse(BaseModel):
sso_enabled: bool
providers: list[SSOProvider]
registration_enabled: bool = True
def create_or_update_sso_user(openid: OpenID, provider: str) -> User:
"""Create or update user from SSO provider data"""
# Generate username from email or use provider ID
email = openid.email
if not email:
raise HTTPException(status_code=400, detail="Email is required for SSO authentication")
# Use email prefix as username, fallback to provider + id
username = email.split("@")[0]
if not username:
username = f"{provider}_{openid.id}"
# Check if user already exists by email
existing_user = None
users = user_manager.load_users()
for user_data in users.values():
if user_data.get("email") == email:
existing_user = User(**{k: v for k, v in user_data.items() if k != "password_hash"})
break
if existing_user:
# Update last login for existing user (always allowed)
users[existing_user.username]["last_login"] = datetime.utcnow().isoformat()
users[existing_user.username]["sso_provider"] = provider
users[existing_user.username]["sso_id"] = openid.id
user_manager.save_users(users)
return existing_user
else:
# Check if registration is disabled before creating new user
if DISABLE_REGISTRATION:
raise HTTPException(
status_code=403,
detail="Registration is disabled. Contact an administrator to create an account."
)
# Create new user
# Ensure username is unique
counter = 1
original_username = username
while username in users:
username = f"{original_username}{counter}"
counter += 1
user = User(
username=username,
email=email,
role="user" # Default role for SSO users
)
users[username] = {
**user.to_dict(),
"sso_provider": provider,
"sso_id": openid.id,
"password_hash": None # SSO users don't have passwords
}
user_manager.save_users(users)
logger.info(f"Created SSO user: {username} via {provider}")
return user
@router.get("/sso/status", response_model=SSOStatusResponse)
async def sso_status():
"""Get SSO status and available providers"""
providers = []
if google_sso:
providers.append(SSOProvider(
name="google",
display_name="Google",
enabled=True,
login_url="/api/auth/sso/login/google"
))
if github_sso:
providers.append(SSOProvider(
name="github",
display_name="GitHub",
enabled=True,
login_url="/api/auth/sso/login/github"
))
return SSOStatusResponse(
sso_enabled=SSO_ENABLED and AUTH_ENABLED,
providers=providers,
registration_enabled=not DISABLE_REGISTRATION
)
@router.get("/sso/login/google")
async def google_login():
"""Initiate Google SSO login"""
if not SSO_ENABLED or not AUTH_ENABLED:
raise HTTPException(status_code=400, detail="SSO is disabled")
if not google_sso:
raise HTTPException(status_code=400, detail="Google SSO is not configured")
async with google_sso:
return await google_sso.get_login_redirect(params={"prompt": "consent", "access_type": "offline"})
@router.get("/sso/login/github")
async def github_login():
"""Initiate GitHub SSO login"""
if not SSO_ENABLED or not AUTH_ENABLED:
raise HTTPException(status_code=400, detail="SSO is disabled")
if not github_sso:
raise HTTPException(status_code=400, detail="GitHub SSO is not configured")
async with github_sso:
return await github_sso.get_login_redirect()
@router.get("/sso/callback/google")
async def google_callback(request: Request):
"""Handle Google SSO callback"""
if not SSO_ENABLED or not AUTH_ENABLED:
raise HTTPException(status_code=400, detail="SSO is disabled")
if not google_sso:
raise HTTPException(status_code=400, detail="Google SSO is not configured")
try:
async with google_sso:
openid = await google_sso.verify_and_process(request)
# Create or update user
user = create_or_update_sso_user(openid, "google")
# Create JWT token
access_token = token_manager.create_token(user)
# Redirect to frontend with token (you might want to customize this)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
response = RedirectResponse(url=f"{frontend_url}?token={access_token}")
# Also set as HTTP-only cookie
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="lax",
max_age=timedelta(hours=24).total_seconds()
)
return response
except HTTPException as e:
# Handle specific HTTP exceptions (like registration disabled)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
error_msg = e.detail if hasattr(e, 'detail') else "Authentication failed"
logger.warning(f"Google SSO callback error: {error_msg}")
return RedirectResponse(url=f"{frontend_url}?error={error_msg}")
except Exception as e:
logger.error(f"Google SSO callback error: {e}")
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
return RedirectResponse(url=f"{frontend_url}?error=Authentication failed")
@router.get("/sso/callback/github")
async def github_callback(request: Request):
"""Handle GitHub SSO callback"""
if not SSO_ENABLED or not AUTH_ENABLED:
raise HTTPException(status_code=400, detail="SSO is disabled")
if not github_sso:
raise HTTPException(status_code=400, detail="GitHub SSO is not configured")
try:
async with github_sso:
openid = await github_sso.verify_and_process(request)
# Create or update user
user = create_or_update_sso_user(openid, "github")
# Create JWT token
access_token = token_manager.create_token(user)
# Redirect to frontend with token (you might want to customize this)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
response = RedirectResponse(url=f"{frontend_url}?token={access_token}")
# Also set as HTTP-only cookie
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="lax",
max_age=timedelta(hours=24).total_seconds()
)
return response
except HTTPException as e:
# Handle specific HTTP exceptions (like registration disabled)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
error_msg = e.detail if hasattr(e, 'detail') else "Authentication failed"
logger.warning(f"GitHub SSO callback error: {error_msg}")
return RedirectResponse(url=f"{frontend_url}?error={error_msg}")
except Exception as e:
logger.error(f"GitHub SSO callback error: {e}")
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
return RedirectResponse(url=f"{frontend_url}?error=Authentication failed")
@router.post("/sso/unlink/{provider}", response_model=MessageResponse)
async def unlink_sso_provider(
provider: str,
request: Request,
):
"""Unlink SSO provider from user account"""
if not SSO_ENABLED or not AUTH_ENABLED:
raise HTTPException(status_code=400, detail="SSO is disabled")
if provider not in ["google", "github"]:
raise HTTPException(status_code=400, detail="Invalid SSO provider")
# Get current user from request (avoiding circular imports)
from .middleware import require_auth_from_state
current_user = await require_auth_from_state(request)
if not current_user.sso_provider:
raise HTTPException(status_code=400, detail="User is not linked to any SSO provider")
if current_user.sso_provider != provider:
raise HTTPException(status_code=400, detail=f"User is not linked to {provider}")
# Update user to remove SSO linkage
users = user_manager.load_users()
if current_user.username in users:
users[current_user.username]["sso_provider"] = None
users[current_user.username]["sso_id"] = None
user_manager.save_users(users)
logger.info(f"Unlinked SSO provider {provider} from user {current_user.username}")
return MessageResponse(message=f"SSO provider {provider} unlinked successfully")

View File

@@ -1,212 +0,0 @@
from flask import Blueprint, jsonify, request
import json
import logging
import os
from typing import Any
# Import the centralized config getters that handle file creation and defaults
from routes.utils.celery_config import (
get_config_params as get_main_config_params,
DEFAULT_MAIN_CONFIG,
CONFIG_FILE_PATH as MAIN_CONFIG_FILE_PATH,
)
from routes.utils.watch.manager import (
get_watch_config as get_watch_manager_config,
DEFAULT_WATCH_CONFIG,
CONFIG_FILE_PATH as WATCH_CONFIG_FILE_PATH,
)
logger = logging.getLogger(__name__)
config_bp = Blueprint("config", __name__)
# Flag for config change notifications
config_changed = False
last_config: dict[str, Any] = {}
# Define parameters that should trigger notification when changed
NOTIFY_PARAMETERS = [
"maxConcurrentDownloads",
"service",
"fallback",
"spotifyQuality",
"deezerQuality",
]
# Helper to get main config (uses the one from celery_config)
def get_config():
"""Retrieves the main configuration, creating it with defaults if necessary."""
return get_main_config_params()
# Helper to save main config
def save_config(config_data):
"""Saves the main configuration data to main.json."""
try:
MAIN_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Load current or default config
existing_config = {}
if MAIN_CONFIG_FILE_PATH.exists():
with open(MAIN_CONFIG_FILE_PATH, "r") as f_read:
existing_config = json.load(f_read)
else: # Should be rare if get_config_params was called
existing_config = DEFAULT_MAIN_CONFIG.copy()
# Update with new data
for key, value in config_data.items():
existing_config[key] = value
# Ensure all default keys are still there
for default_key, default_value in DEFAULT_MAIN_CONFIG.items():
if default_key not in existing_config:
existing_config[default_key] = default_value
with open(MAIN_CONFIG_FILE_PATH, "w") as f:
json.dump(existing_config, f, indent=4)
logger.info(f"Main configuration saved to {MAIN_CONFIG_FILE_PATH}")
return True, None
except Exception as e:
logger.error(f"Error saving main configuration: {e}", exc_info=True)
return False, str(e)
# Helper to get watch config (uses the one from watch/manager.py)
def get_watch_config_http(): # Renamed to avoid conflict with the imported get_watch_config
"""Retrieves the watch configuration, creating it with defaults if necessary."""
return get_watch_manager_config()
# Helper to save watch config
def save_watch_config_http(watch_config_data): # Renamed
"""Saves the watch configuration data to watch.json."""
try:
WATCH_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Similar logic to save_config: merge with defaults/existing
existing_config = {}
if WATCH_CONFIG_FILE_PATH.exists():
with open(WATCH_CONFIG_FILE_PATH, "r") as f_read:
existing_config = json.load(f_read)
else: # Should be rare if get_watch_manager_config was called
existing_config = DEFAULT_WATCH_CONFIG.copy()
for key, value in watch_config_data.items():
existing_config[key] = value
for default_key, default_value in DEFAULT_WATCH_CONFIG.items():
if default_key not in existing_config:
existing_config[default_key] = default_value
with open(WATCH_CONFIG_FILE_PATH, "w") as f:
json.dump(existing_config, f, indent=4)
logger.info(f"Watch configuration saved to {WATCH_CONFIG_FILE_PATH}")
return True, None
except Exception as e:
logger.error(f"Error saving watch configuration: {e}", exc_info=True)
return False, str(e)
@config_bp.route("/config", methods=["GET"])
def handle_config():
"""Handles GET requests for the main configuration."""
try:
config = get_config()
return jsonify(config)
except Exception as e:
logger.error(f"Error in GET /config: {e}", exc_info=True)
return jsonify(
{"error": "Failed to retrieve configuration", "details": str(e)}
), 500
@config_bp.route("/config", methods=["POST", "PUT"])
def update_config():
"""Handles POST/PUT requests to update the main configuration."""
try:
new_config = request.get_json()
if not isinstance(new_config, dict):
return jsonify({"error": "Invalid config format"}), 400
# Preserve the explicitFilter setting from environment
explicit_filter_env = os.environ.get("EXPLICIT_FILTER", "false").lower()
new_config["explicitFilter"] = explicit_filter_env in ("true", "1", "yes", "on")
success, error_msg = save_config(new_config)
if success:
# Return the updated config
updated_config_values = get_config()
if updated_config_values is None:
# This case should ideally not be reached if save_config succeeded
# and get_config handles errors by returning a default or None.
return jsonify(
{"error": "Failed to retrieve configuration after saving"}
), 500
return jsonify(updated_config_values)
else:
return jsonify(
{"error": "Failed to update configuration", "details": error_msg}
), 500
except json.JSONDecodeError:
return jsonify({"error": "Invalid JSON data"}), 400
except Exception as e:
logger.error(f"Error in POST/PUT /config: {e}", exc_info=True)
return jsonify(
{"error": "Failed to update configuration", "details": str(e)}
), 500
@config_bp.route("/config/check", methods=["GET"])
def check_config_changes():
# This endpoint seems more related to dynamically checking if config changed
# on disk, which might not be necessary if settings are applied on restart
# or by a dedicated manager. For now, just return current config.
try:
config = get_config()
return jsonify(
{"message": "Current configuration retrieved.", "config": config}
)
except Exception as e:
logger.error(f"Error in GET /config/check: {e}", exc_info=True)
return jsonify(
{"error": "Failed to check configuration", "details": str(e)}
), 500
@config_bp.route("/config/watch", methods=["GET"])
def handle_watch_config():
"""Handles GET requests for the watch configuration."""
try:
watch_config = get_watch_config_http()
return jsonify(watch_config)
except Exception as e:
logger.error(f"Error in GET /config/watch: {e}", exc_info=True)
return jsonify(
{"error": "Failed to retrieve watch configuration", "details": str(e)}
), 500
@config_bp.route("/config/watch", methods=["POST", "PUT"])
def update_watch_config():
"""Handles POST/PUT requests to update the watch configuration."""
try:
new_watch_config = request.get_json()
if not isinstance(new_watch_config, dict):
return jsonify({"error": "Invalid watch config format"}), 400
success, error_msg = save_watch_config_http(new_watch_config)
if success:
return jsonify({"message": "Watch configuration updated successfully"}), 200
else:
return jsonify(
{"error": "Failed to update watch configuration", "details": error_msg}
), 500
except json.JSONDecodeError:
return jsonify({"error": "Invalid JSON data for watch config"}), 400
except Exception as e:
logger.error(f"Error in POST/PUT /config/watch: {e}", exc_info=True)
return jsonify(
{"error": "Failed to update watch configuration", "details": str(e)}
), 500

View File

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, Response, request from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json import json
import traceback import traceback
import uuid import uuid
@@ -8,17 +9,25 @@ from routes.utils.celery_tasks import store_task_info, store_task_status, Progre
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_spotify_info
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
album_bp = Blueprint("album", __name__) # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
router = APIRouter()
@album_bp.route("/download/<album_id>", methods=["GET"]) def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
def handle_download(album_id): """Construct a Spotify URL for a given item ID and type."""
return f"https://open.spotify.com/{item_type}/{item_id}"
@router.get("/download/{album_id}")
async def handle_download(album_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
# Retrieve essential parameters from the request. # Retrieve essential parameters from the request.
# name = request.args.get('name') # name = request.args.get('name')
# artist = request.args.get('artist') # artist = request.args.get('artist')
# Construct the URL from album_id # Construct the URL from album_id
url = f"https://open.spotify.com/album/{album_id}" url = construct_spotify_url(album_id, "album")
# Fetch metadata from Spotify # Fetch metadata from Spotify
try: try:
@@ -28,12 +37,9 @@ def handle_download(album_id):
or not album_info.get("name") or not album_info.get("name")
or not album_info.get("artists") or not album_info.get("artists")
): ):
return Response( return JSONResponse(
json.dumps( content={"error": f"Could not retrieve metadata for album ID: {album_id}"},
{"error": f"Could not retrieve metadata for album ID: {album_id}"} status_code=404
),
status=404,
mimetype="application/json",
) )
name_from_spotify = album_info.get("name") name_from_spotify = album_info.get("name")
@@ -44,27 +50,23 @@ def handle_download(album_id):
) )
except Exception as e: except Exception as e:
return Response( return JSONResponse(
json.dumps( content={"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"},
{"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"} status_code=500
),
status=500,
mimetype="application/json",
) )
# Validate required parameters # Validate required parameters
if not url: if not url:
return Response( return JSONResponse(
json.dumps({"error": "Missing required parameter: url"}), content={"error": "Missing required parameter: url"},
status=400, status_code=400
mimetype="application/json",
) )
# Add the task to the queue with only essential parameters # Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters # The queue manager will now handle all config parameters
# Include full original request URL in metadata # Include full original request URL in metadata
orig_params = request.args.to_dict() orig_params = dict(request.query_params)
orig_params["original_url"] = request.url orig_params["original_url"] = str(request.url)
try: try:
task_id = download_queue_manager.add_task( task_id = download_queue_manager.add_task(
{ {
@@ -76,15 +78,12 @@ def handle_download(album_id):
} }
) )
except DuplicateDownloadError as e: except DuplicateDownloadError as e:
return Response( return JSONResponse(
json.dumps( content={
{ "error": "Duplicate download detected.",
"error": "Duplicate download detected.", "existing_task": e.existing_task,
"existing_task": e.existing_task, },
} status_code=409
),
status=409,
mimetype="application/json",
) )
except Exception as e: except Exception as e:
# Generic error handling for other issues during task submission # Generic error handling for other issues during task submission
@@ -111,63 +110,57 @@ def handle_download(album_id):
"timestamp": time.time(), "timestamp": time.time(),
}, },
) )
return Response( return JSONResponse(
json.dumps( content={
{ "error": f"Failed to queue album download: {str(e)}",
"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id,
"task_id": error_task_id, },
} status_code=500
),
status=500,
mimetype="application/json",
) )
return Response( return JSONResponse(
json.dumps({"task_id": task_id}), status=202, mimetype="application/json" content={"task_id": task_id},
status_code=202
) )
@album_bp.route("/download/cancel", methods=["GET"]) @router.get("/download/cancel")
def cancel_download(): async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Cancel a running download process by its task id. Cancel a running download process by its task id.
""" """
task_id = request.args.get("task_id") task_id = request.query_params.get("task_id")
if not task_id: if not task_id:
return Response( return JSONResponse(
json.dumps({"error": "Missing process id (task_id) parameter"}), content={"error": "Missing process id (task_id) parameter"},
status=400, status_code=400
mimetype="application/json",
) )
# Use the queue manager's cancellation method. # Use the queue manager's cancellation method.
result = download_queue_manager.cancel_task(task_id) result = download_queue_manager.cancel_task(task_id)
status_code = 200 if result.get("status") == "cancelled" else 404 status_code = 200 if result.get("status") == "cancelled" else 404
return Response(json.dumps(result), status=status_code, mimetype="application/json") return JSONResponse(content=result, status_code=status_code)
@album_bp.route("/info", methods=["GET"]) @router.get("/info")
def get_album_info(): async def get_album_info(request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Retrieve Spotify album metadata given a Spotify album ID. Retrieve Spotify album metadata given a Spotify album ID.
Expects a query parameter 'id' that contains the Spotify album ID. Expects a query parameter 'id' that contains the Spotify album ID.
""" """
spotify_id = request.args.get("id") spotify_id = request.query_params.get("id")
if not spotify_id: if not spotify_id:
return Response( return JSONResponse(
json.dumps({"error": "Missing parameter: id"}), content={"error": "Missing parameter: id"},
status=400, status_code=400
mimetype="application/json",
) )
try: try:
# Import and use the get_spotify_info function from the utility module. # Use the get_spotify_info function (already imported at top)
from routes.utils.get_info import get_spotify_info
album_info = get_spotify_info(spotify_id, "album") album_info = get_spotify_info(spotify_id, "album")
return Response(json.dumps(album_info), status=200, mimetype="application/json") return JSONResponse(content=album_info, status_code=200)
except Exception as e: except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()} error_data = {"error": str(e), "traceback": traceback.format_exc()}
return Response(json.dumps(error_data), status=500, mimetype="application/json") return JSONResponse(content=error_data, status_code=500)

View File

@@ -1,8 +1,9 @@
""" """
Artist endpoint blueprint. Artist endpoint router.
""" """
from flask import Blueprint, Response, request, jsonify from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json import json
import traceback import traceback
from routes.utils.artist import download_artist_albums from routes.utils.artist import download_artist_albums
@@ -22,36 +23,43 @@ from routes.utils.watch.db import (
from routes.utils.watch.manager import check_watched_artists, get_watch_config from routes.utils.watch.manager import check_watched_artists, get_watch_config
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_spotify_info
artist_bp = Blueprint("artist", __name__, url_prefix="/api/artist") # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User
router = APIRouter()
# Existing log_json can be used, or a logger instance. # Existing log_json can be used, or a logger instance.
# Let's initialize a logger for consistency with merged code. # Let's initialize a logger for consistency with merged code.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
"""Construct a Spotify URL for a given item ID and type."""
return f"https://open.spotify.com/{item_type}/{item_id}"
def log_json(message_dict): def log_json(message_dict):
print(json.dumps(message_dict)) print(json.dumps(message_dict))
@artist_bp.route("/download/<artist_id>", methods=["GET"]) @router.get("/download/{artist_id}")
def handle_artist_download(artist_id): async def handle_artist_download(artist_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Enqueues album download tasks for the given artist. Enqueues album download tasks for the given artist.
Expected query parameters: Expected query parameters:
- album_type: string(s); comma-separated values such as "album,single,appears_on,compilation" - album_type: string(s); comma-separated values such as "album,single,appears_on,compilation"
""" """
# Construct the artist URL from artist_id # Construct the artist URL from artist_id
url = f"https://open.spotify.com/artist/{artist_id}" url = construct_spotify_url(artist_id, "artist")
# Retrieve essential parameters from the request. # Retrieve essential parameters from the request.
album_type = request.args.get("album_type", "album,single,compilation") album_type = request.query_params.get("album_type", "album,single,compilation")
# Validate required parameters # Validate required parameters
if not url: # This check is mostly for safety, as url is constructed if not url: # This check is mostly for safety, as url is constructed
return Response( return JSONResponse(
json.dumps({"error": "Missing required parameter: url"}), content={"error": "Missing required parameter: url"},
status=400, status_code=400
mimetype="application/json",
) )
try: try:
@@ -60,7 +68,7 @@ def handle_artist_download(artist_id):
# Delegate to the download_artist_albums function which will handle album filtering # Delegate to the download_artist_albums function which will handle album filtering
successfully_queued_albums, duplicate_albums = download_artist_albums( successfully_queued_albums, duplicate_albums = download_artist_albums(
url=url, album_type=album_type, request_args=request.args.to_dict() url=url, album_type=album_type, request_args=dict(request.query_params)
) )
# Return the list of album task IDs. # Return the list of album task IDs.
@@ -75,64 +83,68 @@ def handle_artist_download(artist_id):
f" {len(duplicate_albums)} albums were already in progress or queued." f" {len(duplicate_albums)} albums were already in progress or queued."
) )
return Response( return JSONResponse(
json.dumps(response_data), content=response_data,
status=202, # Still 202 Accepted as some operations may have succeeded status_code=202 # Still 202 Accepted as some operations may have succeeded
mimetype="application/json",
) )
except Exception as e: except Exception as e:
return Response( return JSONResponse(
json.dumps( content={
{ "status": "error",
"status": "error", "message": str(e),
"message": str(e), "traceback": traceback.format_exc(),
"traceback": traceback.format_exc(), },
} status_code=500
),
status=500,
mimetype="application/json",
) )
@artist_bp.route("/download/cancel", methods=["GET"]) @router.get("/download/cancel")
def cancel_artist_download(): async def cancel_artist_download():
""" """
Cancelling an artist download is not supported since the endpoint only enqueues album tasks. Cancelling an artist download is not supported since the endpoint only enqueues album tasks.
(Cancellation for individual album tasks can be implemented via the queue manager.) (Cancellation for individual album tasks can be implemented via the queue manager.)
""" """
return Response( return JSONResponse(
json.dumps({"error": "Artist download cancellation is not supported."}), content={"error": "Artist download cancellation is not supported."},
status=400, status_code=400
mimetype="application/json",
) )
@artist_bp.route("/info", methods=["GET"]) @router.get("/info")
def get_artist_info(): async def get_artist_info(request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Retrieves Spotify artist metadata given a Spotify artist ID. Retrieves Spotify artist metadata given a Spotify artist ID.
Expects a query parameter 'id' with the Spotify artist ID. Expects a query parameter 'id' with the Spotify artist ID.
""" """
spotify_id = request.args.get("id") spotify_id = request.query_params.get("id")
if not spotify_id: if not spotify_id:
return Response( return JSONResponse(
json.dumps({"error": "Missing parameter: id"}), content={"error": "Missing parameter: id"},
status=400, status_code=400
mimetype="application/json",
) )
try: try:
artist_info = get_spotify_info(spotify_id, "artist_discography") # Get artist metadata first
artist_metadata = get_spotify_info(spotify_id, "artist")
# Get artist discography for albums
artist_discography = get_spotify_info(spotify_id, "artist_discography")
# Combine metadata with discography
artist_info = {
**artist_metadata,
"albums": artist_discography
}
# If artist_info is successfully fetched (it contains album items), # If artist_info is successfully fetched and has albums,
# check if the artist is watched and augment album items with is_locally_known status # check if the artist is watched and augment album items with is_locally_known status
if artist_info and artist_info.get("items"): if artist_info and artist_info.get("albums") and artist_info["albums"].get("items"):
watched_artist_details = get_watched_artist( watched_artist_details = get_watched_artist(
spotify_id spotify_id
) # spotify_id is the artist ID ) # spotify_id is the artist ID
if watched_artist_details: # Artist is being watched if watched_artist_details: # Artist is being watched
for album_item in artist_info["items"]: for album_item in artist_info["albums"]["items"]:
if album_item and album_item.get("id"): if album_item and album_item.get("id"):
album_id = album_item["id"] album_id = album_item["id"]
album_item["is_locally_known"] = is_album_in_artist_db( album_item["is_locally_known"] = is_album_in_artist_db(
@@ -143,92 +155,65 @@ def get_artist_info():
# If not watched, or no albums, is_locally_known will not be added. # If not watched, or no albums, is_locally_known will not be added.
# Frontend should handle absence of this key as false. # Frontend should handle absence of this key as false.
return Response( return JSONResponse(
json.dumps(artist_info), status=200, mimetype="application/json" content=artist_info, status_code=200
) )
except Exception as e: except Exception as e:
return Response( return JSONResponse(
json.dumps({"error": str(e), "traceback": traceback.format_exc()}), content={"error": str(e), "traceback": traceback.format_exc()},
status=500, status_code=500
mimetype="application/json",
) )
# --- Merged Artist Watch Routes --- # --- Merged Artist Watch Routes ---
@artist_bp.route("/watch/<string:artist_spotify_id>", methods=["PUT"]) @router.put("/watch/{artist_spotify_id}")
def add_artist_to_watchlist(artist_spotify_id): async def add_artist_to_watchlist(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Adds an artist to the watchlist.""" """Adds an artist to the watchlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify({"error": "Watch feature is currently disabled globally."}), 403 raise HTTPException(status_code=403, detail={"error": "Watch feature is currently disabled globally."})
logger.info(f"Attempting to add artist {artist_spotify_id} to watchlist.") logger.info(f"Attempting to add artist {artist_spotify_id} to watchlist.")
try: try:
if get_watched_artist(artist_spotify_id): if get_watched_artist(artist_spotify_id):
return jsonify( return {"message": f"Artist {artist_spotify_id} is already being watched."}
{"message": f"Artist {artist_spotify_id} is already being watched."}
), 200
# This call returns an album list-like structure based on logs # Get artist metadata directly for name and basic info
artist_metadata = get_spotify_info(artist_spotify_id, "artist")
# Get artist discography for album count
artist_album_list_data = get_spotify_info( artist_album_list_data = get_spotify_info(
artist_spotify_id, "artist_discography" artist_spotify_id, "artist_discography"
) )
# Check if we got any data and if it has items # Check if we got artist metadata
if not artist_metadata or not artist_metadata.get("name"):
logger.error(
f"Could not fetch artist metadata for {artist_spotify_id} from Spotify."
)
raise HTTPException(
status_code=404,
detail={
"error": f"Could not fetch artist metadata for {artist_spotify_id} to initiate watch."
}
)
# Check if we got album data
if not artist_album_list_data or not isinstance( if not artist_album_list_data or not isinstance(
artist_album_list_data.get("items"), list artist_album_list_data.get("items"), list
): ):
logger.error(
f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist_discography'). Data: {artist_album_list_data}"
)
return jsonify(
{
"error": f"Could not fetch sufficient details for artist {artist_spotify_id} to initiate watch."
}
), 404
# Attempt to extract artist name and verify ID
# The actual artist name might be consistently found in the items, if they exist
artist_name_from_albums = "Unknown Artist" # Default
if artist_album_list_data["items"]:
first_album = artist_album_list_data["items"][0]
if (
first_album
and isinstance(first_album.get("artists"), list)
and first_album["artists"]
):
# Find the artist in the list that matches the artist_spotify_id
found_artist = next(
(
art
for art in first_album["artists"]
if art.get("id") == artist_spotify_id
),
None,
)
if found_artist and found_artist.get("name"):
artist_name_from_albums = found_artist["name"]
elif first_album["artists"][0].get(
"name"
): # Fallback to first artist if specific match not found or no ID
artist_name_from_albums = first_album["artists"][0]["name"]
logger.warning(
f"Could not find exact artist ID {artist_spotify_id} in first album's artists list. Using name '{artist_name_from_albums}'."
)
else:
logger.warning( logger.warning(
f"No album items found for artist {artist_spotify_id} to extract name. Using default." f"Could not fetch album list details for artist {artist_spotify_id} from Spotify. Proceeding with metadata only."
) )
# Construct the artist_data object expected by add_artist_db # Construct the artist_data object expected by add_artist_db
# We use the provided artist_spotify_id as the primary ID.
artist_data_for_db = { artist_data_for_db = {
"id": artist_spotify_id, # This is the crucial part "id": artist_spotify_id,
"name": artist_name_from_albums, "name": artist_metadata.get("name", "Unknown Artist"),
"albums": { # Mimic structure if add_artist_db expects it for total_albums "albums": { # Mimic structure if add_artist_db expects it for total_albums
"total": artist_album_list_data.get("total", 0) "total": artist_album_list_data.get("total", 0) if artist_album_list_data else 0
}, },
# Add any other fields add_artist_db might expect from a true artist object if necessary # Add any other fields add_artist_db might expect from a true artist object if necessary
} }
@@ -236,117 +221,120 @@ def add_artist_to_watchlist(artist_spotify_id):
add_artist_db(artist_data_for_db) add_artist_db(artist_data_for_db)
logger.info( logger.info(
f"Artist {artist_spotify_id} ('{artist_name_from_albums}') added to watchlist. Their albums will be processed by the watch manager." f"Artist {artist_spotify_id} ('{artist_metadata.get('name', 'Unknown Artist')}') added to watchlist. Their albums will be processed by the watch manager."
) )
return jsonify( return {
{ "message": f"Artist {artist_spotify_id} added to watchlist. Albums will be processed shortly."
"message": f"Artist {artist_spotify_id} added to watchlist. Albums will be processed shortly." }
} except HTTPException:
), 201 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error adding artist {artist_spotify_id} to watchlist: {e}", exc_info=True f"Error adding artist {artist_spotify_id} to watchlist: {e}", exc_info=True
) )
return jsonify({"error": f"Could not add artist to watchlist: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not add artist to watchlist: {str(e)}"})
@artist_bp.route("/watch/<string:artist_spotify_id>/status", methods=["GET"]) @router.get("/watch/{artist_spotify_id}/status")
def get_artist_watch_status(artist_spotify_id): async def get_artist_watch_status(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Checks if a specific artist is being watched.""" """Checks if a specific artist is being watched."""
logger.info(f"Checking watch status for artist {artist_spotify_id}.") logger.info(f"Checking watch status for artist {artist_spotify_id}.")
try: try:
artist = get_watched_artist(artist_spotify_id) artist = get_watched_artist(artist_spotify_id)
if artist: if artist:
return jsonify({"is_watched": True, "artist_data": dict(artist)}), 200 return {"is_watched": True, "artist_data": dict(artist)}
else: else:
return jsonify({"is_watched": False}), 200 return {"is_watched": False}
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error checking watch status for artist {artist_spotify_id}: {e}", f"Error checking watch status for artist {artist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not check watch status: {str(e)}"})
@artist_bp.route("/watch/<string:artist_spotify_id>", methods=["DELETE"]) @router.delete("/watch/{artist_spotify_id}")
def remove_artist_from_watchlist(artist_spotify_id): async def remove_artist_from_watchlist(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Removes an artist from the watchlist.""" """Removes an artist from the watchlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify({"error": "Watch feature is currently disabled globally."}), 403 raise HTTPException(status_code=403, detail={"error": "Watch feature is currently disabled globally."})
logger.info(f"Attempting to remove artist {artist_spotify_id} from watchlist.") logger.info(f"Attempting to remove artist {artist_spotify_id} from watchlist.")
try: try:
if not get_watched_artist(artist_spotify_id): if not get_watched_artist(artist_spotify_id):
return jsonify( raise HTTPException(
{"error": f"Artist {artist_spotify_id} not found in watchlist."} status_code=404,
), 404 detail={"error": f"Artist {artist_spotify_id} not found in watchlist."}
)
remove_artist_db(artist_spotify_id) remove_artist_db(artist_spotify_id)
logger.info(f"Artist {artist_spotify_id} removed from watchlist successfully.") logger.info(f"Artist {artist_spotify_id} removed from watchlist successfully.")
return jsonify( return {"message": f"Artist {artist_spotify_id} removed from watchlist."}
{"message": f"Artist {artist_spotify_id} removed from watchlist."} except HTTPException:
), 200 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error removing artist {artist_spotify_id} from watchlist: {e}", f"Error removing artist {artist_spotify_id} from watchlist: {e}",
exc_info=True, exc_info=True,
) )
return jsonify( raise HTTPException(
{"error": f"Could not remove artist from watchlist: {str(e)}"} status_code=500,
), 500 detail={"error": f"Could not remove artist from watchlist: {str(e)}"}
)
@artist_bp.route("/watch/list", methods=["GET"]) @router.get("/watch/list")
def list_watched_artists_endpoint(): async def list_watched_artists_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Lists all artists currently in the watchlist.""" """Lists all artists currently in the watchlist."""
try: try:
artists = get_watched_artists() artists = get_watched_artists()
return jsonify([dict(artist) for artist in artists]), 200 return [dict(artist) for artist in artists]
except Exception as e: except Exception as e:
logger.error(f"Error listing watched artists: {e}", exc_info=True) logger.error(f"Error listing watched artists: {e}", exc_info=True)
return jsonify({"error": f"Could not list watched artists: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not list watched artists: {str(e)}"})
@artist_bp.route("/watch/trigger_check", methods=["POST"]) @router.post("/watch/trigger_check")
def trigger_artist_check_endpoint(): async def trigger_artist_check_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the artist checking mechanism for all watched artists.""" """Manually triggers the artist checking mechanism for all watched artists."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot trigger check." "error": "Watch feature is currently disabled globally. Cannot trigger check."
} }
), 403 )
logger.info("Manual trigger for artist check received for all artists.") logger.info("Manual trigger for artist check received for all artists.")
try: try:
thread = threading.Thread(target=check_watched_artists, args=(None,)) thread = threading.Thread(target=check_watched_artists, args=(None,))
thread.start() thread.start()
return jsonify( return {
{ "message": "Artist check triggered successfully in the background for all artists."
"message": "Artist check triggered successfully in the background for all artists." }
}
), 202
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error manually triggering artist check for all: {e}", exc_info=True f"Error manually triggering artist check for all: {e}", exc_info=True
) )
return jsonify( raise HTTPException(
{"error": f"Could not trigger artist check for all: {str(e)}"} status_code=500,
), 500 detail={"error": f"Could not trigger artist check for all: {str(e)}"}
)
@artist_bp.route("/watch/trigger_check/<string:artist_spotify_id>", methods=["POST"]) @router.post("/watch/trigger_check/{artist_spotify_id}")
def trigger_specific_artist_check_endpoint(artist_spotify_id: str): async def trigger_specific_artist_check_endpoint(artist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the artist checking mechanism for a specific artist.""" """Manually triggers the artist checking mechanism for a specific artist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot trigger check." "error": "Watch feature is currently disabled globally. Cannot trigger check."
} }
), 403 )
logger.info( logger.info(
f"Manual trigger for specific artist check received for ID: {artist_spotify_id}" f"Manual trigger for specific artist check received for ID: {artist_spotify_id}"
@@ -357,11 +345,12 @@ def trigger_specific_artist_check_endpoint(artist_spotify_id: str):
logger.warning( logger.warning(
f"Trigger specific check: Artist ID {artist_spotify_id} not found in watchlist." f"Trigger specific check: Artist ID {artist_spotify_id} not found in watchlist."
) )
return jsonify( raise HTTPException(
{ status_code=404,
detail={
"error": f"Artist {artist_spotify_id} is not in the watchlist. Add it first." "error": f"Artist {artist_spotify_id} is not in the watchlist. Add it first."
} }
), 404 )
thread = threading.Thread( thread = threading.Thread(
target=check_watched_artists, args=(artist_spotify_id,) target=check_watched_artists, args=(artist_spotify_id,)
@@ -370,50 +359,54 @@ def trigger_specific_artist_check_endpoint(artist_spotify_id: str):
logger.info( logger.info(
f"Artist check triggered in background for specific artist ID: {artist_spotify_id}" f"Artist check triggered in background for specific artist ID: {artist_spotify_id}"
) )
return jsonify( return {
{ "message": f"Artist check triggered successfully in the background for {artist_spotify_id}."
"message": f"Artist check triggered successfully in the background for {artist_spotify_id}." }
} except HTTPException:
), 202 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error manually triggering specific artist check for {artist_spotify_id}: {e}", f"Error manually triggering specific artist check for {artist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify( raise HTTPException(
{ status_code=500,
detail={
"error": f"Could not trigger artist check for {artist_spotify_id}: {str(e)}" "error": f"Could not trigger artist check for {artist_spotify_id}: {str(e)}"
} }
), 500 )
@artist_bp.route("/watch/<string:artist_spotify_id>/albums", methods=["POST"]) @router.post("/watch/{artist_spotify_id}/albums")
def mark_albums_as_known_for_artist(artist_spotify_id): async def mark_albums_as_known_for_artist(artist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Fetches details for given album IDs and adds/updates them in the artist's local DB table.""" """Fetches details for given album IDs and adds/updates them in the artist's local DB table."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot mark albums." "error": "Watch feature is currently disabled globally. Cannot mark albums."
} }
), 403 )
logger.info(f"Attempting to mark albums as known for artist {artist_spotify_id}.") logger.info(f"Attempting to mark albums as known for artist {artist_spotify_id}.")
try: try:
album_ids = request.json album_ids = await request.json()
if not isinstance(album_ids, list) or not all( if not isinstance(album_ids, list) or not all(
isinstance(aid, str) for aid in album_ids isinstance(aid, str) for aid in album_ids
): ):
return jsonify( raise HTTPException(
{ status_code=400,
detail={
"error": "Invalid request body. Expecting a JSON array of album Spotify IDs." "error": "Invalid request body. Expecting a JSON array of album Spotify IDs."
} }
), 400 )
if not get_watched_artist(artist_spotify_id): if not get_watched_artist(artist_spotify_id):
return jsonify( raise HTTPException(
{"error": f"Artist {artist_spotify_id} is not being watched."} status_code=404,
), 404 detail={"error": f"Artist {artist_spotify_id} is not being watched."}
)
fetched_albums_details = [] fetched_albums_details = []
for album_id in album_ids: for album_id in album_ids:
@@ -432,12 +425,10 @@ def mark_albums_as_known_for_artist(artist_spotify_id):
) )
if not fetched_albums_details: if not fetched_albums_details:
return jsonify( return {
{ "message": "No valid album details could be fetched to mark as known.",
"message": "No valid album details could be fetched to mark as known.", "processed_count": 0,
"processed_count": 0, }
}
), 200
processed_count = add_specific_albums_to_artist_table( processed_count = add_specific_albums_to_artist_table(
artist_spotify_id, fetched_albums_details artist_spotify_id, fetched_albums_details
@@ -445,48 +436,51 @@ def mark_albums_as_known_for_artist(artist_spotify_id):
logger.info( logger.info(
f"Successfully marked/updated {processed_count} albums as known for artist {artist_spotify_id}." f"Successfully marked/updated {processed_count} albums as known for artist {artist_spotify_id}."
) )
return jsonify( return {
{ "message": f"Successfully processed {processed_count} albums for artist {artist_spotify_id}."
"message": f"Successfully processed {processed_count} albums for artist {artist_spotify_id}." }
} except HTTPException:
), 200 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error marking albums as known for artist {artist_spotify_id}: {e}", f"Error marking albums as known for artist {artist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not mark albums as known: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not mark albums as known: {str(e)}"})
@artist_bp.route("/watch/<string:artist_spotify_id>/albums", methods=["DELETE"]) @router.delete("/watch/{artist_spotify_id}/albums")
def mark_albums_as_missing_locally_for_artist(artist_spotify_id): async def mark_albums_as_missing_locally_for_artist(artist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Removes specified albums from the artist's local DB table.""" """Removes specified albums from the artist's local DB table."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot mark albums." "error": "Watch feature is currently disabled globally. Cannot mark albums."
} }
), 403 )
logger.info( logger.info(
f"Attempting to mark albums as missing (delete locally) for artist {artist_spotify_id}." f"Attempting to mark albums as missing (delete locally) for artist {artist_spotify_id}."
) )
try: try:
album_ids = request.json album_ids = await request.json()
if not isinstance(album_ids, list) or not all( if not isinstance(album_ids, list) or not all(
isinstance(aid, str) for aid in album_ids isinstance(aid, str) for aid in album_ids
): ):
return jsonify( raise HTTPException(
{ status_code=400,
detail={
"error": "Invalid request body. Expecting a JSON array of album Spotify IDs." "error": "Invalid request body. Expecting a JSON array of album Spotify IDs."
} }
), 400 )
if not get_watched_artist(artist_spotify_id): if not get_watched_artist(artist_spotify_id):
return jsonify( raise HTTPException(
{"error": f"Artist {artist_spotify_id} is not being watched."} status_code=404,
), 404 detail={"error": f"Artist {artist_spotify_id} is not being watched."}
)
deleted_count = remove_specific_albums_from_artist_table( deleted_count = remove_specific_albums_from_artist_table(
artist_spotify_id, album_ids artist_spotify_id, album_ids
@@ -494,14 +488,14 @@ def mark_albums_as_missing_locally_for_artist(artist_spotify_id):
logger.info( logger.info(
f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}." f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}."
) )
return jsonify( return {
{ "message": f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}."
"message": f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}." }
} except HTTPException:
), 200 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error marking albums as missing (deleting locally) for artist {artist_spotify_id}: {e}", f"Error marking albums as missing (deleting locally) for artist {artist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not mark albums as missing: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not mark albums as missing: {str(e)}"})

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, Response, request, jsonify from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json import json
import traceback import traceback
import logging # Added logging import import logging # Added logging import
@@ -29,39 +30,43 @@ from routes.utils.watch.manager import (
) # For manual trigger & config ) # For manual trigger & config
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User
logger = logging.getLogger(__name__) # Added logger initialization logger = logging.getLogger(__name__) # Added logger initialization
playlist_bp = Blueprint("playlist", __name__, url_prefix="/api/playlist") router = APIRouter()
@playlist_bp.route("/download/<playlist_id>", methods=["GET"]) def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
def handle_download(playlist_id): """Construct a Spotify URL for a given item ID and type."""
return f"https://open.spotify.com/{item_type}/{item_id}"
@router.get("/download/{playlist_id}")
async def handle_download(playlist_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
# Retrieve essential parameters from the request. # Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed # name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed # artist = request.args.get('artist') # Removed
orig_params = request.args.to_dict() orig_params = dict(request.query_params)
# Construct the URL from playlist_id # Construct the URL from playlist_id
url = f"https://open.spotify.com/playlist/{playlist_id}" url = construct_spotify_url(playlist_id, "playlist")
orig_params["original_url"] = ( orig_params["original_url"] = str(request.url) # Update original_url to the constructed one
request.url
) # Update original_url to the constructed one
# Fetch metadata from Spotify # Fetch metadata from Spotify using optimized function
try: try:
playlist_info = get_spotify_info(playlist_id, "playlist") from routes.utils.get_info import get_playlist_metadata
playlist_info = get_playlist_metadata(playlist_id)
if ( if (
not playlist_info not playlist_info
or not playlist_info.get("name") or not playlist_info.get("name")
or not playlist_info.get("owner") or not playlist_info.get("owner")
): ):
return Response( return JSONResponse(
json.dumps( content={
{ "error": f"Could not retrieve metadata for playlist ID: {playlist_id}"
"error": f"Could not retrieve metadata for playlist ID: {playlist_id}" },
} status_code=404
),
status=404,
mimetype="application/json",
) )
name_from_spotify = playlist_info.get("name") name_from_spotify = playlist_info.get("name")
@@ -70,22 +75,18 @@ def handle_download(playlist_id):
artist_from_spotify = owner_info.get("display_name", "Unknown Owner") artist_from_spotify = owner_info.get("display_name", "Unknown Owner")
except Exception as e: except Exception as e:
return Response( return JSONResponse(
json.dumps( content={
{ "error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}"
"error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}" },
} status_code=500
),
status=500,
mimetype="application/json",
) )
# Validate required parameters # Validate required parameters
if not url: # This check might be redundant now but kept for safety if not url: # This check might be redundant now but kept for safety
return Response( return JSONResponse(
json.dumps({"error": "Missing required parameter: url"}), content={"error": "Missing required parameter: url"},
status=400, status_code=400
mimetype="application/json",
) )
try: try:
@@ -99,15 +100,12 @@ def handle_download(playlist_id):
} }
) )
except DuplicateDownloadError as e: except DuplicateDownloadError as e:
return Response( return JSONResponse(
json.dumps( content={
{ "error": "Duplicate download detected.",
"error": "Duplicate download detected.", "existing_task": e.existing_task,
"existing_task": e.existing_task, },
} status_code=409
),
status=409,
mimetype="application/json",
) )
except Exception as e: except Exception as e:
# Generic error handling for other issues during task submission # Generic error handling for other issues during task submission
@@ -132,62 +130,58 @@ def handle_download(playlist_id):
"timestamp": time.time(), "timestamp": time.time(),
}, },
) )
return Response( return JSONResponse(
json.dumps( content={
{ "error": f"Failed to queue playlist download: {str(e)}",
"error": f"Failed to queue playlist download: {str(e)}", "task_id": error_task_id,
"task_id": error_task_id, },
} status_code=500
),
status=500,
mimetype="application/json",
) )
return Response( return JSONResponse(
json.dumps({"task_id": task_id}), content={"task_id": task_id},
status=202, status_code=202
mimetype="application/json",
) )
@playlist_bp.route("/download/cancel", methods=["GET"]) @router.get("/download/cancel")
def cancel_download(): async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Cancel a running playlist download process by its task id. Cancel a running playlist download process by its task id.
""" """
task_id = request.args.get("task_id") task_id = request.query_params.get("task_id")
if not task_id: if not task_id:
return Response( return JSONResponse(
json.dumps({"error": "Missing task id (task_id) parameter"}), content={"error": "Missing task id (task_id) parameter"},
status=400, status_code=400
mimetype="application/json",
) )
# Use the queue manager's cancellation method. # Use the queue manager's cancellation method.
result = download_queue_manager.cancel_task(task_id) result = download_queue_manager.cancel_task(task_id)
status_code = 200 if result.get("status") == "cancelled" else 404 status_code = 200 if result.get("status") == "cancelled" else 404
return Response(json.dumps(result), status=status_code, mimetype="application/json") return JSONResponse(content=result, status_code=status_code)
@playlist_bp.route("/info", methods=["GET"]) @router.get("/info")
def get_playlist_info(): async def get_playlist_info(request: Request, current_user: User = Depends(require_auth_from_state)):
""" """
Retrieve Spotify playlist metadata given a Spotify playlist ID. Retrieve Spotify playlist metadata given a Spotify playlist ID.
Expects a query parameter 'id' that contains the Spotify playlist ID. Expects a query parameter 'id' that contains the Spotify playlist ID.
""" """
spotify_id = request.args.get("id") spotify_id = request.query_params.get("id")
include_tracks = request.query_params.get("include_tracks", "false").lower() == "true"
if not spotify_id: if not spotify_id:
return Response( return JSONResponse(
json.dumps({"error": "Missing parameter: id"}), content={"error": "Missing parameter: id"},
status=400, status_code=400
mimetype="application/json",
) )
try: try:
# Import and use the get_spotify_info function from the utility module. # Use the optimized playlist info function
playlist_info = get_spotify_info(spotify_id, "playlist") from routes.utils.get_info import get_playlist_info_optimized
playlist_info = get_playlist_info_optimized(spotify_id, include_tracks=include_tracks)
# If playlist_info is successfully fetched, check if it's watched # If playlist_info is successfully fetched, check if it's watched
# and augment track items with is_locally_known status # and augment track items with is_locally_known status
@@ -208,40 +202,96 @@ def get_playlist_info():
# If not watched, or no tracks, is_locally_known will not be added, or tracks won't exist to add it to. # If not watched, or no tracks, is_locally_known will not be added, or tracks won't exist to add it to.
# Frontend should handle absence of this key as false. # Frontend should handle absence of this key as false.
return Response( return JSONResponse(
json.dumps(playlist_info), status=200, mimetype="application/json" content=playlist_info, status_code=200
) )
except Exception as e: except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()} error_data = {"error": str(e), "traceback": traceback.format_exc()}
return Response(json.dumps(error_data), status=500, mimetype="application/json") return JSONResponse(content=error_data, status_code=500)
@playlist_bp.route("/watch/<string:playlist_spotify_id>", methods=["PUT"]) @router.get("/metadata")
def add_to_watchlist(playlist_spotify_id): async def get_playlist_metadata(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve only Spotify playlist metadata (no tracks) to avoid rate limiting.
Expects a query parameter 'id' that contains the Spotify playlist ID.
"""
spotify_id = request.query_params.get("id")
if not spotify_id:
return JSONResponse(
content={"error": "Missing parameter: id"},
status_code=400
)
try:
# Use the optimized playlist metadata function
from routes.utils.get_info import get_playlist_metadata
playlist_metadata = get_playlist_metadata(spotify_id)
return JSONResponse(
content=playlist_metadata, status_code=200
)
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
return JSONResponse(content=error_data, status_code=500)
@router.get("/tracks")
async def get_playlist_tracks(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve playlist tracks with pagination support for progressive loading.
Expects query parameters: 'id' (playlist ID), 'limit' (optional), 'offset' (optional).
"""
spotify_id = request.query_params.get("id")
limit = int(request.query_params.get("limit", 50))
offset = int(request.query_params.get("offset", 0))
if not spotify_id:
return JSONResponse(
content={"error": "Missing parameter: id"},
status_code=400
)
try:
# Use the optimized playlist tracks function
from routes.utils.get_info import get_playlist_tracks
tracks_data = get_playlist_tracks(spotify_id, limit=limit, offset=offset)
return JSONResponse(
content=tracks_data, status_code=200
)
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
return JSONResponse(content=error_data, status_code=500)
@router.put("/watch/{playlist_spotify_id}")
async def add_to_watchlist(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Adds a playlist to the watchlist.""" """Adds a playlist to the watchlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify({"error": "Watch feature is currently disabled globally."}), 403 raise HTTPException(status_code=403, detail={"error": "Watch feature is currently disabled globally."})
logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.") logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.")
try: try:
# Check if already watched # Check if already watched
if get_watched_playlist(playlist_spotify_id): if get_watched_playlist(playlist_spotify_id):
return jsonify( return {"message": f"Playlist {playlist_spotify_id} is already being watched."}
{"message": f"Playlist {playlist_spotify_id} is already being watched."}
), 200
# Fetch playlist details from Spotify to populate our DB # Fetch playlist details from Spotify to populate our DB
playlist_data = get_spotify_info(playlist_spotify_id, "playlist") from routes.utils.get_info import get_playlist_metadata
playlist_data = get_playlist_metadata(playlist_spotify_id)
if not playlist_data or "id" not in playlist_data: if not playlist_data or "id" not in playlist_data:
logger.error( logger.error(
f"Could not fetch details for playlist {playlist_spotify_id} from Spotify." f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."
) )
return jsonify( raise HTTPException(
{ status_code=404,
detail={
"error": f"Could not fetch details for playlist {playlist_spotify_id} from Spotify." "error": f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."
} }
), 404 )
add_playlist_db(playlist_data) # This also creates the tracks table add_playlist_db(playlist_data) # This also creates the tracks table
@@ -256,99 +306,104 @@ def add_to_watchlist(playlist_spotify_id):
logger.info( logger.info(
f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager." f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager."
) )
return jsonify( return {
{ "message": f"Playlist {playlist_spotify_id} added to watchlist. Tracks will be processed shortly."
"message": f"Playlist {playlist_spotify_id} added to watchlist. Tracks will be processed shortly." }
} except HTTPException:
), 201 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error adding playlist {playlist_spotify_id} to watchlist: {e}", f"Error adding playlist {playlist_spotify_id} to watchlist: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not add playlist to watchlist: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not add playlist to watchlist: {str(e)}"})
@playlist_bp.route("/watch/<string:playlist_spotify_id>/status", methods=["GET"]) @router.get("/watch/{playlist_spotify_id}/status")
def get_playlist_watch_status(playlist_spotify_id): async def get_playlist_watch_status(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Checks if a specific playlist is being watched.""" """Checks if a specific playlist is being watched."""
logger.info(f"Checking watch status for playlist {playlist_spotify_id}.") logger.info(f"Checking watch status for playlist {playlist_spotify_id}.")
try: try:
playlist = get_watched_playlist(playlist_spotify_id) playlist = get_watched_playlist(playlist_spotify_id)
if playlist: if playlist:
return jsonify({"is_watched": True, "playlist_data": playlist}), 200 return {"is_watched": True, "playlist_data": playlist}
else: else:
# Return 200 with is_watched: false, so frontend can clearly distinguish # Return 200 with is_watched: false, so frontend can clearly distinguish
# between "not watched" and an actual error fetching status. # between "not watched" and an actual error fetching status.
return jsonify({"is_watched": False}), 200 return {"is_watched": False}
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error checking watch status for playlist {playlist_spotify_id}: {e}", f"Error checking watch status for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not check watch status: {str(e)}"})
@playlist_bp.route("/watch/<string:playlist_spotify_id>", methods=["DELETE"]) @router.delete("/watch/{playlist_spotify_id}")
def remove_from_watchlist(playlist_spotify_id): async def remove_from_watchlist(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
"""Removes a playlist from the watchlist.""" """Removes a playlist from the watchlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify({"error": "Watch feature is currently disabled globally."}), 403 raise HTTPException(status_code=403, detail={"error": "Watch feature is currently disabled globally."})
logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.") logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.")
try: try:
if not get_watched_playlist(playlist_spotify_id): if not get_watched_playlist(playlist_spotify_id):
return jsonify( raise HTTPException(
{"error": f"Playlist {playlist_spotify_id} not found in watchlist."} status_code=404,
), 404 detail={"error": f"Playlist {playlist_spotify_id} not found in watchlist."}
)
remove_playlist_db(playlist_spotify_id) remove_playlist_db(playlist_spotify_id)
logger.info( logger.info(
f"Playlist {playlist_spotify_id} removed from watchlist successfully." f"Playlist {playlist_spotify_id} removed from watchlist successfully."
) )
return jsonify( return {"message": f"Playlist {playlist_spotify_id} removed from watchlist."}
{"message": f"Playlist {playlist_spotify_id} removed from watchlist."} except HTTPException:
), 200 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error removing playlist {playlist_spotify_id} from watchlist: {e}", f"Error removing playlist {playlist_spotify_id} from watchlist: {e}",
exc_info=True, exc_info=True,
) )
return jsonify( raise HTTPException(
{"error": f"Could not remove playlist from watchlist: {str(e)}"} status_code=500,
), 500 detail={"error": f"Could not remove playlist from watchlist: {str(e)}"}
)
@playlist_bp.route("/watch/<string:playlist_spotify_id>/tracks", methods=["POST"]) @router.post("/watch/{playlist_spotify_id}/tracks")
def mark_tracks_as_known(playlist_spotify_id): async def mark_tracks_as_known(playlist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Fetches details for given track IDs and adds/updates them in the playlist's local DB table.""" """Fetches details for given track IDs and adds/updates them in the playlist's local DB table."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot mark tracks." "error": "Watch feature is currently disabled globally. Cannot mark tracks."
} }
), 403 )
logger.info( logger.info(
f"Attempting to mark tracks as known for playlist {playlist_spotify_id}." f"Attempting to mark tracks as known for playlist {playlist_spotify_id}."
) )
try: try:
track_ids = request.json track_ids = await request.json()
if not isinstance(track_ids, list) or not all( if not isinstance(track_ids, list) or not all(
isinstance(tid, str) for tid in track_ids isinstance(tid, str) for tid in track_ids
): ):
return jsonify( raise HTTPException(
{ status_code=400,
detail={
"error": "Invalid request body. Expecting a JSON array of track Spotify IDs." "error": "Invalid request body. Expecting a JSON array of track Spotify IDs."
} }
), 400 )
if not get_watched_playlist(playlist_spotify_id): if not get_watched_playlist(playlist_spotify_id):
return jsonify( raise HTTPException(
{"error": f"Playlist {playlist_spotify_id} is not being watched."} status_code=404,
), 404 detail={"error": f"Playlist {playlist_spotify_id} is not being watched."}
)
fetched_tracks_details = [] fetched_tracks_details = []
for track_id in track_ids: for track_id in track_ids:
@@ -366,12 +421,10 @@ def mark_tracks_as_known(playlist_spotify_id):
) )
if not fetched_tracks_details: if not fetched_tracks_details:
return jsonify( return {
{ "message": "No valid track details could be fetched to mark as known.",
"message": "No valid track details could be fetched to mark as known.", "processed_count": 0,
"processed_count": 0, }
}
), 200
add_specific_tracks_to_playlist_table( add_specific_tracks_to_playlist_table(
playlist_spotify_id, fetched_tracks_details playlist_spotify_id, fetched_tracks_details
@@ -379,48 +432,51 @@ def mark_tracks_as_known(playlist_spotify_id):
logger.info( logger.info(
f"Successfully marked/updated {len(fetched_tracks_details)} tracks as known for playlist {playlist_spotify_id}." f"Successfully marked/updated {len(fetched_tracks_details)} tracks as known for playlist {playlist_spotify_id}."
) )
return jsonify( return {
{ "message": f"Successfully processed {len(fetched_tracks_details)} tracks for playlist {playlist_spotify_id}."
"message": f"Successfully processed {len(fetched_tracks_details)} tracks for playlist {playlist_spotify_id}." }
} except HTTPException:
), 200 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}", f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not mark tracks as known: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not mark tracks as known: {str(e)}"})
@playlist_bp.route("/watch/<string:playlist_spotify_id>/tracks", methods=["DELETE"]) @router.delete("/watch/{playlist_spotify_id}/tracks")
def mark_tracks_as_missing_locally(playlist_spotify_id): async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
"""Removes specified tracks from the playlist's local DB table.""" """Removes specified tracks from the playlist's local DB table."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot mark tracks." "error": "Watch feature is currently disabled globally. Cannot mark tracks."
} }
), 403 )
logger.info( logger.info(
f"Attempting to mark tracks as missing (remove locally) for playlist {playlist_spotify_id}." f"Attempting to mark tracks as missing (remove locally) for playlist {playlist_spotify_id}."
) )
try: try:
track_ids = request.json track_ids = await request.json()
if not isinstance(track_ids, list) or not all( if not isinstance(track_ids, list) or not all(
isinstance(tid, str) for tid in track_ids isinstance(tid, str) for tid in track_ids
): ):
return jsonify( raise HTTPException(
{ status_code=400,
detail={
"error": "Invalid request body. Expecting a JSON array of track Spotify IDs." "error": "Invalid request body. Expecting a JSON array of track Spotify IDs."
} }
), 400 )
if not get_watched_playlist(playlist_spotify_id): if not get_watched_playlist(playlist_spotify_id):
return jsonify( raise HTTPException(
{"error": f"Playlist {playlist_spotify_id} is not being watched."} status_code=404,
), 404 detail={"error": f"Playlist {playlist_spotify_id} is not being watched."}
)
deleted_count = remove_specific_tracks_from_playlist_table( deleted_count = remove_specific_tracks_from_playlist_table(
playlist_spotify_id, track_ids playlist_spotify_id, track_ids
@@ -428,72 +484,71 @@ def mark_tracks_as_missing_locally(playlist_spotify_id):
logger.info( logger.info(
f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}." f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."
) )
return jsonify( return {
{ "message": f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."
"message": f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}." }
} except HTTPException:
), 200 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}", f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify({"error": f"Could not mark tracks as missing: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not mark tracks as missing: {str(e)}"})
@playlist_bp.route("/watch/list", methods=["GET"]) @router.get("/watch/list")
def list_watched_playlists_endpoint(): async def list_watched_playlists_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Lists all playlists currently in the watchlist.""" """Lists all playlists currently in the watchlist."""
try: try:
playlists = get_watched_playlists() playlists = get_watched_playlists()
return jsonify(playlists), 200 return playlists
except Exception as e: except Exception as e:
logger.error(f"Error listing watched playlists: {e}", exc_info=True) logger.error(f"Error listing watched playlists: {e}", exc_info=True)
return jsonify({"error": f"Could not list watched playlists: {str(e)}"}), 500 raise HTTPException(status_code=500, detail={"error": f"Could not list watched playlists: {str(e)}"})
@playlist_bp.route("/watch/trigger_check", methods=["POST"]) @router.post("/watch/trigger_check")
def trigger_playlist_check_endpoint(): async def trigger_playlist_check_endpoint(current_user: User = Depends(require_auth_from_state)):
"""Manually triggers the playlist checking mechanism for all watched playlists.""" """Manually triggers the playlist checking mechanism for all watched playlists."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot trigger check." "error": "Watch feature is currently disabled globally. Cannot trigger check."
} }
), 403 )
logger.info("Manual trigger for playlist check received for all playlists.") logger.info("Manual trigger for playlist check received for all playlists.")
try: try:
# Run check_watched_playlists without an ID to check all # Run check_watched_playlists without an ID to check all
thread = threading.Thread(target=check_watched_playlists, args=(None,)) thread = threading.Thread(target=check_watched_playlists, args=(None,))
thread.start() thread.start()
return jsonify( return {
{ "message": "Playlist check triggered successfully in the background for all playlists."
"message": "Playlist check triggered successfully in the background for all playlists." }
}
), 202
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error manually triggering playlist check for all: {e}", exc_info=True f"Error manually triggering playlist check for all: {e}", exc_info=True
) )
return jsonify( raise HTTPException(
{"error": f"Could not trigger playlist check for all: {str(e)}"} status_code=500,
), 500 detail={"error": f"Could not trigger playlist check for all: {str(e)}"}
)
@playlist_bp.route( @router.post("/watch/trigger_check/{playlist_spotify_id}")
"/watch/trigger_check/<string:playlist_spotify_id>", methods=["POST"] async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)):
)
def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str):
"""Manually triggers the playlist checking mechanism for a specific playlist.""" """Manually triggers the playlist checking mechanism for a specific playlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
return jsonify( raise HTTPException(
{ status_code=403,
detail={
"error": "Watch feature is currently disabled globally. Cannot trigger check." "error": "Watch feature is currently disabled globally. Cannot trigger check."
} }
), 403 )
logger.info( logger.info(
f"Manual trigger for specific playlist check received for ID: {playlist_spotify_id}" f"Manual trigger for specific playlist check received for ID: {playlist_spotify_id}"
@@ -505,11 +560,12 @@ def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str):
logger.warning( logger.warning(
f"Trigger specific check: Playlist ID {playlist_spotify_id} not found in watchlist." f"Trigger specific check: Playlist ID {playlist_spotify_id} not found in watchlist."
) )
return jsonify( raise HTTPException(
{ status_code=404,
detail={
"error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first." "error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first."
} }
), 404 )
# Run check_watched_playlists with the specific ID # Run check_watched_playlists with the specific ID
thread = threading.Thread( thread = threading.Thread(
@@ -519,18 +575,19 @@ def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str):
logger.info( logger.info(
f"Playlist check triggered in background for specific playlist ID: {playlist_spotify_id}" f"Playlist check triggered in background for specific playlist ID: {playlist_spotify_id}"
) )
return jsonify( return {
{ "message": f"Playlist check triggered successfully in the background for {playlist_spotify_id}."
"message": f"Playlist check triggered successfully in the background for {playlist_spotify_id}." }
} except HTTPException:
), 202 raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error manually triggering specific playlist check for {playlist_spotify_id}: {e}", f"Error manually triggering specific playlist check for {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
return jsonify( raise HTTPException(
{ status_code=500,
detail={
"error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}" "error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}"
} }
), 500 )

166
routes/content/track.py Executable file
View File

@@ -0,0 +1,166 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
import uuid
import time
from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
from routes.utils.get_info import get_spotify_info
from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
router = APIRouter()
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
"""Construct a Spotify URL for a given item ID and type."""
return f"https://open.spotify.com/{item_type}/{item_id}"
@router.get("/download/{track_id}")
async def handle_download(track_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
# Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed
# Construct the URL from track_id
url = construct_spotify_url(track_id, "track")
# Fetch metadata from Spotify
try:
track_info = get_spotify_info(track_id, "track")
if (
not track_info
or not track_info.get("name")
or not track_info.get("artists")
):
return JSONResponse(
content={"error": f"Could not retrieve metadata for track ID: {track_id}"},
status_code=404
)
name_from_spotify = track_info.get("name")
artist_from_spotify = (
track_info["artists"][0].get("name")
if track_info["artists"]
else "Unknown Artist"
)
except Exception as e:
return JSONResponse(
content={"error": f"Failed to fetch metadata for track {track_id}: {str(e)}"},
status_code=500
)
# Validate required parameters
if not url:
return JSONResponse(
content={"error": "Missing required parameter: url"},
status_code=400
)
# Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters
# Include full original request URL in metadata
orig_params = dict(request.query_params)
orig_params["original_url"] = str(request.url)
try:
task_id = download_queue_manager.add_task(
{
"download_type": "track",
"url": url,
"name": name_from_spotify,
"artist": artist_from_spotify,
"orig_request": orig_params,
}
)
except DuplicateDownloadError as e:
return JSONResponse(
content={
"error": "Duplicate download detected.",
"existing_task": e.existing_task,
},
status_code=409
)
except Exception as e:
# Generic error handling for other issues during task submission
# Create an error task ID if add_task itself fails before returning an ID
error_task_id = str(uuid.uuid4())
store_task_info(
error_task_id,
{
"download_type": "track",
"url": url,
"name": name_from_spotify,
"artist": artist_from_spotify,
"original_request": orig_params,
"created_at": time.time(),
"is_submission_error_task": True,
},
)
store_task_status(
error_task_id,
{
"status": ProgressState.ERROR,
"error": f"Failed to queue track download: {str(e)}",
"timestamp": time.time(),
},
)
return JSONResponse(
content={
"error": f"Failed to queue track download: {str(e)}",
"task_id": error_task_id,
},
status_code=500
)
return JSONResponse(
content={"task_id": task_id},
status_code=202
)
@router.get("/download/cancel")
async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Cancel a running download process by its task id.
"""
task_id = request.query_params.get("task_id")
if not task_id:
return JSONResponse(
content={"error": "Missing process id (task_id) parameter"},
status_code=400
)
# Use the queue manager's cancellation method.
result = download_queue_manager.cancel_task(task_id)
status_code = 200 if result.get("status") == "cancelled" else 404
return JSONResponse(content=result, status_code=status_code)
@router.get("/info")
async def get_track_info(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve Spotify track metadata given a Spotify track ID.
Expects a query parameter 'id' that contains the Spotify track ID.
"""
spotify_id = request.query_params.get("id")
if not spotify_id:
return JSONResponse(
content={"error": "Missing parameter: id"},
status_code=400
)
try:
# Use the get_spotify_info function (already imported at top)
track_info = get_spotify_info(spotify_id, "track")
return JSONResponse(content=track_info, status_code=200)
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
return JSONResponse(content=error_data, status_code=500)

0
routes/core/__init__.py Normal file
View File

351
routes/core/history.py Normal file
View File

@@ -0,0 +1,351 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
import logging
from routes.utils.history_manager import history_manager
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/")
async def get_history(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve download history with optional filtering and pagination.
Query parameters:
- limit: Maximum number of records (default: 100, max: 500)
- offset: Number of records to skip (default: 0)
- download_type: Filter by type ('track', 'album', 'playlist')
- status: Filter by status ('completed', 'failed', 'skipped', 'in_progress')
"""
try:
# Parse query parameters
limit = min(int(request.query_params.get("limit", 100)), 500) # Cap at 500
offset = max(int(request.query_params.get("offset", 0)), 0)
download_type = request.query_params.get("download_type")
status = request.query_params.get("status")
# Validate download_type if provided
valid_types = ["track", "album", "playlist"]
if download_type and download_type not in valid_types:
return JSONResponse(
content={"error": f"Invalid download_type. Must be one of: {valid_types}"},
status_code=400
)
# Validate status if provided
valid_statuses = ["completed", "failed", "skipped", "in_progress"]
if status and status not in valid_statuses:
return JSONResponse(
content={"error": f"Invalid status. Must be one of: {valid_statuses}"},
status_code=400
)
# Get history from manager
history = history_manager.get_download_history(
limit=limit,
offset=offset,
download_type=download_type,
status=status
)
# Add pagination info
response_data = {
"downloads": history,
"pagination": {
"limit": limit,
"offset": offset,
"returned_count": len(history)
}
}
if download_type:
response_data["filters"] = {"download_type": download_type}
if status:
if "filters" not in response_data:
response_data["filters"] = {}
response_data["filters"]["status"] = status
return JSONResponse(
content=response_data,
status_code=200
)
except ValueError as e:
return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"},
status_code=400
)
except Exception as e:
logger.error(f"Error retrieving download history: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to retrieve download history", "details": str(e)},
status_code=500
)
@router.get("/{task_id}")
async def get_download_by_task_id(task_id: str, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve specific download history by task ID.
Args:
task_id: Celery task ID
"""
try:
download = history_manager.get_download_by_task_id(task_id)
if not download:
return JSONResponse(
content={"error": f"Download with task ID '{task_id}' not found"},
status_code=404
)
return JSONResponse(
content=download,
status_code=200
)
except Exception as e:
logger.error(f"Error retrieving download for task {task_id}: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to retrieve download", "details": str(e)},
status_code=500
)
@router.get("/{task_id}/children")
async def get_download_children(task_id: str, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve children tracks for an album or playlist download.
Args:
task_id: Celery task ID
"""
try:
# First get the main download to find the children table
download = history_manager.get_download_by_task_id(task_id)
if not download:
return JSONResponse(
content={"error": f"Download with task ID '{task_id}' not found"},
status_code=404
)
children_table = download.get("children_table")
if not children_table:
return JSONResponse(
content={"error": f"Download '{task_id}' has no children tracks"},
status_code=404
)
# Get children tracks
children = history_manager.get_children_history(children_table)
response_data = {
"task_id": task_id,
"download_type": download.get("download_type"),
"title": download.get("title"),
"children_table": children_table,
"tracks": children,
"track_count": len(children)
}
return JSONResponse(
content=response_data,
status_code=200
)
except Exception as e:
logger.error(f"Error retrieving children for task {task_id}: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to retrieve download children", "details": str(e)},
status_code=500
)
@router.get("/stats")
async def get_download_stats(current_user: User = Depends(require_auth_from_state)):
"""
Get download statistics and summary information.
"""
try:
stats = history_manager.get_download_stats()
return JSONResponse(
content=stats,
status_code=200
)
except Exception as e:
logger.error(f"Error retrieving download stats: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to retrieve download statistics", "details": str(e)},
status_code=500
)
@router.get("/search")
async def search_history(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Search download history by title or artist.
Query parameters:
- q: Search query (required)
- limit: Maximum number of results (default: 50, max: 200)
"""
try:
query = request.query_params.get("q")
if not query:
return JSONResponse(
content={"error": "Missing required parameter: q (search query)"},
status_code=400
)
limit = min(int(request.query_params.get("limit", 50)), 200) # Cap at 200
# Search history
results = history_manager.search_history(query, limit)
response_data = {
"query": query,
"results": results,
"result_count": len(results),
"limit": limit
}
return JSONResponse(
content=response_data,
status_code=200
)
except ValueError as e:
return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"},
status_code=400
)
except Exception as e:
logger.error(f"Error searching download history: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to search download history", "details": str(e)},
status_code=500
)
@router.get("/recent")
async def get_recent_downloads(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Get most recent downloads.
Query parameters:
- limit: Maximum number of results (default: 20, max: 100)
"""
try:
limit = min(int(request.query_params.get("limit", 20)), 100) # Cap at 100
recent = history_manager.get_recent_downloads(limit)
response_data = {
"downloads": recent,
"count": len(recent),
"limit": limit
}
return JSONResponse(
content=response_data,
status_code=200
)
except ValueError as e:
return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"},
status_code=400
)
except Exception as e:
logger.error(f"Error retrieving recent downloads: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to retrieve recent downloads", "details": str(e)},
status_code=500
)
@router.get("/failed")
async def get_failed_downloads(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Get failed downloads.
Query parameters:
- limit: Maximum number of results (default: 50, max: 200)
"""
try:
limit = min(int(request.query_params.get("limit", 50)), 200) # Cap at 200
failed = history_manager.get_failed_downloads(limit)
response_data = {
"downloads": failed,
"count": len(failed),
"limit": limit
}
return JSONResponse(
content=response_data,
status_code=200
)
except ValueError as e:
return JSONResponse(
content={"error": f"Invalid parameter value: {str(e)}"},
status_code=400
)
except Exception as e:
logger.error(f"Error retrieving failed downloads: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to retrieve failed downloads", "details": str(e)},
status_code=500
)
@router.post("/cleanup")
async def cleanup_old_history(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Clean up old download history.
JSON body:
- days_old: Number of days old to keep (default: 30)
"""
try:
data = await request.json() if request.headers.get("content-type") == "application/json" else {}
days_old = data.get("days_old", 30)
if not isinstance(days_old, int) or days_old <= 0:
return JSONResponse(
content={"error": "days_old must be a positive integer"},
status_code=400
)
deleted_count = history_manager.clear_old_history(days_old)
response_data = {
"message": f"Successfully cleaned up old download history",
"deleted_records": deleted_count,
"days_old": days_old
}
return JSONResponse(
content=response_data,
status_code=200
)
except Exception as e:
logger.error(f"Error cleaning up old history: {e}", exc_info=True)
return JSONResponse(
content={"error": "Failed to cleanup old history", "details": str(e)},
status_code=500
)

69
routes/core/search.py Executable file
View File

@@ -0,0 +1,69 @@
from fastapi import APIRouter, HTTPException, Request, Depends
import json
import traceback
import logging
from routes.utils.search import search
# Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/")
@router.get("")
async def handle_search(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Handle search requests for tracks, albums, playlists, or artists.
Frontend compatible endpoint that returns results in { items: [] } format.
"""
query = request.query_params.get("q")
# Frontend sends 'search_type', so check both 'search_type' and 'type'
search_type = request.query_params.get("search_type") or request.query_params.get("type", "track")
limit = request.query_params.get("limit", "20")
main = request.query_params.get("main") # Account context
if not query:
raise HTTPException(status_code=400, detail={"error": "Missing parameter: q"})
try:
limit = int(limit)
except ValueError:
raise HTTPException(status_code=400, detail={"error": "limit must be an integer"})
try:
# Use the single search_type (not multiple types like before)
result = search(
query=query,
search_type=search_type,
limit=limit,
main=main
)
# Extract items from the Spotify API response based on search type
# Spotify API returns results in format like { "tracks": { "items": [...] } }
items = []
# Map search types to their plural forms in Spotify response
type_mapping = {
"track": "tracks",
"album": "albums",
"artist": "artists",
"playlist": "playlists",
"episode": "episodes",
"show": "shows"
}
response_key = type_mapping.get(search_type.lower(), "tracks")
if result and response_key in result:
items = result[response_key].get("items", [])
# Return in the format expected by frontend: { items: [] }
return {"items": items}
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
logger.error(f"Error in search: {error_data}")
raise HTTPException(status_code=500, detail=error_data)

View File

@@ -1,241 +0,0 @@
from flask import Blueprint, request, jsonify
from routes.utils.credentials import (
get_credential,
list_credentials,
create_credential,
delete_credential,
edit_credential,
init_credentials_db,
# Import new utility functions for global Spotify API creds
_get_global_spotify_api_creds,
save_global_spotify_api_creds,
)
import logging
logger = logging.getLogger(__name__)
credentials_bp = Blueprint("credentials", __name__)
# Initialize the database and tables when the blueprint is loaded
init_credentials_db()
@credentials_bp.route("/spotify_api_config", methods=["GET", "PUT"])
def handle_spotify_api_config():
"""Handles GET and PUT requests for the global Spotify API client_id and client_secret."""
try:
if request.method == "GET":
client_id, client_secret = _get_global_spotify_api_creds()
if client_id is not None and client_secret is not None:
return jsonify(
{"client_id": client_id, "client_secret": client_secret}
), 200
else:
# If search.json exists but is empty/incomplete, or doesn't exist
return jsonify(
{
"warning": "Global Spotify API credentials are not fully configured or file is missing.",
"client_id": client_id or "",
"client_secret": client_secret or "",
}
), 200
elif request.method == "PUT":
data = request.get_json()
if not data or "client_id" not in data or "client_secret" not in data:
return jsonify(
{
"error": "Request body must contain 'client_id' and 'client_secret'"
}
), 400
client_id = data["client_id"]
client_secret = data["client_secret"]
if not isinstance(client_id, str) or not isinstance(client_secret, str):
return jsonify(
{"error": "'client_id' and 'client_secret' must be strings"}
), 400
if save_global_spotify_api_creds(client_id, client_secret):
return jsonify(
{"message": "Global Spotify API credentials updated successfully."}
), 200
else:
return jsonify(
{"error": "Failed to save global Spotify API credentials."}
), 500
except Exception as e:
logger.error(f"Error in /spotify_api_config: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route("/<service>", methods=["GET"])
def handle_list_credentials(service):
try:
if service not in ["spotify", "deezer"]:
return jsonify(
{"error": "Invalid service. Must be 'spotify' or 'deezer'"}
), 400
return jsonify(list_credentials(service))
except ValueError as e: # Should not happen with service check above
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error listing credentials for {service}: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route("/<service>/<name>", methods=["GET", "POST", "PUT", "DELETE"])
def handle_single_credential(service, name):
try:
if service not in ["spotify", "deezer"]:
return jsonify(
{"error": "Invalid service. Must be 'spotify' or 'deezer'"}
), 400
# cred_type logic is removed for Spotify as API keys are global.
# For Deezer, it's always 'credentials' type implicitly.
if request.method == "GET":
# get_credential for Spotify now only returns region and blob_file_path
return jsonify(get_credential(service, name))
elif request.method == "POST":
data = request.get_json()
if not data:
return jsonify({"error": "Request body cannot be empty."}), 400
# create_credential for Spotify now expects 'region' and 'blob_content'
# For Deezer, it expects 'arl' and 'region'
# Validation is handled within create_credential utility function
result = create_credential(service, name, data)
return jsonify(
{
"message": f"Credential for '{name}' ({service}) created successfully.",
"details": result,
}
), 201
elif request.method == "PUT":
data = request.get_json()
if not data:
return jsonify({"error": "Request body cannot be empty."}), 400
# edit_credential for Spotify now handles updates to 'region', 'blob_content'
# For Deezer, 'arl', 'region'
result = edit_credential(service, name, data)
return jsonify(
{
"message": f"Credential for '{name}' ({service}) updated successfully.",
"details": result,
}
)
elif request.method == "DELETE":
# delete_credential for Spotify also handles deleting the blob directory
result = delete_credential(service, name)
return jsonify(
{
"message": f"Credential for '{name}' ({service}) deleted successfully.",
"details": result,
}
)
except (ValueError, FileNotFoundError, FileExistsError) as e:
status_code = 400
if isinstance(e, FileNotFoundError):
status_code = 404
elif isinstance(e, FileExistsError):
status_code = 409
logger.warning(f"Client error in /<{service}>/<{name}>: {str(e)}")
return jsonify({"error": str(e)}), status_code
except Exception as e:
logger.error(f"Server error in /<{service}>/<{name}>: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
# The '/search/<service>/<name>' route is now obsolete for Spotify and has been removed.
@credentials_bp.route("/all/<service>", methods=["GET"])
def handle_all_credentials(service):
"""Lists all credentials for a given service. For Spotify, API keys are global and not listed per account."""
try:
if service not in ["spotify", "deezer"]:
return jsonify(
{"error": "Invalid service. Must be 'spotify' or 'deezer'"}
), 400
credentials_list = []
account_names = list_credentials(service) # This lists names from DB
for name in account_names:
try:
# get_credential for Spotify returns region and blob_file_path.
# For Deezer, it returns arl and region.
account_data = get_credential(service, name)
# We don't add global Spotify API keys here as they are separate
credentials_list.append({"name": name, "details": account_data})
except FileNotFoundError:
logger.warning(
f"Credential name '{name}' listed for service '{service}' but not found by get_credential. Skipping."
)
except Exception as e_inner:
logger.error(
f"Error fetching details for credential '{name}' ({service}): {e_inner}",
exc_info=True,
)
credentials_list.append(
{
"name": name,
"error": f"Could not retrieve details: {str(e_inner)}",
}
)
return jsonify(credentials_list)
except Exception as e:
logger.error(f"Error in /all/{service}: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
@credentials_bp.route("/markets", methods=["GET"])
def handle_markets():
"""
Returns a list of unique market regions for Deezer and Spotify accounts.
"""
try:
deezer_regions = set()
spotify_regions = set()
# Process Deezer accounts
deezer_account_names = list_credentials("deezer")
for name in deezer_account_names:
try:
account_data = get_credential("deezer", name)
if account_data and "region" in account_data and account_data["region"]:
deezer_regions.add(account_data["region"])
except Exception as e:
logger.warning(
f"Could not retrieve region for deezer account {name}: {e}"
)
# Process Spotify accounts
spotify_account_names = list_credentials("spotify")
for name in spotify_account_names:
try:
account_data = get_credential("spotify", name)
if account_data and "region" in account_data and account_data["region"]:
spotify_regions.add(account_data["region"])
except Exception as e:
logger.warning(
f"Could not retrieve region for spotify account {name}: {e}"
)
return jsonify(
{
"deezer": sorted(list(deezer_regions)),
"spotify": sorted(list(spotify_regions)),
}
), 200
except Exception as e:
logger.error(f"Error in /markets: {e}", exc_info=True)
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500

View File

@@ -1,96 +0,0 @@
from flask import Blueprint, jsonify, request
from routes.utils.history_manager import get_history_entries
import logging
logger = logging.getLogger(__name__)
history_bp = Blueprint("history", __name__, url_prefix="/api/history")
@history_bp.route("", methods=["GET"])
def get_download_history():
"""API endpoint to retrieve download history with pagination, sorting, and filtering."""
try:
limit = request.args.get("limit", 25, type=int)
offset = request.args.get("offset", 0, type=int)
sort_by = request.args.get("sort_by", "timestamp_completed")
sort_order = request.args.get("sort_order", "DESC")
# Create filters dictionary for various filter options
filters = {}
# Status filter
status_filter = request.args.get("status_final")
if status_filter:
filters["status_final"] = status_filter
# Download type filter
type_filter = request.args.get("download_type")
if type_filter:
filters["download_type"] = type_filter
# Parent task filter
parent_task_filter = request.args.get("parent_task_id")
if parent_task_filter:
filters["parent_task_id"] = parent_task_filter
# Track status filter
track_status_filter = request.args.get("track_status")
if track_status_filter:
filters["track_status"] = track_status_filter
# Show/hide child tracks
hide_child_tracks = request.args.get("hide_child_tracks", "false").lower() == "true"
if hide_child_tracks:
filters["parent_task_id"] = None # Only show parent entries or standalone tracks
# Show only tracks with specific parent
only_parent_tracks = request.args.get("only_parent_tracks", "false").lower() == "true"
if only_parent_tracks and not parent_task_filter:
filters["parent_task_id"] = "NOT_NULL" # Special value to indicate we want only child tracks
entries, total_count = get_history_entries(
limit, offset, sort_by, sort_order, filters
)
return jsonify(
{
"entries": entries,
"total_count": total_count,
"limit": limit,
"offset": offset,
}
)
except Exception as e:
logger.error(f"Error in /api/history endpoint: {e}", exc_info=True)
return jsonify({"error": "Failed to retrieve download history"}), 500
@history_bp.route("/tracks/<parent_task_id>", methods=["GET"])
def get_tracks_for_parent(parent_task_id):
"""API endpoint to retrieve all track entries for a specific parent task."""
try:
# We don't need pagination for this endpoint as we want all tracks for a parent
filters = {"parent_task_id": parent_task_id}
# Optional sorting
sort_by = request.args.get("sort_by", "timestamp_completed")
sort_order = request.args.get("sort_order", "DESC")
entries, total_count = get_history_entries(
limit=1000, # High limit to get all tracks
offset=0,
sort_by=sort_by,
sort_order=sort_order,
filters=filters
)
return jsonify(
{
"parent_task_id": parent_task_id,
"tracks": entries,
"total_count": total_count,
}
)
except Exception as e:
logger.error(f"Error in /api/history/tracks endpoint: {e}", exc_info=True)
return jsonify({"error": f"Failed to retrieve tracks for parent task {parent_task_id}"}), 500

View File

@@ -1,342 +0,0 @@
from flask import Blueprint, abort, jsonify, request
import logging
import time
from routes.utils.celery_tasks import (
get_task_info,
get_task_status,
get_last_task_status,
get_all_tasks,
cancel_task,
retry_task,
redis_client,
delete_task_data,
)
# Configure logging
logger = logging.getLogger(__name__)
prgs_bp = Blueprint("prgs", __name__, url_prefix="/api/prgs")
# (Old .prg file system removed. Using new task system only.)
def _build_error_callback_object(last_status):
"""
Constructs a structured error callback object based on the last status of a task.
This conforms to the CallbackObject types in the frontend.
"""
# The 'type' from the status update corresponds to the download_type (album, playlist, track)
download_type = last_status.get("type")
name = last_status.get("name")
# The 'artist' field from the status may contain artist names or a playlist owner's name
artist_or_owner = last_status.get("artist")
error_message = last_status.get("error", "An unknown error occurred.")
status_info = {"status": "error", "error": error_message}
callback_object = {"status_info": status_info}
if download_type == "album":
callback_object["album"] = {
"type": "album",
"title": name,
"artists": [{
"type": "artistAlbum",
"name": artist_or_owner
}] if artist_or_owner else [],
}
elif download_type == "playlist":
playlist_payload = {"type": "playlist", "title": name}
if artist_or_owner:
playlist_payload["owner"] = {"type": "user", "name": artist_or_owner}
callback_object["playlist"] = playlist_payload
elif download_type == "track":
callback_object["track"] = {
"type": "track",
"title": name,
"artists": [{
"type": "artistTrack",
"name": artist_or_owner
}] if artist_or_owner else [],
}
else:
# Fallback for unknown types to avoid breaking the client, returning a basic error structure.
return {
"status_info": status_info,
"unstructured_error": True,
"details": {
"type": download_type,
"name": name,
"artist_or_owner": artist_or_owner,
},
}
return callback_object
@prgs_bp.route("/<task_id>", methods=["GET"])
def get_task_details(task_id):
"""
Return a JSON object with the resource type, its name (title),
the last progress update, and, if available, the original request parameters.
This function works with the new task ID based system.
Args:
task_id: A task UUID from Celery
"""
# Only support new task IDs
task_info = get_task_info(task_id)
if not task_info:
abort(404, "Task not found")
# Dynamically construct original_url
dynamic_original_url = ""
download_type = task_info.get("download_type")
# The 'url' field in task_info stores the Spotify/Deezer URL of the item
# e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId
item_url = task_info.get("url")
if download_type and item_url:
try:
# Extract the ID from the item_url (last part of the path)
item_id = item_url.split("/")[-1]
if item_id: # Ensure item_id is not empty
base_url = request.host_url.rstrip("/")
dynamic_original_url = (
f"{base_url}/api/{download_type}/download/{item_id}"
)
else:
logger.warning(
f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url."
)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get("original_url", "")
except Exception as e:
logger.error(
f"Error constructing dynamic original_url for task {task_id}: {e}",
exc_info=True,
)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get(
"original_url", ""
) # Fallback on any error
else:
logger.warning(
f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url."
)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get("original_url", "")
last_status = get_last_task_status(task_id)
status_count = len(get_task_status(task_id))
# Determine last_line content
if last_status and "raw_callback" in last_status:
last_line_content = last_status["raw_callback"]
elif last_status and last_status.get("status") == "error":
last_line_content = _build_error_callback_object(last_status)
else:
# Fallback for non-error, no raw_callback, or if last_status is None
last_line_content = last_status
response = {
"original_url": dynamic_original_url,
"last_line": last_line_content,
"timestamp": last_status.get("timestamp") if last_status else time.time(),
"task_id": task_id,
"status_count": status_count,
"created_at": task_info.get("created_at"),
"name": task_info.get("name"),
"artist": task_info.get("artist"),
"type": task_info.get("type"),
"download_type": task_info.get("download_type"),
}
if last_status and last_status.get("summary"):
response["summary"] = last_status["summary"]
return jsonify(response)
@prgs_bp.route("/delete/<task_id>", methods=["DELETE"])
def delete_task(task_id):
"""
Delete a task's information and history.
Args:
task_id: A task UUID from Celery
"""
# Only support new task IDs
task_info = get_task_info(task_id)
if not task_info:
abort(404, "Task not found")
# First, cancel the task if it's running
cancel_task(task_id)
# Then, delete all associated data from Redis
delete_task_data(task_id)
return {"message": f"Task {task_id} deleted successfully"}, 200
@prgs_bp.route("/list", methods=["GET"])
def list_tasks():
"""
Retrieve a list of all tasks in the system.
Returns a detailed list of task objects including status and metadata.
By default, it returns active tasks. Use ?include_finished=true to include completed tasks.
"""
try:
# Check for 'include_finished' query parameter
include_finished_str = request.args.get("include_finished", "false")
include_finished = include_finished_str.lower() in ["true", "1", "yes"]
tasks = get_all_tasks(include_finished=include_finished)
detailed_tasks = []
for task_summary in tasks:
task_id = task_summary.get("task_id")
if not task_id:
continue
task_info = get_task_info(task_id)
if not task_info:
continue
# Dynamically construct original_url
dynamic_original_url = ""
download_type = task_info.get("download_type")
# The 'url' field in task_info stores the Spotify/Deezer URL of the item
# e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId
item_url = task_info.get("url")
if download_type and item_url:
try:
# Extract the ID from the item_url (last part of the path)
item_id = item_url.split("/")[-1]
if item_id: # Ensure item_id is not empty
base_url = request.host_url.rstrip("/")
dynamic_original_url = (
f"{base_url}/api/{download_type}/download/{item_id}"
)
else:
logger.warning(
f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url."
)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get(
"original_url", ""
)
except Exception as e:
logger.error(
f"Error constructing dynamic original_url for task {task_id}: {e}",
exc_info=True,
)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get(
"original_url", ""
) # Fallback on any error
else:
logger.warning(
f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url."
)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get("original_url", "")
last_status = get_last_task_status(task_id)
status_count = len(get_task_status(task_id))
# Determine last_line content
if last_status and "raw_callback" in last_status:
last_line_content = last_status["raw_callback"]
elif last_status and last_status.get("status") == "error":
last_line_content = _build_error_callback_object(last_status)
else:
# Fallback for non-error, no raw_callback, or if last_status is None
last_line_content = last_status
response = {
"original_url": dynamic_original_url,
"last_line": last_line_content,
"timestamp": last_status.get("timestamp") if last_status else time.time(),
"task_id": task_id,
"status_count": status_count,
"created_at": task_info.get("created_at"),
"name": task_info.get("name"),
"artist": task_info.get("artist"),
"type": task_info.get("type"),
"download_type": task_info.get("download_type"),
}
if last_status and last_status.get("summary"):
response["summary"] = last_status["summary"]
detailed_tasks.append(response)
# Sort tasks by creation time (newest first)
detailed_tasks.sort(key=lambda x: x.get("created_at", 0), reverse=True)
return jsonify(detailed_tasks)
except Exception as e:
logger.error(f"Error in /api/prgs/list: {e}", exc_info=True)
return jsonify({"error": "Failed to retrieve task list"}), 500
@prgs_bp.route("/cancel/<task_id>", methods=["POST"])
def cancel_task_endpoint(task_id):
"""
Cancel a running or queued task.
Args:
task_id: The ID of the task to cancel
"""
try:
# First check if this is a task ID in the new system
task_info = get_task_info(task_id)
if task_info:
# This is a task ID in the new system
result = cancel_task(task_id)
return jsonify(result)
# If not found in new system, we need to handle the old system cancellation
# For now, return an error as we're transitioning to the new system
return jsonify(
{
"status": "error",
"message": "Cancellation for old system is not supported in the new API. Please use the new task ID format.",
}
), 400
except Exception as e:
abort(500, f"An error occurred: {e}")
@prgs_bp.route("/cancel/all", methods=["POST"])
def cancel_all_tasks():
"""
Cancel all active (running or queued) tasks.
"""
try:
tasks_to_cancel = get_all_tasks(include_finished=False)
cancelled_count = 0
errors = []
for task_summary in tasks_to_cancel:
task_id = task_summary.get("task_id")
if not task_id:
continue
try:
cancel_task(task_id)
cancelled_count += 1
except Exception as e:
error_message = f"Failed to cancel task {task_id}: {e}"
logger.error(error_message)
errors.append(error_message)
response = {
"message": f"Attempted to cancel all active tasks. {cancelled_count} tasks cancelled.",
"cancelled_count": cancelled_count,
"errors": errors,
}
return jsonify(response), 200
except Exception as e:
logger.error(f"Error in /api/prgs/cancel/all: {e}", exc_info=True)
return jsonify({"error": "Failed to cancel all tasks"}), 500

View File

@@ -1,71 +0,0 @@
from flask import Blueprint, jsonify, request
from routes.utils.search import search # Corrected import
from routes.config import get_config # Import get_config function
search_bp = Blueprint("search", __name__)
@search_bp.route("/search", methods=["GET"])
def handle_search():
try:
# Get query parameters
query = request.args.get("q", "")
search_type = request.args.get("search_type", "")
limit = int(request.args.get("limit", 10))
main = request.args.get(
"main", ""
) # Get the main parameter for account selection
# If main parameter is not provided in the request, get it from config
if not main:
config = get_config()
if config and "spotify" in config:
main = config["spotify"]
print(f"Using main from config: {main}")
# Validate parameters
if not query:
return jsonify({"error": "Missing search query"}), 400
valid_types = ["track", "album", "artist", "playlist", "episode"]
if search_type not in valid_types:
return jsonify({"error": "Invalid search type"}), 400
# Perform the search with corrected parameter name
raw_results = search(
query=query,
search_type=search_type, # Fixed parameter name
limit=limit,
main=main, # Pass the main parameter
)
# Extract items from the appropriate section of the response based on search_type
items = []
if raw_results and search_type + "s" in raw_results:
type_key = search_type + "s"
items = raw_results[type_key].get("items", [])
elif raw_results and search_type in raw_results:
items = raw_results[search_type].get("items", [])
# Filter out any null items from the results
if items:
items = [item for item in items if item is not None]
# Return both the items array and the full data for debugging
return jsonify(
{
"items": items,
"data": raw_results, # Include full data for debugging
"error": None,
}
)
except ValueError as e:
print(f"ValueError in search: {str(e)}")
return jsonify({"error": str(e)}), 400
except Exception as e:
import traceback
print(f"Exception in search: {str(e)}")
print(traceback.format_exc())
return jsonify({"error": f"Internal server error: {str(e)}"}), 500

View File

394
routes/system/config.py Normal file
View File

@@ -0,0 +1,394 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
import json
import logging
import os
from typing import Any, Optional, List
from pathlib import Path
from pydantic import BaseModel
# Import the centralized config getters that handle file creation and defaults
from routes.utils.celery_config import (
get_config_params as get_main_config_params,
DEFAULT_MAIN_CONFIG,
CONFIG_FILE_PATH as MAIN_CONFIG_FILE_PATH,
)
from routes.utils.watch.manager import (
get_watch_config as get_watch_manager_config,
DEFAULT_WATCH_CONFIG,
CONFIG_FILE_PATH as WATCH_CONFIG_FILE_PATH,
)
# Import authentication dependencies
from routes.auth.middleware import require_admin_from_state, User
from routes.auth import AUTH_ENABLED, DISABLE_REGISTRATION
logger = logging.getLogger(__name__)
router = APIRouter()
# Flag for config change notifications
config_changed = False
last_config: dict[str, Any] = {}
# Define parameters that should trigger notification when changed
NOTIFY_PARAMETERS = [
"maxConcurrentDownloads",
"service",
"fallback",
"spotifyQuality",
"deezerQuality",
]
# Helper function to check if credentials exist for a service
def has_credentials(service: str) -> bool:
"""Check if credentials exist for the specified service (spotify or deezer)."""
try:
credentials_path = Path(f"./data/credentials/{service}")
if not credentials_path.exists():
return False
# Check if there are any credential files in the directory
credential_files = list(credentials_path.glob("*.json"))
return len(credential_files) > 0
except Exception as e:
logger.warning(f"Error checking credentials for {service}: {e}")
return False
# Validation function for configuration consistency
def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool, str]:
"""
Validate configuration for consistency and requirements.
Returns (is_valid, error_message).
"""
try:
# Get current watch config if not provided
if watch_config is None:
watch_config = get_watch_config_http()
# Check if fallback is enabled but missing required accounts
if config_data.get("fallback", False):
has_spotify = has_credentials("spotify")
has_deezer = has_credentials("deezer")
if not has_spotify or not has_deezer:
missing_services = []
if not has_spotify:
missing_services.append("Spotify")
if not has_deezer:
missing_services.append("Deezer")
return False, f"Download Fallback requires accounts to be configured for both services. Missing: {', '.join(missing_services)}. Configure accounts before enabling fallback."
# Check if watch is enabled but no download methods are available
if watch_config.get("enabled", False):
real_time = config_data.get("realTime", False)
fallback = config_data.get("fallback", False)
if not real_time and not fallback:
return False, "Watch functionality requires either Real-time downloading or Download Fallback to be enabled."
return True, ""
except Exception as e:
logger.error(f"Error validating configuration: {e}", exc_info=True)
return False, f"Configuration validation error: {str(e)}"
def validate_watch_config(watch_data: dict, main_config: dict = None) -> tuple[bool, str]:
"""
Validate watch configuration for consistency and requirements.
Returns (is_valid, error_message).
"""
try:
# Get current main config if not provided
if main_config is None:
main_config = get_config()
# Check if trying to enable watch without download methods
if watch_data.get("enabled", False):
real_time = main_config.get("realTime", False)
fallback = main_config.get("fallback", False)
if not real_time and not fallback:
return False, "Cannot enable watch: either Real-time downloading or Download Fallback must be enabled in download settings."
# If fallback is enabled, check for required accounts
if fallback:
has_spotify = has_credentials("spotify")
has_deezer = has_credentials("deezer")
if not has_spotify or not has_deezer:
missing_services = []
if not has_spotify:
missing_services.append("Spotify")
if not has_deezer:
missing_services.append("Deezer")
return False, f"Cannot enable watch with fallback: missing accounts for {', '.join(missing_services)}. Configure accounts before enabling watch."
return True, ""
except Exception as e:
logger.error(f"Error validating watch configuration: {e}", exc_info=True)
return False, f"Watch configuration validation error: {str(e)}"
# Helper to get main config (uses the one from celery_config)
def get_config():
"""Retrieves the main configuration, creating it with defaults if necessary."""
return get_main_config_params()
# Helper to save main config
def save_config(config_data):
"""Saves the main configuration data to main.json."""
try:
MAIN_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Load current or default config
existing_config = {}
if MAIN_CONFIG_FILE_PATH.exists():
with open(MAIN_CONFIG_FILE_PATH, "r") as f_read:
existing_config = json.load(f_read)
else: # Should be rare if get_config_params was called
existing_config = DEFAULT_MAIN_CONFIG.copy()
# Update with new data
for key, value in config_data.items():
existing_config[key] = value
# Ensure all default keys are still there
for default_key, default_value in DEFAULT_MAIN_CONFIG.items():
if default_key not in existing_config:
existing_config[default_key] = default_value
with open(MAIN_CONFIG_FILE_PATH, "w") as f:
json.dump(existing_config, f, indent=4)
logger.info(f"Main configuration saved to {MAIN_CONFIG_FILE_PATH}")
return True, None
except Exception as e:
logger.error(f"Error saving main configuration: {e}", exc_info=True)
return False, str(e)
# Helper to get watch config (uses the one from watch/manager.py)
def get_watch_config_http(): # Renamed to avoid conflict with the imported get_watch_config
"""Retrieves the watch configuration, creating it with defaults if necessary."""
return get_watch_manager_config()
# Helper to save watch config
def save_watch_config_http(watch_config_data): # Renamed
"""Saves the watch configuration data to watch.json."""
try:
WATCH_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Similar logic to save_config: merge with defaults/existing
existing_config = {}
if WATCH_CONFIG_FILE_PATH.exists():
with open(WATCH_CONFIG_FILE_PATH, "r") as f_read:
existing_config = json.load(f_read)
else: # Should be rare if get_watch_manager_config was called
existing_config = DEFAULT_WATCH_CONFIG.copy()
for key, value in watch_config_data.items():
existing_config[key] = value
for default_key, default_value in DEFAULT_WATCH_CONFIG.items():
if default_key not in existing_config:
existing_config[default_key] = default_value
with open(WATCH_CONFIG_FILE_PATH, "w") as f:
json.dump(existing_config, f, indent=4)
logger.info(f"Watch configuration saved to {WATCH_CONFIG_FILE_PATH}")
return True, None
except Exception as e:
logger.error(f"Error saving watch configuration: {e}", exc_info=True)
return False, str(e)
@router.get("/")
@router.get("")
async def handle_config(current_user: User = Depends(require_admin_from_state)):
"""Handles GET requests for the main configuration."""
try:
config = get_config()
return config
except Exception as e:
logger.error(f"Error in GET /config: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to retrieve configuration", "details": str(e)}
)
@router.post("/")
@router.put("/")
async def update_config(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Handles POST/PUT requests to update the main configuration."""
try:
new_config = await request.json()
if not isinstance(new_config, dict):
raise HTTPException(status_code=400, detail={"error": "Invalid config format"})
# Preserve the explicitFilter setting from environment
explicit_filter_env = os.environ.get("EXPLICIT_FILTER", "false").lower()
new_config["explicitFilter"] = explicit_filter_env in ("true", "1", "yes", "on")
# Validate configuration before saving
is_valid, error_message = validate_config(new_config)
if not is_valid:
raise HTTPException(
status_code=400,
detail={"error": "Configuration validation failed", "details": error_message}
)
success, error_msg = save_config(new_config)
if success:
# Return the updated config
updated_config_values = get_config()
if updated_config_values is None:
# This case should ideally not be reached if save_config succeeded
# and get_config handles errors by returning a default or None.
raise HTTPException(
status_code=500,
detail={"error": "Failed to retrieve configuration after saving"}
)
return updated_config_values
else:
raise HTTPException(
status_code=500,
detail={"error": "Failed to update configuration", "details": error_msg}
)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in POST/PUT /config: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to update configuration", "details": str(e)}
)
@router.get("/check")
async def check_config_changes(current_user: User = Depends(require_admin_from_state)):
# This endpoint seems more related to dynamically checking if config changed
# on disk, which might not be necessary if settings are applied on restart
# or by a dedicated manager. For now, just return current config.
try:
config = get_config()
return {"message": "Current configuration retrieved.", "config": config}
except Exception as e:
logger.error(f"Error in GET /config/check: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to check configuration", "details": str(e)}
)
@router.post("/validate")
async def validate_config_endpoint(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Validate configuration without saving it."""
try:
config_data = await request.json()
if not isinstance(config_data, dict):
raise HTTPException(status_code=400, detail={"error": "Invalid config format"})
is_valid, error_message = validate_config(config_data)
return {
"valid": is_valid,
"message": "Configuration is valid" if is_valid else error_message,
"details": error_message if not is_valid else None
}
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"})
except Exception as e:
logger.error(f"Error in POST /config/validate: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to validate configuration", "details": str(e)}
)
@router.post("/watch/validate")
async def validate_watch_config_endpoint(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Validate watch configuration without saving it."""
try:
watch_data = await request.json()
if not isinstance(watch_data, dict):
raise HTTPException(status_code=400, detail={"error": "Invalid watch config format"})
is_valid, error_message = validate_watch_config(watch_data)
return {
"valid": is_valid,
"message": "Watch configuration is valid" if is_valid else error_message,
"details": error_message if not is_valid else None
}
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"})
except Exception as e:
logger.error(f"Error in POST /config/watch/validate: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to validate watch configuration", "details": str(e)}
)
@router.get("/watch")
async def handle_watch_config(current_user: User = Depends(require_admin_from_state)):
"""Handles GET requests for the watch configuration."""
try:
watch_config = get_watch_config_http()
return watch_config
except Exception as e:
logger.error(f"Error in GET /config/watch: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to retrieve watch configuration", "details": str(e)}
)
@router.post("/watch")
@router.put("/watch")
async def update_watch_config(request: Request, current_user: User = Depends(require_admin_from_state)):
"""Handles POST/PUT requests to update the watch configuration."""
try:
new_watch_config = await request.json()
if not isinstance(new_watch_config, dict):
raise HTTPException(status_code=400, detail={"error": "Invalid watch config format"})
# Validate watch configuration before saving
is_valid, error_message = validate_watch_config(new_watch_config)
if not is_valid:
raise HTTPException(
status_code=400,
detail={"error": "Watch configuration validation failed", "details": error_message}
)
success, error_msg = save_watch_config_http(new_watch_config)
if success:
return {"message": "Watch configuration updated successfully"}
else:
raise HTTPException(
status_code=500,
detail={"error": "Failed to update watch configuration", "details": error_msg}
)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data for watch config"})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in POST/PUT /config/watch: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={"error": "Failed to update watch configuration", "details": str(e)}
)

1213
routes/system/progress.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -1,196 +0,0 @@
from flask import Blueprint, Response, request
import json
import traceback
import uuid # For generating error task IDs
import time # For timestamps
from routes.utils.celery_queue_manager import (
download_queue_manager,
get_existing_task_id,
)
from routes.utils.celery_tasks import (
store_task_info,
store_task_status,
ProgressState,
) # For error task creation
from urllib.parse import urlparse # for URL validation
from routes.utils.get_info import get_spotify_info # Added import
track_bp = Blueprint("track", __name__)
@track_bp.route("/download/<track_id>", methods=["GET"])
def handle_download(track_id):
# Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed
orig_params = request.args.to_dict()
# Construct the URL from track_id
url = f"https://open.spotify.com/track/{track_id}"
orig_params["original_url"] = url # Update original_url to the constructed one
# Fetch metadata from Spotify
try:
track_info = get_spotify_info(track_id, "track")
if (
not track_info
or not track_info.get("name")
or not track_info.get("artists")
):
return Response(
json.dumps(
{"error": f"Could not retrieve metadata for track ID: {track_id}"}
),
status=404,
mimetype="application/json",
)
name_from_spotify = track_info.get("name")
artist_from_spotify = (
track_info["artists"][0].get("name")
if track_info["artists"]
else "Unknown Artist"
)
except Exception as e:
return Response(
json.dumps(
{"error": f"Failed to fetch metadata for track {track_id}: {str(e)}"}
),
status=500,
mimetype="application/json",
)
# Validate required parameters
if not url:
return Response(
json.dumps(
{"error": "Missing required parameter: url", "original_url": url}
),
status=400,
mimetype="application/json",
)
# Validate URL domain
parsed = urlparse(url)
host = parsed.netloc.lower()
if not (
host.endswith("deezer.com")
or host.endswith("open.spotify.com")
or host.endswith("spotify.com")
):
return Response(
json.dumps({"error": f"Invalid Link {url} :(", "original_url": url}),
status=400,
mimetype="application/json",
)
# Check for existing task before adding to the queue
existing_task = get_existing_task_id(url)
if existing_task:
return Response(
json.dumps(
{
"error": "Duplicate download detected.",
"existing_task": existing_task,
}
),
status=409,
mimetype="application/json",
)
try:
task_id = download_queue_manager.add_task(
{
"download_type": "track",
"url": url,
"name": name_from_spotify, # Use fetched name
"artist": artist_from_spotify, # Use fetched artist
"orig_request": orig_params,
}
)
# Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
except Exception as e:
# Generic error handling for other issues during task submission
error_task_id = str(uuid.uuid4())
store_task_info(
error_task_id,
{
"download_type": "track",
"url": url,
"name": name_from_spotify, # Use fetched name
"artist": artist_from_spotify, # Use fetched artist
"original_request": orig_params,
"created_at": time.time(),
"is_submission_error_task": True,
},
)
store_task_status(
error_task_id,
{
"status": ProgressState.ERROR,
"error": f"Failed to queue track download: {str(e)}",
"timestamp": time.time(),
},
)
return Response(
json.dumps(
{
"error": f"Failed to queue track download: {str(e)}",
"task_id": error_task_id,
}
),
status=500,
mimetype="application/json",
)
return Response(
json.dumps({"task_id": task_id}),
status=202,
mimetype="application/json",
)
@track_bp.route("/download/cancel", methods=["GET"])
def cancel_download():
"""
Cancel a running track download process by its task id.
"""
task_id = request.args.get("task_id")
if not task_id:
return Response(
json.dumps({"error": "Missing task id (task_id) parameter"}),
status=400,
mimetype="application/json",
)
# Use the queue manager's cancellation method.
result = download_queue_manager.cancel_task(task_id)
status_code = 200 if result.get("status") == "cancelled" else 404
return Response(json.dumps(result), status=status_code, mimetype="application/json")
@track_bp.route("/info", methods=["GET"])
def get_track_info():
"""
Retrieve Spotify track metadata given a Spotify track ID.
Expects a query parameter 'id' that contains the Spotify track ID.
"""
spotify_id = request.args.get("id")
if not spotify_id:
return Response(
json.dumps({"error": "Missing parameter: id"}),
status=400,
mimetype="application/json",
)
try:
# Import and use the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
track_info = get_spotify_info(spotify_id, "track")
return Response(json.dumps(track_info), status=200, mimetype="application/json")
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
return Response(json.dumps(error_data), status=500, mimetype="application/json")

View File

@@ -1,12 +1,10 @@
import json import json
import logging import logging
from flask import url_for
from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_spotify_info
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
from deezspot.easy_spoty import Spo
from deezspot.libutils.utils import get_ids, link_is_valid from deezspot.libutils.utils import get_ids, link_is_valid
# Configure logging # Configure logging
@@ -71,8 +69,6 @@ def get_artist_discography(
f"Error checking Spotify account '{main_spotify_account_name}' for discography context: {e}" f"Error checking Spotify account '{main_spotify_account_name}' for discography context: {e}"
) )
Spo.__init__(client_id, client_secret) # Initialize with global API keys
try: try:
artist_id = get_ids(url) artist_id = get_ids(url)
except Exception as id_error: except Exception as id_error:
@@ -81,12 +77,8 @@ def get_artist_discography(
raise ValueError(msg) raise ValueError(msg)
try: try:
# The progress_callback is not a standard param for Spo.get_artist # Use the optimized get_spotify_info function
# If Spo.get_artist is meant to be Spo.get_artist_discography, that would take limit/offset discography = get_spotify_info(artist_id, "artist_discography")
# Assuming it's Spo.get_artist which takes artist_id and album_type.
# If progress_callback was for a different Spo method, this needs review.
# For now, removing progress_callback from this specific call as Spo.get_artist doesn't use it.
discography = Spo.get_artist(artist_id, album_type=album_type)
return discography return discography
except Exception as fetch_error: except Exception as fetch_error:
msg = f"An error occurred while fetching the discography: {fetch_error}" msg = f"An error occurred while fetching the discography: {fetch_error}"

View File

@@ -121,6 +121,16 @@ task_default_queue = "downloads"
task_default_exchange = "downloads" task_default_exchange = "downloads"
task_default_routing_key = "downloads" task_default_routing_key = "downloads"
# Task routing - ensure SSE and utility tasks go to utility_tasks queue
task_routes = {
'routes.utils.celery_tasks.trigger_sse_update_task': {'queue': 'utility_tasks'},
'routes.utils.celery_tasks.cleanup_stale_errors': {'queue': 'utility_tasks'},
'routes.utils.celery_tasks.delayed_delete_task_data': {'queue': 'utility_tasks'},
'routes.utils.celery_tasks.download_track': {'queue': 'downloads'},
'routes.utils.celery_tasks.download_album': {'queue': 'downloads'},
'routes.utils.celery_tasks.download_playlist': {'queue': 'downloads'},
}
# Celery task settings # Celery task settings
task_serializer = "json" task_serializer = "json"
accept_content = ["json"] accept_content = ["json"]
@@ -141,6 +151,19 @@ task_annotations = {
"routes.utils.celery_tasks.download_playlist": { "routes.utils.celery_tasks.download_playlist": {
"rate_limit": f"{MAX_CONCURRENT_DL}/m", "rate_limit": f"{MAX_CONCURRENT_DL}/m",
}, },
"routes.utils.celery_tasks.trigger_sse_update_task": {
"rate_limit": "500/m", # Allow high rate for real-time SSE updates
"default_retry_delay": 1, # Quick retry for SSE updates
"max_retries": 1, # Limited retries for best-effort delivery
"ignore_result": True, # Don't store results for SSE tasks
"track_started": False, # Don't track when SSE tasks start
},
"routes.utils.celery_tasks.cleanup_stale_errors": {
"rate_limit": "10/m", # Moderate rate for cleanup tasks
},
"routes.utils.celery_tasks.delayed_delete_task_data": {
"rate_limit": "100/m", # Moderate rate for cleanup
},
} }
# Configure retry settings # Configure retry settings

View File

@@ -149,8 +149,9 @@ class CeleryManager:
else: else:
utility_cmd = self._get_worker_command( utility_cmd = self._get_worker_command(
queues="utility_tasks,default", # Listen to utility and default queues="utility_tasks,default", # Listen to utility and default
concurrency=3, 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)
) )
logger.info( logger.info(
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}" f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"
@@ -174,7 +175,7 @@ class CeleryManager:
self.utility_log_thread_stdout.start() self.utility_log_thread_stdout.start()
self.utility_log_thread_stderr.start() self.utility_log_thread_stderr.start()
logger.info( logger.info(
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) started with concurrency 3." f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) started with concurrency 5."
) )
if ( if (

View File

@@ -108,6 +108,14 @@ def get_existing_task_id(url, download_type=None):
ProgressState.ERROR, ProgressState.ERROR,
ProgressState.ERROR_RETRIED, ProgressState.ERROR_RETRIED,
ProgressState.ERROR_AUTO_CLEANED, ProgressState.ERROR_AUTO_CLEANED,
# Include string variants from standardized status_info structure
"cancelled",
"error",
"done",
"complete",
"completed",
"failed",
"skipped",
} }
logger.debug(f"GET_EXISTING_TASK_ID: Terminal states defined as: {TERMINAL_STATES}") logger.debug(f"GET_EXISTING_TASK_ID: Terminal states defined as: {TERMINAL_STATES}")
@@ -129,7 +137,13 @@ def get_existing_task_id(url, download_type=None):
logger.debug(f"GET_EXISTING_TASK_ID: No last status object for task_id='{existing_task_id}'. Skipping.") logger.debug(f"GET_EXISTING_TASK_ID: No last status object for task_id='{existing_task_id}'. Skipping.")
continue continue
existing_status = existing_last_status_obj.get("status") # Extract status from standard structure (status_info.status) or fallback to top-level status
existing_status = None
if "status_info" in existing_last_status_obj and existing_last_status_obj["status_info"]:
existing_status = existing_last_status_obj["status_info"].get("status")
if not existing_status:
existing_status = existing_last_status_obj.get("status")
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}', last_status_obj='{existing_last_status_obj}', extracted status='{existing_status}'.") logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}', last_status_obj='{existing_last_status_obj}', extracted status='{existing_status}'.")
# If the task is in a terminal state, ignore it and move to the next one. # If the task is in a terminal state, ignore it and move to the next one.
@@ -215,6 +229,14 @@ class CeleryDownloadQueueManager:
ProgressState.ERROR, ProgressState.ERROR,
ProgressState.ERROR_RETRIED, ProgressState.ERROR_RETRIED,
ProgressState.ERROR_AUTO_CLEANED, ProgressState.ERROR_AUTO_CLEANED,
# Include string variants from standardized status_info structure
"cancelled",
"error",
"done",
"complete",
"completed",
"failed",
"skipped",
} }
all_existing_tasks_summary = get_all_tasks() all_existing_tasks_summary = get_all_tasks()
@@ -233,7 +255,13 @@ class CeleryDownloadQueueManager:
existing_url = existing_task_info.get("url") existing_url = existing_task_info.get("url")
existing_type = existing_task_info.get("download_type") existing_type = existing_task_info.get("download_type")
existing_status = existing_last_status_obj.get("status")
# Extract status from standard structure (status_info.status) or fallback to top-level status
existing_status = None
if "status_info" in existing_last_status_obj and existing_last_status_obj["status_info"]:
existing_status = existing_last_status_obj["status_info"].get("status")
if not existing_status:
existing_status = existing_last_status_obj.get("status")
if ( if (
existing_url == incoming_url existing_url == incoming_url

View File

@@ -2,6 +2,7 @@ import time
import json import json
import logging import logging
import traceback import traceback
import asyncio
from celery import Celery, Task, states from celery import Celery, Task, states
from celery.signals import ( from celery.signals import (
task_prerun, task_prerun,
@@ -28,8 +29,8 @@ from routes.utils.watch.db import (
add_or_update_album_for_artist, add_or_update_album_for_artist,
) )
# Import history manager function # Import for download history management
from .history_manager import add_entry_to_history, add_tracks_from_summary from routes.utils.history_manager import history_manager
# Create Redis connection for storing task data that's not part of the Celery result backend # Create Redis connection for storing task data that's not part of the Celery result backend
import redis import redis
@@ -49,6 +50,26 @@ celery_app.config_from_object("routes.utils.celery_config")
redis_client = redis.Redis.from_url(REDIS_URL) redis_client = redis.Redis.from_url(REDIS_URL)
def trigger_sse_event(task_id: str, reason: str = "status_change"):
"""Trigger an SSE event using a dedicated Celery worker task"""
try:
# Submit SSE update task to utility worker queue
# This is non-blocking and more reliable than threads
trigger_sse_update_task.apply_async(
args=[task_id, reason],
queue="utility_tasks",
priority=9 # High priority for real-time updates
)
# Only log at debug level to reduce verbosity
logger.debug(f"SSE: Submitted SSE update task for {task_id} (reason: {reason})")
except Exception as e:
logger.error(f"Error submitting SSE update task for task {task_id}: {e}", exc_info=True)
class ProgressState: class ProgressState:
"""Enum-like class for progress states""" """Enum-like class for progress states"""
@@ -131,6 +152,10 @@ def store_task_status(task_id, status_data):
redis_client.publish( redis_client.publish(
update_channel, json.dumps({"task_id": task_id, "status_id": status_id}) update_channel, json.dumps({"task_id": task_id, "status_id": status_id})
) )
# Trigger immediate SSE event for real-time frontend updates
trigger_sse_event(task_id, "status_update")
except Exception as e: except Exception as e:
logger.error(f"Error storing task status: {e}") logger.error(f"Error storing task status: {e}")
traceback.print_exc() traceback.print_exc()
@@ -217,131 +242,6 @@ def get_all_tasks():
return [] return []
# --- History Logging Helper ---
def _log_task_to_history(task_id, final_status_str, error_msg=None):
"""Helper function to gather task data and log it to the history database."""
try:
task_info = get_task_info(task_id)
last_status_obj = get_last_task_status(task_id)
if not task_info:
logger.warning(
f"History: No task_info found for task_id {task_id}. Cannot log to history."
)
return
# Determine service_used and quality_profile
main_service_name = str(
task_info.get("main", "Unknown")
).capitalize() # e.g. Spotify, Deezer from their respective .env values
fallback_service_name = str(task_info.get("fallback", "")).capitalize()
service_used_str = main_service_name
if (
task_info.get("fallback") and fallback_service_name
): # Check if fallback was configured
# Try to infer actual service used if possible, otherwise show configured.
# This part is a placeholder for more accurate determination if deezspot gives explicit feedback.
# For now, we assume 'main' was used unless an error hints otherwise.
# A more robust solution would involve deezspot callback providing this.
service_used_str = (
f"{main_service_name} (Fallback: {fallback_service_name})"
)
# If error message indicates fallback, we could try to parse it.
# e.g. if error_msg and "fallback" in error_msg.lower(): service_used_str = f"{fallback_service_name} (Used Fallback)"
# Determine quality profile (primarily from the 'quality' field)
# 'quality' usually holds the primary service's quality (e.g., spotifyQuality, deezerQuality)
quality_profile_str = str(task_info.get("quality", "N/A"))
# Get convertTo and bitrate
convert_to_str = str(
task_info.get("convertTo", "")
) # Empty string if None or not present
bitrate_str = str(
task_info.get("bitrate", "")
) # Empty string if None or not present
# Extract Spotify ID from item URL if possible
spotify_id = None
item_url = task_info.get("url", "")
if item_url:
try:
spotify_id = item_url.split("/")[-1]
# Further validation if it looks like a Spotify ID (e.g., 22 chars, alphanumeric)
if not (spotify_id and len(spotify_id) == 22 and spotify_id.isalnum()):
spotify_id = None # Reset if not a valid-looking ID
except Exception:
spotify_id = None # Ignore errors in parsing
# Check for the new summary object in the last status
summary_obj = last_status_obj.get("summary") if last_status_obj else None
history_entry = {
"task_id": task_id,
"download_type": task_info.get("download_type"),
"item_name": task_info.get("name"),
"item_artist": task_info.get("artist"),
"item_album": task_info.get(
"album",
task_info.get("name")
if task_info.get("download_type") == "album"
else None,
),
"item_url": item_url,
"spotify_id": spotify_id,
"status_final": final_status_str,
"error_message": error_msg
if error_msg
else (last_status_obj.get("error") if last_status_obj else None),
"timestamp_added": task_info.get("created_at", time.time()),
"timestamp_completed": last_status_obj.get("timestamp", time.time())
if last_status_obj
else time.time(),
"original_request_json": json.dumps(task_info.get("original_request", {})),
"last_status_obj_json": json.dumps(
last_status_obj if last_status_obj else {}
),
"service_used": service_used_str,
"quality_profile": quality_profile_str,
"convert_to": convert_to_str
if convert_to_str
else None, # Store None if empty string
"bitrate": bitrate_str
if bitrate_str
else None, # Store None if empty string
"summary_json": json.dumps(summary_obj) if summary_obj else None,
"total_successful": summary_obj.get("total_successful")
if summary_obj
else None,
"total_skipped": summary_obj.get("total_skipped") if summary_obj else None,
"total_failed": summary_obj.get("total_failed") if summary_obj else None,
}
# Add the main history entry for the task
add_entry_to_history(history_entry)
# Process track-level entries from summary if this is a multi-track download
if summary_obj and task_info.get("download_type") in ["album", "playlist"]:
tracks_processed = add_tracks_from_summary(
summary_data=summary_obj,
parent_task_id=task_id,
parent_history_data=history_entry
)
logger.info(
f"Track-level history: Processed {tracks_processed['successful']} successful, "
f"{tracks_processed['skipped']} skipped, and {tracks_processed['failed']} failed tracks for task {task_id}"
)
except Exception as e:
logger.error(
f"History: Error preparing or logging history for task {task_id}: {e}",
exc_info=True,
)
# --- End History Logging Helper ---
def cancel_task(task_id): def cancel_task(task_id):
"""Cancel a task by its ID""" """Cancel a task by its ID"""
try: try:
@@ -349,24 +249,23 @@ def cancel_task(task_id):
store_task_status( store_task_status(
task_id, task_id,
{ {
"status": ProgressState.CANCELLED, "status_info": {
"error": "Task cancelled by user", "status": ProgressState.CANCELLED,
"timestamp": time.time(), "error": "Task cancelled by user",
"timestamp": time.time(),
}
}, },
) )
# Try to revoke the Celery task if it hasn't started yet # Try to revoke the Celery task if it hasn't started yet
celery_app.control.revoke(task_id, terminate=True, signal="SIGTERM") celery_app.control.revoke(task_id, terminate=True, signal="SIGTERM")
# Log cancellation to history # Schedule deletion of task data after 3 seconds
_log_task_to_history(task_id, "CANCELLED", "Task cancelled by user")
# Schedule deletion of task data after 30 seconds
delayed_delete_task_data.apply_async( delayed_delete_task_data.apply_async(
args=[task_id, "Task cancelled by user and auto-cleaned."], countdown=30 args=[task_id, "Task cancelled by user and auto-cleaned."], countdown=3
) )
logger.info( logger.info(
f"Task {task_id} cancelled by user. Data scheduled for deletion in 30s." f"Task {task_id} cancelled by user. Data scheduled for deletion in 3s."
) )
return {"status": "cancelled", "task_id": task_id} return {"status": "cancelled", "task_id": task_id}
@@ -592,8 +491,12 @@ class ProgressTrackingTask(Task):
if "timestamp" not in progress_data: if "timestamp" not in progress_data:
progress_data["timestamp"] = time.time() progress_data["timestamp"] = time.time()
status = progress_data.get("status", "unknown") # Extract status from status_info (deezspot callback format)
status_info = progress_data.get("status_info", {})
status = status_info.get("status", progress_data.get("status", "unknown"))
task_info = get_task_info(task_id) task_info = get_task_info(task_id)
logger.debug(f"Task {task_id}: Extracted status: '{status}' from callback")
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug( logger.debug(
@@ -609,12 +512,18 @@ class ProgressTrackingTask(Task):
elif status in ["real_time", "track_progress"]: elif status in ["real_time", "track_progress"]:
self._handle_real_time(task_id, progress_data) self._handle_real_time(task_id, progress_data)
elif status == "skipped": elif status == "skipped":
# Re-fetch task_info to ensure we have the latest children_table info
task_info = get_task_info(task_id)
self._handle_skipped(task_id, progress_data, task_info) self._handle_skipped(task_id, progress_data, task_info)
elif status == "retrying": elif status == "retrying":
self._handle_retrying(task_id, progress_data, task_info) self._handle_retrying(task_id, progress_data, task_info)
elif status == "error": elif status == "error":
# Re-fetch task_info to ensure we have the latest children_table info
task_info = get_task_info(task_id)
self._handle_error(task_id, progress_data, task_info) self._handle_error(task_id, progress_data, task_info)
elif status == "done": elif status == "done":
# Re-fetch task_info to ensure we have the latest children_table info
task_info = get_task_info(task_id)
self._handle_done(task_id, progress_data, task_info) self._handle_done(task_id, progress_data, task_info)
else: else:
logger.info( logger.info(
@@ -627,9 +536,46 @@ class ProgressTrackingTask(Task):
def _handle_initializing(self, task_id, data, task_info): def _handle_initializing(self, task_id, data, task_info):
"""Handle initializing status from deezspot""" """Handle initializing status from deezspot"""
logger.info(f"Task {task_id} initializing...") logger.info(f"Task {task_id} initializing...")
# Initializing object is now very basic, mainly for acknowledging the start. # Initializing object is now very basic, mainly for acknowledging the start.
# More detailed info comes with 'progress' or 'downloading' states. # More detailed info comes with 'progress' or 'downloading' states.
data["status"] = ProgressState.INITIALIZING data["status"] = ProgressState.INITIALIZING
# Store initial history entry for download start
try:
# Check for album/playlist FIRST since their callbacks contain both parent and track info
if "album" in data:
# Album download - create children table and store name in task info
logger.info(f"Task {task_id}: Creating album children table")
children_table = history_manager.store_album_history(data, task_id, "in_progress")
if children_table:
task_info["children_table"] = children_table
store_task_info(task_id, task_info)
logger.info(f"Task {task_id}: Created and stored children table '{children_table}' in task info")
else:
logger.error(f"Task {task_id}: Failed to create album children table")
elif "playlist" in data:
# Playlist download - create children table and store name in task info
logger.info(f"Task {task_id}: Creating playlist children table")
children_table = history_manager.store_playlist_history(data, task_id, "in_progress")
if children_table:
task_info["children_table"] = children_table
store_task_info(task_id, task_info)
logger.info(f"Task {task_id}: Created and stored children table '{children_table}' in task info")
else:
logger.error(f"Task {task_id}: Failed to create playlist children table")
elif "track" in data:
# Individual track download - check if it's part of an album/playlist
children_table = task_info.get("children_table")
if children_table:
# Track is part of album/playlist - don't store in main table during initialization
logger.info(f"Task {task_id}: Skipping track initialization storage (part of album/playlist, children table: {children_table})")
else:
# Individual track download - store in main table
logger.info(f"Task {task_id}: Storing individual track history (initializing)")
history_manager.store_track_history(data, task_id, "in_progress")
except Exception as e:
logger.error(f"Failed to store initial history for task {task_id}: {e}", exc_info=True)
def _handle_downloading(self, task_id, data, task_info): def _handle_downloading(self, task_id, data, task_info):
"""Handle downloading status from deezspot""" """Handle downloading status from deezspot"""
@@ -690,7 +636,6 @@ class ProgressTrackingTask(Task):
logger.debug(f"Task {task_id}: Real-time progress for '{track_name}': {percentage}%") logger.debug(f"Task {task_id}: Real-time progress for '{track_name}': {percentage}%")
data["status"] = ProgressState.TRACK_PROGRESS
data["song"] = track_name data["song"] = track_name
artist = data.get("artist", "Unknown") artist = data.get("artist", "Unknown")
@@ -725,18 +670,29 @@ class ProgressTrackingTask(Task):
) )
# Log at debug level # Log at debug level
logger.debug(f"Task {task_id} track progress: {title} by {artist}: {percent}%") logger.debug(f"Task {task_id} track progress: {track_name} by {artist}: {percent}%")
# Set appropriate status
# data["status"] = (
# ProgressState.REAL_TIME
# if data.get("status") == "real_time"
# else ProgressState.TRACK_PROGRESS
# )
def _handle_skipped(self, task_id, data, task_info): def _handle_skipped(self, task_id, data, task_info):
"""Handle skipped status from deezspot""" """Handle skipped status from deezspot"""
# Extract track info
# Store skipped history for deezspot callback format
try:
if "track" in data:
# Individual track skipped - check if we should use children table
children_table = task_info.get("children_table")
logger.debug(f"Task {task_id}: Skipped track, children_table = '{children_table}'")
if children_table:
# Part of album/playlist - store progressively in children table
logger.info(f"Task {task_id}: Storing skipped track in children table '{children_table}' (progressive)")
history_manager.store_track_history(data, task_id, "skipped", children_table)
else:
# Individual track download - store in main table
logger.info(f"Task {task_id}: Storing skipped track in main table (individual download)")
history_manager.store_track_history(data, task_id, "skipped")
except Exception as e:
logger.error(f"Failed to store skipped history for task {task_id}: {e}")
# Extract track info (legacy format support)
title = data.get("song", "Unknown") title = data.get("song", "Unknown")
artist = data.get("artist", "Unknown") artist = data.get("artist", "Unknown")
reason = data.get("reason", "Unknown reason") reason = data.get("reason", "Unknown reason")
@@ -809,7 +765,34 @@ class ProgressTrackingTask(Task):
def _handle_error(self, task_id, data, task_info): def _handle_error(self, task_id, data, task_info):
"""Handle error status from deezspot""" """Handle error status from deezspot"""
# Extract error info
# Store error history for deezspot callback format
try:
# Check for album/playlist FIRST since their callbacks contain both parent and track info
if "album" in data:
# Album failed - store in main table
logger.info(f"Task {task_id}: Storing album history (failed)")
history_manager.store_album_history(data, task_id, "failed")
elif "playlist" in data:
# Playlist failed - store in main table
logger.info(f"Task {task_id}: Storing playlist history (failed)")
history_manager.store_playlist_history(data, task_id, "failed")
elif "track" in data:
# Individual track failed - check if we should use children table
children_table = task_info.get("children_table")
logger.debug(f"Task {task_id}: Failed track, children_table = '{children_table}'")
if children_table:
# Part of album/playlist - store progressively in children table
logger.info(f"Task {task_id}: Storing failed track in children table '{children_table}' (progressive)")
history_manager.store_track_history(data, task_id, "failed", children_table)
else:
# Individual track download - store in main table
logger.info(f"Task {task_id}: Storing failed track in main table (individual download)")
history_manager.store_track_history(data, task_id, "failed")
except Exception as e:
logger.error(f"Failed to store error history for task {task_id}: {e}")
# Extract error info (legacy format support)
message = data.get("message", "Unknown error") message = data.get("message", "Unknown error")
# Log error # Log error
@@ -826,7 +809,34 @@ class ProgressTrackingTask(Task):
def _handle_done(self, task_id, data, task_info): def _handle_done(self, task_id, data, task_info):
"""Handle done status from deezspot""" """Handle done status from deezspot"""
# Extract data
# Store completion history for deezspot callback format
try:
# Check for album/playlist FIRST since their callbacks contain both parent and track info
if "album" in data:
# Album completion with summary - store in main table
logger.info(f"Task {task_id}: Storing album history (completed)")
history_manager.store_album_history(data, task_id, "completed")
elif "playlist" in data:
# Playlist completion with summary - store in main table
logger.info(f"Task {task_id}: Storing playlist history (completed)")
history_manager.store_playlist_history(data, task_id, "completed")
elif "track" in data:
# Individual track completion - check if we should use children table
children_table = task_info.get("children_table")
logger.debug(f"Task {task_id}: Completed track, children_table = '{children_table}'")
if children_table:
# Part of album/playlist - store progressively in children table
logger.info(f"Task {task_id}: Storing completed track in children table '{children_table}' (progressive)")
history_manager.store_track_history(data, task_id, "completed", children_table)
else:
# Individual track download - store in main table
logger.info(f"Task {task_id}: Storing completed track in main table (individual download)")
history_manager.store_track_history(data, task_id, "completed")
except Exception as e:
logger.error(f"Failed to store completion history for task {task_id}: {e}", exc_info=True)
# Extract data (legacy format support)
content_type = data.get("type", "").lower() content_type = data.get("type", "").lower()
album = data.get("album", "") album = data.get("album", "")
artist = data.get("artist", "") artist = data.get("artist", "")
@@ -924,7 +934,7 @@ class ProgressTrackingTask(Task):
# Schedule deletion for completed multi-track downloads # Schedule deletion for completed multi-track downloads
delayed_delete_task_data.apply_async( delayed_delete_task_data.apply_async(
args=[task_id, "Task completed successfully and auto-cleaned."], args=[task_id, "Task completed successfully and auto-cleaned."],
countdown=30, # Delay in seconds countdown=3, # Delay in seconds
) )
# If from playlist_watch and successful, add track to DB # If from playlist_watch and successful, add track to DB
@@ -998,6 +1008,10 @@ class ProgressTrackingTask(Task):
def task_prerun_handler(task_id=None, task=None, *args, **kwargs): def task_prerun_handler(task_id=None, task=None, *args, **kwargs):
"""Signal handler when a task begins running""" """Signal handler when a task begins running"""
try: try:
# Skip verbose logging for SSE tasks
if task and hasattr(task, 'name') and task.name in ['trigger_sse_update_task']:
return
task_info = get_task_info(task_id) task_info = get_task_info(task_id)
# Update task status to processing # Update task status to processing
@@ -1025,9 +1039,10 @@ def task_postrun_handler(
): ):
"""Signal handler when a task finishes""" """Signal handler when a task finishes"""
try: try:
# Define download task names # Skip verbose logging for SSE tasks
download_task_names = ["download_track", "download_album", "download_playlist"] if task and hasattr(task, 'name') and task.name in ['trigger_sse_update_task']:
return
last_status_for_history = get_last_task_status(task_id) last_status_for_history = get_last_task_status(task_id)
if last_status_for_history and last_status_for_history.get("status") in [ if last_status_for_history and last_status_for_history.get("status") in [
ProgressState.COMPLETE, ProgressState.COMPLETE,
@@ -1041,14 +1056,8 @@ def task_postrun_handler(
and last_status_for_history.get("status") != ProgressState.CANCELLED and last_status_for_history.get("status") != ProgressState.CANCELLED
): ):
logger.info( logger.info(
f"Task {task_id} was REVOKED (likely cancelled), logging to history." f"Task {task_id} was REVOKED (likely cancelled)."
) )
if (
task and task.name in download_task_names
): # Check if it's a download task
_log_task_to_history(
task_id, "CANCELLED", "Task was revoked/cancelled."
)
# return # Let status update proceed if necessary # return # Let status update proceed if necessary
task_info = get_task_info(task_id) task_info = get_task_info(task_id)
@@ -1065,17 +1074,13 @@ def task_postrun_handler(
logger.info( logger.info(
f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}" f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}"
) )
if (
task and task.name in download_task_names
): # Check if it's a download task
_log_task_to_history(task_id, "COMPLETED")
if ( if (
task_info.get("download_type") == "track" task_info.get("download_type") == "track"
): # Applies to single track downloads and tracks from playlists/albums ): # Applies to single track downloads and tracks from playlists/albums
delayed_delete_task_data.apply_async( delayed_delete_task_data.apply_async(
args=[task_id, "Task completed successfully and auto-cleaned."], args=[task_id, "Task completed successfully and auto-cleaned."],
countdown=30, countdown=3,
) )
original_request = task_info.get("original_request", {}) original_request = task_info.get("original_request", {})
@@ -1092,7 +1097,26 @@ def task_postrun_handler(
f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB." f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB."
) )
try: try:
add_single_track_to_playlist_db(playlist_id, track_item_for_db) # Use task_id as primary source for metadata extraction
add_single_track_to_playlist_db(
playlist_spotify_id=playlist_id,
track_item_for_db=track_item_for_db, # Keep as fallback
task_id=task_id # Primary source for metadata
)
# Update the playlist's m3u file after successful track addition
try:
from routes.utils.watch.manager import update_playlist_m3u_file
logger.info(
f"Updating m3u file for playlist {playlist_id} after successful track download."
)
update_playlist_m3u_file(playlist_id)
except Exception as m3u_update_err:
logger.error(
f"Failed to update m3u file for playlist {playlist_id} after successful track download task {task_id}: {m3u_update_err}",
exc_info=True,
)
except Exception as db_add_err: except Exception as db_add_err:
logger.error( logger.error(
f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}",
@@ -1189,24 +1213,20 @@ def task_failure_handler(
) )
logger.error(f"Task {task_id} failed: {str(exception)}") logger.error(f"Task {task_id} failed: {str(exception)}")
if (
sender and sender.name in download_task_names
): # Check if it's a download task
_log_task_to_history(task_id, "ERROR", str(exception))
if can_retry: if can_retry:
logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})") logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})")
else: else:
# If task cannot be retried, schedule its data for deletion # If task cannot be retried, schedule its data for deletion
logger.info( logger.info(
f"Task {task_id} failed and cannot be retried. Data scheduled for deletion in 30s." f"Task {task_id} failed and cannot be retried. Data scheduled for deletion in 3s."
) )
delayed_delete_task_data.apply_async( delayed_delete_task_data.apply_async(
args=[ args=[
task_id, task_id,
f"Task failed ({str(exception)}) and max retries reached. Auto-cleaned.", f"Task failed ({str(exception)}) and max retries reached. Auto-cleaned.",
], ],
countdown=30, countdown=3,
) )
except Exception as e: except Exception as e:
@@ -1552,12 +1572,6 @@ def delete_task_data_and_log(task_id, reason="Task data deleted"):
"timestamp": time.time(), "timestamp": time.time(),
}, },
) )
# History logging for COMPLETION, CANCELLATION, or definitive ERROR should have occurred when those states were first reached.
# If this cleanup is for a task that *wasn't* in such a state (e.g. stale, still processing), log it now.
if final_redis_status == ProgressState.ERROR_AUTO_CLEANED:
_log_task_to_history(
task_id, "ERROR", error_message_for_status
) # Or a more specific status if desired
# Delete Redis keys associated with the task # Delete Redis keys associated with the task
redis_client.delete(f"task:{task_id}:info") redis_client.delete(f"task:{task_id}:info")
@@ -1637,3 +1651,38 @@ def delayed_delete_task_data(task_id, reason):
""" """
logger.info(f"Executing delayed deletion for task {task_id}. Reason: {reason}") logger.info(f"Executing delayed deletion for task {task_id}. Reason: {reason}")
delete_task_data_and_log(task_id, reason) delete_task_data_and_log(task_id, reason)
@celery_app.task(
name="trigger_sse_update_task",
queue="utility_tasks",
bind=True
)
def trigger_sse_update_task(self, task_id: str, reason: str = "status_update"):
"""
Dedicated Celery task for triggering SSE task summary updates.
Uses Redis pub/sub to communicate with the main FastAPI process.
"""
try:
# Send task summary update via Redis pub/sub
logger.debug(f"SSE Task: Processing summary update for task {task_id} (reason: {reason})")
event_data = {
"task_id": task_id,
"reason": reason,
"timestamp": time.time(),
"change_type": "task_summary",
"event_type": "summary_update"
}
# Use Redis pub/sub for cross-process communication
redis_client.publish("sse_events", json.dumps(event_data))
logger.debug(f"SSE Task: Published summary update for task {task_id}")
except Exception as e:
# Only log errors, not success cases
logger.error(f"SSE Task: Failed to publish summary update for task {task_id}: {e}", exc_info=True)
# Don't raise exception to avoid task retry - SSE updates are best-effort

View File

@@ -1,94 +1,335 @@
from deezspot.easy_spoty import Spo import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
from routes.utils.celery_queue_manager import get_config_params from routes.utils.celery_queue_manager import get_config_params
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
import logging
import time
from typing import Dict, List, Optional, Any
import json
from pathlib import Path
# Import Deezer API and logging # Import Deezer API and logging
from deezspot.deezloader.dee_api import API as DeezerAPI from deezspot.deezloader.dee_api import API as DeezerAPI
import logging
# Initialize logger # Initialize logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Global Spotify client instance for reuse
_spotify_client = None
_last_client_init = 0
_client_init_interval = 3600 # Reinitialize client every hour
def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None): def _get_spotify_client():
""" """
Get info from Spotify API. Uses global client_id/secret from search.json. Get or create a Spotify client with global credentials.
The default Spotify account from main.json might still be relevant for other Spo settings or if Spo uses it. Implements client reuse and periodic reinitialization.
"""
global _spotify_client, _last_client_init
current_time = time.time()
# Reinitialize client if it's been more than an hour or if client doesn't exist
if (_spotify_client is None or
current_time - _last_client_init > _client_init_interval):
client_id, client_secret = _get_global_spotify_api_creds()
if not client_id or not client_secret:
raise ValueError(
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
)
# Create new client
_spotify_client = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=client_id,
client_secret=client_secret
)
)
_last_client_init = current_time
logger.info("Spotify client initialized/reinitialized")
return _spotify_client
def _rate_limit_handler(func):
"""
Decorator to handle rate limiting with exponential backoff.
"""
def wrapper(*args, **kwargs):
max_retries = 3
base_delay = 1
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if "429" in str(e) or "rate limit" in str(e).lower():
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logger.warning(f"Rate limited, retrying in {delay} seconds...")
time.sleep(delay)
continue
raise e
return func(*args, **kwargs)
return wrapper
@_rate_limit_handler
def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]:
"""
Get playlist metadata only (no tracks) to avoid rate limiting.
Args:
playlist_id: The Spotify playlist ID
Returns:
Dictionary with playlist metadata (name, description, owner, etc.)
"""
client = _get_spotify_client()
try:
# Get basic playlist info without tracks
playlist = client.playlist(playlist_id, fields="id,name,description,owner,images,snapshot_id,public,followers,tracks.total")
# Add a flag to indicate this is metadata only
playlist['_metadata_only'] = True
playlist['_tracks_loaded'] = False
logger.debug(f"Retrieved playlist metadata for {playlist_id}: {playlist.get('name', 'Unknown')}")
return playlist
except Exception as e:
logger.error(f"Error fetching playlist metadata for {playlist_id}: {e}")
raise
@_rate_limit_handler
def get_playlist_tracks(playlist_id: str, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
"""
Get playlist tracks with pagination support to handle large playlists efficiently.
Args:
playlist_id: The Spotify playlist ID
limit: Number of tracks to fetch per request (max 100)
offset: Starting position for pagination
Returns:
Dictionary with tracks data
"""
client = _get_spotify_client()
try:
# Get tracks with specified limit and offset
tracks_data = client.playlist_tracks(
playlist_id,
limit=min(limit, 100), # Spotify API max is 100
offset=offset,
fields="items(track(id,name,artists,album,external_urls,preview_url,duration_ms,explicit,popularity)),total,limit,offset"
)
logger.debug(f"Retrieved {len(tracks_data.get('items', []))} tracks for playlist {playlist_id} (offset: {offset})")
return tracks_data
except Exception as e:
logger.error(f"Error fetching playlist tracks for {playlist_id}: {e}")
raise
@_rate_limit_handler
def get_playlist_full(playlist_id: str, batch_size: int = 100) -> Dict[str, Any]:
"""
Get complete playlist data with all tracks, using batched requests to avoid rate limiting.
Args:
playlist_id: The Spotify playlist ID
batch_size: Number of tracks to fetch per batch (max 100)
Returns:
Complete playlist data with all tracks
"""
client = _get_spotify_client()
try:
# First get metadata
playlist = get_playlist_metadata(playlist_id)
# Get total track count
total_tracks = playlist.get('tracks', {}).get('total', 0)
if total_tracks == 0:
playlist['tracks'] = {'items': [], 'total': 0}
return playlist
# Fetch all tracks in batches
all_tracks = []
offset = 0
while offset < total_tracks:
batch = get_playlist_tracks(playlist_id, limit=batch_size, offset=offset)
batch_items = batch.get('items', [])
all_tracks.extend(batch_items)
offset += len(batch_items)
# Add small delay between batches to be respectful to API
if offset < total_tracks:
time.sleep(0.1)
# Update playlist with complete tracks data
playlist['tracks'] = {
'items': all_tracks,
'total': total_tracks,
'limit': batch_size,
'offset': 0
}
playlist['_metadata_only'] = False
playlist['_tracks_loaded'] = True
logger.info(f"Retrieved complete playlist {playlist_id} with {total_tracks} tracks")
return playlist
except Exception as e:
logger.error(f"Error fetching complete playlist {playlist_id}: {e}")
raise
def check_playlist_updated(playlist_id: str, last_snapshot_id: str) -> bool:
"""
Check if playlist has been updated by comparing snapshot_id.
This is much more efficient than fetching all tracks.
Args:
playlist_id: The Spotify playlist ID
last_snapshot_id: The last known snapshot_id
Returns:
True if playlist has been updated, False otherwise
"""
try:
metadata = get_playlist_metadata(playlist_id)
current_snapshot_id = metadata.get('snapshot_id')
return current_snapshot_id != last_snapshot_id
except Exception as e:
logger.error(f"Error checking playlist update status for {playlist_id}: {e}")
raise
@_rate_limit_handler
def get_spotify_info(spotify_id: str, spotify_type: str, limit: Optional[int] = None, offset: Optional[int] = None) -> Dict[str, Any]:
"""
Get info from Spotify API using Spotipy directly.
Optimized to prevent rate limiting by using appropriate endpoints.
Args: Args:
spotify_id: The Spotify ID of the entity spotify_id: The Spotify ID of the entity
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode) spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode)
limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist_discography". limit (int, optional): The maximum number of items to return. Used for pagination.
offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist_discography". offset (int, optional): The index of the first item to return. Used for pagination.
Returns: Returns:
Dictionary with the entity information Dictionary with the entity information
""" """
client_id, client_secret = _get_global_spotify_api_creds() client = _get_spotify_client()
if not client_id or not client_secret: try:
raise ValueError( if spotify_type == "track":
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json." return client.track(spotify_id)
)
elif spotify_type == "album":
# Get config parameters including default Spotify account name return client.album(spotify_id)
# This might still be useful if Spo uses the account name for other things (e.g. market/region if not passed explicitly)
# For now, we are just ensuring the API keys are set. elif spotify_type == "playlist":
config_params = get_config_params() # Use optimized playlist fetching
main_spotify_account_name = config_params.get( return get_playlist_full(spotify_id)
"spotify", ""
) # Still good to know which account is 'default' contextually elif spotify_type == "playlist_metadata":
# Get only metadata for playlists
if not main_spotify_account_name: return get_playlist_metadata(spotify_id)
# This is less critical now that API keys are global, but could indicate a misconfiguration
# if other parts of Spo expect an account context. elif spotify_type == "artist":
print( return client.artist(spotify_id)
"WARN: No default Spotify account name configured in settings (main.json). API calls will use global keys."
) elif spotify_type == "artist_discography":
else: # Get artist's albums with pagination
# Optionally, one could load the specific account's region here if Spo.init or methods need it, albums = client.artist_albums(
# but easy_spoty's Spo doesn't seem to take region directly in __init__. spotify_id,
# It might use it internally based on account details if credentials.json (blob) contains it. limit=limit or 20,
try: offset=offset or 0
# We call get_credential just to check if the account exists,
# not for client_id/secret anymore for Spo.__init__
get_credential("spotify", main_spotify_account_name)
except FileNotFoundError:
# This is a more serious warning if an account is expected to exist.
print(
f"WARN: Default Spotify account '{main_spotify_account_name}' configured in main.json was not found in credentials database."
) )
except Exception as e: return albums
print(
f"WARN: Error accessing default Spotify account '{main_spotify_account_name}': {e}" elif spotify_type == "episode":
) return client.episode(spotify_id)
# Initialize the Spotify client with GLOBAL credentials
Spo.__init__(client_id, client_secret)
if spotify_type == "track":
return Spo.get_track(spotify_id)
elif spotify_type == "album":
return Spo.get_album(spotify_id)
elif spotify_type == "playlist":
return Spo.get_playlist(spotify_id)
elif spotify_type == "artist_discography":
if limit is not None and offset is not None:
return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset)
elif limit is not None:
return Spo.get_artist_discography(spotify_id, limit=limit)
elif offset is not None:
return Spo.get_artist_discography(spotify_id, offset=offset)
else: else:
return Spo.get_artist_discography(spotify_id) raise ValueError(f"Unsupported Spotify type: {spotify_type}")
elif spotify_type == "artist":
return Spo.get_artist(spotify_id) except Exception as e:
elif spotify_type == "episode": logger.error(f"Error fetching {spotify_type} {spotify_id}: {e}")
return Spo.get_episode(spotify_id) raise
# Cache for playlist metadata to reduce API calls
_playlist_metadata_cache = {}
_cache_ttl = 300 # 5 minutes cache
def get_cached_playlist_metadata(playlist_id: str) -> Optional[Dict[str, Any]]:
"""
Get playlist metadata from cache if available and not expired.
Args:
playlist_id: The Spotify playlist ID
Returns:
Cached metadata or None if not available/expired
"""
if playlist_id in _playlist_metadata_cache:
cached_data, timestamp = _playlist_metadata_cache[playlist_id]
if time.time() - timestamp < _cache_ttl:
return cached_data
return None
def cache_playlist_metadata(playlist_id: str, metadata: Dict[str, Any]):
"""
Cache playlist metadata with timestamp.
Args:
playlist_id: The Spotify playlist ID
metadata: The metadata to cache
"""
_playlist_metadata_cache[playlist_id] = (metadata, time.time())
def get_playlist_info_optimized(playlist_id: str, include_tracks: bool = False) -> Dict[str, Any]:
"""
Optimized playlist info function that uses caching and selective loading.
Args:
playlist_id: The Spotify playlist ID
include_tracks: Whether to include track data (default: False to save API calls)
Returns:
Playlist data with or without tracks
"""
# Check cache first
cached_metadata = get_cached_playlist_metadata(playlist_id)
if cached_metadata and not include_tracks:
logger.debug(f"Returning cached metadata for playlist {playlist_id}")
return cached_metadata
if include_tracks:
# Get complete playlist data
playlist_data = get_playlist_full(playlist_id)
# Cache the metadata portion
metadata_only = {k: v for k, v in playlist_data.items() if k != 'tracks'}
metadata_only['_metadata_only'] = True
metadata_only['_tracks_loaded'] = False
cache_playlist_metadata(playlist_id, metadata_only)
return playlist_data
else: else:
raise ValueError(f"Unsupported Spotify type: {spotify_type}") # Get metadata only
metadata = get_playlist_metadata(playlist_id)
cache_playlist_metadata(playlist_id, metadata)
return metadata
# Keep the existing Deezer functions unchanged
def get_deezer_info(deezer_id, deezer_type, limit=None): def get_deezer_info(deezer_id, deezer_type, limit=None):
""" """
Get info from Deezer API. Get info from Deezer API.

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,57 @@
from deezspot.easy_spoty import Spo import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
import logging import logging
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
import time
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Global Spotify client instance for reuse (same pattern as get_info.py)
_spotify_client = None
_last_client_init = 0
_client_init_interval = 3600 # Reinitialize client every hour
def _get_spotify_client():
"""
Get or create a Spotify client with global credentials.
Implements client reuse and periodic reinitialization.
"""
global _spotify_client, _last_client_init
current_time = time.time()
# Reinitialize client if it's been more than an hour or if client doesn't exist
if (_spotify_client is None or
current_time - _last_client_init > _client_init_interval):
client_id, client_secret = _get_global_spotify_api_creds()
if not client_id or not client_secret:
raise ValueError(
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
)
# Create new client
_spotify_client = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=client_id,
client_secret=client_secret
)
)
_last_client_init = current_time
logger.info("Spotify client initialized/reinitialized for search")
return _spotify_client
def search(query: str, search_type: str, limit: int = 3, main: str = None) -> dict: def search(query: str, search_type: str, limit: int = 3, main: str = None) -> dict:
logger.info( logger.info(
f"Search requested: query='{query}', type={search_type}, limit={limit}, main_account_name={main}" f"Search requested: query='{query}', type={search_type}, limit={limit}, main_account_name={main}"
)
client_id, client_secret = _get_global_spotify_api_creds()
if not client_id or not client_secret:
logger.error(
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
)
raise ValueError(
"Spotify API credentials are not configured globally for search."
) )
if main: if main:
logger.debug( logger.debug(
f"Spotify account context '{main}' was provided for search. API keys are global, but this account might be used for other context by Spo if relevant." f"Spotify account context '{main}' was provided for search. API keys are global, but this account might be used for other context."
) )
try: try:
get_credential("spotify", main) get_credential("spotify", main)
@@ -41,14 +69,32 @@ def search(query: str, search_type: str, limit: int = 3, main: str = None) -> di
"No specific 'main' account context provided for search. Using global API keys." "No specific 'main' account context provided for search. Using global API keys."
) )
logger.debug("Initializing Spotify client with global API credentials for search.") logger.debug("Getting Spotify client for search.")
Spo.__init__(client_id, client_secret) client = _get_spotify_client()
logger.debug( logger.debug(
f"Executing Spotify search with query='{query}', type={search_type}, limit={limit}" f"Executing Spotify search with query='{query}', type={search_type}, limit={limit}"
) )
try: try:
spotify_response = Spo.search(query=query, search_type=search_type, limit=limit) # Map search types to Spotipy search types
search_type_map = {
'track': 'track',
'album': 'album',
'artist': 'artist',
'playlist': 'playlist',
'episode': 'episode',
'show': 'show'
}
spotify_type = search_type_map.get(search_type.lower(), 'track')
# Execute search using Spotipy
spotify_response = client.search(
q=query,
type=spotify_type,
limit=limit
)
logger.info(f"Search completed successfully for query: '{query}'") logger.info(f"Search completed successfully for query: '{query}'")
return spotify_response return spotify_response
except Exception as e: except Exception as e:

View File

@@ -40,6 +40,7 @@ EXPECTED_PLAYLIST_TRACKS_COLUMNS = {
"added_to_db": "INTEGER", "added_to_db": "INTEGER",
"is_present_in_spotify": "INTEGER DEFAULT 1", "is_present_in_spotify": "INTEGER DEFAULT 1",
"last_seen_in_spotify": "INTEGER", "last_seen_in_spotify": "INTEGER",
"snapshot_id": "TEXT", # Track the snapshot_id when this track was added/updated
} }
EXPECTED_WATCHED_ARTISTS_COLUMNS = { EXPECTED_WATCHED_ARTISTS_COLUMNS = {
@@ -165,6 +166,11 @@ def init_playlists_db():
"watched playlists", "watched playlists",
): ):
conn.commit() conn.commit()
# Update all existing playlist track tables with new schema
_update_all_playlist_track_tables(cursor)
conn.commit()
logger.info( logger.info(
f"Playlists database initialized/updated successfully at {PLAYLISTS_DB_PATH}" f"Playlists database initialized/updated successfully at {PLAYLISTS_DB_PATH}"
) )
@@ -173,6 +179,87 @@ def init_playlists_db():
raise raise
def _update_all_playlist_track_tables(cursor: sqlite3.Cursor):
"""Updates all existing playlist track tables to ensure they have the latest schema."""
try:
# Get all table names that start with 'playlist_'
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'playlist_%'")
playlist_tables = cursor.fetchall()
for table_row in playlist_tables:
table_name = table_row[0]
if _ensure_table_schema(
cursor,
table_name,
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({table_name})",
):
logger.info(f"Updated schema for existing playlist track table: {table_name}")
except sqlite3.Error as e:
logger.error(f"Error updating playlist track tables schema: {e}", exc_info=True)
def update_all_existing_tables_schema():
"""Updates all existing tables to ensure they have the latest schema. Can be called independently."""
try:
with _get_playlists_db_connection() as conn:
cursor = conn.cursor()
# Update main watched_playlists table
if _ensure_table_schema(
cursor,
"watched_playlists",
EXPECTED_WATCHED_PLAYLISTS_COLUMNS,
"watched playlists",
):
logger.info("Updated schema for watched_playlists table")
# Update all playlist track tables
_update_all_playlist_track_tables(cursor)
conn.commit()
logger.info("Successfully updated all existing tables schema in playlists database")
except sqlite3.Error as e:
logger.error(f"Error updating existing tables schema: {e}", exc_info=True)
raise
def ensure_playlist_table_schema(playlist_spotify_id: str):
"""Ensures a specific playlist's track table has the latest schema."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
try:
with _get_playlists_db_connection() as conn:
cursor = conn.cursor()
# Check if table exists
cursor.execute(
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
)
if cursor.fetchone() is None:
logger.warning(f"Table {table_name} does not exist. Cannot update schema.")
return False
# Update schema
if _ensure_table_schema(
cursor,
table_name,
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({playlist_spotify_id})",
):
conn.commit()
logger.info(f"Updated schema for playlist track table: {table_name}")
return True
else:
logger.info(f"Schema already up-to-date for playlist track table: {table_name}")
return True
except sqlite3.Error as e:
logger.error(f"Error updating schema for playlist {playlist_spotify_id}: {e}", exc_info=True)
return False
def _create_playlist_tracks_table(playlist_spotify_id: str): def _create_playlist_tracks_table(playlist_spotify_id: str):
"""Creates or updates a table for a specific playlist to store its tracks in playlists.db.""" """Creates or updates a table for a specific playlist to store its tracks in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_').replace(' ', '_')}" # Sanitize table name table_name = f"playlist_{playlist_spotify_id.replace('-', '_').replace(' ', '_')}" # Sanitize table name
@@ -192,7 +279,8 @@ def _create_playlist_tracks_table(playlist_spotify_id: str):
added_at_playlist TEXT, -- When track was added to Spotify playlist added_at_playlist TEXT, -- When track was added to Spotify playlist
added_to_db INTEGER, -- Timestamp when track was added to this DB table added_to_db INTEGER, -- Timestamp when track was added to this DB table
is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist
last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist last_seen_in_spotify INTEGER, -- Timestamp when last confirmed in Spotify playlist
snapshot_id TEXT -- Track the snapshot_id when this track was added/updated
) )
""") """)
# Ensure schema # Ensure schema
@@ -218,6 +306,10 @@ def add_playlist_to_watch(playlist_data: dict):
"""Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db.""" """Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db."""
try: try:
_create_playlist_tracks_table(playlist_data["id"]) _create_playlist_tracks_table(playlist_data["id"])
# Construct Spotify URL manually since external_urls might not be present in metadata
spotify_url = f"https://open.spotify.com/playlist/{playlist_data['id']}"
with _get_playlists_db_connection() as conn: # Use playlists connection with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
@@ -234,7 +326,7 @@ def add_playlist_to_watch(playlist_data: dict):
"display_name", playlist_data["owner"]["id"] "display_name", playlist_data["owner"]["id"]
), ),
playlist_data["tracks"]["total"], playlist_data["tracks"]["total"],
playlist_data["external_urls"]["spotify"], spotify_url, # Use constructed URL instead of external_urls
playlist_data.get("snapshot_id"), playlist_data.get("snapshot_id"),
int(time.time()), int(time.time()),
int(time.time()), int(time.time()),
@@ -363,11 +455,91 @@ def get_playlist_track_ids_from_db(playlist_spotify_id: str):
return track_ids return track_ids
def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list): def get_playlist_tracks_with_snapshot_from_db(playlist_spotify_id: str):
"""Retrieves all tracks with their snapshot_ids from a specific playlist's tracks table in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
tracks_data = {}
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute(
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
)
if cursor.fetchone() is None:
logger.warning(
f"Track table {table_name} does not exist in {PLAYLISTS_DB_PATH}. Cannot fetch track data."
)
return tracks_data
# Ensure the table has the latest schema before querying
_ensure_table_schema(
cursor,
table_name,
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({playlist_spotify_id})",
)
cursor.execute(
f"SELECT spotify_track_id, snapshot_id, title FROM {table_name} WHERE is_present_in_spotify = 1"
)
rows = cursor.fetchall()
for row in rows:
tracks_data[row["spotify_track_id"]] = {
"snapshot_id": row["snapshot_id"],
"title": row["title"]
}
return tracks_data
except sqlite3.Error as e:
logger.error(
f"Error retrieving track data for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}",
exc_info=True,
)
return tracks_data
def get_playlist_total_tracks_from_db(playlist_spotify_id: str) -> int:
"""Retrieves the total number of tracks in the database for a specific playlist."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute(
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
)
if cursor.fetchone() is None:
return 0
# Ensure the table has the latest schema before querying
_ensure_table_schema(
cursor,
table_name,
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({playlist_spotify_id})",
)
cursor.execute(
f"SELECT COUNT(*) as count FROM {table_name} WHERE is_present_in_spotify = 1"
)
row = cursor.fetchone()
return row["count"] if row else 0
except sqlite3.Error as e:
logger.error(
f"Error retrieving track count for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}",
exc_info=True,
)
return 0
def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list, snapshot_id: str = None):
""" """
Updates existing tracks in the playlist's DB table to mark them as currently present Updates existing tracks in the playlist's DB table to mark them as currently present
in Spotify and updates their last_seen timestamp. Also refreshes metadata. in Spotify and updates their last_seen timestamp and snapshot_id. Also refreshes metadata.
Does NOT insert new tracks. New tracks are only added upon successful download. Does NOT insert new tracks. New tracks are only added upon successful download.
Args:
playlist_spotify_id: The Spotify playlist ID
tracks_data: List of track items from Spotify API
snapshot_id: The current snapshot_id for this playlist update
""" """
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
if not tracks_data: if not tracks_data:
@@ -398,23 +570,29 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
] ]
) )
# Extract track number from the track object
track_number = track.get("track_number")
# Log the raw track_number value for debugging
if track_number is None or track_number == 0:
logger.debug(f"Track '{track.get('name', 'Unknown')}' has track_number: {track_number} (raw API value)")
# Prepare tuple for UPDATE statement. # Prepare tuple for UPDATE statement.
# Order: title, artist_names, album_name, album_artist_names, track_number, # Order: title, artist_names, album_name, album_artist_names, track_number,
# album_spotify_id, duration_ms, added_at_playlist, # album_spotify_id, duration_ms, added_at_playlist,
# is_present_in_spotify, last_seen_in_spotify, spotify_track_id (for WHERE) # is_present_in_spotify, last_seen_in_spotify, snapshot_id, spotify_track_id (for WHERE)
tracks_to_update.append( tracks_to_update.append(
( (
track.get("name", "N/A"), track.get("name", "N/A"),
artist_names, artist_names,
track.get("album", {}).get("name", "N/A"), track.get("album", {}).get("name", "N/A"),
album_artist_names, album_artist_names,
track.get("track_number"), track_number, # Use the extracted track_number
track.get("album", {}).get("id"), track.get("album", {}).get("id"),
track.get("duration_ms"), track.get("duration_ms"),
track_item.get("added_at"), # From playlist item, update if changed track_item.get("added_at"), # From playlist item, update if changed
1, # is_present_in_spotify flag 1, # is_present_in_spotify flag
current_time, # last_seen_in_spotify timestamp current_time, # last_seen_in_spotify timestamp
# added_to_db is NOT updated here as this function only updates existing records. snapshot_id, # Update snapshot_id for this track
track["id"], # spotify_track_id for the WHERE clause track["id"], # spotify_track_id for the WHERE clause
) )
) )
@@ -446,7 +624,8 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
duration_ms = ?, duration_ms = ?,
added_at_playlist = ?, added_at_playlist = ?,
is_present_in_spotify = ?, is_present_in_spotify = ?,
last_seen_in_spotify = ? last_seen_in_spotify = ?,
snapshot_id = ?
WHERE spotify_track_id = ? WHERE spotify_track_id = ?
""", """,
tracks_to_update, tracks_to_update,
@@ -611,41 +790,94 @@ def remove_specific_tracks_from_playlist_table(
return 0 return 0
def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict): def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict, snapshot_id: str = None, task_id: str = None):
"""Adds or updates a single track in the specified playlist's tracks table in playlists.db.""" """
Adds or updates a single track in the specified playlist's tracks table in playlists.db.
Uses deezspot callback data as the source of metadata.
Args:
playlist_spotify_id: The Spotify playlist ID
track_item_for_db: Track item data (used only for spotify_track_id and added_at)
snapshot_id: The playlist snapshot ID
task_id: Task ID to extract metadata from callback data
"""
if not task_id:
logger.error(f"No task_id provided for playlist {playlist_spotify_id}. Task ID is required to extract metadata from deezspot callback.")
return
if not track_item_for_db or not track_item_for_db.get("track", {}).get("id"):
logger.error(f"No track_item_for_db or spotify track ID provided for playlist {playlist_spotify_id}")
return
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
track_detail = track_item_for_db.get("track")
if not track_detail or not track_detail.get("id"): # Extract metadata ONLY from deezspot callback data
logger.warning( try:
f"Skipping single track due to missing data for playlist {playlist_spotify_id}: {track_item_for_db}" # Import here to avoid circular imports
) from routes.utils.celery_tasks import get_last_task_status
last_status = get_last_task_status(task_id)
if not last_status or "raw_callback" not in last_status:
logger.error(f"No raw_callback found in task status for task {task_id}. Cannot extract metadata.")
return
callback_data = last_status["raw_callback"]
# Extract metadata from deezspot callback using correct structure from callbacks.ts
track_obj = callback_data.get("track", {})
if not track_obj:
logger.error(f"No track object found in callback data for task {task_id}")
return
track_name = track_obj.get("title", "N/A")
track_number = track_obj.get("track_number", 1) # Default to 1 if missing
duration_ms = track_obj.get("duration_ms", 0)
# Extract artist names from artists array
artists = track_obj.get("artists", [])
artist_names = ", ".join([artist.get("name", "") for artist in artists if artist.get("name")])
if not artist_names:
artist_names = "N/A"
# Extract album information
album_obj = track_obj.get("album", {})
album_name = album_obj.get("title", "N/A")
# Extract album artist names from album artists array
album_artists = album_obj.get("artists", [])
album_artist_names = ", ".join([artist.get("name", "") for artist in album_artists if artist.get("name")])
if not album_artist_names:
album_artist_names = "N/A"
logger.debug(f"Extracted metadata from deezspot callback for '{track_name}': track_number={track_number}")
except Exception as e:
logger.error(f"Error extracting metadata from task {task_id} callback: {e}", exc_info=True)
return return
current_time = int(time.time()) current_time = int(time.time())
artist_names = ", ".join(
[a["name"] for a in track_detail.get("artists", []) if a.get("name")] # Get spotify_track_id and added_at from original track_item_for_db
) track_id = track_item_for_db["track"]["id"]
album_artist_names = ", ".join( added_at = track_item_for_db.get("added_at")
[ album_id = track_item_for_db.get("track", {}).get("album", {}).get("id") # Only album ID from original data
a["name"]
for a in track_detail.get("album", {}).get("artists", []) logger.info(f"Adding track '{track_name}' (ID: {track_id}) to playlist {playlist_spotify_id} with track_number: {track_number} (from deezspot callback)")
if a.get("name")
]
)
track_data_tuple = ( track_data_tuple = (
track_detail["id"], track_id,
track_detail.get("name", "N/A"), track_name,
artist_names, artist_names,
track_detail.get("album", {}).get("name", "N/A"), album_name,
album_artist_names, album_artist_names,
track_detail.get("track_number"), track_number,
track_detail.get("album", {}).get("id"), album_id,
track_detail.get("duration_ms"), duration_ms,
track_item_for_db.get("added_at"), added_at,
current_time, current_time,
1, 1,
current_time, current_time,
snapshot_id,
) )
try: try:
with _get_playlists_db_connection() as conn: # Use playlists connection with _get_playlists_db_connection() as conn: # Use playlists connection
@@ -654,14 +886,14 @@ def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db:
cursor.execute( cursor.execute(
f""" f"""
INSERT OR REPLACE INTO {table_name} INSERT OR REPLACE INTO {table_name}
(spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify, snapshot_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
track_data_tuple, track_data_tuple,
) )
conn.commit() conn.commit()
logger.info( logger.info(
f"Track '{track_detail.get('name')}' added/updated in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}." f"Track '{track_name}' added/updated in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}."
) )
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error( logger.error(
@@ -1176,3 +1408,29 @@ def is_album_in_artist_db(artist_spotify_id: str, album_spotify_id: str) -> bool
exc_info=True, exc_info=True,
) )
return False # Assume not present on error return False # Assume not present on error
# --- Eager module initialization to ensure DBs and core tables exist ---
_initialized_on_import = False
def initialize_databases_eagerly() -> None:
"""Create DB directory and initialize core tables so they exist before any usage."""
global _initialized_on_import
if _initialized_on_import:
return
try:
# Ensure base directory exists
DB_DIR.mkdir(parents=True, exist_ok=True)
# Initialize core databases and tables
init_playlists_db()
init_artists_db()
_initialized_on_import = True
logger.info("Eagerly initialized watch databases and core tables.")
except Exception:
# Log and proceed; functions will attempt to (re)initialize as needed
logger.error("Failed to eagerly initialize watch databases.", exc_info=True)
# Invoke eager initialization at import time
initialize_databases_eagerly()

View File

@@ -2,6 +2,8 @@ import time
import threading import threading
import logging import logging
import json import json
import os
import re
from pathlib import Path from pathlib import Path
from typing import Any, List, Dict from typing import Any, List, Dict
@@ -9,9 +11,13 @@ from routes.utils.watch.db import (
get_watched_playlists, get_watched_playlists,
get_watched_playlist, get_watched_playlist,
get_playlist_track_ids_from_db, get_playlist_track_ids_from_db,
get_playlist_tracks_with_snapshot_from_db,
get_playlist_total_tracks_from_db,
add_tracks_to_playlist_db, add_tracks_to_playlist_db,
update_playlist_snapshot, update_playlist_snapshot,
mark_tracks_as_not_present_in_spotify, mark_tracks_as_not_present_in_spotify,
update_all_existing_tables_schema,
ensure_playlist_table_schema,
# Artist watch DB functions # Artist watch DB functions
get_watched_artists, get_watched_artists,
get_watched_artist, get_watched_artist,
@@ -20,6 +26,9 @@ from routes.utils.watch.db import (
) )
from routes.utils.get_info import ( from routes.utils.get_info import (
get_spotify_info, get_spotify_info,
get_playlist_metadata,
get_playlist_tracks,
check_playlist_updated,
) # To fetch playlist, track, artist, and album details ) # To fetch playlist, track, artist, and album details
from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_queue_manager import download_queue_manager
@@ -27,6 +36,16 @@ logger = logging.getLogger(__name__)
CONFIG_FILE_PATH = Path("./data/config/watch.json") CONFIG_FILE_PATH = Path("./data/config/watch.json")
STOP_EVENT = threading.Event() STOP_EVENT = threading.Event()
# Format mapping for audio file conversions
AUDIO_FORMAT_EXTENSIONS = {
'mp3': '.mp3',
'flac': '.flac',
'm4a': '.m4a',
'aac': '.m4a',
'ogg': '.ogg',
'wav': '.wav',
}
DEFAULT_WATCH_CONFIG = { DEFAULT_WATCH_CONFIG = {
"enabled": False, "enabled": False,
"watchPollIntervalSeconds": 3600, "watchPollIntervalSeconds": 3600,
@@ -34,6 +53,7 @@ DEFAULT_WATCH_CONFIG = {
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists "watchedArtistAlbumGroup": ["album", "single"], # Default for artists
"delay_between_playlists_seconds": 2, "delay_between_playlists_seconds": 2,
"delay_between_artists_seconds": 5, # Added for artists "delay_between_artists_seconds": 5, # Added for artists
"use_snapshot_id_checking": True, # Enable snapshot_id checking for efficiency
} }
@@ -82,6 +102,152 @@ def construct_spotify_url(item_id, item_type="track"):
return f"https://open.spotify.com/{item_type}/{item_id}" return f"https://open.spotify.com/{item_type}/{item_id}"
def has_playlist_changed(playlist_spotify_id: str, current_snapshot_id: str) -> bool:
"""
Check if a playlist has changed by comparing snapshot_id.
This is much more efficient than fetching all tracks.
Args:
playlist_spotify_id: The Spotify playlist ID
current_snapshot_id: The current snapshot_id from API
Returns:
True if playlist has changed, False otherwise
"""
try:
db_playlist = get_watched_playlist(playlist_spotify_id)
if not db_playlist:
# Playlist not in database, consider it as "changed" to trigger initial processing
return True
last_snapshot_id = db_playlist.get("snapshot_id")
if not last_snapshot_id:
# No previous snapshot_id, consider it as "changed" to trigger initial processing
return True
return current_snapshot_id != last_snapshot_id
except Exception as e:
logger.error(f"Error checking playlist change status for {playlist_spotify_id}: {e}")
# On error, assume playlist has changed to be safe
return True
def needs_track_sync(playlist_spotify_id: str, current_snapshot_id: str, api_total_tracks: int) -> tuple[bool, list[str]]:
"""
Check if tracks need to be synchronized by comparing snapshot_ids and total counts.
Args:
playlist_spotify_id: The Spotify playlist ID
current_snapshot_id: The current snapshot_id from API
api_total_tracks: The total number of tracks reported by API
Returns:
Tuple of (needs_sync, tracks_to_find) where:
- needs_sync: True if tracks need to be synchronized
- tracks_to_find: List of track IDs that need to be found in API response
"""
try:
# Get tracks from database with their snapshot_ids
db_tracks = get_playlist_tracks_with_snapshot_from_db(playlist_spotify_id)
db_total_tracks = get_playlist_total_tracks_from_db(playlist_spotify_id)
# Check if total count matches
if db_total_tracks != api_total_tracks:
logger.info(
f"Track count mismatch for playlist {playlist_spotify_id}: DB={db_total_tracks}, API={api_total_tracks}. Full sync needed to ensure all tracks are captured."
)
# Always do full sync when counts don't match to ensure we don't miss any tracks
# This handles cases like:
# - Empty database (DB=0, API=1345)
# - Missing tracks (DB=1000, API=1345)
# - Removed tracks (DB=1345, API=1000)
return True, [] # Empty list indicates full sync needed
# Check if any tracks have different snapshot_id
tracks_to_find = []
for track_id, track_data in db_tracks.items():
if track_data.get("snapshot_id") != current_snapshot_id:
tracks_to_find.append(track_id)
if tracks_to_find:
logger.info(
f"Found {len(tracks_to_find)} tracks with outdated snapshot_id for playlist {playlist_spotify_id}"
)
return True, tracks_to_find
return False, []
except Exception as e:
logger.error(f"Error checking track sync status for {playlist_spotify_id}: {e}")
# On error, assume sync is needed to be safe
return True, []
def find_tracks_in_playlist(playlist_spotify_id: str, tracks_to_find: list[str], current_snapshot_id: str) -> tuple[list, list]:
"""
Progressively fetch playlist tracks until all specified tracks are found or playlist is exhausted.
Args:
playlist_spotify_id: The Spotify playlist ID
tracks_to_find: List of track IDs to find
current_snapshot_id: The current snapshot_id
Returns:
Tuple of (found_tracks, not_found_tracks) where:
- found_tracks: List of track items that were found
- not_found_tracks: List of track IDs that were not found
"""
found_tracks = []
not_found_tracks = tracks_to_find.copy()
offset = 0
limit = 100
logger.info(
f"Searching for {len(tracks_to_find)} tracks in playlist {playlist_spotify_id} starting from offset {offset}"
)
while not_found_tracks and offset < 10000: # Safety limit
try:
tracks_batch = get_playlist_tracks(playlist_spotify_id, limit=limit, offset=offset)
if not tracks_batch or "items" not in tracks_batch:
logger.warning(f"No tracks returned for playlist {playlist_spotify_id} at offset {offset}")
break
batch_items = tracks_batch.get("items", [])
if not batch_items:
logger.info(f"No more tracks found at offset {offset}")
break
# Check each track in this batch
for track_item in batch_items:
track = track_item.get("track")
if track and track.get("id") and not track.get("is_local"):
track_id = track["id"]
if track_id in not_found_tracks:
found_tracks.append(track_item)
not_found_tracks.remove(track_id)
logger.debug(f"Found track {track_id} at offset {offset}")
offset += len(batch_items)
# Add small delay between batches
time.sleep(0.1)
except Exception as e:
logger.error(f"Error fetching tracks batch for playlist {playlist_spotify_id} at offset {offset}: {e}")
break
logger.info(
f"Track search complete for playlist {playlist_spotify_id}: "
f"Found {len(found_tracks)}/{len(tracks_to_find)} tracks, "
f"Not found: {len(not_found_tracks)}"
)
return found_tracks, not_found_tracks
def check_watched_playlists(specific_playlist_id: str = None): def check_watched_playlists(specific_playlist_id: str = None):
"""Checks watched playlists for new tracks and queues downloads. """Checks watched playlists for new tracks and queues downloads.
If specific_playlist_id is provided, only that playlist is checked. If specific_playlist_id is provided, only that playlist is checked.
@@ -90,6 +256,7 @@ def check_watched_playlists(specific_playlist_id: str = None):
f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}" f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}"
) )
config = get_watch_config() config = get_watch_config()
use_snapshot_checking = config.get("use_snapshot_id_checking", True)
if specific_playlist_id: if specific_playlist_id:
playlist_obj = get_watched_playlist(specific_playlist_id) playlist_obj = get_watched_playlist(specific_playlist_id)
@@ -114,56 +281,127 @@ def check_watched_playlists(specific_playlist_id: str = None):
) )
try: try:
# For playlists, we fetch all tracks in one go usually (Spotify API limit permitting) # Ensure the playlist's track table has the latest schema before processing
current_playlist_data_from_api = get_spotify_info( ensure_playlist_table_schema(playlist_spotify_id)
playlist_spotify_id, "playlist"
) # First, get playlist metadata to check if it has changed
if ( current_playlist_metadata = get_playlist_metadata(playlist_spotify_id)
not current_playlist_data_from_api if not current_playlist_metadata:
or "tracks" not in current_playlist_data_from_api
):
logger.error( logger.error(
f"Playlist Watch Manager: Failed to fetch data or tracks from Spotify for playlist {playlist_spotify_id}." f"Playlist Watch Manager: Failed to fetch metadata from Spotify for playlist {playlist_spotify_id}."
) )
continue continue
api_snapshot_id = current_playlist_data_from_api.get("snapshot_id") api_snapshot_id = current_playlist_metadata.get("snapshot_id")
api_total_tracks = current_playlist_data_from_api.get("tracks", {}).get( api_total_tracks = current_playlist_metadata.get("tracks", {}).get("total", 0)
"total", 0
) # Enhanced snapshot_id checking with track-level tracking
if use_snapshot_checking:
# First check if playlist snapshot_id has changed
playlist_changed = has_playlist_changed(playlist_spotify_id, api_snapshot_id)
if not playlist_changed:
# Even if playlist snapshot_id hasn't changed, check if individual tracks need sync
needs_sync, tracks_to_find = needs_track_sync(playlist_spotify_id, api_snapshot_id, api_total_tracks)
if not needs_sync:
logger.info(
f"Playlist Watch Manager: Playlist '{playlist_name}' ({playlist_spotify_id}) has not changed since last check (snapshot_id: {api_snapshot_id}). Skipping detailed check."
)
continue
else:
if not tracks_to_find:
# Empty tracks_to_find means full sync is needed (track count mismatch detected)
logger.info(
f"Playlist Watch Manager: Playlist '{playlist_name}' snapshot_id unchanged, but full sync needed due to track count mismatch. Proceeding with full check."
)
# Continue to full sync below
else:
logger.info(
f"Playlist Watch Manager: Playlist '{playlist_name}' snapshot_id unchanged, but {len(tracks_to_find)} tracks need sync. Proceeding with targeted check."
)
# Use targeted track search instead of full fetch
found_tracks, not_found_tracks = find_tracks_in_playlist(playlist_spotify_id, tracks_to_find, api_snapshot_id)
# Update found tracks with new snapshot_id
if found_tracks:
add_tracks_to_playlist_db(playlist_spotify_id, found_tracks, api_snapshot_id)
# Mark not found tracks as removed
if not_found_tracks:
logger.info(
f"Playlist Watch Manager: {len(not_found_tracks)} tracks not found in playlist '{playlist_name}'. Marking as removed."
)
mark_tracks_as_not_present_in_spotify(playlist_spotify_id, not_found_tracks)
# Update the playlist's m3u file after tracks are removed
try:
logger.info(
f"Updating m3u file for playlist '{playlist_name}' after removing {len(not_found_tracks)} tracks."
)
update_playlist_m3u_file(playlist_spotify_id)
except Exception as m3u_update_err:
logger.error(
f"Failed to update m3u file for playlist '{playlist_name}' after marking tracks as removed: {m3u_update_err}",
exc_info=True,
)
# Paginate through playlist tracks if necessary # Update playlist snapshot and continue to next playlist
update_playlist_snapshot(playlist_spotify_id, api_snapshot_id, api_total_tracks)
logger.info(
f"Playlist Watch Manager: Finished targeted sync for playlist '{playlist_name}'. Snapshot ID updated to {api_snapshot_id}."
)
continue
else:
logger.info(
f"Playlist Watch Manager: Playlist '{playlist_name}' has changed. New snapshot_id: {api_snapshot_id}. Proceeding with full check."
)
else:
logger.info(
f"Playlist Watch Manager: Snapshot checking disabled. Proceeding with full check for playlist '{playlist_name}'."
)
# Fetch all tracks using the optimized function
# This happens when:
# 1. Playlist snapshot_id has changed (full sync needed)
# 2. Snapshot checking is disabled (full sync always)
# 3. Database is empty but API has tracks (full sync needed)
logger.info(
f"Playlist Watch Manager: Fetching all tracks for playlist '{playlist_name}' ({playlist_spotify_id}) with {api_total_tracks} total tracks."
)
all_api_track_items = [] all_api_track_items = []
offset = 0 offset = 0
limit = 50 # Spotify API limit for playlist items limit = 100 # Use maximum batch size for efficiency
while offset < api_total_tracks:
try:
# Use the optimized get_playlist_tracks function
tracks_batch = get_playlist_tracks(
playlist_spotify_id, limit=limit, offset=offset
)
if not tracks_batch or "items" not in tracks_batch:
logger.warning(
f"Playlist Watch Manager: No tracks returned for playlist {playlist_spotify_id} at offset {offset}"
)
break
while True: batch_items = tracks_batch.get("items", [])
# Re-fetch with pagination if tracks.next is present, or on first call. if not batch_items:
# get_spotify_info for playlist should ideally handle pagination internally if asked for all tracks. break
# Assuming get_spotify_info for playlist returns all items or needs to be called iteratively.
# For simplicity, let's assume current_playlist_data_from_api has 'tracks' -> 'items' for the first page. all_api_track_items.extend(batch_items)
# And that get_spotify_info with 'playlist' type can take offset. offset += len(batch_items)
# Modifying get_spotify_info is outside current scope, so we'll assume it returns ALL items for a playlist.
# If it doesn't, this part would need adjustment for robust pagination. # Add small delay between batches to be respectful to API
# For now, we use the items from the initial fetch. if offset < api_total_tracks:
time.sleep(0.1)
paginated_playlist_data = get_spotify_info(
playlist_spotify_id, "playlist", offset=offset, limit=limit except Exception as e:
) logger.error(
if ( f"Playlist Watch Manager: Error fetching tracks batch for playlist {playlist_spotify_id} at offset {offset}: {e}"
not paginated_playlist_data )
or "tracks" not in paginated_playlist_data
):
break
page_items = paginated_playlist_data.get("tracks", {}).get("items", [])
if not page_items:
break
all_api_track_items.extend(page_items)
if paginated_playlist_data.get("tracks", {}).get("next"):
offset += limit
else:
break break
current_api_track_ids = set() current_api_track_ids = set()
@@ -237,14 +475,14 @@ def check_watched_playlists(specific_playlist_id: str = None):
# Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify') # Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify')
# add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries. # add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries.
# We should pass all current API tracks to ensure their `last_seen_in_spotify` and `is_present_in_spotify` are updated. # We should pass all current API tracks to ensure their `last_seen_in_spotify`, `is_present_in_spotify`, and `snapshot_id` are updated.
if ( if (
all_api_track_items all_api_track_items
): # If there are any tracks in the API for this playlist ): # If there are any tracks in the API for this playlist
logger.info( logger.info(
f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'." f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'."
) )
add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items) add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items, api_snapshot_id)
removed_db_ids = db_track_ids - current_api_track_ids removed_db_ids = db_track_ids - current_api_track_ids
if removed_db_ids: if removed_db_ids:
@@ -255,11 +493,24 @@ def check_watched_playlists(specific_playlist_id: str = None):
playlist_spotify_id, list(removed_db_ids) playlist_spotify_id, list(removed_db_ids)
) )
# Update the playlist's m3u file after any changes (new tracks queued or tracks removed)
if new_track_ids_for_download or removed_db_ids:
try:
logger.info(
f"Updating m3u file for playlist '{playlist_name}' after playlist changes."
)
update_playlist_m3u_file(playlist_spotify_id)
except Exception as m3u_update_err:
logger.error(
f"Failed to update m3u file for playlist '{playlist_name}' after playlist changes: {m3u_update_err}",
exc_info=True,
)
update_playlist_snapshot( update_playlist_snapshot(
playlist_spotify_id, api_snapshot_id, api_total_tracks playlist_spotify_id, api_snapshot_id, api_total_tracks
) # api_total_tracks from initial fetch ) # api_total_tracks from initial fetch
logger.info( logger.info(
f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated. API Total Tracks: {api_total_tracks}." f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated to {api_snapshot_id}. API Total Tracks: {api_total_tracks}. Queued {queued_for_download_count} new tracks."
) )
except Exception as e: except Exception as e:
@@ -309,17 +560,16 @@ def check_watched_artists(specific_artist_id: str = None):
) )
try: try:
# Spotify API for artist albums is paginated. # Use the optimized artist discography function with pagination
# We need to fetch all albums. get_spotify_info with type 'artist-albums' should handle this.
# Let's assume get_spotify_info(artist_id, 'artist-albums') returns a list of all album objects.
# Or we implement pagination here.
all_artist_albums_from_api: List[Dict[str, Any]] = [] all_artist_albums_from_api: List[Dict[str, Any]] = []
offset = 0 offset = 0
limit = 50 # Spotify API limit for artist albums limit = 50 # Spotify API limit for artist albums
logger.info(
f"Artist Watch Manager: Fetching albums for artist '{artist_name}' ({artist_spotify_id})"
)
while True: while True:
# The 'artist-albums' type for get_spotify_info needs to support pagination params.
# And return a list of album objects.
logger.debug( logger.debug(
f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}" f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}"
) )
@@ -560,6 +810,13 @@ def start_watch_manager(): # Renamed from start_playlist_watch_manager
init_playlists_db() # For playlists init_playlists_db() # For playlists
init_artists_db() # For artists init_artists_db() # For artists
# Update all existing tables to ensure they have the latest schema
try:
update_all_existing_tables_schema()
logger.info("Watch Manager: Successfully updated all existing tables schema")
except Exception as e:
logger.error(f"Watch Manager: Error updating existing tables schema: {e}", exc_info=True)
_watch_scheduler_thread = threading.Thread( _watch_scheduler_thread = threading.Thread(
target=playlist_watch_scheduler, daemon=True target=playlist_watch_scheduler, daemon=True
@@ -587,5 +844,239 @@ def stop_watch_manager(): # Renamed from stop_playlist_watch_manager
logger.info("Watch Manager: Background scheduler not running.") logger.info("Watch Manager: Background scheduler not running.")
# If this module is imported, and you want to auto-start the manager, you could call start_watch_manager() here. def get_playlist_tracks_for_m3u(playlist_spotify_id: str) -> List[Dict[str, Any]]:
# However, it's usually better to explicitly start it from the main application/__init__.py. """
Get all tracks for a playlist from the database with complete metadata needed for m3u generation.
Args:
playlist_spotify_id: The Spotify playlist ID
Returns:
List of track dictionaries with metadata
"""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
tracks = []
try:
from routes.utils.watch.db import _get_playlists_db_connection, _ensure_table_schema, EXPECTED_PLAYLIST_TRACKS_COLUMNS
with _get_playlists_db_connection() as conn:
cursor = conn.cursor()
# Check if table exists
cursor.execute(
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
)
if cursor.fetchone() is None:
logger.warning(
f"Track table {table_name} does not exist. Cannot generate m3u file."
)
return tracks
# Ensure the table has the latest schema before querying
_ensure_table_schema(
cursor,
table_name,
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
f"playlist tracks ({playlist_spotify_id})",
)
# Get all tracks that are present in Spotify
cursor.execute(f"""
SELECT spotify_track_id, title, artist_names, album_name,
album_artist_names, track_number, duration_ms
FROM {table_name}
WHERE is_present_in_spotify = 1
ORDER BY track_number, title
""")
rows = cursor.fetchall()
for row in rows:
tracks.append({
"spotify_track_id": row["spotify_track_id"],
"title": row["title"] or "Unknown Track",
"artist_names": row["artist_names"] or "Unknown Artist",
"album_name": row["album_name"] or "Unknown Album",
"album_artist_names": row["album_artist_names"] or "Unknown Artist",
"track_number": row["track_number"] or 0,
"duration_ms": row["duration_ms"] or 0,
})
return tracks
except Exception as e:
logger.error(
f"Error retrieving tracks for m3u generation for playlist {playlist_spotify_id}: {e}",
exc_info=True,
)
return tracks
def generate_track_file_path(track: Dict[str, Any], custom_dir_format: str, custom_track_format: str, convert_to: str = None) -> str:
"""
Generate the file path for a track based on custom format strings.
This mimics the path generation logic used by the deezspot library.
Args:
track: Track metadata dictionary
custom_dir_format: Directory format string (e.g., "%ar_album%/%album%")
custom_track_format: Track format string (e.g., "%tracknum%. %music% - %artist%")
convert_to: Target conversion format (e.g., "mp3", "flac", "m4a")
Returns:
Generated file path relative to output directory
"""
try:
# Extract metadata
artist_names = track.get("artist_names", "Unknown Artist")
album_name = track.get("album_name", "Unknown Album")
album_artist_names = track.get("album_artist_names", "Unknown Artist")
title = track.get("title", "Unknown Track")
track_number = track.get("track_number", 0)
duration_ms = track.get("duration_ms", 0)
# Use album artist for directory structure, main artist for track name
main_artist = artist_names.split(", ")[0] if artist_names else "Unknown Artist"
album_artist = album_artist_names.split(", ")[0] if album_artist_names else main_artist
# Clean names for filesystem
def clean_name(name):
# Remove or replace characters that are problematic in filenames
name = re.sub(r'[<>:"/\\|?*]', '_', str(name))
name = re.sub(r'[\x00-\x1f]', '', name) # Remove control characters
return name.strip()
clean_album_artist = clean_name(album_artist)
clean_album = clean_name(album_name)
clean_main_artist = clean_name(main_artist)
clean_title = clean_name(title)
# Prepare placeholder replacements
replacements = {
# Common placeholders
"%music%": clean_title,
"%artist%": clean_main_artist,
"%album%": clean_album,
"%ar_album%": clean_album_artist,
"%tracknum%": f"{track_number:02d}" if track_number > 0 else "00",
"%year%": "", # Not available in current DB schema
# Additional placeholders (not available in current DB schema, using defaults)
"%discnum%": "01", # Default to disc 1
"%date%": "", # Not available
"%genre%": "", # Not available
"%isrc%": "", # Not available
"%explicit%": "", # Not available
"%duration%": str(duration_ms // 1000) if duration_ms > 0 else "0", # Convert ms to seconds
}
# Apply replacements to directory format
dir_path = custom_dir_format
for placeholder, value in replacements.items():
dir_path = dir_path.replace(placeholder, value)
# Apply replacements to track format
track_filename = custom_track_format
for placeholder, value in replacements.items():
track_filename = track_filename.replace(placeholder, value)
# Combine and clean up path
full_path = os.path.join(dir_path, track_filename)
full_path = os.path.normpath(full_path)
# Determine file extension based on convert_to setting or default to mp3
if not any(full_path.lower().endswith(ext) for ext in ['.mp3', '.flac', '.m4a', '.ogg', '.wav']):
if convert_to:
extension = AUDIO_FORMAT_EXTENSIONS.get(convert_to.lower(), '.mp3')
full_path += extension
else:
full_path += '.mp3' # Default fallback
return full_path
except Exception as e:
logger.error(f"Error generating file path for track {track.get('title', 'Unknown')}: {e}")
# Return a fallback path with appropriate extension
safe_title = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', str(track.get('title', 'Unknown Track')))
# Determine extension for fallback
if convert_to:
extension = AUDIO_FORMAT_EXTENSIONS.get(convert_to.lower(), '.mp3')
else:
extension = '.mp3'
return f"Unknown Artist/Unknown Album/{safe_title}{extension}"
def update_playlist_m3u_file(playlist_spotify_id: str):
"""
Generate/update the m3u file for a watched playlist based on tracks in the database.
Args:
playlist_spotify_id: The Spotify playlist ID
"""
try:
# Get playlist metadata
playlist_info = get_watched_playlist(playlist_spotify_id)
if not playlist_info:
logger.warning(f"Playlist {playlist_spotify_id} not found in watched playlists. Cannot update m3u file.")
return
playlist_name = playlist_info.get("name", "Unknown Playlist")
# Get configuration settings
from routes.utils.celery_config import get_config_params
config = get_config_params()
custom_dir_format = config.get("customDirFormat", "%ar_album%/%album%")
custom_track_format = config.get("customTrackFormat", "%tracknum%. %music%")
convert_to = config.get("convertTo") # Get conversion format setting
output_dir = "./downloads" # This matches the output_dir used in download functions
# Get all tracks for the playlist
tracks = get_playlist_tracks_for_m3u(playlist_spotify_id)
if not tracks:
logger.info(f"No tracks found for playlist '{playlist_name}'. M3U file will be empty or removed.")
# Clean playlist name for filename
safe_playlist_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', playlist_name).strip()
# Create m3u file path
playlists_dir = Path(output_dir) / "playlists"
playlists_dir.mkdir(parents=True, exist_ok=True)
m3u_file_path = playlists_dir / f"{safe_playlist_name}.m3u"
# Generate m3u content
m3u_lines = ["#EXTM3U"]
for track in tracks:
# Generate file path for this track
track_file_path = generate_track_file_path(track, custom_dir_format, custom_track_format, convert_to)
# Create relative path from m3u file location to track file
# M3U file is in ./downloads/playlists/
# Track files are in ./downloads/{custom_dir_format}/
relative_path = os.path.join("..", track_file_path)
relative_path = relative_path.replace("\\", "/") # Use forward slashes for m3u compatibility
# Add EXTINF line with track duration and title
duration_seconds = (track.get("duration_ms", 0) // 1000) if track.get("duration_ms") else -1
artist_and_title = f"{track.get('artist_names', 'Unknown Artist')} - {track.get('title', 'Unknown Track')}"
m3u_lines.append(f"#EXTINF:{duration_seconds},{artist_and_title}")
m3u_lines.append(relative_path)
# Write m3u file
with open(m3u_file_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(m3u_lines))
logger.info(
f"Updated m3u file for playlist '{playlist_name}' at {m3u_file_path} with {len(tracks)} tracks{f' (format: {convert_to})' if convert_to else ''}."
)
except Exception as e:
logger.error(
f"Error updating m3u file for playlist {playlist_spotify_id}: {e}",
exc_info=True,
)

View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
dev-dist
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -2,9 +2,38 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Theme-dependent favicons with rounded corners -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- Light theme favicons (black background, white logo) -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" media="(prefers-color-scheme: light)" />
<!-- Dark theme favicons (currently same as light - you could create light background versions) -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" media="(prefers-color-scheme: dark)" />
<!-- Fallback for browsers that don't support media queries -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotizerr</title> <title>Spotizerr</title>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#1e293b" />
<meta name="description" content="Music downloader and manager for Spotify content" />
<!-- iOS specific -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Spotizerr" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<!-- Windows specific -->
<meta name="msapplication-TileColor" content="#0f172a" />
<meta name="msapplication-TileImage" content="/pwa-512x512.png" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,14 +1,15 @@
{ {
"name": "spotizerr-ui", "name": "spotizerr-ui",
"private": true, "private": true,
"version": "0.0.0", "version": "3.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"preview": "vite preview" "preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
@@ -42,9 +43,12 @@
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sharp": "^0.34.3",
"to-ico": "^1.1.5",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5" "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.2"
}, },
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- sharp

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0557 3.59974C12.2752 3.2813 12.2913 2.86484 12.0972 2.53033C11.9031 2.19582 11.5335 2.00324 11.1481 2.03579C6.02351 2.46868 2 6.76392 2 12C2 17.5228 6.47715 22 12 22C17.236 22 21.5313 17.9764 21.9642 12.8518C21.9967 12.4664 21.8041 12.0968 21.4696 11.9027C21.1351 11.7086 20.7187 11.7248 20.4002 11.9443C19.4341 12.6102 18.2641 13 17 13C13.6863 13 11 10.3137 11 6.99996C11 5.73589 11.3898 4.56587 12.0557 3.59974Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
spotizerr-ui/public/favicon.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 64 64" enable-background="new 0 0 64 64" xml:space="preserve">
<g>
<circle fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" cx="32.003" cy="32.005" r="16.001"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M12.001,31.997c0-2.211-1.789-4-4-4H4c-2.211,0-4,1.789-4,4
s1.789,4,4,4h4C10.212,35.997,12.001,34.208,12.001,31.997z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M12.204,46.139l-2.832,2.833c-1.563,1.562-1.563,4.094,0,5.656
c1.562,1.562,4.094,1.562,5.657,0l2.833-2.832c1.562-1.562,1.562-4.095,0-5.657C16.298,44.576,13.767,44.576,12.204,46.139z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M32.003,51.999c-2.211,0-4,1.789-4,4V60c0,2.211,1.789,4,4,4
s4-1.789,4-4l-0.004-4.001C36.003,53.788,34.21,51.999,32.003,51.999z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M51.798,46.143c-1.559-1.566-4.091-1.566-5.653-0.004
s-1.562,4.095,0,5.657l2.829,2.828c1.562,1.57,4.094,1.562,5.656,0s1.566-4.09,0-5.656L51.798,46.143z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M60.006,27.997l-4.009,0.008
c-2.203-0.008-3.992,1.781-3.992,3.992c-0.008,2.211,1.789,4,3.992,4h4.001c2.219,0.008,4-1.789,4-4
C64.002,29.79,62.217,27.997,60.006,27.997z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M51.798,17.859l2.828-2.829c1.574-1.566,1.562-4.094,0-5.657
c-1.559-1.567-4.09-1.567-5.652-0.004l-2.829,2.836c-1.562,1.555-1.562,4.086,0,5.649C47.699,19.426,50.239,19.418,51.798,17.859z"
/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M32.003,11.995c2.207,0.016,4-1.789,4-3.992v-4
c0-2.219-1.789-4-4-4c-2.211-0.008-4,1.781-4,3.993l0.008,4.008C28.003,10.206,29.792,11.995,32.003,11.995z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M12.212,17.855c1.555,1.562,4.079,1.562,5.646-0.004
c1.574-1.551,1.566-4.09,0.008-5.649l-2.829-2.828c-1.57-1.571-4.094-1.559-5.657,0c-1.575,1.559-1.575,4.09-0.012,5.653
L12.212,17.855z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="282px" height="145px" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" xmlns:xlink="http://www.w3.org/1999/xlink">
<g><path style="opacity:0.952" fill="#fefffe" d="M -0.5,-0.5 C 75.5,-0.5 151.5,-0.5 227.5,-0.5C 257.391,6.72372 275.391,25.3904 281.5,55.5C 281.5,66.5 281.5,77.5 281.5,88.5C 275.391,118.61 257.391,137.276 227.5,144.5C 165.833,144.5 104.167,144.5 42.5,144.5C 24.4585,136.734 18.6252,123.401 25,104.5C 27.0322,100.592 29.8655,97.425 33.5,95C 22.8385,86.0099 19.6719,74.8432 24,61.5C 25.8865,57.5598 28.3865,54.0598 31.5,51C 14.0385,43.1972 3.37183,30.0305 -0.5,11.5C -0.5,7.5 -0.5,3.5 -0.5,-0.5 Z M 20.5,9.5 C 50.3305,8.33612 80.3305,8.16945 110.5,9C 104.07,13.4286 99.0699,19.0952 95.5,26C 77.4613,26.9158 59.4613,26.5825 41.5,25C 32.5985,22.246 25.5985,17.0793 20.5,9.5 Z M 137.5,8.5 C 151.833,8.5 166.167,8.5 180.5,8.5C 180.667,24.8367 180.5,41.1701 180,57.5C 172.704,80.7861 156.87,91.2861 132.5,89C 105.769,80.0414 95.936,61.8748 103,34.5C 109.798,19.5282 121.298,10.8615 137.5,8.5 Z M 190.5,8.5 C 200.172,8.33353 209.839,8.5002 219.5,9C 242.508,11.4265 258.675,23.2598 268,44.5C 270.902,53.43 272.069,62.5966 271.5,72C 273.122,100.781 260.788,120.781 234.5,132C 230.584,133.451 226.584,134.118 222.5,134C 233.428,117.354 234.261,100.187 225,82.5C 217.133,70.3038 205.966,63.4704 191.5,62C 190.572,61.6121 189.905,60.9454 189.5,60C 190.479,42.8682 190.813,25.7016 190.5,8.5 Z M 44.5,54.5 C 59.8333,54.5 75.1667,54.5 90.5,54.5C 91.0085,60.3713 92.6752,65.8713 95.5,71C 81.5,71.6667 67.5,71.6667 53.5,71C 46.222,68.7388 42.3887,63.9054 42,56.5C 42.9947,55.9341 43.828,55.2674 44.5,54.5 Z M 46.5,99.5 C 76.1667,99.5 105.833,99.5 135.5,99.5C 135.193,105.093 135.527,110.593 136.5,116C 108.5,116.667 80.5,116.667 52.5,116C 45.6274,113.34 42.294,108.507 42.5,101.5C 44.0255,101.006 45.3588,100.339 46.5,99.5 Z M 154.5,99.5 C 166.171,99.3335 177.838,99.5001 189.5,100C 205.152,104.125 212.318,114.291 211,130.5C 207.498,123 201.664,118.5 193.5,117C 185.167,116.667 176.833,116.333 168.5,116C 160.775,113.108 156.109,107.608 154.5,99.5 Z"/></g>
<g><path style="opacity:0.958" fill="#fefffe" d="M 136.5,21.5 C 155.343,21.178 166.01,30.3446 168.5,49C 165.558,69.2725 153.891,78.2725 133.5,76C 113.904,67.8912 108.404,54.0579 117,34.5C 121.815,27.5177 128.315,23.1843 136.5,21.5 Z"/></g>
<g><path style="opacity:0.923" fill="#fefffe" d="M 216.5,21.5 C 228.111,21.1253 234.778,26.6253 236.5,38C 235.667,48.1667 230.167,53.6667 220,54.5C 209.826,53.6598 204.326,48.1598 203.5,38C 204.011,29.3381 208.344,23.8381 216.5,21.5 Z"/></g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 6C2 4.34315 3.34315 3 5 3H19C20.6569 3 22 4.34315 22 6V15C22 16.6569 20.6569 18 19 18H13V19H15C15.5523 19 16 19.4477 16 20C16 20.5523 15.5523 21 15 21H9C8.44772 21 8 20.5523 8 20C8 19.4477 8.44772 19 9 19H11V18H5C3.34315 18 2 16.6569 2 15V6ZM5 5C4.44772 5 4 5.44772 4 6V15C4 15.5523 4.44772 16 5 16H19C19.5523 16 20 15.5523 20 15V6C20 5.44772 19.5523 5 19 5H5Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

View File

@@ -0,0 +1,329 @@
import sharp from 'sharp';
import { existsSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import toIco from 'to-ico';
// Helper function to create a rounded square mask
async function createRoundedSquareMask(size, radius) {
const svg = `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="white"/>
</svg>
`;
return Buffer.from(svg);
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const publicDir = join(__dirname, '../public');
const svgPath = join(publicDir, 'spotizerr.svg');
async function generateIcons() {
try {
// Check if the SVG file exists
if (!existsSync(svgPath)) {
throw new Error(`SVG file not found at ${svgPath}. Please ensure spotizerr.svg exists in the public directory.`);
}
console.log('🎨 Generating PWA icons from SVG...');
// First, convert SVG to PNG and process it
console.log('📐 Processing SVG: converting to PNG, scaling by 0.67, and centering in black box...');
// Create a base canvas size for processing
const baseCanvasSize = 1000;
const scaleFactor = 3;
// Convert SVG to PNG and get its dimensions
const svgToPng = await sharp(svgPath)
.png()
.toBuffer();
const svgMetadata = await sharp(svgToPng).metadata();
const svgWidth = svgMetadata.width;
const svgHeight = svgMetadata.height;
// Calculate scaled dimensions
const scaledWidth = Math.round(svgWidth * scaleFactor);
const scaledHeight = Math.round(svgHeight * scaleFactor);
// Calculate centering offsets
const offsetX = Math.round((baseCanvasSize - scaledWidth) / 2);
const offsetY = Math.round((baseCanvasSize - scaledHeight) / 2);
// Create the processed base image: scale SVG and center in black box
const processedImage = await sharp({
create: {
width: baseCanvasSize,
height: baseCanvasSize,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sharp(svgToPng)
.resize(scaledWidth, scaledHeight)
.png()
.toBuffer(),
top: offsetY,
left: offsetX
}])
.png()
.toBuffer();
console.log(`✅ Processed SVG: ${svgWidth}x${svgHeight} → scaled to ${scaledWidth}x${scaledHeight} → centered in ${baseCanvasSize}x${baseCanvasSize} black box`);
// Save the processed base image for reference
await sharp(processedImage)
.png()
.toFile(join(publicDir, 'spotizerr.png'));
console.log(`✅ Saved processed base image as spotizerr.png (${baseCanvasSize}x${baseCanvasSize})`);
const sourceSize = baseCanvasSize;
// Define icon configurations
const iconConfigs = [
{
size: 16,
name: 'favicon-16x16.png',
padding: 0.1, // 10% padding for small icons
rounded: true,
cornerRadius: 3, // 3px radius for small icons
},
{
size: 32,
name: 'favicon-32x32.png',
padding: 0.1,
rounded: true,
cornerRadius: 6, // 6px radius
},
{
size: 180,
name: 'apple-touch-icon-180x180.png',
padding: 0.05, // 5% padding for Apple (they prefer less padding)
rounded: true,
cornerRadius: 32, // ~18% radius for Apple icons
},
{
size: 192,
name: 'pwa-192x192.png',
padding: 0.1,
rounded: true,
cornerRadius: 34, // ~18% radius
},
{
size: 512,
name: 'pwa-512x512.png',
padding: 0.1,
rounded: true,
cornerRadius: 92, // ~18% radius
}
];
// Use the processed image as source
const sourceImage = sharp(processedImage);
for (const config of iconConfigs) {
const { size, name, padding, rounded, cornerRadius } = config;
let finalIcon;
if (padding > 0) {
// Create icon with padding by compositing on a background
const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2);
// Create a pure black background and composite the resized logo on top
finalIcon = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toBuffer();
} else {
// Direct resize without padding
finalIcon = await sourceImage
.resize(size, size)
.png()
.toBuffer();
}
// Apply rounded corners if specified
if (rounded && cornerRadius > 0) {
const mask = await createRoundedSquareMask(size, cornerRadius);
finalIcon = await sharp(finalIcon)
.composite([{
input: mask,
blend: 'dest-in'
}])
.png()
.toBuffer();
}
// Write the final icon to file
await sharp(finalIcon).toFile(join(publicDir, name));
const roundedText = rounded ? ` - rounded (${cornerRadius}px)` : '';
console.log(`✅ Generated ${name} (${size}x${size}) - padding: ${padding * 100}%${roundedText}`);
}
// Create maskable icon (less padding, solid background, rounded)
const maskableSize = 512;
const maskablePadding = 0.05; // 5% padding for maskable icons
const maskableRadius = 92; // ~18% radius for consistency
const maskablePaddedSize = Math.round(maskableSize * (1 - maskablePadding * 2));
const maskableOffset = Math.round((maskableSize - maskablePaddedSize) / 2);
let maskableIcon = await sharp({
create: {
width: maskableSize,
height: maskableSize,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background for maskable
}
})
.composite([{
input: await sourceImage.resize(maskablePaddedSize, maskablePaddedSize).png().toBuffer(),
top: maskableOffset,
left: maskableOffset
}])
.png()
.toBuffer();
// Apply rounded corners to maskable icon
const maskableMask = await createRoundedSquareMask(maskableSize, maskableRadius);
maskableIcon = await sharp(maskableIcon)
.composite([{
input: maskableMask,
blend: 'dest-in'
}])
.png()
.toBuffer();
await sharp(maskableIcon).toFile(join(publicDir, 'pwa-512x512-maskable.png'));
console.log(`✅ Generated pwa-512x512-maskable.png (${maskableSize}x${maskableSize}) - maskable, rounded (${maskableRadius}px)`);
// Generate additional favicon sizes for ICO compatibility (with rounded corners)
const additionalSizes = [48, 64, 96, 128, 256];
for (const size of additionalSizes) {
const padding = size <= 48 ? 0.05 : 0.1;
const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2);
// Calculate corner radius proportional to size (~18% for larger icons, smaller for tiny ones)
const cornerRadius = size <= 48 ? Math.round(size * 0.125) : Math.round(size * 0.18);
let additionalIcon = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toBuffer();
// Apply rounded corners
const additionalMask = await createRoundedSquareMask(size, cornerRadius);
additionalIcon = await sharp(additionalIcon)
.composite([{
input: additionalMask,
blend: 'dest-in'
}])
.png()
.toBuffer();
await sharp(additionalIcon).toFile(join(publicDir, `favicon-${size}x${size}.png`));
console.log(`✅ Generated favicon-${size}x${size}.png (${size}x${size}) - padding: ${padding * 100}%, rounded (${cornerRadius}px)`);
}
// Generate favicon.ico with multiple sizes (rounded)
console.log('🎯 Generating favicon.ico with rounded corners...');
const icoSizes = [16, 32, 48];
const icoBuffers = [];
for (const size of icoSizes) {
const padding = 0.1; // 10% padding for ICO
const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2);
const cornerRadius = size <= 32 ? Math.round(size * 0.125) : Math.round(size * 0.18);
let icoIcon = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toBuffer();
// Apply rounded corners to ICO icon
const icoMask = await createRoundedSquareMask(size, cornerRadius);
const roundedIcoIcon = await sharp(icoIcon)
.composite([{
input: icoMask,
blend: 'dest-in'
}])
.png()
.toBuffer();
icoBuffers.push(roundedIcoIcon);
}
// Create the ICO file
const icoBuffer = await toIco(icoBuffers);
writeFileSync(join(publicDir, 'favicon.ico'), icoBuffer);
console.log(`✅ Generated favicon.ico (${icoSizes.join('x, ')}x sizes) - multi-size ICO, rounded corners`);
console.log('🎉 All PWA icons generated successfully with rounded corners!');
console.log('');
console.log('📋 Generated files:');
iconConfigs.forEach(config => {
console.log(`${config.name} (${config.size}x${config.size}) - rounded`);
});
console.log(' • pwa-512x512-maskable.png (512x512) - rounded');
additionalSizes.forEach(size => {
console.log(` • favicon-${size}x${size}.png (${size}x${size}) - rounded`);
});
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48) - rounded');
console.log('');
console.log('💡 Icons generated from SVG source, scaled by 0.67, and centered on pure black backgrounds.');
console.log('💡 All icons feature rounded corners for a modern, polished appearance.');
console.log('💡 Corner radius scales proportionally with icon size (~18% for larger icons, ~12.5% for smaller ones).');
console.log('💡 favicon.ico contains multiple sizes for optimal browser compatibility.');
} catch (error) {
console.error('❌ Error generating PWA icons:', error);
process.exit(1);
}
}
generateIcons();

View File

@@ -11,7 +11,7 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
const subtitle = album.artists.map((artist) => artist.name).join(", "); const subtitle = album.artists.map((artist) => artist.name).join(", ");
return ( return (
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transition-all duration-300 ease-in-out hover:-translate-y-1 hover:scale-105"> <div className="group flex flex-col rounded-lg overflow-hidden bg-surface dark:bg-surface-secondary-dark hover:bg-surface-secondary dark:hover:bg-surface-muted-dark shadow-xl hover:shadow-2xl transition-all duration-300 ease-in-out hover:-translate-y-1 hover:scale-105">
<div className="relative"> <div className="relative">
<Link to="/album/$albumId" params={{ albumId: album.id }}> <Link to="/album/$albumId" params={{ albumId: album.id }}>
<img src={imageUrl} alt={album.name} className="w-full aspect-square object-cover" /> <img src={imageUrl} alt={album.name} className="w-full aspect-square object-cover" />
@@ -21,10 +21,10 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
e.preventDefault(); e.preventDefault();
onDownload(); onDownload();
}} }}
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300" className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
title="Download album" title="Download album"
> >
<img src="/download.svg" alt="Download" className="w-5 h-5" /> <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
</button> </button>
)} )}
</Link> </Link>
@@ -33,11 +33,11 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
<Link <Link
to="/album/$albumId" to="/album/$albumId"
params={{ albumId: album.id }} params={{ albumId: album.id }}
className="font-semibold text-gray-900 dark:text-white truncate block" className="font-semibold text-content-primary dark:text-content-primary-dark truncate block"
> >
{album.name} {album.name}
</Link> </Link>
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>} {subtitle && <p className="text-sm text-content-secondary dark:text-content-secondary-dark mt-1 truncate">{subtitle}</p>}
</div> </div>
</div> </div>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -24,24 +24,26 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
}; };
return ( return (
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transition-shadow duration-300 ease-in-out"> <div className="group flex flex-col rounded-lg overflow-hidden bg-surface dark:bg-surface-secondary-dark hover:bg-surface-secondary dark:hover:bg-surface-muted-dark shadow-xl hover:shadow-2xl transition-shadow duration-300 ease-in-out">
<div className="relative"> <div className="relative">
<img src={imageUrl || "/placeholder.jpg"} alt={name} className="w-full aspect-square object-cover" /> <Link to={getLinkPath()} className="block">
<img src={imageUrl || "/placeholder.jpg"} alt={name} className="w-full aspect-square object-cover hover:scale-105 transition-transform duration-300" />
</Link>
{onDownload && ( {onDownload && (
<button <button
onClick={onDownload} onClick={onDownload}
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300" className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 z-10"
title={`Download ${type}`} title={`Download ${type}`}
> >
<img src="/download.svg" alt="Download" className="w-5 h-5" /> <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
</button> </button>
)} )}
</div> </div>
<div className="p-4 flex-grow flex flex-col"> <div className="p-4 flex-grow flex flex-col">
<Link to={getLinkPath()} className="font-semibold text-gray-900 dark:text-white truncate block"> <Link to={getLinkPath()} className="font-semibold text-content-primary dark:text-content-primary-dark truncate block">
{name} {name}
</Link> </Link>
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>} {subtitle && <p className="text-sm text-content-secondary dark:text-content-secondary-dark mt-1 truncate">{subtitle}</p>}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,420 @@
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
import { toast } from "sonner";
import type { LoginRequest, RegisterRequest, AuthError } from "@/types/auth";
interface LoginScreenProps {
onSuccess?: () => void;
}
export function LoginScreen({ onSuccess }: LoginScreenProps) {
const { login, register, isLoading, authEnabled, registrationEnabled, isRemembered, ssoEnabled, ssoProviders } = useAuth();
const [isLoginMode, setIsLoginMode] = useState(true);
const [formData, setFormData] = useState({
username: "",
password: "",
email: "",
confirmPassword: "",
rememberMe: true, // Default to true for better UX
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [ssoRegistrationError, setSSORegistrationError] = useState(false);
// Initialize remember me checkbox with stored preference
useEffect(() => {
setFormData(prev => ({
...prev,
rememberMe: isRemembered(),
}));
}, [isRemembered]);
// Force login mode if registration is disabled
useEffect(() => {
if (!registrationEnabled && !isLoginMode) {
setIsLoginMode(true);
setErrors({});
}
}, [registrationEnabled, isLoginMode]);
// Handle URL parameters (e.g., SSO errors)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const errorParam = urlParams.get('error');
if (errorParam) {
const decodedError = decodeURIComponent(errorParam);
// Check if this is specifically a registration disabled error from SSO
if (decodedError.includes("Registration is disabled")) {
setSSORegistrationError(true);
}
// Show the error message
toast.error("Authentication Error", {
description: decodedError,
duration: 5000, // Show for 5 seconds
});
// Clean up the URL parameter
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('error');
window.history.replaceState({}, '', newUrl.toString());
}
}, []); // Run only once on component mount
// If auth is not enabled, don't show the login screen
if (!authEnabled) {
return null;
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Username validation
if (!formData.username.trim()) {
newErrors.username = "Username is required";
} else if (formData.username.length < 3) {
newErrors.username = "Username must be at least 3 characters";
}
// Password validation
if (!formData.password) {
newErrors.password = "Password is required";
} else if (formData.password.length < 6) {
newErrors.password = "Password must be at least 6 characters";
}
// Registration-specific validation
if (!isLoginMode) {
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "Please enter a valid email address";
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "Passwords do not match";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
setSSORegistrationError(false); // Clear SSO registration error when submitting
try {
if (isLoginMode) {
const loginData: LoginRequest = {
username: formData.username.trim(),
password: formData.password,
};
await login(loginData, formData.rememberMe);
onSuccess?.();
} else {
const registerData: RegisterRequest = {
username: formData.username.trim(),
password: formData.password,
email: formData.email.trim() || undefined,
};
await register(registerData);
// After successful registration, switch to login mode
setIsLoginMode(true);
setFormData({ ...formData, password: "", confirmPassword: "" });
toast.success("Registration successful! Please log in.");
}
} catch (error) {
const authError = error as AuthError;
toast.error(isLoginMode ? "Login Failed" : "Registration Failed", {
description: authError.message,
});
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (typeof value === 'string' && errors[field]) {
setErrors(prev => ({ ...prev, [field]: "" }));
}
// Clear SSO registration error when user starts interacting with the form
if (typeof value === 'string' && ssoRegistrationError) {
setSSORegistrationError(false);
}
};
const toggleMode = () => {
// Don't allow toggling to registration if it's disabled
if (!registrationEnabled && isLoginMode) {
return;
}
setIsLoginMode(!isLoginMode);
setErrors({});
setSSORegistrationError(false); // Clear SSO registration error when switching modes
setFormData({
username: "",
password: "",
email: "",
confirmPassword: "",
rememberMe: formData.rememberMe, // Preserve remember me preference
});
};
const handleSSOLogin = (provider: string) => {
// Redirect to SSO login endpoint
window.location.href = `/api/auth/sso/login/${provider}`;
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-surface to-surface-secondary dark:from-surface-dark dark:to-surface-secondary-dark p-4">
<div className="w-full max-w-md">
{/* Logo/Brand */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-6">
<img src="/spotizerr.svg" alt="Spotizerr" className="h-16 w-auto logo" />
</div>
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">
Spotizerr
</h1>
<p className="text-content-secondary dark:text-content-secondary-dark mt-2">
{isLoginMode ? "Welcome back" : "Create your account"}
</p>
</div>
{/* Form */}
<div className="bg-surface dark:bg-surface-dark rounded-2xl shadow-xl border border-border dark:border-border-dark p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Username
</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) => handleInputChange("username", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.username
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter your username"
disabled={isSubmitting || isLoading}
/>
{errors.username && (
<p className="mt-1 text-sm text-error">{errors.username}</p>
)}
</div>
{/* Email (Registration only) */}
{!isLoginMode && (
<div>
<label htmlFor="email" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Email (optional)
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.email
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter your email"
disabled={isSubmitting || isLoading}
/>
{errors.email && (
<p className="mt-1 text-sm text-error">{errors.email}</p>
)}
</div>
)}
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Password
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.password
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter your password"
disabled={isSubmitting || isLoading}
/>
{errors.password && (
<p className="mt-1 text-sm text-error">{errors.password}</p>
)}
</div>
{/* Confirm Password (Registration only) */}
{!isLoginMode && (
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
className={`w-full px-4 py-3 rounded-lg border transition-colors ${
errors.confirmPassword
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Confirm your password"
disabled={isSubmitting || isLoading}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-error">{errors.confirmPassword}</p>
)}
</div>
)}
{/* Remember Me Checkbox (Login only) */}
{isLoginMode && (
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
type="checkbox"
id="rememberMe"
checked={formData.rememberMe}
onChange={(e) => handleInputChange("rememberMe", e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary/20 border-input-border dark:border-input-border-dark rounded"
disabled={isSubmitting || isLoading}
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-content-primary dark:text-content-primary-dark">
Remember me
</label>
</div>
<div className="text-xs text-content-muted dark:text-content-muted-dark">
{formData.rememberMe ? "Stay signed in" : "Session only"}
</div>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting || isLoading}
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isSubmitting || isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
{isLoginMode ? "Signing in..." : "Creating account..."}
</>
) : (
<>{isLoginMode ? "Sign In" : "Create Account"}</>
)}
</button>
</form>
{/* SSO Buttons */}
{ssoEnabled && ssoProviders.length > 0 && (
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border dark:border-border-dark" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-surface dark:bg-surface-dark px-2 text-content-secondary dark:text-content-secondary-dark">
Or
</span>
</div>
</div>
{/* Registration disabled notice for SSO */}
{ssoRegistrationError && (
<div className="mt-4 p-3 bg-surface-secondary dark:bg-surface-secondary-dark rounded-lg border border-border dark:border-border-dark">
<p className="text-sm text-content-secondary dark:text-content-secondary-dark text-center">
Only existing users can sign in with SSO
</p>
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-3">
{ssoProviders.map((provider) => (
<button
key={provider.name}
type="button"
onClick={() => handleSSOLogin(provider.name)}
disabled={isSubmitting || isLoading}
className="w-full inline-flex justify-center py-3 px-4 border border-input-border dark:border-input-border-dark rounded-lg bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark shadow-sm hover:bg-input-background/80 dark:hover:bg-input-background-dark/80 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center gap-3">
{provider.name === 'google' && (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
)}
{provider.name === 'github' && (
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
)}
<span className="font-medium">
Continue with {provider.display_name}
</span>
</div>
</button>
))}
</div>
</div>
)}
{/* Toggle Mode */}
<div className="mt-6 text-center">
<p className="text-content-secondary dark:text-content-secondary-dark">
{isLoginMode ? "Don't have an account? " : "Already have an account? "}
{registrationEnabled ? (
<button
type="button"
onClick={toggleMode}
disabled={isSubmitting || isLoading}
className="text-primary hover:text-primary-hover font-medium transition-colors disabled:opacity-50"
>
{isLoginMode ? "Create one" : "Sign in"}
</button>
) : (
<span className="text-content-muted dark:text-content-muted-dark">
Registration is currently disabled. Please contact the administrator.
</span>
)}
</p>
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center">
<p className="text-sm text-content-muted dark:text-content-muted-dark">
The music downloader
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { useAuth } from "@/contexts/auth-context";
import { LoginScreen } from "./LoginScreen";
interface ProtectedRouteProps {
children: ReactNode;
fallback?: ReactNode;
}
export function ProtectedRoute({ children, fallback }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, authEnabled } = useAuth();
// Show loading state while checking authentication
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-surface dark:bg-surface-dark">
<div className="text-center">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center mb-6 mx-auto">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<h2 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-2">
Spotizerr
</h2>
<p className="text-content-secondary dark:text-content-secondary-dark">
{authEnabled ? "Restoring your session..." : "Loading application..."}
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-2">
{authEnabled ? "Checking stored credentials" : "Authentication disabled"}
</p>
</div>
</div>
);
}
// If authentication is disabled, always show children
if (!authEnabled) {
return <>{children}</>;
}
// If authenticated, show children
if (isAuthenticated) {
return <>{children}</>;
}
// If not authenticated, show fallback or login screen
return fallback || <LoginScreen />;
}

View File

@@ -0,0 +1,115 @@
import { useState, useRef, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
import { useNavigate } from "@tanstack/react-router";
export function UserMenu() {
const { user, logout, authEnabled, isRemembered } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
// Don't render if auth is disabled or user is not logged in
if (!authEnabled || !user) {
return null;
}
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
const handleLogout = async () => {
try {
await logout();
setIsOpen(false);
} catch (error) {
console.error("Logout failed:", error);
}
};
const handleProfileSettings = () => {
navigate({ to: "/config", search: { tab: "profile" } });
setIsOpen(false);
};
const sessionType = isRemembered();
return (
<div className="relative" ref={menuRef}>
{/* User Avatar/Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark transition-colors"
title={`Logged in as ${user.username}${sessionType ? " (persistent session)" : " (session only)"}`}
>
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white font-medium text-sm relative">
{user.username.charAt(0).toUpperCase()}
{/* Session type indicator */}
<div className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border border-surface dark:border-surface-dark ${
sessionType ? "bg-green-500" : "bg-orange-500"
}`} title={sessionType ? "Persistent session" : "Session only"} />
</div>
<span className="hidden sm:inline text-sm font-medium text-content-secondary dark:text-content-secondary-dark max-w-20 truncate">
{user.username}
</span>
<svg
className={`w-4 h-4 text-content-muted dark:text-content-muted-dark transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-lg shadow-xl z-50">
<div className="p-3 border-b border-border dark:border-border-dark">
<p className="font-medium text-content-primary dark:text-content-primary-dark">
{user.username}
</p>
{user.email && (
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{user.email}
</p>
)}
<p className="text-xs text-content-muted dark:text-content-muted-dark">
{user.role === "admin" ? "Administrator" : "User"}
</p>
<div className={`text-xs mt-1 flex items-center gap-1 ${
sessionType ? "text-green-600 dark:text-green-400" : "text-orange-600 dark:text-orange-400"
}`}>
<div className={`w-2 h-2 rounded-full ${sessionType ? "bg-green-500" : "bg-orange-500"}`} />
{sessionType ? "Persistent session" : "Session only"}
</div>
</div>
<div className="p-2">
<button
onClick={handleProfileSettings}
className="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark transition-colors"
>
Profile Settings
</button>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark transition-colors"
>
Sign Out
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useForm, type SubmitHandler } from "react-hook-form"; import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -21,7 +21,7 @@ interface AccountFormData {
// --- API Functions --- // --- API Functions ---
const fetchCredentials = async (service: Service): Promise<Credential[]> => { const fetchCredentials = async (service: Service): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`); const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name })); return data.map((name) => ({ name }));
}; };
@@ -31,12 +31,12 @@ const addCredential = async ({ service, data }: { service: Service; data: Accoun
? { blob_content: data.authBlob, region: data.accountRegion } ? { blob_content: data.authBlob, region: data.accountRegion }
: { arl: data.arl, region: data.accountRegion }; : { arl: data.arl, region: data.accountRegion };
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload); const { data: response } = await authApiClient.client.post(`/credentials/${service}/${data.accountName}`, payload);
return response; return response;
}; };
const deleteCredential = async ({ service, name }: { service: Service; name: string }) => { const deleteCredential = async ({ service, name }: { service: Service; name: string }) => {
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`); const { data: response } = await authApiClient.client.delete(`/credentials/${service}/${name}`);
return response; return response;
}; };
@@ -87,61 +87,61 @@ export function AccountsTab() {
}; };
const renderAddForm = () => ( const renderAddForm = () => (
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border 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">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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="accountName">Account Name</label> <label htmlFor="accountName" className="text-content-primary dark:text-content-primary-dark">Account Name</label>
<input <input
id="accountName" id="accountName"
{...register("accountName", { required: "This field is required" })} {...register("accountName", { required: "This field is required" })}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>} {errors.accountName && <p className="text-error-text bg-error-muted px-2 py-1 rounded text-sm">{errors.accountName.message}</p>}
</div> </div>
{activeService === "spotify" && ( {activeService === "spotify" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="authBlob">Auth Blob (JSON)</label> <label htmlFor="authBlob" className="text-content-primary dark:text-content-primary-dark">Auth Blob (JSON)</label>
<textarea <textarea
id="authBlob" id="authBlob"
{...register("authBlob", { required: activeService === "spotify" ? "Auth Blob is required" : false })} {...register("authBlob", { required: activeService === "spotify" ? "Auth Blob is required" : false })}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
rows={4} rows={4}
></textarea> ></textarea>
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>} {errors.authBlob && <p className="text-error-text bg-error-muted px-2 py-1 rounded text-sm">{errors.authBlob.message}</p>}
</div> </div>
)} )}
{activeService === "deezer" && ( {activeService === "deezer" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="arl">ARL Token</label> <label htmlFor="arl" className="text-content-primary dark:text-content-primary-dark">ARL Token</label>
<input <input
id="arl" id="arl"
{...register("arl", { required: activeService === "deezer" ? "ARL is required" : false })} {...register("arl", { required: activeService === "deezer" ? "ARL is required" : false })}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>} {errors.arl && <p className="text-error-text bg-error-muted px-2 py-1 rounded text-sm">{errors.arl.message}</p>}
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="accountRegion">Region (Optional)</label> <label htmlFor="accountRegion" className="text-content-primary dark:text-content-primary-dark">Region (Optional)</label>
<input <input
id="accountRegion" id="accountRegion"
{...register("accountRegion")} {...register("accountRegion")}
placeholder="e.g. US, GB" placeholder="e.g. US, GB"
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={addMutation.isPending} disabled={addMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{addMutation.isPending ? "Saving..." : "Save Account"} {addMutation.isPending ? "Saving..." : "Save Account"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsAdding(false)} onClick={() => setIsAdding(false)}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700" className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md"
> >
Cancel Cancel
</button> </button>
@@ -151,35 +151,35 @@ export function AccountsTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex gap-2 border-b"> <div className="flex gap-2 border-b border-line dark:border-border-dark">
<button <button
onClick={() => setActiveService("spotify")} onClick={() => setActiveService("spotify")}
className={`p-2 ${activeService === "spotify" ? "border-b-2 border-blue-500 font-semibold" : ""}`} className={`p-2 text-content-primary dark:text-content-primary-dark ${activeService === "spotify" ? "border-b-2 border-primary font-semibold" : ""}`}
> >
Spotify Spotify
</button> </button>
<button <button
onClick={() => setActiveService("deezer")} onClick={() => setActiveService("deezer")}
className={`p-2 ${activeService === "deezer" ? "border-b-2 border-blue-500 font-semibold" : ""}`} className={`p-2 text-content-primary dark:text-content-primary-dark ${activeService === "deezer" ? "border-b-2 border-primary font-semibold" : ""}`}
> >
Deezer Deezer
</button> </button>
</div> </div>
{isLoading ? ( {isLoading ? (
<p>Loading accounts...</p> <p className="text-content-muted dark:text-content-muted-dark">Loading accounts...</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{credentials?.map((cred) => ( {credentials?.map((cred) => (
<div <div
key={cred.name} key={cred.name}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white rounded-md" className="flex justify-between items-center p-3 bg-surface-muted dark:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark rounded-md"
> >
<span>{cred.name}</span> <span>{cred.name}</span>
<button <button
onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })} onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })}
disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name} disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name}
className="text-red-500 hover:text-red-400" className="text-error hover:text-error-hover icon-error"
> >
Delete Delete
</button> </button>
@@ -191,7 +191,7 @@ export function AccountsTab() {
{!isAdding && ( {!isAdding && (
<button <button
onClick={() => setIsAdding(true)} onClick={() => setIsAdding(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
Add Account Add Account
</button> </button>

View File

@@ -1,8 +1,8 @@
import { useForm, type SubmitHandler } from "react-hook-form"; import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect, useState } from "react";
// --- Type Definitions --- // --- Type Definitions ---
interface DownloadSettings { interface DownloadSettings {
@@ -23,6 +23,16 @@ interface DownloadSettings {
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH"; spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
} }
interface WatchConfig {
enabled: boolean;
interval: number;
playlists: string[];
}
interface Credential {
name: string;
}
interface DownloadsTabProps { interface DownloadsTabProps {
config: DownloadSettings; config: DownloadSettings;
isLoading: boolean; isLoading: boolean;
@@ -40,13 +50,44 @@ const CONVERSION_FORMATS: Record<string, string[]> = {
// --- API Functions --- // --- API Functions ---
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => { const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
const { data: response } = await apiClient.post("/config", data); const { data: response } = await authApiClient.client.post("/config", data);
return response; return response;
}; };
const fetchWatchConfig = async (): Promise<WatchConfig> => {
const { data } = await authApiClient.client.get("/config/watch");
return data;
};
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
// --- Component --- // --- Component ---
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>("");
// Fetch watch config
const { data: watchConfig } = useQuery({
queryKey: ["watchConfig"],
queryFn: fetchWatchConfig,
staleTime: 30000, // 30 seconds
});
// Fetch credentials for fallback validation
const { data: spotifyCredentials } = useQuery({
queryKey: ["credentials", "spotify"],
queryFn: () => fetchCredentials("spotify"),
staleTime: 30000,
});
const { data: deezerCredentials } = useQuery({
queryKey: ["credentials", "deezer"],
queryFn: () => fetchCredentials("deezer"),
staleTime: 30000,
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: saveDownloadConfig, mutationFn: saveDownloadConfig,
@@ -70,8 +111,48 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
}, [config, reset]); }, [config, reset]);
const selectedFormat = watch("convertTo"); const selectedFormat = watch("convertTo");
const realTime = watch("realTime");
const fallback = watch("fallback");
// Validation effect for watch + download method requirement
useEffect(() => {
let error = "";
// Check watch requirements
if (watchConfig?.enabled && !realTime && !fallback) {
error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled.";
}
// Check fallback account requirements
if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
if (!spotifyCredentials?.length) missingServices.push("Spotify");
if (!deezerCredentials?.length) missingServices.push("Deezer");
error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
}
setValidationError(error);
}, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]);
const onSubmit: SubmitHandler<DownloadSettings> = (data) => { const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
// Check watch requirements
if (watchConfig?.enabled && !data.realTime && !data.fallback) {
setValidationError("When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled.");
toast.error("Validation failed: Watch requires at least one download method to be enabled.");
return;
}
// Check fallback account requirements
if (data.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
if (!spotifyCredentials?.length) missingServices.push("Spotify");
if (!deezerCredentials?.length) missingServices.push("Deezer");
const error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
setValidationError(error);
toast.error("Validation failed: " + error);
return;
}
mutation.mutate({ mutation.mutate({
...data, ...data,
maxConcurrentDownloads: Number(data.maxConcurrentDownloads), maxConcurrentDownloads: Number(data.maxConcurrentDownloads),
@@ -89,36 +170,67 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Download Settings */} {/* Download Settings */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Download Behavior</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Download Behavior</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="maxConcurrentDownloads">Max Concurrent Downloads</label> <label htmlFor="maxConcurrentDownloads" className="text-content-primary dark:text-content-primary-dark">Max Concurrent Downloads</label>
<input <input
id="maxConcurrentDownloads" id="maxConcurrentDownloads"
type="number" type="number"
min="1" min="1"
{...register("maxConcurrentDownloads")} {...register("maxConcurrentDownloads")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label htmlFor="realTimeToggle">Real-time downloading</label> <label htmlFor="realTimeToggle" className="text-content-primary dark:text-content-primary-dark">Real-time downloading</label>
<input id="realTimeToggle" type="checkbox" {...register("realTime")} className="h-6 w-6 rounded" /> <input id="realTimeToggle" type="checkbox" {...register("realTime")} className="h-6 w-6 rounded" />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label htmlFor="fallbackToggle">Download Fallback</label> <label htmlFor="fallbackToggle" className="text-content-primary dark:text-content-primary-dark">Download Fallback</label>
<input id="fallbackToggle" type="checkbox" {...register("fallback")} className="h-6 w-6 rounded" /> <input id="fallbackToggle" type="checkbox" {...register("fallback")} className="h-6 w-6 rounded" />
</div> </div>
{/* Watch validation info */}
{watchConfig?.enabled && (
<div className="p-3 bg-info/10 border border-info/20 rounded-lg">
<p className="text-sm text-info font-medium mb-1">
Watch is currently enabled
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
At least one download method (Real-time or Fallback) must be enabled when using watch functionality.
</p>
</div>
)}
{/* Fallback account requirements info */}
{fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Fallback accounts required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
Download Fallback requires accounts for both Spotify and Deezer. Configure missing accounts in the Accounts tab.
</p>
</div>
)}
{/* Validation error display */}
{validationError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error font-medium">{validationError}</p>
</div>
)}
</div> </div>
{/* Source Quality Settings */} {/* Source Quality Settings */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Source Quality</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Source Quality</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="spotifyQuality">Spotify Quality</label> <label htmlFor="spotifyQuality" className="text-content-primary dark:text-content-primary-dark">Spotify Quality</label>
<select <select
id="spotifyQuality" id="spotifyQuality"
{...register("spotifyQuality")} {...register("spotifyQuality")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
<option value="NORMAL">OGG 96kbps</option> <option value="NORMAL">OGG 96kbps</option>
<option value="HIGH">OGG 160kbps</option> <option value="HIGH">OGG 160kbps</option>
@@ -126,31 +238,31 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
</select> </select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="deezerQuality">Deezer Quality</label> <label htmlFor="deezerQuality" className="text-content-primary dark:text-content-primary-dark">Deezer Quality</label>
<select <select
id="deezerQuality" id="deezerQuality"
{...register("deezerQuality")} {...register("deezerQuality")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
<option value="MP3_128">MP3 128kbps</option> <option value="MP3_128">MP3 128kbps</option>
<option value="MP3_320">MP3 320kbps</option> <option value="MP3_320">MP3 320kbps</option>
<option value="FLAC">FLAC (HiFi)</option> <option value="FLAC">FLAC (HiFi)</option>
</select> </select>
</div> </div>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
This sets the quality of the original download. Conversion settings below are applied after download. This sets the quality of the original download. Conversion settings below are applied after download.
</p> </p>
</div> </div>
{/* Conversion Settings */} {/* Conversion Settings */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Conversion</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Conversion</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="convertToSelect">Convert To Format</label> <label htmlFor="convertToSelect" className="text-content-primary dark:text-content-primary-dark">Convert To Format</label>
<select <select
id="convertToSelect" id="convertToSelect"
{...register("convertTo")} {...register("convertTo")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
<option value="">No Conversion</option> <option value="">No Conversion</option>
{Object.keys(CONVERSION_FORMATS).map((format) => ( {Object.keys(CONVERSION_FORMATS).map((format) => (
@@ -161,11 +273,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
</select> </select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="bitrateSelect">Bitrate</label> <label htmlFor="bitrateSelect" className="text-content-primary dark:text-content-primary-dark">Bitrate</label>
<select <select
id="bitrateSelect" id="bitrateSelect"
{...register("bitrate")} {...register("bitrate")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
disabled={!selectedFormat || CONVERSION_FORMATS[selectedFormat]?.length === 0} disabled={!selectedFormat || CONVERSION_FORMATS[selectedFormat]?.length === 0}
> >
<option value="">Auto</option> <option value="">Auto</option>
@@ -180,43 +292,43 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
{/* Retry Options */} {/* Retry Options */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Retries</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Retries</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="maxRetries">Max Retry Attempts</label> <label htmlFor="maxRetries" className="text-content-primary dark:text-content-primary-dark">Max Retry Attempts</label>
<input <input
id="maxRetries" id="maxRetries"
type="number" type="number"
min="0" min="0"
{...register("maxRetries")} {...register("maxRetries")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="retryDelaySeconds">Initial Retry Delay (s)</label> <label htmlFor="retryDelaySeconds" className="text-content-primary dark:text-content-primary-dark">Initial Retry Delay (s)</label>
<input <input
id="retryDelaySeconds" id="retryDelaySeconds"
type="number" type="number"
min="1" min="1"
{...register("retryDelaySeconds")} {...register("retryDelaySeconds")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="retryDelayIncrease">Retry Delay Increase (s)</label> <label htmlFor="retryDelayIncrease" className="text-content-primary dark:text-content-primary-dark">Retry Delay Increase (s)</label>
<input <input
id="retryDelayIncrease" id="retryDelayIncrease"
type="number" type="number"
min="0" min="0"
{...register("retryDelayIncrease")} {...register("retryDelayIncrease")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
</div> </div>
</div> </div>
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending || !!validationError}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{mutation.isPending ? "Saving..." : "Save Download Settings"} {mutation.isPending ? "Saving..." : "Save Download Settings"}
</button> </button>

View File

@@ -1,6 +1,6 @@
import { useRef } from "react"; import { useRef } from "react";
import { useForm, type SubmitHandler } from "react-hook-form"; import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -23,7 +23,7 @@ interface FormattingTabProps {
// --- API Functions --- // --- API Functions ---
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => { const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
const { data: response } = await apiClient.post("/config", data); const { data: response } = await authApiClient.client.post("/config", data);
return response; return response;
}; };
@@ -50,7 +50,7 @@ const placeholders = {
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => ( const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
<select <select
onChange={(e) => onSelect(e.target.value)} onChange={(e) => onSelect(e.target.value)}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm mt-1" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus text-sm mt-1"
> >
<option value="">-- Insert Placeholder --</option> <option value="">-- Insert Placeholder --</option>
{Object.entries(placeholders).map(([group, options]) => ( {Object.entries(placeholders).map(([group, options]) => (
@@ -104,15 +104,15 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
}; };
if (isLoading) { if (isLoading) {
return <div>Loading formatting settings...</div>; return <div className="text-content-muted dark:text-content-muted-dark">Loading formatting settings...</div>;
} }
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">File Naming</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">File Naming</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="customDirFormat">Custom Directory Format</label> <label htmlFor="customDirFormat" className="text-content-primary dark:text-content-primary-dark">Custom Directory Format</label>
<input <input
id="customDirFormat" id="customDirFormat"
type="text" type="text"
@@ -121,12 +121,12 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
dirFormatRef(e); dirFormatRef(e);
dirInputRef.current = e; dirInputRef.current = e;
}} }}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
<PlaceholderSelector onSelect={handlePlaceholderSelect("customDirFormat", dirInputRef)} /> <PlaceholderSelector onSelect={handlePlaceholderSelect("customDirFormat", dirInputRef)} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="customTrackFormat">Custom Track Format</label> <label htmlFor="customTrackFormat" className="text-content-primary dark:text-content-primary-dark">Custom Track Format</label>
<input <input
id="customTrackFormat" id="customTrackFormat"
type="text" type="text"
@@ -135,12 +135,12 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
trackFormatRef(e); trackFormatRef(e);
trackInputRef.current = e; trackInputRef.current = e;
}} }}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
<PlaceholderSelector onSelect={handlePlaceholderSelect("customTrackFormat", trackInputRef)} /> <PlaceholderSelector onSelect={handlePlaceholderSelect("customTrackFormat", trackInputRef)} />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label> <label htmlFor="tracknumPaddingToggle" className="text-content-primary dark:text-content-primary-dark">Track Number Padding</label>
<input <input
id="tracknumPaddingToggle" id="tracknumPaddingToggle"
type="checkbox" type="checkbox"
@@ -149,7 +149,7 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label htmlFor="saveCoverToggle">Save Album Cover</label> <label htmlFor="saveCoverToggle" className="text-content-primary dark:text-content-primary-dark">Save Album Cover</label>
<input id="saveCoverToggle" type="checkbox" {...register("saveCover")} className="h-6 w-6 rounded" /> <input id="saveCoverToggle" type="checkbox" {...register("saveCover")} className="h-6 w-6 rounded" />
</div> </div>
</div> </div>
@@ -157,7 +157,7 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{mutation.isPending ? "Saving..." : "Save Formatting Settings"} {mutation.isPending ? "Saving..." : "Save Formatting Settings"}
</button> </button>

View File

@@ -1,5 +1,5 @@
import { useForm, type SubmitHandler } from "react-hook-form"; import { useForm, type SubmitHandler } from "react-hook-form";
import apiClient from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "../../contexts/settings-context"; import { useSettings } from "../../contexts/settings-context";
@@ -23,12 +23,12 @@ interface GeneralTabProps {
// --- API Functions --- // --- API Functions ---
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => { const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`); const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name })); return data.map((name) => ({ name }));
}; };
const saveGeneralConfig = async (data: Partial<GeneralSettings>) => { const saveGeneralConfig = async (data: Partial<GeneralSettings>) => {
const { data: response } = await apiClient.post("/config", data); const { data: response } = await authApiClient.client.post("/config", data);
return response; return response;
}; };
@@ -70,33 +70,33 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
}; };
const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading; const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading;
if (isLoading) return <p>Loading general settings...</p>; if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading general settings...</p>;
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Service Defaults</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Service Defaults</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="service">Default Service</label> <label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">Default Service</label>
<select <select
id="service" id="service"
{...register("service")} {...register("service")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
<option value="spotify">Spotify</option> <option value="spotify">Spotify</option>
<option value="deezer">Deezer</option> <option value="deezer" disabled>Deezer (not yet...)</option>
</select> </select>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Spotify Settings</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify Settings</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="spotifyAccount">Active Spotify Account</label> <label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">Active Spotify Account</label>
<select <select
id="spotifyAccount" id="spotifyAccount"
{...register("spotify")} {...register("spotify")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
{spotifyAccounts?.map((acc) => ( {spotifyAccounts?.map((acc) => (
<option key={acc.name} value={acc.name}> <option key={acc.name} value={acc.name}>
@@ -108,13 +108,13 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Deezer Settings</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Deezer Settings</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="deezerAccount">Active Deezer Account</label> <label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">Active Deezer Account</label>
<select <select
id="deezerAccount" id="deezerAccount"
{...register("deezer")} {...register("deezer")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
{deezerAccounts?.map((acc) => ( {deezerAccounts?.map((acc) => (
<option key={acc.name} value={acc.name}> <option key={acc.name} value={acc.name}>
@@ -126,17 +126,17 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Content Filters</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Content Filters</h3>
<div className="form-item--row"> <div className="form-item--row">
<label>Filter Explicit Content</label> <label className="text-content-primary dark:text-content-primary-dark">Filter Explicit Content</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-green-400" : "text-red-400"}`}> <span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}>
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"} {globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
</span> </span>
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span> <span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">ENV</span>
</div> </div>
</div> </div>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
The explicit content filter is controlled by an environment variable and cannot be changed here. The explicit content filter is controlled by an environment variable and cannot be changed here.
</p> </p>
</div> </div>
@@ -144,7 +144,7 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{mutation.isPending ? "Saving..." : "Save General Settings"} {mutation.isPending ? "Saving..." : "Save General Settings"}
</button> </button>

View File

@@ -0,0 +1,266 @@
import { useState } from "react";
import { useAuth } from "@/contexts/auth-context";
import { authApiClient } from "@/lib/api-client";
export function ProfileTab() {
const { user } = useAuth();
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validatePasswordForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!passwordForm.currentPassword) {
newErrors.currentPassword = "Current password is required";
}
if (!passwordForm.newPassword) {
newErrors.newPassword = "New password is required";
} else if (passwordForm.newPassword.length < 6) {
newErrors.newPassword = "New password must be at least 6 characters";
}
if (!passwordForm.confirmPassword) {
newErrors.confirmPassword = "Please confirm your new password";
} else if (passwordForm.newPassword !== passwordForm.confirmPassword) {
newErrors.confirmPassword = "Passwords do not match";
}
if (passwordForm.currentPassword === passwordForm.newPassword) {
newErrors.newPassword = "New password must be different from current password";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
if (!validatePasswordForm()) {
return;
}
try {
setIsChangingPassword(true);
await authApiClient.changePassword(
passwordForm.currentPassword,
passwordForm.newPassword
);
// Reset form on success
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
setErrors({});
} catch (error: any) {
console.error("Password change failed:", error);
// The API client will show the toast error, but we might want to handle specific field errors
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
if (detail.includes("Current password is incorrect")) {
setErrors({ currentPassword: "Current password is incorrect" });
} else if (detail.includes("New password must be")) {
setErrors({ newPassword: detail });
}
}
} finally {
setIsChangingPassword(false);
}
};
const handleInputChange = (field: string, value: string) => {
setPasswordForm(prev => ({
...prev,
[field]: value
}));
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ""
}));
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-semibold text-content-primary dark:text-content-primary-dark mb-4">
Profile Settings
</h2>
<p className="text-content-muted dark:text-content-muted-dark">
Manage your profile information and security settings.
</p>
</div>
{/* User Information */}
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-lg p-6">
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark mb-4">
Account Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Username
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.username}
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Email
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.email || "Not provided"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Role
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.role === "admin" ? "Administrator" : "User"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-1">
Account Type
</label>
<p className="text-content-primary dark:text-content-primary-dark">
{user?.is_sso_user ? `SSO (${user.sso_provider})` : "Local Account"}
</p>
</div>
</div>
</div>
{/* Password Change Section - Only show for non-SSO users */}
{user && !user.is_sso_user && (
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-lg p-6">
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark mb-4">
Change Password
</h3>
<p className="text-content-muted dark:text-content-muted-dark mb-6">
Update your password to keep your account secure.
</p>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2">
Current Password
</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => handleInputChange("currentPassword", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.currentPassword
? "border-error text-error-text bg-error-muted"
: "border-border dark:border-border-dark bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark focus:border-primary focus:ring-1 focus:ring-primary"
}`}
placeholder="Enter your current password"
disabled={isChangingPassword}
/>
{errors.currentPassword && (
<p className="text-error-text text-sm mt-1">{errors.currentPassword}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2">
New Password
</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => handleInputChange("newPassword", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.newPassword
? "border-error text-error-text bg-error-muted"
: "border-border dark:border-border-dark bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark focus:border-primary focus:ring-1 focus:ring-primary"
}`}
placeholder="Enter your new password"
disabled={isChangingPassword}
/>
{errors.newPassword && (
<p className="text-error-text text-sm mt-1">{errors.newPassword}</p>
)}
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">
Must be at least 6 characters long
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary dark:text-content-secondary-dark mb-2">
Confirm New Password
</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.confirmPassword
? "border-error text-error-text bg-error-muted"
: "border-border dark:border-border-dark bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark focus:border-primary focus:ring-1 focus:ring-primary"
}`}
placeholder="Confirm your new password"
disabled={isChangingPassword}
/>
{errors.confirmPassword && (
<p className="text-error-text text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isChangingPassword}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isChangingPassword ? "Changing Password..." : "Change Password"}
</button>
<button
type="button"
onClick={() => {
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
setErrors({});
}}
disabled={isChangingPassword}
className="px-4 py-2 bg-surface-accent dark:bg-surface-accent-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* SSO User Notice */}
{user?.is_sso_user && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="text-lg font-medium text-blue-900 dark:text-blue-100 mb-2">
SSO Account
</h3>
<p className="text-blue-800 dark:text-blue-200">
Your account is managed by {user.sso_provider}. To change your password,
please use your {user.sso_provider} account settings.
</p>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import apiClient from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -18,10 +18,10 @@ interface WebhookSettings {
// --- API Functions --- // --- API Functions ---
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => { const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
const { data } = await apiClient.get("/credentials/spotify_api_config"); const { data } = await authApiClient.client.get("/credentials/spotify_api_config");
return data; return data;
}; };
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put("/credentials/spotify_api_config", data); const saveSpotifyApiConfig = (data: SpotifyApiSettings) => authApiClient.client.put("/credentials/spotify_api_config", data);
const fetchWebhookConfig = async (): Promise<WebhookSettings> => { const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
// Mock a response since backend endpoint doesn't exist // Mock a response since backend endpoint doesn't exist
@@ -62,34 +62,34 @@ function SpotifyApiForm() {
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData); const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
if (isLoading) return <p>Loading Spotify API settings...</p>; if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading Spotify API settings...</p>;
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="client_id">Client ID</label> <label htmlFor="client_id" className="text-content-primary dark:text-content-primary-dark">Client ID</label>
<input <input
id="client_id" id="client_id"
type="password" type="password"
{...register("client_id")} {...register("client_id")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="client_secret">Client Secret</label> <label htmlFor="client_secret" className="text-content-primary dark:text-content-primary-dark">Client Secret</label>
<input <input
id="client_secret" id="client_secret"
type="password" type="password"
{...register("client_secret")} {...register("client_secret")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{mutation.isPending ? "Saving..." : "Save Spotify API"} {mutation.isPending ? "Saving..." : "Save Spotify API"}
</button> </button>
@@ -126,22 +126,22 @@ function WebhookForm() {
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData); const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
if (isLoading) return <p>Loading Webhook settings...</p>; if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading Webhook settings...</p>;
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="webhookUrl">Webhook URL</label> <label htmlFor="webhookUrl" className="text-content-primary dark:text-content-primary-dark">Webhook URL</label>
<input <input
id="webhookUrl" id="webhookUrl"
type="url" type="url"
{...register("url")} {...register("url")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
placeholder="https://example.com/webhook" placeholder="https://example.com/webhook"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label>Webhook Events</label> <label className="text-content-primary dark:text-content-primary-dark">Webhook Events</label>
<div className="grid grid-cols-2 gap-4 pt-2"> <div className="grid grid-cols-2 gap-4 pt-2">
{data?.available_events.map((event) => ( {data?.available_events.map((event) => (
<Controller <Controller
@@ -149,7 +149,7 @@ function WebhookForm() {
name="events" name="events"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<label className="flex items-center gap-2"> <label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
<input <input
type="checkbox" type="checkbox"
className="h-5 w-5 rounded" className="h-5 w-5 rounded"
@@ -171,7 +171,7 @@ function WebhookForm() {
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{mutation.isPending ? "Saving..." : "Save Webhook"} {mutation.isPending ? "Saving..." : "Save Webhook"}
</button> </button>
@@ -179,7 +179,7 @@ function WebhookForm() {
type="button" type="button"
onClick={() => testMutation.mutate(currentUrl)} onClick={() => testMutation.mutate(currentUrl)}
disabled={!currentUrl || testMutation.isPending} disabled={!currentUrl || testMutation.isPending}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50" className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md disabled:opacity-50"
> >
Test Test
</button> </button>
@@ -192,14 +192,14 @@ export function ServerTab() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h3 className="text-xl font-semibold">Spotify API</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify API</h3>
<p className="text-sm text-gray-500 mt-1">Provide your own API credentials to avoid rate-limiting issues.</p> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
<SpotifyApiForm /> <SpotifyApiForm />
</div> </div>
<hr className="border-gray-600" /> <hr className="border-border dark:border-border-dark" />
<div> <div>
<h3 className="text-xl font-semibold">Webhooks</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Webhooks</h3>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
Get notifications for events like download completion. (Currently disabled) Get notifications for events like download completion. (Currently disabled)
</p> </p>
<WebhookForm /> <WebhookForm />

View File

@@ -0,0 +1,495 @@
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/auth-context";
import { authApiClient } from "@/lib/api-client";
import { toast } from "sonner";
import type { User, CreateUserRequest } from "@/types/auth";
export function UserManagementTab() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createForm, setCreateForm] = useState<CreateUserRequest>({
username: "",
password: "",
email: "",
role: "user"
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Password reset state
const [showPasswordResetModal, setShowPasswordResetModal] = useState(false);
const [passwordResetUser, setPasswordResetUser] = useState<string>("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [passwordErrors, setPasswordErrors] = useState<Record<string, string>>({});
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setIsLoading(true);
const userList = await authApiClient.listUsers();
setUsers(userList);
} catch (error) {
console.error("Failed to load users:", error);
toast.error("Failed to load users");
} finally {
setIsLoading(false);
}
};
const validateCreateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!createForm.username.trim()) {
newErrors.username = "Username is required";
} else if (createForm.username.length < 3) {
newErrors.username = "Username must be at least 3 characters";
}
if (!createForm.password) {
newErrors.password = "Password is required";
} else if (createForm.password.length < 6) {
newErrors.password = "Password must be at least 6 characters";
}
if (createForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(createForm.email)) {
newErrors.email = "Please enter a valid email address";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateCreateForm()) {
return;
}
try {
setIsCreating(true);
await authApiClient.createUser({
...createForm,
email: createForm.email?.trim() || undefined,
});
// Reset form and reload users
setCreateForm({ username: "", password: "", email: "", role: "user" });
setShowCreateForm(false);
setErrors({});
await loadUsers();
} catch (error) {
console.error("Failed to create user:", error);
} finally {
setIsCreating(false);
}
};
const handleDeleteUser = async (username: string) => {
if (username === currentUser?.username) {
toast.error("Cannot delete your own account");
return;
}
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
await authApiClient.deleteUser(username);
await loadUsers();
} catch (error) {
console.error("Failed to delete user:", error);
}
};
const handleRoleChange = async (username: string, newRole: "user" | "admin") => {
if (username === currentUser?.username) {
toast.error("Cannot change your own role");
return;
}
try {
await authApiClient.updateUserRole(username, newRole);
await loadUsers();
} catch (error) {
console.error("Failed to update user role:", error);
}
};
const handleInputChange = (field: keyof CreateUserRequest, value: string) => {
setCreateForm(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: "" }));
}
};
const openPasswordResetModal = (username: string) => {
setPasswordResetUser(username);
setNewPassword("");
setConfirmPassword("");
setPasswordErrors({});
setShowPasswordResetModal(true);
};
const closePasswordResetModal = () => {
setShowPasswordResetModal(false);
setPasswordResetUser("");
setNewPassword("");
setConfirmPassword("");
setPasswordErrors({});
};
const validatePasswordReset = (): boolean => {
const errors: Record<string, string> = {};
if (!newPassword) {
errors.newPassword = "New password is required";
} else if (newPassword.length < 6) {
errors.newPassword = "Password must be at least 6 characters long";
}
if (!confirmPassword) {
errors.confirmPassword = "Please confirm the password";
} else if (newPassword !== confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}
setPasswordErrors(errors);
return Object.keys(errors).length === 0;
};
const handlePasswordReset = async (e: React.FormEvent) => {
e.preventDefault();
if (!validatePasswordReset()) {
return;
}
try {
setIsResettingPassword(true);
await authApiClient.adminResetPassword(passwordResetUser, newPassword);
closePasswordResetModal();
} catch (error) {
console.error("Failed to reset password:", error);
} finally {
setIsResettingPassword(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-content-primary dark:text-content-primary-dark">
User Management
</h3>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
Manage user accounts and permissions
</p>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors"
>
{showCreateForm ? "Cancel" : "Create User"}
</button>
</div>
{/* Create User Form */}
{showCreateForm && (
<div className="bg-surface-secondary dark:bg-surface-secondary-dark rounded-lg p-6 border border-border dark:border-border-dark">
<h4 className="text-md font-medium text-content-primary dark:text-content-primary-dark mb-4">
Create New User
</h4>
<form onSubmit={handleCreateUser} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Username *
</label>
<input
type="text"
value={createForm.username}
onChange={(e) => handleInputChange("username", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.username
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter username"
disabled={isCreating}
/>
{errors.username && (
<p className="mt-1 text-sm text-error">{errors.username}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Email
</label>
<input
type="email"
value={createForm.email}
onChange={(e) => handleInputChange("email", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.email
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter email (optional)"
disabled={isCreating}
/>
{errors.email && (
<p className="mt-1 text-sm text-error">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Password *
</label>
<input
type="password"
value={createForm.password}
onChange={(e) => handleInputChange("password", e.target.value)}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
errors.password
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter password"
disabled={isCreating}
/>
{errors.password && (
<p className="mt-1 text-sm text-error">{errors.password}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Role *
</label>
<select
value={createForm.role}
onChange={(e) => handleInputChange("role", e.target.value as "user" | "admin")}
className="w-full px-3 py-2 rounded-lg border border-input-border dark:border-input-border-dark focus:border-primary bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20"
disabled={isCreating}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isCreating}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating...
</>
) : (
"Create User"
)}
</button>
</div>
</form>
</div>
)}
{/* Users List */}
<div className="bg-surface dark:bg-surface-dark rounded-lg border border-border dark:border-border-dark overflow-hidden">
<div className="px-6 py-4 border-b border-border dark:border-border-dark">
<h4 className="text-md font-medium text-content-primary dark:text-content-primary-dark">
Users ({users.length})
</h4>
</div>
{users.length === 0 ? (
<div className="px-6 py-8 text-center text-content-secondary dark:text-content-secondary-dark">
No users found
</div>
) : (
<div className="divide-y divide-border dark:divide-border-dark">
{users.map((user) => (
<div key={user.username} className="px-6 py-4 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<div>
<p className="font-medium text-content-primary dark:text-content-primary-dark">
{user.username}
{user.username === currentUser?.username && (
<span className="ml-2 text-xs text-primary">(You)</span>
)}
{user.is_sso_user && (
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
SSO ({user.sso_provider})
</span>
)}
</p>
{user.email && (
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
{user.email}
</p>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.username, e.target.value as "user" | "admin")}
disabled={user.username === currentUser?.username}
className="px-3 py-1 text-sm rounded-lg border border-input-border dark:border-input-border-dark bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark disabled:opacity-50"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
{/* Only show reset password for non-SSO users */}
{!user.is_sso_user && (
<button
onClick={() => openPasswordResetModal(user.username)}
disabled={user.username === currentUser?.username}
className="px-3 py-1 text-sm text-content-primary dark:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset Password
</button>
)}
<button
onClick={() => handleDeleteUser(user.username)}
disabled={user.username === currentUser?.username}
className="px-3 py-1 text-sm text-error hover:bg-error-muted rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Password Reset Modal */}
{showPasswordResetModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-surface dark:bg-surface-dark rounded-xl border border-border dark:border-border-dark shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h3 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-4">
Reset Password for {passwordResetUser}
</h3>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark mb-6">
Enter a new password for this user. The user will need to use this password to log in.
</p>
<form onSubmit={handlePasswordReset} className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
if (passwordErrors.newPassword) {
setPasswordErrors(prev => ({ ...prev, newPassword: "" }));
}
}}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
passwordErrors.newPassword
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Enter new password"
disabled={isResettingPassword}
/>
{passwordErrors.newPassword && (
<p className="mt-1 text-sm text-error">{passwordErrors.newPassword}</p>
)}
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">
Must be at least 6 characters long
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-primary dark:text-content-primary-dark mb-2">
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (passwordErrors.confirmPassword) {
setPasswordErrors(prev => ({ ...prev, confirmPassword: "" }));
}
}}
className={`w-full px-3 py-2 rounded-lg border transition-colors ${
passwordErrors.confirmPassword
? "border-error focus:border-error"
: "border-input-border dark:border-input-border-dark focus:border-primary"
} bg-input-background dark:bg-input-background-dark text-content-primary dark:text-content-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20`}
placeholder="Confirm new password"
disabled={isResettingPassword}
/>
{passwordErrors.confirmPassword && (
<p className="mt-1 text-sm text-error">{passwordErrors.confirmPassword}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={closePasswordResetModal}
disabled={isResettingPassword}
className="px-4 py-2 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isResettingPassword}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isResettingPassword ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Resetting...
</>
) : (
"Reset Password"
)}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm, type SubmitHandler, Controller } from "react-hook-form"; import { useForm, type SubmitHandler, Controller } from "react-hook-form";
import apiClient from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -15,26 +15,74 @@ interface WatchSettings {
watchedArtistAlbumGroup: AlbumGroup[]; watchedArtistAlbumGroup: AlbumGroup[];
} }
interface DownloadSettings {
realTime: boolean;
fallback: boolean;
maxConcurrentDownloads: number;
convertTo: string;
bitrate: string;
maxRetries: number;
retryDelaySeconds: number;
retryDelayIncrease: number;
deezerQuality: string;
spotifyQuality: string;
}
interface Credential {
name: string;
}
// --- API Functions --- // --- API Functions ---
const fetchWatchConfig = async (): Promise<WatchSettings> => { const fetchWatchConfig = async (): Promise<WatchSettings> => {
const { data } = await apiClient.get("/config/watch"); const { data } = await authApiClient.client.get("/config/watch");
return data; return data;
}; };
const fetchDownloadConfig = async (): Promise<DownloadSettings> => {
const { data } = await authApiClient.client.get("/config");
return data;
};
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
const saveWatchConfig = async (data: Partial<WatchSettings>) => { const saveWatchConfig = async (data: Partial<WatchSettings>) => {
const { data: response } = await apiClient.post("/config/watch", data); const { data: response } = await authApiClient.client.post("/config/watch", data);
return response; return response;
}; };
// --- Component --- // --- Component ---
export function WatchTab() { export function WatchTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>("");
const { data: config, isLoading } = useQuery({ const { data: config, isLoading } = useQuery({
queryKey: ["watchConfig"], queryKey: ["watchConfig"],
queryFn: fetchWatchConfig, queryFn: fetchWatchConfig,
}); });
// Fetch download config to validate requirements
const { data: downloadConfig } = useQuery({
queryKey: ["config"],
queryFn: fetchDownloadConfig,
staleTime: 30000, // 30 seconds
});
// Fetch credentials for fallback validation
const { data: spotifyCredentials } = useQuery({
queryKey: ["credentials", "spotify"],
queryFn: () => fetchCredentials("spotify"),
staleTime: 30000,
});
const { data: deezerCredentials } = useQuery({
queryKey: ["credentials", "deezer"],
queryFn: () => fetchCredentials("deezer"),
staleTime: 30000,
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: saveWatchConfig, mutationFn: saveWatchConfig,
onSuccess: () => { onSuccess: () => {
@@ -46,7 +94,7 @@ export function WatchTab() {
}, },
}); });
const { register, handleSubmit, control, reset } = useForm<WatchSettings>(); const { register, handleSubmit, control, reset, watch } = useForm<WatchSettings>();
useEffect(() => { useEffect(() => {
if (config) { if (config) {
@@ -54,7 +102,47 @@ export function WatchTab() {
} }
}, [config, reset]); }, [config, reset]);
const watchEnabled = watch("enabled");
// Validation effect for watch + download method requirement
useEffect(() => {
let error = "";
// Check if watch can be enabled (need download methods)
if (watchEnabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
error = "To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.";
}
// Check fallback account requirements if watch is enabled and fallback is being used
if (watchEnabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
if (!spotifyCredentials?.length) missingServices.push("Spotify");
if (!deezerCredentials?.length) missingServices.push("Deezer");
error = `Watch with Fallback requires accounts for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
}
setValidationError(error);
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length]);
const onSubmit: SubmitHandler<WatchSettings> = (data) => { const onSubmit: SubmitHandler<WatchSettings> = (data) => {
// Check validation before submitting
if (data.enabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
setValidationError("To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.");
toast.error("Validation failed: Watch requires at least one download method to be enabled in Download Settings.");
return;
}
// Check fallback account requirements if enabling watch with fallback
if (data.enabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
if (!spotifyCredentials?.length) missingServices.push("Spotify");
if (!deezerCredentials?.length) missingServices.push("Deezer");
const error = `Watch with Fallback requires accounts for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
setValidationError(error);
toast.error("Validation failed: " + error);
return;
}
mutation.mutate({ mutation.mutate({
...data, ...data,
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds), watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
@@ -62,33 +150,65 @@ export function WatchTab() {
}; };
if (isLoading) { if (isLoading) {
return <div>Loading watch settings...</div>; return <div className="text-content-muted dark:text-content-muted-dark">Loading watch settings...</div>;
} }
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Watchlist Behavior</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Watchlist Behavior</h3>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label htmlFor="watchEnabledToggle">Enable Watchlist</label> <label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" /> <input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
</div> </div>
{/* Download requirements info */}
{downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Download methods required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.
</p>
</div>
)}
{/* Fallback account requirements info */}
{downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Fallback accounts required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.
</p>
</div>
)}
{/* Validation error display */}
{validationError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error font-medium">{validationError}</p>
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="watchPollIntervalSeconds">Watch Poll Interval (seconds)</label> <label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">Watch Poll Interval (seconds)</label>
<input <input
id="watchPollIntervalSeconds" id="watchPollIntervalSeconds"
type="number" type="number"
min="60" min="60"
{...register("watchPollIntervalSeconds")} {...register("watchPollIntervalSeconds")}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
<p className="text-sm text-gray-500 mt-1">How often to check watched items for updates.</p> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">How often to check watched items for updates.</p>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold">Artist Album Groups</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Artist Album Groups</h3>
<p className="text-sm text-gray-500">Select which album groups to monitor for watched artists.</p> <p className="text-sm text-content-muted dark:text-content-muted-dark">Select which album groups to monitor for watched artists.</p>
<div className="grid grid-cols-2 gap-4 pt-2"> <div className="grid grid-cols-2 gap-4 pt-2">
{ALBUM_GROUPS.map((group) => ( {ALBUM_GROUPS.map((group) => (
<Controller <Controller
@@ -96,7 +216,7 @@ export function WatchTab() {
name="watchedArtistAlbumGroup" name="watchedArtistAlbumGroup"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<label className="flex items-center gap-2"> <label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
<input <input
type="checkbox" type="checkbox"
className="h-5 w-5 rounded" className="h-5 w-5 rounded"
@@ -117,8 +237,8 @@ export function WatchTab() {
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending || !!validationError}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
> >
{mutation.isPending ? "Saving..." : "Save Watch Settings"} {mutation.isPending ? "Saving..." : "Save Watch Settings"}
</button> </button>

View File

@@ -0,0 +1,378 @@
import { useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { AuthContext } from "./auth-context";
import { authApiClient } from "@/lib/api-client";
import type {
User,
LoginRequest,
RegisterRequest,
AuthError,
SSOProvider,
SSOStatusResponse
} from "@/types/auth";
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authEnabled, setAuthEnabled] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [ssoEnabled, setSSOEnabled] = useState(false);
const [ssoProviders, setSSOProviders] = useState<SSOProvider[]>([]);
const [isInitialized, setIsInitialized] = useState(false);
// Guard to prevent multiple simultaneous initializations
const initializingRef = useRef(false);
const isAuthenticated = user !== null;
// Check for SSO token in URL (OAuth callback)
const checkForSSOToken = useCallback(async () => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
console.log("SSO token found in URL, processing...");
try {
const user = await authApiClient.handleSSOToken(token, true); // Default to remember
setUser(user);
// Remove token from URL
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
console.log("SSO login successful:", user.username);
return true;
} catch (error) {
console.error("SSO token processing failed:", error);
// Remove token from URL even if processing failed
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
}
return false;
}, []);
// Initialize authentication on app start
const initializeAuth = useCallback(async () => {
// Prevent multiple simultaneous initializations
if (initializingRef.current) {
console.log("Authentication initialization already in progress, skipping...");
return;
}
try {
initializingRef.current = true;
setIsLoading(true);
console.log("Initializing authentication...");
// First, check for SSO token in URL
const ssoTokenProcessed = await checkForSSOToken();
if (ssoTokenProcessed) {
// SSO token was processed, still need to get auth status for SSO info
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
setRegistrationEnabled(status.registration_enabled);
setSSOEnabled(status.sso_enabled || false);
// Get SSO providers if enabled
if (status.sso_enabled) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
}
}
setIsInitialized(true);
return;
}
// Check if we have a stored token first, before making any API calls
const hasStoredToken = authApiClient.getToken() !== null;
console.log("Has stored token:", hasStoredToken);
if (hasStoredToken) {
// If we have a stored token, validate it first
console.log("Validating stored token...");
const tokenValidation = await authApiClient.validateStoredToken();
if (tokenValidation.isValid && tokenValidation.userData) {
// Token is valid and we have user data
setAuthEnabled(tokenValidation.userData.auth_enabled);
setRegistrationEnabled(tokenValidation.userData.registration_enabled);
setSSOEnabled(tokenValidation.userData.sso_enabled || false);
if (tokenValidation.userData.authenticated && tokenValidation.userData.user) {
setUser(tokenValidation.userData.user);
console.log("Session restored for user:", tokenValidation.userData.user.username);
// Get SSO providers if enabled
if (tokenValidation.userData.sso_enabled) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
}
}
setIsInitialized(true);
return;
} else {
setUser(null);
console.log("Token valid but no user data");
}
} else {
setUser(null);
console.log("Stored token is invalid, cleared");
}
}
// If no stored token or token validation failed, check auth status without token
console.log("Checking auth status...");
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
setRegistrationEnabled(status.registration_enabled);
setSSOEnabled(status.sso_enabled || false);
// Get SSO providers if enabled
if (status.sso_enabled) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
}
}
if (!status.auth_enabled) {
console.log("Authentication is disabled");
setUser(null);
setIsInitialized(true);
return;
}
// If auth is enabled but we're not authenticated, user needs to log in
setUser(null);
console.log("Authentication required");
} catch (error: any) {
console.error("Auth initialization failed:", error);
setUser(null);
// Only clear all auth data on critical initialization failures
// Don't clear tokens due to network errors
if (error.message?.includes("Network Error") || error.code === "ECONNABORTED") {
console.log("Network error during auth initialization, keeping stored token");
} else {
authApiClient.clearAllAuthData();
}
} finally {
initializingRef.current = false;
setIsLoading(false);
setIsInitialized(true);
console.log("Authentication initialization complete");
}
}, [checkForSSOToken]);
// Initialize on mount
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// Check authentication status (for manual refresh)
const checkAuthStatus = useCallback(async () => {
if (!isInitialized) {
return; // Don't check until initialized
}
try {
setIsLoading(true);
const status = await authApiClient.checkAuthStatus();
setAuthEnabled(status.auth_enabled);
setRegistrationEnabled(status.registration_enabled);
setSSOEnabled(status.sso_enabled || false);
// Get SSO providers if enabled
if (status.sso_enabled) {
try {
const ssoStatus = await authApiClient.getSSOStatus();
setSSOProviders(ssoStatus.providers);
// Update registration status based on SSO status (SSO registration control takes precedence)
setRegistrationEnabled(ssoStatus.registration_enabled);
} catch (error) {
console.warn("Failed to get SSO status:", error);
setSSOProviders([]);
}
}
if (status.auth_enabled && status.authenticated && status.user) {
setUser(status.user);
} else {
setUser(null);
// Clear any stale token
if (authApiClient.getToken()) {
authApiClient.clearToken();
}
}
} catch (error) {
console.error("Auth status check failed:", error);
setUser(null);
authApiClient.clearToken();
} finally {
setIsLoading(false);
}
}, [isInitialized]);
// Login function with remember me option
const login = async (credentials: LoginRequest, rememberMe: boolean = true): Promise<void> => {
try {
setIsLoading(true);
const response = await authApiClient.login(credentials, rememberMe);
setUser(response.user);
console.log(`User logged in: ${response.user.username} (remember: ${rememberMe})`);
} catch (error: any) {
const authError: AuthError = {
message: error.response?.data?.detail || "Login failed",
status: error.response?.status,
};
throw authError;
} finally {
setIsLoading(false);
}
};
// Register function
const register = async (userData: RegisterRequest): Promise<void> => {
try {
setIsLoading(true);
await authApiClient.register(userData);
// Note: Registration doesn't auto-login, user needs to log in afterwards
} catch (error: any) {
const authError: AuthError = {
message: error.response?.data?.detail || "Registration failed",
status: error.response?.status,
};
throw authError;
} finally {
setIsLoading(false);
}
};
// Logout function
const logout = useCallback(async () => {
try {
await authApiClient.logout();
console.log("User logged out");
} catch (error) {
console.error("Logout error:", error);
} finally {
setUser(null);
// Don't need to call checkAuthStatus after logout since we're clearing everything
}
}, []);
// Token management
const getToken = useCallback(() => {
return authApiClient.getToken();
}, []);
const setToken = useCallback((token: string | null, rememberMe: boolean = true) => {
authApiClient.setToken(token, rememberMe);
if (token) {
// If we're setting a token, reinitialize to get user info
initializeAuth();
} else {
setUser(null);
}
}, [initializeAuth]);
// Get remember preference
const isRemembered = useCallback(() => {
return authApiClient.isRemembered();
}, []);
// SSO methods
const getSSOStatus = useCallback(async (): Promise<SSOStatusResponse> => {
return await authApiClient.getSSOStatus();
}, []);
const handleSSOCallback = useCallback(async (token: string): Promise<void> => {
try {
const user = await authApiClient.handleSSOToken(token, true);
setUser(user);
} catch (error) {
console.error("SSO callback failed:", error);
throw error;
}
}, []);
// Listen for storage changes (logout in another tab)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "auth_token" || e.key === "auth_remember") {
console.log("Auth storage changed in another tab");
// Re-initialize auth when storage changes
initializeAuth();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [initializeAuth]);
// Update API client when auth enabled state changes
useEffect(() => {
authApiClient.setAuthEnabled(authEnabled);
console.log(`API client auth enabled state updated: ${authEnabled}`);
}, [authEnabled]);
// Enhanced context value with new methods
const contextValue = {
// State
user,
isAuthenticated,
isLoading,
authEnabled,
registrationEnabled,
ssoEnabled,
ssoProviders,
// Actions
login,
register,
logout,
checkAuthStatus,
// SSO Actions
getSSOStatus,
handleSSOCallback,
// Token management
getToken,
setToken,
// Session management
isRemembered,
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import apiClient from "../lib/api-client"; import { authApiClient } from "../lib/api-client";
import { SettingsContext, type AppSettings } from "./settings-context"; import { SettingsContext, type AppSettings } from "./settings-context";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./auth-context";
// --- Case Conversion Utility --- // --- Case Conversion Utility ---
// This is added here to simplify the fix and avoid module resolution issues. // This is added here to simplify the fix and avoid module resolution issues.
@@ -100,36 +101,52 @@ interface FetchedCamelCaseSettings {
} }
const fetchSettings = async (): Promise<FlatAppSettings> => { const fetchSettings = async (): Promise<FlatAppSettings> => {
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([ try {
apiClient.get("/config"), const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
apiClient.get("/config/watch"), authApiClient.client.get("/config"),
]); authApiClient.client.get("/config/watch"),
]);
const combinedConfig = { const combinedConfig = {
...generalConfig, ...generalConfig,
watch: watchConfig, watch: watchConfig,
}; };
// Transform the keys before returning the data // Transform the keys before returning the data
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings; const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
return camelData as unknown as FlatAppSettings; return camelData as unknown as FlatAppSettings;
} catch (error: any) {
// If we get authentication errors, return default settings
if (error.response?.status === 401 || error.response?.status === 403) {
console.log("Authentication required for config access, using default settings");
return defaultSettings;
}
// Re-throw other errors for React Query to handle
throw error;
}
}; };
export function SettingsProvider({ children }: { children: ReactNode }) { export function SettingsProvider({ children }: { children: ReactNode }) {
const { isLoading, authEnabled, isAuthenticated, user } = useAuth();
// Only fetch settings when auth is ready and user is admin (or auth is disabled)
const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin"));
const { const {
data: settings, data: settings,
isLoading, isLoading: isSettingsLoading,
isError, isError,
} = useQuery({ } = useQuery({
queryKey: ["config"], queryKey: ["config"],
queryFn: fetchSettings, queryFn: fetchSettings,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin
}); });
// Use default settings on error to prevent app crash // Use default settings on error to prevent app crash
const value = { settings: isError ? defaultSettings : settings || null, isLoading }; const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading };
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>; return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
} }

View File

@@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
import type { AuthContextType } from "@/types/auth";
export const AuthContext = createContext<AuthContextType | null>(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Optional hook that doesn't throw an error if used outside provider
export function useAuthOptional() {
return useContext(AuthContext);
}

View File

@@ -1,56 +1,163 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { SummaryObject } from "@/types/callbacks"; import type { SummaryObject, CallbackObject, TrackCallbackObject, AlbumCallbackObject, PlaylistCallbackObject, ProcessingCallbackObject } from "@/types/callbacks";
export type DownloadType = "track" | "album" | "artist" | "playlist"; export type DownloadType = "track" | "album" | "playlist";
export type QueueStatus =
| "initializing"
| "pending"
| "downloading"
| "processing"
| "completed"
| "error"
| "skipped"
| "cancelled"
| "done"
| "queued"
| "retrying";
// Type guards for callback objects
const isProcessingCallback = (obj: CallbackObject): obj is ProcessingCallbackObject => {
return "status" in obj && typeof obj.status === "string";
};
const isTrackCallback = (obj: CallbackObject): obj is TrackCallbackObject => {
return "track" in obj && "status_info" in obj;
};
const isAlbumCallback = (obj: CallbackObject): obj is AlbumCallbackObject => {
return "album" in obj && "status_info" in obj;
};
const isPlaylistCallback = (obj: CallbackObject): obj is PlaylistCallbackObject => {
return "playlist" in obj && "status_info" in obj;
};
// Simplified queue item that works directly with callback objects
export interface QueueItem { export interface QueueItem {
id: string; id: string;
name: string; taskId?: string;
type: DownloadType; downloadType: DownloadType;
spotifyId: string; spotifyId: string;
// Display Info // Current callback data - this is the source of truth
artist?: string; lastCallback?: CallbackObject;
albumName?: string;
playlistOwner?: string; // Derived display properties (computed from callback)
currentTrackTitle?: string; name: string;
artist: string;
// Status and Progress
status: QueueStatus; // Summary data for completed downloads
taskId?: string; summary?: SummaryObject;
error?: string;
canRetry?: boolean; // Error state
progress?: number; error?: string;
speed?: string;
size?: string;
eta?: string;
currentTrackNumber?: number;
totalTracks?: number;
summary?: SummaryObject;
} }
// Status extraction utilities
export const getStatus = (item: QueueItem): string => {
if (!item.lastCallback) {
// Only log if this seems problematic (task has been around for a while)
return "initializing";
}
if (isProcessingCallback(item.lastCallback)) {
return item.lastCallback.status;
}
if (isTrackCallback(item.lastCallback)) {
// For parent downloads, check if this is the final track
if (item.downloadType === "album" || item.downloadType === "playlist") {
const currentTrack = item.lastCallback.current_track || 1;
const totalTracks = item.lastCallback.total_tracks || 1;
const trackStatus = item.lastCallback.status_info.status;
// If this is the last track and it's in a terminal state, the parent is done
if (currentTrack >= totalTracks && ["done", "skipped", "error"].includes(trackStatus)) {
console.log(`🎵 Playlist/Album completed: ${item.name} (track ${currentTrack}/${totalTracks}, status: ${trackStatus})`);
return "completed";
}
// If track is in terminal state but not the last track, parent is still downloading
if (["done", "skipped", "error"].includes(trackStatus)) {
console.log(`🎵 Playlist/Album progress: ${item.name} (track ${currentTrack}/${totalTracks}, status: ${trackStatus}) - continuing...`);
return "downloading";
}
// Track is actively being processed
return "downloading";
}
return item.lastCallback.status_info.status;
}
if (isAlbumCallback(item.lastCallback)) {
return item.lastCallback.status_info.status;
}
if (isPlaylistCallback(item.lastCallback)) {
return item.lastCallback.status_info.status;
}
console.warn(`getStatus: Unknown callback type for item ${item.id}:`, item.lastCallback);
return "unknown";
};
export const isActiveStatus = (status: string): boolean => {
return ["initializing", "processing", "downloading", "real-time", "progress", "track_progress", "retrying", "queued"].includes(status);
};
export const isTerminalStatus = (status: string): boolean => {
// Handle both "complete" (backend) and "completed" (frontend) for compatibility
return ["completed", "complete", "done", "error", "cancelled", "skipped"].includes(status);
};
// Progress calculation utilities
export const getProgress = (item: QueueItem): number | undefined => {
if (!item.lastCallback) return undefined;
// For individual tracks
if (item.downloadType === "track" && isTrackCallback(item.lastCallback)) {
if (item.lastCallback.status_info.status === "real-time" && "progress" in item.lastCallback.status_info) {
return item.lastCallback.status_info.progress;
}
return undefined;
}
// For parent downloads (albums/playlists) - calculate based on track callbacks
if ((item.downloadType === "album" || item.downloadType === "playlist") && isTrackCallback(item.lastCallback)) {
const callback = item.lastCallback;
const currentTrack = callback.current_track || 1;
const totalTracks = callback.total_tracks || 1;
const trackProgress = (callback.status_info.status === "real-time" && "progress" in callback.status_info)
? callback.status_info.progress : 0;
// Formula: ((completed tracks) + (current track progress / 100)) / total tracks * 100
const completedTracks = currentTrack - 1;
return ((completedTracks + (trackProgress / 100)) / totalTracks) * 100;
}
return undefined;
};
// Display info extraction
export const getCurrentTrackInfo = (item: QueueItem): { current?: number; total?: number; title?: string } => {
if (!item.lastCallback) return {};
if (isTrackCallback(item.lastCallback)) {
return {
current: item.lastCallback.current_track,
total: item.lastCallback.total_tracks,
title: item.lastCallback.track.title
};
}
return {};
};
export interface QueueContextType { export interface QueueContextType {
items: QueueItem[]; items: QueueItem[];
isVisible: boolean; isVisible: boolean;
activeCount: number;
totalTasks: number;
hasMore: boolean;
isLoadingMore: boolean;
// Actions
addItem: (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => void; addItem: (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => void;
removeItem: (id: string) => void; removeItem: (id: string) => void;
retryItem: (id: string) => void; cancelItem: (id: string) => void;
toggleVisibility: () => void; toggleVisibility: () => void;
clearCompleted: () => void; clearCompleted: () => void;
cancelAll: () => void; cancelAll: () => void;
cancelItem: (id: string) => void; loadMoreTasks: () => void;
restartSSE: () => void; // For auth state changes
} }
export const QueueContext = createContext<QueueContextType | undefined>(undefined); export const QueueContext = createContext<QueueContextType | undefined>(undefined);

View File

@@ -1,7 +1,237 @@
@import "tailwindcss"; @import "tailwindcss";
/* Override dark mode to use class-based instead of prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Background Colors with automatic dark mode variants and gradients */
--color-surface: #ffffff;
--color-surface-dark: #0a0a0a;
--color-surface-secondary: #fafbfc;
--color-surface-secondary-dark: #141418;
--color-surface-muted: #f4f6f8;
--color-surface-muted-dark: #1f1f23;
--color-surface-accent: #e8ecf0;
--color-surface-accent-dark: #2d2d33;
--color-surface-overlay: rgba(255, 255, 255, 0.85);
--color-surface-overlay-dark: rgba(10, 10, 12, 0.85);
/* Gradient backgrounds for depth */
--gradient-surface: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
--gradient-surface-dark: linear-gradient(135deg, #0a0a0a 0%, #141418 100%);
--gradient-muted: linear-gradient(135deg, #f4f6f8 0%, #e8ecf0 100%);
--gradient-muted-dark: linear-gradient(135deg, #1f1f23 0%, #2d2d33 100%);
--gradient-accent: linear-gradient(135deg, #e8ecf0 0%, #d6dce4 100%);
--gradient-accent-dark: linear-gradient(135deg, #2d2d33 0%, #3a3a42 100%);
/* Text Colors with automatic dark mode variants and subtle tints */
--color-content-primary: #0d1117;
--color-content-primary-dark: #f0f6fc;
--color-content-secondary: #4a5568;
--color-content-secondary-dark: #a0aec0;
--color-content-muted: #718096;
--color-content-muted-dark: #9ca3af;
--color-content-accent: #6b7280;
--color-content-accent-dark: #6b7280;
--color-content-inverse: #ffffff;
--color-content-inverse-dark: #0d1117;
/* Interactive Colors - Enhanced Spotify green with sophistication */
--color-primary: #1ed760;
--color-primary-hover: #1fdf64;
--color-primary-active: #1db954;
--color-primary-muted: #d1f7e0;
/* Secondary colors with purple accent for contrast */
--color-secondary: #8b5fbf;
--color-secondary-hover: #9d70d1;
--color-secondary-active: #7a4fa3;
--color-secondary-muted: #f0ebff;
/* Accent purple for complementary design */
--color-accent: #8b5fbf;
--color-accent-hover: #9d70d1;
--color-accent-active: #7a4fa3;
--color-accent-muted: #f0ebff;
/* Status Colors - Refined and harmonious */
--color-success: #28a745;
--color-success-hover: #1e7b34;
--color-success-muted: #d4edda;
--color-success-text: #155724;
--color-error: #dc3545;
--color-error-hover: #c82333;
--color-error-muted: #f8d7da;
--color-error-text: #721c24;
--color-warning: #fd7e14;
--color-warning-hover: #e8590c;
--color-warning-muted: #fff3cd;
--color-warning-text: #856404;
--color-info: #17a2b8;
--color-info-hover: #138496;
--color-info-muted: #d1ecf1;
--color-info-text: #0c5460;
--color-processing: #8b5fbf;
--color-processing-hover: #7a4fa3;
--color-processing-muted: #f0ebff;
--color-processing-text: #5d3e7a;
/* Border Colors with automatic dark mode variants and gradients */
--color-border: #e2e8f0;
--color-border-dark: #374151;
--color-border-muted: #f1f5f9;
--color-border-muted-dark: #1f2937;
--color-border-accent: #cbd5e1;
--color-border-accent-dark: #4b5563;
--color-border-focus: #1ed760;
/* Gradient borders for enhanced depth */
--gradient-border: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
--gradient-border-dark: linear-gradient(135deg, #374151 0%, #4b5563 100%);
/* Input Colors with automatic dark mode variants and gradients */
--color-input-background: #f8fafc;
--color-input-background-dark: #1f2937;
--color-input-border: #e2e8f0;
--color-input-border-dark: #374151;
--color-input-focus: #1ed760;
--gradient-input: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
--gradient-input-dark: linear-gradient(135deg, #1f2937 0%, #111827 100%);
/* Button Colors */
--color-button-primary: #1ed760;
--color-button-primary-hover: #1fdf64;
--color-button-primary-text: #ffffff;
--color-button-secondary: #f4f6f8;
--color-button-secondary-hover: #8b5fbf;
--color-button-secondary-text: #57606a;
--color-button-secondary-text-hover: #ffffff;
--color-button-success: #28a745;
--color-button-success-hover: #1e7b34;
--color-button-success-text: #ffffff;
--color-icon-button-hover: #f1f5f9;
--color-icon-button-hover-dark: #374151;
/* Icon Colors */
--color-icon-primary: #000000;
--color-icon-primary-hover: #000000;
--color-icon-primary-active: #000000;
--color-icon-primary-dark: #ffffff;
--color-icon-primary-hover-dark: #ffffff;
--color-icon-primary-active-dark: #ffffff;
--color-icon-secondary: #000000;
--color-icon-secondary-hover: #000000;
--color-icon-secondary-active: #000000;
--color-icon-secondary-dark: #ffffff;
--color-icon-secondary-hover-dark: #ffffff;
--color-icon-secondary-active-dark: #ffffff;
--color-icon-muted: #000000;
--color-icon-muted-hover: #000000;
--color-icon-muted-active: #000000;
--color-icon-muted-dark: #ffffff;
--color-icon-muted-hover-dark: #ffffff;
--color-icon-muted-active-dark: #ffffff;
--color-icon-accent: #000000;
--color-icon-accent-hover: #000000;
--color-icon-accent-active: #000000;
--color-icon-accent-dark: #ffffff;
--color-icon-accent-hover-dark: #ffffff;
--color-icon-accent-active-dark: #ffffff;
--color-icon-success: #000000;
--color-icon-success-hover: #000000;
--color-icon-success-active: #000000;
--color-icon-success-dark: #ffffff;
--color-icon-success-hover-dark: #ffffff;
--color-icon-success-active-dark: #ffffff;
--color-icon-error: #000000;
--color-icon-error-hover: #000000;
--color-icon-error-active: #000000;
--color-icon-error-dark: #ffffff;
--color-icon-error-hover-dark: #ffffff;
--color-icon-error-active-dark: #ffffff;
--color-icon-warning: #000000;
--color-icon-warning-hover: #000000;
--color-icon-warning-active: #000000;
--color-icon-warning-dark: #ffffff;
--color-icon-warning-hover-dark: #ffffff;
--color-icon-warning-active-dark: #ffffff;
--color-icon-inverse: #ffffff;
--color-icon-inverse-dark: #000000;
}
@layer base { @layer base {
/* PWA Safe Area Support */
:root {
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
}
/* PWA specific body styling */
body {
background: var(--color-surface);
overscroll-behavior: none;
}
.dark body {
background: var(--color-surface-dark);
}
/* PWA viewport fixes */
html {
height: 100vh;
height: 100dvh; /* Dynamic viewport height for mobile */
}
body {
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 0;
}
a { a {
@apply no-underline hover:underline cursor-pointer; @apply no-underline hover:underline cursor-pointer;
} }
/* Logo Utility Classes - Using Tailwind utilities */
.logo {
@apply brightness-0 dark:invert;
}
/* PWA Header Safe Area Classes */
.pwa-header {
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
.pwa-footer {
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
/* PWA Main Content Safe Area */
.pwa-main {
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
} }

View File

@@ -1,41 +1,377 @@
import axios from "axios"; import axios from "axios";
import type { AxiosInstance } from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import type {
LoginRequest,
RegisterRequest,
LoginResponse,
AuthStatusResponse,
User,
CreateUserRequest,
SSOStatusResponse
} from "@/types/auth";
const apiClient = axios.create({ class AuthApiClient {
baseURL: "/api", private apiClient: AxiosInstance;
headers: { private token: string | null = null;
"Content-Type": "application/json", private isCheckingToken: boolean = false;
}, private authEnabled: boolean = false; // Track if auth is enabled
timeout: 10000, // 10 seconds timeout
});
// Response interceptor for error handling constructor() {
apiClient.interceptors.response.use( this.apiClient = axios.create({
(response) => { baseURL: "/api",
const contentType = response.headers["content-type"]; headers: {
if (contentType && contentType.includes("application/json")) { "Content-Type": "application/json",
return response; },
} timeout: 10000,
// If the response is not JSON, reject it to trigger the error handling
const error = new Error("Invalid response type. Expected JSON.");
toast.error("API Error", {
description: "Received an invalid response from the server. Expected JSON data.",
}); });
return Promise.reject(error);
},
(error) => {
if (error.code === "ECONNABORTED") {
toast.error("Request Timed Out", {
description: "The server did not respond in time. Please try again later.",
});
} else {
const errorMessage = error.response?.data?.error || error.message || "An unknown error occurred.";
toast.error("API Error", {
description: errorMessage,
});
}
return Promise.reject(error);
},
);
export default apiClient; // Load token from storage on initialization
this.loadTokenFromStorage();
// Request interceptor to add auth token
this.apiClient.interceptors.request.use(
(config) => {
// Only add auth header if auth is enabled and we have a token
if (this.authEnabled && this.token) {
config.headers.Authorization = `Bearer ${this.token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
this.apiClient.interceptors.response.use(
(response) => {
const contentType = response.headers["content-type"];
if (contentType && contentType.includes("application/json")) {
return response;
}
const error = new Error("Invalid response type. Expected JSON.");
toast.error("API Error", {
description: "Received an invalid response from the server.",
});
return Promise.reject(error);
},
(error) => {
// Handle authentication errors
if (error.response?.status === 401) {
// Only process auth errors if auth is enabled
if (this.authEnabled) {
// Only clear token for auth-related endpoints
const requestUrl = error.config?.url || "";
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
if (isAuthEndpoint) {
// Clear invalid token only for auth endpoints
this.clearToken();
// Only show auth error if not during initial token check
if (!this.isCheckingToken) {
toast.error("Session Expired", {
description: "Please log in again to continue.",
});
}
} else {
// For non-auth endpoints, just log the 401 but don't clear token
// The token might still be valid for auth endpoints
console.log(`401 error on non-auth endpoint: ${requestUrl}`);
}
} else {
// Auth is disabled, 401 errors are expected for auth endpoints
console.log("401 error received but auth is disabled - this is expected");
}
} else if (error.response?.status === 403) {
// Only show access denied errors if auth is enabled
if (this.authEnabled) {
toast.error("Access Denied", {
description: "You don't have permission to perform this action.",
});
} else {
console.log("403 error received but auth is disabled - this may be expected");
}
} else if (error.code === "ECONNABORTED") {
toast.error("Request Timed Out", {
description: "The server did not respond in time. Please try again later.",
});
} else {
const errorMessage = error.response?.data?.detail ||
error.response?.data?.error ||
error.message ||
"An unknown error occurred.";
// Don't show toast errors during token validation
if (!this.isCheckingToken) {
toast.error("API Error", {
description: errorMessage,
});
}
}
return Promise.reject(error);
}
);
}
// Enhanced token management with storage options
setToken(token: string | null, rememberMe: boolean = true) {
this.token = token;
if (token) {
if (rememberMe) {
// Store in localStorage for persistence across browser sessions
localStorage.setItem("auth_token", token);
localStorage.setItem("auth_remember", "true");
sessionStorage.removeItem("auth_token"); // Clear from session storage
} else {
// Store in sessionStorage for current session only
sessionStorage.setItem("auth_token", token);
localStorage.removeItem("auth_token"); // Clear from persistent storage
localStorage.removeItem("auth_remember");
}
} else {
// Clear all storage
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_remember");
sessionStorage.removeItem("auth_token");
}
}
getToken(): string | null {
return this.token;
}
isRemembered(): boolean {
return localStorage.getItem("auth_remember") === "true";
}
private loadTokenFromStorage() {
// Try localStorage first (persistent)
let token = localStorage.getItem("auth_token");
let isRemembered = localStorage.getItem("auth_remember") === "true";
// If not found in localStorage, try sessionStorage
if (!token) {
token = sessionStorage.getItem("auth_token");
isRemembered = false;
}
if (token) {
this.token = token;
console.log(`Loaded ${isRemembered ? 'persistent' : 'session'} token from storage`);
}
}
clearToken() {
// Preserve the remember me preference when clearing invalid tokens
const wasRemembered = this.isRemembered();
this.token = null;
if (wasRemembered) {
// Keep the remember preference but remove the invalid token
localStorage.removeItem("auth_token");
// Keep auth_remember flag for next login
} else {
// Session-only token, clear everything
sessionStorage.removeItem("auth_token");
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_remember");
}
}
clearAllAuthData() {
// Use this method for complete logout - clears everything
this.token = null;
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_remember");
sessionStorage.removeItem("auth_token");
}
// Enhanced token validation that returns detailed information
async validateStoredToken(): Promise<{ isValid: boolean; userData?: AuthStatusResponse }> {
if (!this.token) {
return { isValid: false };
}
try {
this.isCheckingToken = true;
const response = await this.apiClient.get<AuthStatusResponse>("/auth/status");
// If the token is valid and user is authenticated
if (response.data.auth_enabled && response.data.authenticated && response.data.user) {
console.log("Stored token is valid, user authenticated");
return { isValid: true, userData: response.data };
} else {
console.log("Stored token is invalid or user not authenticated");
this.clearToken();
return { isValid: false };
}
} catch (error) {
console.log("Token validation failed:", error);
this.clearToken();
return { isValid: false };
} finally {
this.isCheckingToken = false;
}
}
// Auth API methods
async checkAuthStatus(): Promise<AuthStatusResponse> {
const response = await this.apiClient.get<AuthStatusResponse>("/auth/status");
return response.data;
}
async login(credentials: LoginRequest, rememberMe: boolean = true): Promise<LoginResponse> {
const response = await this.apiClient.post<LoginResponse>("/auth/login", credentials);
const loginData = response.data;
// Store the token with remember preference
this.setToken(loginData.access_token, rememberMe);
toast.success("Login Successful", {
description: `Test , ${loginData.user.username}!`,
});
return loginData;
}
async register(userData: RegisterRequest): Promise<{ message: string }> {
const response = await this.apiClient.post("/auth/register", userData);
toast.success("Registration Successful", {
description: "Account created successfully! You can now log in.",
});
return response.data;
}
async logout(): Promise<void> {
try {
await this.apiClient.post("/auth/logout");
} catch (error) {
// Ignore logout errors - clear token anyway
console.warn("Logout request failed:", error);
}
this.clearAllAuthData(); // Changed from this.clearToken()
toast.success("Logged Out", {
description: "You have been logged out successfully.",
});
}
async getCurrentUser(): Promise<User> {
const response = await this.apiClient.get<User>("/auth/profile");
return response.data;
}
async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const response = await this.apiClient.put("/auth/profile/password", {
current_password: currentPassword,
new_password: newPassword,
});
toast.success("Password Changed", {
description: "Your password has been updated successfully.",
});
return response.data;
}
// Admin methods
async listUsers(): Promise<User[]> {
const response = await this.apiClient.get<User[]>("/auth/users");
return response.data;
}
async deleteUser(username: string): Promise<{ message: string }> {
const response = await this.apiClient.delete(`/auth/users/${username}`);
toast.success("User Deleted", {
description: `User ${username} has been deleted.`,
});
return response.data;
}
async updateUserRole(username: string, role: "user" | "admin"): Promise<{ message: string }> {
const response = await this.apiClient.put(`/auth/users/${username}/role`, { role });
toast.success("Role Updated", {
description: `User ${username} role updated to ${role}.`,
});
return response.data;
}
async createUser(userData: CreateUserRequest): Promise<{ message: string }> {
const response = await this.apiClient.post("/auth/users/create", userData);
toast.success("User Created", {
description: `User ${userData.username} created successfully.`,
});
return response.data;
}
async adminResetPassword(username: string, newPassword: string): Promise<{ message: string }> {
const response = await this.apiClient.put(`/auth/users/${username}/password`, {
new_password: newPassword,
});
toast.success("Password Reset", {
description: `Password for ${username} has been reset successfully.`,
});
return response.data;
}
// SSO methods
async getSSOStatus(): Promise<SSOStatusResponse> {
const response = await this.apiClient.get<SSOStatusResponse>("/auth/sso/status");
return response.data;
}
// Handle SSO callback token (when user returns from OAuth provider)
async handleSSOToken(token: string, rememberMe: boolean = true): Promise<User> {
// Set the token and get user info
this.setToken(token, rememberMe);
// Validate the token and get user data
const tokenValidation = await this.validateStoredToken();
if (tokenValidation.isValid && tokenValidation.userData?.user) {
toast.success("SSO Login Successful", {
description: `Welcome, ${tokenValidation.userData.user.username}!`,
});
return tokenValidation.userData.user;
} else {
this.clearToken();
throw new Error("Invalid SSO token");
}
}
// Get SSO login URLs (these redirect to OAuth provider)
getSSOLoginUrl(provider: string): string {
return `/api/auth/sso/login/${provider}`;
}
// Method to set auth enabled state (to be called by AuthProvider)
setAuthEnabled(enabled: boolean) {
this.authEnabled = enabled;
}
getAuthEnabled(): boolean {
return this.authEnabled;
}
// Expose the underlying axios instance for other API calls
get client() {
return this.apiClient;
}
}
// Create and export a singleton instance
export const authApiClient = new AuthApiClient();
// Export the client as default for backward compatibility
export default authApiClient.client;

View File

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

View File

@@ -1,11 +1,31 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./router"; import { router } from "./router";
import { AuthProvider } from "./contexts/AuthProvider";
import { setupDarkMode } from "./lib/theme";
import "./index.css"; import "./index.css";
// Initialize dark mode
setupDarkMode();
// Create a QueryClient instance
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -1,15 +1,18 @@
import { createRouter, createRootRoute, createRoute } from "@tanstack/react-router"; import { createRouter, createRootRoute, createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { Root } from "./routes/root"; import Root from "./routes/root";
import { Album } from "./routes/album";
import { Artist } from "./routes/artist";
import { Track } from "./routes/track";
import { Home } from "./routes/home";
import { Config } from "./routes/config";
import { Playlist } from "./routes/playlist";
import { History } from "./routes/history";
import { Watchlist } from "./routes/watchlist";
import apiClient from "./lib/api-client"; import apiClient from "./lib/api-client";
import type { SearchResult } from "./types/spotify"; import type { SearchResult, SearchApiResponse } from "./types/spotify";
import { isValidSearchResult } from "./types/spotify";
// Lazy load route components for code splitting
const Album = lazyRouteComponent(() => import("./routes/album").then(m => ({ default: m.Album })));
const Artist = lazyRouteComponent(() => import("./routes/artist").then(m => ({ default: m.Artist })));
const Track = lazyRouteComponent(() => import("./routes/track").then(m => ({ default: m.Track })));
const Home = lazyRouteComponent(() => import("./routes/home").then(m => ({ default: m.Home })));
const Config = lazyRouteComponent(() => import("./routes/config").then(m => ({ default: m.Config })));
const Playlist = lazyRouteComponent(() => import("./routes/playlist").then(m => ({ default: m.Playlist })));
const History = lazyRouteComponent(() => import("./routes/history").then(m => ({ default: m.History })));
const Watchlist = lazyRouteComponent(() => import("./routes/watchlist").then(m => ({ default: m.Watchlist })));
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: Root, component: Root,
@@ -40,12 +43,17 @@ export const indexRoute = createRoute({
return { items: [{ ...response.data, model: urlType as "track" | "album" | "artist" | "playlist" }] }; return { items: [{ ...response.data, model: urlType as "track" | "album" | "artist" | "playlist" }] };
} }
const response = await apiClient.get<{ items: SearchResult[] }>(`/search?q=${q}&search_type=${type}&limit=50`); const response = await apiClient.get<SearchApiResponse>(`/search?q=${q}&search_type=${type}&limit=50`);
const augmentedResults = response.data.items.map((item) => ({
...item, // Filter out null values and add the model property
model: type, const validResults = response.data.items
})); .filter(isValidSearchResult)
return { items: augmentedResults }; .map((item) => ({
...item,
model: type,
}));
return { items: validResults };
}, },
gcTime: 5 * 60 * 1000, // 5 minutes gcTime: 5 * 60 * 1000, // 5 minutes
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
@@ -73,6 +81,11 @@ const configRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/config", path: "/config",
component: Config, component: Config,
validateSearch: (search: Record<string, unknown>): { tab?: string } => {
return {
tab: typeof search.tab === "string" ? search.tab : undefined,
};
},
}); });
const playlistRoute = createRoute({ const playlistRoute = createRoute({

View File

@@ -70,45 +70,52 @@ export const Album = () => {
const hasExplicitTrack = album.tracks.items.some((track) => track.explicit); const hasExplicitTrack = album.tracks.items.some((track) => track.explicit);
return ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="mb-6"> {/* Back Button */}
<div className="mb-4 md:mb-6">
<button <button
onClick={() => window.history.back()} onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors" className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
> >
<FaArrowLeft /> <FaArrowLeft className="icon-secondary hover:icon-primary" />
<span>Back to results</span> <span>Back to results</span>
</button> </button>
</div> </div>
<div className="flex flex-col md:flex-row items-start gap-6">
<img {/* Album Header - Mobile Optimized */}
src={album.images[0]?.url || "/placeholder.jpg"} <div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm">
alt={album.name} <div className="flex flex-col items-center gap-4 md:gap-6">
className="w-48 h-48 object-cover rounded-lg shadow-lg" <img
/> src={album.images[0]?.url || "/placeholder.jpg"}
<div className="flex-grow space-y-2"> alt={album.name}
<h1 className="text-3xl font-bold">{album.name}</h1> className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 object-cover rounded-lg shadow-lg mx-auto"
<p className="text-lg text-gray-500 dark:text-gray-400"> />
By{" "} <div className="flex-grow space-y-2 text-center">
{album.artists.map((artist, index) => ( <h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{album.name}</h1>
<span key={artist.id}> <p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline"> By{" "}
{artist.name} {album.artists.map((artist, index) => (
</Link> <span key={artist.id}>
{index < album.artists.length - 1 && ", "} <Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline font-medium">
</span> {artist.name}
))} </Link>
</p> {index < album.artists.length - 1 && ", "}
<p className="text-sm text-gray-400 dark:text-gray-500"> </span>
{new Date(album.release_date).getFullYear()} {album.total_tracks} songs ))}
</p> </p>
<p className="text-xs text-gray-400 dark:text-gray-600">{album.label}</p> <p className="text-sm text-content-muted dark:text-content-muted-dark">
{new Date(album.release_date).getFullYear()} {album.total_tracks} songs
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">{album.label}</p>
</div>
</div> </div>
<div className="flex flex-col items-center gap-2">
{/* Download Button - Full Width on Mobile */}
<div className="mt-4 md:mt-6">
<button <button
onClick={handleDownloadAlbum} onClick={handleDownloadAlbum}
disabled={isExplicitFilterEnabled && hasExplicitTrack} disabled={isExplicitFilterEnabled && hasExplicitTrack}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed" className="w-full px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed font-semibold shadow-sm"
title={ title={
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album" isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
} }
@@ -118,67 +125,70 @@ export const Album = () => {
</div> </div>
</div> </div>
<div className="space-y-4"> {/* Tracks Section */}
<h2 className="text-xl font-semibold">Tracks</h2> <div className="space-y-3 md:space-y-4">
<div className="space-y-2"> <h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark px-1">Tracks</h2>
{album.tracks.items.map((track, index) => { <div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
if (isExplicitFilterEnabled && track.explicit) { <div className="space-y-1 md:space-y-2">
{album.tracks.items.map((track, index) => {
if (isExplicitFilterEnabled && track.explicit) {
return (
<div
key={index}
className="flex items-center justify-between p-3 md:p-4 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
>
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
<p className="font-medium text-content-muted dark:text-content-muted-dark text-sm md:text-base truncate">Explicit track filtered</p>
</div>
<span className="text-content-muted dark:text-content-muted-dark text-sm shrink-0">--:--</span>
</div>
);
}
return ( return (
<div <div
key={index} key={track.id}
className="flex items-center justify-between p-3 bg-gray-100 dark:bg-gray-800 rounded-lg opacity-50" className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span> <span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
<p className="font-medium text-gray-500">Explicit track filtered</p> <div className="min-w-0 flex-1">
<p className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base truncate">{track.name}</p>
<p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link
to="/artist/$artistId"
params={{
artistId: artist.id,
}}
className="hover:underline"
>
{artist.name}
</Link>
{index < track.artists.length - 1 && ", "}
</span>
))}
</p>
</div>
</div>
<div className="flex items-center gap-2 md:gap-4 shrink-0">
<span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span>
<button
onClick={() => handleDownloadTrack(track)}
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all hover:scale-105 hover:shadow-sm"
title="Download"
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
</button>
</div> </div>
<span className="text-gray-500">--:--</span>
</div> </div>
); );
} })}
return ( </div>
<div
key={track.id}
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
<div>
<p className="font-medium">{track.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link
to="/artist/$artistId"
params={{
artistId: artist.id,
}}
className="hover:underline"
>
{artist.name}
</Link>
{index < track.artists.length - 1 && ", "}
</span>
))}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span>
<button
onClick={() => handleDownloadTrack(track)}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
title="Download"
>
<img src="/download.svg" alt="Download" className="w-5 h-5" />
</button>
</div>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -27,20 +27,33 @@ export const Artist = () => {
const fetchArtistData = async () => { const fetchArtistData = async () => {
if (!artistId) return; if (!artistId) return;
try { try {
const response = await apiClient.get<{ items: AlbumType[] }>(`/artist/info?id=${artistId}`); const response = await apiClient.get(`/artist/info?id=${artistId}`);
const albumData = response.data; const artistData = response.data;
if (albumData?.items && albumData.items.length > 0) { // Check if we have artist data in the response
const firstAlbum = albumData.items[0]; if (artistData?.id && artistData?.name) {
if (firstAlbum.artists && firstAlbum.artists.length > 0) { // Set artist info directly from the response
setArtist(firstAlbum.artists[0]); setArtist({
id: artistData.id,
name: artistData.name,
images: artistData.images || [],
external_urls: artistData.external_urls || { spotify: "" },
followers: artistData.followers || { total: 0 },
genres: artistData.genres || [],
popularity: artistData.popularity || 0,
type: artistData.type || 'artist',
uri: artistData.uri || ''
});
// Check if we have albums data
if (artistData?.albums?.items && artistData.albums.items.length > 0) {
setAlbums(artistData.albums.items);
} else { } else {
setError("Could not determine artist from album data."); setError("No albums found for this artist.");
return; return;
} }
setAlbums(albumData.items);
} else { } else {
setError("No albums found for this artist."); setError("Could not load artist data.");
return; return;
} }
@@ -68,14 +81,34 @@ export const Artist = () => {
addItem({ spotifyId: album.id, type: "album", name: album.name }); addItem({ spotifyId: album.id, type: "album", name: album.name });
}; };
const handleDownloadArtist = () => { const handleDownloadArtist = async () => {
if (!artistId || !artist) return; if (!artistId || !artist) return;
toast.info(`Adding ${artist.name} to queue...`);
addItem({ try {
spotifyId: artistId, toast.info(`Downloading ${artist.name} discography...`);
type: "artist",
name: artist.name, // Call the artist download endpoint which returns album task IDs
}); const response = await apiClient.get(`/artist/download/${artistId}`);
if (response.data.queued_albums?.length > 0) {
toast.success(
`${artist.name} discography queued successfully!`,
{
description: `${response.data.queued_albums.length} albums added to queue.`,
}
);
} else {
toast.info("No new albums to download for this artist.");
}
} catch (error: any) {
console.error("Artist download failed:", error);
toast.error(
"Failed to download artist",
{
description: error.response?.data?.error || "An unexpected error occurred.",
}
);
}
}; };
const handleToggleWatch = async () => { const handleToggleWatch = async () => {
@@ -117,12 +150,12 @@ export const Artist = () => {
return ( return (
<div className="artist-page"> <div className="artist-page">
<div className="mb-6"> <div className="mb-4 md:mb-6">
<button <button
onClick={() => window.history.back()} onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors" className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
> >
<FaArrowLeft /> <FaArrowLeft className="icon-secondary hover:icon-primary" />
<span>Back to results</span> <span>Back to results</span>
</button> </button>
</div> </div>
@@ -134,31 +167,31 @@ export const Artist = () => {
className="artist-image w-48 h-48 rounded-full mx-auto mb-4 shadow-lg" className="artist-image w-48 h-48 rounded-full mx-auto mb-4 shadow-lg"
/> />
)} )}
<h1 className="text-5xl font-bold">{artist.name}</h1> <h1 className="text-5xl font-bold text-content-primary dark:text-content-primary-dark">{artist.name}</h1>
<div className="flex gap-4 justify-center mt-4"> <div className="flex gap-4 justify-center mt-4">
<button <button
onClick={handleDownloadArtist} onClick={handleDownloadArtist}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors" className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
> >
<FaDownload /> <FaDownload className="icon-inverse" />
<span>Download All</span> <span>Download All</span>
</button> </button>
<button <button
onClick={handleToggleWatch} onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${ className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${
isWatched isWatched
? "bg-blue-500 text-white border-blue-500" ? "bg-button-primary text-button-primary-text border-primary"
: "bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800" : "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
}`} }`}
> >
{isWatched ? ( {isWatched ? (
<> <>
<FaBookmark /> <FaBookmark className="icon-inverse" />
<span>Watching</span> <span>Watching</span>
</> </>
) : ( ) : (
<> <>
<FaRegBookmark /> <FaRegBookmark className="icon-primary" />
<span>Watch</span> <span>Watch</span>
</> </>
)} )}
@@ -168,17 +201,20 @@ export const Artist = () => {
{topTracks.length > 0 && ( {topTracks.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Top Tracks</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Top Tracks</h2>
<div className="track-list space-y-2"> <div className="track-list space-y-2">
{topTracks.map((track) => ( {topTracks.map((track) => (
<div <div
key={track.id} key={track.id}
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
> >
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold"> <Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold text-content-primary dark:text-content-primary-dark">
{track.name} {track.name}
</Link> </Link>
<button onClick={() => handleDownloadTrack(track)} className="download-btn"> <button
onClick={() => handleDownloadTrack(track)}
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded"
>
Download Download
</button> </button>
</div> </div>
@@ -189,7 +225,7 @@ export const Artist = () => {
{artistAlbums.length > 0 && ( {artistAlbums.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Albums</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Albums</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistAlbums.map((album) => ( {artistAlbums.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} /> <AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
@@ -200,7 +236,7 @@ export const Artist = () => {
{artistSingles.length > 0 && ( {artistSingles.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Singles</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Singles</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistSingles.map((album) => ( {artistSingles.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} /> <AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
@@ -211,7 +247,7 @@ export const Artist = () => {
{artistCompilations.length > 0 && ( {artistCompilations.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Compilations</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Compilations</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistCompilations.map((album) => ( {artistCompilations.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} /> <AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />

View File

@@ -1,90 +1,264 @@
import { useState } from "react"; import { useState, useEffect, useRef, Suspense, lazy } from "react";
import { GeneralTab } from "../components/config/GeneralTab"; import { useSearch } from "@tanstack/react-router";
import { DownloadsTab } from "../components/config/DownloadsTab";
import { FormattingTab } from "../components/config/FormattingTab";
import { AccountsTab } from "../components/config/AccountsTab";
import { WatchTab } from "../components/config/WatchTab";
import { ServerTab } from "../components/config/ServerTab";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
import { useAuth } from "../contexts/auth-context";
import { LoginScreen } from "../components/auth/LoginScreen";
// Lazy load config tab components for better code splitting
const GeneralTab = lazy(() => import("../components/config/GeneralTab").then(m => ({ default: m.GeneralTab })));
const DownloadsTab = lazy(() => import("../components/config/DownloadsTab").then(m => ({ default: m.DownloadsTab })));
const FormattingTab = lazy(() => import("../components/config/FormattingTab").then(m => ({ default: m.FormattingTab })));
const AccountsTab = lazy(() => import("../components/config/AccountsTab").then(m => ({ default: m.AccountsTab })));
const WatchTab = lazy(() => import("../components/config/WatchTab").then(m => ({ default: m.WatchTab })));
const ServerTab = lazy(() => import("../components/config/ServerTab").then(m => ({ default: m.ServerTab })));
const UserManagementTab = lazy(() => import("../components/config/UserManagementTab").then(m => ({ default: m.UserManagementTab })));
const ProfileTab = lazy(() => import("../components/config/ProfileTab").then(m => ({ default: m.ProfileTab })));
// Loading component for tab transitions
const TabLoading = () => (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
);
const ConfigComponent = () => { const ConfigComponent = () => {
const [activeTab, setActiveTab] = useState("general"); const { tab } = useSearch({ from: "/config" });
const { user, isAuthenticated, authEnabled, isLoading: authLoading } = useAuth();
// Get settings from the context instead of fetching here // Get settings from the context instead of fetching here
const { settings: config, isLoading } = useSettings(); const { settings: config, isLoading } = useSettings();
// Determine initial tab based on URL parameter, user role, and auth state
const getInitialTab = () => {
if (tab) {
return tab; // Use URL parameter if provided
}
if (authEnabled && isAuthenticated && user?.role !== "admin") {
return "profile"; // Non-admin users default to profile
}
return "general"; // Admin users and non-auth mode default to general
};
const [activeTab, setActiveTab] = useState(getInitialTab());
const userHasManuallyChangedTab = useRef(false);
// Update active tab when URL parameter changes
useEffect(() => {
if (tab) {
setActiveTab(tab);
userHasManuallyChangedTab.current = false; // Reset manual flag when URL changes
}
}, [tab]);
// Handle tab clicks - track that user manually changed tab
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
userHasManuallyChangedTab.current = true;
};
// Reset to appropriate tab based on auth state and user role (only when tab becomes invalid)
useEffect(() => {
// Check if current tab is invalid for current user
const isInvalidTab = () => {
if (!authEnabled && (activeTab === "user-management" || activeTab === "profile")) {
return true;
}
if (authEnabled && user?.role !== "admin" && ["user-management", "general", "downloads", "formatting", "accounts", "watch", "server"].includes(activeTab)) {
return true;
}
return false;
};
// Only auto-redirect if tab is invalid OR if user hasn't manually changed tabs and no URL param
if (isInvalidTab() || (!userHasManuallyChangedTab.current && !tab)) {
if (!authEnabled || user?.role === "admin") {
setActiveTab("general");
} else {
setActiveTab("profile");
}
userHasManuallyChangedTab.current = false; // Reset after programmatic change
}
}, [authEnabled, user?.role, activeTab, tab]);
// Show loading while authentication is being checked
if (authLoading) {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8">
<div className="text-center py-12">
<p className="text-content-muted dark:text-content-muted-dark">Loading...</p>
</div>
</div>
);
}
// Show login screen if authentication is enabled but user is not authenticated
if (authEnabled && !isAuthenticated) {
return (
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8">
<div className="space-y-4 text-center mb-6">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Configuration</h1>
<p className="text-content-muted dark:text-content-muted-dark">Please log in to access configuration settings.</p>
</div>
<LoginScreen />
</div>
);
}
// Regular users can access profile tab, but not other config tabs
const isAdmin = user?.role === "admin";
const canAccessAdminTabs = !authEnabled || isAdmin;
const renderTabContent = () => { const renderTabContent = () => {
if (isLoading) return <p className="text-center">Loading configuration...</p>; // User management and profile don't need config data
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>; if (activeTab === "user-management") {
return (
<Suspense fallback={<TabLoading />}>
<UserManagementTab />
</Suspense>
);
}
if (activeTab === "profile") {
return (
<Suspense fallback={<TabLoading />}>
<ProfileTab />
</Suspense>
);
}
if (isLoading) return <div className="text-center py-12"><p className="text-content-muted dark:text-content-muted-dark">Loading configuration...</p></div>;
if (!config) return <div className="text-center py-12"><p className="text-error-text bg-error-muted p-4 rounded-lg">Error loading configuration.</p></div>;
switch (activeTab) { switch (activeTab) {
case "general": case "general":
return <GeneralTab config={config} isLoading={isLoading} />; return (
<Suspense fallback={<TabLoading />}>
<GeneralTab config={config} isLoading={isLoading} />
</Suspense>
);
case "downloads": case "downloads":
return <DownloadsTab config={config} isLoading={isLoading} />; return (
<Suspense fallback={<TabLoading />}>
<DownloadsTab config={config} isLoading={isLoading} />
</Suspense>
);
case "formatting": case "formatting":
return <FormattingTab config={config} isLoading={isLoading} />; return (
<Suspense fallback={<TabLoading />}>
<FormattingTab config={config} isLoading={isLoading} />
</Suspense>
);
case "accounts": case "accounts":
return <AccountsTab />; return (
<Suspense fallback={<TabLoading />}>
<AccountsTab />
</Suspense>
);
case "watch": case "watch":
return <WatchTab />; return (
<Suspense fallback={<TabLoading />}>
<WatchTab />
</Suspense>
);
case "server": case "server":
return <ServerTab />; return (
<Suspense fallback={<TabLoading />}>
<ServerTab />
</Suspense>
);
default: default:
return null; return null;
} }
}; };
return ( return (
<div className="space-y-6"> <div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8 space-y-8">
<div> <div className="space-y-2">
<h1 className="text-3xl font-bold">Configuration</h1> <h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">
<p className="text-gray-500">Manage application settings and services.</p> {authEnabled && !isAdmin ? "Profile Settings" : "Configuration"}
</h1>
<p className="text-content-muted dark:text-content-muted-dark">
{authEnabled && !isAdmin
? "Manage your profile and account settings."
: "Manage application settings and services."}
</p>
{authEnabled && user && (
<p className="text-sm text-content-muted dark:text-content-muted-dark">
Logged in as: <span className="font-medium">{user.username}</span> ({user.role})
</p>
)}
</div> </div>
<div className="flex gap-8"> <div className="flex flex-col lg:flex-row gap-6 lg:gap-10">
<aside className="w-1/4"> <aside className="w-full lg:w-1/4">
<nav className="flex flex-col space-y-1"> <nav className="flex flex-row lg:flex-col overflow-x-auto lg:overflow-x-visible space-x-2 lg:space-x-0 lg:space-y-2 pb-2 lg:pb-0">
<button {/* Profile tab - available to all authenticated users */}
onClick={() => setActiveTab("general")} {authEnabled && isAuthenticated && (
className={`p-2 rounded-md text-left ${activeTab === "general" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`} <button
> onClick={() => handleTabChange("profile")}
General className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "profile" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
</button> >
<button Profile
onClick={() => setActiveTab("downloads")} </button>
className={`p-2 rounded-md text-left ${activeTab === "downloads" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`} )}
>
Downloads {/* Admin-only tabs */}
</button> {canAccessAdminTabs && (
<button <>
onClick={() => setActiveTab("formatting")} <button
className={`p-2 rounded-md text-left ${activeTab === "formatting" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`} onClick={() => handleTabChange("general")}
> className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "general" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
Formatting >
</button> General
<button </button>
onClick={() => setActiveTab("accounts")} <button
className={`p-2 rounded-md text-left ${activeTab === "accounts" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`} onClick={() => handleTabChange("downloads")}
> className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "downloads" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
Accounts >
</button> Downloads
<button </button>
onClick={() => setActiveTab("watch")} <button
className={`p-2 rounded-md text-left ${activeTab === "watch" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`} onClick={() => handleTabChange("formatting")}
> className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "formatting" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
Watch >
</button> Formatting
<button </button>
onClick={() => setActiveTab("server")} <button
className={`p-2 rounded-md text-left ${activeTab === "server" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`} onClick={() => handleTabChange("accounts")}
> className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "accounts" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
Server >
</button> Accounts
</button>
<button
onClick={() => handleTabChange("watch")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "watch" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
>
Watch
</button>
<button
onClick={() => handleTabChange("server")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "server" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
>
Server
</button>
</>
)}
{/* User Management tab - admin only */}
{authEnabled && isAdmin && (
<button
onClick={() => handleTabChange("user-management")}
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "user-management" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
>
User Management
</button>
)}
</nav> </nav>
</aside> </aside>
<main className="w-3/4">{renderTabContent()}</main> <main className="w-full lg:w-3/4 bg-surface dark:bg-surface-dark rounded-xl border border-border dark:border-border-dark p-6 md:p-8 shadow-sm">
{renderTabContent()}
</main>
</div> </div>
</div> </div>
); );

View File

@@ -12,79 +12,99 @@ import {
// --- Type Definitions --- // --- Type Definitions ---
type HistoryEntry = { type HistoryEntry = {
id: number;
download_type: "track" | "album" | "playlist";
title: string;
artists: string[];
timestamp: number;
status: "completed" | "failed" | "skipped" | "in_progress";
service: string;
quality_format?: string;
quality_bitrate?: string;
total_tracks?: number;
successful_tracks?: number;
failed_tracks?: number;
skipped_tracks?: number;
children_table?: string;
task_id: string; task_id: string;
item_name: string; external_ids: Record<string, any>;
item_artist: string; metadata: Record<string, any>;
item_url?: string; release_date?: Record<string, any>;
download_type: "track" | "album" | "playlist" | "artist"; genres: string[];
service_used: string; images: Array<Record<string, any>>;
quality_profile: string; owner?: Record<string, any>;
convert_to?: string; album_type?: string;
bitrate?: string; duration_total_ms?: number;
status_final: "COMPLETED" | "ERROR" | "CANCELLED" | "SKIPPED"; explicit?: boolean;
timestamp_completed: number; };
error_message?: string;
parent_task_id?: string; type ChildTrack = {
track_status?: "SUCCESSFUL" | "SKIPPED" | "FAILED"; id: number;
total_successful?: number; title: string;
total_skipped?: number; artists: string[];
total_failed?: number; album_title?: string;
duration_ms?: number;
track_number?: number;
disc_number?: number;
explicit?: boolean;
status: "completed" | "failed" | "skipped";
external_ids: Record<string, any>;
genres: string[];
isrc?: string;
timestamp: number;
position?: number;
metadata: Record<string, any>;
};
type ChildrenResponse = {
task_id: string;
download_type: string;
title: string;
children_table: string;
tracks: ChildTrack[];
track_count: number;
}; };
const STATUS_CLASS: Record<string, string> = { const STATUS_CLASS: Record<string, string> = {
COMPLETED: "text-green-500", completed: "text-success",
ERROR: "text-red-500", failed: "text-error",
CANCELLED: "text-gray-500", in_progress: "text-warning",
SKIPPED: "text-yellow-500", skipped: "text-content-muted dark:text-content-muted-dark",
};
const QUALITY_MAP: Record<string, Record<string, string>> = {
spotify: {
NORMAL: "OGG 96k",
HIGH: "OGG 160k",
VERY_HIGH: "OGG 320k",
},
deezer: {
MP3_128: "MP3 128k",
MP3_320: "MP3 320k",
FLAC: "FLAC (Hi-Res)",
},
};
const getDownloadSource = (entry: HistoryEntry): "Spotify" | "Deezer" | "Unknown" => {
const url = entry.item_url?.toLowerCase() || "";
const service = entry.service_used?.toLowerCase() || "";
if (url.includes("spotify.com")) return "Spotify";
if (url.includes("deezer.com")) return "Deezer";
if (service.includes("spotify")) return "Spotify";
if (service.includes("deezer")) return "Deezer";
return "Unknown";
}; };
const formatQuality = (entry: HistoryEntry): string => { const formatQuality = (entry: HistoryEntry): string => {
const sourceName = getDownloadSource(entry).toLowerCase(); const format = entry.quality_format || "Unknown";
const profile = entry.quality_profile || "N/A"; const bitrate = entry.quality_bitrate || "";
const sourceQuality = sourceName !== "unknown" ? QUALITY_MAP[sourceName]?.[profile] || profile : profile; return bitrate ? `${format} ${bitrate}` : format;
let qualityDisplay = sourceQuality; };
if (entry.convert_to && entry.convert_to !== "None") {
qualityDisplay += `${entry.convert_to.toUpperCase()}`;
if (entry.bitrate && entry.bitrate !== "None") {
qualityDisplay += ` ${entry.bitrate}`; const formatDuration = (ms?: number): string => {
} if (!ms) return "N/A";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} }
return qualityDisplay; return `${minutes}:${secs.toString().padStart(2, '0')}`;
}; };
// --- Column Definitions --- // --- Column Definitions ---
const columnHelper = createColumnHelper<HistoryEntry>(); const columnHelper = createColumnHelper<HistoryEntry | ChildTrack>();
export const History = () => { export const History = () => {
const [data, setData] = useState<HistoryEntry[]>([]); const [data, setData] = useState<(HistoryEntry | ChildTrack)[]>([]);
const [totalEntries, setTotalEntries] = useState(0); const [totalEntries, setTotalEntries] = useState(0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<HistoryEntry | null>(null);
const [viewingChildren, setViewingChildren] = useState<ChildrenResponse | null>(null);
// State for TanStack Table // State for TanStack Table
const [sorting, setSorting] = useState<SortingState>([{ id: "timestamp_completed", desc: true }]); const [sorting, setSorting] = useState<SortingState>([{ id: "timestamp", desc: true }]);
const [{ pageIndex, pageSize }, setPagination] = useState({ const [{ pageIndex, pageSize }, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: 25, pageSize: 25,
@@ -93,185 +113,206 @@ export const History = () => {
// State for filters // State for filters
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [typeFilter, setTypeFilter] = useState(""); const [typeFilter, setTypeFilter] = useState("");
const [trackStatusFilter, setTrackStatusFilter] = useState("");
const [showChildTracks, setShowChildTracks] = useState(false);
const [parentTaskId, setParentTaskId] = useState<string | null>(null);
const [parentTask, setParentTask] = useState<HistoryEntry | null>(null);
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
const viewTracksForParent = useCallback( const viewChildren = useCallback(
(parentEntry: HistoryEntry) => { async (parentEntry: HistoryEntry) => {
setPagination({ pageIndex: 0, pageSize }); if (!parentEntry.children_table) {
setParentTaskId(parentEntry.task_id); toast.error("This download has no child tracks.");
setParentTask(parentEntry); return;
setStatusFilter(""); }
setTypeFilter("");
setTrackStatusFilter(""); try {
setIsLoading(true);
const response = await apiClient.get<ChildrenResponse>(`/history/${parentEntry.task_id}/children`);
setViewingChildren(response.data);
setData(response.data.tracks);
setTotalEntries(response.data.track_count);
setPagination({ pageIndex: 0, pageSize });
} catch (error) {
toast.error("Failed to load child tracks.");
console.error("Error loading children:", error);
} finally {
setIsLoading(false);
}
}, },
[pageSize], [pageSize],
); );
const viewEntryDetails = useCallback(
async (taskId: string) => {
try {
const response = await apiClient.get<HistoryEntry>(`/history/${taskId}`);
setSelectedEntry(response.data);
} catch (error) {
toast.error("Failed to load entry details.");
console.error("Error loading entry details:", error);
}
},
[],
);
const columns = useMemo( const columns = useMemo(
() => [ () => [
columnHelper.accessor("item_name", { columnHelper.accessor("title", {
header: "Name", header: "Title",
cell: (info) =>
info.row.original.parent_task_id ? (
<span className="pl-8 text-muted-foreground"> {info.getValue()}</span>
) : (
<span className="font-semibold">{info.getValue()}</span>
),
}),
columnHelper.accessor("item_artist", { header: "Artist" }),
columnHelper.accessor("download_type", {
header: "Type",
cell: (info) => <span className="capitalize">{info.getValue()}</span>,
}),
columnHelper.accessor("quality_profile", {
header: "Quality",
cell: (info) => formatQuality(info.row.original),
}),
columnHelper.accessor("status_final", {
header: "Status",
cell: (info) => { cell: (info) => {
const entry = info.row.original; const entry = info.row.original;
const status = entry.parent_task_id ? entry.track_status : entry.status_final; const isChild = "album_title" in entry;
const statusKey = (status || "").toUpperCase(); return isChild ? (
const statusClass = <span className="pl-4 text-muted-foreground"> {entry.title}</span>
{ ) : (
COMPLETED: "text-green-500", <div className="flex items-center gap-2">
SUCCESSFUL: "text-green-500", <span className="font-semibold">{entry.title}</span>
ERROR: "text-red-500", {(entry as HistoryEntry).children_table && (
FAILED: "text-red-500", <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
CANCELLED: "text-gray-500", {(entry as HistoryEntry).total_tracks || "?"} tracks
SKIPPED: "text-yellow-500", </span>
}[statusKey] || "text-gray-500"; )}
</div>
return <span className={`font-semibold ${statusClass}`}>{status}</span>; );
}, },
}), }),
columnHelper.accessor("item_url", { columnHelper.accessor("artists", {
id: "source", header: "Artists",
header: parentTaskId ? "Download Source" : "Search Source", cell: (info) => {
cell: (info) => getDownloadSource(info.row.original), const artists = info.getValue();
return Array.isArray(artists) ? artists.join(", ") : artists || "Unknown Artist";
},
}), }),
columnHelper.accessor("timestamp_completed", { columnHelper.display({
header: "Date Completed", id: "type",
cell: (info) => new Date(info.getValue() * 1000).toLocaleString(), header: "Type",
cell: (info) => {
const entry = info.row.original;
const type = "download_type" in entry ? entry.download_type : "track";
return <span className="capitalize">{type}</span>;
},
}), }),
...(!parentTaskId columnHelper.display({
id: "quality",
header: "Quality",
cell: (info) => {
const entry = info.row.original;
if ("download_type" in entry) {
return formatQuality(entry);
}
return "N/A";
},
}),
columnHelper.accessor("status", {
header: "Status",
cell: (info) => {
const status = info.getValue();
const statusClass = STATUS_CLASS[status] || "text-gray-500";
return <span className={`font-semibold ${statusClass} capitalize`}>{status}</span>;
},
}),
columnHelper.display({
id: "service",
header: "Service",
cell: (info) => {
const entry = info.row.original;
const service = "service" in entry ? entry.service : "Unknown";
return <span className="capitalize">{service}</span>;
},
}),
columnHelper.accessor("timestamp", {
header: "Date",
cell: (info) => {
const timestamp = info.getValue();
return timestamp ? new Date(timestamp * 1000).toLocaleString() : "N/A";
},
}),
...(!viewingChildren
? [ ? [
columnHelper.display({ columnHelper.display({
id: "actions", id: "actions",
header: "Actions", header: "Actions",
cell: ({ row }) => { cell: ({ row }) => {
const entry = row.original; const entry = row.original as HistoryEntry;
if (!entry.parent_task_id && (entry.download_type === "album" || entry.download_type === "playlist")) { const hasChildren = entry.children_table &&
const hasChildren = (entry.download_type === "album" || entry.download_type === "playlist");
(entry.total_successful ?? 0) > 0 ||
(entry.total_skipped ?? 0) > 0 || return (
(entry.total_failed ?? 0) > 0; <div className="flex items-center gap-2">
if (hasChildren) { <button
return ( onClick={() => viewEntryDetails(entry.task_id)}
<div className="flex items-center gap-2"> className="px-2 py-1 text-xs rounded-md bg-gray-600 text-white hover:bg-gray-700"
>
Details
</button>
{hasChildren && (
<>
<button <button
onClick={() => viewTracksForParent(row.original)} onClick={() => viewChildren(entry)}
className="px-2 py-1 text-xs rounded-md bg-blue-600 text-white hover:bg-blue-700" className="px-2 py-1 text-xs rounded-md bg-blue-600 text-white hover:bg-blue-700"
> >
View Tracks View Tracks
</button> </button>
<span className="text-xs"> <span className="text-xs">
<span className="text-green-500">{entry.total_successful ?? 0}</span> /{" "} <span className="text-green-500">
<span className="text-yellow-500">{entry.total_skipped ?? 0}</span> /{" "} {entry.successful_tracks || 0}
<span className="text-red-500">{entry.total_failed ?? 0}</span> </span> /{" "}
<span className="text-yellow-500">
{entry.skipped_tracks || 0}
</span> /{" "}
<span className="text-red-500">
{entry.failed_tracks || 0}
</span>
</span> </span>
</div> </>
); )}
} </div>
} );
return null;
}, },
}), }),
] ]
: []), : []),
], ],
[viewTracksForParent, parentTaskId], [viewChildren, viewEntryDetails, viewingChildren],
); );
useEffect(() => { useEffect(() => {
const fetchHistory = async () => { const fetchHistory = async () => {
if (viewingChildren) return; // Skip if viewing children
setIsLoading(true); setIsLoading(true);
setData([]);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: `${pageSize}`, limit: `${pageSize}`,
offset: `${pageIndex * pageSize}`, offset: `${pageIndex * pageSize}`,
sort_by: sorting[0]?.id ?? "timestamp_completed",
sort_order: sorting[0]?.desc ? "DESC" : "ASC",
}); });
if (statusFilter) params.append("status_final", statusFilter);
if (statusFilter) params.append("status", statusFilter);
if (typeFilter) params.append("download_type", typeFilter); if (typeFilter) params.append("download_type", typeFilter);
if (trackStatusFilter) params.append("track_status", trackStatusFilter);
if (!parentTaskId && !showChildTracks) {
params.append("hide_child_tracks", "true");
}
if (parentTaskId) params.append("parent_task_id", parentTaskId);
const response = await apiClient.get<{ const response = await apiClient.get<{
entries: HistoryEntry[]; downloads: HistoryEntry[];
total_count: number; pagination: {
limit: number;
offset: number;
returned_count: number;
};
}>(`/history?${params.toString()}`); }>(`/history?${params.toString()}`);
const originalEntries = response.data.entries; setData(response.data.downloads);
let processedEntries = originalEntries; // Since we don't get total count, estimate based on returned count
const estimatedTotal = response.data.pagination.returned_count < pageSize
// If including child tracks in the main history, group them with their parents ? pageIndex * pageSize + response.data.pagination.returned_count
if (showChildTracks && !parentTaskId) { : (pageIndex + 1) * pageSize + 1;
const parents = originalEntries.filter((e) => !e.parent_task_id); setTotalEntries(estimatedTotal);
const childrenByParentId = originalEntries } catch (error) {
.filter((e) => e.parent_task_id)
.reduce(
(acc, child) => {
const parentId = child.parent_task_id!;
if (!acc[parentId]) {
acc[parentId] = [];
}
acc[parentId].push(child);
return acc;
},
{} as Record<string, HistoryEntry[]>,
);
const groupedEntries: HistoryEntry[] = [];
parents.forEach((parent) => {
groupedEntries.push(parent);
const children = childrenByParentId[parent.task_id];
if (children) {
groupedEntries.push(...children);
}
});
processedEntries = groupedEntries;
}
// If viewing child tracks for a specific parent, filter out the parent entry from the list
const finalEntries = parentTaskId
? processedEntries.filter((entry) => entry.task_id !== parentTaskId)
: processedEntries;
setData(finalEntries);
// Adjust total count to reflect filtered entries for accurate pagination
const numFiltered = originalEntries.length - finalEntries.length;
setTotalEntries(response.data.total_count - numFiltered);
} catch {
toast.error("Failed to load history."); toast.error("Failed to load history.");
console.error("Error loading history:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchHistory(); fetchHistory();
}, [pageIndex, pageSize, sorting, statusFilter, typeFilter, trackStatusFilter, showChildTracks, parentTaskId]); }, [pageIndex, pageSize, statusFilter, typeFilter, viewingChildren]);
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -289,115 +330,180 @@ export const History = () => {
const clearFilters = () => { const clearFilters = () => {
setStatusFilter(""); setStatusFilter("");
setTypeFilter(""); setTypeFilter("");
setTrackStatusFilter("");
setShowChildTracks(false);
}; };
const viewParentTask = () => { const goBackToHistory = () => {
setViewingChildren(null);
setPagination({ pageIndex: 0, pageSize }); setPagination({ pageIndex: 0, pageSize });
setParentTaskId(null);
setParentTask(null);
clearFilters(); clearFilters();
}; };
const closeDetails = () => {
setSelectedEntry(null);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{parentTaskId && parentTask ? ( {/* Entry Details Modal */}
{selectedEntry && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-surface dark:bg-surface-dark rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">
Download Details
</h2>
<button
onClick={closeDetails}
className="text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark"
>
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">Basic Info</h3>
<div className="space-y-1 text-sm">
<p><strong>Task ID:</strong> {selectedEntry.task_id}</p>
<p><strong>Type:</strong> {selectedEntry.download_type}</p>
<p><strong>Title:</strong> {selectedEntry.title}</p>
<p><strong>Artists:</strong> {selectedEntry.artists.join(", ")}</p>
<p><strong>Status:</strong> {selectedEntry.status}</p>
<p><strong>Service:</strong> {selectedEntry.service}</p>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Quality & Stats</h3>
<div className="space-y-1 text-sm">
<p><strong>Quality:</strong> {formatQuality(selectedEntry)}</p>
<p><strong>Date:</strong> {new Date(selectedEntry.timestamp * 1000).toLocaleString()}</p>
{selectedEntry.total_tracks && (
<>
<p><strong>Total Tracks:</strong> {selectedEntry.total_tracks}</p>
<p><strong>Successful:</strong> {selectedEntry.successful_tracks || 0}</p>
<p><strong>Failed:</strong> {selectedEntry.failed_tracks || 0}</p>
<p><strong>Skipped:</strong> {selectedEntry.skipped_tracks || 0}</p>
</>
)}
{selectedEntry.duration_total_ms && (
<p><strong>Duration:</strong> {formatDuration(selectedEntry.duration_total_ms)}</p>
)}
</div>
</div>
</div>
{selectedEntry.external_ids && Object.keys(selectedEntry.external_ids).length > 0 && (
<div>
<h3 className="font-semibold mb-2">External IDs</h3>
<div className="bg-surface-secondary dark:bg-surface-secondary-dark p-3 rounded text-sm">
<pre className="whitespace-pre-wrap">{JSON.stringify(selectedEntry.external_ids, null, 2)}</pre>
</div>
</div>
)}
{selectedEntry.metadata && Object.keys(selectedEntry.metadata).length > 0 && (
<div>
<h3 className="font-semibold mb-2">Metadata</h3>
<div className="bg-surface-secondary dark:bg-surface-secondary-dark p-3 rounded text-sm max-h-60 overflow-y-auto">
<pre className="whitespace-pre-wrap">{JSON.stringify(selectedEntry.metadata, null, 2)}</pre>
</div>
</div>
)}
{selectedEntry.genres && selectedEntry.genres.length > 0 && (
<div>
<h3 className="font-semibold mb-2">Genres</h3>
<div className="flex flex-wrap gap-2">
{selectedEntry.genres.map((genre, index) => (
<span key={index} className="bg-primary/10 text-primary px-2 py-1 rounded text-sm">
{genre}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{viewingChildren ? (
<div className="space-y-4"> <div className="space-y-4">
<button onClick={viewParentTask} className="flex items-center gap-2 text-sm hover:underline"> <button onClick={goBackToHistory} className="flex items-center gap-2 text-sm hover:underline text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark">
&larr; Back to All History &larr; Back to All History
</button> </button>
<div className="rounded-lg border bg-gradient-to-br from-card to-muted/30 p-6 shadow-lg"> <div className="rounded-lg border border-border dark:border-border-dark bg-gradient-to-br from-surface to-surface-muted dark:from-surface-dark dark:to-surface-muted-dark p-6 shadow-lg">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2 space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-3xl font-bold tracking-tight">{parentTask.item_name}</h2> <h2 className="text-3xl font-bold tracking-tight text-content-primary dark:text-content-primary-dark">{viewingChildren.title}</h2>
<p className="text-xl text-muted-foreground">{parentTask.item_artist}</p>
<div className="pt-2"> <div className="pt-2">
<span className="capitalize inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-secondary text-secondary-foreground"> <span className="capitalize inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark">
{parentTask.download_type} {viewingChildren.download_type}
</span> </span>
</div> </div>
</div> </div>
<div className="space-y-2 text-sm md:text-right"> <div className="space-y-2 text-sm md:text-right">
<div <p className="text-content-muted dark:text-content-muted-dark">
className={`inline-flex items-center rounded-full border px-3 py-1 text-base font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${ <span className="font-semibold text-content-primary dark:text-content-primary-dark">Total Tracks: </span>
STATUS_CLASS[parentTask.status_final] {viewingChildren.track_count}
}`}
>
{parentTask.status_final}
</div>
<p className="text-muted-foreground pt-2">
<span className="font-semibold text-foreground">Quality: </span>
{formatQuality(parentTask)}
</p>
<p className="text-muted-foreground">
<span className="font-semibold text-foreground">Completed: </span>
{new Date(parentTask.timestamp_completed * 1000).toLocaleString()}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<h3 className="text-2xl font-bold tracking-tight pt-4">Tracks</h3> <h3 className="text-2xl font-bold tracking-tight text-content-primary dark:text-content-primary-dark">
Tracks
</h3>
</div> </div>
) : ( ) : (
<h1 className="text-3xl font-bold">Download History</h1> <h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Download History</h1>
)} )}
{/* Filter Controls */} {/* Filter Controls */}
{!parentTaskId && ( {!viewingChildren && (
<div className="flex gap-4 items-center"> <div className="space-y-4">
<select <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
value={statusFilter} <select
onChange={(e) => setStatusFilter(e.target.value)} value={statusFilter}
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700" onChange={(e) => setStatusFilter(e.target.value)}
> 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"
<option value="">All Statuses</option> >
<option value="COMPLETED">Completed</option> <option value="">All Statuses</option>
<option value="ERROR">Error</option> <option value="completed">Completed</option>
<option value="CANCELLED">Cancelled</option> <option value="failed">Failed</option>
<option value="SKIPPED">Skipped</option> <option value="skipped">Skipped</option>
</select> <option value="in_progress">In Progress</option>
<select </select>
value={typeFilter} <select
onChange={(e) => setTypeFilter(e.target.value)} value={typeFilter}
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700" onChange={(e) => setTypeFilter(e.target.value)}
> 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"
<option value="">All Types</option> >
<option value="track">Track</option> <option value="">All Types</option>
<option value="album">Album</option> <option value="track">Track</option>
<option value="playlist">Playlist</option> <option value="album">Album</option>
<option value="artist">Artist</option> <option value="playlist">Playlist</option>
</select> </select>
<select <button
value={trackStatusFilter} onClick={clearFilters}
onChange={(e) => setTrackStatusFilter(e.target.value)} className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border border-border dark:border-border-dark rounded-md"
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700" >
> Clear Filters
<option value="">All Track Statuses</option> </button>
<option value="SUCCESSFUL">Successful</option> </div>
<option value="SKIPPED">Skipped</option>
<option value="FAILED">Failed</option>
</select>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showChildTracks}
onChange={(e) => setShowChildTracks(e.target.checked)}
disabled={!!parentTaskId}
/>
Include child tracks
</label>
</div> </div>
)} )}
{/* Table */} {/* Desktop Table */}
<div className="overflow-x-auto"> <div className="hidden lg:block overflow-x-auto">
<table className="min-w-full"> <table className="min-w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th key={header.id} className="p-2 text-left"> <th key={header.id} className="p-2 text-left text-content-primary dark:text-content-primary-dark">
{header.isPlaceholder ? null : ( {header.isPlaceholder ? null : (
<div <div
{...{ {...{
@@ -417,33 +523,33 @@ export const History = () => {
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
<tr> <tr>
<td colSpan={columns.length} className="text-center p-4"> <td colSpan={columns.length} className="text-center p-4 text-content-muted dark:text-content-muted-dark">
Loading... Loading...
</td> </td>
</tr> </tr>
) : table.getRowModel().rows.length === 0 ? ( ) : table.getRowModel().rows.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length} className="text-center p-4"> <td colSpan={columns.length} className="text-center p-4 text-content-muted dark:text-content-muted-dark">
No history entries found. No history entries found.
</td> </td>
</tr> </tr>
) : ( ) : (
table.getRowModel().rows.map((row) => { table.getRowModel().rows.map((row) => {
const isParent = const entry = row.original;
!row.original.parent_task_id && const isChild = "album_title" in entry;
(row.original.download_type === "album" || row.original.download_type === "playlist"); const isParent = !isChild && "children_table" in entry && entry.children_table;
const isChild = !!row.original.parent_task_id; let rowClass = "hover:bg-surface-muted dark:hover:bg-surface-muted-dark";
let rowClass = "hover:bg-muted/50";
if (isParent) { if (isParent) {
rowClass += " bg-muted/50 font-semibold hover:bg-muted"; rowClass += " bg-surface-accent dark:bg-surface-accent-dark font-semibold";
} else if (isChild) { } else if (isChild) {
rowClass += " border-t border-dashed border-muted-foreground/20"; rowClass += " border-t border-dashed border-content-muted dark:border-content-muted-dark border-opacity-20";
} }
return ( return (
<tr key={row.id} className={`border-b border-border ${rowClass}`}> <tr key={row.id} className={`border-b border-border dark:border-border-dark ${rowClass}`}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-3"> <td key={cell.id} className="p-3 text-content-primary dark:text-content-primary-dark">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
))} ))}
@@ -455,39 +561,156 @@ export const History = () => {
</table> </table>
</div> </div>
{/* Mobile Card Layout */}
<div className="lg:hidden space-y-3">
{isLoading ? (
<div className="text-center p-8 text-content-muted dark:text-content-muted-dark">
Loading...
</div>
) : table.getRowModel().rows.length === 0 ? (
<div className="text-center p-8 text-content-muted dark:text-content-muted-dark">
No history entries found.
</div>
) : (
table.getRowModel().rows.map((row) => {
const entry = row.original;
const isChild = "album_title" in entry;
const isParent = !isChild && "children_table" in entry && entry.children_table;
const status = entry.status;
const statusClass = STATUS_CLASS[status] || "text-gray-500";
let cardClass = "bg-surface dark:bg-surface-secondary-dark rounded-lg border border-border dark:border-border-dark p-4";
if (isParent) {
cardClass += " border-l-4 border-l-primary";
} else if (isChild) {
cardClass += " ml-4 border-l-2 border-l-content-muted dark:border-l-content-muted-dark";
}
return (
<div key={row.id} className={cardClass}>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className={`font-semibold text-content-primary dark:text-content-primary-dark truncate ${isChild ? 'text-sm' : 'text-base'}`}>
{isChild ? `└─ ${entry.title}` : entry.title}
</h3>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{Array.isArray(entry.artists) ? entry.artists.join(", ") : entry.artists}
</p>
</div>
<span className={`text-sm font-semibold ${statusClass} ml-2 capitalize`}>
{status}
</span>
</div>
{/* Details Grid */}
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
<div>
<span className="text-content-muted dark:text-content-muted-dark">Type:</span>
<span className="ml-1 capitalize text-content-primary dark:text-content-primary-dark">
{"download_type" in entry ? entry.download_type : "track"}
</span>
</div>
<div>
<span className="text-content-muted dark:text-content-muted-dark">Service:</span>
<span className="ml-1 text-content-primary dark:text-content-primary-dark capitalize">
{"service" in entry ? entry.service : "Unknown"}
</span>
</div>
<div className="col-span-2">
<span className="text-content-muted dark:text-content-muted-dark">Quality:</span>
<span className="ml-1 text-content-primary dark:text-content-primary-dark">
{"download_type" in entry ? formatQuality(entry) : "N/A"}
</span>
</div>
<div className="col-span-2">
<span className="text-content-muted dark:text-content-muted-dark">Date:</span>
<span className="ml-1 text-content-primary dark:text-content-primary-dark">
{new Date(entry.timestamp * 1000).toLocaleString()}
</span>
</div>
</div>
{/* Actions */}
{!viewingChildren && !isChild && (
<div className="mt-3 pt-3 border-t border-border dark:border-border-dark flex items-center justify-between">
{isParent && (
<div className="flex items-center gap-2 text-xs">
<span className="text-success">
{(entry as HistoryEntry).successful_tracks || 0}
</span>
<span className="text-warning">
{(entry as HistoryEntry).skipped_tracks || 0}
</span>
<span className="text-error">
{(entry as HistoryEntry).failed_tracks || 0}
</span>
</div>
)}
<div className="flex gap-2 ml-auto">
<button
onClick={() => viewEntryDetails((entry as HistoryEntry).task_id)}
className="px-3 py-1 text-xs rounded-md bg-gray-600 hover:bg-gray-700 text-white"
>
Details
</button>
{isParent && (
<button
onClick={() => viewChildren(entry as HistoryEntry)}
className="px-3 py-1 text-xs rounded-md bg-primary hover:bg-primary-hover text-white"
>
View Tracks
</button>
)}
</div>
</div>
)}
</div>
);
})
)}
</div>
{/* Pagination Controls */} {/* Pagination Controls */}
<div className="flex items-center justify-between gap-2"> <div className="space-y-4">
<button <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
onClick={() => table.previousPage()} <div className="flex items-center justify-center sm:justify-start gap-2">
disabled={!table.getCanPreviousPage()} <button
className="p-2 border rounded-md disabled:opacity-50" onClick={() => table.previousPage()}
> disabled={!table.getCanPreviousPage()}
Previous className="px-4 py-2 border bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border-border dark:border-border-dark rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
</button> >
<span> Previous
Page{" "} </button>
<strong> <button
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()} onClick={() => table.nextPage()}
</strong> disabled={!table.getCanNextPage()}
</span> className="px-4 py-2 border bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border-border dark:border-border-dark rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
<button >
onClick={() => table.nextPage()} Next
disabled={!table.getCanNextPage()} </button>
className="p-2 border rounded-md disabled:opacity-50" </div>
>
Next <div className="flex flex-col sm:flex-row items-center gap-2 text-sm">
</button> <span className="text-content-primary dark:text-content-primary-dark whitespace-nowrap">
<select Page{" "}
value={table.getState().pagination.pageSize} <strong>
onChange={(e) => table.setPageSize(Number(e.target.value))} {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700" </strong>
> </span>
{[10, 25, 50, 100].map((size) => ( <select
<option key={size} value={size}> value={table.getState().pagination.pageSize}
Show {size} onChange={(e) => table.setPageSize(Number(e.target.value))}
</option> className="px-3 py-1 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"
))} >
</select> {[10, 25, 50, 100].map((size) => (
<option key={size} value={size}>
Show {size}
</option>
))}
</select>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -2,11 +2,27 @@ import { useState, useEffect, useMemo, useContext, useCallback, useRef } from "r
import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router"; 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, ArtistType, PlaylistType, SearchResult } from "@/types/spotify"; import type { TrackType, AlbumType, SearchResult } from "@/types/spotify";
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";
// Utility function to safely get properties from search results
const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => {
try {
let current = obj;
for (const key of path) {
if (current == null || typeof current !== 'object') {
return fallback;
}
current = current[key];
}
return current ?? fallback;
} catch {
return fallback;
}
};
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
export const Home = () => { export const Home = () => {
@@ -24,6 +40,41 @@ export const Home = () => {
const context = useContext(QueueContext); const context = useContext(QueueContext);
const loaderRef = useRef<HTMLDivElement | null>(null); const loaderRef = useRef<HTMLDivElement | null>(null);
// Prevent scrolling on mobile only when there are no results (empty state)
useEffect(() => {
const isMobile = window.innerWidth < 768; // md breakpoint
if (!isMobile) return;
// Only prevent scrolling when there are no results to show
const shouldPreventScroll = !isLoading && displayedResults.length === 0 && !query.trim();
if (!shouldPreventScroll) return;
// Store original styles
const originalOverflow = document.body.style.overflow;
const originalHeight = document.body.style.height;
// Find the mobile main content container
const mobileMain = document.querySelector('.pwa-main') as HTMLElement;
const originalMainOverflow = mobileMain?.style.overflow;
// Prevent body and main container scrolling on mobile when empty
document.body.style.overflow = 'hidden';
document.body.style.height = '100vh';
if (mobileMain) {
mobileMain.style.overflow = 'hidden';
}
// Cleanup function
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.height = originalHeight;
if (mobileMain) {
mobileMain.style.overflow = originalMainOverflow;
}
};
}, [isLoading, displayedResults.length, query]);
useEffect(() => { useEffect(() => {
navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) }); navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) });
}, [debouncedQuery, searchType, navigate]); }, [debouncedQuery, searchType, navigate]);
@@ -92,24 +143,32 @@ export const Home = () => {
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.map((item) => {
// Add safety checks for essential properties
if (!item || !item.id || !item.name || !item.model) {
return null;
}
let imageUrl; let imageUrl;
let onDownload; let onDownload;
let subtitle; let subtitle;
if (item.model === "track") { if (item.model === "track") {
imageUrl = (item as TrackType).album?.images?.[0]?.url; imageUrl = safelyGetProperty(item, ['album', 'images', '0', 'url'], undefined);
onDownload = () => handleDownloadTrack(item as TrackType); onDownload = () => handleDownloadTrack(item as TrackType);
subtitle = (item as TrackType).artists?.map((a) => a.name).join(", "); const artists = safelyGetProperty(item, ['artists'], []);
subtitle = Array.isArray(artists) ? artists.map((a: any) => safelyGetProperty(a, ['name'], 'Unknown')).join(", ") : "Unknown Artist";
} else if (item.model === "album") { } else if (item.model === "album") {
imageUrl = (item as AlbumType).images?.[0]?.url; imageUrl = safelyGetProperty(item, ['images', '0', 'url'], undefined);
onDownload = () => handleDownloadAlbum(item as AlbumType); onDownload = () => handleDownloadAlbum(item as AlbumType);
subtitle = (item as AlbumType).artists?.map((a) => a.name).join(", "); const artists = safelyGetProperty(item, ['artists'], []);
subtitle = Array.isArray(artists) ? artists.map((a: any) => safelyGetProperty(a, ['name'], 'Unknown')).join(", ") : "Unknown Artist";
} else if (item.model === "artist") { } else if (item.model === "artist") {
imageUrl = (item as ArtistType).images?.[0]?.url; imageUrl = safelyGetProperty(item, ['images', '0', 'url'], undefined);
subtitle = "Artist"; subtitle = "Artist";
} else if (item.model === "playlist") { } else if (item.model === "playlist") {
imageUrl = (item as PlaylistType).images?.[0]?.url; imageUrl = safelyGetProperty(item, ['images', '0', 'url'], undefined);
subtitle = `By ${(item as PlaylistType).owner?.display_name || "Unknown"}`; const ownerName = safelyGetProperty(item, ['owner', 'display_name'], 'Unknown');
subtitle = `By ${ownerName}`;
} }
return ( return (
@@ -123,26 +182,28 @@ export const Home = () => {
onDownload={onDownload} onDownload={onDownload}
/> />
); );
})} }).filter(Boolean)} {/* Filter out null components */}
</div> </div>
); );
}, [displayedResults, handleDownloadTrack, handleDownloadAlbum]); }, [displayedResults, handleDownloadTrack, handleDownloadAlbum]);
return ( return (
<div className="max-w-4xl mx-auto p-4"> <div className="max-w-4xl mx-auto h-full flex flex-col md:p-4">
<h1 className="text-2xl font-bold mb-6">Search Spotify</h1> <div className="text-center mb-4 md:mb-8 px-4 md:px-0">
<div className="flex flex-col sm:flex-row gap-3 mb-6"> <h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1>
</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 <input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a track, album, or artist" placeholder="Search for a track, album, or artist"
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 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"
/> />
<select <select
value={searchType} value={searchType}
onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")} onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")}
className="p-2 border rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500" 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"
> >
<option value="track">Track</option> <option value="track">Track</option>
<option value="album">Album</option> <option value="album">Album</option>
@@ -150,15 +211,20 @@ export const Home = () => {
<option value="playlist">Playlist</option> <option value="playlist">Playlist</option>
</select> </select>
</div> </div>
{isLoading ? ( <div className={`flex-1 px-4 md:px-0 pb-4 ${
<p className="text-center my-4">Loading results...</p> // Only restrict overflow on mobile when there are results, otherwise allow normal behavior
) : ( displayedResults.length > 0 ? 'overflow-y-auto md:overflow-visible' : ''
<> }`}>
{resultComponent} {isLoading ? (
<div ref={loaderRef} /> <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
{isLoadingMore && <p className="text-center my-4">Loading more results...</p>} ) : (
</> <>
)} {resultComponent}
<div ref={loaderRef} />
{isLoadingMore && <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>}
</>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -1,34 +1,45 @@
import { Link, useParams } from "@tanstack/react-router"; import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext } from "react"; import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client"; import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PlaylistType, TrackType } from "../types/spotify"; import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context"; import { QueueContext } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa"; import { FaArrowLeft } from "react-icons/fa";
import { FaDownload } from "react-icons/fa6";
export const Playlist = () => { export const Playlist = () => {
const { playlistId } = useParams({ from: "/playlist/$playlistId" }); const { playlistId } = useParams({ from: "/playlist/$playlistId" });
const [playlist, setPlaylist] = useState<PlaylistType | null>(null); const [playlistMetadata, setPlaylistMetadata] = useState<PlaylistMetadataType | null>(null);
const [tracks, setTracks] = useState<PlaylistItemType[]>([]);
const [isWatched, setIsWatched] = useState(false); const [isWatched, setIsWatched] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadingTracks, setLoadingTracks] = useState(false);
const [hasMoreTracks, setHasMoreTracks] = useState(true);
const [tracksOffset, setTracksOffset] = useState(0);
const [totalTracks, setTotalTracks] = useState(0);
const context = useContext(QueueContext); const context = useContext(QueueContext);
const { settings } = useSettings(); const { settings } = useSettings();
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
if (!context) { if (!context) {
throw new Error("useQueue must be used within a QueueProvider"); throw new Error("useQueue must be used within a QueueProvider");
} }
const { addItem } = context; const { addItem } = context;
// Load playlist metadata first
useEffect(() => { useEffect(() => {
const fetchPlaylist = async () => { const fetchPlaylistMetadata = async () => {
if (!playlistId) return; if (!playlistId) return;
try { try {
const response = await apiClient.get<PlaylistType>(`/playlist/info?id=${playlistId}`); const response = await apiClient.get<PlaylistMetadataType>(`/playlist/metadata?id=${playlistId}`);
setPlaylist(response.data); setPlaylistMetadata(response.data);
setTotalTracks(response.data.tracks.total);
} catch (err) { } catch (err) {
setError("Failed to load playlist"); setError("Failed to load playlist metadata");
console.error(err); console.error(err);
} }
}; };
@@ -45,10 +56,76 @@ export const Playlist = () => {
} }
}; };
fetchPlaylist(); fetchPlaylistMetadata();
checkWatchStatus(); checkWatchStatus();
}, [playlistId]); }, [playlistId]);
// Load tracks progressively
const loadMoreTracks = useCallback(async () => {
if (!playlistId || loadingTracks || !hasMoreTracks) return;
setLoadingTracks(true);
try {
const limit = 50; // Load 50 tracks at a time
const response = await apiClient.get<PlaylistTracksResponseType>(
`/playlist/tracks?id=${playlistId}&limit=${limit}&offset=${tracksOffset}`
);
const newTracks = response.data.items;
setTracks(prev => [...prev, ...newTracks]);
setTracksOffset(prev => prev + newTracks.length);
// Check if we've loaded all tracks
if (tracksOffset + newTracks.length >= totalTracks) {
setHasMoreTracks(false);
}
} catch (err) {
console.error("Failed to load tracks:", err);
toast.error("Failed to load more tracks");
} finally {
setLoadingTracks(false);
}
}, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks]);
// Intersection Observer for infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMoreTracks && !loadingTracks) {
loadMoreTracks();
}
},
{ threshold: 0.1 }
);
if (loadingRef.current) {
observer.observe(loadingRef.current);
}
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [loadMoreTracks, hasMoreTracks, loadingTracks]);
// Load initial tracks when metadata is loaded
useEffect(() => {
if (playlistMetadata && tracks.length === 0 && totalTracks > 0) {
loadMoreTracks();
}
}, [playlistMetadata, tracks.length, totalTracks, loadMoreTracks]);
// Reset state when playlist ID changes
useEffect(() => {
setTracks([]);
setTracksOffset(0);
setHasMoreTracks(true);
setTotalTracks(0);
}, [playlistId]);
const handleDownloadTrack = (track: TrackType) => { const handleDownloadTrack = (track: TrackType) => {
if (!track?.id) return; if (!track?.id) return;
addItem({ spotifyId: track.id, type: "track", name: track.name }); addItem({ spotifyId: track.id, type: "track", name: track.name });
@@ -56,13 +133,13 @@ export const Playlist = () => {
}; };
const handleDownloadPlaylist = () => { const handleDownloadPlaylist = () => {
if (!playlist) return; if (!playlistMetadata) return;
addItem({ addItem({
spotifyId: playlist.id, spotifyId: playlistMetadata.id,
type: "playlist", type: "playlist",
name: playlist.name, name: playlistMetadata.name,
}); });
toast.info(`Adding ${playlist.name} to queue...`); toast.info(`Adding ${playlistMetadata.name} to queue...`);
}; };
const handleToggleWatch = async () => { const handleToggleWatch = async () => {
@@ -70,10 +147,10 @@ export const Playlist = () => {
try { try {
if (isWatched) { if (isWatched) {
await apiClient.delete(`/playlist/watch/${playlistId}`); await apiClient.delete(`/playlist/watch/${playlistId}`);
toast.success(`Removed ${playlist?.name} from watchlist.`); toast.success(`Removed ${playlistMetadata?.name} from watchlist.`);
} else { } else {
await apiClient.put(`/playlist/watch/${playlistId}`); await apiClient.put(`/playlist/watch/${playlistId}`);
toast.success(`Added ${playlist?.name} to watchlist.`); toast.success(`Added ${playlistMetadata?.name} to watchlist.`);
} }
setIsWatched(!isWatched); setIsWatched(!isWatched);
} catch (err) { } catch (err) {
@@ -86,118 +163,159 @@ export const Playlist = () => {
return <div className="text-red-500 p-8 text-center">{error}</div>; return <div className="text-red-500 p-8 text-center">{error}</div>;
} }
if (!playlist) { if (!playlistMetadata) {
return <div className="p-8 text-center">Loading...</div>; return <div className="p-8 text-center">Loading playlist...</div>;
} }
const filteredTracks = playlist.tracks.items.filter(({ track }) => { const filteredTracks = tracks.filter(({ track }) => {
if (!track) return false; if (!track) return false;
if (settings?.explicitFilter && track.explicit) return false; if (settings?.explicitFilter && track.explicit) return false;
return true; return true;
}); });
return ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="mb-6"> {/* Back Button */}
<div className="mb-4 md:mb-6">
<button <button
onClick={() => window.history.back()} onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors" className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
> >
<FaArrowLeft /> <FaArrowLeft className="icon-secondary hover:icon-primary" />
<span>Back to results</span> <span>Back to results</span>
</button> </button>
</div> </div>
<div className="flex flex-col md:flex-row items-start gap-6">
<img {/* Playlist Header - Mobile Optimized */}
src={playlist.images[0]?.url || "/placeholder.jpg"} <div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm">
alt={playlist.name} <div className="flex flex-col items-center gap-4 md:gap-6">
className="w-48 h-48 object-cover rounded-lg shadow-lg" <img
/> src={playlistMetadata.images[0]?.url || "/placeholder.jpg"}
<div className="flex-grow space-y-2"> alt={playlistMetadata.name}
<h1 className="text-3xl font-bold">{playlist.name}</h1> className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 object-cover rounded-lg shadow-lg mx-auto"
{playlist.description && <p className="text-gray-500 dark:text-gray-400">{playlist.description}</p>} />
<div className="text-sm text-gray-400 dark:text-gray-500"> <div className="flex-grow space-y-2 text-center">
<p> <h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{playlistMetadata.name}</h1>
By {playlist.owner.display_name} {playlist.followers.total.toLocaleString()} followers {" "} {playlistMetadata.description && (
{playlist.tracks.total} songs <p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p>
)}
<p className="text-sm text-content-muted dark:text-content-muted-dark">
By {playlistMetadata.owner.display_name} {playlistMetadata.followers.total.toLocaleString()} followers {totalTracks} songs
</p> </p>
</div> </div>
<div className="flex gap-2 pt-2"> </div>
<button
onClick={handleDownloadPlaylist} {/* Action Buttons - Full Width on Mobile */}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" <div className="mt-4 md:mt-6 flex flex-col sm:flex-row gap-2 sm:gap-3">
> <button
Download All onClick={handleDownloadPlaylist}
</button> className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm"
<button >
onClick={handleToggleWatch} Download All
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${ </button>
isWatched <button
? "bg-red-600 text-white hover:bg-red-700" onClick={handleToggleWatch}
: "bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600" className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
}`} isWatched
> ? "bg-error hover:bg-error-hover text-button-primary-text"
<img : "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"} }`}
alt="Watch status" >
className="w-5 h-5" <img
style={{ filter: !isWatched ? "invert(1)" : undefined }} src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
/> alt="Watch status"
{isWatched ? "Unwatch" : "Watch"} className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
</button> />
</div> {isWatched ? "Unwatch" : "Watch"}
</button>
</div> </div>
</div> </div>
<div className="space-y-4"> {/* Tracks Section */}
<h2 className="text-xl font-semibold">Tracks</h2> <div className="space-y-3 md:space-y-4">
<div className="space-y-2"> <div className="flex items-center justify-between px-1">
{filteredTracks.map(({ track }, index) => { <h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
if (!track) return null; {tracks.length > 0 && (
return ( <span className="text-sm text-content-muted dark:text-content-muted-dark">
<div Showing {tracks.length} of {totalTracks} tracks
key={track.id} </span>
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" )}
> </div>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span> <div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
<img <div className="space-y-1 md:space-y-2">
src={track.album.images.at(-1)?.url} {filteredTracks.map(({ track }, index) => {
alt={track.album.name} if (!track) return null;
className="w-10 h-10 object-cover rounded" return (
/> <div
<div> key={track.id}
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium hover:underline"> className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
{track.name} >
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
<Link to="/album/$albumId" params={{ albumId: track.album.id }}>
<img
src={track.album.images.at(-1)?.url}
alt={track.album.name}
className="w-10 h-10 md:w-12 md:h-12 object-cover rounded hover:scale-105 transition-transform duration-300"
/>
</Link> </Link>
<p className="text-sm text-gray-500 dark:text-gray-400"> <div className="min-w-0 flex-1">
{track.artists.map((artist, index) => ( <Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base hover:underline block truncate">
<span key={artist.id}> {track.name}
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline"> </Link>
{artist.name} <p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
</Link> {track.artists.map((artist, index) => (
{index < track.artists.length - 1 && ", "} <span key={artist.id}>
</span> <Link
))} to="/artist/$artistId"
</p> params={{ artistId: artist.id }}
className="hover:underline"
>
{artist.name}
</Link>
{index < track.artists.length - 1 && ", "}
</span>
))}
</p>
</div>
</div>
<div className="flex items-center gap-2 md:gap-4 shrink-0">
<span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span>
<button
onClick={() => handleDownloadTrack(track)}
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all hover:scale-105 hover:shadow-sm"
title="Download"
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
</button>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> );
<span className="text-gray-500 dark:text-gray-400"> })}
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")} {/* Loading indicator */}
</span> {loadingTracks && (
<button <div className="flex justify-center py-4">
onClick={() => handleDownloadTrack(track)} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
title="Download"
>
<FaDownload />
</button>
</div>
</div> </div>
); )}
})}
{/* Intersection observer target */}
{hasMoreTracks && (
<div ref={loadingRef} className="h-4" />
)}
{/* End of tracks indicator */}
{!hasMoreTracks && tracks.length > 0 && (
<div className="text-center py-4 text-content-muted dark:text-content-muted-dark">
All tracks loaded
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,61 +1,183 @@
import { Outlet } from "@tanstack/react-router"; import { Outlet, Link } from "@tanstack/react-router";
import { QueueProvider } from "../contexts/QueueProvider"; import { QueueProvider } from "@/contexts/QueueProvider";
import { useQueue } from "../contexts/queue-context"; import { SettingsProvider } from "@/contexts/SettingsProvider";
import { Queue } from "../components/Queue"; import { QueueContext } from "@/contexts/queue-context";
import { Link } from "@tanstack/react-router"; import { Queue } from "@/components/Queue";
import { SettingsProvider } from "../contexts/SettingsProvider"; import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
import { Toaster } from "sonner"; import { UserMenu } from "@/components/auth/UserMenu";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useContext, useState, useEffect } from "react";
import { getTheme, toggleTheme } from "@/lib/theme";
// Create a client function ThemeToggle() {
const queryClient = new QueryClient(); const [currentTheme, setCurrentTheme] = useState<'light' | 'dark' | 'system'>('system');
useEffect(() => {
// Set initial theme
setCurrentTheme(getTheme());
// Listen for theme changes (in case they happen elsewhere)
const handleStorageChange = () => {
setCurrentTheme(getTheme());
};
// Listen for system theme changes that might affect our display
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = () => {
// Force a re-render when system preference changes
// This ensures our toggle shows the correct state
setCurrentTheme(getTheme());
};
window.addEventListener('storage', handleStorageChange);
mediaQuery.addEventListener('change', handleSystemChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
mediaQuery.removeEventListener('change', handleSystemChange);
};
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setCurrentTheme(newTheme);
};
const getThemeIcon = () => {
switch (currentTheme) {
case 'light':
return <img src="/light.svg" alt="Light theme" className="w-5 h-5 logo" />;
case 'dark':
return <img src="/dark.svg" alt="Dark theme" className="w-5 h-5 logo" />;
default:
return <img src="/system.svg" alt="System theme" className="w-5 h-5 logo" />;
}
};
const getThemeLabel = () => {
switch (currentTheme) {
case 'light':
return 'Light';
case 'dark':
return 'Dark';
default:
return 'System';
}
};
return (
<button
onClick={handleToggle}
className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark flex items-center gap-1"
title={`Current theme: ${getThemeLabel()}. Click to cycle through themes.`}
>
{getThemeIcon()}
<span className="hidden sm:inline text-sm font-medium text-content-secondary dark:text-content-secondary-dark">
{getThemeLabel()}
</span>
</button>
);
}
function AppLayout() { function AppLayout() {
const { toggleVisibility } = useQueue(); const { toggleVisibility, totalTasks } = useContext(QueueContext) || {};
return ( return (
<> <div className="min-h-screen bg-gradient-to-br from-surface-secondary via-surface-muted to-surface-accent dark:from-surface-dark dark:via-surface-muted-dark dark:to-surface-secondary-dark text-content-primary dark:text-content-primary-dark flex flex-col">
<div className="min-h-screen bg-background text-foreground"> {/* Desktop Header */}
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur-sm"> <header className="hidden md:block sticky top-0 z-40 w-full border-b border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-sm">
<div className="container mx-auto h-14 flex items-center justify-between"> <div className="container mx-auto h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2"> <Link to="/" className="flex items-center">
<img src="/music.svg" alt="Logo" className="w-6 h-6" /> <img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
<h1 className="text-xl font-bold">Spotizerr</h1> </Link>
<div className="flex items-center gap-2">
<ThemeToggle />
<UserMenu />
<Link to="/watchlist" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6 logo" />
</Link> </Link>
<div className="flex items-center gap-2"> <Link to="/history" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<Link to="/watchlist" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> <img src="/history.svg" alt="History" className="w-6 h-6 logo" />
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6" /> </Link>
</Link> <Link to="/config" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<Link to="/history" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> <img src="/settings.svg" alt="Settings" className="w-6 h-6 logo" />
<img src="/history.svg" alt="History" className="w-6 h-6" /> </Link>
</Link> <button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark relative">
<Link to="/config" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> <img src="/queue.svg" alt="Queue" className="w-6 h-6 logo" />
<img src="/settings.svg" alt="Settings" className="w-6 h-6" /> {(totalTasks ?? 0) > 0 && (
</Link> <span className="absolute -top-1 -right-1 bg-primary text-white text-xs font-bold rounded-full min-w-[20px] h-5 flex items-center justify-center px-1.5 shadow-lg animate-pulse">
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> {(totalTasks ?? 0) > 99 ? '99+' : totalTasks}
<img src="/queue.svg" alt="Queue" className="w-6 h-6" /> </span>
</button> )}
</div> </button>
</div>
</div>
</header>
{/* Desktop Main Content */}
<main className="hidden md:block container mx-auto p-4 flex-1">
<Outlet />
</main>
{/* Mobile Layout Container */}
<div className="md:hidden flex flex-col h-screen">
{/* Mobile Header - Fixed */}
<header className="fixed top-0 left-0 right-0 z-40 border-b border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-sm pwa-header">
<div className="container mx-auto h-14 flex items-center justify-between px-4">
<Link to="/" className="flex items-center">
<img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
</Link>
<ThemeToggle />
<UserMenu />
</div> </div>
</header> </header>
<main className="container mx-auto px-4 py-8">
<Outlet /> {/* Mobile Main Content - Scrollable container */}
<main className="flex-1 overflow-y-auto mt-14 mb-16 pwa-main">
<div className="container mx-auto p-4">
<Outlet />
</div>
</main> </main>
{/* Mobile Bottom Navigation - Fixed */}
<nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-md pwa-footer">
<div className="container mx-auto h-16 flex items-center justify-around">
<Link to="/" className="p-3 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/home.svg" alt="Home" className="w-6 h-6 logo" />
</Link>
<Link to="/watchlist" className="p-3 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6 logo" />
</Link>
<Link to="/history" className="p-3 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/history.svg" alt="History" className="w-6 h-6 logo" />
</Link>
<Link to="/config" className="p-3 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/settings.svg" alt="Settings" className="w-6 h-6 logo" />
</Link>
<button onClick={toggleVisibility} className="p-3 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark relative">
<img src="/queue.svg" alt="Queue" className="w-6 h-6 logo" />
{(totalTasks ?? 0) > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-primary text-white text-xs font-bold rounded-full min-w-[20px] h-5 flex items-center justify-center px-1.5 shadow-lg animate-pulse">
{(totalTasks ?? 0) > 99 ? '99+' : totalTasks}
</span>
)}
</button>
</div>
</nav>
</div> </div>
<Queue /> <Queue />
<Toaster richColors duration={1500} position="bottom-left" /> </div>
</>
); );
} }
export function Root() { export default function Root() {
return ( return (
<QueryClientProvider client={queryClient}> <SettingsProvider>
<SettingsProvider> <QueueProvider>
<QueueProvider> <ProtectedRoute>
<AppLayout /> <AppLayout />
</QueueProvider> </ProtectedRoute>
</SettingsProvider> </QueueProvider>
</QueryClientProvider> </SettingsProvider>
); );
} }

View File

@@ -47,7 +47,7 @@ export const Track = () => {
if (error) { if (error) {
return ( return (
<div className="flex justify-center items-center h-full"> <div className="flex justify-center items-center h-full">
<p className="text-red-500 text-lg">{error}</p> <p className="text-error text-lg">{error}</p>
</div> </div>
); );
} }
@@ -55,7 +55,7 @@ export const Track = () => {
if (!track) { if (!track) {
return ( return (
<div className="flex justify-center items-center h-full"> <div className="flex justify-center items-center h-full">
<p className="text-lg">Loading...</p> <p className="text-lg text-content-muted dark:text-content-muted-dark">Loading...</p>
</div> </div>
); );
} }
@@ -63,76 +63,131 @@ export const Track = () => {
const imageUrl = track.album.images?.[0]?.url; const imageUrl = track.album.images?.[0]?.url;
return ( return (
<div className="max-w-4xl mx-auto p-4 md:p-8"> <div className="max-w-6xl mx-auto p-4 md:p-8">
<div className="mb-6"> <div className="mb-4 md:mb-6">
<button <button
onClick={() => window.history.back()} onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors" className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
> >
<FaArrowLeft /> <FaArrowLeft className="icon-secondary hover:icon-primary" />
<span>Back to results</span> <span>Back to results</span>
</button> </button>
</div> </div>
<div className="bg-white shadow-lg rounded-lg overflow-hidden md:flex">
{imageUrl && ( {/* Hero Section with Cover */}
<div className="md:w-1/3"> <div className="bg-gradient-to-b from-surface-muted to-surface dark:from-surface-muted-dark dark:to-surface-dark rounded-lg overflow-hidden mb-8">
<img src={imageUrl} alt={track.album.name} className="w-full h-auto object-cover" /> <div className="flex flex-col md:flex-row items-center md:items-end gap-6 p-6 md:p-8">
{/* Album Cover */}
<div className="flex-shrink-0">
{imageUrl ? (
<img
src={imageUrl}
alt={track.album.name}
className="w-48 h-48 md:w-64 md:h-64 object-cover rounded-lg shadow-2xl"
/>
) : (
<div className="w-48 h-48 md:w-64 md:h-64 bg-surface-accent dark:bg-surface-accent-dark rounded-lg shadow-2xl flex items-center justify-center">
<img src="/placeholder.jpg" alt="No cover" className="w-16 h-16 opacity-50 logo" />
</div>
)}
</div> </div>
)}
<div className="p-6 md:w-2/3 flex flex-col justify-between"> {/* Track Info */}
<div> <div className="flex-1 text-center md:text-left md:pb-4">
<div className="flex items-baseline justify-between"> <div className="flex flex-col md:flex-row md:items-baseline gap-2 md:gap-4 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{track.name}</h1> <h1 className="text-3xl md:text-5xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">
{track.name}
</h1>
{track.explicit && ( {track.explicit && (
<span className="text-xs bg-gray-700 text-white px-2 py-1 rounded-full">EXPLICIT</span> <span className="text-xs bg-surface-dark dark:bg-surface text-content-primary-dark dark:text-content-primary px-3 py-1 rounded-full self-center md:self-auto font-semibold">
EXPLICIT
</span>
)} )}
</div> </div>
<div className="text-lg text-gray-600 mt-1">
<div className="text-lg md:text-xl text-content-secondary dark:text-content-secondary-dark mb-2">
{track.artists.map((artist, index) => ( {track.artists.map((artist, index) => (
<span key={artist.id}> <span key={artist.id}>
<Link to="/artist/$artistId" params={{ artistId: artist.id }}> <Link
to="/artist/$artistId"
params={{ artistId: artist.id }}
className="hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
>
{artist.name} {artist.name}
</Link> </Link>
{index < track.artists.length - 1 && ", "} {index < track.artists.length - 1 && ", "}
</span> </span>
))} ))}
</div> </div>
<p className="text-md text-gray-500 mt-4">
From the album{" "} <p className="text-content-muted dark:text-content-muted-dark">
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="font-semibold"> From{" "}
<Link
to="/album/$albumId"
params={{ albumId: track.album.id }}
className="font-semibold hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
>
{track.album.name} {track.album.name}
</Link> </Link>
</p> </p>
<div className="mt-4 text-sm text-gray-600"> </div>
<p>Release Date: {track.album.release_date}</p> </div>
<p>Duration: {formatDuration(track.duration_ms)}</p> </div>
</div>
<div className="mt-4"> {/* Details Section */}
<p className="text-sm text-gray-600">Popularity:</p> <div className="bg-surface dark:bg-surface-secondary-dark rounded-lg shadow-lg p-6 md:p-8">
<div className="w-full bg-gray-200 rounded-full h-2.5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-green-500 h-2.5 rounded-full" style={{ width: `${track.popularity}%` }}></div> {/* Track Details */}
<div>
<h2 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-4">Track Details</h2>
<div className="space-y-2 text-sm text-content-secondary dark:text-content-secondary-dark">
<div className="flex gap-4">
<span className="w-24 flex-shrink-0">Release Date:</span>
<span>{track.album.release_date}</span>
</div>
<div className="flex gap-4">
<span className="w-24 flex-shrink-0">Duration:</span>
<span>{formatDuration(track.duration_ms)}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 mt-6">
<button {/* Popularity */}
onClick={handleDownloadTrack} <div>
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-full transition duration-300" <h2 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-4">Popularity</h2>
> <div className="flex items-center gap-3">
Download <div className="flex-1 bg-surface-muted dark:bg-surface-muted-dark rounded-full h-3">
</button> <div
<a className="bg-primary h-3 rounded-full transition-all duration-500"
href={track.external_urls.spotify} style={{ width: `${track.popularity}%` }}
target="_blank" ></div>
rel="noopener noreferrer" </div>
className="flex items-center gap-2 text-gray-700 hover:text-black transition duration-300" <span className="text-sm font-medium text-content-secondary dark:text-content-secondary-dark">
aria-label="Listen on Spotify" {track.popularity}%
> </span>
<FaSpotify size={24} /> </div>
<span className="font-semibold">Listen on Spotify</span>
</a>
</div> </div>
</div> </div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-center gap-4">
<button
onClick={handleDownloadTrack}
className="w-full sm:w-auto bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-3 px-8 rounded-full transition duration-300 shadow-lg hover:shadow-xl"
>
Download
</button>
<a
href={track.external_urls.spotify}
target="_blank"
rel="noopener noreferrer"
className="w-full sm:w-auto flex items-center justify-center gap-3 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition duration-300 py-3 px-8 border border-border dark:border-border-dark rounded-full hover:border-border-accent dark:hover:border-border-accent-dark"
aria-label="Listen on Spotify"
>
<FaSpotify size={20} className="icon-secondary hover:icon-primary" />
<span className="font-semibold">Listen on Spotify</span>
</a>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -95,15 +95,15 @@ export const Watchlist = () => {
}; };
if (isLoading || settingsLoading) { if (isLoading || settingsLoading) {
return <div className="text-center">Loading Watchlist...</div>; return <div className="text-center text-content-muted dark:text-content-muted-dark">Loading Watchlist...</div>;
} }
if (!settings?.watch?.enabled) { if (!settings?.watch?.enabled) {
return ( return (
<div className="text-center p-8"> <div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2">Watchlist Disabled</h2> <h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist Disabled</h2>
<p>The watchlist feature is currently disabled. You can enable it in the settings.</p> <p className="text-content-secondary dark:text-content-secondary-dark">The watchlist feature is currently disabled. You can enable it in the settings.</p>
<Link to="/config" className="text-blue-500 hover:underline mt-4 inline-block"> <Link to="/config" className="text-primary hover:underline mt-4 inline-block">
Go to Settings Go to Settings
</Link> </Link>
</div> </div>
@@ -113,8 +113,8 @@ export const Watchlist = () => {
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="text-center p-8"> <div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2">Watchlist is Empty</h2> <h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2>
<p>Start watching artists or playlists to see them here.</p> <p className="text-content-secondary dark:text-content-secondary-dark">Start watching artists or playlists to see them here.</p>
</div> </div>
); );
} }
@@ -122,38 +122,38 @@ export const Watchlist = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1> <h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Watched Artists & Playlists</h1>
<button <button
onClick={handleCheckAll} onClick={handleCheckAll}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2" className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md flex items-center gap-2"
> >
<FaSearch /> Check All <FaSearch className="icon-inverse" /> Check All
</button> </button>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map((item) => ( {items.map((item) => (
<div key={item.id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col"> <div key={item.id} className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col">
<a href={`/${item.itemType}/${item.id}`} className="flex-grow"> <a href={`/${item.itemType}/${item.id}`} className="flex-grow">
<img <img
src={item.images?.[0]?.url || "/images/placeholder.jpg"} src={item.images?.[0]?.url || "/images/placeholder.jpg"}
alt={item.name} alt={item.name}
className="w-full h-auto object-cover rounded-md aspect-square" className="w-full h-auto object-cover rounded-md aspect-square"
/> />
<h3 className="font-bold pt-2 truncate">{item.name}</h3> <h3 className="font-bold pt-2 truncate text-content-primary dark:text-content-primary-dark">{item.name}</h3>
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p> <p className="text-sm text-content-muted dark:text-content-muted-dark capitalize">{item.itemType}</p>
</a> </a>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
onClick={() => handleUnwatch(item)} onClick={() => handleUnwatch(item)}
className="w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 flex items-center justify-center gap-2" className="w-full px-3 py-1.5 text-sm bg-error hover:bg-error-hover text-button-primary-text rounded-md flex items-center justify-center gap-2"
> >
<FaRegTrashAlt /> Unwatch <FaRegTrashAlt className="icon-inverse" /> Unwatch
</button> </button>
<button <button
onClick={() => handleCheck(item)} onClick={() => handleCheck(item)}
className="w-full px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center justify-center gap-2" className="w-full px-3 py-1.5 text-sm bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md flex items-center justify-center gap-2"
> >
<FaSearch /> Check <FaSearch className="icon-secondary hover:icon-primary" /> Check
</button> </button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,98 @@
// User and authentication types
export interface User {
username: string;
email?: string;
role: "user" | "admin";
created_at: string;
last_login?: string;
sso_provider?: string;
is_sso_user?: boolean;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: User;
}
export interface AuthStatusResponse {
auth_enabled: boolean;
authenticated: boolean;
user?: User;
registration_enabled: boolean;
sso_enabled?: boolean;
sso_providers?: string[];
}
export interface SSOProvider {
name: string;
display_name: string;
enabled: boolean;
login_url?: string;
}
export interface SSOStatusResponse {
sso_enabled: boolean;
providers: SSOProvider[];
registration_enabled: boolean;
}
export interface CreateUserRequest {
username: string;
password: string;
email?: string;
role: "user" | "admin";
}
export interface PasswordChangeRequest {
current_password: string;
new_password: string;
}
export interface AdminPasswordResetRequest {
new_password: string;
}
export interface AuthContextType {
// State
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
authEnabled: boolean;
registrationEnabled: boolean;
ssoEnabled: boolean;
ssoProviders: SSOProvider[];
// Actions
login: (credentials: LoginRequest, rememberMe?: boolean) => Promise<void>;
register: (userData: RegisterRequest) => Promise<void>;
logout: () => void;
checkAuthStatus: () => Promise<void>;
// SSO Actions
getSSOStatus: () => Promise<SSOStatusResponse>;
handleSSOCallback: (token: string) => Promise<void>;
// Token management
getToken: () => string | null;
setToken: (token: string | null, rememberMe?: boolean) => void;
// Session management
isRemembered: () => boolean;
}
export interface AuthError {
message: string;
status?: number;
}

View File

@@ -8,6 +8,16 @@ export interface ArtistType {
id: string; id: string;
name: string; name: string;
images?: ImageType[]; images?: ImageType[];
external_urls?: {
spotify: string;
};
followers?: {
total: number;
};
genres?: string[];
popularity?: number;
type?: string;
uri?: string;
} }
export interface TrackAlbumInfo { export interface TrackAlbumInfo {
@@ -50,6 +60,7 @@ export interface PlaylistItemType {
added_at: string; added_at: string;
is_local: boolean; is_local: boolean;
track: TrackType | null; track: TrackType | null;
is_locally_known?: boolean;
} }
export interface PlaylistOwnerType { export interface PlaylistOwnerType {
@@ -57,6 +68,31 @@ export interface PlaylistOwnerType {
display_name: string; display_name: string;
} }
// New interface for playlist metadata only (no tracks)
export interface PlaylistMetadataType {
id: string;
name: string;
description: string | null;
images: ImageType[];
tracks: {
total: number;
};
owner: PlaylistOwnerType;
followers: {
total: number;
};
_metadata_only: boolean;
_tracks_loaded: boolean;
}
// New interface for playlist tracks response
export interface PlaylistTracksResponseType {
items: PlaylistItemType[];
total: number;
limit: number;
offset: number;
}
export interface PlaylistType { export interface PlaylistType {
id: string; id: string;
name: string; name: string;
@@ -75,3 +111,13 @@ export interface PlaylistType {
export type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & { export type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & {
model: "track" | "album" | "artist" | "playlist"; model: "track" | "album" | "artist" | "playlist";
}; };
// API response type that can contain null values
export interface SearchApiResponse {
items: (SearchResult | null)[];
}
// Type guard to check if a search result is valid (not null)
export function isValidSearchResult(item: SearchResult | null): item is SearchResult {
return item !== null && typeof item === 'object' && 'id' in item;
}

View File

@@ -3,19 +3,151 @@ import react from "@vitejs/plugin-react";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname, resolve } from "path"; import { dirname, resolve } from "path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'spotizerr.svg', '*.svg'],
injectRegister: 'auto',
manifest: {
name: 'Spotizerr',
short_name: 'Spotizerr',
description: 'Music downloader and manager for Spotify content',
theme_color: '#1e293b',
background_color: '#0f172a',
display: 'standalone',
scope: '/',
start_url: '/',
lang: 'en',
orientation: 'portrait-primary',
categories: ['music', 'entertainment', 'utilities'],
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
},
{
src: 'apple-touch-icon-180x180.png',
sizes: '180x180',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/_/, /\/[^/?]+\.[^/]+$/, /^\/api\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
},
devOptions: {
enabled: true,
type: 'module'
}
})
],
resolve: { resolve: {
alias: { alias: {
"@": resolve(__dirname, "./src"), "@": resolve(__dirname, "./src"),
}, },
}, },
build: {
chunkSizeWarningLimit: 1000, // Increase warning limit to 1MB
rollupOptions: {
output: {
manualChunks: {
// Core React and routing
'react-vendor': ['react', 'react-dom'],
'router-vendor': ['@tanstack/react-router'],
// Query and state management
'query-vendor': ['@tanstack/react-query'],
// UI and icon libraries
'ui-vendor': ['lucide-react', 'react-icons', 'sonner'],
// Table components (only used in specific routes)
'table-vendor': ['@tanstack/react-table'],
// Form handling
'form-vendor': ['react-hook-form', 'use-debounce'],
// HTTP client
'http-vendor': ['axios'],
// Config components (heavy route with many tabs)
'config-components': [
'./src/components/config/GeneralTab',
'./src/components/config/DownloadsTab',
'./src/components/config/FormattingTab',
'./src/components/config/AccountsTab',
'./src/components/config/WatchTab',
'./src/components/config/ServerTab',
'./src/components/config/UserManagementTab',
'./src/components/config/ProfileTab'
],
// Utilities and helpers
'utils-vendor': ['uuid'],
},
// Additional chunk optimization
chunkFileNames: () => {
return `assets/[name]-[hash].js`;
},
},
},
},
server: { server: {
host: true,
port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:7171", target: "http://localhost:7171",