BREAKING CHANGE: migrate api to librespot internal client

This commit is contained in:
Xoconoch
2025-08-27 10:06:33 -06:00
parent 443edd9c3d
commit 2de323a75f
17 changed files with 1035 additions and 933 deletions

View File

@@ -1,7 +1,7 @@
fastapi==0.116.1 fastapi==0.116.1
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
celery==5.5.3 celery==5.5.3
deezspot-spotizerr==2.7.7 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

View File

@@ -5,11 +5,12 @@ import uuid
import time import time
from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_client, get_album
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User from routes.auth.middleware import require_auth_from_state, User
# Config and credentials helpers
router = APIRouter() router = APIRouter()
@@ -34,7 +35,8 @@ async def handle_download(
# Fetch metadata from Spotify # Fetch metadata from Spotify
try: try:
album_info = get_spotify_info(album_id, "album") client = get_client()
album_info = get_album(client, album_id)
if ( if (
not album_info not album_info
or not album_info.get("name") or not album_info.get("name")
@@ -155,6 +157,7 @@ async def get_album_info(
""" """
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.
Returns the raw JSON from get_album in routes.utils.get_info.
""" """
spotify_id = request.query_params.get("id") spotify_id = request.query_params.get("id")
@@ -162,27 +165,9 @@ async def get_album_info(
return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400) return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400)
try: try:
# Optional pagination params for tracks client = get_client()
limit_param = request.query_params.get("limit") album_info = get_album(client, spotify_id)
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) 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)

View File

@@ -18,10 +18,9 @@ from routes.utils.watch.db import (
get_watched_artists, get_watched_artists,
add_specific_albums_to_artist_table, add_specific_albums_to_artist_table,
remove_specific_albums_from_artist_table, remove_specific_albums_from_artist_table,
is_album_in_artist_db,
) )
from routes.utils.watch.manager import check_watched_artists, get_watch_config from routes.utils.watch.manager import check_watched_artists, get_watch_config
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_client, get_artist, get_album
# Import authentication dependencies # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, User from routes.auth.middleware import require_auth_from_state, User
@@ -66,9 +65,6 @@ async def handle_artist_download(
) )
try: try:
# Import and call the updated download_artist_albums() function.
# from routes.utils.artist import download_artist_albums # Already imported at top
# Delegate to the download_artist_albums function which will handle album filtering # Delegate to the download_artist_albums function which will handle album filtering
successfully_queued_albums, duplicate_albums = download_artist_albums( successfully_queued_albums, duplicate_albums = download_artist_albums(
url=url, url=url,
@@ -118,13 +114,15 @@ async def cancel_artist_download():
@router.get("/info") @router.get("/info")
async def get_artist_info( async def get_artist_info(
request: Request, current_user: User = Depends(require_auth_from_state), request: Request,
limit: int = Query(10, ge=1), # default=10, must be >=1 current_user: User = Depends(require_auth_from_state),
offset: int = Query(0, ge=0) # default=0, must be >=0 limit: int = Query(10, ge=1), # default=10, must be >=1
offset: int = Query(0, ge=0), # default=0, must be >=0
): ):
""" """
Retrieves Spotify artist metadata given a Spotify artist ID. Retrieves Spotify artist metadata given a Spotify artist ID.
Expects a query parameter 'id' with the Spotify artist ID. Expects a query parameter 'id' with the Spotify artist ID.
Returns the raw JSON from get_artist in routes.utils.get_info.
""" """
spotify_id = request.query_params.get("id") spotify_id = request.query_params.get("id")
@@ -132,37 +130,8 @@ async def get_artist_info(
return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400) return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400)
try: try:
# Get artist metadata first client = get_client()
artist_metadata = get_spotify_info(spotify_id, "artist") artist_info = get_artist(client, spotify_id)
# Get artist discography for albums
artist_discography = get_spotify_info(spotify_id, "artist_discography", limit=limit, offset=offset)
# Combine metadata with discography
artist_info = {**artist_metadata, "albums": artist_discography}
# If artist_info is successfully fetched and has albums,
# check if the artist is watched and augment album items with is_locally_known status
if (
artist_info
and artist_info.get("albums")
and artist_info["albums"].get("items")
):
watched_artist_details = get_watched_artist(
spotify_id
) # spotify_id is the artist ID
if watched_artist_details: # Artist is being watched
for album_item in artist_info["albums"]["items"]:
if album_item and album_item.get("id"):
album_id = album_item["id"]
album_item["is_locally_known"] = is_album_in_artist_db(
spotify_id, album_id
)
elif album_item: # Album object exists but no ID
album_item["is_locally_known"] = False
# If not watched, or no albums, is_locally_known will not be added.
# Frontend should handle absence of this key as false.
return JSONResponse(content=artist_info, status_code=200) return JSONResponse(content=artist_info, status_code=200)
except Exception as e: except Exception as e:
return JSONResponse( return JSONResponse(
@@ -191,15 +160,9 @@ async def add_artist_to_watchlist(
if get_watched_artist(artist_spotify_id): if get_watched_artist(artist_spotify_id):
return {"message": f"Artist {artist_spotify_id} is already being watched."} return {"message": f"Artist {artist_spotify_id} is already being watched."}
# Get artist metadata directly for name and basic info client = get_client()
artist_metadata = get_spotify_info(artist_spotify_id, "artist") artist_metadata = get_artist(client, artist_spotify_id)
# Get artist discography for album count
artist_album_list_data = get_spotify_info(
artist_spotify_id, "artist_discography"
)
# Check if we got artist metadata
if not artist_metadata or not artist_metadata.get("name"): if not artist_metadata or not artist_metadata.get("name"):
logger.error( logger.error(
f"Could not fetch artist metadata for {artist_spotify_id} from Spotify." f"Could not fetch artist metadata for {artist_spotify_id} from Spotify."
@@ -211,24 +174,22 @@ async def add_artist_to_watchlist(
}, },
) )
# Check if we got album data # Derive a rough total album count from groups if present
if not artist_album_list_data or not isinstance( total_albums = 0
artist_album_list_data.get("items"), list for key in (
"album_group",
"single_group",
"compilation_group",
"appears_on_group",
): ):
logger.warning( grp = artist_metadata.get(key)
f"Could not fetch album list details for artist {artist_spotify_id} from Spotify. Proceeding with metadata only." if isinstance(grp, list):
) total_albums += len(grp)
# Construct the artist_data object expected by add_artist_db
artist_data_for_db = { artist_data_for_db = {
"id": artist_spotify_id, "id": artist_spotify_id,
"name": artist_metadata.get("name", "Unknown Artist"), "name": artist_metadata.get("name", "Unknown Artist"),
"albums": { # Mimic structure if add_artist_db expects it for total_albums "albums": {"total": total_albums},
"total": artist_album_list_data.get("total", 0)
if artist_album_list_data
else 0
},
# Add any other fields add_artist_db might expect from a true artist object if necessary
} }
add_artist_db(artist_data_for_db) add_artist_db(artist_data_for_db)
@@ -446,21 +407,25 @@ async def mark_albums_as_known_for_artist(
detail={"error": f"Artist {artist_spotify_id} is not being watched."}, detail={"error": f"Artist {artist_spotify_id} is not being watched."},
) )
client = get_client()
fetched_albums_details = [] fetched_albums_details = []
for album_id in album_ids: try:
try: for album_id in album_ids:
# We need full album details. get_spotify_info with type "album" should provide this. try:
album_detail = get_spotify_info(album_id, "album") album_detail = get_album(client, album_id)
if album_detail and album_detail.get("id"): if album_detail and album_detail.get("id"):
fetched_albums_details.append(album_detail) fetched_albums_details.append(album_detail)
else: else:
logger.warning( logger.warning(
f"Could not fetch details for album {album_id} when marking as known for artist {artist_spotify_id}." f"Could not fetch details for album {album_id} when marking as known for artist {artist_spotify_id}."
)
except Exception as e:
logger.error(
f"Failed to fetch Spotify details for album {album_id}: {e}"
) )
except Exception as e: finally:
logger.error( # No need to close_client here, as get_client is shared
f"Failed to fetch Spotify details for album {album_id}: {e}" pass
)
if not fetched_albums_details: if not fetched_albums_details:
return { return {

View File

@@ -1,32 +1,46 @@
import re import re
from typing import List, Dict, Any from typing import List
from fastapi import APIRouter, HTTPException from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
import logging import logging
# Assuming these imports are available for queue management and Spotify info # Assuming these imports are available for queue management and Spotify info
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import (
get_client,
get_track,
get_album,
get_playlist,
get_artist,
)
from routes.utils.celery_tasks import download_track, download_album, download_playlist from routes.utils.celery_tasks import download_track, download_album, download_playlist
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BulkAddLinksRequest(BaseModel): class BulkAddLinksRequest(BaseModel):
links: List[str] links: List[str]
@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):
added_count = 0 added_count = 0
failed_links = [] failed_links = []
total_links = len(request.links) total_links = len(request.links)
client = get_client()
for link in request.links: for link in request.links:
# Assuming links are pre-filtered by the frontend, # Assuming links are pre-filtered by the frontend,
# 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(r"https://open\.spotify\.com(?:/intl-[a-z]{2})?/(track|album|playlist|artist)/([a-zA-Z0-9]+)(?:\?.*)?", link) match = re.match(
r"https://open\.spotify\.com(?:/intl-[a-z]{2})?/(track|album|playlist|artist)/([a-zA-Z0-9]+)(?:\?.*)?",
link,
)
if not match: if not match:
logger.warning(f"Could not parse Spotify link (unexpected format after frontend filter): {link}") logger.warning(
f"Could not parse Spotify link (unexpected format after frontend filter): {link}"
)
failed_links.append(link) failed_links.append(link)
continue continue
@@ -35,18 +49,30 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest):
try: try:
# Get basic info to confirm existence and get name/artist # Get basic info to confirm existence and get name/artist
# For playlists, we might want to get full info later when adding to queue
if spotify_type == "playlist": if spotify_type == "playlist":
item_info = get_spotify_info(spotify_id, "playlist_metadata") item_info = get_playlist(client, spotify_id, expand_items=False)
elif spotify_type == "track":
item_info = get_track(client, spotify_id)
elif spotify_type == "album":
item_info = get_album(client, spotify_id)
elif spotify_type == "artist":
# Not queued below, but fetch to validate link and name if needed
item_info = get_artist(client, spotify_id)
else: else:
item_info = get_spotify_info(spotify_id, spotify_type) logger.warning(
f"Unsupported Spotify type: {spotify_type} for link: {link}"
)
failed_links.append(link)
continue
item_name = item_info.get("name", "Unknown Name") item_name = item_info.get("name", "Unknown Name")
artist_name = "" artist_name = ""
if spotify_type in ["track", "album"]: if spotify_type in ["track", "album"]:
artists = item_info.get("artists", []) artists = item_info.get("artists", [])
if artists: if artists:
artist_name = ", ".join([a.get("name", "Unknown Artist") for a in artists]) artist_name = ", ".join(
[a.get("name", "Unknown Artist") for a in artists]
)
elif spotify_type == "playlist": elif spotify_type == "playlist":
owner = item_info.get("owner", {}) owner = item_info.get("owner", {})
artist_name = owner.get("display_name", "Unknown Owner") artist_name = owner.get("display_name", "Unknown Owner")
@@ -83,12 +109,16 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest):
download_type="playlist", download_type="playlist",
) )
else: else:
logger.warning(f"Unsupported Spotify type for download: {spotify_type} for link: {link}") logger.warning(
f"Unsupported Spotify type for download: {spotify_type} for link: {link}"
)
failed_links.append(link) failed_links.append(link)
continue continue
added_count += 1 added_count += 1
logger.debug(f"Added {added_count+1}/{total_links} {spotify_type} '{item_name}' ({spotify_id}) to queue.") 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)
@@ -105,4 +135,4 @@ async def bulk_add_spotify_links(request: BulkAddLinksRequest):
"message": message, "message": message,
"count": added_count, "count": added_count,
"failed_links": failed_links, "failed_links": failed_links,
} }

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, HTTPException, Request, Depends from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import json
import traceback import traceback
import logging # Added logging import import logging # Added logging import
import uuid # For generating error task IDs import uuid # For generating error task IDs
@@ -20,10 +19,9 @@ from routes.utils.watch.db import (
get_watched_playlist, get_watched_playlist,
get_watched_playlists, get_watched_playlists,
add_specific_tracks_to_playlist_table, add_specific_tracks_to_playlist_table,
remove_specific_tracks_from_playlist_table, remove_specific_tracks_from_playlist_table, # Added import
is_track_in_playlist_db, # Added import
) )
from routes.utils.get_info import get_spotify_info # Already used, but ensure it's here from routes.utils.get_info import get_client, get_playlist, get_track
from routes.utils.watch.manager import ( from routes.utils.watch.manager import (
check_watched_playlists, check_watched_playlists,
get_watch_config, get_watch_config,
@@ -31,7 +29,9 @@ from routes.utils.watch.manager import (
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies # Import authentication dependencies
from routes.auth.middleware import require_auth_from_state, require_admin_from_state, User from routes.auth.middleware import require_auth_from_state, User
from routes.utils.celery_config import get_config_params
from routes.utils.credentials import get_spotify_blob_path
logger = logging.getLogger(__name__) # Added logger initialization logger = logging.getLogger(__name__) # Added logger initialization
router = APIRouter() router = APIRouter()
@@ -43,7 +43,11 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{playlist_id}") @router.get("/download/{playlist_id}")
async def handle_download(playlist_id: str, request: Request, current_user: User = Depends(require_auth_from_state)): async def handle_download(
playlist_id: str,
request: Request,
current_user: User = Depends(require_auth_from_state),
):
# Retrieve essential parameters from the request. # Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed # name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed # artist = request.args.get('artist') # Removed
@@ -51,11 +55,14 @@ async def handle_download(playlist_id: str, request: Request, current_user: User
# Construct the URL from playlist_id # Construct the URL from playlist_id
url = construct_spotify_url(playlist_id, "playlist") url = construct_spotify_url(playlist_id, "playlist")
orig_params["original_url"] = str(request.url) # Update original_url to the constructed one orig_params["original_url"] = str(
request.url
) # Update original_url to the constructed one
# Fetch metadata from Spotify using optimized function # Fetch metadata from Spotify using optimized function
try: try:
from routes.utils.get_info import get_playlist_metadata from routes.utils.get_info import get_playlist_metadata
playlist_info = get_playlist_metadata(playlist_id) playlist_info = get_playlist_metadata(playlist_id)
if ( if (
not playlist_info not playlist_info
@@ -66,7 +73,7 @@ async def handle_download(playlist_id: str, request: Request, current_user: User
content={ content={
"error": f"Could not retrieve metadata for playlist ID: {playlist_id}" "error": f"Could not retrieve metadata for playlist ID: {playlist_id}"
}, },
status_code=404 status_code=404,
) )
name_from_spotify = playlist_info.get("name") name_from_spotify = playlist_info.get("name")
@@ -79,14 +86,13 @@ async def handle_download(playlist_id: str, request: Request, current_user: User
content={ content={
"error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}" "error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}"
}, },
status_code=500 status_code=500,
) )
# Validate required parameters # Validate required parameters
if not url: # This check might be redundant now but kept for safety if not url: # This check might be redundant now but kept for safety
return JSONResponse( return JSONResponse(
content={"error": "Missing required parameter: url"}, content={"error": "Missing required parameter: url"}, status_code=400
status_code=400
) )
try: try:
@@ -106,7 +112,7 @@ async def handle_download(playlist_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
@@ -136,25 +142,23 @@ async def handle_download(playlist_id: str, request: Request, current_user: User
"error": f"Failed to queue playlist download: {str(e)}", "error": f"Failed to queue playlist download: {str(e)}",
"task_id": error_task_id, "task_id": error_task_id,
}, },
status_code=500 status_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 playlist download process by its task id. Cancel a running playlist 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 task id (task_id) parameter"}, content={"error": "Missing task id (task_id) parameter"}, status_code=400
status_code=400
) )
# Use the queue manager's cancellation method. # Use the queue manager's cancellation method.
@@ -165,124 +169,94 @@ async def cancel_download(request: Request, current_user: User = Depends(require
@router.get("/info") @router.get("/info")
async def get_playlist_info(request: Request, current_user: User = Depends(require_auth_from_state)): async def get_playlist_info(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Retrieve Spotify playlist metadata given a Spotify playlist ID. Retrieve Spotify playlist metadata given a Spotify playlist ID.
Expects a query parameter 'id' that contains the Spotify playlist ID. Expects a query parameter 'id' that contains the Spotify playlist ID.
""" Always returns the raw JSON from get_playlist with expand_items=False.
spotify_id = request.query_params.get("id")
include_tracks = request.query_params.get("include_tracks", "false").lower() == "true"
if not spotify_id:
return JSONResponse(
content={"error": "Missing parameter: id"},
status_code=400
)
try:
# Use the optimized playlist info function
from routes.utils.get_info import get_playlist_info_optimized
playlist_info = get_playlist_info_optimized(spotify_id, include_tracks=include_tracks)
# If playlist_info is successfully fetched, check if it's watched
# and augment track items with is_locally_known status
if playlist_info and playlist_info.get("id"):
watched_playlist_details = get_watched_playlist(playlist_info["id"])
if watched_playlist_details: # Playlist is being watched
if playlist_info.get("tracks") and playlist_info["tracks"].get("items"):
for item in playlist_info["tracks"]["items"]:
if item and item.get("track") and item["track"].get("id"):
track_id = item["track"]["id"]
item["track"]["is_locally_known"] = is_track_in_playlist_db(
playlist_info["id"], track_id
)
elif item and item.get(
"track"
): # Track object exists but no ID
item["track"]["is_locally_known"] = False
# If not watched, or no tracks, is_locally_known will not be added, or tracks won't exist to add it to.
# Frontend should handle absence of this key as false.
return JSONResponse(
content=playlist_info, status_code=200
)
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
return JSONResponse(content=error_data, status_code=500)
@router.get("/metadata")
async def get_playlist_metadata(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve only Spotify playlist metadata (no tracks) to avoid rate limiting.
Expects a query parameter 'id' that contains the Spotify playlist ID.
""" """
spotify_id = request.query_params.get("id") 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 optimized playlist metadata function # Resolve active account's credentials blob
from routes.utils.get_info import get_playlist_metadata cfg = get_config_params() or {}
playlist_metadata = get_playlist_metadata(spotify_id) active_account = cfg.get("spotify")
if not active_account:
return JSONResponse(
content={"error": "Active Spotify account not set in configuration."},
status_code=500,
)
blob_path = get_spotify_blob_path(active_account)
if not blob_path.exists():
return JSONResponse(
content={
"error": f"Spotify credentials blob not found for account '{active_account}'"
},
status_code=500,
)
return JSONResponse( client = get_client()
content=playlist_metadata, status_code=200 try:
) playlist_info = get_playlist(client, spotify_id, expand_items=False)
except Exception as e: finally:
error_data = {"error": str(e), "traceback": traceback.format_exc()} pass
return JSONResponse(content=error_data, status_code=500)
return JSONResponse(content=playlist_info, status_code=200)
@router.get("/tracks")
async def get_playlist_tracks(request: Request, current_user: User = Depends(require_auth_from_state)):
"""
Retrieve playlist tracks with pagination support for progressive loading.
Expects query parameters: 'id' (playlist ID), 'limit' (optional), 'offset' (optional).
"""
spotify_id = request.query_params.get("id")
limit = int(request.query_params.get("limit", 50))
offset = int(request.query_params.get("offset", 0))
if not spotify_id:
return JSONResponse(
content={"error": "Missing parameter: id"},
status_code=400
)
try:
# Use the optimized playlist tracks function
from routes.utils.get_info import get_playlist_tracks
tracks_data = get_playlist_tracks(spotify_id, limit=limit, offset=offset)
return JSONResponse(
content=tracks_data, status_code=200
)
except Exception as e: 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)
@router.put("/watch/{playlist_spotify_id}") @router.put("/watch/{playlist_spotify_id}")
async def add_to_watchlist(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)): async def add_to_watchlist(
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
):
"""Adds a playlist to the watchlist.""" """Adds a playlist to the watchlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
raise HTTPException(status_code=403, detail={"error": "Watch feature is currently disabled globally."}) raise HTTPException(
status_code=403,
detail={"error": "Watch feature is currently disabled globally."},
)
logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.") logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.")
try: try:
# Check if already watched # Check if already watched
if get_watched_playlist(playlist_spotify_id): if get_watched_playlist(playlist_spotify_id):
return {"message": f"Playlist {playlist_spotify_id} is already being watched."} return {
"message": f"Playlist {playlist_spotify_id} is already being watched."
}
# Fetch playlist details from Spotify to populate our DB (metadata only)
cfg = get_config_params() or {}
active_account = cfg.get("spotify")
if not active_account:
raise HTTPException(
status_code=500,
detail={"error": "Active Spotify account not set in configuration."},
)
blob_path = get_spotify_blob_path(active_account)
if not blob_path.exists():
raise HTTPException(
status_code=500,
detail={
"error": f"Spotify credentials blob not found for account '{active_account}'"
},
)
client = get_client()
try:
playlist_data = get_playlist(
client, playlist_spotify_id, expand_items=False
)
finally:
pass
# Fetch playlist details from Spotify to populate our DB
from routes.utils.get_info import get_playlist_metadata
playlist_data = get_playlist_metadata(playlist_spotify_id)
if not playlist_data or "id" not in playlist_data: if not playlist_data or "id" not in playlist_data:
logger.error( logger.error(
f"Could not fetch details for playlist {playlist_spotify_id} from Spotify." f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."
@@ -291,19 +265,11 @@ async def add_to_watchlist(playlist_spotify_id: str, current_user: User = Depend
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 details for playlist {playlist_spotify_id} from Spotify."
} },
) )
add_playlist_db(playlist_data) # This also creates the tracks table add_playlist_db(playlist_data) # This also creates the tracks table
# REMOVED: Do not add initial tracks directly to DB.
# The playlist watch manager will pick them up as new and queue downloads.
# Tracks will be added to DB only after successful download via Celery task callback.
# initial_track_items = playlist_data.get('tracks', {}).get('items', [])
# if initial_track_items:
# from routes.utils.watch.db import add_tracks_to_playlist_db # Keep local import for clarity
# add_tracks_to_playlist_db(playlist_spotify_id, initial_track_items)
logger.info( logger.info(
f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager." f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager."
) )
@@ -317,11 +283,16 @@ async def add_to_watchlist(playlist_spotify_id: str, current_user: User = Depend
f"Error adding playlist {playlist_spotify_id} to watchlist: {e}", f"Error adding playlist {playlist_spotify_id} to watchlist: {e}",
exc_info=True, exc_info=True,
) )
raise HTTPException(status_code=500, detail={"error": f"Could not add playlist to watchlist: {str(e)}"}) raise HTTPException(
status_code=500,
detail={"error": f"Could not add playlist to watchlist: {str(e)}"},
)
@router.get("/watch/{playlist_spotify_id}/status") @router.get("/watch/{playlist_spotify_id}/status")
async def get_playlist_watch_status(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)): async def get_playlist_watch_status(
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
):
"""Checks if a specific playlist is being watched.""" """Checks if a specific playlist is being watched."""
logger.info(f"Checking watch status for playlist {playlist_spotify_id}.") logger.info(f"Checking watch status for playlist {playlist_spotify_id}.")
try: try:
@@ -337,22 +308,31 @@ async def get_playlist_watch_status(playlist_spotify_id: str, current_user: User
f"Error checking watch status for playlist {playlist_spotify_id}: {e}", f"Error checking watch status for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
raise HTTPException(status_code=500, detail={"error": f"Could not check watch status: {str(e)}"}) raise HTTPException(
status_code=500, detail={"error": f"Could not check watch status: {str(e)}"}
)
@router.delete("/watch/{playlist_spotify_id}") @router.delete("/watch/{playlist_spotify_id}")
async def remove_from_watchlist(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)): async def remove_from_watchlist(
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
):
"""Removes a playlist from the watchlist.""" """Removes a playlist from the watchlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
raise HTTPException(status_code=403, detail={"error": "Watch feature is currently disabled globally."}) raise HTTPException(
status_code=403,
detail={"error": "Watch feature is currently disabled globally."},
)
logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.") logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.")
try: try:
if not get_watched_playlist(playlist_spotify_id): if not get_watched_playlist(playlist_spotify_id):
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail={"error": f"Playlist {playlist_spotify_id} not found in watchlist."} detail={
"error": f"Playlist {playlist_spotify_id} not found in watchlist."
},
) )
remove_playlist_db(playlist_spotify_id) remove_playlist_db(playlist_spotify_id)
@@ -369,12 +349,16 @@ async def remove_from_watchlist(playlist_spotify_id: str, current_user: User = D
) )
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail={"error": f"Could not remove playlist from watchlist: {str(e)}"} detail={"error": f"Could not remove playlist from watchlist: {str(e)}"},
) )
@router.post("/watch/{playlist_spotify_id}/tracks") @router.post("/watch/{playlist_spotify_id}/tracks")
async def mark_tracks_as_known(playlist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)): async def mark_tracks_as_known(
playlist_spotify_id: str,
request: Request,
current_user: User = Depends(require_auth_from_state),
):
"""Fetches details for given track IDs and adds/updates them in the playlist's local DB table.""" """Fetches details for given track IDs and adds/updates them in the playlist's local DB table."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
@@ -382,7 +366,7 @@ async def mark_tracks_as_known(playlist_spotify_id: str, request: Request, curre
status_code=403, status_code=403,
detail={ detail={
"error": "Watch feature is currently disabled globally. Cannot mark tracks." "error": "Watch feature is currently disabled globally. Cannot mark tracks."
} },
) )
logger.info( logger.info(
@@ -397,19 +381,22 @@ async def mark_tracks_as_known(playlist_spotify_id: str, request: Request, curre
status_code=400, status_code=400,
detail={ detail={
"error": "Invalid request body. Expecting a JSON array of track Spotify IDs." "error": "Invalid request body. Expecting a JSON array of track Spotify IDs."
} },
) )
if not get_watched_playlist(playlist_spotify_id): if not get_watched_playlist(playlist_spotify_id):
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail={"error": f"Playlist {playlist_spotify_id} is not being watched."} detail={
"error": f"Playlist {playlist_spotify_id} is not being watched."
},
) )
fetched_tracks_details = [] fetched_tracks_details = []
client = get_client()
for track_id in track_ids: for track_id in track_ids:
try: try:
track_detail = get_spotify_info(track_id, "track") track_detail = get_track(client, track_id)
if track_detail and track_detail.get("id"): if track_detail and track_detail.get("id"):
fetched_tracks_details.append(track_detail) fetched_tracks_details.append(track_detail)
else: else:
@@ -443,11 +430,18 @@ async def mark_tracks_as_known(playlist_spotify_id: str, request: Request, curre
f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}", f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
raise HTTPException(status_code=500, detail={"error": f"Could not mark tracks as known: {str(e)}"}) raise HTTPException(
status_code=500,
detail={"error": f"Could not mark tracks as known: {str(e)}"},
)
@router.delete("/watch/{playlist_spotify_id}/tracks") @router.delete("/watch/{playlist_spotify_id}/tracks")
async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Request, current_user: User = Depends(require_auth_from_state)): async def mark_tracks_as_missing_locally(
playlist_spotify_id: str,
request: Request,
current_user: User = Depends(require_auth_from_state),
):
"""Removes specified tracks from the playlist's local DB table.""" """Removes specified tracks from the playlist's local DB table."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
@@ -455,7 +449,7 @@ async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Requ
status_code=403, status_code=403,
detail={ detail={
"error": "Watch feature is currently disabled globally. Cannot mark tracks." "error": "Watch feature is currently disabled globally. Cannot mark tracks."
} },
) )
logger.info( logger.info(
@@ -470,13 +464,15 @@ async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Requ
status_code=400, status_code=400,
detail={ detail={
"error": "Invalid request body. Expecting a JSON array of track Spotify IDs." "error": "Invalid request body. Expecting a JSON array of track Spotify IDs."
} },
) )
if not get_watched_playlist(playlist_spotify_id): if not get_watched_playlist(playlist_spotify_id):
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail={"error": f"Playlist {playlist_spotify_id} is not being watched."} detail={
"error": f"Playlist {playlist_spotify_id} is not being watched."
},
) )
deleted_count = remove_specific_tracks_from_playlist_table( deleted_count = remove_specific_tracks_from_playlist_table(
@@ -495,22 +491,32 @@ async def mark_tracks_as_missing_locally(playlist_spotify_id: str, request: Requ
f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}", f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
raise HTTPException(status_code=500, detail={"error": f"Could not mark tracks as missing: {str(e)}"}) raise HTTPException(
status_code=500,
detail={"error": f"Could not mark tracks as missing: {str(e)}"},
)
@router.get("/watch/list") @router.get("/watch/list")
async def list_watched_playlists_endpoint(current_user: User = Depends(require_auth_from_state)): async def list_watched_playlists_endpoint(
current_user: User = Depends(require_auth_from_state),
):
"""Lists all playlists currently in the watchlist.""" """Lists all playlists currently in the watchlist."""
try: try:
playlists = get_watched_playlists() playlists = get_watched_playlists()
return playlists return playlists
except Exception as e: except Exception as e:
logger.error(f"Error listing watched playlists: {e}", exc_info=True) logger.error(f"Error listing watched playlists: {e}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": f"Could not list watched playlists: {str(e)}"}) raise HTTPException(
status_code=500,
detail={"error": f"Could not list watched playlists: {str(e)}"},
)
@router.post("/watch/trigger_check") @router.post("/watch/trigger_check")
async def trigger_playlist_check_endpoint(current_user: User = Depends(require_auth_from_state)): async def trigger_playlist_check_endpoint(
current_user: User = Depends(require_auth_from_state),
):
"""Manually triggers the playlist checking mechanism for all watched playlists.""" """Manually triggers the playlist checking mechanism for all watched playlists."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
@@ -518,7 +524,7 @@ async def trigger_playlist_check_endpoint(current_user: User = Depends(require_a
status_code=403, status_code=403,
detail={ detail={
"error": "Watch feature is currently disabled globally. Cannot trigger check." "error": "Watch feature is currently disabled globally. Cannot trigger check."
} },
) )
logger.info("Manual trigger for playlist check received for all playlists.") logger.info("Manual trigger for playlist check received for all playlists.")
@@ -535,12 +541,14 @@ async def trigger_playlist_check_endpoint(current_user: User = Depends(require_a
) )
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail={"error": f"Could not trigger playlist check for all: {str(e)}"} detail={"error": f"Could not trigger playlist check for all: {str(e)}"},
) )
@router.post("/watch/trigger_check/{playlist_spotify_id}") @router.post("/watch/trigger_check/{playlist_spotify_id}")
async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)): async def trigger_specific_playlist_check_endpoint(
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
):
"""Manually triggers the playlist checking mechanism for a specific playlist.""" """Manually triggers the playlist checking mechanism for a specific playlist."""
watch_config = get_watch_config() watch_config = get_watch_config()
if not watch_config.get("enabled", False): if not watch_config.get("enabled", False):
@@ -548,7 +556,7 @@ async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str, cur
status_code=403, status_code=403,
detail={ detail={
"error": "Watch feature is currently disabled globally. Cannot trigger check." "error": "Watch feature is currently disabled globally. Cannot trigger check."
} },
) )
logger.info( logger.info(
@@ -565,7 +573,7 @@ async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str, cur
status_code=404, status_code=404,
detail={ detail={
"error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first." "error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first."
} },
) )
# Run check_watched_playlists with the specific ID # Run check_watched_playlists with the specific ID
@@ -590,5 +598,5 @@ async def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str, cur
status_code=500, status_code=500,
detail={ detail={
"error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}" "error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}"
} },
) )

View File

@@ -1,12 +1,11 @@
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
from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
from routes.utils.get_info import get_spotify_info from routes.utils.get_info import get_client, get_track
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
# Import authentication dependencies # Import authentication dependencies
@@ -21,7 +20,11 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{track_id}") @router.get("/download/{track_id}")
async def handle_download(track_id: str, request: Request, current_user: User = Depends(require_auth_from_state)): async def handle_download(
track_id: str,
request: Request,
current_user: User = Depends(require_auth_from_state),
):
# Retrieve essential parameters from the request. # Retrieve essential parameters from the request.
# name = request.args.get('name') # Removed # name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed # artist = request.args.get('artist') # Removed
@@ -31,15 +34,18 @@ async def handle_download(track_id: str, request: Request, current_user: User =
# Fetch metadata from Spotify # Fetch metadata from Spotify
try: try:
track_info = get_spotify_info(track_id, "track") client = get_client()
track_info = get_track(client, track_id)
if ( if (
not track_info not track_info
or not track_info.get("name") or not track_info.get("name")
or not track_info.get("artists") or not track_info.get("artists")
): ):
return JSONResponse( return JSONResponse(
content={"error": f"Could not retrieve metadata for track ID: {track_id}"}, content={
status_code=404 "error": f"Could not retrieve metadata for track ID: {track_id}"
},
status_code=404,
) )
name_from_spotify = track_info.get("name") name_from_spotify = track_info.get("name")
@@ -51,15 +57,16 @@ async def handle_download(track_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 track {track_id}: {str(e)}"}, content={
status_code=500 "error": f"Failed to fetch metadata for track {track_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 +91,7 @@ async def handle_download(track_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 +123,23 @@ async def handle_download(track_id: str, request: Request, current_user: User =
"error": f"Failed to queue track download: {str(e)}", "error": f"Failed to queue track 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 +150,9 @@ async def cancel_download(request: Request, current_user: User = Depends(require
@router.get("/info") @router.get("/info")
async def get_track_info(request: Request, current_user: User = Depends(require_auth_from_state)): async def get_track_info(
request: Request, current_user: User = Depends(require_auth_from_state)
):
""" """
Retrieve Spotify track metadata given a Spotify track ID. Retrieve Spotify track metadata given a Spotify track ID.
Expects a query parameter 'id' that contains the Spotify track ID. Expects a query parameter 'id' that contains the Spotify track ID.
@@ -153,14 +160,11 @@ async def get_track_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) client = get_client()
track_info = get_spotify_info(spotify_id, "track") track_info = get_track(client, spotify_id)
return JSONResponse(content=track_info, status_code=200) return JSONResponse(content=track_info, status_code=200)
except Exception as e: except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()} error_data = {"error": str(e), "traceback": traceback.format_exc()}

View File

@@ -2,9 +2,9 @@ import json
from routes.utils.watch.manager import get_watch_config from routes.utils.watch.manager import get_watch_config
import logging 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.get_info import get_spotify_info
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
from routes.utils.errors import DuplicateDownloadError from routes.utils.errors import DuplicateDownloadError
from routes.utils.get_info import get_spotify_info
from deezspot.libutils.utils import get_ids, link_is_valid from deezspot.libutils.utils import get_ids, link_is_valid

View File

@@ -1,422 +1,152 @@
import spotipy import os
from spotipy.oauth2 import SpotifyClientCredentials from typing import Any, Dict, Optional
from routes.utils.credentials import _get_global_spotify_api_creds import threading
import logging
import time
from typing import Dict, Optional, Any
# Import Deezer API and logging from deezspot.libutils import LibrespotClient
from deezspot.deezloader.dee_api import API as DeezerAPI
# Initialize logger # Config helpers to resolve active credentials
logger = logging.getLogger(__name__) from routes.utils.celery_config import get_config_params
from routes.utils.credentials import get_spotify_blob_path
# Global Spotify client instance for reuse
_spotify_client = None
_last_client_init = 0
_client_init_interval = 3600 # Reinitialize client every hour
def _get_spotify_client(): # -------- Shared Librespot client (process-wide) --------
"""
Get or create a Spotify client with global credentials.
Implements client reuse and periodic reinitialization.
"""
global _spotify_client, _last_client_init
current_time = time.time() _shared_client: Optional[LibrespotClient] = None
_shared_blob_path: Optional[str] = None
_client_lock = threading.RLock()
# Reinitialize client if it's been more than an hour or if client doesn't exist
if (
_spotify_client is None
or current_time - _last_client_init > _client_init_interval
):
client_id, client_secret = _get_global_spotify_api_creds()
if not client_id or not client_secret: def _resolve_blob_path() -> str:
raise ValueError( cfg = get_config_params() or {}
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json." active_account = cfg.get("spotify")
) if not active_account:
raise RuntimeError("Active Spotify account not set in configuration.")
# Create new client blob_path = get_spotify_blob_path(active_account)
_spotify_client = spotipy.Spotify( abs_path = os.path.abspath(str(blob_path))
client_credentials_manager=SpotifyClientCredentials( if not os.path.isfile(abs_path):
client_id=client_id, client_secret=client_secret raise FileNotFoundError(
) f"Spotify credentials blob not found for account '{active_account}' at {abs_path}"
) )
_last_client_init = current_time return abs_path
logger.info("Spotify client initialized/reinitialized")
return _spotify_client
def _rate_limit_handler(func): def get_client() -> LibrespotClient:
""" """
Decorator to handle rate limiting with exponential backoff. Return a shared LibrespotClient instance initialized from the active account blob.
Re-initializes if the active account changes.
""" """
global _shared_client, _shared_blob_path
def wrapper(*args, **kwargs): with _client_lock:
max_retries = 3 desired_blob = _resolve_blob_path()
base_delay = 1 if _shared_client is None or _shared_blob_path != desired_blob:
for attempt in range(max_retries):
try: try:
return func(*args, **kwargs) if _shared_client is not None:
except Exception as e: _shared_client.close()
if "429" in str(e) or "rate limit" in str(e).lower(): except Exception:
if attempt < max_retries - 1: pass
delay = base_delay * (2**attempt) _shared_client = LibrespotClient(stored_credentials_path=desired_blob)
logger.warning(f"Rate limited, retrying in {delay} seconds...") _shared_blob_path = desired_blob
time.sleep(delay) return _shared_client
continue
raise e
return func(*args, **kwargs)
return wrapper
@_rate_limit_handler # -------- Thin wrapper API (programmatic use) --------
def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]:
def create_client(credentials_path: str) -> LibrespotClient:
""" """
Get playlist metadata only (no tracks) to avoid rate limiting. Create a LibrespotClient from a librespot-generated credentials.json file.
Args:
playlist_id: The Spotify playlist ID
Returns:
Dictionary with playlist metadata (name, description, owner, etc.)
""" """
client = _get_spotify_client() abs_path = os.path.abspath(credentials_path)
if not os.path.isfile(abs_path):
try: raise FileNotFoundError(f"Credentials file not found: {abs_path}")
# Get basic playlist info without tracks return LibrespotClient(stored_credentials_path=abs_path)
playlist = client.playlist(
playlist_id,
fields="id,name,description,owner,images,snapshot_id,public,followers,tracks.total",
)
# Add a flag to indicate this is metadata only
playlist["_metadata_only"] = True
playlist["_tracks_loaded"] = False
logger.debug(
f"Retrieved playlist metadata for {playlist_id}: {playlist.get('name', 'Unknown')}"
)
return playlist
except Exception as e:
logger.error(f"Error fetching playlist metadata for {playlist_id}: {e}")
raise
@_rate_limit_handler def close_client(client: LibrespotClient) -> None:
def get_playlist_tracks( """
playlist_id: str, limit: int = 100, offset: int = 0 Dispose a LibrespotClient instance.
"""
client.close()
def get_track(client: LibrespotClient, track_in: str) -> Dict[str, Any]:
"""Fetch a track object."""
return client.get_track(track_in)
def get_album(
client: LibrespotClient, album_in: str, include_tracks: bool = False
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """Fetch an album object; optionally include expanded tracks."""
Get playlist tracks with pagination support to handle large playlists efficiently. return client.get_album(album_in, include_tracks=include_tracks)
Args:
playlist_id: The Spotify playlist ID
limit: Number of tracks to fetch per request (max 100)
offset: Starting position for pagination
Returns:
Dictionary with tracks data
"""
client = _get_spotify_client()
try:
# Get tracks with specified limit and offset
tracks_data = client.playlist_tracks(
playlist_id,
limit=min(limit, 100), # Spotify API max is 100
offset=offset,
fields="items(track(id,name,artists,album,external_urls,preview_url,duration_ms,explicit,popularity)),total,limit,offset",
)
logger.debug(
f"Retrieved {len(tracks_data.get('items', []))} tracks for playlist {playlist_id} (offset: {offset})"
)
return tracks_data
except Exception as e:
logger.error(f"Error fetching playlist tracks for {playlist_id}: {e}")
raise
@_rate_limit_handler def get_artist(client: LibrespotClient, artist_in: str) -> Dict[str, Any]:
def get_playlist_full(playlist_id: str, batch_size: int = 100) -> Dict[str, Any]: """Fetch an artist object."""
""" return client.get_artist(artist_in)
Get complete playlist data with all tracks, using batched requests to avoid rate limiting.
Args:
playlist_id: The Spotify playlist ID
batch_size: Number of tracks to fetch per batch (max 100)
Returns:
Complete playlist data with all tracks
"""
try:
# First get metadata
playlist = get_playlist_metadata(playlist_id)
# Get total track count
total_tracks = playlist.get("tracks", {}).get("total", 0)
if total_tracks == 0:
playlist["tracks"] = {"items": [], "total": 0}
return playlist
# Fetch all tracks in batches
all_tracks = []
offset = 0
while offset < total_tracks:
batch = get_playlist_tracks(playlist_id, limit=batch_size, offset=offset)
batch_items = batch.get("items", [])
all_tracks.extend(batch_items)
offset += len(batch_items)
# Add small delay between batches to be respectful to API
if offset < total_tracks:
time.sleep(0.1)
# Update playlist with complete tracks data
playlist["tracks"] = {
"items": all_tracks,
"total": total_tracks,
"limit": batch_size,
"offset": 0,
}
playlist["_metadata_only"] = False
playlist["_tracks_loaded"] = True
logger.info(
f"Retrieved complete playlist {playlist_id} with {total_tracks} tracks"
)
return playlist
except Exception as e:
logger.error(f"Error fetching complete playlist {playlist_id}: {e}")
raise
def check_playlist_updated(playlist_id: str, last_snapshot_id: str) -> bool: def get_playlist(
""" client: LibrespotClient, playlist_in: str, expand_items: bool = False
Check if playlist has been updated by comparing snapshot_id. ) -> Dict[str, Any]:
This is much more efficient than fetching all tracks. """Fetch a playlist object; optionally expand track items to full track objects."""
return client.get_playlist(playlist_in, expand_items=expand_items)
Args:
playlist_id: The Spotify playlist ID
last_snapshot_id: The last known snapshot_id
Returns:
True if playlist has been updated, False otherwise
"""
try:
metadata = get_playlist_metadata(playlist_id)
current_snapshot_id = metadata.get("snapshot_id")
return current_snapshot_id != last_snapshot_id
except Exception as e:
logger.error(f"Error checking playlist update status for {playlist_id}: {e}")
raise
@_rate_limit_handler
def get_spotify_info( def get_spotify_info(
spotify_id: str, spotify_id: str,
spotify_type: str, info_type: str,
limit: Optional[int] = None, limit: int = 50,
offset: Optional[int] = None, offset: int = 0,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get info from Spotify API using Spotipy directly. Thin, typed wrapper around common Spotify info lookups using the shared client.
Optimized to prevent rate limiting by using appropriate endpoints.
Args: Currently supports:
spotify_id: The Spotify ID of the entity - "artist_discography": returns a paginated view over the artist's releases
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode, album_tracks) combined across album_group/single_group/compilation_group/appears_on_group.
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.
Returns: Returns a mapping with at least: items, total, limit, offset.
Dictionary with the entity information Also includes a truthy "next" key when more pages are available.
""" """
client = _get_spotify_client() client = get_client()
try: if info_type == "artist_discography":
if spotify_type == "track": artist = client.get_artist(spotify_id)
return client.track(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),
}
elif spotify_type == "album": raise ValueError(f"Unsupported info_type: {info_type}")
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)
elif spotify_type == "playlist_metadata":
# Get only metadata for playlists
return get_playlist_metadata(spotify_id)
elif spotify_type == "artist":
return client.artist(spotify_id)
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",
)
return albums
elif spotify_type == "episode":
return client.episode(spotify_id)
else:
raise ValueError(f"Unsupported Spotify type: {spotify_type}")
except Exception as e:
logger.error(f"Error fetching {spotify_type} {spotify_id}: {e}")
raise
# Cache for playlist metadata to reduce API calls def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]:
_playlist_metadata_cache: Dict[str, tuple[Dict[str, Any], float]] = {}
_cache_ttl = 300 # 5 minutes cache
def get_cached_playlist_metadata(playlist_id: str) -> Optional[Dict[str, Any]]:
""" """
Get playlist metadata from cache if available and not expired. Fetch playlist metadata using the shared client without expanding items.
Args:
playlist_id: The Spotify playlist ID
Returns:
Cached metadata or None if not available/expired
""" """
if playlist_id in _playlist_metadata_cache: client = get_client()
cached_data, timestamp = _playlist_metadata_cache[playlist_id] return get_playlist(client, playlist_id, expand_items=False)
if time.time() - timestamp < _cache_ttl:
return cached_data
return None
def cache_playlist_metadata(playlist_id: str, metadata: Dict[str, Any]):
"""
Cache playlist metadata with timestamp.
Args:
playlist_id: The Spotify playlist ID
metadata: The metadata to cache
"""
_playlist_metadata_cache[playlist_id] = (metadata, time.time())
def get_playlist_info_optimized(
playlist_id: str, include_tracks: bool = False
) -> Dict[str, Any]:
"""
Optimized playlist info function that uses caching and selective loading.
Args:
playlist_id: The Spotify playlist ID
include_tracks: Whether to include track data (default: False to save API calls)
Returns:
Playlist data with or without tracks
"""
# Check cache first
cached_metadata = get_cached_playlist_metadata(playlist_id)
if cached_metadata and not include_tracks:
logger.debug(f"Returning cached metadata for playlist {playlist_id}")
return cached_metadata
if include_tracks:
# Get complete playlist data
playlist_data = get_playlist_full(playlist_id)
# Cache the metadata portion
metadata_only = {k: v for k, v in playlist_data.items() if k != "tracks"}
metadata_only["_metadata_only"] = True
metadata_only["_tracks_loaded"] = False
cache_playlist_metadata(playlist_id, metadata_only)
return playlist_data
else:
# Get metadata only
metadata = get_playlist_metadata(playlist_id)
cache_playlist_metadata(playlist_id, metadata)
return metadata
# Keep the existing Deezer functions unchanged
def get_deezer_info(deezer_id, deezer_type, limit=None):
"""
Get info from Deezer API.
Args:
deezer_id: The Deezer ID of the entity.
deezer_type: The type of entity (track, album, playlist, artist, episode,
artist_top_tracks, artist_albums, artist_related,
artist_radio, artist_playlists).
limit (int, optional): The maximum number of items to return. Used for
artist_top_tracks, artist_albums, artist_playlists.
Deezer API methods usually have their own defaults (e.g., 25)
if limit is not provided or None is passed to them.
Returns:
Dictionary with the entity information.
Raises:
ValueError: If deezer_type is unsupported.
Various exceptions from DeezerAPI (NoDataApi, QuotaExceeded, requests.exceptions.RequestException, etc.)
"""
logger.debug(
f"Fetching Deezer info for ID {deezer_id}, type {deezer_type}, limit {limit}"
)
# DeezerAPI uses class methods; its @classmethod __init__ handles setup.
# No specific ARL or account handling here as DeezerAPI seems to use general endpoints.
if deezer_type == "track":
return DeezerAPI.get_track(deezer_id)
elif deezer_type == "album":
return DeezerAPI.get_album(deezer_id)
elif deezer_type == "playlist":
return DeezerAPI.get_playlist(deezer_id)
elif deezer_type == "artist":
return DeezerAPI.get_artist(deezer_id)
elif deezer_type == "episode":
return DeezerAPI.get_episode(deezer_id)
elif deezer_type == "artist_top_tracks":
if limit is not None:
return DeezerAPI.get_artist_top_tracks(deezer_id, limit=limit)
return DeezerAPI.get_artist_top_tracks(deezer_id) # Use API default limit
elif deezer_type == "artist_albums": # Maps to get_artist_top_albums
if limit is not None:
return DeezerAPI.get_artist_top_albums(deezer_id, limit=limit)
return DeezerAPI.get_artist_top_albums(deezer_id) # Use API default limit
elif deezer_type == "artist_related":
return DeezerAPI.get_artist_related(deezer_id)
elif deezer_type == "artist_radio":
return DeezerAPI.get_artist_radio(deezer_id)
elif deezer_type == "artist_playlists":
if limit is not None:
return DeezerAPI.get_artist_top_playlists(deezer_id, limit=limit)
return DeezerAPI.get_artist_top_playlists(deezer_id) # Use API default limit
else:
logger.error(f"Unsupported Deezer type: {deezer_type}")
raise ValueError(f"Unsupported Deezer type: {deezer_type}")

View File

@@ -27,15 +27,9 @@ from routes.utils.watch.db import (
get_artist_batch_next_offset, get_artist_batch_next_offset,
set_artist_batch_next_offset, set_artist_batch_next_offset,
) )
from routes.utils.get_info import (
get_spotify_info,
get_playlist_metadata,
get_playlist_tracks,
) # To fetch playlist, track, artist, and album details
from routes.utils.celery_queue_manager import download_queue_manager
# Added import to fetch base formatting config from routes.utils.celery_queue_manager import download_queue_manager, get_config_params
from routes.utils.celery_queue_manager import get_config_params from routes.utils.get_info import get_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAIN_CONFIG_FILE_PATH = Path("./data/config/main.json") MAIN_CONFIG_FILE_PATH = Path("./data/config/main.json")
@@ -358,7 +352,7 @@ def find_tracks_in_playlist(
while not_found_tracks and offset < 10000: # Safety limit while not_found_tracks and offset < 10000: # Safety limit
try: try:
tracks_batch = get_playlist_tracks( tracks_batch = _fetch_playlist_tracks_page(
playlist_spotify_id, limit=limit, offset=offset playlist_spotify_id, limit=limit, offset=offset
) )
@@ -459,7 +453,9 @@ def check_watched_playlists(specific_playlist_id: str = None):
ensure_playlist_table_schema(playlist_spotify_id) ensure_playlist_table_schema(playlist_spotify_id)
# First, get playlist metadata to check if it has changed # First, get playlist metadata to check if it has changed
current_playlist_metadata = get_playlist_metadata(playlist_spotify_id) current_playlist_metadata = _fetch_playlist_metadata(
playlist_spotify_id
)
if not current_playlist_metadata: if not current_playlist_metadata:
logger.error( logger.error(
f"Playlist Watch Manager: Failed to fetch metadata from Spotify for playlist {playlist_spotify_id}." f"Playlist Watch Manager: Failed to fetch metadata from Spotify for playlist {playlist_spotify_id}."
@@ -507,7 +503,7 @@ def check_watched_playlists(specific_playlist_id: str = None):
progress_offset, _ = get_playlist_batch_progress( progress_offset, _ = get_playlist_batch_progress(
playlist_spotify_id playlist_spotify_id
) )
tracks_batch = get_playlist_tracks( tracks_batch = _fetch_playlist_tracks_page(
playlist_spotify_id, playlist_spotify_id,
limit=batch_limit, limit=batch_limit,
offset=progress_offset, offset=progress_offset,
@@ -573,7 +569,7 @@ def check_watched_playlists(specific_playlist_id: str = None):
logger.info( logger.info(
f"Playlist Watch Manager: Fetching one batch (limit={batch_limit}, offset={progress_offset}) for playlist '{playlist_name}'." f"Playlist Watch Manager: Fetching one batch (limit={batch_limit}, offset={progress_offset}) for playlist '{playlist_name}'."
) )
tracks_batch = get_playlist_tracks( tracks_batch = _fetch_playlist_tracks_page(
playlist_spotify_id, limit=batch_limit, offset=progress_offset playlist_spotify_id, limit=batch_limit, offset=progress_offset
) )
batch_items = tracks_batch.get("items", []) if tracks_batch else [] batch_items = tracks_batch.get("items", []) if tracks_batch else []
@@ -734,8 +730,8 @@ def check_watched_artists(specific_artist_id: str = None):
logger.debug( logger.debug(
f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}" f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}"
) )
artist_albums_page = get_spotify_info( artist_albums_page = _fetch_artist_discography_page(
artist_spotify_id, "artist_discography", limit=limit, offset=offset artist_spotify_id, limit=limit, offset=offset
) )
current_page_albums = ( current_page_albums = (
@@ -911,7 +907,8 @@ 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)
metadata = get_playlist_metadata(playlist_spotify_id) # Use local helper that leverages Librespot client
metadata = _fetch_playlist_metadata(playlist_spotify_id)
if not metadata: if not metadata:
logger.warning( logger.warning(
f"Manual Playlist Runner: Could not load metadata for {playlist_spotify_id}. Stopping." f"Manual Playlist Runner: Could not load metadata for {playlist_spotify_id}. Stopping."
@@ -1167,3 +1164,84 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
f"Error updating m3u file for playlist {playlist_spotify_id}: {e}", f"Error updating m3u file for playlist {playlist_spotify_id}: {e}",
exc_info=True, exc_info=True,
) )
# Helper to build a Librespot client from active account
def _build_librespot_client():
try:
# Reuse shared client managed in routes.utils.get_info
return get_client()
except Exception as e:
raise RuntimeError(f"Failed to initialize Librespot client: {e}")
def _fetch_playlist_metadata(playlist_id: str) -> dict:
client = _build_librespot_client()
return client.get_playlist(playlist_id, expand_items=False)
def _fetch_playlist_tracks_page(playlist_id: str, limit: int, offset: int) -> dict:
client = _build_librespot_client()
# Fetch playlist with minimal items to avoid expanding all tracks unnecessarily
pl = client.get_playlist(playlist_id, expand_items=False)
items = (pl.get("tracks", {}) or {}).get("items", [])
total = (pl.get("tracks", {}) or {}).get("total", len(items))
start = max(0, offset or 0)
end = start + max(1, limit or 50)
page_items_minimal = items[start:end]
# Expand only the tracks in this page using client cache for efficiency
page_items_expanded = []
for item in page_items_minimal:
track_stub = (item or {}).get("track") or {}
track_id = track_stub.get("id")
expanded_track = None
if track_id:
try:
expanded_track = client.get_track(track_id)
except Exception:
expanded_track = None
if expanded_track is None:
# Keep stub as fallback; ensure structure
expanded_track = {
k: v
for k, v in track_stub.items()
if k in ("id", "uri", "type", "external_urls")
}
# Propagate local flag onto track for downstream checks
if item and isinstance(item, dict) and item.get("is_local"):
expanded_track["is_local"] = True
# Rebuild item with expanded track
new_item = dict(item)
new_item["track"] = expanded_track
page_items_expanded.append(new_item)
return {
"items": page_items_expanded,
"total": total,
"limit": end - start,
"offset": start,
}
def _fetch_artist_discography_page(artist_id: str, limit: int, offset: int) -> dict:
# LibrespotClient.get_artist returns a pruned mapping; flatten common discography groups
client = _build_librespot_client()
artist = client.get_artist(artist_id)
all_items = []
# Collect from known groups; also support nested structures if present
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)
end = start + max(1, limit or 50)
page_items = all_items[start:end]
return {"items": page_items, "total": total, "limit": limit, "offset": start}

View File

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

View File

@@ -2,10 +2,10 @@ import { Link } from "@tanstack/react-router";
import { useContext, useEffect } from "react"; import { useContext, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { QueueContext, getStatus } from "../contexts/queue-context"; import { QueueContext, getStatus } from "../contexts/queue-context";
import type { AlbumType } from "../types/spotify"; import type { LibrespotAlbumType } from "@/types/librespot";
interface AlbumCardProps { interface AlbumCardProps {
album: AlbumType; album: LibrespotAlbumType;
onDownload?: () => void; onDownload?: () => void;
} }
@@ -38,7 +38,7 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
onDownload(); onDownload();
}} }}
disabled={!!status && status !== "error"} disabled={!!status && status !== "error"}
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 disabled:opacity-50 disabled:cursor-not-allowed" className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-100 sm:opacity-0 sm:group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
title={ title={
status status
? status === "queued" ? status === "queued"
@@ -53,9 +53,9 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
? status === "queued" ? status === "queued"
? "Queued." ? "Queued."
: status === "error" : status === "error"
? <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" /> ? <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
: <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" /> : <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" />
: <img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" /> : <img src="/download.svg" alt="Download" className="w-5 h-5 logo" />
} }
</button> </button>
)} )}

View File

@@ -235,3 +235,46 @@
} }
} }
@layer components {
/* Artist hero banner (Spotify-like) */
.artist-hero {
position: relative;
height: clamp(220px, 40vh, 460px);
border-radius: 0.75rem;
overflow: hidden;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.artist-hero::after {
content: "";
position: absolute;
inset: 0;
/* top vignette and bottom darkening for readable text */
background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 55%, rgba(0,0,0,0.70) 100%);
}
.dark .artist-hero::after {
background: linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.55) 55%, rgba(0,0,0,0.85) 100%);
}
.artist-hero-content {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 1rem 1.25rem 1.5rem 1.25rem;
color: var(--color-content-inverse);
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 1;
}
.artist-hero-title {
font-size: clamp(2rem, 7vw, 5rem);
line-height: 1;
font-weight: 800;
letter-spacing: -0.02em;
text-shadow: 0 2px 24px rgba(0,0,0,0.45);
}
}

View File

@@ -3,14 +3,14 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client"; import apiClient from "../lib/api-client";
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 type { AlbumType, TrackType } from "../types/spotify"; import type { LibrespotAlbumType, LibrespotTrackType } from "@/types/librespot";
import { toast } from "sonner"; import { toast } from "sonner";
import { FaArrowLeft } from "react-icons/fa"; 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<LibrespotAlbumType | null>(null);
const [tracks, setTracks] = useState<TrackType[]>([]); const [tracks, setTracks] = useState<LibrespotTrackType[]>([]);
const [offset, setOffset] = useState<number>(0); const [offset, setOffset] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
@@ -19,7 +19,7 @@ export const Album = () => {
const { settings } = useSettings(); const { settings } = useSettings();
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const PAGE_SIZE = 50; const PAGE_SIZE = 6;
if (!context) { if (!context) {
throw new Error("useQueue must be used within a QueueProvider"); throw new Error("useQueue must be used within a QueueProvider");
@@ -48,11 +48,28 @@ export const Album = () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=0`); const response = await apiClient.get(`/album/info?id=${albumId}`);
const data: AlbumType & { tracks: { items: TrackType[]; total?: number; limit?: number; offset?: number } } = response.data; const data: LibrespotAlbumType = response.data;
setAlbum(data); setAlbum(data);
setTracks(data.tracks.items || []); // Tracks may be string[] (ids) or expanded track objects depending on backend
setOffset((data.tracks.items || []).length); const rawTracks = data.tracks;
if (Array.isArray(rawTracks) && rawTracks.length > 0) {
if (typeof rawTracks[0] === "string") {
// fetch first page of tracks by id
const ids = (rawTracks as string[]).slice(0, PAGE_SIZE);
const trackResponses = await Promise.all(
ids.map((id) => apiClient.get<LibrespotTrackType>(`/track/info?id=${id}`).then(r => r.data).catch(() => null))
);
setTracks(trackResponses.filter(Boolean) as LibrespotTrackType[]);
setOffset(ids.length);
} else {
setTracks((rawTracks as LibrespotTrackType[]).slice(0, PAGE_SIZE));
setOffset(Math.min(PAGE_SIZE, (rawTracks as LibrespotTrackType[]).length));
}
} else {
setTracks([]);
setOffset(0);
}
} 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);
@@ -71,20 +88,31 @@ export const Album = () => {
}, [albumId]); }, [albumId]);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (!albumId || isLoadingMore || !hasMore) return; if (!albumId || isLoadingMore || !hasMore || !album) return;
setIsLoadingMore(true); setIsLoadingMore(true);
try { try {
const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=${offset}`); // If album.tracks is a list of ids, continue fetching by ids
const data: AlbumType & { tracks: { items: TrackType[]; total?: number; limit?: number; offset?: number } } = response.data; if (Array.isArray(album.tracks) && (album.tracks.length === 0 || typeof album.tracks[0] === "string")) {
const newItems = data.tracks.items || []; const ids = (album.tracks as string[]).slice(offset, offset + PAGE_SIZE);
setTracks((prev) => [...prev, ...newItems]); const trackResponses = await Promise.all(
setOffset((prev) => prev + newItems.length); ids.map((id) => apiClient.get<LibrespotTrackType>(`/track/info?id=${id}`).then(r => r.data).catch(() => null))
);
const newItems = trackResponses.filter(Boolean) as LibrespotTrackType[];
setTracks((prev) => [...prev, ...newItems]);
setOffset((prev) => prev + newItems.length);
} else {
// Already expanded; append next page from in-memory array
const raw = album.tracks as LibrespotTrackType[];
const slice = raw.slice(offset, offset + PAGE_SIZE);
setTracks((prev) => [...prev, ...slice]);
setOffset((prev) => prev + slice.length);
}
} catch (err) { } catch (err) {
console.error("Error fetching more tracks:", err); console.error("Error fetching more tracks:", err);
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
} }
}, [albumId, offset, isLoadingMore, hasMore]); }, [albumId, offset, isLoadingMore, hasMore, album]);
// IntersectionObserver to trigger loadMore // IntersectionObserver to trigger loadMore
useEffect(() => { useEffect(() => {
@@ -107,7 +135,7 @@ export const Album = () => {
}; };
}, [loadMore]); }, [loadMore]);
const handleDownloadTrack = (track: TrackType) => { 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...`);
addItem({ spotifyId: track.id, type: "track", name: track.name }); addItem({ spotifyId: track.id, type: "track", name: track.name });
@@ -129,16 +157,7 @@ export const Album = () => {
const isExplicitFilterEnabled = settings?.explicitFilter ?? false; const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
// Show placeholder for an entirely explicit album // Not provided by librespot directly; keep feature gated by settings
if (isExplicitFilterEnabled && album.explicit) {
return (
<div className="p-8 text-center border rounded-lg">
<h2 className="text-2xl font-bold">Explicit Content Filtered</h2>
<p className="mt-2 text-gray-500">This album has been filtered based on your settings.</p>
</div>
);
}
const hasExplicitTrack = tracks.some((track) => track.explicit); const hasExplicitTrack = tracks.some((track) => track.explicit);
return ( return (
@@ -178,7 +197,7 @@ export const Album = () => {
<p className="text-sm text-content-muted dark:text-content-muted-dark"> <p className="text-sm text-content-muted dark:text-content-muted-dark">
{new Date(album.release_date).getFullYear()} {album.total_tracks} songs {new Date(album.release_date).getFullYear()} {album.total_tracks} songs
</p> </p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">{album.label}</p> {album.label && <p className="text-xs text-content-muted dark:text-content-muted-dark">{album.label}</p>}
</div> </div>
</div> </div>

View File

@@ -2,17 +2,30 @@ 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 { AlbumType, ArtistType, TrackType } from "../types/spotify"; import type { LibrespotAlbumType, LibrespotArtistType, LibrespotTrackType, LibrespotImage } 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<ArtistType | null>(null); const [artist, setArtist] = useState<ArtistInfoResponse | null>(null);
const [albums, setAlbums] = useState<AlbumType[]>([]); const [artistAlbums, setArtistAlbums] = useState<LibrespotAlbumType[]>([]);
const [topTracks, setTopTracks] = useState<TrackType[]>([]); const [artistSingles, setArtistSingles] = useState<LibrespotAlbumType[]>([]);
const [artistAppearsOn, setArtistAppearsOn] = useState<LibrespotAlbumType[]>([]);
const [topTracks, setTopTracks] = useState<LibrespotTrackType[]>([]);
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const [isWatched, setIsWatched] = useState(false); const [isWatched, setIsWatched] = useState(false);
const [artistStatus, setArtistStatus] = useState<string | null>(null); const [artistStatus, setArtistStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -22,8 +35,10 @@ export const Artist = () => {
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
// Pagination state // Pagination state
const LIMIT = 20; // tune as you like const ALBUM_BATCH = 12;
const [offset, setOffset] = useState<number>(0); const [albumOffset, setAlbumOffset] = useState<number>(0);
const [singleOffset, setSingleOffset] = 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);
const [hasMore, setHasMore] = useState<boolean>(true); // assume more until we learn otherwise const [hasMore, setHasMore] = useState<boolean>(true); // assume more until we learn otherwise
@@ -33,26 +48,27 @@ export const Artist = () => {
} }
const { addItem, items } = context; const { addItem, items } = context;
// Preload commonly used icons ASAP (before first buttons need them)
useEffect(() => {
const i = new Image();
i.src = "/download.svg";
return () => { /* no-op */ };
}, []);
// Track queue status mapping // Track queue status mapping
const trackStatuses = topTracks.reduce((acc, t) => { const trackStatuses = topTracks.reduce((acc, t) => {
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === t.id); const qi = items.find(item => item.downloadType === "track" && item.spotifyId === t.id);
acc[t.id] = qi ? getStatus(qi) : null; acc[t.id] = qi ? getStatus(qi) : null;
return acc; return acc;
}, {} as Record<string, string | null>); }, {} as Record<string, string | null>);
const applyFilters = useCallback( // Helper: fetch a batch of albums by ids
(items: AlbumType[]) => { const fetchAlbumsByIds = useCallback(async (ids: string[]): Promise<LibrespotAlbumType[]> => {
return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true)); const results = await Promise.all(
}, ids.map((id) => apiClient.get<LibrespotAlbumType>(`/album/info?id=${id}`).then(r => r.data).catch(() => null))
[settings?.explicitFilter] );
); return results.filter(Boolean) as LibrespotAlbumType[];
}, []);
// Helper to dedupe albums by id
const dedupeAppendAlbums = (current: AlbumType[], incoming: AlbumType[]) => {
const seen = new Set(current.map((a) => a.id));
const filtered = incoming.filter((a) => !seen.has(a.id));
return current.concat(filtered);
};
// Fetch artist info & first page of albums // Fetch artist info & first page of albums
useEffect(() => { useEffect(() => {
@@ -63,48 +79,90 @@ export const Artist = () => {
const fetchInitial = async () => { const fetchInitial = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setAlbums([]); setArtistAlbums([]);
setOffset(0); setArtistSingles([]);
setArtistAppearsOn([]);
setAlbumOffset(0);
setSingleOffset(0);
setAppearsOffset(0);
setHasMore(true); setHasMore(true);
setBannerUrl(null); // reset hero; will lazy-load below
try { try {
const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=0`); const resp = await apiClient.get<ArtistInfoResponse>(`/artist/info?id=${artistId}`);
const data = resp.data; const data: ArtistInfoResponse = resp.data;
if (cancelled) return; if (cancelled) return;
if (data?.id && data?.name) { if (data?.id && data?.name) {
// set artist meta // set artist meta
setArtist({ setArtist(data);
id: data.id,
name: data.name, // Lazy-load banner image after render
images: data.images || [], const bioEntry = Array.isArray(data.biography) && data.biography.length > 0 ? data.biography[0] : undefined;
external_urls: data.external_urls || { spotify: "" }, const portraitImages = data.portrait_group?.image ?? bioEntry?.portrait_group?.image ?? [];
followers: data.followers || { total: 0 }, const allImages = [...(portraitImages ?? []), ...((data.images as LibrespotImage[] | undefined) ?? [])];
genres: data.genres || [], const candidateBanner = allImages.sort((a, b) => (b?.width ?? 0) - (a?.width ?? 0))[0]?.url || "/placeholder.jpg";
popularity: data.popularity || 0, // Use async preload to avoid blocking initial paint
type: data.type || "artist", setTimeout(() => {
uri: data.uri || "", const img = new Image();
}); img.src = candidateBanner;
img.onload = () => { if (!cancelled) setBannerUrl(candidateBanner); };
}, 0);
// top tracks (if provided) // top tracks (if provided)
if (Array.isArray(data.top_tracks)) { const topTrackIds = Array.isArray(data.top_track) && data.top_track.length > 0
setTopTracks(data.top_tracks); ? data.top_track[0].track.slice(0, 10)
: [];
if (topTrackIds.length) {
const tracksFull = await Promise.all(
topTrackIds.map((id) => apiClient.get<LibrespotTrackType>(`/track/info?id=${id}`).then(r => r.data).catch(() => null))
);
if (!cancelled) setTopTracks(tracksFull.filter(Boolean) as LibrespotTrackType[]);
} else { } else {
setTopTracks([]); if (!cancelled) setTopTracks([]);
} }
// albums pagination info // Progressive album loading: album -> single -> appears_on
const items: AlbumType[] = (data?.albums?.items as AlbumType[]) || []; const albumIds = data.album_group ?? [];
const total: number | undefined = data?.albums?.total; const singleIds = data.single_group ?? [];
const appearsIds = data.appears_on_group ?? [];
setAlbums(items); // Determine initial number based on screen size: 4 on small screens
setOffset(items.length); const isSmallScreen = typeof window !== "undefined" && !window.matchMedia("(min-width: 640px)").matches;
if (typeof total === "number") { const initialTarget = isSmallScreen ? 4 : ALBUM_BATCH;
setHasMore(items.length < total);
} else { // Load initial batch from albumIds, then if needed from singles, then appears
// If server didn't return total, default behavior: stop when an empty page arrives. const initialBatch: LibrespotAlbumType[] = [];
setHasMore(items.length > 0); let aOff = 0, sOff = 0, apOff = 0;
if (albumIds.length > 0) {
const take = albumIds.slice(0, initialTarget);
initialBatch.push(...await fetchAlbumsByIds(take));
aOff = take.length;
}
if (initialBatch.length < initialTarget && singleIds.length > 0) {
const remaining = initialTarget - initialBatch.length;
const take = singleIds.slice(0, remaining);
initialBatch.push(...await fetchAlbumsByIds(take));
sOff = take.length;
}
if (initialBatch.length < initialTarget && appearsIds.length > 0) {
const remaining = initialTarget - initialBatch.length;
const take = appearsIds.slice(0, remaining);
initialBatch.push(...await fetchAlbumsByIds(take));
apOff = take.length;
}
if (!cancelled) {
setArtistAlbums(initialBatch.filter(a => a.album_type === "album"));
setArtistSingles(initialBatch.filter(a => a.album_type === "single"));
setArtistAppearsOn([]); // placeholder; appears_on grouping not explicitly typed
// Store offsets for next loads
setAlbumOffset(aOff);
setSingleOffset(sOff);
setAppearsOffset(apOff);
// Determine if more remain
setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (appearsIds.length > apOff));
} }
} else { } else {
setError("Could not load artist data."); setError("Could not load artist data.");
@@ -133,28 +191,44 @@ export const Artist = () => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [artistId, LIMIT]); }, [artistId, fetchAlbumsByIds]);
// Fetch more albums (next page) // Fetch more albums (next page)
const fetchMoreAlbums = useCallback(async () => { const fetchMoreAlbums = useCallback(async () => {
if (!artistId || loadingMore || loading || !hasMore) return; if (!artistId || loadingMore || loading || !hasMore || !artist) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=${offset}`); const albumIds = artist.album_group ?? [];
const data = resp.data; const singleIds = artist.single_group ?? [];
const items: AlbumType[] = (data?.albums?.items as AlbumType[]) || []; const appearsIds = artist.appears_on_group ?? [];
const total: number | undefined = data?.albums?.total;
setAlbums((cur) => dedupeAppendAlbums(cur, items)); const nextBatch: LibrespotAlbumType[] = [];
setOffset((cur) => cur + items.length); let aOff = albumOffset, sOff = singleOffset, apOff = appearsOffset;
if (aOff < albumIds.length) {
if (typeof total === "number") { const take = albumIds.slice(aOff, aOff + ALBUM_BATCH - nextBatch.length);
setHasMore((prev) => prev && offset + items.length < total); nextBatch.push(...await fetchAlbumsByIds(take));
} else { aOff += take.length;
// if server doesn't expose total, stop when we get fewer than LIMIT items
setHasMore(items.length === LIMIT);
} }
if (nextBatch.length < ALBUM_BATCH && sOff < singleIds.length) {
const remaining = ALBUM_BATCH - nextBatch.length;
const take = singleIds.slice(sOff, sOff + remaining);
nextBatch.push(...await fetchAlbumsByIds(take));
sOff += take.length;
}
if (nextBatch.length < ALBUM_BATCH && apOff < appearsIds.length) {
const remaining = ALBUM_BATCH - nextBatch.length;
const take = appearsIds.slice(apOff, apOff + remaining);
nextBatch.push(...await fetchAlbumsByIds(take));
apOff += take.length;
}
setArtistAlbums((cur) => cur.concat(nextBatch.filter(a => a.album_type === "album")));
setArtistSingles((cur) => cur.concat(nextBatch.filter(a => a.album_type === "single")));
setAppearsOffset(apOff);
setAlbumOffset(aOff);
setSingleOffset(sOff);
setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (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");
@@ -162,7 +236,7 @@ export const Artist = () => {
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
}, [artistId, offset, LIMIT, loadingMore, loading, hasMore]); }, [artistId, loadingMore, loading, hasMore, artist, albumOffset, singleOffset, appearsOffset, fetchAlbumsByIds]);
// IntersectionObserver to trigger fetchMoreAlbums when sentinel is visible // IntersectionObserver to trigger fetchMoreAlbums when sentinel is visible
useEffect(() => { useEffect(() => {
@@ -190,13 +264,13 @@ export const Artist = () => {
}, [fetchMoreAlbums, hasMore]); }, [fetchMoreAlbums, hasMore]);
// --- existing handlers (unchanged) --- // --- existing handlers (unchanged) ---
const handleDownloadTrack = (track: TrackType) => { 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...`);
addItem({ spotifyId: track.id, type: "track", name: track.name }); addItem({ spotifyId: track.id, type: "track", name: track.name });
}; };
const handleDownloadAlbum = (album: AlbumType) => { const handleDownloadAlbum = (album: LibrespotAlbumType) => {
toast.info(`Adding ${album.name} to queue...`); toast.info(`Adding ${album.name} to queue...`);
addItem({ spotifyId: album.id, type: "album", name: album.name }); addItem({ spotifyId: album.id, type: "album", name: album.name });
}; };
@@ -258,11 +332,6 @@ export const Artist = () => {
return <div>Artist data could not be fully loaded. Please try again later.</div>; return <div>Artist data could not be fully loaded. Please try again later.</div>;
} }
const artistAlbums = applyFilters(albums.filter((album) => (album.album_group ?? album.album_type) === "album"));
const artistSingles = applyFilters(albums.filter((album) => (album.album_group ?? album.album_type) === "single"));
const artistCompilations = applyFilters(albums.filter((album) => (album.album_group ?? album.album_type) === "compilation"));
const artistAppearsOn = applyFilters(albums.filter((album) => (album.album_group ?? "") === "appears_on"));
return ( return (
<div className="artist-page"> <div className="artist-page">
<div className="mb-4 md:mb-6"> <div className="mb-4 md:mb-6">
@@ -274,64 +343,65 @@ export const Artist = () => {
<span>Back to results</span> <span>Back to results</span>
</button> </button>
</div> </div>
<div className="artist-header mb-8 text-center">
{artist.images && artist.images.length > 0 && ( {/* Hero banner using highest resolution image (lazy-loaded) */}
<img <div
src={artist.images[0]?.url} className="relative mb-8 rounded-xl overflow-hidden h-56 sm:h-64 md:h-80 lg:h-[420px] bg-surface-accent dark:bg-surface-accent-dark"
alt={artist.name} style={bannerUrl ? { backgroundImage: `url(${bannerUrl})`, backgroundSize: "cover", backgroundPosition: "center" } : undefined}
className="artist-image w-48 h-48 rounded-full mx-auto mb-4 shadow-lg" >
/> <div className="absolute inset-0 bg-gradient-to-b from-black/30 via-black/50 to-black/80" />
)} <div className="absolute inset-x-0 bottom-0 p-4 md:p-6 flex flex-col gap-3 text-white">
<h1 className="text-5xl font-bold text-content-primary dark:text-content-primary-dark">{artist.name}</h1> <h1 className="text-4xl md:text-6xl font-extrabold tracking-tight leading-none">{artist.name}</h1>
<div className="flex gap-4 justify-center mt-4"> <div className="flex flex-wrap items-center gap-3">
<button <button
onClick={handleDownloadArtist} onClick={handleDownloadArtist}
disabled={artistStatus === "downloading" || artistStatus === "queued"} disabled={artistStatus === "downloading" || artistStatus === "queued"}
className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={ title={
artistStatus === "downloading" artistStatus === "downloading"
? "Downloading..." ? "Downloading..."
: artistStatus === "queued" : artistStatus === "queued"
? "Queued." ? "Queued."
: "Download All" : "Download All"
} }
> >
{artistStatus {artistStatus
? artistStatus === "queued" ? artistStatus === "queued"
? "Queued." ? "Queued."
: artistStatus === "downloading" : artistStatus === "downloading"
? <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" /> ? <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin" />
: <>
<FaDownload className="icon-inverse" />
<span>Download All</span>
</>
: <> : <>
<FaDownload className="icon-inverse" /> <FaDownload className="icon-inverse" />
<span>Download All</span> <span>Download All</span>
</> </>
: <> }
<FaDownload className="icon-inverse" />
<span>Download All</span>
</>
}
</button>
{settings?.watch?.enabled && (
<button
onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
? "bg-button-primary text-button-primary-text border-primary"
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
}`}
>
{isWatched ? (
<>
<FaBookmark className="icon-inverse" />
<span>Watching</span>
</>
) : (
<>
<FaRegBookmark className="icon-primary" />
<span>Watch</span>
</>
)}
</button> </button>
)} {settings?.watch?.enabled && (
<button
onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
? "bg-button-primary text-button-primary-text border-primary"
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
}`}
>
{isWatched ? (
<>
<FaBookmark className="icon-inverse" />
<span>Watching</span>
</>
) : (
<>
<FaRegBookmark className="icon-primary" />
<span>Watch</span>
</>
)}
</button>
)}
</div>
</div> </div>
</div> </div>
@@ -339,10 +409,10 @@ export const Artist = () => {
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Top Tracks</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Top Tracks</h2>
<div className="track-list space-y-2"> <div className="track-list space-y-2">
{topTracks.map((track) => ( {topTracks.map((track, index) => (
<div <div
key={track.id} key={track.id}
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors" className={`track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors ${index >= 5 ? "hidden sm:flex" : ""}`}
> >
<Link <Link
to="/track/$trackId" to="/track/$trackId"
@@ -354,15 +424,25 @@ export const Artist = () => {
<button <button
onClick={() => handleDownloadTrack(track)} onClick={() => handleDownloadTrack(track)}
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"} disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"}
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded disabled:opacity-50 disabled:cursor-not-allowed" className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed"
title={
trackStatuses[track.id]
? trackStatuses[track.id] === "queued"
? "Queued."
: trackStatuses[track.id] === "error"
? "Download"
: "Downloading..."
: "Download"
}
> >
{trackStatuses[track.id] {trackStatuses[track.id]
? trackStatuses[track.id] === "queued" ? trackStatuses[track.id] === "queued"
? "Queued." ? "Queued."
: trackStatuses[track.id] === "error" : trackStatuses[track.id] === "error"
? "Download" ? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
: <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin inline-block" /> : <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin" />
: "Download"} : <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
}
</button> </button>
</div> </div>
))} ))}
@@ -394,18 +474,6 @@ export const Artist = () => {
</div> </div>
)} )}
{/* Compilations */}
{artistCompilations.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Compilations</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{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">

View File

@@ -3,16 +3,14 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client"; import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner"; import { toast } from "sonner";
import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify"; import type { LibrespotTrackType, LibrespotPlaylistType, LibrespotPlaylistItemType, LibrespotPlaylistTrackStubType } from "@/types/librespot";
import { QueueContext, getStatus } from "../contexts/queue-context"; import { QueueContext, getStatus } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa"; import { FaArrowLeft } from "react-icons/fa";
export const Playlist = () => { export const Playlist = () => {
const { playlistId } = useParams({ from: "/playlist/$playlistId" }); const { playlistId } = useParams({ from: "/playlist/$playlistId" });
const [playlistMetadata, setPlaylistMetadata] = useState<PlaylistMetadataType | null>(null); const [playlistMetadata, setPlaylistMetadata] = useState<LibrespotPlaylistType | null>(null);
const [tracks, setTracks] = useState<PlaylistItemType[]>([]); const [items, setItems] = useState<LibrespotPlaylistItemType[]>([]);
const [isWatched, setIsWatched] = useState(false); const [isWatched, setIsWatched] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadingTracks, setLoadingTracks] = useState(false); const [loadingTracks, setLoadingTracks] = useState(false);
@@ -28,11 +26,11 @@ export const Playlist = () => {
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, items } = context; const { addItem, items: queueItems } = context;
// Playlist queue status // Playlist queue status
const playlistQueueItem = playlistMetadata const playlistQueueItem = playlistMetadata
? items.find(item => item.downloadType === "playlist" && item.spotifyId === playlistMetadata.id) ? queueItems.find(item => item.downloadType === "playlist" && item.spotifyId === (playlistId ?? ""))
: undefined; : undefined;
const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null; const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null;
@@ -44,14 +42,15 @@ export const Playlist = () => {
} }
}, [playlistStatus]); }, [playlistStatus]);
// Load playlist metadata first // Load playlist metadata first (no expanded items)
useEffect(() => { useEffect(() => {
const fetchPlaylistMetadata = async () => { const fetchPlaylist = async () => {
if (!playlistId) return; if (!playlistId) return;
try { try {
const response = await apiClient.get<PlaylistMetadataType>(`/playlist/metadata?id=${playlistId}`); const response = await apiClient.get<LibrespotPlaylistType>(`/playlist/info?id=${playlistId}`);
setPlaylistMetadata(response.data); const data = response.data;
setTotalTracks(response.data.tracks.total); setPlaylistMetadata(data);
setTotalTracks(data.tracks.total);
} catch (err) { } catch (err) {
setError("Failed to load playlist metadata"); setError("Failed to load playlist metadata");
console.error(err); console.error(err);
@@ -70,27 +69,49 @@ export const Playlist = () => {
} }
}; };
fetchPlaylistMetadata(); setItems([]);
setTracksOffset(0);
setHasMoreTracks(true);
setTotalTracks(0);
setError(null);
fetchPlaylist();
checkWatchStatus(); checkWatchStatus();
}, [playlistId]); }, [playlistId]);
// Load tracks progressively const BATCH_SIZE = 6;
// Load items progressively by expanding track stubs when needed
const loadMoreTracks = useCallback(async () => { const loadMoreTracks = useCallback(async () => {
if (!playlistId || loadingTracks || !hasMoreTracks) return; if (!playlistId || loadingTracks || !hasMoreTracks || !playlistMetadata) return;
setLoadingTracks(true); setLoadingTracks(true);
try { try {
const limit = 50; // Load 50 tracks at a time // Fetch full playlist snapshot (stub items)
const response = await apiClient.get<PlaylistTracksResponseType>( const response = await apiClient.get<LibrespotPlaylistType>(`/playlist/info?id=${playlistId}`);
`/playlist/tracks?id=${playlistId}&limit=${limit}&offset=${tracksOffset}` const allItems = response.data.tracks.items;
); const slice = allItems.slice(tracksOffset, tracksOffset + BATCH_SIZE);
const newTracks = response.data.items; // Expand any stubbed track entries by fetching full track info
setTracks(prev => [...prev, ...newTracks]); const expandedSlice: LibrespotPlaylistItemType[] = await Promise.all(
setTracksOffset(prev => prev + newTracks.length); slice.map(async (it) => {
const t = it.track as LibrespotPlaylistTrackStubType | LibrespotTrackType;
// Check if we've loaded all tracks // If track has only stub fields (no duration_ms), fetch full
if (tracksOffset + newTracks.length >= totalTracks) { if (t && (t as any).id && !("duration_ms" in (t as any))) {
try {
const full = await apiClient.get<LibrespotTrackType>(`/track/info?id=${(t as LibrespotPlaylistTrackStubType).id}`).then(r => r.data);
return { ...it, track: full } as LibrespotPlaylistItemType;
} catch {
return it; // fallback to stub if fetch fails
}
}
return it;
})
);
setItems((prev) => [...prev, ...expandedSlice]);
const loaded = tracksOffset + expandedSlice.length;
setTracksOffset(loaded);
if (loaded >= totalTracks) {
setHasMoreTracks(false); setHasMoreTracks(false);
} }
} catch (err) { } catch (err) {
@@ -99,7 +120,7 @@ export const Playlist = () => {
} finally { } finally {
setLoadingTracks(false); setLoadingTracks(false);
} }
}, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks]); }, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks, playlistMetadata]);
// Intersection Observer for infinite scroll // Intersection Observer for infinite scroll
useEffect(() => { useEffect(() => {
@@ -125,22 +146,14 @@ export const Playlist = () => {
}; };
}, [loadMoreTracks, hasMoreTracks, loadingTracks]); }, [loadMoreTracks, hasMoreTracks, loadingTracks]);
// Load initial tracks when metadata is loaded // Kick off initial batch
useEffect(() => { useEffect(() => {
if (playlistMetadata && tracks.length === 0 && totalTracks > 0) { if (playlistMetadata && items.length === 0 && totalTracks > 0) {
loadMoreTracks(); loadMoreTracks();
} }
}, [playlistMetadata, tracks.length, totalTracks, loadMoreTracks]); }, [playlistMetadata, items.length, totalTracks, loadMoreTracks]);
// Reset state when playlist ID changes const handleDownloadTrack = (track: LibrespotTrackType) => {
useEffect(() => {
setTracks([]);
setTracksOffset(0);
setHasMoreTracks(true);
setTotalTracks(0);
}, [playlistId]);
const handleDownloadTrack = (track: TrackType) => {
if (!track?.id) return; if (!track?.id) return;
addItem({ spotifyId: track.id, type: "track", name: track.name }); addItem({ spotifyId: track.id, type: "track", name: track.name });
toast.info(`Adding ${track.name} to queue...`); toast.info(`Adding ${track.name} to queue...`);
@@ -149,7 +162,7 @@ export const Playlist = () => {
const handleDownloadPlaylist = () => { const handleDownloadPlaylist = () => {
if (!playlistMetadata) return; if (!playlistMetadata) return;
addItem({ addItem({
spotifyId: playlistMetadata.id, spotifyId: playlistId!,
type: "playlist", type: "playlist",
name: playlistMetadata.name, name: playlistMetadata.name,
}); });
@@ -182,16 +195,19 @@ export const Playlist = () => {
} }
// Map track download statuses // Map track download statuses
const trackStatuses = tracks.reduce((acc, { track }) => { const trackStatuses = items.reduce((acc, { track }) => {
if (!track) return acc; if (!track || (track as any).id === undefined) return acc;
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === track.id); const t = track as LibrespotTrackType;
acc[track.id] = qi ? getStatus(qi) : null; const qi = queueItems.find(item => item.downloadType === "track" && item.spotifyId === t.id);
acc[t.id] = qi ? getStatus(qi) : null;
return acc; return acc;
}, {} as Record<string, string | null>); }, {} as Record<string, string | null>);
const filteredTracks = tracks.filter(({ track }) => { const filteredItems = items.filter(({ track }) => {
if (!track) return false; const t = track as LibrespotTrackType | LibrespotPlaylistTrackStubType | null;
if (settings?.explicitFilter && track.explicit) return false; if (!t || (t as any).id === undefined) return false;
const full = t as LibrespotTrackType;
if (settings?.explicitFilter && full.explicit) return false;
return true; return true;
}); });
@@ -222,7 +238,7 @@ export const Playlist = () => {
<p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p> <p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p>
)} )}
<p className="text-sm text-content-muted dark:text-content-muted-dark"> <p className="text-sm text-content-muted dark:text-content-muted-dark">
By {playlistMetadata.owner.display_name} {playlistMetadata.followers.total.toLocaleString()} followers {totalTracks} songs By {playlistMetadata.owner.display_name} {totalTracks} songs
</p> </p>
</div> </div>
</div> </div>
@@ -266,37 +282,38 @@ export const Playlist = () => {
<div className="space-y-3 md:space-y-4"> <div className="space-y-3 md:space-y-4">
<div className="flex items-center justify_between px-1"> <div className="flex items-center justify_between px-1">
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2> <h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
{tracks.length > 0 && ( {items.length > 0 && (
<span className="text-sm text-content-muted dark:text-content-muted-dark"> <span className="text-sm text-content-muted dark:text-content-muted-dark">
Showing {tracks.length} of {totalTracks} tracks Showing {items.length} of {totalTracks} tracks
</span> </span>
)} )}
</div> </div>
<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">
{filteredTracks.map(({ track }, index) => { {filteredItems.map(({ track }, index) => {
if (!track) return null; const t = track as LibrespotTrackType;
if (!t || !t.id) return null;
return ( return (
<div <div
key={track.id} key={`${t.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">
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span> <span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
<Link to="/album/$albumId" params={{ albumId: track.album.id }}> <Link to="/album/$albumId" params={{ albumId: t.album.id }}>
<img <img
src={track.album.images?.at(-1)?.url || "/placeholder.jpg "} src={t.album.images?.at(-1)?.url || "/placeholder.jpg "}
alt={track.album.name} alt={t.album.name}
className="w-10 h-10 md:w-12 md:h-12 object-cover rounded hover:scale-105 transition-transform duration-300" className="w-10 h-10 md:w-12 md:h-12 object-cover rounded hover:scale-105 transition-transform duration-300"
/> />
</Link> </Link>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base hover:underline block truncate"> <Link to="/track/$trackId" params={{ trackId: t.id }} className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base hover:underline block truncate">
{track.name} {t.name}
</Link> </Link>
<p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate"> <p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{track.artists.map((artist, index) => ( {t.artists.map((artist, index) => (
<span key={artist.id}> <span key={artist.id}>
<Link <Link
to="/artist/$artistId" to="/artist/$artistId"
@@ -305,7 +322,7 @@ export const Playlist = () => {
> >
{artist.name} {artist.name}
</Link> </Link>
{index < track.artists.length - 1 && ", "} {index < t.artists.length - 1 && ", "}
</span> </span>
))} ))}
</p> </p>
@@ -313,27 +330,27 @@ export const Playlist = () => {
</div> </div>
<div className="flex items-center gap-2 md:gap-4 shrink-0"> <div className="flex items-center gap-2 md:gap-4 shrink-0">
<span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block"> <span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block">
{Math.floor(track.duration_ms / 60000)}: {Math.floor(t.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")} {((t.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span> </span>
<button <button
onClick={() => handleDownloadTrack(track)} onClick={() => handleDownloadTrack(t)}
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"} disabled={!!trackStatuses[t.id] && trackStatuses[t.id] !== "error"}
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed"
title={ title={
trackStatuses[track.id] trackStatuses[t.id]
? trackStatuses[track.id] === "queued" ? trackStatuses[t.id] === "queued"
? "Queued." ? "Queued."
: trackStatuses[track.id] === "error" : trackStatuses[t.id] === "error"
? "Download" ? "Download"
: "Downloading..." : "Downloading..."
: "Download" : "Download"
} }
> >
{trackStatuses[track.id] {trackStatuses[t.id]
? trackStatuses[track.id] === "queued" ? trackStatuses[t.id] === "queued"
? "Queued." ? "Queued."
: trackStatuses[track.id] === "error" : trackStatuses[t.id] === "error"
? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" /> ? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
: <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin" /> : <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin" />
: <img src="/download.svg" alt="Download" className="w-4 h-4 logo" /> : <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
@@ -357,7 +374,7 @@ export const Playlist = () => {
)} )}
{/* End of tracks indicator */} {/* End of tracks indicator */}
{!hasMoreTracks && tracks.length > 0 && ( {!hasMoreTracks && items.length > 0 && (
<div className="text-center py-4 text-content-muted dark:text-content-muted-dark"> <div className="text-center py-4 text-content-muted dark:text-content-muted-dark">
All tracks loaded All tracks loaded
</div> </div>

View File

@@ -1,7 +1,7 @@
import { Link, useParams } from "@tanstack/react-router"; import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext } from "react"; import { useEffect, useState, useContext } from "react";
import apiClient from "../lib/api-client"; import apiClient from "../lib/api-client";
import type { TrackType } from "../types/spotify"; import type { LibrespotTrackType } from "@/types/librespot";
import { toast } from "sonner"; import { toast } from "sonner";
import { QueueContext, getStatus } from "../contexts/queue-context"; import { QueueContext, getStatus } from "../contexts/queue-context";
import { FaSpotify, FaArrowLeft } from "react-icons/fa"; import { FaSpotify, FaArrowLeft } from "react-icons/fa";
@@ -15,7 +15,7 @@ const formatDuration = (ms: number) => {
export const Track = () => { export const Track = () => {
const { trackId } = useParams({ from: "/track/$trackId" }); const { trackId } = useParams({ from: "/track/$trackId" });
const [track, setTrack] = useState<TrackType | null>(null); const [track, setTrack] = useState<LibrespotTrackType | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const context = useContext(QueueContext); const context = useContext(QueueContext);
@@ -40,7 +40,7 @@ export const Track = () => {
const fetchTrack = async () => { const fetchTrack = async () => {
if (!trackId) return; if (!trackId) return;
try { try {
const response = await apiClient.get<TrackType>(`/track/info?id=${trackId}`); const response = await apiClient.get<LibrespotTrackType>(`/track/info?id=${trackId}`);
setTrack(response.data); setTrack(response.data);
} catch (err) { } catch (err) {
setError("Failed to load track"); setError("Failed to load track");
@@ -171,11 +171,11 @@ export const Track = () => {
<div className="flex-1 bg-surface-muted dark:bg-surface-muted-dark rounded-full h-3"> <div className="flex-1 bg-surface-muted dark:bg-surface-muted-dark rounded-full h-3">
<div <div
className="bg-primary h-3 rounded-full transition-all duration-500" className="bg-primary h-3 rounded-full transition-all duration-500"
style={{ width: `${track.popularity}%` }} style={{ width: `${track.popularity ?? 0}%` }}
></div> ></div>
</div> </div>
<span className="text-sm font-medium text-content_secondary dark:text-content-secondary-dark"> <span className="text-sm font-medium text-content_secondary dark:text-content-secondary-dark">
{track.popularity}% {track.popularity ?? 0}%
</span> </span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,155 @@
// Librespot wrapper response types for frontend consumption
export interface LibrespotExternalUrls {
spotify: string;
}
export interface LibrespotImage {
url: string;
width?: number;
height?: number;
}
export interface LibrespotArtistStub {
id: string;
name: string;
type?: "artist";
uri?: string;
external_urls?: LibrespotExternalUrls;
}
// Full artist object (get_artist)
export interface LibrespotArtistType {
id: string;
name: string;
images?: LibrespotImage[];
external_urls?: LibrespotExternalUrls;
followers?: { total: number };
genres?: string[];
popularity?: number;
type?: "artist";
uri?: string;
}
export interface LibrespotCopyright {
text: string;
type: string;
}
export type LibrespotReleaseDatePrecision = "day" | "month" | "year";
// Minimal embedded album object returned inside track objects (does not include tracks array)
export interface LibrespotAlbumRef {
id: string;
name: string;
images?: LibrespotImage[];
release_date?: string;
release_date_precision?: LibrespotReleaseDatePrecision;
type?: "album";
uri?: string;
album_type?: "album" | "single" | "compilation";
external_urls?: LibrespotExternalUrls;
artists?: LibrespotArtistStub[];
}
export interface LibrespotTrackType {
album: LibrespotAlbumRef;
artists: LibrespotArtistStub[];
available_markets?: string[];
disc_number: number;
duration_ms: number;
explicit: boolean;
external_ids?: { isrc?: string };
external_urls: LibrespotExternalUrls;
id: string;
name: string;
popularity?: number;
track_number: number;
type: "track";
uri: string;
preview_url?: string;
has_lyrics?: boolean;
earliest_live_timestamp?: number;
licensor_uuid?: string; // when available
}
export interface LibrespotAlbumType {
album_type: "album" | "single" | "compilation";
total_tracks: number;
available_markets?: string[];
external_urls: LibrespotExternalUrls;
id: string;
images: LibrespotImage[];
name: string;
release_date: string;
release_date_precision: LibrespotReleaseDatePrecision;
type: "album";
uri: string;
artists: LibrespotArtistStub[];
// When include_tracks=False -> string[] of base62 IDs
// When include_tracks=True -> LibrespotTrackType[]
tracks: string[] | LibrespotTrackType[];
copyrights?: LibrespotCopyright[];
external_ids?: { upc?: string };
label?: string;
popularity?: number;
}
// Playlist types
export interface LibrespotPlaylistOwnerType {
id: string;
type: "user";
uri: string;
external_urls: LibrespotExternalUrls;
display_name: string;
}
export interface LibrespotPlaylistTrackStubType {
id: string;
uri: string; // spotify:track:{id}
type: "track";
external_urls: LibrespotExternalUrls;
}
export interface LibrespotPlaylistItemType {
added_at: string;
added_by: LibrespotPlaylistOwnerType;
is_local: boolean;
// If expand_items=False -> LibrespotPlaylistTrackStubType
// If expand_items=True -> LibrespotTrackType
track: LibrespotPlaylistTrackStubType | LibrespotTrackType;
// Additional reference, not a Web API field
item_id?: string;
}
export interface LibrespotPlaylistTracksPageType {
offset: number;
total: number;
items: LibrespotPlaylistItemType[];
}
export interface LibrespotPlaylistType {
name: string;
description?: string | null;
collaborative?: boolean;
images?: Array<Pick<LibrespotImage, "url"> & Partial<LibrespotImage>>;
owner: LibrespotPlaylistOwnerType;
snapshot_id: string;
tracks: LibrespotPlaylistTracksPageType;
type: "playlist";
}
// Type guards
export function isAlbumWithExpandedTracks(
album: LibrespotAlbumType
): album is LibrespotAlbumType & { tracks: LibrespotTrackType[] } {
const { tracks } = album as LibrespotAlbumType;
return Array.isArray(tracks) && (tracks.length === 0 || typeof tracks[0] === "object");
}
export function isPlaylistItemWithExpandedTrack(
item: LibrespotPlaylistItemType
): item is LibrespotPlaylistItemType & { track: LibrespotTrackType } {
const t = item.track as unknown;
return !!t && typeof t === "object" && (t as any).type === "track" && "duration_ms" in (t as any);
}