fixed #191
This commit is contained in:
@@ -11,6 +11,11 @@ from routes.utils.errors import DuplicateDownloadError
|
|||||||
album_bp = Blueprint("album", __name__)
|
album_bp = Blueprint("album", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
|
||||||
|
"""Construct a Spotify URL for a given item ID and type."""
|
||||||
|
return f"https://open.spotify.com/{item_type}/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
@album_bp.route("/download/<album_id>", methods=["GET"])
|
@album_bp.route("/download/<album_id>", methods=["GET"])
|
||||||
def handle_download(album_id):
|
def handle_download(album_id):
|
||||||
# Retrieve essential parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
@@ -18,7 +23,7 @@ def handle_download(album_id):
|
|||||||
# artist = request.args.get('artist')
|
# artist = request.args.get('artist')
|
||||||
|
|
||||||
# Construct the URL from album_id
|
# Construct the URL from album_id
|
||||||
url = f"https://open.spotify.com/album/{album_id}"
|
url = construct_spotify_url(album_id, "album")
|
||||||
|
|
||||||
# Fetch metadata from Spotify
|
# Fetch metadata from Spotify
|
||||||
try:
|
try:
|
||||||
@@ -163,9 +168,7 @@ def get_album_info():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import and use the get_spotify_info function from the utility module.
|
# Use the get_spotify_info function (already imported at top)
|
||||||
from routes.utils.get_info import get_spotify_info
|
|
||||||
|
|
||||||
album_info = get_spotify_info(spotify_id, "album")
|
album_info = get_spotify_info(spotify_id, "album")
|
||||||
return Response(json.dumps(album_info), status=200, mimetype="application/json")
|
return Response(json.dumps(album_info), status=200, mimetype="application/json")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ artist_bp = Blueprint("artist", __name__, url_prefix="/api/artist")
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
|
||||||
|
"""Construct a Spotify URL for a given item ID and type."""
|
||||||
|
return f"https://open.spotify.com/{item_type}/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
def log_json(message_dict):
|
def log_json(message_dict):
|
||||||
print(json.dumps(message_dict))
|
print(json.dumps(message_dict))
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ def handle_artist_download(artist_id):
|
|||||||
- album_type: string(s); comma-separated values such as "album,single,appears_on,compilation"
|
- album_type: string(s); comma-separated values such as "album,single,appears_on,compilation"
|
||||||
"""
|
"""
|
||||||
# Construct the artist URL from artist_id
|
# Construct the artist URL from artist_id
|
||||||
url = f"https://open.spotify.com/artist/{artist_id}"
|
url = construct_spotify_url(artist_id, "artist")
|
||||||
|
|
||||||
# Retrieve essential parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
album_type = request.args.get("album_type", "album,single,compilation")
|
album_type = request.args.get("album_type", "album,single,compilation")
|
||||||
@@ -123,16 +128,26 @@ def get_artist_info():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
artist_info = get_spotify_info(spotify_id, "artist_discography")
|
# Get artist metadata first
|
||||||
|
artist_metadata = get_spotify_info(spotify_id, "artist")
|
||||||
|
|
||||||
|
# Get artist discography for albums
|
||||||
|
artist_discography = get_spotify_info(spotify_id, "artist_discography")
|
||||||
|
|
||||||
|
# Combine metadata with discography
|
||||||
|
artist_info = {
|
||||||
|
**artist_metadata,
|
||||||
|
"albums": artist_discography
|
||||||
|
}
|
||||||
|
|
||||||
# If artist_info is successfully fetched (it contains album items),
|
# If artist_info is successfully fetched and has albums,
|
||||||
# check if the artist is watched and augment album items with is_locally_known status
|
# check if the artist is watched and augment album items with is_locally_known status
|
||||||
if artist_info and artist_info.get("items"):
|
if artist_info and artist_info.get("albums") and artist_info["albums"].get("items"):
|
||||||
watched_artist_details = get_watched_artist(
|
watched_artist_details = get_watched_artist(
|
||||||
spotify_id
|
spotify_id
|
||||||
) # spotify_id is the artist ID
|
) # spotify_id is the artist ID
|
||||||
if watched_artist_details: # Artist is being watched
|
if watched_artist_details: # Artist is being watched
|
||||||
for album_item in artist_info["items"]:
|
for album_item in artist_info["albums"]["items"]:
|
||||||
if album_item and album_item.get("id"):
|
if album_item and album_item.get("id"):
|
||||||
album_id = album_item["id"]
|
album_id = album_item["id"]
|
||||||
album_item["is_locally_known"] = is_album_in_artist_db(
|
album_item["is_locally_known"] = is_album_in_artist_db(
|
||||||
@@ -171,64 +186,39 @@ def add_artist_to_watchlist(artist_spotify_id):
|
|||||||
{"message": f"Artist {artist_spotify_id} is already being watched."}
|
{"message": f"Artist {artist_spotify_id} is already being watched."}
|
||||||
), 200
|
), 200
|
||||||
|
|
||||||
# This call returns an album list-like structure based on logs
|
# Get artist metadata directly for name and basic info
|
||||||
|
artist_metadata = get_spotify_info(artist_spotify_id, "artist")
|
||||||
|
|
||||||
|
# Get artist discography for album count
|
||||||
artist_album_list_data = get_spotify_info(
|
artist_album_list_data = get_spotify_info(
|
||||||
artist_spotify_id, "artist_discography"
|
artist_spotify_id, "artist_discography"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if we got any data and if it has items
|
# Check if we got artist metadata
|
||||||
if not artist_album_list_data or not isinstance(
|
if not artist_metadata or not artist_metadata.get("name"):
|
||||||
artist_album_list_data.get("items"), list
|
|
||||||
):
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist_discography'). Data: {artist_album_list_data}"
|
f"Could not fetch artist metadata for {artist_spotify_id} from Spotify."
|
||||||
)
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"error": f"Could not fetch sufficient details for artist {artist_spotify_id} to initiate watch."
|
"error": f"Could not fetch artist metadata for {artist_spotify_id} to initiate watch."
|
||||||
}
|
}
|
||||||
), 404
|
), 404
|
||||||
|
|
||||||
# Attempt to extract artist name and verify ID
|
# Check if we got album data
|
||||||
# The actual artist name might be consistently found in the items, if they exist
|
if not artist_album_list_data or not isinstance(
|
||||||
artist_name_from_albums = "Unknown Artist" # Default
|
artist_album_list_data.get("items"), list
|
||||||
if artist_album_list_data["items"]:
|
):
|
||||||
first_album = artist_album_list_data["items"][0]
|
|
||||||
if (
|
|
||||||
first_album
|
|
||||||
and isinstance(first_album.get("artists"), list)
|
|
||||||
and first_album["artists"]
|
|
||||||
):
|
|
||||||
# Find the artist in the list that matches the artist_spotify_id
|
|
||||||
found_artist = next(
|
|
||||||
(
|
|
||||||
art
|
|
||||||
for art in first_album["artists"]
|
|
||||||
if art.get("id") == artist_spotify_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if found_artist and found_artist.get("name"):
|
|
||||||
artist_name_from_albums = found_artist["name"]
|
|
||||||
elif first_album["artists"][0].get(
|
|
||||||
"name"
|
|
||||||
): # Fallback to first artist if specific match not found or no ID
|
|
||||||
artist_name_from_albums = first_album["artists"][0]["name"]
|
|
||||||
logger.warning(
|
|
||||||
f"Could not find exact artist ID {artist_spotify_id} in first album's artists list. Using name '{artist_name_from_albums}'."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No album items found for artist {artist_spotify_id} to extract name. Using default."
|
f"Could not fetch album list details for artist {artist_spotify_id} from Spotify. Proceeding with metadata only."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Construct the artist_data object expected by add_artist_db
|
# Construct the artist_data object expected by add_artist_db
|
||||||
# We use the provided artist_spotify_id as the primary ID.
|
|
||||||
artist_data_for_db = {
|
artist_data_for_db = {
|
||||||
"id": artist_spotify_id, # This is the crucial part
|
"id": artist_spotify_id,
|
||||||
"name": artist_name_from_albums,
|
"name": artist_metadata.get("name", "Unknown Artist"),
|
||||||
"albums": { # Mimic structure if add_artist_db expects it for total_albums
|
"albums": { # Mimic structure if add_artist_db expects it for total_albums
|
||||||
"total": artist_album_list_data.get("total", 0)
|
"total": artist_album_list_data.get("total", 0) if artist_album_list_data else 0
|
||||||
},
|
},
|
||||||
# Add any other fields add_artist_db might expect from a true artist object if necessary
|
# Add any other fields add_artist_db might expect from a true artist object if necessary
|
||||||
}
|
}
|
||||||
@@ -236,7 +226,7 @@ def add_artist_to_watchlist(artist_spotify_id):
|
|||||||
add_artist_db(artist_data_for_db)
|
add_artist_db(artist_data_for_db)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Artist {artist_spotify_id} ('{artist_name_from_albums}') added to watchlist. Their albums will be processed by the watch manager."
|
f"Artist {artist_spotify_id} ('{artist_metadata.get('name', 'Unknown Artist')}') added to watchlist. Their albums will be processed by the watch manager."
|
||||||
)
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ logger = logging.getLogger(__name__) # Added logger initialization
|
|||||||
playlist_bp = Blueprint("playlist", __name__, url_prefix="/api/playlist")
|
playlist_bp = Blueprint("playlist", __name__, url_prefix="/api/playlist")
|
||||||
|
|
||||||
|
|
||||||
|
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
|
||||||
|
"""Construct a Spotify URL for a given item ID and type."""
|
||||||
|
return f"https://open.spotify.com/{item_type}/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route("/download/<playlist_id>", methods=["GET"])
|
@playlist_bp.route("/download/<playlist_id>", methods=["GET"])
|
||||||
def handle_download(playlist_id):
|
def handle_download(playlist_id):
|
||||||
# Retrieve essential parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
@@ -41,14 +46,15 @@ def handle_download(playlist_id):
|
|||||||
orig_params = request.args.to_dict()
|
orig_params = request.args.to_dict()
|
||||||
|
|
||||||
# Construct the URL from playlist_id
|
# Construct the URL from playlist_id
|
||||||
url = f"https://open.spotify.com/playlist/{playlist_id}"
|
url = construct_spotify_url(playlist_id, "playlist")
|
||||||
orig_params["original_url"] = (
|
orig_params["original_url"] = (
|
||||||
request.url
|
request.url
|
||||||
) # Update original_url to the constructed one
|
) # Update original_url to the constructed one
|
||||||
|
|
||||||
# Fetch metadata from Spotify
|
# Fetch metadata from Spotify using optimized function
|
||||||
try:
|
try:
|
||||||
playlist_info = get_spotify_info(playlist_id, "playlist")
|
from routes.utils.get_info import get_playlist_metadata
|
||||||
|
playlist_info = get_playlist_metadata(playlist_id)
|
||||||
if (
|
if (
|
||||||
not playlist_info
|
not playlist_info
|
||||||
or not playlist_info.get("name")
|
or not playlist_info.get("name")
|
||||||
@@ -177,6 +183,7 @@ def get_playlist_info():
|
|||||||
Expects a query parameter 'id' that contains the Spotify playlist ID.
|
Expects a query parameter 'id' that contains the Spotify playlist ID.
|
||||||
"""
|
"""
|
||||||
spotify_id = request.args.get("id")
|
spotify_id = request.args.get("id")
|
||||||
|
include_tracks = request.args.get("include_tracks", "false").lower() == "true"
|
||||||
|
|
||||||
if not spotify_id:
|
if not spotify_id:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -186,8 +193,9 @@ def get_playlist_info():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import and use the get_spotify_info function from the utility module.
|
# Use the optimized playlist info function
|
||||||
playlist_info = get_spotify_info(spotify_id, "playlist")
|
from routes.utils.get_info import get_playlist_info_optimized
|
||||||
|
playlist_info = get_playlist_info_optimized(spotify_id, include_tracks=include_tracks)
|
||||||
|
|
||||||
# If playlist_info is successfully fetched, check if it's watched
|
# If playlist_info is successfully fetched, check if it's watched
|
||||||
# and augment track items with is_locally_known status
|
# and augment track items with is_locally_known status
|
||||||
@@ -216,6 +224,64 @@ def get_playlist_info():
|
|||||||
return Response(json.dumps(error_data), status=500, mimetype="application/json")
|
return Response(json.dumps(error_data), status=500, mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
@playlist_bp.route("/metadata", methods=["GET"])
|
||||||
|
def get_playlist_metadata():
|
||||||
|
"""
|
||||||
|
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.args.get("id")
|
||||||
|
|
||||||
|
if not spotify_id:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Missing parameter: id"}),
|
||||||
|
status=400,
|
||||||
|
mimetype="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the optimized playlist metadata function
|
||||||
|
from routes.utils.get_info import get_playlist_metadata
|
||||||
|
playlist_metadata = get_playlist_metadata(spotify_id)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json.dumps(playlist_metadata), status=200, mimetype="application/json"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error_data = {"error": str(e), "traceback": traceback.format_exc()}
|
||||||
|
return Response(json.dumps(error_data), status=500, mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
@playlist_bp.route("/tracks", methods=["GET"])
|
||||||
|
def get_playlist_tracks():
|
||||||
|
"""
|
||||||
|
Retrieve playlist tracks with pagination support for progressive loading.
|
||||||
|
Expects query parameters: 'id' (playlist ID), 'limit' (optional), 'offset' (optional).
|
||||||
|
"""
|
||||||
|
spotify_id = request.args.get("id")
|
||||||
|
limit = request.args.get("limit", 50, type=int)
|
||||||
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
|
||||||
|
if not spotify_id:
|
||||||
|
return Response(
|
||||||
|
json.dumps({"error": "Missing parameter: id"}),
|
||||||
|
status=400,
|
||||||
|
mimetype="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 Response(
|
||||||
|
json.dumps(tracks_data), status=200, mimetype="application/json"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error_data = {"error": str(e), "traceback": traceback.format_exc()}
|
||||||
|
return Response(json.dumps(error_data), status=500, mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route("/watch/<string:playlist_spotify_id>", methods=["PUT"])
|
@playlist_bp.route("/watch/<string:playlist_spotify_id>", methods=["PUT"])
|
||||||
def add_to_watchlist(playlist_spotify_id):
|
def add_to_watchlist(playlist_spotify_id):
|
||||||
"""Adds a playlist to the watchlist."""
|
"""Adds a playlist to the watchlist."""
|
||||||
@@ -232,7 +298,8 @@ def add_to_watchlist(playlist_spotify_id):
|
|||||||
), 200
|
), 200
|
||||||
|
|
||||||
# Fetch playlist details from Spotify to populate our DB
|
# Fetch playlist details from Spotify to populate our DB
|
||||||
playlist_data = get_spotify_info(playlist_spotify_id, "playlist")
|
from routes.utils.get_info import get_playlist_metadata
|
||||||
|
playlist_data = get_playlist_metadata(playlist_spotify_id)
|
||||||
if not playlist_data or "id" not in playlist_data:
|
if not playlist_data or "id" not in playlist_data:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."
|
f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ from routes.utils.celery_tasks import (
|
|||||||
get_last_task_status,
|
get_last_task_status,
|
||||||
get_all_tasks,
|
get_all_tasks,
|
||||||
cancel_task,
|
cancel_task,
|
||||||
retry_task,
|
|
||||||
redis_client,
|
|
||||||
delete_task_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -174,9 +171,6 @@ def delete_task(task_id):
|
|||||||
# First, cancel the task if it's running
|
# First, cancel the task if it's running
|
||||||
cancel_task(task_id)
|
cancel_task(task_id)
|
||||||
|
|
||||||
# Then, delete all associated data from Redis
|
|
||||||
delete_task_data(task_id)
|
|
||||||
|
|
||||||
return {"message": f"Task {task_id} deleted successfully"}, 200
|
return {"message": f"Task {task_id} deleted successfully"}, 200
|
||||||
|
|
||||||
|
|
||||||
@@ -185,14 +179,9 @@ def list_tasks():
|
|||||||
"""
|
"""
|
||||||
Retrieve a list of all tasks in the system.
|
Retrieve a list of all tasks in the system.
|
||||||
Returns a detailed list of task objects including status and metadata.
|
Returns a detailed list of task objects including status and metadata.
|
||||||
By default, it returns active tasks. Use ?include_finished=true to include completed tasks.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check for 'include_finished' query parameter
|
tasks = get_all_tasks()
|
||||||
include_finished_str = request.args.get("include_finished", "false")
|
|
||||||
include_finished = include_finished_str.lower() in ["true", "1", "yes"]
|
|
||||||
|
|
||||||
tasks = get_all_tasks(include_finished=include_finished)
|
|
||||||
detailed_tasks = []
|
detailed_tasks = []
|
||||||
for task_summary in tasks:
|
for task_summary in tasks:
|
||||||
task_id = task_summary.get("task_id")
|
task_id = task_summary.get("task_id")
|
||||||
@@ -315,7 +304,7 @@ def cancel_all_tasks():
|
|||||||
Cancel all active (running or queued) tasks.
|
Cancel all active (running or queued) tasks.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tasks_to_cancel = get_all_tasks(include_finished=False)
|
tasks_to_cancel = get_all_tasks()
|
||||||
cancelled_count = 0
|
cancelled_count = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from routes.utils.get_info import get_spotify_info # Added import
|
|||||||
track_bp = Blueprint("track", __name__)
|
track_bp = Blueprint("track", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
|
||||||
|
"""Construct a Spotify URL for a given item ID and type."""
|
||||||
|
return f"https://open.spotify.com/{item_type}/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
@track_bp.route("/download/<track_id>", methods=["GET"])
|
@track_bp.route("/download/<track_id>", methods=["GET"])
|
||||||
def handle_download(track_id):
|
def handle_download(track_id):
|
||||||
# Retrieve essential parameters from the request.
|
# Retrieve essential parameters from the request.
|
||||||
@@ -26,7 +31,7 @@ def handle_download(track_id):
|
|||||||
orig_params = request.args.to_dict()
|
orig_params = request.args.to_dict()
|
||||||
|
|
||||||
# Construct the URL from track_id
|
# Construct the URL from track_id
|
||||||
url = f"https://open.spotify.com/track/{track_id}"
|
url = construct_spotify_url(track_id, "track")
|
||||||
orig_params["original_url"] = url # Update original_url to the constructed one
|
orig_params["original_url"] = url # Update original_url to the constructed one
|
||||||
|
|
||||||
# Fetch metadata from Spotify
|
# Fetch metadata from Spotify
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from routes.utils.get_info import get_spotify_info
|
|||||||
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
||||||
from routes.utils.errors import DuplicateDownloadError
|
from routes.utils.errors import DuplicateDownloadError
|
||||||
|
|
||||||
from deezspot.easy_spoty import Spo
|
|
||||||
from deezspot.libutils.utils import get_ids, link_is_valid
|
from deezspot.libutils.utils import get_ids, link_is_valid
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -71,8 +70,6 @@ def get_artist_discography(
|
|||||||
f"Error checking Spotify account '{main_spotify_account_name}' for discography context: {e}"
|
f"Error checking Spotify account '{main_spotify_account_name}' for discography context: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
Spo.__init__(client_id, client_secret) # Initialize with global API keys
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
artist_id = get_ids(url)
|
artist_id = get_ids(url)
|
||||||
except Exception as id_error:
|
except Exception as id_error:
|
||||||
@@ -81,12 +78,8 @@ def get_artist_discography(
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# The progress_callback is not a standard param for Spo.get_artist
|
# Use the optimized get_spotify_info function
|
||||||
# If Spo.get_artist is meant to be Spo.get_artist_discography, that would take limit/offset
|
discography = get_spotify_info(artist_id, "artist_discography")
|
||||||
# Assuming it's Spo.get_artist which takes artist_id and album_type.
|
|
||||||
# If progress_callback was for a different Spo method, this needs review.
|
|
||||||
# For now, removing progress_callback from this specific call as Spo.get_artist doesn't use it.
|
|
||||||
discography = Spo.get_artist(artist_id, album_type=album_type)
|
|
||||||
return discography
|
return discography
|
||||||
except Exception as fetch_error:
|
except Exception as fetch_error:
|
||||||
msg = f"An error occurred while fetching the discography: {fetch_error}"
|
msg = f"An error occurred while fetching the discography: {fetch_error}"
|
||||||
|
|||||||
@@ -1,94 +1,335 @@
|
|||||||
from deezspot.easy_spoty import Spo
|
import spotipy
|
||||||
|
from spotipy.oauth2 import SpotifyClientCredentials
|
||||||
from routes.utils.celery_queue_manager import get_config_params
|
from routes.utils.celery_queue_manager import get_config_params
|
||||||
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Import Deezer API and logging
|
# Import Deezer API and logging
|
||||||
from deezspot.deezloader.dee_api import API as DeezerAPI
|
from deezspot.deezloader.dee_api import API as DeezerAPI
|
||||||
import logging
|
|
||||||
|
|
||||||
# Initialize logger
|
# Initialize logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global Spotify client instance for reuse
|
||||||
|
_spotify_client = None
|
||||||
|
_last_client_init = 0
|
||||||
|
_client_init_interval = 3600 # Reinitialize client every hour
|
||||||
|
|
||||||
def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None):
|
def _get_spotify_client():
|
||||||
"""
|
"""
|
||||||
Get info from Spotify API. Uses global client_id/secret from search.json.
|
Get or create a Spotify client with global credentials.
|
||||||
The default Spotify account from main.json might still be relevant for other Spo settings or if Spo uses it.
|
Implements client reuse and periodic reinitialization.
|
||||||
|
|
||||||
Args:
|
|
||||||
spotify_id: The Spotify ID of the entity
|
|
||||||
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode)
|
|
||||||
limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist_discography".
|
|
||||||
offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist_discography".
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with the entity information
|
|
||||||
"""
|
"""
|
||||||
client_id, client_secret = _get_global_spotify_api_creds()
|
global _spotify_client, _last_client_init
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Reinitialize client if it's been more than an hour or if client doesn't exist
|
||||||
|
if (_spotify_client is None or
|
||||||
|
current_time - _last_client_init > _client_init_interval):
|
||||||
|
|
||||||
|
client_id, client_secret = _get_global_spotify_api_creds()
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
|
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get config parameters including default Spotify account name
|
# Create new client
|
||||||
# This might still be useful if Spo uses the account name for other things (e.g. market/region if not passed explicitly)
|
_spotify_client = spotipy.Spotify(
|
||||||
# For now, we are just ensuring the API keys are set.
|
client_credentials_manager=SpotifyClientCredentials(
|
||||||
config_params = get_config_params()
|
client_id=client_id,
|
||||||
main_spotify_account_name = config_params.get(
|
client_secret=client_secret
|
||||||
"spotify", ""
|
)
|
||||||
) # Still good to know which account is 'default' contextually
|
|
||||||
|
|
||||||
if not main_spotify_account_name:
|
|
||||||
# This is less critical now that API keys are global, but could indicate a misconfiguration
|
|
||||||
# if other parts of Spo expect an account context.
|
|
||||||
print(
|
|
||||||
"WARN: No default Spotify account name configured in settings (main.json). API calls will use global keys."
|
|
||||||
)
|
)
|
||||||
else:
|
_last_client_init = current_time
|
||||||
# Optionally, one could load the specific account's region here if Spo.init or methods need it,
|
logger.info("Spotify client initialized/reinitialized")
|
||||||
# but easy_spoty's Spo doesn't seem to take region directly in __init__.
|
|
||||||
# It might use it internally based on account details if credentials.json (blob) contains it.
|
return _spotify_client
|
||||||
try:
|
|
||||||
# We call get_credential just to check if the account exists,
|
|
||||||
# not for client_id/secret anymore for Spo.__init__
|
|
||||||
get_credential("spotify", main_spotify_account_name)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# This is a more serious warning if an account is expected to exist.
|
|
||||||
print(
|
|
||||||
f"WARN: Default Spotify account '{main_spotify_account_name}' configured in main.json was not found in credentials database."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"WARN: Error accessing default Spotify account '{main_spotify_account_name}': {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize the Spotify client with GLOBAL credentials
|
def _rate_limit_handler(func):
|
||||||
Spo.__init__(client_id, client_secret)
|
"""
|
||||||
|
Decorator to handle rate limiting with exponential backoff.
|
||||||
|
"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
max_retries = 3
|
||||||
|
base_delay = 1
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
if "429" in str(e) or "rate limit" in str(e).lower():
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
delay = base_delay * (2 ** attempt)
|
||||||
|
logger.warning(f"Rate limited, retrying in {delay} seconds...")
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
raise e
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
if spotify_type == "track":
|
@_rate_limit_handler
|
||||||
return Spo.get_track(spotify_id)
|
def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]:
|
||||||
elif spotify_type == "album":
|
"""
|
||||||
return Spo.get_album(spotify_id)
|
Get playlist metadata only (no tracks) to avoid rate limiting.
|
||||||
elif spotify_type == "playlist":
|
|
||||||
return Spo.get_playlist(spotify_id)
|
Args:
|
||||||
elif spotify_type == "artist_discography":
|
playlist_id: The Spotify playlist ID
|
||||||
if limit is not None and offset is not None:
|
|
||||||
return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset)
|
Returns:
|
||||||
elif limit is not None:
|
Dictionary with playlist metadata (name, description, owner, etc.)
|
||||||
return Spo.get_artist_discography(spotify_id, limit=limit)
|
"""
|
||||||
elif offset is not None:
|
client = _get_spotify_client()
|
||||||
return Spo.get_artist_discography(spotify_id, offset=offset)
|
|
||||||
|
try:
|
||||||
|
# Get basic playlist info without tracks
|
||||||
|
playlist = client.playlist(playlist_id, fields="id,name,description,owner,images,snapshot_id,public,followers,tracks.total")
|
||||||
|
|
||||||
|
# Add a flag to indicate this is metadata only
|
||||||
|
playlist['_metadata_only'] = True
|
||||||
|
playlist['_tracks_loaded'] = False
|
||||||
|
|
||||||
|
logger.debug(f"Retrieved playlist metadata for {playlist_id}: {playlist.get('name', 'Unknown')}")
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching playlist metadata for {playlist_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@_rate_limit_handler
|
||||||
|
def get_playlist_tracks(playlist_id: str, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get playlist tracks with pagination support to handle large playlists efficiently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_id: The Spotify playlist ID
|
||||||
|
limit: Number of tracks to fetch per request (max 100)
|
||||||
|
offset: Starting position for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with tracks data
|
||||||
|
"""
|
||||||
|
client = _get_spotify_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get tracks with specified limit and offset
|
||||||
|
tracks_data = client.playlist_tracks(
|
||||||
|
playlist_id,
|
||||||
|
limit=min(limit, 100), # Spotify API max is 100
|
||||||
|
offset=offset,
|
||||||
|
fields="items(track(id,name,artists,album,external_urls,preview_url,duration_ms,explicit,popularity)),total,limit,offset"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Retrieved {len(tracks_data.get('items', []))} tracks for playlist {playlist_id} (offset: {offset})")
|
||||||
|
return tracks_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching playlist tracks for {playlist_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@_rate_limit_handler
|
||||||
|
def get_playlist_full(playlist_id: str, batch_size: int = 100) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get complete playlist data with all tracks, using batched requests to avoid rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_id: The Spotify playlist ID
|
||||||
|
batch_size: Number of tracks to fetch per batch (max 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete playlist data with all tracks
|
||||||
|
"""
|
||||||
|
client = _get_spotify_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First get metadata
|
||||||
|
playlist = get_playlist_metadata(playlist_id)
|
||||||
|
|
||||||
|
# Get total track count
|
||||||
|
total_tracks = playlist.get('tracks', {}).get('total', 0)
|
||||||
|
|
||||||
|
if total_tracks == 0:
|
||||||
|
playlist['tracks'] = {'items': [], 'total': 0}
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
# Fetch all tracks in batches
|
||||||
|
all_tracks = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while offset < total_tracks:
|
||||||
|
batch = get_playlist_tracks(playlist_id, limit=batch_size, offset=offset)
|
||||||
|
batch_items = batch.get('items', [])
|
||||||
|
all_tracks.extend(batch_items)
|
||||||
|
|
||||||
|
offset += len(batch_items)
|
||||||
|
|
||||||
|
# Add small delay between batches to be respectful to API
|
||||||
|
if offset < total_tracks:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Update playlist with complete tracks data
|
||||||
|
playlist['tracks'] = {
|
||||||
|
'items': all_tracks,
|
||||||
|
'total': total_tracks,
|
||||||
|
'limit': batch_size,
|
||||||
|
'offset': 0
|
||||||
|
}
|
||||||
|
playlist['_metadata_only'] = False
|
||||||
|
playlist['_tracks_loaded'] = True
|
||||||
|
|
||||||
|
logger.info(f"Retrieved complete playlist {playlist_id} with {total_tracks} tracks")
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching complete playlist {playlist_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def check_playlist_updated(playlist_id: str, last_snapshot_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if playlist has been updated by comparing snapshot_id.
|
||||||
|
This is much more efficient than fetching all tracks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_id: The Spotify playlist ID
|
||||||
|
last_snapshot_id: The last known snapshot_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if playlist has been updated, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
metadata = get_playlist_metadata(playlist_id)
|
||||||
|
current_snapshot_id = metadata.get('snapshot_id')
|
||||||
|
|
||||||
|
return current_snapshot_id != last_snapshot_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking playlist update status for {playlist_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@_rate_limit_handler
|
||||||
|
def get_spotfy_info(spotify_id: str, spotify_type: str, limit: Optional[int] = None, offset: Optional[int] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get info from Spotify API using Spotipy directly.
|
||||||
|
Optimized to prevent rate limiting by using appropriate endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spotify_id: The Spotify ID of the entity
|
||||||
|
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode)
|
||||||
|
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:
|
||||||
|
Dictionary with the entity information
|
||||||
|
"""
|
||||||
|
client = _get_spotify_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if spotify_type == "track":
|
||||||
|
return client.track(spotify_id)
|
||||||
|
|
||||||
|
elif spotify_type == "album":
|
||||||
|
return client.album(spotify_id)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return albums
|
||||||
|
|
||||||
|
elif spotify_type == "episode":
|
||||||
|
return client.episode(spotify_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return Spo.get_artist_discography(spotify_id)
|
raise ValueError(f"Unsupported Spotify type: {spotify_type}")
|
||||||
elif spotify_type == "artist":
|
|
||||||
return Spo.get_artist(spotify_id)
|
except Exception as e:
|
||||||
elif spotify_type == "episode":
|
logger.error(f"Error fetching {spotify_type} {spotify_id}: {e}")
|
||||||
return Spo.get_episode(spotify_id)
|
raise
|
||||||
|
|
||||||
|
# Cache for playlist metadata to reduce API calls
|
||||||
|
_playlist_metadata_cache = {}
|
||||||
|
_cache_ttl = 300 # 5 minutes cache
|
||||||
|
|
||||||
|
def get_cached_playlist_metadata(playlist_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get playlist metadata from cache if available and not expired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_id: The Spotify playlist ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached metadata or None if not available/expired
|
||||||
|
"""
|
||||||
|
if playlist_id in _playlist_metadata_cache:
|
||||||
|
cached_data, timestamp = _playlist_metadata_cache[playlist_id]
|
||||||
|
if time.time() - timestamp < _cache_ttl:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cache_playlist_metadata(playlist_id: str, metadata: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Cache playlist metadata with timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_id: The Spotify playlist ID
|
||||||
|
metadata: The metadata to cache
|
||||||
|
"""
|
||||||
|
_playlist_metadata_cache[playlist_id] = (metadata, time.time())
|
||||||
|
|
||||||
|
def get_playlist_info_optimized(playlist_id: str, include_tracks: bool = False) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Optimized playlist info function that uses caching and selective loading.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_id: The Spotify playlist ID
|
||||||
|
include_tracks: Whether to include track data (default: False to save API calls)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Playlist data with or without tracks
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
cached_metadata = get_cached_playlist_metadata(playlist_id)
|
||||||
|
|
||||||
|
if cached_metadata and not include_tracks:
|
||||||
|
logger.debug(f"Returning cached metadata for playlist {playlist_id}")
|
||||||
|
return cached_metadata
|
||||||
|
|
||||||
|
if include_tracks:
|
||||||
|
# Get complete playlist data
|
||||||
|
playlist_data = get_playlist_full(playlist_id)
|
||||||
|
# Cache the metadata portion
|
||||||
|
metadata_only = {k: v for k, v in playlist_data.items() if k != 'tracks'}
|
||||||
|
metadata_only['_metadata_only'] = True
|
||||||
|
metadata_only['_tracks_loaded'] = False
|
||||||
|
cache_playlist_metadata(playlist_id, metadata_only)
|
||||||
|
return playlist_data
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported Spotify type: {spotify_type}")
|
# Get metadata only
|
||||||
|
metadata = get_playlist_metadata(playlist_id)
|
||||||
|
cache_playlist_metadata(playlist_id, metadata)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
# Keep the existing Deezer functions unchanged
|
||||||
def get_deezer_info(deezer_id, deezer_type, limit=None):
|
def get_deezer_info(deezer_id, deezer_type, limit=None):
|
||||||
"""
|
"""
|
||||||
Get info from Deezer API.
|
Get info from Deezer API.
|
||||||
|
|||||||
@@ -1,29 +1,57 @@
|
|||||||
from deezspot.easy_spoty import Spo
|
import spotipy
|
||||||
|
from spotipy.oauth2 import SpotifyClientCredentials
|
||||||
import logging
|
import logging
|
||||||
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
||||||
|
import time
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global Spotify client instance for reuse (same pattern as get_info.py)
|
||||||
|
_spotify_client = None
|
||||||
|
_last_client_init = 0
|
||||||
|
_client_init_interval = 3600 # Reinitialize client every hour
|
||||||
|
|
||||||
|
def _get_spotify_client():
|
||||||
|
"""
|
||||||
|
Get or create a Spotify client with global credentials.
|
||||||
|
Implements client reuse and periodic reinitialization.
|
||||||
|
"""
|
||||||
|
global _spotify_client, _last_client_init
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Reinitialize client if it's been more than an hour or if client doesn't exist
|
||||||
|
if (_spotify_client is None or
|
||||||
|
current_time - _last_client_init > _client_init_interval):
|
||||||
|
|
||||||
|
client_id, client_secret = _get_global_spotify_api_creds()
|
||||||
|
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
raise ValueError(
|
||||||
|
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new client
|
||||||
|
_spotify_client = spotipy.Spotify(
|
||||||
|
client_credentials_manager=SpotifyClientCredentials(
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_last_client_init = current_time
|
||||||
|
logger.info("Spotify client initialized/reinitialized for search")
|
||||||
|
|
||||||
|
return _spotify_client
|
||||||
|
|
||||||
def search(query: str, search_type: str, limit: int = 3, main: str = None) -> dict:
|
def search(query: str, search_type: str, limit: int = 3, main: str = None) -> dict:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Search requested: query='{query}', type={search_type}, limit={limit}, main_account_name={main}"
|
f"Search requested: query='{query}', type={search_type}, limit={limit}, main_account_name={main}"
|
||||||
)
|
|
||||||
|
|
||||||
client_id, client_secret = _get_global_spotify_api_creds()
|
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
|
||||||
logger.error(
|
|
||||||
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
|
|
||||||
)
|
|
||||||
raise ValueError(
|
|
||||||
"Spotify API credentials are not configured globally for search."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if main:
|
if main:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Spotify account context '{main}' was provided for search. API keys are global, but this account might be used for other context by Spo if relevant."
|
f"Spotify account context '{main}' was provided for search. API keys are global, but this account might be used for other context."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
get_credential("spotify", main)
|
get_credential("spotify", main)
|
||||||
@@ -41,14 +69,32 @@ def search(query: str, search_type: str, limit: int = 3, main: str = None) -> di
|
|||||||
"No specific 'main' account context provided for search. Using global API keys."
|
"No specific 'main' account context provided for search. Using global API keys."
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Initializing Spotify client with global API credentials for search.")
|
logger.debug("Getting Spotify client for search.")
|
||||||
Spo.__init__(client_id, client_secret)
|
client = _get_spotify_client()
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Executing Spotify search with query='{query}', type={search_type}, limit={limit}"
|
f"Executing Spotify search with query='{query}', type={search_type}, limit={limit}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
spotify_response = Spo.search(query=query, search_type=search_type, limit=limit)
|
# Map search types to Spotipy search types
|
||||||
|
search_type_map = {
|
||||||
|
'track': 'track',
|
||||||
|
'album': 'album',
|
||||||
|
'artist': 'artist',
|
||||||
|
'playlist': 'playlist',
|
||||||
|
'episode': 'episode',
|
||||||
|
'show': 'show'
|
||||||
|
}
|
||||||
|
|
||||||
|
spotify_type = search_type_map.get(search_type.lower(), 'track')
|
||||||
|
|
||||||
|
# Execute search using Spotipy
|
||||||
|
spotify_response = client.search(
|
||||||
|
q=query,
|
||||||
|
type=spotify_type,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Search completed successfully for query: '{query}'")
|
logger.info(f"Search completed successfully for query: '{query}'")
|
||||||
return spotify_response
|
return spotify_response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ EXPECTED_PLAYLIST_TRACKS_COLUMNS = {
|
|||||||
"added_to_db": "INTEGER",
|
"added_to_db": "INTEGER",
|
||||||
"is_present_in_spotify": "INTEGER DEFAULT 1",
|
"is_present_in_spotify": "INTEGER DEFAULT 1",
|
||||||
"last_seen_in_spotify": "INTEGER",
|
"last_seen_in_spotify": "INTEGER",
|
||||||
|
"snapshot_id": "TEXT", # Track the snapshot_id when this track was added/updated
|
||||||
}
|
}
|
||||||
|
|
||||||
EXPECTED_WATCHED_ARTISTS_COLUMNS = {
|
EXPECTED_WATCHED_ARTISTS_COLUMNS = {
|
||||||
@@ -165,6 +166,11 @@ def init_playlists_db():
|
|||||||
"watched playlists",
|
"watched playlists",
|
||||||
):
|
):
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Update all existing playlist track tables with new schema
|
||||||
|
_update_all_playlist_track_tables(cursor)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Playlists database initialized/updated successfully at {PLAYLISTS_DB_PATH}"
|
f"Playlists database initialized/updated successfully at {PLAYLISTS_DB_PATH}"
|
||||||
)
|
)
|
||||||
@@ -173,6 +179,87 @@ def init_playlists_db():
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _update_all_playlist_track_tables(cursor: sqlite3.Cursor):
|
||||||
|
"""Updates all existing playlist track tables to ensure they have the latest schema."""
|
||||||
|
try:
|
||||||
|
# Get all table names that start with 'playlist_'
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'playlist_%'")
|
||||||
|
playlist_tables = cursor.fetchall()
|
||||||
|
|
||||||
|
for table_row in playlist_tables:
|
||||||
|
table_name = table_row[0]
|
||||||
|
if _ensure_table_schema(
|
||||||
|
cursor,
|
||||||
|
table_name,
|
||||||
|
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
|
||||||
|
f"playlist tracks ({table_name})",
|
||||||
|
):
|
||||||
|
logger.info(f"Updated schema for existing playlist track table: {table_name}")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(f"Error updating playlist track tables schema: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_all_existing_tables_schema():
|
||||||
|
"""Updates all existing tables to ensure they have the latest schema. Can be called independently."""
|
||||||
|
try:
|
||||||
|
with _get_playlists_db_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Update main watched_playlists table
|
||||||
|
if _ensure_table_schema(
|
||||||
|
cursor,
|
||||||
|
"watched_playlists",
|
||||||
|
EXPECTED_WATCHED_PLAYLISTS_COLUMNS,
|
||||||
|
"watched playlists",
|
||||||
|
):
|
||||||
|
logger.info("Updated schema for watched_playlists table")
|
||||||
|
|
||||||
|
# Update all playlist track tables
|
||||||
|
_update_all_playlist_track_tables(cursor)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Successfully updated all existing tables schema in playlists database")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(f"Error updating existing tables schema: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_playlist_table_schema(playlist_spotify_id: str):
|
||||||
|
"""Ensures a specific playlist's track table has the latest schema."""
|
||||||
|
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
||||||
|
try:
|
||||||
|
with _get_playlists_db_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if table exists
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
logger.warning(f"Table {table_name} does not exist. Cannot update schema.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update schema
|
||||||
|
if _ensure_table_schema(
|
||||||
|
cursor,
|
||||||
|
table_name,
|
||||||
|
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
|
||||||
|
f"playlist tracks ({playlist_spotify_id})",
|
||||||
|
):
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Updated schema for playlist track table: {table_name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.info(f"Schema already up-to-date for playlist track table: {table_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(f"Error updating schema for playlist {playlist_spotify_id}: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _create_playlist_tracks_table(playlist_spotify_id: str):
|
def _create_playlist_tracks_table(playlist_spotify_id: str):
|
||||||
"""Creates or updates a table for a specific playlist to store its tracks in playlists.db."""
|
"""Creates or updates a table for a specific playlist to store its tracks in playlists.db."""
|
||||||
table_name = f"playlist_{playlist_spotify_id.replace('-', '_').replace(' ', '_')}" # Sanitize table name
|
table_name = f"playlist_{playlist_spotify_id.replace('-', '_').replace(' ', '_')}" # Sanitize table name
|
||||||
@@ -192,7 +279,8 @@ def _create_playlist_tracks_table(playlist_spotify_id: str):
|
|||||||
added_at_playlist TEXT, -- When track was added to Spotify playlist
|
added_at_playlist TEXT, -- When track was added to Spotify playlist
|
||||||
added_to_db INTEGER, -- Timestamp when track was added to this DB table
|
added_to_db INTEGER, -- Timestamp when track was added to this DB table
|
||||||
is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist
|
is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist
|
||||||
last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist
|
last_seen_in_spotify INTEGER, -- Timestamp when last confirmed in Spotify playlist
|
||||||
|
snapshot_id TEXT -- Track the snapshot_id when this track was added/updated
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
# Ensure schema
|
# Ensure schema
|
||||||
@@ -218,6 +306,10 @@ def add_playlist_to_watch(playlist_data: dict):
|
|||||||
"""Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db."""
|
"""Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db."""
|
||||||
try:
|
try:
|
||||||
_create_playlist_tracks_table(playlist_data["id"])
|
_create_playlist_tracks_table(playlist_data["id"])
|
||||||
|
|
||||||
|
# Construct Spotify URL manually since external_urls might not be present in metadata
|
||||||
|
spotify_url = f"https://open.spotify.com/playlist/{playlist_data['id']}"
|
||||||
|
|
||||||
with _get_playlists_db_connection() as conn: # Use playlists connection
|
with _get_playlists_db_connection() as conn: # Use playlists connection
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
@@ -234,7 +326,7 @@ def add_playlist_to_watch(playlist_data: dict):
|
|||||||
"display_name", playlist_data["owner"]["id"]
|
"display_name", playlist_data["owner"]["id"]
|
||||||
),
|
),
|
||||||
playlist_data["tracks"]["total"],
|
playlist_data["tracks"]["total"],
|
||||||
playlist_data["external_urls"]["spotify"],
|
spotify_url, # Use constructed URL instead of external_urls
|
||||||
playlist_data.get("snapshot_id"),
|
playlist_data.get("snapshot_id"),
|
||||||
int(time.time()),
|
int(time.time()),
|
||||||
int(time.time()),
|
int(time.time()),
|
||||||
@@ -363,11 +455,91 @@ def get_playlist_track_ids_from_db(playlist_spotify_id: str):
|
|||||||
return track_ids
|
return track_ids
|
||||||
|
|
||||||
|
|
||||||
def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
|
def get_playlist_tracks_with_snapshot_from_db(playlist_spotify_id: str):
|
||||||
|
"""Retrieves all tracks with their snapshot_ids from a specific playlist's tracks table in playlists.db."""
|
||||||
|
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
||||||
|
tracks_data = {}
|
||||||
|
try:
|
||||||
|
with _get_playlists_db_connection() as conn: # Use playlists connection
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Track table {table_name} does not exist in {PLAYLISTS_DB_PATH}. Cannot fetch track data."
|
||||||
|
)
|
||||||
|
return tracks_data
|
||||||
|
|
||||||
|
# Ensure the table has the latest schema before querying
|
||||||
|
_ensure_table_schema(
|
||||||
|
cursor,
|
||||||
|
table_name,
|
||||||
|
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
|
||||||
|
f"playlist tracks ({playlist_spotify_id})",
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT spotify_track_id, snapshot_id, title FROM {table_name} WHERE is_present_in_spotify = 1"
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
for row in rows:
|
||||||
|
tracks_data[row["spotify_track_id"]] = {
|
||||||
|
"snapshot_id": row["snapshot_id"],
|
||||||
|
"title": row["title"]
|
||||||
|
}
|
||||||
|
return tracks_data
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error retrieving track data for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return tracks_data
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlist_total_tracks_from_db(playlist_spotify_id: str) -> int:
|
||||||
|
"""Retrieves the total number of tracks in the database for a specific playlist."""
|
||||||
|
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
||||||
|
try:
|
||||||
|
with _get_playlists_db_connection() as conn: # Use playlists connection
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Ensure the table has the latest schema before querying
|
||||||
|
_ensure_table_schema(
|
||||||
|
cursor,
|
||||||
|
table_name,
|
||||||
|
EXPECTED_PLAYLIST_TRACKS_COLUMNS,
|
||||||
|
f"playlist tracks ({playlist_spotify_id})",
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT COUNT(*) as count FROM {table_name} WHERE is_present_in_spotify = 1"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row["count"] if row else 0
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error retrieving track count for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list, snapshot_id: str = None):
|
||||||
"""
|
"""
|
||||||
Updates existing tracks in the playlist's DB table to mark them as currently present
|
Updates existing tracks in the playlist's DB table to mark them as currently present
|
||||||
in Spotify and updates their last_seen timestamp. Also refreshes metadata.
|
in Spotify and updates their last_seen timestamp and snapshot_id. Also refreshes metadata.
|
||||||
Does NOT insert new tracks. New tracks are only added upon successful download.
|
Does NOT insert new tracks. New tracks are only added upon successful download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_spotify_id: The Spotify playlist ID
|
||||||
|
tracks_data: List of track items from Spotify API
|
||||||
|
snapshot_id: The current snapshot_id for this playlist update
|
||||||
"""
|
"""
|
||||||
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
||||||
if not tracks_data:
|
if not tracks_data:
|
||||||
@@ -401,7 +573,7 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
|
|||||||
# Prepare tuple for UPDATE statement.
|
# Prepare tuple for UPDATE statement.
|
||||||
# Order: title, artist_names, album_name, album_artist_names, track_number,
|
# Order: title, artist_names, album_name, album_artist_names, track_number,
|
||||||
# album_spotify_id, duration_ms, added_at_playlist,
|
# album_spotify_id, duration_ms, added_at_playlist,
|
||||||
# is_present_in_spotify, last_seen_in_spotify, spotify_track_id (for WHERE)
|
# is_present_in_spotify, last_seen_in_spotify, snapshot_id, spotify_track_id (for WHERE)
|
||||||
tracks_to_update.append(
|
tracks_to_update.append(
|
||||||
(
|
(
|
||||||
track.get("name", "N/A"),
|
track.get("name", "N/A"),
|
||||||
@@ -414,7 +586,7 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
|
|||||||
track_item.get("added_at"), # From playlist item, update if changed
|
track_item.get("added_at"), # From playlist item, update if changed
|
||||||
1, # is_present_in_spotify flag
|
1, # is_present_in_spotify flag
|
||||||
current_time, # last_seen_in_spotify timestamp
|
current_time, # last_seen_in_spotify timestamp
|
||||||
# added_to_db is NOT updated here as this function only updates existing records.
|
snapshot_id, # Update snapshot_id for this track
|
||||||
track["id"], # spotify_track_id for the WHERE clause
|
track["id"], # spotify_track_id for the WHERE clause
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -446,7 +618,8 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
|
|||||||
duration_ms = ?,
|
duration_ms = ?,
|
||||||
added_at_playlist = ?,
|
added_at_playlist = ?,
|
||||||
is_present_in_spotify = ?,
|
is_present_in_spotify = ?,
|
||||||
last_seen_in_spotify = ?
|
last_seen_in_spotify = ?,
|
||||||
|
snapshot_id = ?
|
||||||
WHERE spotify_track_id = ?
|
WHERE spotify_track_id = ?
|
||||||
""",
|
""",
|
||||||
tracks_to_update,
|
tracks_to_update,
|
||||||
@@ -611,7 +784,7 @@ def remove_specific_tracks_from_playlist_table(
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict):
|
def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict, snapshot_id: str = None):
|
||||||
"""Adds or updates a single track in the specified playlist's tracks table in playlists.db."""
|
"""Adds or updates a single track in the specified playlist's tracks table in playlists.db."""
|
||||||
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
|
||||||
track_detail = track_item_for_db.get("track")
|
track_detail = track_item_for_db.get("track")
|
||||||
@@ -646,6 +819,7 @@ def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db:
|
|||||||
current_time,
|
current_time,
|
||||||
1,
|
1,
|
||||||
current_time,
|
current_time,
|
||||||
|
snapshot_id, # Add snapshot_id to the tuple
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with _get_playlists_db_connection() as conn: # Use playlists connection
|
with _get_playlists_db_connection() as conn: # Use playlists connection
|
||||||
@@ -654,8 +828,8 @@ def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db:
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT OR REPLACE INTO {table_name}
|
INSERT OR REPLACE INTO {table_name}
|
||||||
(spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify)
|
(spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify, snapshot_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
track_data_tuple,
|
track_data_tuple,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ from routes.utils.watch.db import (
|
|||||||
get_watched_playlists,
|
get_watched_playlists,
|
||||||
get_watched_playlist,
|
get_watched_playlist,
|
||||||
get_playlist_track_ids_from_db,
|
get_playlist_track_ids_from_db,
|
||||||
|
get_playlist_tracks_with_snapshot_from_db,
|
||||||
|
get_playlist_total_tracks_from_db,
|
||||||
add_tracks_to_playlist_db,
|
add_tracks_to_playlist_db,
|
||||||
update_playlist_snapshot,
|
update_playlist_snapshot,
|
||||||
mark_tracks_as_not_present_in_spotify,
|
mark_tracks_as_not_present_in_spotify,
|
||||||
|
update_all_existing_tables_schema,
|
||||||
|
ensure_playlist_table_schema,
|
||||||
# Artist watch DB functions
|
# Artist watch DB functions
|
||||||
get_watched_artists,
|
get_watched_artists,
|
||||||
get_watched_artist,
|
get_watched_artist,
|
||||||
@@ -20,6 +24,9 @@ from routes.utils.watch.db import (
|
|||||||
)
|
)
|
||||||
from routes.utils.get_info import (
|
from routes.utils.get_info import (
|
||||||
get_spotify_info,
|
get_spotify_info,
|
||||||
|
get_playlist_metadata,
|
||||||
|
get_playlist_tracks,
|
||||||
|
check_playlist_updated,
|
||||||
) # To fetch playlist, track, artist, and album details
|
) # To fetch playlist, track, artist, and album details
|
||||||
from routes.utils.celery_queue_manager import download_queue_manager
|
from routes.utils.celery_queue_manager import download_queue_manager
|
||||||
|
|
||||||
@@ -34,6 +41,7 @@ DEFAULT_WATCH_CONFIG = {
|
|||||||
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists
|
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists
|
||||||
"delay_between_playlists_seconds": 2,
|
"delay_between_playlists_seconds": 2,
|
||||||
"delay_between_artists_seconds": 5, # Added for artists
|
"delay_between_artists_seconds": 5, # Added for artists
|
||||||
|
"use_snapshot_id_checking": True, # Enable snapshot_id checking for efficiency
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +90,152 @@ def construct_spotify_url(item_id, item_type="track"):
|
|||||||
return f"https://open.spotify.com/{item_type}/{item_id}"
|
return f"https://open.spotify.com/{item_type}/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def has_playlist_changed(playlist_spotify_id: str, current_snapshot_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a playlist has changed by comparing snapshot_id.
|
||||||
|
This is much more efficient than fetching all tracks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_spotify_id: The Spotify playlist ID
|
||||||
|
current_snapshot_id: The current snapshot_id from API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if playlist has changed, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_playlist = get_watched_playlist(playlist_spotify_id)
|
||||||
|
if not db_playlist:
|
||||||
|
# Playlist not in database, consider it as "changed" to trigger initial processing
|
||||||
|
return True
|
||||||
|
|
||||||
|
last_snapshot_id = db_playlist.get("snapshot_id")
|
||||||
|
if not last_snapshot_id:
|
||||||
|
# No previous snapshot_id, consider it as "changed" to trigger initial processing
|
||||||
|
return True
|
||||||
|
|
||||||
|
return current_snapshot_id != last_snapshot_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking playlist change status for {playlist_spotify_id}: {e}")
|
||||||
|
# On error, assume playlist has changed to be safe
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def needs_track_sync(playlist_spotify_id: str, current_snapshot_id: str, api_total_tracks: int) -> tuple[bool, list[str]]:
|
||||||
|
"""
|
||||||
|
Check if tracks need to be synchronized by comparing snapshot_ids and total counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_spotify_id: The Spotify playlist ID
|
||||||
|
current_snapshot_id: The current snapshot_id from API
|
||||||
|
api_total_tracks: The total number of tracks reported by API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (needs_sync, tracks_to_find) where:
|
||||||
|
- needs_sync: True if tracks need to be synchronized
|
||||||
|
- tracks_to_find: List of track IDs that need to be found in API response
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get tracks from database with their snapshot_ids
|
||||||
|
db_tracks = get_playlist_tracks_with_snapshot_from_db(playlist_spotify_id)
|
||||||
|
db_total_tracks = get_playlist_total_tracks_from_db(playlist_spotify_id)
|
||||||
|
|
||||||
|
# Check if total count matches
|
||||||
|
if db_total_tracks != api_total_tracks:
|
||||||
|
logger.info(
|
||||||
|
f"Track count mismatch for playlist {playlist_spotify_id}: DB={db_total_tracks}, API={api_total_tracks}. Full sync needed to ensure all tracks are captured."
|
||||||
|
)
|
||||||
|
# Always do full sync when counts don't match to ensure we don't miss any tracks
|
||||||
|
# This handles cases like:
|
||||||
|
# - Empty database (DB=0, API=1345)
|
||||||
|
# - Missing tracks (DB=1000, API=1345)
|
||||||
|
# - Removed tracks (DB=1345, API=1000)
|
||||||
|
return True, [] # Empty list indicates full sync needed
|
||||||
|
|
||||||
|
# Check if any tracks have different snapshot_id
|
||||||
|
tracks_to_find = []
|
||||||
|
for track_id, track_data in db_tracks.items():
|
||||||
|
if track_data.get("snapshot_id") != current_snapshot_id:
|
||||||
|
tracks_to_find.append(track_id)
|
||||||
|
|
||||||
|
if tracks_to_find:
|
||||||
|
logger.info(
|
||||||
|
f"Found {len(tracks_to_find)} tracks with outdated snapshot_id for playlist {playlist_spotify_id}"
|
||||||
|
)
|
||||||
|
return True, tracks_to_find
|
||||||
|
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking track sync status for {playlist_spotify_id}: {e}")
|
||||||
|
# On error, assume sync is needed to be safe
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
|
||||||
|
def find_tracks_in_playlist(playlist_spotify_id: str, tracks_to_find: list[str], current_snapshot_id: str) -> tuple[list, list]:
|
||||||
|
"""
|
||||||
|
Progressively fetch playlist tracks until all specified tracks are found or playlist is exhausted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist_spotify_id: The Spotify playlist ID
|
||||||
|
tracks_to_find: List of track IDs to find
|
||||||
|
current_snapshot_id: The current snapshot_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (found_tracks, not_found_tracks) where:
|
||||||
|
- found_tracks: List of track items that were found
|
||||||
|
- not_found_tracks: List of track IDs that were not found
|
||||||
|
"""
|
||||||
|
found_tracks = []
|
||||||
|
not_found_tracks = tracks_to_find.copy()
|
||||||
|
offset = 0
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Searching for {len(tracks_to_find)} tracks in playlist {playlist_spotify_id} starting from offset {offset}"
|
||||||
|
)
|
||||||
|
|
||||||
|
while not_found_tracks and offset < 10000: # Safety limit
|
||||||
|
try:
|
||||||
|
tracks_batch = get_playlist_tracks(playlist_spotify_id, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
if not tracks_batch or "items" not in tracks_batch:
|
||||||
|
logger.warning(f"No tracks returned for playlist {playlist_spotify_id} at offset {offset}")
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_items = tracks_batch.get("items", [])
|
||||||
|
if not batch_items:
|
||||||
|
logger.info(f"No more tracks found at offset {offset}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check each track in this batch
|
||||||
|
for track_item in batch_items:
|
||||||
|
track = track_item.get("track")
|
||||||
|
if track and track.get("id") and not track.get("is_local"):
|
||||||
|
track_id = track["id"]
|
||||||
|
if track_id in not_found_tracks:
|
||||||
|
found_tracks.append(track_item)
|
||||||
|
not_found_tracks.remove(track_id)
|
||||||
|
logger.debug(f"Found track {track_id} at offset {offset}")
|
||||||
|
|
||||||
|
offset += len(batch_items)
|
||||||
|
|
||||||
|
# Add small delay between batches
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching tracks batch for playlist {playlist_spotify_id} at offset {offset}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Track search complete for playlist {playlist_spotify_id}: "
|
||||||
|
f"Found {len(found_tracks)}/{len(tracks_to_find)} tracks, "
|
||||||
|
f"Not found: {len(not_found_tracks)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return found_tracks, not_found_tracks
|
||||||
|
|
||||||
|
|
||||||
def check_watched_playlists(specific_playlist_id: str = None):
|
def check_watched_playlists(specific_playlist_id: str = None):
|
||||||
"""Checks watched playlists for new tracks and queues downloads.
|
"""Checks watched playlists for new tracks and queues downloads.
|
||||||
If specific_playlist_id is provided, only that playlist is checked.
|
If specific_playlist_id is provided, only that playlist is checked.
|
||||||
@@ -90,6 +244,7 @@ def check_watched_playlists(specific_playlist_id: str = None):
|
|||||||
f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}"
|
f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}"
|
||||||
)
|
)
|
||||||
config = get_watch_config()
|
config = get_watch_config()
|
||||||
|
use_snapshot_checking = config.get("use_snapshot_id_checking", True)
|
||||||
|
|
||||||
if specific_playlist_id:
|
if specific_playlist_id:
|
||||||
playlist_obj = get_watched_playlist(specific_playlist_id)
|
playlist_obj = get_watched_playlist(specific_playlist_id)
|
||||||
@@ -114,56 +269,115 @@ def check_watched_playlists(specific_playlist_id: str = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# For playlists, we fetch all tracks in one go usually (Spotify API limit permitting)
|
# Ensure the playlist's track table has the latest schema before processing
|
||||||
current_playlist_data_from_api = get_spotify_info(
|
ensure_playlist_table_schema(playlist_spotify_id)
|
||||||
playlist_spotify_id, "playlist"
|
|
||||||
)
|
# First, get playlist metadata to check if it has changed
|
||||||
if (
|
current_playlist_metadata = get_playlist_metadata(playlist_spotify_id)
|
||||||
not current_playlist_data_from_api
|
if not current_playlist_metadata:
|
||||||
or "tracks" not in current_playlist_data_from_api
|
|
||||||
):
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Playlist Watch Manager: Failed to fetch data or tracks from Spotify for playlist {playlist_spotify_id}."
|
f"Playlist Watch Manager: Failed to fetch metadata from Spotify for playlist {playlist_spotify_id}."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
api_snapshot_id = current_playlist_data_from_api.get("snapshot_id")
|
api_snapshot_id = current_playlist_metadata.get("snapshot_id")
|
||||||
api_total_tracks = current_playlist_data_from_api.get("tracks", {}).get(
|
api_total_tracks = current_playlist_metadata.get("tracks", {}).get("total", 0)
|
||||||
"total", 0
|
|
||||||
)
|
# Enhanced snapshot_id checking with track-level tracking
|
||||||
|
if use_snapshot_checking:
|
||||||
|
# First check if playlist snapshot_id has changed
|
||||||
|
playlist_changed = has_playlist_changed(playlist_spotify_id, api_snapshot_id)
|
||||||
|
|
||||||
|
if not playlist_changed:
|
||||||
|
# Even if playlist snapshot_id hasn't changed, check if individual tracks need sync
|
||||||
|
needs_sync, tracks_to_find = needs_track_sync(playlist_spotify_id, api_snapshot_id, api_total_tracks)
|
||||||
|
|
||||||
|
if not needs_sync:
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Playlist '{playlist_name}' ({playlist_spotify_id}) has not changed since last check (snapshot_id: {api_snapshot_id}). Skipping detailed check."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if not tracks_to_find:
|
||||||
|
# Empty tracks_to_find means full sync is needed (track count mismatch detected)
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Playlist '{playlist_name}' snapshot_id unchanged, but full sync needed due to track count mismatch. Proceeding with full check."
|
||||||
|
)
|
||||||
|
# Continue to full sync below
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Playlist '{playlist_name}' snapshot_id unchanged, but {len(tracks_to_find)} tracks need sync. Proceeding with targeted check."
|
||||||
|
)
|
||||||
|
# Use targeted track search instead of full fetch
|
||||||
|
found_tracks, not_found_tracks = find_tracks_in_playlist(playlist_spotify_id, tracks_to_find, api_snapshot_id)
|
||||||
|
|
||||||
|
# Update found tracks with new snapshot_id
|
||||||
|
if found_tracks:
|
||||||
|
add_tracks_to_playlist_db(playlist_spotify_id, found_tracks, api_snapshot_id)
|
||||||
|
|
||||||
|
# Mark not found tracks as removed
|
||||||
|
if not_found_tracks:
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: {len(not_found_tracks)} tracks not found in playlist '{playlist_name}'. Marking as removed."
|
||||||
|
)
|
||||||
|
mark_tracks_as_not_present_in_spotify(playlist_spotify_id, not_found_tracks)
|
||||||
|
|
||||||
# Paginate through playlist tracks if necessary
|
# Update playlist snapshot and continue to next playlist
|
||||||
|
update_playlist_snapshot(playlist_spotify_id, api_snapshot_id, api_total_tracks)
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Finished targeted sync for playlist '{playlist_name}'. Snapshot ID updated to {api_snapshot_id}."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Playlist '{playlist_name}' has changed. New snapshot_id: {api_snapshot_id}. Proceeding with full check."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Snapshot checking disabled. Proceeding with full check for playlist '{playlist_name}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch all tracks using the optimized function
|
||||||
|
# This happens when:
|
||||||
|
# 1. Playlist snapshot_id has changed (full sync needed)
|
||||||
|
# 2. Snapshot checking is disabled (full sync always)
|
||||||
|
# 3. Database is empty but API has tracks (full sync needed)
|
||||||
|
logger.info(
|
||||||
|
f"Playlist Watch Manager: Fetching all tracks for playlist '{playlist_name}' ({playlist_spotify_id}) with {api_total_tracks} total tracks."
|
||||||
|
)
|
||||||
|
|
||||||
all_api_track_items = []
|
all_api_track_items = []
|
||||||
offset = 0
|
offset = 0
|
||||||
limit = 50 # Spotify API limit for playlist items
|
limit = 100 # Use maximum batch size for efficiency
|
||||||
|
|
||||||
while True:
|
while offset < api_total_tracks:
|
||||||
# Re-fetch with pagination if tracks.next is present, or on first call.
|
try:
|
||||||
# get_spotify_info for playlist should ideally handle pagination internally if asked for all tracks.
|
# Use the optimized get_playlist_tracks function
|
||||||
# Assuming get_spotify_info for playlist returns all items or needs to be called iteratively.
|
tracks_batch = get_playlist_tracks(
|
||||||
# For simplicity, let's assume current_playlist_data_from_api has 'tracks' -> 'items' for the first page.
|
playlist_spotify_id, limit=limit, offset=offset
|
||||||
# And that get_spotify_info with 'playlist' type can take offset.
|
)
|
||||||
# Modifying get_spotify_info is outside current scope, so we'll assume it returns ALL items for a playlist.
|
|
||||||
# If it doesn't, this part would need adjustment for robust pagination.
|
if not tracks_batch or "items" not in tracks_batch:
|
||||||
# For now, we use the items from the initial fetch.
|
logger.warning(
|
||||||
|
f"Playlist Watch Manager: No tracks returned for playlist {playlist_spotify_id} at offset {offset}"
|
||||||
paginated_playlist_data = get_spotify_info(
|
)
|
||||||
playlist_spotify_id, "playlist", offset=offset, limit=limit
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not paginated_playlist_data
|
|
||||||
or "tracks" not in paginated_playlist_data
|
|
||||||
):
|
|
||||||
break
|
break
|
||||||
|
|
||||||
page_items = paginated_playlist_data.get("tracks", {}).get("items", [])
|
batch_items = tracks_batch.get("items", [])
|
||||||
if not page_items:
|
if not batch_items:
|
||||||
break
|
break
|
||||||
all_api_track_items.extend(page_items)
|
|
||||||
|
all_api_track_items.extend(batch_items)
|
||||||
if paginated_playlist_data.get("tracks", {}).get("next"):
|
offset += len(batch_items)
|
||||||
offset += limit
|
|
||||||
else:
|
# Add small delay between batches to be respectful to API
|
||||||
|
if offset < api_total_tracks:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Playlist Watch Manager: Error fetching tracks batch for playlist {playlist_spotify_id} at offset {offset}: {e}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
current_api_track_ids = set()
|
current_api_track_ids = set()
|
||||||
@@ -237,14 +451,14 @@ def check_watched_playlists(specific_playlist_id: str = None):
|
|||||||
|
|
||||||
# Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify')
|
# Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify')
|
||||||
# add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries.
|
# add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries.
|
||||||
# We should pass all current API tracks to ensure their `last_seen_in_spotify` and `is_present_in_spotify` are updated.
|
# We should pass all current API tracks to ensure their `last_seen_in_spotify`, `is_present_in_spotify`, and `snapshot_id` are updated.
|
||||||
if (
|
if (
|
||||||
all_api_track_items
|
all_api_track_items
|
||||||
): # If there are any tracks in the API for this playlist
|
): # If there are any tracks in the API for this playlist
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'."
|
f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'."
|
||||||
)
|
)
|
||||||
add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items)
|
add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items, api_snapshot_id)
|
||||||
|
|
||||||
removed_db_ids = db_track_ids - current_api_track_ids
|
removed_db_ids = db_track_ids - current_api_track_ids
|
||||||
if removed_db_ids:
|
if removed_db_ids:
|
||||||
@@ -259,7 +473,7 @@ def check_watched_playlists(specific_playlist_id: str = None):
|
|||||||
playlist_spotify_id, api_snapshot_id, api_total_tracks
|
playlist_spotify_id, api_snapshot_id, api_total_tracks
|
||||||
) # api_total_tracks from initial fetch
|
) # api_total_tracks from initial fetch
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated. API Total Tracks: {api_total_tracks}."
|
f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated to {api_snapshot_id}. API Total Tracks: {api_total_tracks}. Queued {queued_for_download_count} new tracks."
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -309,17 +523,16 @@ def check_watched_artists(specific_artist_id: str = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Spotify API for artist albums is paginated.
|
# Use the optimized artist discography function with pagination
|
||||||
# We need to fetch all albums. get_spotify_info with type 'artist-albums' should handle this.
|
|
||||||
# Let's assume get_spotify_info(artist_id, 'artist-albums') returns a list of all album objects.
|
|
||||||
# Or we implement pagination here.
|
|
||||||
|
|
||||||
all_artist_albums_from_api: List[Dict[str, Any]] = []
|
all_artist_albums_from_api: List[Dict[str, Any]] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
limit = 50 # Spotify API limit for artist albums
|
limit = 50 # Spotify API limit for artist albums
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Artist Watch Manager: Fetching albums for artist '{artist_name}' ({artist_spotify_id})"
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# The 'artist-albums' type for get_spotify_info needs to support pagination params.
|
|
||||||
# And return a list of album objects.
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}"
|
f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}"
|
||||||
)
|
)
|
||||||
@@ -560,6 +773,13 @@ def start_watch_manager(): # Renamed from start_playlist_watch_manager
|
|||||||
|
|
||||||
init_playlists_db() # For playlists
|
init_playlists_db() # For playlists
|
||||||
init_artists_db() # For artists
|
init_artists_db() # For artists
|
||||||
|
|
||||||
|
# Update all existing tables to ensure they have the latest schema
|
||||||
|
try:
|
||||||
|
update_all_existing_tables_schema()
|
||||||
|
logger.info("Watch Manager: Successfully updated all existing tables schema")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Watch Manager: Error updating existing tables schema: {e}", exc_info=True)
|
||||||
|
|
||||||
_watch_scheduler_thread = threading.Thread(
|
_watch_scheduler_thread = threading.Thread(
|
||||||
target=playlist_watch_scheduler, daemon=True
|
target=playlist_watch_scheduler, daemon=True
|
||||||
@@ -585,7 +805,3 @@ def stop_watch_manager(): # Renamed from stop_playlist_watch_manager
|
|||||||
_watch_scheduler_thread = None
|
_watch_scheduler_thread = None
|
||||||
else:
|
else:
|
||||||
logger.info("Watch Manager: Background scheduler not running.")
|
logger.info("Watch Manager: Background scheduler not running.")
|
||||||
|
|
||||||
|
|
||||||
# If this module is imported, and you want to auto-start the manager, you could call start_watch_manager() here.
|
|
||||||
# However, it's usually better to explicitly start it from the main application/__init__.py.
|
|
||||||
|
|||||||
@@ -1,34 +1,46 @@
|
|||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { useEffect, useState, useContext } from "react";
|
import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
||||||
import apiClient from "../lib/api-client";
|
import apiClient from "../lib/api-client";
|
||||||
import { useSettings } from "../contexts/settings-context";
|
import { useSettings } from "../contexts/settings-context";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { PlaylistType, TrackType } from "../types/spotify";
|
import type { PlaylistType, TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
|
||||||
import { QueueContext } from "../contexts/queue-context";
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
import { FaArrowLeft } from "react-icons/fa";
|
import { FaArrowLeft } from "react-icons/fa";
|
||||||
import { FaDownload } from "react-icons/fa6";
|
import { FaDownload } from "react-icons/fa6";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const Playlist = () => {
|
export const Playlist = () => {
|
||||||
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
|
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
|
||||||
const [playlist, setPlaylist] = useState<PlaylistType | null>(null);
|
const [playlistMetadata, setPlaylistMetadata] = useState<PlaylistMetadataType | null>(null);
|
||||||
|
const [tracks, setTracks] = useState<PlaylistItemType[]>([]);
|
||||||
const [isWatched, setIsWatched] = useState(false);
|
const [isWatched, setIsWatched] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loadingTracks, setLoadingTracks] = useState(false);
|
||||||
|
const [hasMoreTracks, setHasMoreTracks] = useState(true);
|
||||||
|
const [tracksOffset, setTracksOffset] = useState(0);
|
||||||
|
const [totalTracks, setTotalTracks] = useState(0);
|
||||||
|
|
||||||
const context = useContext(QueueContext);
|
const context = useContext(QueueContext);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const loadingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useQueue must be used within a QueueProvider");
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
}
|
}
|
||||||
const { addItem } = context;
|
const { addItem } = context;
|
||||||
|
|
||||||
|
// Load playlist metadata first
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlaylist = async () => {
|
const fetchPlaylistMetadata = async () => {
|
||||||
if (!playlistId) return;
|
if (!playlistId) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<PlaylistType>(`/playlist/info?id=${playlistId}`);
|
const response = await apiClient.get<PlaylistMetadataType>(`/playlist/metadata?id=${playlistId}`);
|
||||||
setPlaylist(response.data);
|
setPlaylistMetadata(response.data);
|
||||||
|
setTotalTracks(response.data.tracks.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to load playlist");
|
setError("Failed to load playlist metadata");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -45,10 +57,76 @@ export const Playlist = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPlaylist();
|
fetchPlaylistMetadata();
|
||||||
checkWatchStatus();
|
checkWatchStatus();
|
||||||
}, [playlistId]);
|
}, [playlistId]);
|
||||||
|
|
||||||
|
// Load tracks progressively
|
||||||
|
const loadMoreTracks = useCallback(async () => {
|
||||||
|
if (!playlistId || loadingTracks || !hasMoreTracks) return;
|
||||||
|
|
||||||
|
setLoadingTracks(true);
|
||||||
|
try {
|
||||||
|
const limit = 50; // Load 50 tracks at a time
|
||||||
|
const response = await apiClient.get<PlaylistTracksResponseType>(
|
||||||
|
`/playlist/tracks?id=${playlistId}&limit=${limit}&offset=${tracksOffset}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTracks = response.data.items;
|
||||||
|
setTracks(prev => [...prev, ...newTracks]);
|
||||||
|
setTracksOffset(prev => prev + newTracks.length);
|
||||||
|
|
||||||
|
// Check if we've loaded all tracks
|
||||||
|
if (tracksOffset + newTracks.length >= totalTracks) {
|
||||||
|
setHasMoreTracks(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load tracks:", err);
|
||||||
|
toast.error("Failed to load more tracks");
|
||||||
|
} finally {
|
||||||
|
setLoadingTracks(false);
|
||||||
|
}
|
||||||
|
}, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks]);
|
||||||
|
|
||||||
|
// Intersection Observer for infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMoreTracks && !loadingTracks) {
|
||||||
|
loadMoreTracks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loadingRef.current) {
|
||||||
|
observer.observe(loadingRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
observerRef.current = observer;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [loadMoreTracks, hasMoreTracks, loadingTracks]);
|
||||||
|
|
||||||
|
// Load initial tracks when metadata is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (playlistMetadata && tracks.length === 0 && totalTracks > 0) {
|
||||||
|
loadMoreTracks();
|
||||||
|
}
|
||||||
|
}, [playlistMetadata, tracks.length, totalTracks, loadMoreTracks]);
|
||||||
|
|
||||||
|
// Reset state when playlist ID changes
|
||||||
|
useEffect(() => {
|
||||||
|
setTracks([]);
|
||||||
|
setTracksOffset(0);
|
||||||
|
setHasMoreTracks(true);
|
||||||
|
setTotalTracks(0);
|
||||||
|
}, [playlistId]);
|
||||||
|
|
||||||
const handleDownloadTrack = (track: TrackType) => {
|
const handleDownloadTrack = (track: TrackType) => {
|
||||||
if (!track?.id) return;
|
if (!track?.id) return;
|
||||||
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||||
@@ -56,13 +134,13 @@ export const Playlist = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPlaylist = () => {
|
const handleDownloadPlaylist = () => {
|
||||||
if (!playlist) return;
|
if (!playlistMetadata) return;
|
||||||
addItem({
|
addItem({
|
||||||
spotifyId: playlist.id,
|
spotifyId: playlistMetadata.id,
|
||||||
type: "playlist",
|
type: "playlist",
|
||||||
name: playlist.name,
|
name: playlistMetadata.name,
|
||||||
});
|
});
|
||||||
toast.info(`Adding ${playlist.name} to queue...`);
|
toast.info(`Adding ${playlistMetadata.name} to queue...`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleWatch = async () => {
|
const handleToggleWatch = async () => {
|
||||||
@@ -70,10 +148,10 @@ export const Playlist = () => {
|
|||||||
try {
|
try {
|
||||||
if (isWatched) {
|
if (isWatched) {
|
||||||
await apiClient.delete(`/playlist/watch/${playlistId}`);
|
await apiClient.delete(`/playlist/watch/${playlistId}`);
|
||||||
toast.success(`Removed ${playlist?.name} from watchlist.`);
|
toast.success(`Removed ${playlistMetadata?.name} from watchlist.`);
|
||||||
} else {
|
} else {
|
||||||
await apiClient.put(`/playlist/watch/${playlistId}`);
|
await apiClient.put(`/playlist/watch/${playlistId}`);
|
||||||
toast.success(`Added ${playlist?.name} to watchlist.`);
|
toast.success(`Added ${playlistMetadata?.name} to watchlist.`);
|
||||||
}
|
}
|
||||||
setIsWatched(!isWatched);
|
setIsWatched(!isWatched);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -86,11 +164,11 @@ export const Playlist = () => {
|
|||||||
return <div className="text-red-500 p-8 text-center">{error}</div>;
|
return <div className="text-red-500 p-8 text-center">{error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlistMetadata) {
|
||||||
return <div className="p-8 text-center">Loading...</div>;
|
return <div className="p-8 text-center">Loading playlist...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredTracks = playlist.tracks.items.filter(({ track }) => {
|
const filteredTracks = tracks.filter(({ track }) => {
|
||||||
if (!track) return false;
|
if (!track) return false;
|
||||||
if (settings?.explicitFilter && track.explicit) return false;
|
if (settings?.explicitFilter && track.explicit) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -107,19 +185,23 @@ export const Playlist = () => {
|
|||||||
<span>Back to results</span>
|
<span>Back to results</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Playlist Header */}
|
||||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||||
<img
|
<img
|
||||||
src={playlist.images[0]?.url || "/placeholder.jpg"}
|
src={playlistMetadata.images[0]?.url || "/placeholder.jpg"}
|
||||||
alt={playlist.name}
|
alt={playlistMetadata.name}
|
||||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex-grow space-y-2">
|
<div className="flex-grow space-y-2">
|
||||||
<h1 className="text-3xl font-bold">{playlist.name}</h1>
|
<h1 className="text-3xl font-bold">{playlistMetadata.name}</h1>
|
||||||
{playlist.description && <p className="text-gray-500 dark:text-gray-400">{playlist.description}</p>}
|
{playlistMetadata.description && (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{playlistMetadata.description}</p>
|
||||||
|
)}
|
||||||
<div className="text-sm text-gray-400 dark:text-gray-500">
|
<div className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
<p>
|
<p>
|
||||||
By {playlist.owner.display_name} • {playlist.followers.total.toLocaleString()} followers •{" "}
|
By {playlistMetadata.owner.display_name} • {playlistMetadata.followers.total.toLocaleString()} followers •{" "}
|
||||||
{playlist.tracks.total} songs
|
{totalTracks} songs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
@@ -149,8 +231,17 @@ export const Playlist = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tracks Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Tracks</h2>
|
<h2 className="text-xl font-semibold">Tracks</h2>
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Showing {tracks.length} of {totalTracks} tracks
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredTracks.map(({ track }, index) => {
|
{filteredTracks.map(({ track }, index) => {
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
@@ -198,6 +289,25 @@ export const Playlist = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{loadingTracks && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Intersection observer target */}
|
||||||
|
{hasMoreTracks && (
|
||||||
|
<div ref={loadingRef} className="h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of tracks indicator */}
|
||||||
|
{!hasMoreTracks && tracks.length > 0 && (
|
||||||
|
<div className="text-center py-4 text-gray-500">
|
||||||
|
All tracks loaded
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface PlaylistItemType {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
is_local: boolean;
|
is_local: boolean;
|
||||||
track: TrackType | null;
|
track: TrackType | null;
|
||||||
|
is_locally_known?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistOwnerType {
|
export interface PlaylistOwnerType {
|
||||||
@@ -57,6 +58,31 @@ export interface PlaylistOwnerType {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New interface for playlist metadata only (no tracks)
|
||||||
|
export interface PlaylistMetadataType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
images: ImageType[];
|
||||||
|
tracks: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
owner: PlaylistOwnerType;
|
||||||
|
followers: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
_metadata_only: boolean;
|
||||||
|
_tracks_loaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New interface for playlist tracks response
|
||||||
|
export interface PlaylistTracksResponseType {
|
||||||
|
items: PlaylistItemType[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlaylistType {
|
export interface PlaylistType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user