34 Commits
4.0.0 ... main

Author SHA1 Message Date
eb6e7bd4b2 Update files
Signed-off-by: Lev Rusanov <30170278+JDM170@users.noreply.github.com>
2025-09-24 21:10:12 +07:00
9c1c195353 Update requirements.txt
Signed-off-by: Lev Rusanov <30170278+JDM170@users.noreply.github.com>
2025-09-24 19:54:41 +07:00
spotizerr
773e5a55e1 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: https://lavaforge.org/spotizerr/spotizerr/pulls/1
2025-08-31 01:23:26 +00:00
xoconoch
46af6b518d fix: config page 2025-08-30 10:35:56 -06:00
Xoconoch
3ff6134712 fix: artist images 2025-08-30 07:27:13 -06:00
Xoconoch
5942e6ea36 fix: images and id not loading for playlists in watchlist 2025-08-30 06:58:46 -06:00
Spotizerr
9e4b2fcd01 Merge pull request #338 from Phlogi/performance-improvements
enh(api): add per-task sse throttling and batching for robust updates
2025-08-30 06:12:50 -06:00
Spotizerr
63afc969c0 Merge branch 'dev' into performance-improvements 2025-08-30 06:12:42 -06:00
Phlogi
bf2f9eda29 Merge pull request #4 from Phlogi/gh-wf
Gh wf
2025-08-30 13:08:52 +02:00
Phlogi
91fead1f51 Merge branch 'performance-improvements' into gh-wf 2025-08-30 13:08:45 +02:00
che-pj
6922b4a5da updates 2025-08-30 12:59:30 +02:00
che-pj
1016d333cc ci(workflows): add pr-build workflow for dev/test container images
- Introduces a new GitHub Actions workflow that automatically builds and pushes multi-arch Docker images for pull requests
- Images are tagged with the PR number (e.g., dev-pr-123) for easy identification
- Uses GHCR as the container registry with proper authentication via GITHUB_TOKEN
- Implements BuildKit cache optimization for faster builds
- Supports both linux/amd64 and linux/arm64 platforms
2025-08-30 12:24:37 +02:00
che-pj
f9cf953de1 feat(api): add per-task sse throttling and batching for robust updates 2025-08-30 09:32:44 +02:00
Xoconoch
e777dbeba2 feat: added librespotConcurrency, which determines the threadpool of librespot api processes 2025-08-29 09:32:37 -06:00
Xoconoch
41db454414 feat: implement tweakable utility workers concurrency, instead of hard-coded value set to 5 2025-08-29 08:33:23 -06:00
Xoconoch
fe5e7964fa fix: minor optimizations, trying to fix #333 2025-08-29 08:26:16 -06:00
Xoconoch
f800251de1 fix: load playlist image on frontend 2025-08-28 08:40:39 -06:00
Xoconoch
0b7c9d0da8 feat: Reimplement download artist discography per groups in artist page 2025-08-28 07:51:10 -06:00
Xoconoch
4476d39d39 fix: artist frontend rendering 2025-08-28 07:16:05 -06:00
Spotizerr
84b93f900e Merge pull request #337 from Phlogi/fix-slow-loading-of-watchlist
fix(ui): improve watchlist loading with batching and skeletons
2025-08-28 06:54:49 -06:00
Spotizerr
c5e9d0cabc Merge pull request #335 from Phlogi/add-logging-info-to-example
enh(config): add logging configuration to .env.example
2025-08-28 06:53:55 -06:00
Spotizerr
c81df38571 Merge pull request #334 from Phlogi/fixup-bulk-add-celery
(fix): bulk add links correctly to celery manager
2025-08-28 06:53:07 -06:00
che-pj
7b7e32c923 fixup after merge/rebase to dev 2025-08-27 21:39:08 +02:00
Phlogi
957928bfa0 refactor(api): replace direct celery tasks with queue manager in bulk add 2025-08-27 21:30:01 +02:00
che-pj
6c6a215e7c set default level to info 2025-08-27 21:19:23 +02:00
Phlogi
8806e2da34 Merge branch 'dev' into fixup-bulk-add-celery 2025-08-27 21:17:47 +02:00
che-pj
1e9271eac4 fix(ui): improve watchlist loading with batching and skeletons 2025-08-27 16:30:00 +02:00
Phlogi
af1e74294c feat(config): add logging configuration to .env.example
- Add LOG_LEVEL environment variable with possible values and usage guidance
- Improve redis host documentation for docker-compose compatibility
2025-08-27 14:20:05 +02:00
Phlogi
d83e320a82 refactor(api): replace direct celery tasks with queue manager in bulk add 2025-08-27 09:43:01 +02:00
Spotizerr
8b90c7b75b Update README.md 2025-08-23 13:07:36 -06:00
Spotizerr
09a623f98b Update .env.example 2025-08-23 12:55:27 -06:00
Spotizerr
e5aa4f0aef Update README.md 2025-08-23 12:53:37 -06:00
Spotizerr
499a2472e5 Merge pull request #310 from spotizerr-dev/dev
Dev
2025-08-23 12:52:01 -06:00
Spotizerr
7848c8f218 Merge pull request #299 from spotizerr-dev/dev
3.2.1
2025-08-21 19:57:35 -05:00
28 changed files with 1309 additions and 438 deletions

View File

@@ -4,13 +4,14 @@
### can leave the defaults as they are. ### can leave the defaults as they are.
### ###
### If you plan on using for a server, ### If you plan on using for a server,
### see [insert docs url] ### see https://spotizerr.rtfd.io
### ###
# Interface to bind to. Unless you know what you're doing, don't change this # Interface to bind to. Unless you know what you're doing, don't change this
HOST=0.0.0.0 HOST=0.0.0.0
# Redis connection (external or internal). # Redis connection (external or internal).
# Host name 'redis' works with docker-compose.yml setup
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_DB=0 REDIS_DB=0
@@ -56,4 +57,9 @@ GOOGLE_CLIENT_SECRET=
# GitHub SSO (get from GitHub Developer Settings) # GitHub SSO (get from GitHub Developer Settings)
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
# Log level for application logging.
# Possible values: debug, info, warning, error, critical
# Set to 'info' or 'warning' for general use. Use 'debug' for troubleshooting.
LOG_LEVEL=info

60
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: PR Dev/Test Container
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: 'Pull request number (optional, for manual runs)'
required: false
branch:
description: 'Branch to build (optional, defaults to PR head or main)'
required: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract Docker metadata
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=dev-pr-${{ github.event.inputs.pr_number || github.event.pull_request.number }}
# Build and push multi-arch dev image
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -8,11 +8,14 @@ COPY spotizerr-ui/. .
RUN pnpm build RUN pnpm build
# Stage 2: Python dependencies builder (create relocatable deps dir) # Stage 2: Python dependencies builder (create relocatable deps dir)
FROM python:3.11-slim AS py-deps FROM python:3.11-alpine AS py-deps
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
RUN uv pip install --target /python -r requirements.txt RUN apk add --no-cache git; \
uv pip install --target /python -r requirements.txt; \
uv pip install --target /python "git+https://git.jdm17.ru/JDM170/librespot-spotizerr-dev.git@main"; \
uv pip install --target /python "git+https://git.jdm17.ru/JDM170/deezspot-spotizerr-dev.git@main"
# Stage 3: Fetch static ffmpeg/ffprobe binaries # Stage 3: Fetch static ffmpeg/ffprobe binaries
FROM debian:stable-slim AS ffmpeg FROM debian:stable-slim AS ffmpeg

View File

@@ -27,6 +27,10 @@ If you self-host a music server with other users than yourself, you almost certa
<img width="1588" height="994" alt="image" src="https://github.com/user-attachments/assets/e34d7dbb-29e3-4d75-bcbd-0cee03fa57dc" /> <img width="1588" height="994" alt="image" src="https://github.com/user-attachments/assets/e34d7dbb-29e3-4d75-bcbd-0cee03fa57dc" />
</details> </details>
## How do I start?
Docs are available at: https://spotizerr.rtfd.io
### Common Issues ### Common Issues
**Downloads not starting?** **Downloads not starting?**

95
app.py
View File

@@ -13,11 +13,12 @@ import redis
import socket import socket
from urllib.parse import urlparse from urllib.parse import urlparse
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Parse log level from environment as early as possible, default to INFO for visibility # Parse log level from environment as early as possible, default to INFO for visibility
log_level_str = os.getenv("LOG_LEVEL", "WARNING").upper() log_level_str = os.getenv("LOG_LEVEL", "WARNING").upper()
log_level = getattr(logging, log_level_str, logging.INFO) log_level = getattr(logging, log_level_str, logging.WARNING)
# Set up a very basic logging config immediately, so early logs (including import/migration errors) are visible # Set up a very basic logging config immediately, so early logs (including import/migration errors) are visible
logging.basicConfig( logging.basicConfig(
@@ -50,32 +51,20 @@ if _umask_value:
# Defer logging setup; avoid failing on invalid UMASK # Defer logging setup; avoid failing on invalid UMASK
pass pass
# Import and initialize routes (this will start the watch manager) # Import and initialize routes (this will start the watch manager)
from routes.auth.credentials import router as credentials_router from routes.auth.credentials import router as credentials_router # noqa: E402
from routes.auth.auth import router as auth_router from routes.auth.auth import router as auth_router # noqa: E402
from routes.content.album import router as album_router from routes.content.album import router as album_router # noqa: E402
from routes.content.artist import router as artist_router from routes.content.artist import router as artist_router # noqa: E402
from routes.content.track import router as track_router from routes.content.track import router as track_router # noqa: E402
from routes.content.playlist import router as playlist_router from routes.content.playlist import router as playlist_router # noqa: E402
from routes.content.bulk_add import router as bulk_add_router from routes.content.bulk_add import router as bulk_add_router # noqa: E402
from routes.core.search import router as search_router from routes.core.search import router as search_router # noqa: E402
from routes.core.history import router as history_router from routes.core.history import router as history_router # noqa: E402
from routes.system.progress import router as prgs_router from routes.system.progress import router as prgs_router # noqa: E402
from routes.system.config import router as config_router from routes.system.config import router as config_router # noqa: E402
# Import Celery configuration and manager
from routes.utils.celery_manager import celery_manager
from routes.utils.celery_config import REDIS_URL
# Import authentication system
from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware
# Import watch manager controls (start/stop) without triggering side effects
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
from routes.utils.celery_config import REDIS_URL # noqa: E402
# Configure application-wide logging # Configure application-wide logging
@@ -136,9 +125,9 @@ def setup_logging():
"routes.utils.celery_manager", "routes.utils.celery_manager",
"routes.utils.celery_tasks", "routes.utils.celery_tasks",
"routes.utils.watch", "routes.utils.watch",
"uvicorn", # General Uvicorn logger "uvicorn", # General Uvicorn logger
"uvicorn.access", # Uvicorn access logs "uvicorn.access", # Uvicorn access logs
"uvicorn.error", # Uvicorn error logs "uvicorn.error", # Uvicorn error logs
"spotizerr", "spotizerr",
]: ]:
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
@@ -152,7 +141,6 @@ def setup_logging():
def check_redis_connection(): def check_redis_connection():
"""Check if Redis is available and accessible""" """Check if Redis is available and accessible"""
from routes.utils.celery_config import REDIS_URL
if not REDIS_URL: if not REDIS_URL:
logging.error("REDIS_URL is not configured. Please check your environment.") logging.error("REDIS_URL is not configured. Please check your environment.")
@@ -199,7 +187,9 @@ async def lifespan(app: FastAPI):
# Startup # Startup
setup_logging() setup_logging()
effective_level = logging.getLevelName(log_level) effective_level = logging.getLevelName(log_level)
logging.getLogger(__name__).info(f"Logging system fully initialized (lifespan startup). Effective log level: {effective_level}") logging.getLogger(__name__).info(
f"Logging system fully initialized (lifespan startup). Effective log level: {effective_level}"
)
# Run migrations before initializing services # Run migrations before initializing services
try: try:
@@ -226,8 +216,19 @@ async def lifespan(app: FastAPI):
try: try:
from routes.utils.celery_manager import celery_manager from routes.utils.celery_manager import celery_manager
celery_manager.start() start_workers = os.getenv("START_EMBEDDED_WORKERS", "true").lower() in (
logging.info("Celery workers started successfully") "1",
"true",
"yes",
"on",
)
if start_workers:
celery_manager.start()
logging.info("Celery workers started successfully")
else:
logging.info(
"START_EMBEDDED_WORKERS is false; skipping embedded Celery workers startup."
)
except Exception as e: except Exception as e:
logging.error(f"Failed to start Celery workers: {e}") logging.error(f"Failed to start Celery workers: {e}")
@@ -257,8 +258,19 @@ async def lifespan(app: FastAPI):
try: try:
from routes.utils.celery_manager import celery_manager from routes.utils.celery_manager import celery_manager
celery_manager.stop() start_workers = os.getenv("START_EMBEDDED_WORKERS", "true").lower() in (
logging.info("Celery workers stopped") "1",
"true",
"yes",
"on",
)
if start_workers:
celery_manager.stop()
logging.info("Celery workers stopped")
else:
logging.info(
"START_EMBEDDED_WORKERS is false; no embedded Celery workers to stop."
)
except Exception as e: except Exception as e:
logging.error(f"Error stopping Celery workers: {e}") logging.error(f"Error stopping Celery workers: {e}")
@@ -295,17 +307,6 @@ def create_app():
logging.warning(f"Auth system initialization failed or unavailable: {e}") logging.warning(f"Auth system initialization failed or unavailable: {e}")
# Register routers with URL prefixes # Register routers with URL prefixes
from routes.auth.auth import router as auth_router
from routes.system.config import router as config_router
from routes.core.search import router as search_router
from routes.auth.credentials import router as credentials_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.content.bulk_add import router as bulk_add_router
from routes.content.artist import router as artist_router
from routes.system.progress import router as prgs_router
from routes.core.history import router as history_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
@@ -449,4 +450,6 @@ if __name__ == "__main__":
except ValueError: except ValueError:
port = 7171 port = 7171
uvicorn.run(app, host=host, port=port, log_level=log_level_str.lower(), access_log=False) uvicorn.run(
app, host=host, port=port, log_level=log_level_str.lower(), access_log=False
)

0
log.txt Normal file
View File

View File

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

View File

@@ -1,10 +1,18 @@
import re import re
from typing import List from typing import List
from fastapi import APIRouter from fastapi import APIRouter, Request, Depends
from pydantic import BaseModel from pydantic import BaseModel
import logging import logging
# Assuming these imports are available for queue management and Spotify info # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User
# Import queue management and Spotify info
from routes.utils.celery_queue_manager import download_queue_manager
# Import authentication dependencies
# Import queue management and Spotify info
from routes.utils.get_info import ( from routes.utils.get_info import (
get_client, get_client,
get_track, get_track,
@@ -12,7 +20,6 @@ from routes.utils.get_info import (
get_playlist, get_playlist,
get_artist, get_artist,
) )
from routes.utils.celery_tasks import download_track, download_album, download_playlist
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,7 +30,11 @@ class BulkAddLinksRequest(BaseModel):
@router.post("/bulk-add-spotify-links") @router.post("/bulk-add-spotify-links")
async def bulk_add_spotify_links(request: BulkAddLinksRequest): async def bulk_add_spotify_links(
request: BulkAddLinksRequest,
req: Request,
current_user: User = Depends(require_auth_from_state),
):
added_count = 0 added_count = 0
failed_links = [] failed_links = []
total_links = len(request.links) total_links = len(request.links)
@@ -34,7 +45,7 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest):
# but still handle potential errors during info retrieval or unsupported types # but still handle potential errors during info retrieval or unsupported types
# Extract type and ID from the link directly using regex # Extract type and ID from the link directly using regex
match = re.match( match = re.match(
r"https://open\.spotify\.com(?:/intl-[a-z]{2})?/(track|album|playlist|artist)/([a-zA-Z0-9]+)(?:\?.*)?", r"https://open\.spotify\.com(?:/[a-z]{2})?/(track|album|playlist|artist)/([a-zA-Z0-9]+)(?:\?.*)?",
link, link,
) )
if not match: if not match:
@@ -46,6 +57,12 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest):
spotify_type = match.group(1) spotify_type = match.group(1)
spotify_id = match.group(2) spotify_id = match.group(2)
logger.debug(
f"Extracted from link: spotify_type={spotify_type}, spotify_id={spotify_id}"
)
logger.debug(
f"Extracted from link: spotify_type={spotify_type}, spotify_id={spotify_id}"
)
try: try:
# Get basic info to confirm existence and get name/artist # Get basic info to confirm existence and get name/artist
@@ -80,46 +97,33 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest):
# Construct URL for the download task # Construct URL for the download task
spotify_url = f"https://open.spotify.com/{spotify_type}/{spotify_id}" spotify_url = f"https://open.spotify.com/{spotify_type}/{spotify_id}"
# Add to Celery queue based on type # Prepare task data for the queue manager
if spotify_type == "track": task_data = {
download_track.delay( "download_type": spotify_type,
url=spotify_url, "url": spotify_url,
spotify_id=spotify_id, "name": item_name,
type=spotify_type, "artist": artist_name,
name=item_name, "spotify_id": spotify_id,
artist=artist_name, "type": spotify_type,
download_type="track", "username": current_user.username,
) "orig_request": dict(req.query_params),
elif spotify_type == "album": }
download_album.delay(
url=spotify_url, # Add to download queue using the queue manager
spotify_id=spotify_id, task_id = download_queue_manager.add_task(task_data)
type=spotify_type,
name=item_name, if task_id:
artist=artist_name, added_count += 1
download_type="album", logger.debug(
) f"Added {added_count}/{total_links} {spotify_type} '{item_name}' ({spotify_id}) to queue with task_id: {task_id}."
elif spotify_type == "playlist":
download_playlist.delay(
url=spotify_url,
spotify_id=spotify_id,
type=spotify_type,
name=item_name,
artist=artist_name,
download_type="playlist",
) )
else: else:
logger.warning( logger.warning(
f"Unsupported Spotify type for download: {spotify_type} for link: {link}" f"Failed to add {spotify_type} '{item_name}' ({spotify_id}) to queue."
) )
failed_links.append(link) failed_links.append(link)
continue continue
added_count += 1
logger.debug(
f"Added {added_count + 1}/{total_links} {spotify_type} '{item_name}' ({spotify_id}) to queue."
)
except Exception as e: except Exception as e:
logger.error(f"Error processing Spotify link {link}: {e}", exc_info=True) logger.error(f"Error processing Spotify link {link}: {e}", exc_info=True)
failed_links.append(link) failed_links.append(link)

View File

@@ -205,6 +205,9 @@ async def get_playlist_info(
playlist_info = get_playlist(client, spotify_id, expand_items=False) playlist_info = get_playlist(client, spotify_id, expand_items=False)
finally: finally:
pass pass
# Ensure id field is present (librespot sometimes omits it)
if playlist_info and "id" not in playlist_info:
playlist_info["id"] = spotify_id
return JSONResponse(content=playlist_info, status_code=200) return JSONResponse(content=playlist_info, status_code=200)
except Exception as e: except Exception as e:
@@ -233,41 +236,70 @@ async def add_to_watchlist(
} }
# Fetch playlist details from Spotify to populate our DB (metadata only) # Fetch playlist details from Spotify to populate our DB (metadata only)
cfg = get_config_params() or {} # Use shared helper and add a safe fallback for missing 'id'
active_account = cfg.get("spotify") try:
if not active_account: from routes.utils.get_info import get_playlist_metadata
raise HTTPException(
status_code=500, playlist_data = get_playlist_metadata(playlist_spotify_id) or {}
detail={"error": "Active Spotify account not set in configuration."}, except Exception as e:
logger.error(
f"Failed to fetch playlist metadata for {playlist_spotify_id}: {e}",
exc_info=True,
) )
blob_path = get_spotify_blob_path(active_account)
if not blob_path.exists():
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail={ detail={
"error": f"Spotify credentials blob not found for account '{active_account}'" "error": f"Failed to fetch metadata for playlist {playlist_spotify_id}: {str(e)}"
}, },
) )
client = get_client() # Some Librespot responses may omit 'id' even when the payload is valid.
try: # Fall back to the path parameter to avoid false negatives.
playlist_data = get_playlist( if playlist_data and "id" not in playlist_data:
client, playlist_spotify_id, expand_items=False logger.warning(
f"Playlist metadata for {playlist_spotify_id} missing 'id'. Injecting from path param. Keys: {list(playlist_data.keys())}"
) )
finally: try:
pass playlist_data["id"] = playlist_spotify_id
except Exception:
pass
if not playlist_data or "id" not in playlist_data: # Validate minimal fields needed downstream and normalize shape to be resilient to client changes
if not playlist_data or not playlist_data.get("name"):
logger.error( logger.error(
f"Could not fetch details for playlist {playlist_spotify_id} from Spotify." f"Insufficient playlist metadata for {playlist_spotify_id}. Keys present: {list(playlist_data.keys()) if isinstance(playlist_data, dict) else type(playlist_data)}"
) )
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail={ detail={
"error": f"Could not fetch details for playlist {playlist_spotify_id} from Spotify." "error": f"Could not fetch sufficient details for playlist {playlist_spotify_id} from Spotify."
}, },
) )
# Ensure 'owner' is a dict with at least id/display_name to satisfy DB layer
owner = playlist_data.get("owner")
if not isinstance(owner, dict):
owner = {}
if "id" not in owner or not owner.get("id"):
owner["id"] = "unknown_owner"
if "display_name" not in owner or not owner.get("display_name"):
owner["display_name"] = owner.get("id", "Unknown Owner")
playlist_data["owner"] = owner
# Ensure 'tracks' is a dict with a numeric 'total'
tracks = playlist_data.get("tracks")
if not isinstance(tracks, dict):
tracks = {}
total = tracks.get("total")
if not isinstance(total, int):
items = tracks.get("items")
if isinstance(items, list):
total = len(items)
else:
total = 0
tracks["total"] = total
playlist_data["tracks"] = tracks
add_playlist_db(playlist_data) # This also creates the tracks table add_playlist_db(playlist_data) # This also creates the tracks table
logger.info( logger.info(

View File

@@ -40,6 +40,90 @@ NOTIFY_PARAMETERS = [
] ]
# Helper functions to get final merged configs (simulate save without actually saving)
def get_final_main_config(new_config_data: dict) -> dict:
"""Returns the final main config that will be saved after merging with new_config_data."""
try:
# 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:
existing_config = DEFAULT_MAIN_CONFIG.copy()
# Update with new data
for key, value in new_config_data.items():
existing_config[key] = value
# Migration: unify legacy keys to camelCase
_migrate_legacy_keys_inplace(existing_config)
# 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
return existing_config
except Exception as e:
logger.error(f"Error creating final main config: {e}", exc_info=True)
return DEFAULT_MAIN_CONFIG.copy()
def get_final_watch_config(new_watch_config_data: dict) -> dict:
"""Returns the final watch config that will be saved after merging with new_watch_config_data."""
try:
# Load current main config
main_cfg: dict = {}
if WATCH_MAIN_CONFIG_FILE_PATH.exists():
with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f:
main_cfg = json.load(f) or {}
else:
main_cfg = DEFAULT_MAIN_CONFIG.copy()
# Get and update watch config
watch_value = main_cfg.get("watch")
current_watch = (
watch_value.copy() if isinstance(watch_value, dict) else {}
).copy()
current_watch.update(new_watch_config_data or {})
# Ensure defaults
for k, v in DEFAULT_WATCH_CONFIG.items():
if k not in current_watch:
current_watch[k] = v
return current_watch
except Exception as e:
logger.error(f"Error creating final watch config: {e}", exc_info=True)
return DEFAULT_WATCH_CONFIG.copy()
def get_final_main_config_for_watch(new_watch_config_data: dict) -> dict:
"""Returns the final main config when updating watch config."""
try:
# Load current main config
main_cfg: dict = {}
if WATCH_MAIN_CONFIG_FILE_PATH.exists():
with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f:
main_cfg = json.load(f) or {}
else:
main_cfg = DEFAULT_MAIN_CONFIG.copy()
# Migrate legacy keys
_migrate_legacy_keys_inplace(main_cfg)
# Ensure all default keys are still there
for default_key, default_value in DEFAULT_MAIN_CONFIG.items():
if default_key not in main_cfg:
main_cfg[default_key] = default_value
return main_cfg
except Exception as e:
logger.error(f"Error creating final main config for watch: {e}", exc_info=True)
return DEFAULT_MAIN_CONFIG.copy()
# Helper function to check if credentials exist for a service # Helper function to check if credentials exist for a service
def has_credentials(service: str) -> bool: def has_credentials(service: str) -> bool:
"""Check if credentials exist for the specified service (spotify or deezer).""" """Check if credentials exist for the specified service (spotify or deezer)."""
@@ -68,9 +152,12 @@ def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool,
Returns (is_valid, error_message). Returns (is_valid, error_message).
""" """
try: try:
# Get current watch config if not provided # Get final merged watch config for validation
if watch_config is None: if watch_config is None:
watch_config = get_watch_config_http() if "watch" in config_data:
watch_config = get_final_watch_config(config_data["watch"])
else:
watch_config = get_watch_config_http()
# Ensure realTimeMultiplier is a valid integer in range 0..10 if provided # Ensure realTimeMultiplier is a valid integer in range 0..10 if provided
if "realTimeMultiplier" in config_data or "real_time_multiplier" in config_data: if "realTimeMultiplier" in config_data or "real_time_multiplier" in config_data:
@@ -137,9 +224,9 @@ def validate_watch_config(
Returns (is_valid, error_message). Returns (is_valid, error_message).
""" """
try: try:
# Get current main config if not provided # Get final merged main config for validation
if main_config is None: if main_config is None:
main_config = get_config() main_config = get_final_main_config_for_watch(watch_data)
# Check if trying to enable watch without download methods # Check if trying to enable watch without download methods
if watch_data.get("enabled", False): if watch_data.get("enabled", False):

View File

@@ -8,7 +8,7 @@ from typing import Set, Optional
import redis import redis
import threading import threading
from routes.utils.celery_config import REDIS_URL from routes.utils.celery_config import REDIS_URL, get_config_params
from routes.utils.celery_tasks import ( from routes.utils.celery_tasks import (
get_task_info, get_task_info,
@@ -37,6 +37,11 @@ router = APIRouter()
class SSEBroadcaster: class SSEBroadcaster:
def __init__(self): def __init__(self):
self.clients: Set[asyncio.Queue] = set() self.clients: Set[asyncio.Queue] = set()
# Per-task throttling/batching/deduplication state
self._task_state = {} # task_id -> dict with last_sent, last_event, last_send_time, scheduled_handle
# Load configurable interval
config = get_config_params()
self.sse_update_interval = float(config.get("sseUpdateIntervalSeconds", 1))
async def add_client(self, queue: asyncio.Queue): async def add_client(self, queue: asyncio.Queue):
"""Add a new SSE client""" """Add a new SSE client"""
@@ -49,43 +54,105 @@ class SSEBroadcaster:
logger.debug(f"SSE: Client disconnected (total: {len(self.clients)})") logger.debug(f"SSE: Client disconnected (total: {len(self.clients)})")
async def broadcast_event(self, event_data: dict): async def broadcast_event(self, event_data: dict):
"""Broadcast an event to all connected clients""" """
logger.debug( Throttle, batch, and deduplicate SSE events per task.
f"SSE Broadcaster: Attempting to broadcast to {len(self.clients)} clients" Only emit at most 1 update/sec per task, aggregate within window, suppress redundant updates.
) """
if not self.clients: if not self.clients:
logger.debug("SSE Broadcaster: No clients connected, skipping broadcast") logger.debug("SSE Broadcaster: No clients connected, skipping broadcast")
return return
# Defensive: always work with a list of tasks
tasks = event_data.get("tasks", [])
if not isinstance(tasks, list):
tasks = [tasks]
# Add global task counts right before broadcasting - this is the single source of truth # For each task, throttle/batch/dedupe
for task in tasks:
task_id = task.get("task_id")
if not task_id:
continue
now = time.time()
state = self._task_state.setdefault(task_id, {
"last_sent": None,
"last_event": None,
"last_send_time": 0,
"scheduled_handle": None,
})
# Deduplication: if event is identical to last sent, skip
if state["last_sent"] is not None and self._events_equal(state["last_sent"], task):
logger.debug(f"SSE: Deduped event for task {task_id}")
continue
# Throttling: if within interval, batch (store as last_event, schedule send)
elapsed = now - state["last_send_time"]
if elapsed < self.sse_update_interval:
state["last_event"] = task
if state["scheduled_handle"] is None:
delay = self.sse_update_interval - elapsed
loop = asyncio.get_event_loop()
state["scheduled_handle"] = loop.call_later(
delay, lambda: asyncio.create_task(self._send_batched_event(task_id))
)
continue
# Otherwise, send immediately
await self._send_event(task_id, task)
state["last_send_time"] = now
state["last_sent"] = task
state["last_event"] = None
if state["scheduled_handle"]:
state["scheduled_handle"].cancel()
state["scheduled_handle"] = None
async def _send_batched_event(self, task_id):
state = self._task_state.get(task_id)
if not state or not state["last_event"]:
return
await self._send_event(task_id, state["last_event"])
state["last_send_time"] = time.time()
state["last_sent"] = state["last_event"]
state["last_event"] = None
state["scheduled_handle"] = None
async def _send_event(self, task_id, task):
# Compose event_data for this task
event_data = {
"tasks": [task],
"current_timestamp": time.time(),
"change_type": "update",
}
enhanced_event_data = add_global_task_counts_to_event(event_data.copy()) enhanced_event_data = add_global_task_counts_to_event(event_data.copy())
event_json = json.dumps(enhanced_event_data) event_json = json.dumps(enhanced_event_data)
sse_data = f"data: {event_json}\n\n" sse_data = f"data: {event_json}\n\n"
logger.debug(
f"SSE Broadcaster: Broadcasting event: {enhanced_event_data.get('change_type', 'unknown')} with {enhanced_event_data.get('active_tasks', 0)} active tasks"
)
# Send to all clients, remove disconnected ones
disconnected = set() disconnected = set()
sent_count = 0 sent_count = 0
for client_queue in self.clients.copy(): for client_queue in self.clients.copy():
try: try:
await client_queue.put(sse_data) await client_queue.put(sse_data)
sent_count += 1 sent_count += 1
logger.debug("SSE: Successfully sent to client queue")
except Exception as e: except Exception as e:
logger.error(f"SSE: Failed to send to client: {e}") logger.error(f"SSE: Failed to send to client: {e}")
disconnected.add(client_queue) disconnected.add(client_queue)
# Clean up disconnected clients
for client in disconnected: for client in disconnected:
self.clients.discard(client) self.clients.discard(client)
logger.debug( logger.debug(
f"SSE Broadcaster: Successfully sent to {sent_count} clients, removed {len(disconnected)} disconnected clients" f"SSE Broadcaster: Sent throttled/batched event for task {task_id} to {sent_count} clients"
) )
def _events_equal(self, a, b):
# Compare two task dicts for deduplication (ignore timestamps)
if not isinstance(a, dict) or not isinstance(b, dict):
return False
a_copy = dict(a)
b_copy = dict(b)
a_copy.pop("timestamp", None)
b_copy.pop("timestamp", None)
return a_copy == b_copy
# Global broadcaster instance # Global broadcaster instance
sse_broadcaster = SSEBroadcaster() sse_broadcaster = SSEBroadcaster()
@@ -105,6 +172,10 @@ def start_sse_redis_subscriber():
pubsub.subscribe("sse_events") pubsub.subscribe("sse_events")
logger.info("SSE Redis Subscriber: Started listening for events") logger.info("SSE Redis Subscriber: Started listening for events")
# Create a single event loop for this thread and reuse it
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for message in pubsub.listen(): for message in pubsub.listen():
if message["type"] == "message": if message["type"] == "message":
try: try:
@@ -121,47 +192,44 @@ def start_sse_redis_subscriber():
# Transform callback data into standardized update format expected by frontend # Transform callback data into standardized update format expected by frontend
standardized = standardize_incoming_event(event_data) standardized = standardize_incoming_event(event_data)
if standardized: if standardized:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
sse_broadcaster.broadcast_event(standardized)
)
logger.debug(
f"SSE Redis Subscriber: Broadcasted standardized progress update to {len(sse_broadcaster.clients)} clients"
)
finally:
loop.close()
elif event_type == "summary_update":
# Task summary update - use standardized trigger
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete( loop.run_until_complete(
trigger_sse_update( sse_broadcaster.broadcast_event(standardized)
task_id, event_data.get("reason", "update")
)
) )
logger.debug( logger.debug(
f"SSE Redis Subscriber: Processed summary update for {task_id}" f"SSE Redis Subscriber: Broadcasted standardized progress update to {len(sse_broadcaster.clients)} clients"
)
elif event_type == "summary_update":
# Task summary update - use standardized trigger
# Short-circuit if task no longer exists to avoid expensive processing
try:
if not get_task_info(task_id):
logger.debug(
f"SSE Redis Subscriber: summary_update for missing task {task_id}, skipping"
)
else:
loop.run_until_complete(
trigger_sse_update(
task_id, event_data.get("reason", "update")
)
)
logger.debug(
f"SSE Redis Subscriber: Processed summary update for {task_id}"
)
except Exception as _e:
logger.error(
f"SSE Redis Subscriber: Error handling summary_update for {task_id}: {_e}",
exc_info=True,
) )
finally:
loop.close()
else: else:
# Unknown event type - attempt to standardize and broadcast # Unknown event type - attempt to standardize and broadcast
standardized = standardize_incoming_event(event_data) standardized = standardize_incoming_event(event_data)
if standardized: if standardized:
loop = asyncio.new_event_loop() loop.run_until_complete(
asyncio.set_event_loop(loop) sse_broadcaster.broadcast_event(standardized)
try: )
loop.run_until_complete( logger.debug(
sse_broadcaster.broadcast_event(standardized) f"SSE Redis Subscriber: Broadcasted standardized {event_type} to {len(sse_broadcaster.clients)} clients"
) )
logger.debug(
f"SSE Redis Subscriber: Broadcasted standardized {event_type} to {len(sse_broadcaster.clients)} clients"
)
finally:
loop.close()
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -315,7 +383,7 @@ async def trigger_sse_update(task_id: str, reason: str = "task_update"):
# Find the specific task that changed # Find the specific task that changed
task_info = get_task_info(task_id) task_info = get_task_info(task_id)
if not task_info: if not task_info:
logger.warning(f"SSE: Task {task_id} not found for update") logger.debug(f"SSE: Task {task_id} not found for update")
return return
last_status = get_last_task_status(task_id) last_status = get_last_task_status(task_id)

View File

@@ -8,6 +8,7 @@ from routes.utils.credentials import (
) )
from routes.utils.celery_queue_manager import get_existing_task_id from routes.utils.celery_queue_manager import get_existing_task_id
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
from routes.utils.celery_config import get_config_params
def download_album( def download_album(
@@ -98,6 +99,7 @@ def download_album(
spotify_client_id=global_spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=global_spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback, progress_callback=progress_callback,
spotify_credentials_path=str(get_spotify_blob_path(main)),
) )
dl.download_albumspo( dl.download_albumspo(
link_album=url, # Spotify URL link_album=url, # Spotify URL
@@ -257,6 +259,11 @@ def download_album(
spotify_client_id=global_spotify_client_id, # Global Spotify keys spotify_client_id=global_spotify_client_id, # Global Spotify keys
spotify_client_secret=global_spotify_client_secret, # Global Spotify keys spotify_client_secret=global_spotify_client_secret, # Global Spotify keys
progress_callback=progress_callback, progress_callback=progress_callback,
spotify_credentials_path=(
str(get_spotify_blob_path(get_config_params().get("spotify")))
if get_config_params().get("spotify")
else None
),
) )
dl.download_albumdee( # Deezer URL, download via Deezer dl.download_albumdee( # Deezer URL, download via Deezer
link_album=url, link_album=url,

View File

@@ -4,7 +4,7 @@ import logging
from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_queue_manager import download_queue_manager
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 routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_client, get_artist
from deezspot.libutils.utils import get_ids, link_is_valid from deezspot.libutils.utils import get_ids, link_is_valid
@@ -77,10 +77,26 @@ def get_artist_discography(
log_json({"status": "error", "message": msg}) log_json({"status": "error", "message": msg})
raise ValueError(msg) raise ValueError(msg)
# Fetch artist once and return grouped arrays without pagination
try: try:
# Use the optimized get_spotify_info function client = get_client()
discography = get_spotify_info(artist_id, "artist_discography") artist_obj = get_artist(client, artist_id)
return discography
# Normalize groups as arrays of IDs; tolerate dict shape from some sources
def normalize_group(val):
if isinstance(val, list):
return val
if isinstance(val, dict):
items = val.get("items") or val.get("releases") or []
return items if isinstance(items, list) else []
return []
return {
"album_group": normalize_group(artist_obj.get("album_group")),
"single_group": normalize_group(artist_obj.get("single_group")),
"compilation_group": normalize_group(artist_obj.get("compilation_group")),
"appears_on_group": normalize_group(artist_obj.get("appears_on_group")),
}
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}"
log_json({"status": "error", "message": msg}) log_json({"status": "error", "message": msg})
@@ -120,61 +136,55 @@ def download_artist_albums(url, album_type=None, request_args=None, username=Non
raise ValueError(error_msg) raise ValueError(error_msg)
# Get watch config to determine which album groups to download # Get watch config to determine which album groups to download
watch_config = get_watch_config() valid_groups = {"album", "single", "compilation", "appears_on"}
allowed_groups = [ if album_type and isinstance(album_type, str):
g.lower() requested = [g.strip().lower() for g in album_type.split(",") if g.strip()]
for g in watch_config.get("watchedArtistAlbumGroup", ["album", "single"]) allowed_groups = [g for g in requested if g in valid_groups]
] if not allowed_groups:
logger.warning(
f"album_type query provided but no valid groups found in {requested}; falling back to watch config."
)
if not album_type or not isinstance(album_type, str) or not allowed_groups:
watch_config = get_watch_config()
allowed_groups = [
g.lower()
for g in watch_config.get("watchedArtistAlbumGroup", ["album", "single"])
if g.lower() in valid_groups
]
logger.info( logger.info(
f"Filtering albums by watchedArtistAlbumGroup setting (exact album_group match): {allowed_groups}" f"Filtering albums by album_type/watch setting (exact album_group match): {allowed_groups}"
) )
# Fetch all artist albums with pagination # Fetch artist and aggregate group arrays without pagination
client = get_client()
artist_obj = get_artist(client, artist_id)
def normalize_group(val):
if isinstance(val, list):
return val
if isinstance(val, dict):
items = val.get("items") or val.get("releases") or []
return items if isinstance(items, list) else []
return []
group_key_to_type = [
("album_group", "album"),
("single_group", "single"),
("compilation_group", "compilation"),
("appears_on_group", "appears_on"),
]
all_artist_albums = [] all_artist_albums = []
offset = 0 for key, group_type in group_key_to_type:
limit = 50 # Spotify API limit for artist albums ids = normalize_group(artist_obj.get(key))
# transform to minimal album objects with album_group tagging for filtering parity
logger.info(f"Fetching all albums for artist ID: {artist_id} with pagination") for album_id in ids:
all_artist_albums.append(
while True: {
logger.debug( "id": album_id,
f"Fetching albums for {artist_id}. Limit: {limit}, Offset: {offset}" "album_group": group_type,
) }
artist_data_page = get_spotify_info(
artist_id, "artist_discography", limit=limit, offset=offset
)
if not artist_data_page or not isinstance(artist_data_page.get("items"), list):
logger.warning(
f"No album items found or invalid format for artist {artist_id} at offset {offset}. Response: {artist_data_page}"
) )
break
current_page_albums = artist_data_page.get("items", [])
if not current_page_albums:
logger.info(
f"No more albums on page for artist {artist_id} at offset {offset}. Total fetched so far: {len(all_artist_albums)}."
)
break
logger.debug(
f"Fetched {len(current_page_albums)} albums on current page for artist {artist_id}."
)
all_artist_albums.extend(current_page_albums)
# Check if Spotify indicates a next page URL
if artist_data_page.get("next"):
offset += limit # Increment offset by the limit used for the request
else:
logger.info(
f"No next page URL for artist {artist_id}. Pagination complete. Total albums fetched: {len(all_artist_albums)}."
)
break
if not all_artist_albums:
raise ValueError(
f"Failed to retrieve artist data or no albums found for artist ID {artist_id}"
)
# Filter albums based on the allowed types using album_group field (like in manager.py) # Filter albums based on the allowed types using album_group field (like in manager.py)
filtered_albums = [] filtered_albums = []
@@ -201,13 +211,23 @@ def download_artist_albums(url, album_type=None, request_args=None, username=Non
duplicate_albums = [] duplicate_albums = []
for album in filtered_albums: for album in filtered_albums:
album_url = album.get("external_urls", {}).get("spotify", "") album_id = album.get("id")
album_name = album.get("name", "Unknown Album") if not album_id:
album_artists = album.get("artists", []) logger.warning("Skipping album without ID in filtered list.")
continue
# fetch album details to construct URL and names
try:
album_obj = download_queue_manager.client.get_album(
album_id, include_tracks=False
) # type: ignore[attr-defined]
except AttributeError:
# If download_queue_manager lacks a client, fallback to shared client
album_obj = get_client().get_album(album_id, include_tracks=False)
album_url = album_obj.get("external_urls", {}).get("spotify", "")
album_name = album_obj.get("name", "Unknown Album")
artists = album_obj.get("artists", []) or []
album_artist = ( album_artist = (
album_artists[0].get("name", "Unknown Artist") artists[0].get("name", "Unknown Artist") if artists else "Unknown Artist"
if album_artists
else "Unknown Artist"
) )
if not album_url: if not album_url:

View File

@@ -40,6 +40,8 @@ DEFAULT_MAIN_CONFIG = {
"tracknumPadding": True, "tracknumPadding": True,
"saveCover": True, "saveCover": True,
"maxConcurrentDownloads": 3, "maxConcurrentDownloads": 3,
"utilityConcurrency": 1,
"librespotConcurrency": 2,
"maxRetries": 3, "maxRetries": 3,
"retryDelaySeconds": 5, "retryDelaySeconds": 5,
"retryDelayIncrease": 5, "retryDelayIncrease": 5,
@@ -52,6 +54,7 @@ DEFAULT_MAIN_CONFIG = {
"watch": {}, "watch": {},
"realTimeMultiplier": 0, "realTimeMultiplier": 0,
"padNumberWidth": 3, "padNumberWidth": 3,
"sseUpdateIntervalSeconds": 1, # Configurable SSE update interval (default: 1s)
} }
@@ -188,7 +191,7 @@ task_annotations = {
"rate_limit": f"{MAX_CONCURRENT_DL}/m", "rate_limit": f"{MAX_CONCURRENT_DL}/m",
}, },
"routes.utils.celery_tasks.trigger_sse_update_task": { "routes.utils.celery_tasks.trigger_sse_update_task": {
"rate_limit": "500/m", # Allow high rate for real-time SSE updates "rate_limit": "60/m", # Throttle to 1 update/sec per task (matches SSE throttle)
"default_retry_delay": 1, # Quick retry for SSE updates "default_retry_delay": 1, # Quick retry for SSE updates
"max_retries": 1, # Limited retries for best-effort delivery "max_retries": 1, # Limited retries for best-effort delivery
"ignore_result": True, # Don't store results for SSE tasks "ignore_result": True, # Don't store results for SSE tasks

View File

@@ -6,10 +6,11 @@ import os
import sys import sys
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Import Celery task utilities # Import Celery task utilities
from .celery_config import get_config_params, MAX_CONCURRENT_DL from .celery_config import get_config_params, MAX_CONCURRENT_DL # noqa: E402
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,15 +41,22 @@ class CeleryManager:
self.concurrency = get_config_params().get( self.concurrency = get_config_params().get(
"maxConcurrentDownloads", MAX_CONCURRENT_DL "maxConcurrentDownloads", MAX_CONCURRENT_DL
) )
self.utility_concurrency = max(
1, int(get_config_params().get("utilityConcurrency", 1))
)
logger.info( logger.info(
f"CeleryManager initialized. Download concurrency set to: {self.concurrency}" f"CeleryManager initialized. Download concurrency set to: {self.concurrency} | Utility concurrency: {self.utility_concurrency}"
) )
def _get_worker_command( def _get_worker_command(
self, queues, concurrency, worker_name_suffix, log_level_env=None self, queues, concurrency, worker_name_suffix, log_level_env=None
): ):
# Use LOG_LEVEL from environment if provided, otherwise default to INFO # Use LOG_LEVEL from environment if provided, otherwise default to INFO
log_level = log_level_env if log_level_env else os.getenv("LOG_LEVEL", "WARNING").upper() log_level = (
log_level_env
if log_level_env
else os.getenv("LOG_LEVEL", "WARNING").upper()
)
# Use a unique worker name to avoid conflicts. # Use a unique worker name to avoid conflicts.
# %h is replaced by celery with the actual hostname. # %h is replaced by celery with the actual hostname.
hostname = f"worker_{worker_name_suffix}@%h" hostname = f"worker_{worker_name_suffix}@%h"
@@ -167,12 +175,19 @@ class CeleryManager:
if self.utility_worker_process and self.utility_worker_process.poll() is None: if self.utility_worker_process and self.utility_worker_process.poll() is None:
logger.info("Celery Utility Worker is already running.") logger.info("Celery Utility Worker is already running.")
else: else:
self.utility_concurrency = max(
1,
int(
get_config_params().get(
"utilityConcurrency", self.utility_concurrency
)
),
)
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=5, # Increased concurrency for SSE updates and utility tasks concurrency=self.utility_concurrency,
worker_name_suffix="utw", # Utility Worker worker_name_suffix="utw", # Utility Worker
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(), log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
) )
logger.info( logger.info(
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}" f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"
@@ -197,7 +212,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 5." f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) started with concurrency {self.utility_concurrency}."
) )
if ( if (
@@ -221,7 +236,9 @@ class CeleryManager:
) )
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
time.sleep(10) # Check every 10 seconds # Wait using stop_event to be responsive to shutdown and respect interval
if self.stop_event.wait(CONFIG_CHECK_INTERVAL):
break
if self.stop_event.is_set(): if self.stop_event.is_set():
break break
@@ -229,6 +246,14 @@ class CeleryManager:
new_max_concurrent_downloads = current_config.get( new_max_concurrent_downloads = current_config.get(
"maxConcurrentDownloads", self.concurrency "maxConcurrentDownloads", self.concurrency
) )
new_utility_concurrency = max(
1,
int(
current_config.get(
"utilityConcurrency", self.utility_concurrency
)
),
)
if new_max_concurrent_downloads != self.concurrency: if new_max_concurrent_downloads != self.concurrency:
logger.info( logger.info(
@@ -272,7 +297,10 @@ class CeleryManager:
# Restart only the download worker # Restart only the download worker
download_cmd = self._get_worker_command( download_cmd = self._get_worker_command(
"downloads", self.concurrency, "dlw", log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper() "downloads",
self.concurrency,
"dlw",
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
) )
logger.info( logger.info(
f"Restarting Celery Download Worker with command: {' '.join(download_cmd)}" f"Restarting Celery Download Worker with command: {' '.join(download_cmd)}"
@@ -303,6 +331,82 @@ class CeleryManager:
f"Celery Download Worker (PID: {self.download_worker_process.pid}) restarted with new concurrency {self.concurrency}." f"Celery Download Worker (PID: {self.download_worker_process.pid}) restarted with new concurrency {self.concurrency}."
) )
# Handle utility worker concurrency changes
if new_utility_concurrency != self.utility_concurrency:
logger.info(
f"CeleryManager: Detected change in utilityConcurrency from {self.utility_concurrency} to {new_utility_concurrency}. Restarting utility worker only."
)
if (
self.utility_worker_process
and self.utility_worker_process.poll() is None
):
logger.info(
f"Stopping Celery Utility Worker (PID: {self.utility_worker_process.pid}) for config update..."
)
self.utility_worker_process.terminate()
try:
self.utility_worker_process.wait(timeout=10)
logger.info(
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) terminated."
)
except subprocess.TimeoutExpired:
logger.warning(
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) did not terminate gracefully, killing."
)
self.utility_worker_process.kill()
self.utility_worker_process = None
# Wait for log threads of utility worker to finish
if (
self.utility_log_thread_stdout
and self.utility_log_thread_stdout.is_alive()
):
self.utility_log_thread_stdout.join(timeout=5)
if (
self.utility_log_thread_stderr
and self.utility_log_thread_stderr.is_alive()
):
self.utility_log_thread_stderr.join(timeout=5)
self.utility_concurrency = new_utility_concurrency
# Restart only the utility worker
utility_cmd = self._get_worker_command(
"utility_tasks,default",
self.utility_concurrency,
"utw",
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
)
logger.info(
f"Restarting Celery Utility Worker with command: {' '.join(utility_cmd)}"
)
self.utility_worker_process = subprocess.Popen(
utility_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
)
self.utility_log_thread_stdout = threading.Thread(
target=self._process_output_reader,
args=(self.utility_worker_process.stdout, "Celery[UW-STDOUT]"),
)
self.utility_log_thread_stderr = threading.Thread(
target=self._process_output_reader,
args=(
self.utility_worker_process.stderr,
"Celery[UW-STDERR]",
True,
),
)
self.utility_log_thread_stdout.start()
self.utility_log_thread_stderr.start()
logger.info(
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) restarted with new concurrency {self.utility_concurrency}."
)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"CeleryManager: Error in config monitor thread: {e}", exc_info=True f"CeleryManager: Error in config monitor thread: {e}", exc_info=True

View File

@@ -44,7 +44,11 @@ def get_client() -> LibrespotClient:
_shared_client.close() _shared_client.close()
except Exception: except Exception:
pass pass
_shared_client = LibrespotClient(stored_credentials_path=desired_blob) cfg = get_config_params() or {}
max_workers = int(cfg.get("librespotConcurrency", 2) or 2)
_shared_client = LibrespotClient(
stored_credentials_path=desired_blob, max_workers=max_workers
)
_shared_blob_path = desired_blob _shared_blob_path = desired_blob
return _shared_client return _shared_client
@@ -59,7 +63,9 @@ def create_client(credentials_path: str) -> LibrespotClient:
abs_path = os.path.abspath(credentials_path) abs_path = os.path.abspath(credentials_path)
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
raise FileNotFoundError(f"Credentials file not found: {abs_path}") raise FileNotFoundError(f"Credentials file not found: {abs_path}")
return LibrespotClient(stored_credentials_path=abs_path) cfg = get_config_params() or {}
max_workers = int(cfg.get("librespotConcurrency", 2) or 2)
return LibrespotClient(stored_credentials_path=abs_path, max_workers=max_workers)
def close_client(client: LibrespotClient) -> None: def close_client(client: LibrespotClient) -> None:
@@ -93,57 +99,6 @@ def get_playlist(
return client.get_playlist(playlist_in, expand_items=expand_items) return client.get_playlist(playlist_in, expand_items=expand_items)
def get_spotify_info(
spotify_id: str,
info_type: str,
limit: int = 50,
offset: int = 0,
) -> Dict[str, Any]:
"""
Thin, typed wrapper around common Spotify info lookups using the shared client.
Currently supports:
- "artist_discography": returns a paginated view over the artist's releases
combined across album_group/single_group/compilation_group/appears_on_group.
Returns a mapping with at least: items, total, limit, offset.
Also includes a truthy "next" key when more pages are available.
"""
client = get_client()
if info_type == "artist_discography":
artist = client.get_artist(spotify_id)
all_items = []
for key in (
"album_group",
"single_group",
"compilation_group",
"appears_on_group",
):
grp = artist.get(key)
if isinstance(grp, list):
all_items.extend(grp)
elif isinstance(grp, dict):
items = grp.get("items") or grp.get("releases") or []
if isinstance(items, list):
all_items.extend(items)
total = len(all_items)
start = max(0, offset or 0)
page_limit = max(1, limit or 50)
end = min(total, start + page_limit)
page_items = all_items[start:end]
has_more = end < total
return {
"items": page_items,
"total": total,
"limit": page_limit,
"offset": start,
"next": bool(has_more),
}
raise ValueError(f"Unsupported info_type: {info_type}")
def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]: def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]:
""" """
Fetch playlist metadata using the shared client without expanding items. Fetch playlist metadata using the shared client without expanding items.

View File

@@ -3,6 +3,8 @@ from deezspot.spotloader import SpoLogin
from deezspot.deezloader import DeeLogin from deezspot.deezloader import DeeLogin
from pathlib import Path from pathlib import Path
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.credentials import get_spotify_blob_path
from routes.utils.celery_config import get_config_params
from routes.utils.celery_queue_manager import get_existing_task_id from routes.utils.celery_queue_manager import get_existing_task_id
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
@@ -95,6 +97,7 @@ def download_playlist(
spotify_client_id=global_spotify_client_id, spotify_client_id=global_spotify_client_id,
spotify_client_secret=global_spotify_client_secret, spotify_client_secret=global_spotify_client_secret,
progress_callback=progress_callback, progress_callback=progress_callback,
spotify_credentials_path=str(get_spotify_blob_path(main)),
) )
dl.download_playlistspo( dl.download_playlistspo(
link_playlist=url, # Spotify URL link_playlist=url, # Spotify URL
@@ -265,6 +268,11 @@ def download_playlist(
spotify_client_id=global_spotify_client_id, # Global Spotify keys spotify_client_id=global_spotify_client_id, # Global Spotify keys
spotify_client_secret=global_spotify_client_secret, # Global Spotify keys spotify_client_secret=global_spotify_client_secret, # Global Spotify keys
progress_callback=progress_callback, progress_callback=progress_callback,
spotify_credentials_path=(
str(get_spotify_blob_path(get_config_params().get("spotify")))
if get_config_params().get("spotify")
else None
),
) )
dl.download_playlistdee( # Deezer URL, download via Deezer dl.download_playlistdee( # Deezer URL, download via Deezer
link_playlist=url, link_playlist=url,

View File

@@ -6,6 +6,7 @@ from routes.utils.credentials import (
_get_global_spotify_api_creds, _get_global_spotify_api_creds,
get_spotify_blob_path, get_spotify_blob_path,
) )
from routes.utils.celery_config import get_config_params
def download_track( def download_track(
@@ -90,6 +91,7 @@ def download_track(
spotify_client_id=global_spotify_client_id, # Global creds spotify_client_id=global_spotify_client_id, # Global creds
spotify_client_secret=global_spotify_client_secret, # Global creds spotify_client_secret=global_spotify_client_secret, # Global creds
progress_callback=progress_callback, progress_callback=progress_callback,
spotify_credentials_path=str(get_spotify_blob_path(main)),
) )
# download_trackspo means: Spotify URL, download via Deezer # download_trackspo means: Spotify URL, download via Deezer
dl.download_trackspo( dl.download_trackspo(
@@ -169,7 +171,6 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
artist_separator=artist_separator, artist_separator=artist_separator,
spotify_metadata=spotify_metadata,
pad_number_width=pad_number_width, pad_number_width=pad_number_width,
) )
print( print(
@@ -251,6 +252,11 @@ def download_track(
spotify_client_id=global_spotify_client_id, # Global Spotify keys for internal Spo use by DeeLogin spotify_client_id=global_spotify_client_id, # Global Spotify keys for internal Spo use by DeeLogin
spotify_client_secret=global_spotify_client_secret, # Global Spotify keys spotify_client_secret=global_spotify_client_secret, # Global Spotify keys
progress_callback=progress_callback, progress_callback=progress_callback,
spotify_credentials_path=(
str(get_spotify_blob_path(get_config_params().get("spotify")))
if get_config_params().get("spotify")
else None
),
) )
dl.download_trackdee( # Deezer URL, download via Deezer dl.download_trackdee( # Deezer URL, download via Deezer
link_track=url, link_track=url,

View File

@@ -167,6 +167,46 @@ def get_watch_config():
watch_cfg["maxItemsPerRun"] = clamped_value watch_cfg["maxItemsPerRun"] = clamped_value
migrated = True migrated = True
# Enforce sane ranges and types for poll/delay intervals to prevent tight loops
def _safe_int(value, default):
try:
return int(value)
except Exception:
return default
# Clamp poll interval to at least 1 second
poll_val = _safe_int(
watch_cfg.get(
"watchPollIntervalSeconds",
DEFAULT_WATCH_CONFIG["watchPollIntervalSeconds"],
),
DEFAULT_WATCH_CONFIG["watchPollIntervalSeconds"],
)
if poll_val < 1:
watch_cfg["watchPollIntervalSeconds"] = 1
migrated = True
# Clamp per-item delays to at least 1 second
delay_pl = _safe_int(
watch_cfg.get(
"delayBetweenPlaylistsSeconds",
DEFAULT_WATCH_CONFIG["delayBetweenPlaylistsSeconds"],
),
DEFAULT_WATCH_CONFIG["delayBetweenPlaylistsSeconds"],
)
if delay_pl < 1:
watch_cfg["delayBetweenPlaylistsSeconds"] = 1
migrated = True
delay_ar = _safe_int(
watch_cfg.get(
"delayBetweenArtistsSeconds",
DEFAULT_WATCH_CONFIG["delayBetweenArtistsSeconds"],
),
DEFAULT_WATCH_CONFIG["delayBetweenArtistsSeconds"],
)
if delay_ar < 1:
watch_cfg["delayBetweenArtistsSeconds"] = 1
migrated = True
if migrated or legacy_file_found: if migrated or legacy_file_found:
# Persist migration back to main.json # Persist migration back to main.json
main_cfg["watch"] = watch_cfg main_cfg["watch"] = watch_cfg
@@ -670,7 +710,9 @@ def check_watched_playlists(specific_playlist_id: str = None):
# Only sleep between items when running a batch (no specific ID) # Only sleep between items when running a batch (no specific ID)
if not specific_playlist_id: if not specific_playlist_id:
time.sleep(max(1, config.get("delayBetweenPlaylistsSeconds", 2))) time.sleep(
max(1, _safe_to_int(config.get("delayBetweenPlaylistsSeconds"), 2))
)
logger.info("Playlist Watch Manager: Finished checking all watched playlists.") logger.info("Playlist Watch Manager: Finished checking all watched playlists.")
@@ -817,7 +859,9 @@ def check_watched_artists(specific_artist_id: str = None):
# Only sleep between items when running a batch (no specific ID) # Only sleep between items when running a batch (no specific ID)
if not specific_artist_id: if not specific_artist_id:
time.sleep(max(1, config.get("delayBetweenArtistsSeconds", 5))) time.sleep(
max(1, _safe_to_int(config.get("delayBetweenArtistsSeconds"), 5))
)
logger.info("Artist Watch Manager: Finished checking all watched artists.") logger.info("Artist Watch Manager: Finished checking all watched artists.")
@@ -832,6 +876,14 @@ def playlist_watch_scheduler():
interval = current_config.get("watchPollIntervalSeconds", 3600) interval = current_config.get("watchPollIntervalSeconds", 3600)
watch_enabled = current_config.get("enabled", False) # Get enabled status watch_enabled = current_config.get("enabled", False) # Get enabled status
# Ensure interval is a positive integer to avoid tight loops
try:
interval = int(interval)
except Exception:
interval = 3600
if interval < 1:
interval = 1
if not watch_enabled: if not watch_enabled:
logger.info( logger.info(
"Watch Scheduler: Watch feature is disabled in config. Skipping checks." "Watch Scheduler: Watch feature is disabled in config. Skipping checks."
@@ -907,6 +959,13 @@ def run_playlist_check_over_intervals(playlist_spotify_id: str) -> None:
# Determine if we are done: no active processing snapshot and no pending sync # Determine if we are done: no active processing snapshot and no pending sync
cfg = get_watch_config() cfg = get_watch_config()
interval = cfg.get("watchPollIntervalSeconds", 3600) interval = cfg.get("watchPollIntervalSeconds", 3600)
# Ensure interval is a positive integer
try:
interval = int(interval)
except Exception:
interval = 3600
if interval < 1:
interval = 1
# Use local helper that leverages Librespot client # Use local helper that leverages Librespot client
metadata = _fetch_playlist_metadata(playlist_spotify_id) metadata = _fetch_playlist_metadata(playlist_spotify_id)
if not metadata: if not metadata:
@@ -1169,6 +1228,17 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
# Helper to build a Librespot client from active account # Helper to build a Librespot client from active account
# Add a small internal helper for safe int conversion
_def_safe_int_added = True
def _safe_to_int(value, default):
try:
return int(value)
except Exception:
return default
def _build_librespot_client(): def _build_librespot_client():
try: try:
# Reuse shared client managed in routes.utils.get_info # Reuse shared client managed in routes.utils.get_info
@@ -1235,11 +1305,35 @@ def _fetch_artist_discography_page(artist_id: str, limit: int, offset: int) -> d
for key in ("album_group", "single_group", "compilation_group", "appears_on_group"): for key in ("album_group", "single_group", "compilation_group", "appears_on_group"):
grp = artist.get(key) grp = artist.get(key)
if isinstance(grp, list): if isinstance(grp, list):
all_items.extend(grp) # Check if items are strings (IDs) or dictionaries (metadata)
if grp and isinstance(grp[0], str):
# Items are album IDs as strings, fetch metadata for each
for album_id in grp:
try:
album_data = client.get_album(album_id, include_tracks=False)
if album_data:
# Add the album_group type for filtering
album_data["album_group"] = key.replace("_group", "")
all_items.append(album_data)
except Exception as e:
logger.warning(f"Failed to fetch album {album_id}: {e}")
else:
# Items are already dictionaries (album metadata)
for item in grp:
if isinstance(item, dict):
# Ensure album_group is set for filtering
if "album_group" not in item:
item["album_group"] = key.replace("_group", "")
all_items.append(item)
elif isinstance(grp, dict): elif isinstance(grp, dict):
items = grp.get("items") or grp.get("releases") or [] items = grp.get("items") or grp.get("releases") or []
if isinstance(items, list): if isinstance(items, list):
all_items.extend(items) for item in items:
if isinstance(item, dict):
# Ensure album_group is set for filtering
if "album_group" not in item:
item["album_group"] = key.replace("_group", "")
all_items.append(item)
total = len(all_items) total = len(all_items)
start = max(0, offset or 0) start = max(0, offset or 0)
end = start + max(1, limit or 50) end = start + max(1, limit or 50)

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { authApiClient } from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,12 +16,32 @@ interface WebhookSettings {
available_events: string[]; // Provided by API, not saved available_events: string[]; // Provided by API, not saved
} }
// --- API Functions --- interface ServerConfig {
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => { client_id?: string;
const { data } = await authApiClient.client.get("/credentials/spotify_api_config"); client_secret?: string;
return data; utilityConcurrency?: number;
librespotConcurrency?: number;
url?: string;
events?: string[];
}
const fetchServerConfig = async (): Promise<ServerConfig> => {
const [spotifyConfig, generalConfig] = await Promise.all([
authApiClient.client.get("/credentials/spotify_api_config").catch(() => ({ data: {} })),
authApiClient.getConfig<any>(),
]);
return {
...spotifyConfig.data,
...generalConfig,
};
};
const saveServerConfig = async (data: Partial<ServerConfig>) => {
const payload = { ...data };
const { data: response } = await authApiClient.client.post("/config", payload);
return response;
}; };
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
@@ -32,40 +52,34 @@ const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
available_events: ["download_start", "download_complete", "download_failed", "watch_added"], available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
}); });
}; };
const saveWebhookConfig = (data: Partial<WebhookSettings>) => {
toast.info("Webhook configuration is not available."); const saveWebhookConfig = async (data: Partial<WebhookSettings>) => {
return Promise.resolve(data); const payload = { ...data };
const { data: response } = await authApiClient.client.post("/config", payload);
return response;
}; };
const testWebhook = (url: string) => { const testWebhook = (url: string) => {
toast.info("Webhook testing is not available."); toast.info("Webhook testing is not available.");
return Promise.resolve(url); return Promise.resolve(url);
}; };
// --- Components --- // --- Components ---
function SpotifyApiForm() { function SpotifyApiForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial<ServerConfig>) => void }) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig });
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>(); const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
const mutation = useMutation({
mutationFn: saveSpotifyApiConfig,
onSuccess: () => {
toast.success("Spotify API settings saved!");
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
},
onError: (e) => {
console.error("Failed to save Spotify API settings:", (e as any).message);
toast.error(`Failed to save: ${(e as any).message}`);
},
});
useEffect(() => { useEffect(() => {
if (data) reset(data); if (config) {
}, [data, reset]); reset({
client_id: config.client_id || "",
client_secret: config.client_secret || "",
});
}
}, [config, reset]);
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData); const onSubmit = (formData: SpotifyApiSettings) => {
onConfigChange(formData);
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">
@@ -73,15 +87,10 @@ function SpotifyApiForm() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="submit" type="submit"
disabled={mutation.isPending} className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md"
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" title="Save Spotify API Settings"
title="Save Spotify API"
> >
{mutation.isPending ? ( <img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
) : (
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
)}
</button> </button>
</div> </div>
</div> </div>
@@ -110,6 +119,101 @@ function SpotifyApiForm() {
); );
} }
function UtilityConcurrencyForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial<ServerConfig>) => void }) {
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<{ utilityConcurrency: number }>();
useEffect(() => {
if (config) {
reset({ utilityConcurrency: Number(config.utilityConcurrency ?? 1) });
}
}, [config, reset]);
const onSubmit = (values: { utilityConcurrency: number }) => {
const value = Math.max(1, Number(values.utilityConcurrency || 1));
onConfigChange({ utilityConcurrency: value });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!isDirty}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Utility Concurrency"
>
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="utilityConcurrency" className="text-content-primary dark:text-content-primary-dark">Utility Worker Concurrency</label>
<input
id="utilityConcurrency"
type="number"
min={1}
step={1}
{...register("utilityConcurrency", { valueAsNumber: true })}
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="1"
/>
<p className="text-xs text-content-secondary dark:text-content-secondary-dark">Controls concurrency of the utility Celery worker. Minimum 1.</p>
</div>
</form>
);
}
function LibrespotConcurrencyForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial<ServerConfig>) => void }) {
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<{ librespotConcurrency: number }>();
useEffect(() => {
if (config) {
reset({ librespotConcurrency: Number(config.librespotConcurrency ?? 2) });
}
}, [config, reset]);
const onSubmit = (values: { librespotConcurrency: number }) => {
const raw = Number(values.librespotConcurrency || 2);
const safe = Math.max(1, Math.min(16, raw));
onConfigChange({ librespotConcurrency: safe });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!isDirty}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Librespot Concurrency"
>
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="librespotConcurrency" className="text-content-primary dark:text-content-primary-dark">Librespot Concurrency</label>
<input
id="librespotConcurrency"
type="number"
min={1}
max={16}
step={1}
{...register("librespotConcurrency", { valueAsNumber: true })}
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="2"
/>
<p className="text-xs text-content-secondary dark:text-content-secondary-dark">Controls worker threads used by the Librespot client. 116 is recommended.</p>
</div>
</form>
);
}
// --- Components ---
function WebhookForm() { function WebhookForm() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig }); const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
@@ -152,7 +256,7 @@ function WebhookForm() {
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md 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"
title="Save Webhook" title="Save Webhook Settings"
> >
{mutation.isPending ? ( {mutation.isPending ? (
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" /> <img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
@@ -215,12 +319,61 @@ function WebhookForm() {
} }
export function ServerTab() { export function ServerTab() {
const queryClient = useQueryClient();
const [localConfig, setLocalConfig] = useState<ServerConfig>({});
const { data: serverConfig, isLoading } = useQuery({
queryKey: ["serverConfig"],
queryFn: fetchServerConfig,
});
const mutation = useMutation({
mutationFn: saveServerConfig,
onSuccess: () => {
toast.success("Server settings saved successfully!");
queryClient.invalidateQueries({ queryKey: ["serverConfig"] });
queryClient.invalidateQueries({ queryKey: ["config"] });
},
onError: (error) => {
console.error("Failed to save server settings", (error as any).message);
toast.error(`Failed to save server settings: ${(error as any).message}`);
},
});
useEffect(() => {
if (serverConfig) {
setLocalConfig(serverConfig);
}
}, [serverConfig]);
const handleConfigChange = (updates: Partial<ServerConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
mutation.mutate(newConfig);
};
if (isLoading) {
return <div>Loading server settings...</div>;
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">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-content-muted dark:text-content-muted-dark 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 config={localConfig} onConfigChange={handleConfigChange} />
</div>
<hr className="border-border dark:border-border-dark" />
<div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Utility Worker</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Tune background utility worker concurrency for low-powered systems.</p>
<UtilityConcurrencyForm config={localConfig} onConfigChange={handleConfigChange} />
</div>
<hr className="border-border dark:border-border-dark" />
<div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Librespot</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Adjust Librespot client worker threads.</p>
<LibrespotConcurrencyForm config={localConfig} onConfigChange={handleConfigChange} />
</div> </div>
<hr className="border-border dark:border-border-dark" /> <hr className="border-border dark:border-border-dark" />
<div> <div>

View File

@@ -32,6 +32,8 @@ export type FlatAppSettings = {
deezer: string; deezer: string;
deezerQuality: "MP3_128" | "MP3_320" | "FLAC"; deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
maxConcurrentDownloads: number; maxConcurrentDownloads: number;
utilityConcurrency: number;
librespotConcurrency: number;
realTime: boolean; realTime: boolean;
fallback: boolean; fallback: boolean;
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | ""; convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
@@ -72,6 +74,8 @@ const defaultSettings: FlatAppSettings = {
deezer: "", deezer: "",
deezerQuality: "MP3_128", deezerQuality: "MP3_128",
maxConcurrentDownloads: 3, maxConcurrentDownloads: 3,
utilityConcurrency: 1,
librespotConcurrency: 2,
realTime: false, realTime: false,
fallback: false, fallback: false,
convertTo: "", convertTo: "",
@@ -135,6 +139,8 @@ const fetchSettings = async (): Promise<FlatAppSettings> => {
// Ensure required frontend-only fields exist // Ensure required frontend-only fields exist
recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false), recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false),
realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0), realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0),
utilityConcurrency: Number((camelData as any).utilityConcurrency ?? 1),
librespotConcurrency: Number((camelData as any).librespotConcurrency ?? 2),
// Ensure watch subkeys default if missing // Ensure watch subkeys default if missing
watch: { watch: {
...(camelData.watch as any), ...(camelData.watch as any),

View File

@@ -8,6 +8,8 @@ export interface AppSettings {
deezer: string; deezer: string;
deezerQuality: "MP3_128" | "MP3_320" | "FLAC"; deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
maxConcurrentDownloads: number; maxConcurrentDownloads: number;
utilityConcurrency: number;
librespotConcurrency: number;
realTime: boolean; realTime: boolean;
fallback: boolean; fallback: boolean;
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | ""; convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";

View File

@@ -369,6 +369,17 @@ class AuthApiClient {
get client() { get client() {
return this.apiClient; return this.apiClient;
} }
// General config helpers
async getConfig<T = any>(): Promise<T> {
const response = await this.apiClient.get<T>("/config");
return response.data;
}
async updateConfig<T = any>(partial: Record<string, unknown>): Promise<T> {
const response = await this.apiClient.put<T>("/config", partial);
return response.data;
}
} }
// Create and export a singleton instance // Create and export a singleton instance

View File

@@ -135,6 +135,16 @@ export const Album = () => {
}; };
}, [loadMore]); }, [loadMore]);
// Auto progressive loading regardless of scroll
useEffect(() => {
if (!album) return;
if (!hasMore || isLoadingMore) return;
const t = setTimeout(() => {
loadMore();
}, 300);
return () => clearTimeout(t);
}, [album, hasMore, isLoadingMore, loadMore]);
const handleDownloadTrack = (track: LibrespotTrackType) => { const handleDownloadTrack = (track: LibrespotTrackType) => {
if (!track.id) return; if (!track.id) return;
toast.info(`Adding ${track.name} to queue...`); toast.info(`Adding ${track.name} to queue...`);

View File

@@ -2,27 +2,18 @@ import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext, useRef, useCallback } from "react"; import { useEffect, useState, useContext, useRef, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import apiClient from "../lib/api-client"; import apiClient from "../lib/api-client";
import type { LibrespotAlbumType, LibrespotArtistType, LibrespotTrackType, LibrespotImage } from "@/types/librespot"; import type { LibrespotAlbumType, LibrespotArtistType, LibrespotTrackType } from "@/types/librespot";
import { QueueContext, getStatus } from "../contexts/queue-context"; import { QueueContext, getStatus } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa"; import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
import { AlbumCard } from "../components/AlbumCard"; import { AlbumCard } from "../components/AlbumCard";
// Narrow type for the artist info response additions
type ArtistInfoResponse = LibrespotArtistType & {
biography?: Array<{ text?: string; portrait_group?: { image?: LibrespotImage[] } }>;
portrait_group?: { image?: LibrespotImage[] };
top_track?: Array<{ country: string; track: string[] }>;
album_group?: string[];
single_group?: string[];
appears_on_group?: string[];
};
export const Artist = () => { export const Artist = () => {
const { artistId } = useParams({ from: "/artist/$artistId" }); const { artistId } = useParams({ from: "/artist/$artistId" });
const [artist, setArtist] = useState<ArtistInfoResponse | null>(null); const [artist, setArtist] = useState<LibrespotArtistType | null>(null);
const [artistAlbums, setArtistAlbums] = useState<LibrespotAlbumType[]>([]); const [artistAlbums, setArtistAlbums] = useState<LibrespotAlbumType[]>([]);
const [artistSingles, setArtistSingles] = useState<LibrespotAlbumType[]>([]); const [artistSingles, setArtistSingles] = useState<LibrespotAlbumType[]>([]);
const [artistCompilations, setArtistCompilations] = useState<LibrespotAlbumType[]>([]);
const [artistAppearsOn, setArtistAppearsOn] = useState<LibrespotAlbumType[]>([]); const [artistAppearsOn, setArtistAppearsOn] = useState<LibrespotAlbumType[]>([]);
const [topTracks, setTopTracks] = useState<LibrespotTrackType[]>([]); const [topTracks, setTopTracks] = useState<LibrespotTrackType[]>([]);
const [bannerUrl, setBannerUrl] = useState<string | null>(null); const [bannerUrl, setBannerUrl] = useState<string | null>(null);
@@ -38,6 +29,7 @@ export const Artist = () => {
const ALBUM_BATCH = 12; const ALBUM_BATCH = 12;
const [albumOffset, setAlbumOffset] = useState<number>(0); const [albumOffset, setAlbumOffset] = useState<number>(0);
const [singleOffset, setSingleOffset] = useState<number>(0); const [singleOffset, setSingleOffset] = useState<number>(0);
const [compOffset, setCompOffset] = useState<number>(0);
const [appearsOffset, setAppearsOffset] = useState<number>(0); const [appearsOffset, setAppearsOffset] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [loadingMore, setLoadingMore] = useState<boolean>(false); const [loadingMore, setLoadingMore] = useState<boolean>(false);
@@ -81,16 +73,18 @@ export const Artist = () => {
setError(null); setError(null);
setArtistAlbums([]); setArtistAlbums([]);
setArtistSingles([]); setArtistSingles([]);
setArtistCompilations([]);
setArtistAppearsOn([]); setArtistAppearsOn([]);
setAlbumOffset(0); setAlbumOffset(0);
setSingleOffset(0); setSingleOffset(0);
setCompOffset(0);
setAppearsOffset(0); setAppearsOffset(0);
setHasMore(true); setHasMore(true);
setBannerUrl(null); // reset hero; will lazy-load below setBannerUrl(null); // reset hero; will lazy-load below
try { try {
const resp = await apiClient.get<ArtistInfoResponse>(`/artist/info?id=${artistId}`); const resp = await apiClient.get<LibrespotArtistType>(`/artist/info?id=${artistId}`);
const data: ArtistInfoResponse = resp.data; const data: LibrespotArtistType = resp.data;
if (cancelled) return; if (cancelled) return;
@@ -99,10 +93,10 @@ export const Artist = () => {
setArtist(data); setArtist(data);
// Lazy-load banner image after render // Lazy-load banner image after render
const bioEntry = Array.isArray(data.biography) && data.biography.length > 0 ? data.biography[0] : undefined; const allImages = [...(data.portrait_group.image ?? []), ...(data.biography?.[0].portrait_group.image ?? [])];
const portraitImages = data.portrait_group?.image ?? bioEntry?.portrait_group?.image ?? []; const candidateBanner = allImages
const allImages = [...(portraitImages ?? []), ...((data.images as LibrespotImage[] | undefined) ?? [])]; .filter(img => img && typeof img === 'object' && 'url' in img)
const candidateBanner = allImages.sort((a, b) => (b?.width ?? 0) - (a?.width ?? 0))[0]?.url || "/placeholder.jpg"; .sort((a, b) => (b.width ?? 0) - (a.width ?? 0))[0]?.url || "/placeholder.jpg";
// Use async preload to avoid blocking initial paint // Use async preload to avoid blocking initial paint
setTimeout(() => { setTimeout(() => {
const img = new Image(); const img = new Image();
@@ -123,46 +117,61 @@ export const Artist = () => {
if (!cancelled) setTopTracks([]); if (!cancelled) setTopTracks([]);
} }
// Progressive album loading: album -> single -> appears_on // Progressive album loading: album -> single -> compilation -> appears_on
const albumIds = data.album_group ?? []; const albumIds = data.album_group ?? [];
const singleIds = data.single_group ?? []; const singleIds = data.single_group ?? [];
const compIds = data.compilation_group ?? [];
const appearsIds = data.appears_on_group ?? []; const appearsIds = data.appears_on_group ?? [];
// Determine initial number based on screen size: 4 on small screens // Determine initial number based on screen size: 4 on small screens
const isSmallScreen = typeof window !== "undefined" && !window.matchMedia("(min-width: 640px)").matches; const isSmallScreen = typeof window !== "undefined" && !window.matchMedia("(min-width: 640px)").matches;
const initialTarget = isSmallScreen ? 4 : ALBUM_BATCH; const initialTarget = isSmallScreen ? 4 : ALBUM_BATCH;
// Load initial batch from albumIds, then if needed from singles, then appears // Load initial sets from each group in order until initialTarget reached
const initialBatch: LibrespotAlbumType[] = []; let aOff = 0, sOff = 0, cOff = 0, apOff = 0;
let aOff = 0, sOff = 0, apOff = 0; let loaded = 0;
if (albumIds.length > 0) { let aList: LibrespotAlbumType[] = [];
const take = albumIds.slice(0, initialTarget); let sList: LibrespotAlbumType[] = [];
initialBatch.push(...await fetchAlbumsByIds(take)); let cList: LibrespotAlbumType[] = [];
let apList: LibrespotAlbumType[] = [];
if (albumIds.length > 0 && loaded < initialTarget) {
const take = albumIds.slice(0, initialTarget - loaded);
aList = await fetchAlbumsByIds(take);
aOff = take.length; aOff = take.length;
loaded += aList.length;
} }
if (initialBatch.length < initialTarget && singleIds.length > 0) { if (singleIds.length > 0 && loaded < initialTarget) {
const remaining = initialTarget - initialBatch.length; const take = singleIds.slice(0, initialTarget - loaded);
const take = singleIds.slice(0, remaining); sList = await fetchAlbumsByIds(take);
initialBatch.push(...await fetchAlbumsByIds(take));
sOff = take.length; sOff = take.length;
loaded += sList.length;
} }
if (initialBatch.length < initialTarget && appearsIds.length > 0) { if (compIds.length > 0 && loaded < initialTarget) {
const remaining = initialTarget - initialBatch.length; const take = compIds.slice(0, initialTarget - loaded);
const take = appearsIds.slice(0, remaining); cList = await fetchAlbumsByIds(take);
initialBatch.push(...await fetchAlbumsByIds(take)); cOff = take.length;
loaded += cList.length;
}
if (appearsIds.length > 0 && loaded < initialTarget) {
const take = appearsIds.slice(0, initialTarget - loaded);
apList = await fetchAlbumsByIds(take);
apOff = take.length; apOff = take.length;
loaded += apList.length;
} }
if (!cancelled) { if (!cancelled) {
setArtistAlbums(initialBatch.filter(a => a.album_type === "album")); setArtistAlbums(aList);
setArtistSingles(initialBatch.filter(a => a.album_type === "single")); setArtistSingles(sList);
setArtistAppearsOn([]); // placeholder; appears_on grouping not explicitly typed setArtistCompilations(cList);
setArtistAppearsOn(apList);
// Store offsets for next loads // Store offsets for next loads
setAlbumOffset(aOff); setAlbumOffset(aOff);
setSingleOffset(sOff); setSingleOffset(sOff);
setCompOffset(cOff);
setAppearsOffset(apOff); setAppearsOffset(apOff);
// Determine if more remain // Determine if more remain
setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (appearsIds.length > apOff)); setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (compIds.length > cOff) || (appearsIds.length > apOff));
} }
} else { } else {
setError("Could not load artist data."); setError("Could not load artist data.");
@@ -201,34 +210,54 @@ export const Artist = () => {
try { try {
const albumIds = artist.album_group ?? []; const albumIds = artist.album_group ?? [];
const singleIds = artist.single_group ?? []; const singleIds = artist.single_group ?? [];
const compIds = artist.compilation_group ?? [];
const appearsIds = artist.appears_on_group ?? []; const appearsIds = artist.appears_on_group ?? [];
const nextBatch: LibrespotAlbumType[] = []; const nextA: LibrespotAlbumType[] = [];
let aOff = albumOffset, sOff = singleOffset, apOff = appearsOffset; const nextS: LibrespotAlbumType[] = [];
if (aOff < albumIds.length) { const nextC: LibrespotAlbumType[] = [];
const take = albumIds.slice(aOff, aOff + ALBUM_BATCH - nextBatch.length); const nextAp: LibrespotAlbumType[] = [];
nextBatch.push(...await fetchAlbumsByIds(take));
let aOff = albumOffset, sOff = singleOffset, cOff = compOffset, apOff = appearsOffset;
const totalLoaded = () => nextA.length + nextS.length + nextC.length + nextAp.length;
if (aOff < albumIds.length && totalLoaded() < ALBUM_BATCH) {
const remaining = ALBUM_BATCH - totalLoaded();
const take = albumIds.slice(aOff, aOff + remaining);
nextA.push(...await fetchAlbumsByIds(take));
aOff += take.length; aOff += take.length;
} }
if (nextBatch.length < ALBUM_BATCH && sOff < singleIds.length) { if (sOff < singleIds.length && totalLoaded() < ALBUM_BATCH) {
const remaining = ALBUM_BATCH - nextBatch.length; const remaining = ALBUM_BATCH - totalLoaded();
const take = singleIds.slice(sOff, sOff + remaining); const take = singleIds.slice(sOff, sOff + remaining);
nextBatch.push(...await fetchAlbumsByIds(take)); nextS.push(...await fetchAlbumsByIds(take));
sOff += take.length; sOff += take.length;
} }
if (nextBatch.length < ALBUM_BATCH && apOff < appearsIds.length) { if (cOff < compIds.length && totalLoaded() < ALBUM_BATCH) {
const remaining = ALBUM_BATCH - nextBatch.length; const remaining = ALBUM_BATCH - totalLoaded();
const take = compIds.slice(cOff, cOff + remaining);
nextC.push(...await fetchAlbumsByIds(take));
cOff += take.length;
}
if (apOff < appearsIds.length && totalLoaded() < ALBUM_BATCH) {
const remaining = ALBUM_BATCH - totalLoaded();
const take = appearsIds.slice(apOff, apOff + remaining); const take = appearsIds.slice(apOff, apOff + remaining);
nextBatch.push(...await fetchAlbumsByIds(take)); nextAp.push(...await fetchAlbumsByIds(take));
apOff += take.length; apOff += take.length;
} }
setArtistAlbums((cur) => cur.concat(nextBatch.filter(a => a.album_type === "album"))); setArtistAlbums((cur) => cur.concat(nextA));
setArtistSingles((cur) => cur.concat(nextBatch.filter(a => a.album_type === "single"))); setArtistSingles((cur) => cur.concat(nextS));
setAppearsOffset(apOff); setArtistCompilations((cur) => cur.concat(nextC));
setArtistAppearsOn((cur) => cur.concat(nextAp));
setAlbumOffset(aOff); setAlbumOffset(aOff);
setSingleOffset(sOff); setSingleOffset(sOff);
setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (appearsIds.length > apOff)); setCompOffset(cOff);
setAppearsOffset(apOff);
setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (compIds.length > cOff) || (appearsIds.length > apOff));
} catch (err) { } catch (err) {
console.error("Failed to load more albums", err); console.error("Failed to load more albums", err);
toast.error("Failed to load more albums"); toast.error("Failed to load more albums");
@@ -236,7 +265,7 @@ export const Artist = () => {
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
}, [artistId, loadingMore, loading, hasMore, artist, albumOffset, singleOffset, appearsOffset, fetchAlbumsByIds]); }, [artistId, loadingMore, loading, hasMore, artist, albumOffset, singleOffset, compOffset, appearsOffset, fetchAlbumsByIds]);
// IntersectionObserver to trigger fetchMoreAlbums when sentinel is visible // IntersectionObserver to trigger fetchMoreAlbums when sentinel is visible
useEffect(() => { useEffect(() => {
@@ -263,6 +292,16 @@ export const Artist = () => {
return () => observer.disconnect(); return () => observer.disconnect();
}, [fetchMoreAlbums, hasMore]); }, [fetchMoreAlbums, hasMore]);
// Auto progressive loading regardless of scroll
useEffect(() => {
if (!artist) return;
if (!hasMore || loading || loadingMore) return;
const t = setTimeout(() => {
fetchMoreAlbums();
}, 350);
return () => clearTimeout(t);
}, [artist, hasMore, loading, loadingMore, fetchMoreAlbums]);
// --- existing handlers (unchanged) --- // --- existing handlers (unchanged) ---
const handleDownloadTrack = (track: LibrespotTrackType) => { const handleDownloadTrack = (track: LibrespotTrackType) => {
if (!track.id) return; if (!track.id) return;
@@ -303,6 +342,25 @@ export const Artist = () => {
} }
}; };
const handleDownloadGroup = async (group: "album" | "single" | "compilation" | "appears_on") => {
if (!artistId || !artist) return;
try {
toast.info(`Queueing ${group} downloads for ${artist.name}...`);
const response = await apiClient.get(`/artist/download/${artistId}?album_type=${group}`);
const count = response.data?.queued_albums?.length ?? 0;
if (count > 0) {
toast.success(`Queued ${count} ${group}${count > 1 ? "s" : ""}.`);
} else {
toast.info(`No new ${group} releases to download.`);
}
} catch (error: any) {
console.error(`Failed to queue ${group} downloads:`, error);
toast.error(`Failed to queue ${group} downloads`, {
description: error.response?.data?.error || "An unexpected error occurred.",
});
}
};
const handleToggleWatch = async () => { const handleToggleWatch = async () => {
if (!artistId || !artist) return; if (!artistId || !artist) return;
try { try {
@@ -453,7 +511,17 @@ export const Artist = () => {
{/* Albums */} {/* Albums */}
{artistAlbums.length > 0 && ( {artistAlbums.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Albums</h2> <div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Albums</h2>
<button
onClick={() => handleDownloadGroup("album")}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
title="Download all albums"
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
<span>Download</span>
</button>
</div>
<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)} />
@@ -465,7 +533,17 @@ export const Artist = () => {
{/* Singles */} {/* Singles */}
{artistSingles.length > 0 && ( {artistSingles.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Singles</h2> <div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Singles</h2>
<button
onClick={() => handleDownloadGroup("single")}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
title="Download all singles"
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
<span>Download</span>
</button>
</div>
<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)} />
@@ -474,10 +552,42 @@ export const Artist = () => {
</div> </div>
)} )}
{/* Compilations */}
{artistCompilations.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Compilations</h2>
<button
onClick={() => handleDownloadGroup("compilation")}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
title="Download all compilations"
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
<span>Download</span>
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistCompilations.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
))}
</div>
</div>
)}
{/* Appears On */} {/* Appears On */}
{artistAppearsOn.length > 0 && ( {artistAppearsOn.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Appears On</h2> <div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Appears On</h2>
<button
onClick={() => handleDownloadGroup("appears_on")}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
title="Download all appears on"
>
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
<span>Download</span>
</button>
</div>
<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">
{artistAppearsOn.map((album) => ( {artistAppearsOn.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} /> <AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
@@ -494,9 +604,9 @@ export const Artist = () => {
{hasMore && !loadingMore && ( {hasMore && !loadingMore && (
<button <button
onClick={() => fetchMoreAlbums()} onClick={() => fetchMoreAlbums()}
className="px-4 py-2 mb-6 rounded bg-surface-muted hover:bg-surface-muted-dark" className="px-4 py-2 mb-6 rounded"
> >
Load more Loading...
</button> </button>
)} )}
<div ref={sentinelRef} style={{ height: 1, width: "100%" }} /> <div ref={sentinelRef} style={{ height: 1, width: "100%" }} />

View File

@@ -153,6 +153,16 @@ export const Playlist = () => {
} }
}, [playlistMetadata, items.length, totalTracks, loadMoreTracks]); }, [playlistMetadata, items.length, totalTracks, loadMoreTracks]);
// Auto progressive loading regardless of scroll
useEffect(() => {
if (!playlistMetadata) return;
if (!hasMoreTracks || loadingTracks) return;
const t = setTimeout(() => {
loadMoreTracks();
}, 300);
return () => clearTimeout(t);
}, [playlistMetadata, hasMoreTracks, loadingTracks, loadMoreTracks]);
const handleDownloadTrack = (track: LibrespotTrackType) => { const handleDownloadTrack = (track: LibrespotTrackType) => {
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 });
@@ -227,11 +237,40 @@ export const Playlist = () => {
{/* Playlist Header - Mobile Optimized */} {/* Playlist Header - Mobile Optimized */}
<div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm"> <div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm">
<div className="flex flex-col items-center gap-4 md:gap-6"> <div className="flex flex-col items-center gap-4 md:gap-6">
<img {playlistMetadata.picture ? (
src={playlistMetadata.images?.at(0)?.url || "/placeholder.jpg"} <img
alt={playlistMetadata.name} src={playlistMetadata.picture}
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" alt={playlistMetadata.name}
/> 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"
/>
) : (
<div
className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 rounded-lg shadow-lg mx-auto overflow-hidden bg-surface-muted dark:bg-surface-muted-dark grid grid-cols-2 grid-rows-2"
>
{(Array.from(
new Map(
filteredItems
.map(({ track }) => (track as any)?.album?.images?.at(-1)?.url)
.filter((u) => !!u)
.map((u) => [u, u] as const)
).values()
) as string[]).slice(0, 4).map((url, i) => (
<img
key={`${url}-${i}`}
src={url}
alt={`Cover ${i + 1}`}
className="w-full h-full object-cover"
/>
))}
{filteredItems.length === 0 && (
<img
src="/placeholder.jpg"
alt={playlistMetadata.name}
className="col-span-2 row-span-2 w-full h-full object-cover"
/>
)}
</div>
)}
<div className="flex-grow space-y-2 text-center"> <div className="flex-grow space-y-2 text-center">
<h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{playlistMetadata.name}</h1> <h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{playlistMetadata.name}</h1>
{playlistMetadata.description && ( {playlistMetadata.description && (

View File

@@ -3,7 +3,7 @@ import apiClient from "../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import type { ArtistType, PlaylistType } from "../types/spotify"; import type { LibrespotArtistType, LibrespotPlaylistType } from "../types/librespot";
import { FaRegTrashAlt, FaSearch } from "react-icons/fa"; import { FaRegTrashAlt, FaSearch } from "react-icons/fa";
// --- Type Definitions --- // --- Type Definitions ---
@@ -11,8 +11,8 @@ interface BaseWatched {
itemType: "artist" | "playlist"; itemType: "artist" | "playlist";
spotify_id: string; spotify_id: string;
} }
type WatchedArtist = ArtistType & { itemType: "artist" }; type WatchedArtist = LibrespotArtistType & { itemType: "artist" };
type WatchedPlaylist = PlaylistType & { itemType: "playlist" }; type WatchedPlaylist = LibrespotPlaylistType & { itemType: "playlist" };
type WatchedItem = WatchedArtist | WatchedPlaylist; type WatchedItem = WatchedArtist | WatchedPlaylist;
@@ -20,39 +20,77 @@ export const Watchlist = () => {
const { settings, isLoading: settingsLoading } = useSettings(); const { settings, isLoading: settingsLoading } = useSettings();
const [items, setItems] = useState<WatchedItem[]>([]); const [items, setItems] = useState<WatchedItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [expectedCount, setExpectedCount] = useState<number | null>(null);
// Utility to batch fetch details
async function batchFetch<T>(
ids: string[],
fetchFn: (id: string) => Promise<T>,
batchSize: number,
onBatch: (results: T[]) => void
) {
for (let i = 0; i < ids.length; i += batchSize) {
const batchIds = ids.slice(i, i + batchSize);
const batchResults = await Promise.all(
batchIds.map((id) => fetchFn(id).catch(() => null))
);
onBatch(batchResults.filter(Boolean) as T[]);
}
}
const fetchWatchlist = useCallback(async () => { const fetchWatchlist = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setItems([]); // Clear previous items
setExpectedCount(null);
try { try {
const [artistsRes, playlistsRes] = await Promise.all([ const [artistsRes, playlistsRes] = await Promise.all([
apiClient.get<BaseWatched[]>("/artist/watch/list"), apiClient.get<BaseWatched[]>("/artist/watch/list"),
apiClient.get<BaseWatched[]>("/playlist/watch/list"), apiClient.get<BaseWatched[]>("/playlist/watch/list"),
]); ]);
const artistDetailsPromises = artistsRes.data.map((artist) => // Prepare lists of IDs
apiClient.get<ArtistType>(`/artist/info?id=${artist.spotify_id}`), const artistIds = artistsRes.data.map((artist) => artist.spotify_id);
); const playlistIds = playlistsRes.data.map((playlist) => playlist.spotify_id);
const playlistDetailsPromises = playlistsRes.data.map((playlist) => setExpectedCount(artistIds.length + playlistIds.length);
apiClient.get<PlaylistType>(`/playlist/info?id=${playlist.spotify_id}`),
// Allow UI to render grid and skeletons immediately
setIsLoading(false);
// Helper to update state incrementally
const appendItems = (newItems: WatchedItem[]) => {
setItems((prev) => [...prev, ...newItems]);
};
// Fetch artist details in batches
await batchFetch<LibrespotArtistType>(
artistIds,
(id) => apiClient.get<LibrespotArtistType>(`/artist/info?id=${id}`).then(res => res.data),
5, // batch size
(results) => {
const items: WatchedArtist[] = results.map((data) => ({
...data,
itemType: "artist",
}));
appendItems(items);
}
); );
const [artistDetailsRes, playlistDetailsRes] = await Promise.all([ // Fetch playlist details in batches
Promise.all(artistDetailsPromises), await batchFetch<LibrespotPlaylistType>(
Promise.all(playlistDetailsPromises), playlistIds,
]); (id) => apiClient.get<LibrespotPlaylistType>(`/playlist/info?id=${id}`).then(res => res.data),
5, // batch size
const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" })); (results) => {
const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({ const items: WatchedPlaylist[] = results.map((data) => ({
...res.data, ...data,
itemType: "playlist", itemType: "playlist",
spotify_id: res.data.id, spotify_id: data.id,
})); }));
appendItems(items);
setItems([...artists, ...playlists]); }
);
} catch { } catch {
toast.error("Failed to load watchlist."); toast.error("Failed to load watchlist.");
} finally {
setIsLoading(false);
} }
}, []); }, []);
@@ -110,7 +148,8 @@ export const Watchlist = () => {
); );
} }
if (items.length === 0) { // Show "empty" only if not loading and nothing expected
if (!isLoading && items.length === 0 && (!expectedCount || expectedCount === 0)) {
return ( return (
<div className="text-center p-8"> <div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2> <h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2>
@@ -131,11 +170,15 @@ export const Watchlist = () => {
</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-surface dark:bg-surface-secondary-dark 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.itemType === "artist"
? (item as WatchedArtist).portrait_group.image[0].url || "/images/placeholder.jpg"
: (item as WatchedPlaylist).picture || "/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"
/> />
@@ -158,6 +201,25 @@ export const Watchlist = () => {
</div> </div>
</div> </div>
))} ))}
{/* Skeletons for loading items */}
{isLoading && expectedCount && items.length < expectedCount &&
Array.from({ length: expectedCount - items.length }).map((_, idx) => (
<div
key={`skeleton-${idx}`}
className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col animate-pulse"
>
<div className="flex-grow">
<div className="w-full aspect-square bg-gray-200 dark:bg-gray-700 rounded-md mb-2" />
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-1" />
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded w-1/2" />
</div>
<div className="flex gap-2 pt-2">
<div className="w-full h-8 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="w-full h-8 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
</div>
))
}
</div> </div>
</div> </div>
); );

View File

@@ -6,8 +6,8 @@ export interface LibrespotExternalUrls {
export interface LibrespotImage { export interface LibrespotImage {
url: string; url: string;
width?: number; width: number;
height?: number; height: number;
} }
export interface LibrespotArtistStub { export interface LibrespotArtistStub {
@@ -18,17 +18,32 @@ export interface LibrespotArtistStub {
external_urls?: LibrespotExternalUrls; external_urls?: LibrespotExternalUrls;
} }
export interface LibrespotBiographyType {
text: string;
portrait_group: LibrespotArtistImageType;
}
export interface LibrespotTopTrackType {
country: string;
track: string[];
}
export interface LibrespotArtistImageType {
image: LibrespotImage[];
}
// Full artist object (get_artist) // Full artist object (get_artist)
export interface LibrespotArtistType { export interface LibrespotArtistType {
id: string; id: string;
name: string; name: string;
images?: LibrespotImage[]; top_track: LibrespotTopTrackType[];
external_urls?: LibrespotExternalUrls; portrait_group: LibrespotArtistImageType;
followers?: { total: number }; popularity: number;
genres?: string[]; biography?: LibrespotBiographyType[];
popularity?: number; album_group?: string[];
type?: "artist"; single_group?: string[];
uri?: string; compilation_group?: string[];
appears_on_group?: string[];
} }
export interface LibrespotCopyright { export interface LibrespotCopyright {
@@ -59,24 +74,23 @@ export interface LibrespotTrackType {
disc_number: number; disc_number: number;
duration_ms: number; duration_ms: number;
explicit: boolean; explicit: boolean;
external_ids?: { isrc?: string }; external_ids: { isrc?: string };
external_urls: LibrespotExternalUrls; external_urls: LibrespotExternalUrls;
id: string; id: string;
name: string; name: string;
popularity?: number; popularity: number;
track_number: number; track_number: number;
type: "track"; type: "track";
uri: string; uri: string;
preview_url?: string; preview_url: string;
has_lyrics?: boolean; has_lyrics: boolean;
earliest_live_timestamp?: number; earliest_live_timestamp: number;
licensor_uuid?: string; // when available licensor_uuid: string; // when available
} }
export interface LibrespotAlbumType { export interface LibrespotAlbumType {
album_type: "album" | "single" | "compilation"; album_type: "album" | "single" | "compilation";
total_tracks: number; total_tracks: number;
available_markets?: string[];
external_urls: LibrespotExternalUrls; external_urls: LibrespotExternalUrls;
id: string; id: string;
images: LibrespotImage[]; images: LibrespotImage[];
@@ -91,8 +105,8 @@ export interface LibrespotAlbumType {
tracks: string[] | LibrespotTrackType[]; tracks: string[] | LibrespotTrackType[];
copyrights?: LibrespotCopyright[]; copyrights?: LibrespotCopyright[];
external_ids?: { upc?: string }; external_ids?: { upc?: string };
label?: string; label: string;
popularity?: number; popularity: number;
} }
// Playlist types // Playlist types
@@ -130,13 +144,14 @@ export interface LibrespotPlaylistTracksPageType {
export interface LibrespotPlaylistType { export interface LibrespotPlaylistType {
name: string; name: string;
description?: string | null; id: string;
collaborative?: boolean; description: string | null;
images?: Array<Pick<LibrespotImage, "url"> & Partial<LibrespotImage>>; collaborative: boolean;
owner: LibrespotPlaylistOwnerType; owner: LibrespotPlaylistOwnerType;
snapshot_id: string; snapshot_id: string;
tracks: LibrespotPlaylistTracksPageType; tracks: LibrespotPlaylistTracksPageType;
type: "playlist"; type: "playlist";
picture: string;
} }
// Type guards // Type guards