feat: Add real_time_multiplier to backend
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
import json
|
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
@@ -21,7 +20,11 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/download/{album_id}")
|
@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.
|
# Retrieve essential parameters from the request.
|
||||||
# name = request.args.get('name')
|
# name = request.args.get('name')
|
||||||
# artist = request.args.get('artist')
|
# artist = request.args.get('artist')
|
||||||
@@ -38,8 +41,10 @@ async def handle_download(album_id: str, request: Request, current_user: User =
|
|||||||
or not album_info.get("artists")
|
or not album_info.get("artists")
|
||||||
):
|
):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"error": f"Could not retrieve metadata for album ID: {album_id}"},
|
content={
|
||||||
status_code=404
|
"error": f"Could not retrieve metadata for album ID: {album_id}"
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
name_from_spotify = album_info.get("name")
|
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:
|
except Exception as e:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"},
|
content={
|
||||||
status_code=500
|
"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate required parameters
|
# Validate required parameters
|
||||||
if not url:
|
if not url:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"error": "Missing required parameter: url"},
|
content={"error": "Missing required parameter: url"}, status_code=400
|
||||||
status_code=400
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add the task to the queue with only essential parameters
|
# 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.",
|
"error": "Duplicate download detected.",
|
||||||
"existing_task": e.existing_task,
|
"existing_task": e.existing_task,
|
||||||
},
|
},
|
||||||
status_code=409
|
status_code=409,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Generic error handling for other issues during task submission
|
# Generic error handling for other issues during task submission
|
||||||
@@ -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)}",
|
"error": f"Failed to queue album download: {str(e)}",
|
||||||
"task_id": error_task_id,
|
"task_id": error_task_id,
|
||||||
},
|
},
|
||||||
status_code=500
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(content={"task_id": task_id}, status_code=202)
|
||||||
content={"task_id": task_id},
|
|
||||||
status_code=202
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download/cancel")
|
@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.
|
Cancel a running download process by its task id.
|
||||||
"""
|
"""
|
||||||
task_id = request.query_params.get("task_id")
|
task_id = request.query_params.get("task_id")
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"error": "Missing process id (task_id) parameter"},
|
content={"error": "Missing process id (task_id) parameter"}, status_code=400
|
||||||
status_code=400
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use the queue manager's cancellation method.
|
# 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")
|
@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.
|
Retrieve Spotify album metadata given a Spotify album ID.
|
||||||
Expects a query parameter 'id' that contains the Spotify album ID.
|
Expects a query parameter 'id' that contains the Spotify album ID.
|
||||||
@@ -153,15 +159,30 @@ async def get_album_info(request: Request, current_user: User = Depends(require_
|
|||||||
spotify_id = request.query_params.get("id")
|
spotify_id = request.query_params.get("id")
|
||||||
|
|
||||||
if not spotify_id:
|
if not spotify_id:
|
||||||
return JSONResponse(
|
return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400)
|
||||||
content={"error": "Missing parameter: id"},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
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")
|
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)
|
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:
|
except Exception as e:
|
||||||
error_data = {"error": str(e), "traceback": traceback.format_exc()}
|
error_data = {"error": str(e), "traceback": traceback.format_exc()}
|
||||||
return JSONResponse(content=error_data, status_code=500)
|
return JSONResponse(content=error_data, status_code=500)
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool,
|
|||||||
if watch_config is None:
|
if watch_config is None:
|
||||||
watch_config = get_watch_config_http()
|
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
|
# Check if fallback is enabled but missing required accounts
|
||||||
if config_data.get("fallback", False):
|
if config_data.get("fallback", False):
|
||||||
has_spotify = has_credentials("spotify")
|
has_spotify = has_credentials("spotify")
|
||||||
@@ -169,6 +191,7 @@ def _migrate_legacy_keys_inplace(cfg: dict) -> bool:
|
|||||||
"artist_separator": "artistSeparator",
|
"artist_separator": "artistSeparator",
|
||||||
"recursive_quality": "recursiveQuality",
|
"recursive_quality": "recursiveQuality",
|
||||||
"spotify_metadata": "spotifyMetadata",
|
"spotify_metadata": "spotifyMetadata",
|
||||||
|
"real_time_multiplier": "realTimeMultiplier",
|
||||||
}
|
}
|
||||||
modified = False
|
modified = False
|
||||||
for legacy, camel in legacy_map.items():
|
for legacy, camel in legacy_map.items():
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def download_album(
|
|||||||
recursive_quality=True,
|
recursive_quality=True,
|
||||||
spotify_metadata=True,
|
spotify_metadata=True,
|
||||||
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
|
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
|
||||||
|
real_time_multiplier=None,
|
||||||
):
|
):
|
||||||
if not _is_celery_task_execution:
|
if not _is_celery_task_execution:
|
||||||
existing_task = get_existing_task_id(
|
existing_task = get_existing_task_id(
|
||||||
@@ -173,6 +174,7 @@ def download_album(
|
|||||||
convert_to=convert_to,
|
convert_to=convert_to,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"DEBUG: album.py - Spotify direct download (account: {main} for blob) successful."
|
f"DEBUG: album.py - Spotify direct download (account: {main} for blob) successful."
|
||||||
@@ -228,6 +230,7 @@ def download_album(
|
|||||||
convert_to=convert_to,
|
convert_to=convert_to,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"DEBUG: album.py - Direct Spotify download (account: {main} for blob) successful."
|
f"DEBUG: album.py - Direct Spotify download (account: {main} for blob) successful."
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ DEFAULT_MAIN_CONFIG = {
|
|||||||
"spotifyMetadata": True,
|
"spotifyMetadata": True,
|
||||||
"separateTracksByUser": False,
|
"separateTracksByUser": False,
|
||||||
"watch": {},
|
"watch": {},
|
||||||
|
"realTimeMultiplier": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ def _migrate_legacy_keys(cfg: dict) -> tuple[dict, bool]:
|
|||||||
"artist_separator": "artistSeparator",
|
"artist_separator": "artistSeparator",
|
||||||
"recursive_quality": "recursiveQuality",
|
"recursive_quality": "recursiveQuality",
|
||||||
"spotify_metadata": "spotifyMetadata",
|
"spotify_metadata": "spotifyMetadata",
|
||||||
|
"real_time_multiplier": "realTimeMultiplier",
|
||||||
}
|
}
|
||||||
for legacy, camel in legacy_map.items():
|
for legacy, camel in legacy_map.items():
|
||||||
if legacy in out and camel not in out:
|
if legacy in out and camel not in out:
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ def get_config_params():
|
|||||||
),
|
),
|
||||||
"separateTracksByUser": config.get("separateTracksByUser", False),
|
"separateTracksByUser": config.get("separateTracksByUser", False),
|
||||||
"watch": config.get("watch", {}),
|
"watch": config.get("watch", {}),
|
||||||
|
"realTimeMultiplier": config.get(
|
||||||
|
"realTimeMultiplier", config.get("real_time_multiplier", 0)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading config for parameters: {e}")
|
logger.error(f"Error reading config for parameters: {e}")
|
||||||
@@ -96,6 +99,7 @@ def get_config_params():
|
|||||||
"recursiveQuality": False,
|
"recursiveQuality": False,
|
||||||
"separateTracksByUser": False,
|
"separateTracksByUser": False,
|
||||||
"watch": {},
|
"watch": {},
|
||||||
|
"realTimeMultiplier": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -363,7 +367,7 @@ class CeleryDownloadQueueManager:
|
|||||||
original_request = task.get(
|
original_request = task.get(
|
||||||
"orig_request", task.get("original_request", {})
|
"orig_request", task.get("original_request", {})
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get username for user-specific paths
|
# Get username for user-specific paths
|
||||||
username = task.get("username", "")
|
username = task.get("username", "")
|
||||||
|
|
||||||
@@ -389,9 +393,11 @@ class CeleryDownloadQueueManager:
|
|||||||
original_request.get("real_time"), config_params["realTime"]
|
original_request.get("real_time"), config_params["realTime"]
|
||||||
),
|
),
|
||||||
"custom_dir_format": self._get_user_specific_dir_format(
|
"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),
|
config_params.get("separateTracksByUser", False),
|
||||||
username
|
username,
|
||||||
),
|
),
|
||||||
"custom_track_format": original_request.get(
|
"custom_track_format": original_request.get(
|
||||||
"custom_track_format", config_params["customTrackFormat"]
|
"custom_track_format", config_params["customTrackFormat"]
|
||||||
@@ -419,6 +425,9 @@ class CeleryDownloadQueueManager:
|
|||||||
"retry_count": 0,
|
"retry_count": 0,
|
||||||
"original_request": original_request,
|
"original_request": original_request,
|
||||||
"created_at": time.time(),
|
"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
|
# 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):
|
def _get_user_specific_dir_format(self, base_format, separate_by_user, username):
|
||||||
"""
|
"""
|
||||||
Modify the directory format to include username if separateTracksByUser is enabled
|
Modify the directory format to include username if separateTracksByUser is enabled
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_format (str): The base directory format from config
|
base_format (str): The base directory format from config
|
||||||
separate_by_user (bool): Whether to separate tracks by user
|
separate_by_user (bool): Whether to separate tracks by user
|
||||||
username (str): The username to include in path
|
username (str): The username to include in path
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The modified directory format
|
str: The modified directory format
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1626,6 +1626,9 @@ def download_track(self, **task_data):
|
|||||||
spotify_metadata = task_data.get(
|
spotify_metadata = task_data.get(
|
||||||
"spotify_metadata", config_params.get("spotifyMetadata", True)
|
"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
|
# Execute the download - service is now determined from URL
|
||||||
download_track_func(
|
download_track_func(
|
||||||
@@ -1646,6 +1649,7 @@ def download_track(self, **task_data):
|
|||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
spotify_metadata=spotify_metadata,
|
spotify_metadata=spotify_metadata,
|
||||||
_is_celery_task_execution=True, # Skip duplicate check inside Celery task (consistency)
|
_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"}
|
return {"status": "success", "message": "Track download completed"}
|
||||||
@@ -1725,6 +1729,9 @@ def download_album(self, **task_data):
|
|||||||
spotify_metadata = task_data.get(
|
spotify_metadata = task_data.get(
|
||||||
"spotify_metadata", config_params.get("spotifyMetadata", True)
|
"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
|
# Execute the download - service is now determined from URL
|
||||||
download_album_func(
|
download_album_func(
|
||||||
@@ -1745,6 +1752,7 @@ def download_album(self, **task_data):
|
|||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
spotify_metadata=spotify_metadata,
|
spotify_metadata=spotify_metadata,
|
||||||
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
|
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "success", "message": "Album download completed"}
|
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)
|
"retry_delay_increase", config_params.get("retryDelayIncrease", 5)
|
||||||
)
|
)
|
||||||
max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3))
|
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
|
# Execute the download - service is now determined from URL
|
||||||
download_playlist_func(
|
download_playlist_func(
|
||||||
@@ -1856,6 +1867,7 @@ def download_playlist(self, **task_data):
|
|||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
spotify_metadata=spotify_metadata,
|
spotify_metadata=spotify_metadata,
|
||||||
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
|
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "success", "message": "Playlist download completed"}
|
return {"status": "success", "message": "Playlist download completed"}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ def get_spotify_info(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
spotify_id: The Spotify ID of the entity
|
spotify_id: The Spotify ID of the entity
|
||||||
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode)
|
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode, album_tracks)
|
||||||
limit (int, optional): The maximum number of items to return. Used for pagination.
|
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.
|
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":
|
elif spotify_type == "album":
|
||||||
return client.album(spotify_id)
|
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":
|
elif spotify_type == "playlist":
|
||||||
# Use optimized playlist fetching
|
# Use optimized playlist fetching
|
||||||
return get_playlist_full(spotify_id)
|
return get_playlist_full(spotify_id)
|
||||||
@@ -269,7 +275,10 @@ def get_spotify_info(
|
|||||||
elif spotify_type == "artist_discography":
|
elif spotify_type == "artist_discography":
|
||||||
# Get artist's albums with pagination
|
# Get artist's albums with pagination
|
||||||
albums = client.artist_albums(
|
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
|
return albums
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ def download_playlist(
|
|||||||
recursive_quality=True,
|
recursive_quality=True,
|
||||||
spotify_metadata=True,
|
spotify_metadata=True,
|
||||||
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
|
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
|
||||||
|
real_time_multiplier=None,
|
||||||
):
|
):
|
||||||
if not _is_celery_task_execution:
|
if not _is_celery_task_execution:
|
||||||
existing_task = get_existing_task_id(
|
existing_task = get_existing_task_id(
|
||||||
@@ -175,6 +176,7 @@ def download_playlist(
|
|||||||
convert_to=convert_to,
|
convert_to=convert_to,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"DEBUG: playlist.py - Spotify direct download (account: {main} for blob) successful."
|
f"DEBUG: playlist.py - Spotify direct download (account: {main} for blob) successful."
|
||||||
@@ -236,6 +238,7 @@ def download_playlist(
|
|||||||
convert_to=convert_to,
|
convert_to=convert_to,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"DEBUG: playlist.py - Direct Spotify download (account: {main} for blob) successful."
|
f"DEBUG: playlist.py - Direct Spotify download (account: {main} for blob) successful."
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def download_track(
|
|||||||
recursive_quality=False,
|
recursive_quality=False,
|
||||||
spotify_metadata=True,
|
spotify_metadata=True,
|
||||||
_is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check
|
_is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check
|
||||||
|
real_time_multiplier=None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Detect URL source (Spotify or Deezer) from URL
|
# Detect URL source (Spotify or Deezer) from URL
|
||||||
@@ -166,6 +167,7 @@ def download_track(
|
|||||||
convert_to=convert_to,
|
convert_to=convert_to,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful."
|
f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful."
|
||||||
@@ -222,6 +224,7 @@ def download_track(
|
|||||||
convert_to=convert_to,
|
convert_to=convert_to,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
artist_separator=artist_separator,
|
artist_separator=artist_separator,
|
||||||
|
real_time_multiplier=real_time_multiplier,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"DEBUG: track.py - Direct Spotify download (account: {main} for blob) successful."
|
f"DEBUG: track.py - Direct Spotify download (account: {main} for blob) successful."
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { useEffect, useState, useContext } from "react";
|
import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
||||||
import apiClient from "../lib/api-client";
|
import apiClient from "../lib/api-client";
|
||||||
import { QueueContext } from "../contexts/queue-context";
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
import { useSettings } from "../contexts/settings-context";
|
import { useSettings } from "../contexts/settings-context";
|
||||||
@@ -10,31 +10,91 @@ import { FaArrowLeft } from "react-icons/fa";
|
|||||||
export const Album = () => {
|
export const Album = () => {
|
||||||
const { albumId } = useParams({ from: "/album/$albumId" });
|
const { albumId } = useParams({ from: "/album/$albumId" });
|
||||||
const [album, setAlbum] = useState<AlbumType | null>(null);
|
const [album, setAlbum] = useState<AlbumType | null>(null);
|
||||||
|
const [tracks, setTracks] = useState<TrackType[]>([]);
|
||||||
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const context = useContext(QueueContext);
|
const context = useContext(QueueContext);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useQueue must be used within a QueueProvider");
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
}
|
}
|
||||||
const { addItem } = context;
|
const { addItem } = context;
|
||||||
|
|
||||||
|
const totalTracks = album?.total_tracks ?? 0;
|
||||||
|
const hasMore = tracks.length < totalTracks;
|
||||||
|
|
||||||
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAlbum = async () => {
|
const fetchAlbum = async () => {
|
||||||
|
if (!albumId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/album/info?id=${albumId}`);
|
const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=0`);
|
||||||
setAlbum(response.data);
|
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) {
|
} catch (err) {
|
||||||
setError("Failed to load album");
|
setError("Failed to load album");
|
||||||
console.error("Error fetching album:", err);
|
console.error("Error fetching album:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// reset state when albumId changes
|
||||||
|
setAlbum(null);
|
||||||
|
setTracks([]);
|
||||||
|
setOffset(0);
|
||||||
if (albumId) {
|
if (albumId) {
|
||||||
fetchAlbum();
|
fetchAlbum();
|
||||||
}
|
}
|
||||||
}, [albumId]);
|
}, [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) => {
|
const handleDownloadTrack = (track: TrackType) => {
|
||||||
if (!track.id) return;
|
if (!track.id) return;
|
||||||
toast.info(`Adding ${track.name} to queue...`);
|
toast.info(`Adding ${track.name} to queue...`);
|
||||||
@@ -51,7 +111,7 @@ export const Album = () => {
|
|||||||
return <div className="text-red-500">{error}</div>;
|
return <div className="text-red-500">{error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!album) {
|
if (!album || isLoading) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +127,7 @@ export const Album = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasExplicitTrack = album.tracks.items.some((track) => track.explicit);
|
const hasExplicitTrack = tracks.some((track) => track.explicit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
@@ -130,11 +190,11 @@ export const Album = () => {
|
|||||||
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark px-1">Tracks</h2>
|
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark px-1">Tracks</h2>
|
||||||
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
|
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
{album.tracks.items.map((track, index) => {
|
{tracks.map((track, index) => {
|
||||||
if (isExplicitFilterEnabled && track.explicit) {
|
if (isExplicitFilterEnabled && track.explicit) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={`${track.id || "explicit"}-${index}`}
|
||||||
className="flex items-center justify-between p-3 md:p-4 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
|
className="flex items-center justify-between p-3 md:p-4 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||||
@@ -147,7 +207,7 @@ export const Album = () => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={track.id}
|
key={track.id || `${index}`}
|
||||||
className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
|
className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||||
@@ -188,6 +248,13 @@ export const Album = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<div ref={loadMoreRef} />
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="p-3 text-center text-content-muted dark:text-content-muted-dark text-sm">Loading more...</div>
|
||||||
|
)}
|
||||||
|
{!hasMore && tracks.length > 0 && (
|
||||||
|
<div className="p-3 text-center text-content-muted dark:text-content-muted-dark text-sm">End of album</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user