From 2b3c400c10b75a65c57188a3510c2113da75bc93 Mon Sep 17 00:00:00 2001 From: Kamil Date: Tue, 3 Dec 2024 12:44:40 +0000 Subject: [PATCH] =?UTF-8?q?Major=20Overhaul:=20-=20No=20more=20dict=C2=B4s?= =?UTF-8?q?=20,=20goal=20is=20to=20have=20type=20safety=20and=20a=20generi?= =?UTF-8?q?c=20approach=20to=20support=20multiple=20music=20(playlist)=20p?= =?UTF-8?q?roviders=20-=20removed=20unneeded=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/functions.py | 316 ++++++++++----------------------------- app/models.py | 2 + app/providers/base.py | 50 ++++--- app/providers/spotify.py | 184 +++++++++++++++-------- app/tasks.py | 138 ++++++++--------- 5 files changed, 297 insertions(+), 393 deletions(-) diff --git a/app/functions.py b/app/functions.py index 3144ac0..9317648 100644 --- a/app/functions.py +++ b/app/functions.py @@ -1,12 +1,16 @@ import json -from typing import Optional -from flask import flash, redirect, session, url_for +from typing import List, Optional +from flask import flash, redirect, session, url_for,g import requests +from app.classes import CombinedPlaylistData, CombinedTrackData from app.models import JellyfinUser, Playlist,Track -from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache +from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache from functools import wraps from celery.result import AsyncResult -from app.tasks import download_missing_tracks,check_for_playlist_updates, update_all_playlists_track_status, update_jellyfin_id_for_downloaded_tracks +from app.providers import base +from app.providers.base import PlaylistTrack +from app.registry.music_provider_registry import MusicProviderRegistry +from . import tasks from jellyfin.objects import PlaylistMetadata from spotipy.exceptions import SpotifyException @@ -35,149 +39,63 @@ def manage_task(task_name): if result.state in ['PENDING', 'STARTED']: return result.state, result.info if result.info else {} if task_name == 'update_all_playlists_track_status': - result = update_all_playlists_track_status.delay() + result = tasks.update_all_playlists_track_status.delay() elif task_name == 'download_missing_tracks': - result = download_missing_tracks.delay() + result = tasks.download_missing_tracks.delay() elif task_name == 'check_for_playlist_updates': - result = check_for_playlist_updates.delay() + result = tasks.check_for_playlist_updates.delay() elif task_name == 'update_jellyfin_id_for_downloaded_tracks': - result = update_jellyfin_id_for_downloaded_tracks.delay() + result = tasks.update_jellyfin_id_for_downloaded_tracks.delay() TASK_STATUS[task_name] = result.id return result.state, result.info if result.info else {} -def prepPlaylistData(data): - playlists = [] +def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]: jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first() if not jellyfin_user: app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again") return None - if not data.get('playlists'): - - data['playlists']= {} - data['playlists']['items'] = [data] - - for playlist_data in data['playlists']['items']: - # Fetch the playlist from the database if it exists - if playlist_data: - db_playlist = Playlist.query.filter_by(provider_playlist_id=playlist_data['id']).first() - if db_playlist: - # If the playlist is in the database, use the stored values - if playlist_data.get('tracks'): - if isinstance(playlist_data['tracks'],list): - track_count = len(playlist_data['tracks'] ) - else: - track_count = playlist_data['tracks']['total'] or 0 - else: - track_count = 0 - tracks_available = db_playlist.tracks_available or 0 - tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) or 0 - percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0 - - # Determine playlist status - if not playlist_data.get('status'): - if tracks_available == track_count and track_count > 0: - playlist_data['status'] = 'green' # Fully available - elif tracks_available > 0: - playlist_data['status'] = 'yellow' # Partially available - else: - playlist_data['status'] = 'red' # Not available - - else: - # If the playlist is not in the database, initialize with 0 - track_count = 0 - tracks_available = 0 - tracks_linked = 0 - percent_available = 0 - playlist_data['status'] = 'red' # Not requested yet + # Fetch the playlist from the database if it exists + db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None - # Append playlist data to the list - playlists.append({ - 'name': playlist_data['name'], - 'description': playlist_data['description'], - 'image': playlist_data['images'][0]['url'] if playlist_data.get('images') else '/static/images/placeholder.png', - 'url': playlist_data['external_urls']['spotify'] if playlist_data.get('external_urls') else '', - 'id': playlist_data['id'] if playlist_data['id'] else '', - 'jellyfin_id': db_playlist.jellyfin_id if db_playlist else '', - 'can_add': (db_playlist not in jellyfin_user.playlists) if db_playlist else True, - 'can_remove' : (db_playlist in jellyfin_user.playlists) if db_playlist else False, - 'last_updated':db_playlist.last_updated if db_playlist else '', - 'last_changed':db_playlist.last_changed if db_playlist else '', - 'tracks_available': tracks_available, - 'track_count': track_count, - 'tracks_linked': tracks_linked, - 'percent_available': percent_available, - 'status': playlist_data['status'] # Red, yellow, or green based on availability - }) - - return playlists + # Initialize default values + track_count = db_playlist.track_count if db_playlist else 0 + tracks_available = db_playlist.tracks_available if db_playlist else 0 + tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) if db_playlist else 0 + percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0 -def get_cached_spotify_playlists(playlist_ids): - """ - Fetches multiple Spotify playlists by their IDs, utilizing individual caching. + # Determine playlist status + if tracks_available == track_count and track_count > 0: + status = 'green' # Fully available + elif tracks_available > 0: + status = 'yellow' # Partially available + else: + status = 'red' # Not available - :param playlist_ids: A list of Spotify playlist IDs. - :return: A dictionary containing the fetched playlists. - """ - spotify_data = {'playlists': {'items': []}} - - for playlist_id in playlist_ids: - playlist_data = None - not_found = False - try: - playlist_data = get_cached_spotify_playlist(playlist_id) - - except SpotifyException as e: - app.logger.error(f"Error Fetching Playlist {playlist_id}: {e}") - not_found = 'http status: 404' in str(e) - if not_found: - playlist_data = { - 'status':'red', - 'description': 'Playlist has most likely been removed. You can keep it, but won´t receive Updates.', - 'id': playlist_id, - 'name' : '' - - } + # Build and return the PlaylistResponse object + return CombinedPlaylistData( + name=playlist.name, + description=playlist.description, + image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png', + url=playlist.external_urls[0].url if playlist.external_urls else '', + id=playlist.id, + jellyfin_id=db_playlist.jellyfin_id if db_playlist else '', + can_add=(db_playlist not in jellyfin_user.playlists) if db_playlist else True, + can_remove=(db_playlist in jellyfin_user.playlists) if db_playlist else False, + last_updated=db_playlist.last_updated if db_playlist else None, + last_changed=db_playlist.last_changed if db_playlist else None, + tracks_available=tracks_available, + track_count=track_count, + tracks_linked=tracks_linked, + percent_available=percent_available, + status=status + ) - if playlist_data: - spotify_data['playlists']['items'].append(playlist_data) - - return spotify_data -@cache.memoize(timeout=3600) -def get_cached_playlist(playlist_id): - """ - Fetches a Spotify playlist by its ID, utilizing caching to minimize API calls. - - :param playlist_id: The Spotify playlist ID. - :return: Playlist data as a dictionary, or None if an error occurs. - """ - # When the playlist_id starts with 37i9dQZF1, we need to use the new function - # as the standard Spotify API endpoints are deprecated for these playlists. - # Reference: https://github.com/kamilkosek/jellyplist/issues/25 - - if playlist_id.startswith("37i9dQZF1"): - app.logger.warning(f"Algorithmic or Spotify-owned editorial playlist, using custom Implementation to fetch details") - # Use the custom implementation for these playlists - try: - data = fetch_spotify_playlist(playlist_id) - return transform_playlist_response(data) - except Exception as e: - print(f"Error fetching playlist with custom method: {e}") - return None - - # Otherwise, use the standard Spotipy API - try: - playlist_data = sp.playlist(playlist_id) # Fetch data using Spotipy - return playlist_data - except Exception as e: - print(f"Error fetching playlist with Spotipy: {e}") - return None - -@cache.memoize(timeout=3600*24*10) -def get_cached_spotify_track(track_id): +@cache.memoize(timeout=3600*24*10) +def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track: """ Fetches a Spotify track by its ID, utilizing caching to minimize API calls. @@ -185,86 +103,28 @@ def get_cached_spotify_track(track_id): :return: Track data as a dictionary, or None if an error occurs. """ try: - track_data = sp.track(track_id=track_id) # Fetch data from Spotify API + # get the provider from the registry + provider = MusicProviderRegistry.get_provider(provider_id) + track_data = provider.get_track(track_id) return track_data except Exception as e: - app.logger.error(f"Error fetching track {track_id} from Spotify: {str(e)}") + app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}") return None -def prepAlbumData(data): - items = [] - for item in data['albums']['items']: - items.append({ - 'name': item['name'], - 'description': f"Released: {item['release_date']}", - 'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg', - 'url': item['external_urls']['spotify'], - 'id' : item['id'], - 'can_add' : False - }) - return items - -def prepArtistData(data): - items = [] - for item in data['artists']['items']: - items.append({ - 'name': item['name'], - 'description': f"Popularity: {item['popularity']}", - 'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg', - 'url': item['external_urls']['spotify'], - 'id' : item['id'], - 'can_add' : False - - }) - return items - - - -def getFeaturedPlaylists(country: str, offset: int): - try: - playlists_data = sp.featured_playlists(country=country, limit=16, offset=offset) - return prepPlaylistData(playlists_data), playlists_data['playlists']['total'], 'Featured Playlists' - except SpotifyException as e: - app.logger.error(f"Spotify API error in getFeaturedPlaylists: {e}") - return [], e, f'Error: Could not load featured playlists. Please try again later. This is most likely due to an Error in the Spotify API or an rate limit has been reached.' - -def getCategoryPlaylists(category: str, offset: int): - try: - playlists_data = sp.category_playlists(category_id=category, country=app.config['SPOTIFY_COUNTRY_CODE'], limit=16, offset=offset) - return prepPlaylistData(playlists_data), playlists_data['playlists']['total'], f"Category {playlists_data['message']}" - except SpotifyException as e: - app.logger.error(f"Spotify API error in getCategoryPlaylists: {e}") - return [], e, 'Error: Could not load category playlists. Please try again later. This is most likely due to an Error in the Spotify API or an rate limit has been reached.' - -def getCategories(country,offset): - categories_data = sp.categories(limit=16, offset= offset) - categories = [] - - for cat in categories_data['categories']['items']: - categories.append({ - 'name': cat['name'], - 'description': '', - 'image': cat['icons'][0]['url'] if cat['icons'] else 'default-image.jpg', - 'url': f"/playlists?cat={cat['id']}", - 'id' : cat['id'], - 'type':'category' - }) - return categories, categories_data['categories']['total'],'Browse Categories' - -def get_tracks_for_playlist(data): - results = data - tracks = [] +def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]: is_admin = session.get('is_admin', False) + tracks = [] - for idx, item in enumerate(results['tracks']['items']): - track_data = item['track'] + for idx, item in enumerate(data): + track_data = item.track if track_data: - duration_ms = track_data['duration_ms'] + duration_ms = track_data.duration_ms minutes = duration_ms // 60000 seconds = (duration_ms % 60000) // 1000 - track_db = Track.query.filter_by(provider_track_id=track_data['id']).first() + # Query track from the database + track_db = Track.query.filter_by(provider_track_id=track_data.id).first() if track_db: downloaded = track_db.downloaded @@ -277,40 +137,26 @@ def get_tracks_for_playlist(data): jellyfin_id = None download_status = None - tracks.append({ - 'title': track_data['name'], - 'artist': ', '.join([artist['name'] for artist in track_data['artists']]), - 'url': track_data['external_urls']['spotify'], - 'duration': f'{minutes}:{seconds:02d}', - 'preview_url': track_data['preview_url'], - 'downloaded': downloaded, - 'filesystem_path': filesystem_path, - 'jellyfin_id': jellyfin_id, - 'spotify_id': track_data['id'], - 'duration_ms': duration_ms, - 'download_status' : download_status - }) - + # Append a TrackResponse object + tracks.append( + CombinedTrackData( + title=track_data.name, + artist=[a.name for a in track_data.artists], + url=[url.url for url in track_data.external_urls], + duration=f'{minutes}:{seconds:02d}', + downloaded=downloaded, + filesystem_path=filesystem_path, + jellyfin_id=jellyfin_id, + provider_track_id=track_data.id, + provider_id = provider_id, + duration_ms=duration_ms, + download_status=download_status, + provider=provider_id + ) + ) + return tracks -def get_full_playlist_data(playlist_id): - playlist_data = get_cached_spotify_playlist(playlist_id) - all_tracks = [] - - offset = 0 - while True: - response = sp.playlist_items(playlist_id, offset=offset, limit=100) - items = response['items'] - all_tracks.extend(items) - - if len(items) < 100: - break - offset += 100 - - playlist_data['tracks'] = all_tracks - playlist_data['prepped_data'] = prepPlaylistData(playlist_data) - return playlist_data - def jellyfin_login_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -332,13 +178,13 @@ def jellyfin_admin_required(f): -def update_playlist_metadata(playlist,spotify_playlist_data): +def update_playlist_metadata(playlist,provider_playlist_data : base.Playlist): metadata = PlaylistMetadata() metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available'] - metadata.Overview = spotify_playlist_data['description'] + metadata.Overview = provider_playlist_data.description jellyfin.update_playlist_metadata(session_token=_get_api_token(),playlist_id=playlist.jellyfin_id,updates= metadata , user_id= _get_admin_id()) - if spotify_playlist_data['images'] != None: - jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url']) + if provider_playlist_data.images: + jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,provider_image_url= provider_playlist_data.images[0].url) @@ -347,7 +193,7 @@ def _get_token_from_sessioncookie() -> str: def _get_api_token() -> str: #return app.config['JELLYFIN_ACCESS_TOKEN'] return jellyfin_admin_token -def _get_logged_in_user(): +def _get_logged_in_user() -> JellyfinUser: return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first() def _get_admin_id(): #return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id diff --git a/app/models.py b/app/models.py index 76bac0f..da454e8 100644 --- a/app/models.py +++ b/app/models.py @@ -35,6 +35,7 @@ class Playlist(db.Model): snapshot_id = db.Column(db.String(120), nullable=True) # Many-to-Many relationship with JellyfinUser users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists') + provider_id = db.Column(db.String(20)) def __repr__(self): return f'' @@ -56,6 +57,7 @@ class Track(db.Model): filesystem_path = db.Column(db.String(), nullable=True) jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field download_status = db.Column(db.String(2048), nullable=True) + provider_id = db.Column(db.String(20)) # Many-to-Many relationship with Playlists playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') diff --git a/app/providers/base.py b/app/providers/base.py index 6968a80..29850d6 100644 --- a/app/providers/base.py +++ b/app/providers/base.py @@ -82,6 +82,19 @@ class Playlist(ItemBase): images: Optional[List[Image]] owner: Optional[Owner] tracks: List[PlaylistTrack] = field(default_factory=list) +@dataclass +class BrowseCard: + title: str + uri: str + background_color: str + artwork: List[Image] + + +@dataclass +class BrowseSection: + title: str + items: List[BrowseCard] + uri: str # Abstract base class for music providers class MusicProviderClient(ABC): @@ -113,9 +126,19 @@ class MusicProviderClient(ABC): :return: A Playlist object. """ pass + @abstractmethod + def extract_playlist_id(self, uri: str) -> str: + """ + Extracts the playlist ID from a playlist URI. + :param uri: The playlist URI. + :return: The playlist ID. + """ + pass + + @abstractmethod - def search_tracks(self, query: str, limit: int = 50) -> List[Track]: + def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]: """ Searches for tracks based on a query string. :param query: The search query. @@ -133,29 +156,18 @@ class MusicProviderClient(ABC): """ pass @abstractmethod - def get_featured_playlists(self, limit: int = 50) -> List[Playlist]: + def browse(self, **kwargs) -> List[BrowseSection]: """ - Fetches a list of featured playlists. - :param limit: Maximum number of featured playlists to return. - :return: A list of Playlist objects. + Generic browse method for the music provider. + :param kwargs: Variable keyword arguments to support different browse parameters + :return: A dictionary containing browse results """ pass - @abstractmethod - def get_playlists_by_category(self, category_id: str, limit: int = 50) -> List[Playlist]: + def browse_page(self, uri: str) -> List[Playlist]: """ - Fetches playlists belonging to a specific category. - :param category_id: The ID of the category. - :param limit: Maximum number of playlists to return. + Fetches a specific page of browse results. + :param uri: The uri to query. :return: A list of Playlist objects. """ - pass - - @abstractmethod - def get_categories(self, limit: int = 50) -> List[Category]: - """ - Fetches a list of available categories. - :param limit: Maximum number of categories to return. - :return: A list of categories, where each category is a dictionary with 'id' and 'name'. - """ pass \ No newline at end of file diff --git a/app/providers/spotify.py b/app/providers/spotify.py index 83f1f06..07c8da9 100644 --- a/app/providers/spotify.py +++ b/app/providers/spotify.py @@ -1,6 +1,6 @@ from dataclasses import dataclass import os -from app.providers.base import AccountAttributes, Album, Artist, Image, MusicProviderClient, Owner,Playlist, PlaylistTrack, Profile,Track,ExternalUrl,Category +from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category import requests import json @@ -13,21 +13,6 @@ import logging l = logging.getLogger(__name__) -@dataclass -class BrowseCard: - title: str - uri: str - background_color: str - artwork: List[Image] - - -@dataclass -class BrowseSection: - title: str - items: List[BrowseCard] - uri: str - - class SpotifyClient(MusicProviderClient): """ Spotify implementation of the MusicProviderClient. @@ -201,12 +186,15 @@ class SpotifyClient(MusicProviderClient): :param album_data: Dictionary representing an album. :return: An Album instance. """ + artists = [] + if album_data.get("artists"): + artists = [self._parse_artist(artist) for artist in album_data.get("artists").get('items', [])] return Album( id=album_data["uri"].split(":")[-1], name=album_data["name"], uri=album_data["uri"], external_urls=self._parse_external_urls(album_data["uri"], "album"), - artists=[self._parse_artist(artist) for artist in album_data["artists"]["items"]], + artists=artists, images=self._parse_images(album_data["coverArt"]["sources"]) ) @@ -218,15 +206,33 @@ class SpotifyClient(MusicProviderClient): :param track_data: Dictionary representing a track. :return: A Track instance. """ + duration_ms = 0 + aritsts = [] + if track_data.get("duration"): + duration_ms = int(track_data.get("duration", 0).get("totalMilliseconds", 0)) + elif track_data.get("trackDuration"): + duration_ms = track_data["trackDuration"]["totalMilliseconds"] + + if track_data.get("firstArtist"): + for artist in track_data.get("firstArtist").get('items', []): + aritsts.append(self._parse_artist(artist)) + elif track_data.get("artists"): + for artist in track_data.get("artists").get('items', []): + aritsts.append(self._parse_artist(artist)) + + if track_data.get("albumOfTrack"): + album = self._parse_album(track_data["albumOfTrack"]) + + return Track( id=track_data["uri"].split(":")[-1], name=track_data["name"], uri=track_data["uri"], external_urls=self._parse_external_urls(track_data["uri"], "track"), - duration_ms=track_data["trackDuration"]["totalMilliseconds"], + duration_ms=duration_ms, explicit=track_data.get("explicit", False), album=self._parse_album(track_data["albumOfTrack"]), - artists=[self._parse_artist(artist) for artist in track_data["artists"]["items"]] + artists=aritsts ) def _parse_owner(self, owner_data: Dict) -> Optional[Owner]: """ @@ -283,10 +289,14 @@ class SpotifyClient(MusicProviderClient): owner_data = playlist_data.get("ownerV2", {}).get("data", {}) owner = self._parse_owner(owner_data) - tracks = [ - self._parse_track(item["itemV2"]["data"]) - for item in playlist_data.get("content", {}).get("items", []) - ] + + valid_tracks = [] + for item in playlist_data.get("content", {}).get("items", []): + data = item.get("itemV2", {}).get("data", {}) + uri = data.get("uri", "") + if uri.startswith("spotify:track"): + valid_tracks.append(self._parse_track(data)) + tracks = valid_tracks return Playlist( id=playlist_data.get("uri", "").split(":")[-1], @@ -369,16 +379,61 @@ class SpotifyClient(MusicProviderClient): playlist_data["content"]["items"] = all_items return self._parse_playlist(playlist_data) - - def search_tracks(self, query: str, limit: int = 10) -> List[Track]: + + def extract_playlist_id(self, uri: str) -> str: """ - Searches for tracks on Spotify. + Extract the playlist ID from a Spotify URI. + """ + # check whether the uri is a full url with https or just a uri + if uri.startswith("https://open.spotify.com/"): + #if it starts with https, we need to extract the playlist id from the url + return uri.split('/')[-1] + elif uri.startswith("spotify:playlist:"): + return uri.split(':')[-1] + else : + raise ValueError("Invalid Spotify URI.") + + def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]: + """ + Searches for playlists on Spotify. :param query: Search query. :param limit: Maximum number of results. - :return: A list of Track objects. + :return: A list of Playlist objects. """ - print(f"search_tracks: Placeholder for search with query '{query}' and limit {limit}.") - return [] + query_parameters = { + "operationName": "searchDesktop", + "variables": json.dumps({ + "searchTerm": query, + "offset": 0, + "limit": limit, + "numberOfTopResults": 5, + "includeAudiobooks": False, + "includeArtistHasConcertsField": False, + "includePreReleases": False, + "includeLocalConcertsField": False + }), + "extensions": json.dumps({ + "persistedQuery": { + "version": 1, + "sha256Hash": "f1f1c151cd392433ef4d2683a10deb9adeefd660f29692d8539ce450d2dfdb96" + } + }) + } + encoded_query = urlencode(query_parameters) + url = f"pathfinder/v1/query?{encoded_query}" + + try: + response = self._make_request(url) + search_data = response.get("data", {}).get("searchV2", {}) + playlists_data = search_data.get("playlists", {}).get("items", []) + + playlists = [self._parse_playlist(item["data"]) for item in playlists_data] + return playlists + + except Exception as e: + print(f"An error occurred while searching for playlists: {e}") + return [] + def get_track(self, track_id: str) -> Track: """ @@ -386,37 +441,30 @@ class SpotifyClient(MusicProviderClient): :param track_id: The ID of the track. :return: A Track object. """ - print(f"get_track: Placeholder for track with ID {track_id}.") - return Track(id=track_id, name="", uri="", duration_ms=0, explicit=False, album=Album(), artists=[], external_urls= ExternalUrl()) + query_parameters = { + "operationName": "getTrack", + "variables": json.dumps({ + "uri": f"spotify:track:{track_id}" + }), + "extensions": json.dumps({ + "persistedQuery": { + "version": 1, + "sha256Hash": "5c5ec8c973a0ac2d5b38d7064056c45103c5a062ee12b62ce683ab397b5fbe7d" + } + }) + } + encoded_query = urlencode(query_parameters) + url = f"pathfinder/v1/query?{encoded_query}" - def get_featured_playlists(self, limit: int = 10) -> List[Playlist]: - """ - Fetches featured playlists. - :param limit: Maximum number of results. - :return: A list of Playlist objects. - """ - print(f"get_featured_playlists: Placeholder for featured playlists with limit {limit}.") - return [] + try: + response = self._make_request(url) + track_data = response.get("data", {}).get("trackUnion", {}) + return self._parse_track(track_data) - def get_playlists_by_category(self, category_id: str, limit: int = 10) -> List[Playlist]: - """ - Fetches playlists for a specific category. - :param category_id: The ID of the category. - :param limit: Maximum number of results. - :return: A list of Playlist objects. - """ - print(f"get_playlists_by_category: Placeholder for playlists in category {category_id}.") - return [] + except Exception as e: + print(f"An error occurred while fetching the track: {e}") + return None - def get_categories(self, limit: int = 10) -> List[Category]: - """ - Fetches categories from Spotify. - :param limit: Maximum number of results. - :return: A list of Category objects. - """ - print(f"get_categories: Placeholder for categories with limit {limit}.") - return [] - # non generic method implementations: def get_profile(self) -> Optional[Profile]: @@ -506,14 +554,17 @@ class SpotifyClient(MusicProviderClient): except Exception as e: print(f"An error occurred while fetching account attributes: {e}") return None - def browse_all(self, page_limit: int = 50, section_limit: int = 99) -> List[BrowseSection]: + def browse(self, **kwargs) -> List[BrowseSection]: """ Fetch all browse sections with cards. - :param page_limit: Maximum number of pages to fetch. - :param section_limit: Maximum number of sections per page. + :param kwargs: Keyword arguments. Supported: + - page_limit: Maximum number of pages to fetch (default: 50) + - section_limit: Maximum number of sections per page (default: 99) :return: A list of BrowseSection objects. """ + page_limit = kwargs.get('page_limit', 50) + section_limit = kwargs.get('section_limit', 99) query_parameters = { "operationName": "browseAll", "variables": json.dumps({ @@ -541,22 +592,23 @@ class SpotifyClient(MusicProviderClient): print(f"An error occurred while fetching browse sections: {e}") return [] - def browse_page(self, card: BrowseCard) -> List[Playlist]: + def browse_page(self, uri: str) -> List[Playlist]: """ - Fetch the content of a browse page using the URI from a BrowseCard. + Fetch the content of a browse page using the URI. - :param card: A BrowseCard instance with a URI starting with 'spotify:page'. + :param uri: Should start with 'spotify:page'. :return: A list of Playlist objects from the browse page. """ - if not card.uri.startswith("spotify:page"): - raise ValueError("The BrowseCard URI must start with 'spotify:page'.") + + if not uri or not uri.startswith("spotify:page"): + raise ValueError("The 'uri' parameter must be provided and start with 'spotify:page'.") query_parameters = { "operationName": "browsePage", "variables": json.dumps({ "pagePagination": {"offset": 0, "limit": 10}, "sectionPagination": {"offset": 0, "limit": 10}, - "uri": card.uri + "uri": uri }), "extensions": json.dumps({ "persistedQuery": { diff --git a/app/tasks.py b/app/tasks.py index a8084d6..f9c11c7 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -1,6 +1,7 @@ from datetime import datetime,timezone import logging import subprocess +from typing import List from sqlalchemy import insert from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id @@ -10,8 +11,8 @@ from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tra import os import redis from celery import current_task,signals -import asyncio -import requests + +from app.registry.music_provider_registry import MusicProviderRegistry redis_client = redis.StrictRedis(host='redis', port=6379, db=0) def acquire_lock(lock_name, expiration=60): @@ -96,7 +97,8 @@ def download_missing_tracks(self): client_secret = app.config['SPOTIFY_CLIENT_SECRET'] search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD'] - undownloaded_tracks = Track.query.filter_by(downloaded=False).all() + # Downloading using SpotDL only works for Spotify tracks + undownloaded_tracks : List[Track] = Track.query.filter_by(downloaded=False,provider_id = "Spotify").all() total_tracks = len(undownloaded_tracks) if not undownloaded_tracks: app.logger.info("No undownloaded tracks found.") @@ -113,7 +115,7 @@ def download_missing_tracks(self): # region search before download if search_before_download: app.logger.info(f"Searching for track in Jellyfin: {track.name}") - spotify_track = functions.get_cached_spotify_track(track.provider_track_id) + spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify") # at first try to find the track without fingerprinting it best_match = find_best_match_from_jellyfin(track) if best_match: @@ -123,39 +125,39 @@ def download_missing_tracks(self): app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})") if track.filesystem_path != best_match['Path']: track.filesystem_path = best_match['Path'] - - db.session.commit() - processed_tracks+=1 + db.session.commit() + processed_tracks+=1 continue # region search with fingerprinting - if spotify_track: - preview_url = spotify_track.get('preview_url') - if not preview_url: - app.logger.error(f"Preview URL not found for track {track.name}.") - # Decide whether to skip or proceed to download - # For now, we'll proceed to download - else: - # Get the list of Spotify artist names - spotify_artists = [artist['name'] for artist in spotify_track['artists']] + # as long as there is no endpoint found providing a preview url, we can't use this feature + # if spotify_track: + # preview_url = spotify_track.get('preview_url') + # if not preview_url: + # app.logger.error(f"Preview URL not found for track {track.name}.") + # # Decide whether to skip or proceed to download + # # For now, we'll proceed to download + # else: + # # Get the list of Spotify artist names + # spotify_artists = [artist['name'] for artist in spotify_track['artists']] - # Perform the search in Jellyfin - match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin( - session_token=jellyfin_admin_token, - preview_url=preview_url, - song_name=track.name, - artist_names=spotify_artists - ) - if match_found: - app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.") - track.downloaded = True - track.filesystem_path = jellyfin_file_path - db.session.commit() - continue - else: - app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.") - else: - app.logger.warning(f"spotify_track not set, see previous log messages") + # # Perform the search in Jellyfin + # match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin( + # session_token=jellyfin_admin_token, + # preview_url=preview_url, + # song_name=track.name, + # artist_names=spotify_artists + # ) + # if match_found: + # app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.") + # track.downloaded = True + # track.filesystem_path = jellyfin_file_path + # db.session.commit() + # continue + # else: + # app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.") + # else: + # app.logger.warning(f"spotify_track not set, see previous log messages") #endregion #endregion @@ -240,7 +242,7 @@ def check_for_playlist_updates(self): try: app.logger.info('Starting playlist update check...') with app.app_context(): - playlists = Playlist.query.all() + playlists: List[Playlist] = Playlist.query.all() total_playlists = len(playlists) if not playlists: app.logger.info("No playlists found.") @@ -251,40 +253,28 @@ def check_for_playlist_updates(self): for playlist in playlists: playlist.last_updated = datetime.now( timezone.utc) - sp_playlist = sp.playlist(playlist.provider_playlist_id) + # get the correct MusicProvider from the registry + provider = MusicProviderRegistry.get_provider(playlist.provider_id) + provider_playlist = provider.get_playlist(playlist.provider_playlist_id) + provider_tracks = provider_playlist.tracks full_update = True - app.logger.info(f'Checking updates for playlist: {playlist.name}, s_snapshot = {sp_playlist['snapshot_id']}') + app.logger.info(f'Checking updates for playlist: {playlist.name}') db.session.commit() - if sp_playlist['snapshot_id'] == playlist.snapshot_id: - app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}') - full_update = False + try: #region Check for updates - # Fetch all playlist data from Spotify if full_update: - spotify_tracks = {} - offset = 0 - playlist.snapshot_id = sp_playlist['snapshot_id'] - while True: - playlist_data = sp.playlist_items(playlist.provider_playlist_id, offset=offset, limit=100) - items = playlist_data['items'] - spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']}) - - if len(items) < 100: # No more tracks to fetch - break - offset += 100 # Move to the next batch - existing_tracks = {track.provider_track_id: track for track in playlist.tracks} # Determine tracks to add and remove tracks_to_add = [] - for idx, track_info in spotify_tracks.items(): + for idx, track_info in enumerate(provider_tracks): if track_info: - track_id = track_info['id'] + track_id = track_info.track.id if track_id not in existing_tracks: - track = Track.query.filter_by(provider_track_id=track_id).first() + track = Track.query.filter_by(provider_track_id=track_id,provider_id = playlist.provider_id).first() if not track: - track = Track(name=track_info['name'], provider_track_id=track_id, provider_uri=track_info['uri'], downloaded=False) + track = Track(name=track_info.track.name, provider_track_id=track_id, provider_uri=track_info.track.uri, downloaded=False,provider_id = playlist.provider_id) db.session.add(track) db.session.commit() app.logger.info(f'Added new track: {track.name}') @@ -293,7 +283,7 @@ def check_for_playlist_updates(self): tracks_to_remove = [ existing_tracks[track_id] for track_id in existing_tracks - if track_id not in {track['id'] for track in spotify_tracks.values() if track} + if track_id not in {track.track.id for track in provider_tracks if track} ] if tracks_to_add or tracks_to_remove: @@ -321,7 +311,7 @@ def check_for_playlist_updates(self): #endregion #region Update Playlist Items and Metadata - functions.update_playlist_metadata(playlist, sp_playlist) + functions.update_playlist_metadata(playlist, provider_playlist) ordered_tracks = db.session.execute( db.select(Track, playlist_tracks.c.track_order) .join(playlist_tracks, playlist_tracks.c.track_id == Track.id) @@ -414,45 +404,47 @@ def update_jellyfin_id_for_downloaded_tracks(self): def find_best_match_from_jellyfin(track: Track): app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}") search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name)) - spotify_track = None + provider_track = None try: best_match = None best_quality_score = -1 # Initialize with the lowest possible score - - for result in search_results: - app.logger.debug(f"Processing search result: {result['Id']}, Path = {result['Path']}") quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE']) try: - spotify_track = functions.get_cached_spotify_track(track.provider_track_id) - spotify_track_name = spotify_track['name'].lower() - spotify_artists = [artist['name'].lower() for artist in spotify_track['artists']] + provider_track = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id) + provider_track_name = provider_track.name.lower() + provider_artists = [artist.name.lower() for artist in provider_track.artists] except Exception as e: app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}") continue jellyfin_track_name = result.get('Name', '').lower() - jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])] + if len(result.get('Artists', [])) == 1: + jellyfin_artists = [a.lower() for a in result.get('Artists', [])[0].split('/')] + else: + jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])] + jellyfin_album_artists = [artist['Name'].lower() for artist in result.get('AlbumArtists', [])] - if spotify_track and jellyfin_track_name and jellyfin_artists and spotify_artists: + if provider_track and jellyfin_track_name and jellyfin_artists and provider_artists: app.logger.debug("\tTrack details to compare: ") app.logger.debug(f"\t\tJellyfin-Trackname : {jellyfin_track_name}") - app.logger.debug(f"\t\t Spotify-Trackname : {spotify_track_name}") + app.logger.debug(f"\t\t Spotify-Trackname : {provider_track_name}") app.logger.debug(f"\t\t Jellyfin-Artists : {jellyfin_artists}") - app.logger.debug(f"\t\t Spotify-Artists : {spotify_artists}") + app.logger.debug(f"\t\t Spotify-Artists : {provider_artists}") + app.logger.debug(f"\t\t Jellyfin-Alb.Art.: {jellyfin_album_artists}") if len(search_results) == 1: app.logger.debug(f"\tOnly 1 search_result: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})") - if (spotify_track_name.lower() == jellyfin_track_name and - set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)): + if (provider_track_name.lower() == jellyfin_track_name and + (set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))): app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]") best_match = result break - if (spotify_track_name.lower() == jellyfin_track_name and - set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)): + if (provider_track_name.lower() == jellyfin_track_name and + (set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))): app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]") if quality_score > best_quality_score: