From cf6d367915a5c65ec61b08caa7a9c52546327c88 Mon Sep 17 00:00:00 2001 From: Xoconoch Date: Tue, 19 Aug 2025 21:26:14 -0600 Subject: [PATCH] feat: Add real_time_multiplier to backend --- routes/content/album.py | 69 +++++++++++++++-------- routes/system/config.py | 23 ++++++++ routes/utils/album.py | 3 + routes/utils/celery_config.py | 2 + routes/utils/celery_queue_manager.py | 19 +++++-- routes/utils/celery_tasks.py | 12 ++++ routes/utils/get_info.py | 13 ++++- routes/utils/playlist.py | 3 + routes/utils/track.py | 3 + spotizerr-ui/src/routes/album.tsx | 83 +++++++++++++++++++++++++--- 10 files changed, 191 insertions(+), 39 deletions(-) diff --git a/routes/content/album.py b/routes/content/album.py index ba797d2..de98864 100755 --- a/routes/content/album.py +++ b/routes/content/album.py @@ -1,6 +1,5 @@ -from fastapi import APIRouter, HTTPException, Request, Depends +from fastapi import APIRouter, Request, Depends from fastapi.responses import JSONResponse -import json import traceback import uuid import time @@ -21,7 +20,11 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str: @router.get("/download/{album_id}") -async def handle_download(album_id: str, request: Request, current_user: User = Depends(require_auth_from_state)): +async def handle_download( + album_id: str, + request: Request, + current_user: User = Depends(require_auth_from_state), +): # Retrieve essential parameters from the request. # name = request.args.get('name') # artist = request.args.get('artist') @@ -38,8 +41,10 @@ async def handle_download(album_id: str, request: Request, current_user: User = or not album_info.get("artists") ): return JSONResponse( - content={"error": f"Could not retrieve metadata for album ID: {album_id}"}, - status_code=404 + content={ + "error": f"Could not retrieve metadata for album ID: {album_id}" + }, + status_code=404, ) name_from_spotify = album_info.get("name") @@ -51,15 +56,16 @@ async def handle_download(album_id: str, request: Request, current_user: User = except Exception as e: return JSONResponse( - content={"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"}, - status_code=500 + content={ + "error": f"Failed to fetch metadata for album {album_id}: {str(e)}" + }, + status_code=500, ) # Validate required parameters if not url: return JSONResponse( - content={"error": "Missing required parameter: url"}, - status_code=400 + content={"error": "Missing required parameter: url"}, status_code=400 ) # Add the task to the queue with only essential parameters @@ -84,7 +90,7 @@ async def handle_download(album_id: str, request: Request, current_user: User = "error": "Duplicate download detected.", "existing_task": e.existing_task, }, - status_code=409 + status_code=409, ) except Exception as e: # Generic error handling for other issues during task submission @@ -116,25 +122,23 @@ async def handle_download(album_id: str, request: Request, current_user: User = "error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id, }, - status_code=500 + status_code=500, ) - return JSONResponse( - content={"task_id": task_id}, - status_code=202 - ) + return JSONResponse(content={"task_id": task_id}, status_code=202) @router.get("/download/cancel") -async def cancel_download(request: Request, current_user: User = Depends(require_auth_from_state)): +async def cancel_download( + request: Request, current_user: User = Depends(require_auth_from_state) +): """ Cancel a running download process by its task id. """ task_id = request.query_params.get("task_id") if not task_id: return JSONResponse( - content={"error": "Missing process id (task_id) parameter"}, - status_code=400 + content={"error": "Missing process id (task_id) parameter"}, status_code=400 ) # Use the queue manager's cancellation method. @@ -145,7 +149,9 @@ async def cancel_download(request: Request, current_user: User = Depends(require @router.get("/info") -async def get_album_info(request: Request, current_user: User = Depends(require_auth_from_state)): +async def get_album_info( + request: Request, current_user: User = Depends(require_auth_from_state) +): """ Retrieve Spotify album metadata given a Spotify album ID. Expects a query parameter 'id' that contains the Spotify album ID. @@ -153,15 +159,30 @@ async def get_album_info(request: Request, current_user: User = Depends(require_ spotify_id = request.query_params.get("id") if not spotify_id: - return JSONResponse( - content={"error": "Missing parameter: id"}, - status_code=400 - ) + return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400) try: - # Use the get_spotify_info function (already imported at top) + # Optional pagination params for tracks + limit_param = request.query_params.get("limit") + offset_param = request.query_params.get("offset") + limit = int(limit_param) if limit_param is not None else None + offset = int(offset_param) if offset_param is not None else None + + # Fetch album metadata album_info = get_spotify_info(spotify_id, "album") + # Fetch album tracks with pagination + album_tracks = get_spotify_info( + spotify_id, "album_tracks", limit=limit, offset=offset + ) + + # Merge tracks into album payload in the same shape Spotify returns on album + album_info["tracks"] = album_tracks + return JSONResponse(content=album_info, status_code=200) + except ValueError as ve: + return JSONResponse( + content={"error": f"Invalid limit/offset: {str(ve)}"}, status_code=400 + ) except Exception as e: error_data = {"error": str(e), "traceback": traceback.format_exc()} return JSONResponse(content=error_data, status_code=500) diff --git a/routes/system/config.py b/routes/system/config.py index ecf7f0c..95fead3 100644 --- a/routes/system/config.py +++ b/routes/system/config.py @@ -72,6 +72,28 @@ def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool, if watch_config is None: watch_config = get_watch_config_http() + # Ensure realTimeMultiplier is a valid integer in range 0..10 if provided + if "realTimeMultiplier" in config_data or "real_time_multiplier" in config_data: + key = ( + "realTimeMultiplier" + if "realTimeMultiplier" in config_data + else "real_time_multiplier" + ) + val = config_data.get(key) + if isinstance(val, bool): + return False, "realTimeMultiplier must be an integer between 0 and 10." + try: + ival = int(val) + except Exception: + return False, "realTimeMultiplier must be an integer between 0 and 10." + if ival < 0 or ival > 10: + return False, "realTimeMultiplier must be between 0 and 10." + # Normalize to camelCase in the working dict so save_config writes it + if key == "real_time_multiplier": + config_data["realTimeMultiplier"] = ival + else: + config_data["realTimeMultiplier"] = ival + # Check if fallback is enabled but missing required accounts if config_data.get("fallback", False): has_spotify = has_credentials("spotify") @@ -169,6 +191,7 @@ def _migrate_legacy_keys_inplace(cfg: dict) -> bool: "artist_separator": "artistSeparator", "recursive_quality": "recursiveQuality", "spotify_metadata": "spotifyMetadata", + "real_time_multiplier": "realTimeMultiplier", } modified = False for legacy, camel in legacy_map.items(): diff --git a/routes/utils/album.py b/routes/utils/album.py index 23c7c06..b81e0fa 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -31,6 +31,7 @@ def download_album( recursive_quality=True, spotify_metadata=True, _is_celery_task_execution=False, # Added to skip duplicate check from Celery task + real_time_multiplier=None, ): if not _is_celery_task_execution: existing_task = get_existing_task_id( @@ -173,6 +174,7 @@ def download_album( convert_to=convert_to, bitrate=bitrate, artist_separator=artist_separator, + real_time_multiplier=real_time_multiplier, ) print( f"DEBUG: album.py - Spotify direct download (account: {main} for blob) successful." @@ -228,6 +230,7 @@ def download_album( convert_to=convert_to, bitrate=bitrate, artist_separator=artist_separator, + real_time_multiplier=real_time_multiplier, ) print( f"DEBUG: album.py - Direct Spotify download (account: {main} for blob) successful." diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index 5eeb64a..f95812e 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -49,6 +49,7 @@ DEFAULT_MAIN_CONFIG = { "spotifyMetadata": True, "separateTracksByUser": False, "watch": {}, + "realTimeMultiplier": 0, } @@ -63,6 +64,7 @@ def _migrate_legacy_keys(cfg: dict) -> tuple[dict, bool]: "artist_separator": "artistSeparator", "recursive_quality": "recursiveQuality", "spotify_metadata": "spotifyMetadata", + "real_time_multiplier": "realTimeMultiplier", } for legacy, camel in legacy_map.items(): if legacy in out and camel not in out: diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index d067a27..10b47f1 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -72,6 +72,9 @@ def get_config_params(): ), "separateTracksByUser": config.get("separateTracksByUser", False), "watch": config.get("watch", {}), + "realTimeMultiplier": config.get( + "realTimeMultiplier", config.get("real_time_multiplier", 0) + ), } except Exception as e: logger.error(f"Error reading config for parameters: {e}") @@ -96,6 +99,7 @@ def get_config_params(): "recursiveQuality": False, "separateTracksByUser": False, "watch": {}, + "realTimeMultiplier": 0, } @@ -363,7 +367,7 @@ class CeleryDownloadQueueManager: original_request = task.get( "orig_request", task.get("original_request", {}) ) - + # Get username for user-specific paths username = task.get("username", "") @@ -389,9 +393,11 @@ class CeleryDownloadQueueManager: original_request.get("real_time"), config_params["realTime"] ), "custom_dir_format": self._get_user_specific_dir_format( - original_request.get("custom_dir_format", config_params["customDirFormat"]), + original_request.get( + "custom_dir_format", config_params["customDirFormat"] + ), config_params.get("separateTracksByUser", False), - username + username, ), "custom_track_format": original_request.get( "custom_track_format", config_params["customTrackFormat"] @@ -419,6 +425,9 @@ class CeleryDownloadQueueManager: "retry_count": 0, "original_request": original_request, "created_at": time.time(), + "real_time_multiplier": original_request.get( + "real_time_multiplier", config_params.get("realTimeMultiplier", 0) + ), } # If from_watch_job is True, ensure track_details_for_db is passed through @@ -497,12 +506,12 @@ class CeleryDownloadQueueManager: def _get_user_specific_dir_format(self, base_format, separate_by_user, username): """ Modify the directory format to include username if separateTracksByUser is enabled - + Args: base_format (str): The base directory format from config separate_by_user (bool): Whether to separate tracks by user username (str): The username to include in path - + Returns: str: The modified directory format """ diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 654d8a7..f1a384d 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -1626,6 +1626,9 @@ def download_track(self, **task_data): spotify_metadata = task_data.get( "spotify_metadata", config_params.get("spotifyMetadata", True) ) + real_time_multiplier = task_data.get( + "real_time_multiplier", config_params.get("realTimeMultiplier", 0) + ) # Execute the download - service is now determined from URL download_track_func( @@ -1646,6 +1649,7 @@ def download_track(self, **task_data): artist_separator=artist_separator, spotify_metadata=spotify_metadata, _is_celery_task_execution=True, # Skip duplicate check inside Celery task (consistency) + real_time_multiplier=real_time_multiplier, ) return {"status": "success", "message": "Track download completed"} @@ -1725,6 +1729,9 @@ def download_album(self, **task_data): spotify_metadata = task_data.get( "spotify_metadata", config_params.get("spotifyMetadata", True) ) + real_time_multiplier = task_data.get( + "real_time_multiplier", config_params.get("realTimeMultiplier", 0) + ) # Execute the download - service is now determined from URL download_album_func( @@ -1745,6 +1752,7 @@ def download_album(self, **task_data): artist_separator=artist_separator, spotify_metadata=spotify_metadata, _is_celery_task_execution=True, # Skip duplicate check inside Celery task + real_time_multiplier=real_time_multiplier, ) return {"status": "success", "message": "Album download completed"} @@ -1833,6 +1841,9 @@ def download_playlist(self, **task_data): "retry_delay_increase", config_params.get("retryDelayIncrease", 5) ) max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3)) + real_time_multiplier = task_data.get( + "real_time_multiplier", config_params.get("realTimeMultiplier", 0) + ) # Execute the download - service is now determined from URL download_playlist_func( @@ -1856,6 +1867,7 @@ def download_playlist(self, **task_data): artist_separator=artist_separator, spotify_metadata=spotify_metadata, _is_celery_task_execution=True, # Skip duplicate check inside Celery task + real_time_multiplier=real_time_multiplier, ) return {"status": "success", "message": "Playlist download completed"} diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 7729b17..df1384d 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -239,7 +239,7 @@ def get_spotify_info( Args: spotify_id: The Spotify ID of the entity - spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode) + spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode, album_tracks) limit (int, optional): The maximum number of items to return. Used for pagination. offset (int, optional): The index of the first item to return. Used for pagination. @@ -255,6 +255,12 @@ def get_spotify_info( elif spotify_type == "album": return client.album(spotify_id) + elif spotify_type == "album_tracks": + # Fetch album's tracks with pagination support + return client.album_tracks( + spotify_id, limit=limit or 20, offset=offset or 0 + ) + elif spotify_type == "playlist": # Use optimized playlist fetching return get_playlist_full(spotify_id) @@ -269,7 +275,10 @@ def get_spotify_info( elif spotify_type == "artist_discography": # Get artist's albums with pagination albums = client.artist_albums( - spotify_id, limit=limit or 20, offset=offset or 0, include_groups="single,album,appears_on" + spotify_id, + limit=limit or 20, + offset=offset or 0, + include_groups="single,album,appears_on", ) return albums diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index d6105b3..ffd47ed 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -28,6 +28,7 @@ def download_playlist( recursive_quality=True, spotify_metadata=True, _is_celery_task_execution=False, # Added to skip duplicate check from Celery task + real_time_multiplier=None, ): if not _is_celery_task_execution: existing_task = get_existing_task_id( @@ -175,6 +176,7 @@ def download_playlist( convert_to=convert_to, bitrate=bitrate, artist_separator=artist_separator, + real_time_multiplier=real_time_multiplier, ) print( f"DEBUG: playlist.py - Spotify direct download (account: {main} for blob) successful." @@ -236,6 +238,7 @@ def download_playlist( convert_to=convert_to, bitrate=bitrate, artist_separator=artist_separator, + real_time_multiplier=real_time_multiplier, ) print( f"DEBUG: playlist.py - Direct Spotify download (account: {main} for blob) successful." diff --git a/routes/utils/track.py b/routes/utils/track.py index 61d86af..6af3c7d 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -29,6 +29,7 @@ def download_track( recursive_quality=False, spotify_metadata=True, _is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check + real_time_multiplier=None, ): try: # Detect URL source (Spotify or Deezer) from URL @@ -166,6 +167,7 @@ def download_track( convert_to=convert_to, bitrate=bitrate, artist_separator=artist_separator, + real_time_multiplier=real_time_multiplier, ) print( f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful." @@ -222,6 +224,7 @@ def download_track( convert_to=convert_to, bitrate=bitrate, artist_separator=artist_separator, + real_time_multiplier=real_time_multiplier, ) print( f"DEBUG: track.py - Direct Spotify download (account: {main} for blob) successful." diff --git a/spotizerr-ui/src/routes/album.tsx b/spotizerr-ui/src/routes/album.tsx index 74a7825..502001c 100644 --- a/spotizerr-ui/src/routes/album.tsx +++ b/spotizerr-ui/src/routes/album.tsx @@ -1,5 +1,5 @@ import { Link, useParams } from "@tanstack/react-router"; -import { useEffect, useState, useContext } from "react"; +import { useEffect, useState, useContext, useRef, useCallback } from "react"; import apiClient from "../lib/api-client"; import { QueueContext } from "../contexts/queue-context"; import { useSettings } from "../contexts/settings-context"; @@ -10,31 +10,91 @@ import { FaArrowLeft } from "react-icons/fa"; export const Album = () => { const { albumId } = useParams({ from: "/album/$albumId" }); const [album, setAlbum] = useState(null); + const [tracks, setTracks] = useState([]); + const [offset, setOffset] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); const context = useContext(QueueContext); const { settings } = useSettings(); + const loadMoreRef = useRef(null); + + const PAGE_SIZE = 50; if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } const { addItem } = context; + const totalTracks = album?.total_tracks ?? 0; + const hasMore = tracks.length < totalTracks; + + // Initial load useEffect(() => { const fetchAlbum = async () => { + if (!albumId) return; + setIsLoading(true); + setError(null); try { - const response = await apiClient.get(`/album/info?id=${albumId}`); - setAlbum(response.data); + const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=0`); + const data: AlbumType & { tracks: { items: TrackType[]; total?: number; limit?: number; offset?: number } } = response.data; + setAlbum(data); + setTracks(data.tracks.items || []); + setOffset((data.tracks.items || []).length); } catch (err) { setError("Failed to load album"); console.error("Error fetching album:", err); + } finally { + setIsLoading(false); } }; + // reset state when albumId changes + setAlbum(null); + setTracks([]); + setOffset(0); if (albumId) { fetchAlbum(); } }, [albumId]); + const loadMore = useCallback(async () => { + if (!albumId || isLoadingMore || !hasMore) return; + setIsLoadingMore(true); + try { + const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=${offset}`); + const data: AlbumType & { tracks: { items: TrackType[]; total?: number; limit?: number; offset?: number } } = response.data; + const newItems = data.tracks.items || []; + setTracks((prev) => [...prev, ...newItems]); + setOffset((prev) => prev + newItems.length); + } catch (err) { + console.error("Error fetching more tracks:", err); + } finally { + setIsLoadingMore(false); + } + }, [albumId, offset, isLoadingMore, hasMore]); + + // IntersectionObserver to trigger loadMore + useEffect(() => { + if (!loadMoreRef.current) return; + const sentinel = loadMoreRef.current; + const observer = new IntersectionObserver( + (entries) => { + const first = entries[0]; + if (first.isIntersecting) { + loadMore(); + } + }, + { root: null, rootMargin: "200px", threshold: 0.1 } + ); + + observer.observe(sentinel); + return () => { + observer.unobserve(sentinel); + observer.disconnect(); + }; + }, [loadMore]); + const handleDownloadTrack = (track: TrackType) => { if (!track.id) return; toast.info(`Adding ${track.name} to queue...`); @@ -51,7 +111,7 @@ export const Album = () => { return
{error}
; } - if (!album) { + if (!album || isLoading) { return
Loading...
; } @@ -67,7 +127,7 @@ export const Album = () => { ); } - const hasExplicitTrack = album.tracks.items.some((track) => track.explicit); + const hasExplicitTrack = tracks.some((track) => track.explicit); return (
@@ -130,11 +190,11 @@ export const Album = () => {

Tracks

- {album.tracks.items.map((track, index) => { + {tracks.map((track, index) => { if (isExplicitFilterEnabled && track.explicit) { return (
@@ -147,7 +207,7 @@ export const Album = () => { } return (
@@ -188,6 +248,13 @@ export const Album = () => {
); })} +
+ {isLoadingMore && ( +
Loading more...
+ )} + {!hasMore && tracks.length > 0 && ( +
End of album
+ )}