This commit is contained in:
Xoconoch
2025-07-26 19:44:23 -06:00
parent 2eb54a636b
commit 523eeed06b
12 changed files with 1110 additions and 250 deletions

View File

@@ -11,6 +11,11 @@ from routes.utils.errors import DuplicateDownloadError
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"])
def handle_download(album_id):
# Retrieve essential parameters from the request.
@@ -18,7 +23,7 @@ def handle_download(album_id):
# artist = request.args.get('artist')
# 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
try:
@@ -163,9 +168,7 @@ def get_album_info():
)
try:
# Import and use the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
# Use the get_spotify_info function (already imported at top)
album_info = get_spotify_info(spotify_id, "album")
return Response(json.dumps(album_info), status=200, mimetype="application/json")
except Exception as e:

View File

@@ -29,6 +29,11 @@ artist_bp = Blueprint("artist", __name__, url_prefix="/api/artist")
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):
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"
"""
# 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.
album_type = request.args.get("album_type", "album,single,compilation")
@@ -123,16 +128,26 @@ def get_artist_info():
)
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
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(
spotify_id
) # spotify_id is the artist ID
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"):
album_id = album_item["id"]
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."}
), 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_spotify_id, "artist_discography"
)
# Check if we got any data and if it has items
if not artist_album_list_data or not isinstance(
artist_album_list_data.get("items"), list
):
# Check if we got artist metadata
if not artist_metadata or not artist_metadata.get("name"):
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(
{
"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
# Attempt to extract artist name and verify ID
# The actual artist name might be consistently found in the items, if they exist
artist_name_from_albums = "Unknown Artist" # Default
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:
# Check if we got album data
if not artist_album_list_data or not isinstance(
artist_album_list_data.get("items"), list
):
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
# We use the provided artist_spotify_id as the primary ID.
artist_data_for_db = {
"id": artist_spotify_id, # This is the crucial part
"name": artist_name_from_albums,
"id": artist_spotify_id,
"name": artist_metadata.get("name", "Unknown Artist"),
"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
}
@@ -236,7 +226,7 @@ def add_artist_to_watchlist(artist_spotify_id):
add_artist_db(artist_data_for_db)
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(
{

View File

@@ -33,6 +33,11 @@ logger = logging.getLogger(__name__) # Added logger initialization
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"])
def handle_download(playlist_id):
# Retrieve essential parameters from the request.
@@ -41,14 +46,15 @@ def handle_download(playlist_id):
orig_params = request.args.to_dict()
# 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"] = (
request.url
) # Update original_url to the constructed one
# Fetch metadata from Spotify
# Fetch metadata from Spotify using optimized function
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 (
not playlist_info
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.
"""
spotify_id = request.args.get("id")
include_tracks = request.args.get("include_tracks", "false").lower() == "true"
if not spotify_id:
return Response(
@@ -186,8 +193,9 @@ def get_playlist_info():
)
try:
# Import and use the get_spotify_info function from the utility module.
playlist_info = get_spotify_info(spotify_id, "playlist")
# Use the optimized playlist info function
from routes.utils.get_info import get_playlist_info_optimized
playlist_info = get_playlist_info_optimized(spotify_id, include_tracks=include_tracks)
# If playlist_info is successfully fetched, check if it's watched
# and augment track items with is_locally_known status
@@ -216,6 +224,64 @@ def get_playlist_info():
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"])
def add_to_watchlist(playlist_spotify_id):
"""Adds a playlist to the watchlist."""
@@ -232,7 +298,8 @@ def add_to_watchlist(playlist_spotify_id):
), 200
# 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:
logger.error(
f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."

View File

@@ -8,9 +8,6 @@ from routes.utils.celery_tasks import (
get_last_task_status,
get_all_tasks,
cancel_task,
retry_task,
redis_client,
delete_task_data,
)
# Configure logging
@@ -174,9 +171,6 @@ def delete_task(task_id):
# First, cancel the task if it's running
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
@@ -185,14 +179,9 @@ def list_tasks():
"""
Retrieve a list of all tasks in the system.
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:
# Check for 'include_finished' query parameter
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)
tasks = get_all_tasks()
detailed_tasks = []
for task_summary in tasks:
task_id = task_summary.get("task_id")
@@ -315,7 +304,7 @@ def cancel_all_tasks():
Cancel all active (running or queued) tasks.
"""
try:
tasks_to_cancel = get_all_tasks(include_finished=False)
tasks_to_cancel = get_all_tasks()
cancelled_count = 0
errors = []

View File

@@ -18,6 +18,11 @@ from routes.utils.get_info import get_spotify_info # Added import
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"])
def handle_download(track_id):
# Retrieve essential parameters from the request.
@@ -26,7 +31,7 @@ def handle_download(track_id):
orig_params = request.args.to_dict()
# 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
# Fetch metadata from Spotify

View File

@@ -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.errors import DuplicateDownloadError
from deezspot.easy_spoty import Spo
from deezspot.libutils.utils import get_ids, link_is_valid
# Configure logging
@@ -71,8 +70,6 @@ def get_artist_discography(
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:
artist_id = get_ids(url)
except Exception as id_error:
@@ -81,12 +78,8 @@ def get_artist_discography(
raise ValueError(msg)
try:
# The progress_callback is not a standard param for Spo.get_artist
# If Spo.get_artist is meant to be Spo.get_artist_discography, that would take limit/offset
# 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)
# Use the optimized get_spotify_info function
discography = get_spotify_info(artist_id, "artist_discography")
return discography
except Exception as fetch_error:
msg = f"An error occurred while fetching the discography: {fetch_error}"

View File

@@ -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.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
from deezspot.deezloader.dee_api import API as DeezerAPI
import logging
# Initialize logger
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.
The default Spotify account from main.json might still be relevant for other Spo settings or if Spo uses it.
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
Get or create a Spotify client with global credentials.
Implements client reuse and periodic reinitialization.
"""
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:
raise ValueError(
"Global Spotify API client_id or client_secret not configured in ./data/creds/search.json."
)
# Get config parameters including default Spotify account name
# This might still be useful if Spo uses the account name for other things (e.g. market/region if not passed explicitly)
# For now, we are just ensuring the API keys are set.
config_params = get_config_params()
main_spotify_account_name = config_params.get(
"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."
# Create new client
_spotify_client = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=client_id,
client_secret=client_secret
)
)
else:
# Optionally, one could load the specific account's region here if Spo.init or methods need it,
# 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.
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}"
)
_last_client_init = current_time
logger.info("Spotify client initialized/reinitialized")
return _spotify_client
# Initialize the Spotify client with GLOBAL credentials
Spo.__init__(client_id, client_secret)
def _rate_limit_handler(func):
"""
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":
return Spo.get_track(spotify_id)
elif spotify_type == "album":
return Spo.get_album(spotify_id)
elif spotify_type == "playlist":
return Spo.get_playlist(spotify_id)
elif spotify_type == "artist_discography":
if limit is not None and offset is not None:
return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset)
elif limit is not None:
return Spo.get_artist_discography(spotify_id, limit=limit)
elif offset is not None:
return Spo.get_artist_discography(spotify_id, offset=offset)
@_rate_limit_handler
def get_playlist_metadata(playlist_id: str) -> Dict[str, Any]:
"""
Get playlist metadata only (no tracks) to avoid rate limiting.
Args:
playlist_id: The Spotify playlist ID
Returns:
Dictionary with playlist metadata (name, description, owner, etc.)
"""
client = _get_spotify_client()
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:
return Spo.get_artist_discography(spotify_id)
elif spotify_type == "artist":
return Spo.get_artist(spotify_id)
elif spotify_type == "episode":
return Spo.get_episode(spotify_id)
raise ValueError(f"Unsupported Spotify type: {spotify_type}")
except Exception as e:
logger.error(f"Error fetching {spotify_type} {spotify_id}: {e}")
raise
# Cache for playlist metadata to reduce API calls
_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:
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):
"""
Get info from Deezer API.

View File

@@ -1,29 +1,57 @@
from deezspot.easy_spoty import Spo
import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
import logging
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
import time
# Configure logger
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:
logger.info(
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:
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:
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."
)
logger.debug("Initializing Spotify client with global API credentials for search.")
Spo.__init__(client_id, client_secret)
logger.debug("Getting Spotify client for search.")
client = _get_spotify_client()
logger.debug(
f"Executing Spotify search with query='{query}', type={search_type}, limit={limit}"
)
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}'")
return spotify_response
except Exception as e:

View File

@@ -40,6 +40,7 @@ EXPECTED_PLAYLIST_TRACKS_COLUMNS = {
"added_to_db": "INTEGER",
"is_present_in_spotify": "INTEGER DEFAULT 1",
"last_seen_in_spotify": "INTEGER",
"snapshot_id": "TEXT", # Track the snapshot_id when this track was added/updated
}
EXPECTED_WATCHED_ARTISTS_COLUMNS = {
@@ -165,6 +166,11 @@ def init_playlists_db():
"watched playlists",
):
conn.commit()
# Update all existing playlist track tables with new schema
_update_all_playlist_track_tables(cursor)
conn.commit()
logger.info(
f"Playlists database initialized/updated successfully at {PLAYLISTS_DB_PATH}"
)
@@ -173,6 +179,87 @@ def init_playlists_db():
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):
"""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
@@ -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_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
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
@@ -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."""
try:
_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
cursor = conn.cursor()
cursor.execute(
@@ -234,7 +326,7 @@ def add_playlist_to_watch(playlist_data: dict):
"display_name", playlist_data["owner"]["id"]
),
playlist_data["tracks"]["total"],
playlist_data["external_urls"]["spotify"],
spotify_url, # Use constructed URL instead of external_urls
playlist_data.get("snapshot_id"),
int(time.time()),
int(time.time()),
@@ -363,11 +455,91 @@ def get_playlist_track_ids_from_db(playlist_spotify_id: str):
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
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.
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('-', '_')}"
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.
# Order: title, artist_names, album_name, album_artist_names, track_number,
# 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(
(
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
1, # is_present_in_spotify flag
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
)
)
@@ -446,7 +618,8 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
duration_ms = ?,
added_at_playlist = ?,
is_present_in_spotify = ?,
last_seen_in_spotify = ?
last_seen_in_spotify = ?,
snapshot_id = ?
WHERE spotify_track_id = ?
""",
tracks_to_update,
@@ -611,7 +784,7 @@ def remove_specific_tracks_from_playlist_table(
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."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
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,
1,
current_time,
snapshot_id, # Add snapshot_id to the tuple
)
try:
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(
f"""
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
track_data_tuple,
)

View File

@@ -9,9 +9,13 @@ from routes.utils.watch.db import (
get_watched_playlists,
get_watched_playlist,
get_playlist_track_ids_from_db,
get_playlist_tracks_with_snapshot_from_db,
get_playlist_total_tracks_from_db,
add_tracks_to_playlist_db,
update_playlist_snapshot,
mark_tracks_as_not_present_in_spotify,
update_all_existing_tables_schema,
ensure_playlist_table_schema,
# Artist watch DB functions
get_watched_artists,
get_watched_artist,
@@ -20,6 +24,9 @@ from routes.utils.watch.db import (
)
from routes.utils.get_info import (
get_spotify_info,
get_playlist_metadata,
get_playlist_tracks,
check_playlist_updated,
) # To fetch playlist, track, artist, and album details
from routes.utils.celery_queue_manager import download_queue_manager
@@ -34,6 +41,7 @@ DEFAULT_WATCH_CONFIG = {
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists
"delay_between_playlists_seconds": 2,
"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}"
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):
"""Checks watched playlists for new tracks and queues downloads.
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'}"
)
config = get_watch_config()
use_snapshot_checking = config.get("use_snapshot_id_checking", True)
if 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:
# For playlists, we fetch all tracks in one go usually (Spotify API limit permitting)
current_playlist_data_from_api = get_spotify_info(
playlist_spotify_id, "playlist"
)
if (
not current_playlist_data_from_api
or "tracks" not in current_playlist_data_from_api
):
# Ensure the playlist's track table has the latest schema before processing
ensure_playlist_table_schema(playlist_spotify_id)
# First, get playlist metadata to check if it has changed
current_playlist_metadata = get_playlist_metadata(playlist_spotify_id)
if not current_playlist_metadata:
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
api_snapshot_id = current_playlist_data_from_api.get("snapshot_id")
api_total_tracks = current_playlist_data_from_api.get("tracks", {}).get(
"total", 0
)
api_snapshot_id = current_playlist_metadata.get("snapshot_id")
api_total_tracks = current_playlist_metadata.get("tracks", {}).get("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 = []
offset = 0
limit = 50 # Spotify API limit for playlist items
while True:
# Re-fetch with pagination if tracks.next is present, or on first call.
# get_spotify_info for playlist should ideally handle pagination internally if asked for all tracks.
# Assuming get_spotify_info for playlist returns all items or needs to be called iteratively.
# For simplicity, let's assume current_playlist_data_from_api has 'tracks' -> 'items' for the first page.
# 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.
# For now, we use the items from the initial fetch.
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
):
limit = 100 # Use maximum batch size for efficiency
while offset < api_total_tracks:
try:
# Use the optimized get_playlist_tracks function
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"Playlist Watch Manager: No tracks returned for playlist {playlist_spotify_id} at offset {offset}"
)
break
page_items = paginated_playlist_data.get("tracks", {}).get("items", [])
if not page_items:
break
all_api_track_items.extend(page_items)
if paginated_playlist_data.get("tracks", {}).get("next"):
offset += limit
else:
batch_items = tracks_batch.get("items", [])
if not batch_items:
break
all_api_track_items.extend(batch_items)
offset += len(batch_items)
# 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
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')
# 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 (
all_api_track_items
): # If there are any tracks in the API for this playlist
logger.info(
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
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
) # api_total_tracks from initial fetch
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:
@@ -309,17 +523,16 @@ def check_watched_artists(specific_artist_id: str = None):
)
try:
# Spotify API for artist albums is paginated.
# 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.
# Use the optimized artist discography function with pagination
all_artist_albums_from_api: List[Dict[str, Any]] = []
offset = 0
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:
# The 'artist-albums' type for get_spotify_info needs to support pagination params.
# And return a list of album objects.
logger.debug(
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_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(
target=playlist_watch_scheduler, daemon=True
@@ -585,7 +805,3 @@ def stop_watch_manager(): # Renamed from stop_playlist_watch_manager
_watch_scheduler_thread = None
else:
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.

View File

@@ -1,34 +1,46 @@
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 { useSettings } from "../contexts/settings-context";
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 { FaArrowLeft } from "react-icons/fa";
import { FaDownload } from "react-icons/fa6";
export const Playlist = () => {
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 [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 { settings } = useSettings();
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
// Load playlist metadata first
useEffect(() => {
const fetchPlaylist = async () => {
const fetchPlaylistMetadata = async () => {
if (!playlistId) return;
try {
const response = await apiClient.get<PlaylistType>(`/playlist/info?id=${playlistId}`);
setPlaylist(response.data);
const response = await apiClient.get<PlaylistMetadataType>(`/playlist/metadata?id=${playlistId}`);
setPlaylistMetadata(response.data);
setTotalTracks(response.data.tracks.total);
} catch (err) {
setError("Failed to load playlist");
setError("Failed to load playlist metadata");
console.error(err);
}
};
@@ -45,10 +57,76 @@ export const Playlist = () => {
}
};
fetchPlaylist();
fetchPlaylistMetadata();
checkWatchStatus();
}, [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) => {
if (!track?.id) return;
addItem({ spotifyId: track.id, type: "track", name: track.name });
@@ -56,13 +134,13 @@ export const Playlist = () => {
};
const handleDownloadPlaylist = () => {
if (!playlist) return;
if (!playlistMetadata) return;
addItem({
spotifyId: playlist.id,
spotifyId: playlistMetadata.id,
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 () => {
@@ -70,10 +148,10 @@ export const Playlist = () => {
try {
if (isWatched) {
await apiClient.delete(`/playlist/watch/${playlistId}`);
toast.success(`Removed ${playlist?.name} from watchlist.`);
toast.success(`Removed ${playlistMetadata?.name} from watchlist.`);
} else {
await apiClient.put(`/playlist/watch/${playlistId}`);
toast.success(`Added ${playlist?.name} to watchlist.`);
toast.success(`Added ${playlistMetadata?.name} to watchlist.`);
}
setIsWatched(!isWatched);
} catch (err) {
@@ -86,11 +164,11 @@ export const Playlist = () => {
return <div className="text-red-500 p-8 text-center">{error}</div>;
}
if (!playlist) {
return <div className="p-8 text-center">Loading...</div>;
if (!playlistMetadata) {
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 (settings?.explicitFilter && track.explicit) return false;
return true;
@@ -107,19 +185,23 @@ export const Playlist = () => {
<span>Back to results</span>
</button>
</div>
{/* Playlist Header */}
<div className="flex flex-col md:flex-row items-start gap-6">
<img
src={playlist.images[0]?.url || "/placeholder.jpg"}
alt={playlist.name}
src={playlistMetadata.images[0]?.url || "/placeholder.jpg"}
alt={playlistMetadata.name}
className="w-48 h-48 object-cover rounded-lg shadow-lg"
/>
<div className="flex-grow space-y-2">
<h1 className="text-3xl font-bold">{playlist.name}</h1>
{playlist.description && <p className="text-gray-500 dark:text-gray-400">{playlist.description}</p>}
<h1 className="text-3xl font-bold">{playlistMetadata.name}</h1>
{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">
<p>
By {playlist.owner.display_name} {playlist.followers.total.toLocaleString()} followers {" "}
{playlist.tracks.total} songs
By {playlistMetadata.owner.display_name} {playlistMetadata.followers.total.toLocaleString()} followers {" "}
{totalTracks} songs
</p>
</div>
<div className="flex gap-2 pt-2">
@@ -149,8 +231,17 @@ export const Playlist = () => {
</div>
</div>
{/* Tracks Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<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">
{filteredTracks.map(({ track }, index) => {
if (!track) return null;
@@ -198,6 +289,25 @@ export const Playlist = () => {
</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>

View File

@@ -50,6 +50,7 @@ export interface PlaylistItemType {
added_at: string;
is_local: boolean;
track: TrackType | null;
is_locally_known?: boolean;
}
export interface PlaylistOwnerType {
@@ -57,6 +58,31 @@ export interface PlaylistOwnerType {
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 {
id: string;
name: string;