Major Overhaul:

- No more dict´s , goal is to have type safety and a generic approach to support multiple music (playlist) providers
- removed unneeded functions
This commit is contained in:
Kamil
2024-12-03 12:44:40 +00:00
parent 00ba693fb9
commit 2b3c400c10
5 changed files with 297 additions and 393 deletions

View File

@@ -1,12 +1,16 @@
import json import json
from typing import Optional from typing import List, Optional
from flask import flash, redirect, session, url_for from flask import flash, redirect, session, url_for,g
import requests import requests
from app.classes import CombinedPlaylistData, CombinedTrackData
from app.models import JellyfinUser, Playlist,Track 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 functools import wraps
from celery.result import AsyncResult 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 jellyfin.objects import PlaylistMetadata
from spotipy.exceptions import SpotifyException from spotipy.exceptions import SpotifyException
@@ -35,149 +39,63 @@ def manage_task(task_name):
if result.state in ['PENDING', 'STARTED']: if result.state in ['PENDING', 'STARTED']:
return result.state, result.info if result.info else {} return result.state, result.info if result.info else {}
if task_name == 'update_all_playlists_track_status': 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': elif task_name == 'download_missing_tracks':
result = download_missing_tracks.delay() result = tasks.download_missing_tracks.delay()
elif task_name == 'check_for_playlist_updates': 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': 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 TASK_STATUS[task_name] = result.id
return result.state, result.info if result.info else {} return result.state, result.info if result.info else {}
def prepPlaylistData(data): def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
playlists = []
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first() jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not jellyfin_user: if not jellyfin_user:
app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again") app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again")
return None 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: # Fetch the playlist from the database if it exists
# If the playlist is in the database, use the stored values db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None
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
# Append playlist data to the list # Initialize default values
playlists.append({ track_count = db_playlist.track_count if db_playlist else 0
'name': playlist_data['name'], tracks_available = db_playlist.tracks_available if db_playlist else 0
'description': playlist_data['description'], tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) if db_playlist else 0
'image': playlist_data['images'][0]['url'] if playlist_data.get('images') else '/static/images/placeholder.png', percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
'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
def get_cached_spotify_playlists(playlist_ids): # Determine playlist status
""" if tracks_available == track_count and track_count > 0:
Fetches multiple Spotify playlists by their IDs, utilizing individual caching. 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. # Build and return the PlaylistResponse object
:return: A dictionary containing the fetched playlists. return CombinedPlaylistData(
""" name=playlist.name,
spotify_data = {'playlists': {'items': []}} description=playlist.description,
image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png',
for playlist_id in playlist_ids: url=playlist.external_urls[0].url if playlist.external_urls else '',
playlist_data = None id=playlist.id,
not_found = False jellyfin_id=db_playlist.jellyfin_id if db_playlist else '',
try: can_add=(db_playlist not in jellyfin_user.playlists) if db_playlist else True,
playlist_data = get_cached_spotify_playlist(playlist_id) can_remove=(db_playlist in jellyfin_user.playlists) if db_playlist else False,
last_updated=db_playlist.last_updated if db_playlist else None,
except SpotifyException as e: last_changed=db_playlist.last_changed if db_playlist else None,
app.logger.error(f"Error Fetching Playlist {playlist_id}: {e}") tracks_available=tracks_available,
not_found = 'http status: 404' in str(e) track_count=track_count,
if not_found: tracks_linked=tracks_linked,
playlist_data = { percent_available=percent_available,
'status':'red', status=status
'description': 'Playlist has most likely been removed. You can keep it, but won´t receive Updates.', )
'id': playlist_id,
'name' : ''
}
if playlist_data:
spotify_data['playlists']['items'].append(playlist_data)
return spotify_data
@cache.memoize(timeout=3600) @cache.memoize(timeout=3600*24*10)
def get_cached_playlist(playlist_id): def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:
"""
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):
""" """
Fetches a Spotify track by its ID, utilizing caching to minimize API calls. 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. :return: Track data as a dictionary, or None if an error occurs.
""" """
try: 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 return track_data
except Exception as e: 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 return None
def prepAlbumData(data): def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
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 = []
is_admin = session.get('is_admin', False) is_admin = session.get('is_admin', False)
tracks = []
for idx, item in enumerate(results['tracks']['items']): for idx, item in enumerate(data):
track_data = item['track'] track_data = item.track
if track_data: if track_data:
duration_ms = track_data['duration_ms'] duration_ms = track_data.duration_ms
minutes = duration_ms // 60000 minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000 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: if track_db:
downloaded = track_db.downloaded downloaded = track_db.downloaded
@@ -277,40 +137,26 @@ def get_tracks_for_playlist(data):
jellyfin_id = None jellyfin_id = None
download_status = None download_status = None
tracks.append({ # Append a TrackResponse object
'title': track_data['name'], tracks.append(
'artist': ', '.join([artist['name'] for artist in track_data['artists']]), CombinedTrackData(
'url': track_data['external_urls']['spotify'], title=track_data.name,
'duration': f'{minutes}:{seconds:02d}', artist=[a.name for a in track_data.artists],
'preview_url': track_data['preview_url'], url=[url.url for url in track_data.external_urls],
'downloaded': downloaded, duration=f'{minutes}:{seconds:02d}',
'filesystem_path': filesystem_path, downloaded=downloaded,
'jellyfin_id': jellyfin_id, filesystem_path=filesystem_path,
'spotify_id': track_data['id'], jellyfin_id=jellyfin_id,
'duration_ms': duration_ms, provider_track_id=track_data.id,
'download_status' : download_status provider_id = provider_id,
}) duration_ms=duration_ms,
download_status=download_status,
provider=provider_id
)
)
return tracks 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): def jellyfin_login_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): 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 = PlaylistMetadata()
metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available'] 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()) 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: if provider_playlist_data.images:
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url']) 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: def _get_api_token() -> str:
#return app.config['JELLYFIN_ACCESS_TOKEN'] #return app.config['JELLYFIN_ACCESS_TOKEN']
return jellyfin_admin_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() return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
def _get_admin_id(): def _get_admin_id():
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id #return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id

View File

@@ -35,6 +35,7 @@ class Playlist(db.Model):
snapshot_id = db.Column(db.String(120), nullable=True) snapshot_id = db.Column(db.String(120), nullable=True)
# Many-to-Many relationship with JellyfinUser # Many-to-Many relationship with JellyfinUser
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists') users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
provider_id = db.Column(db.String(20))
def __repr__(self): def __repr__(self):
return f'<Playlist {self.name}:{self.provider_playlist_id}>' return f'<Playlist {self.name}:{self.provider_playlist_id}>'
@@ -56,6 +57,7 @@ class Track(db.Model):
filesystem_path = db.Column(db.String(), nullable=True) filesystem_path = db.Column(db.String(), nullable=True)
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
download_status = db.Column(db.String(2048), nullable=True) download_status = db.Column(db.String(2048), nullable=True)
provider_id = db.Column(db.String(20))
# Many-to-Many relationship with Playlists # Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')

View File

@@ -82,6 +82,19 @@ class Playlist(ItemBase):
images: Optional[List[Image]] images: Optional[List[Image]]
owner: Optional[Owner] owner: Optional[Owner]
tracks: List[PlaylistTrack] = field(default_factory=list) 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 # Abstract base class for music providers
class MusicProviderClient(ABC): class MusicProviderClient(ABC):
@@ -113,9 +126,19 @@ class MusicProviderClient(ABC):
:return: A Playlist object. :return: A Playlist object.
""" """
pass 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 @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. Searches for tracks based on a query string.
:param query: The search query. :param query: The search query.
@@ -133,29 +156,18 @@ class MusicProviderClient(ABC):
""" """
pass pass
@abstractmethod @abstractmethod
def get_featured_playlists(self, limit: int = 50) -> List[Playlist]: def browse(self, **kwargs) -> List[BrowseSection]:
""" """
Fetches a list of featured playlists. Generic browse method for the music provider.
:param limit: Maximum number of featured playlists to return. :param kwargs: Variable keyword arguments to support different browse parameters
:return: A list of Playlist objects. :return: A dictionary containing browse results
""" """
pass pass
@abstractmethod @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. Fetches a specific page of browse results.
:param category_id: The ID of the category. :param uri: The uri to query.
:param limit: Maximum number of playlists to return.
:return: A list of Playlist objects. :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 pass

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
import os 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 requests
import json import json
@@ -13,21 +13,6 @@ import logging
l = logging.getLogger(__name__) 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): class SpotifyClient(MusicProviderClient):
""" """
Spotify implementation of the MusicProviderClient. Spotify implementation of the MusicProviderClient.
@@ -201,12 +186,15 @@ class SpotifyClient(MusicProviderClient):
:param album_data: Dictionary representing an album. :param album_data: Dictionary representing an album.
:return: An Album instance. :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( return Album(
id=album_data["uri"].split(":")[-1], id=album_data["uri"].split(":")[-1],
name=album_data["name"], name=album_data["name"],
uri=album_data["uri"], uri=album_data["uri"],
external_urls=self._parse_external_urls(album_data["uri"], "album"), 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"]) images=self._parse_images(album_data["coverArt"]["sources"])
) )
@@ -218,15 +206,33 @@ class SpotifyClient(MusicProviderClient):
:param track_data: Dictionary representing a track. :param track_data: Dictionary representing a track.
:return: A Track instance. :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( return Track(
id=track_data["uri"].split(":")[-1], id=track_data["uri"].split(":")[-1],
name=track_data["name"], name=track_data["name"],
uri=track_data["uri"], uri=track_data["uri"],
external_urls=self._parse_external_urls(track_data["uri"], "track"), 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), explicit=track_data.get("explicit", False),
album=self._parse_album(track_data["albumOfTrack"]), 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]: 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_data = playlist_data.get("ownerV2", {}).get("data", {})
owner = self._parse_owner(owner_data) owner = self._parse_owner(owner_data)
tracks = [
self._parse_track(item["itemV2"]["data"]) valid_tracks = []
for item in playlist_data.get("content", {}).get("items", []) 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( return Playlist(
id=playlist_data.get("uri", "").split(":")[-1], id=playlist_data.get("uri", "").split(":")[-1],
@@ -369,16 +379,61 @@ class SpotifyClient(MusicProviderClient):
playlist_data["content"]["items"] = all_items playlist_data["content"]["items"] = all_items
return self._parse_playlist(playlist_data) 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 query: Search query.
:param limit: Maximum number of results. :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}.") query_parameters = {
return [] "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: def get_track(self, track_id: str) -> Track:
""" """
@@ -386,37 +441,30 @@ class SpotifyClient(MusicProviderClient):
:param track_id: The ID of the track. :param track_id: The ID of the track.
:return: A Track object. :return: A Track object.
""" """
print(f"get_track: Placeholder for track with ID {track_id}.") query_parameters = {
return Track(id=track_id, name="", uri="", duration_ms=0, explicit=False, album=Album(), artists=[], external_urls= ExternalUrl()) "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]: try:
""" response = self._make_request(url)
Fetches featured playlists. track_data = response.get("data", {}).get("trackUnion", {})
:param limit: Maximum number of results. return self._parse_track(track_data)
:return: A list of Playlist objects.
"""
print(f"get_featured_playlists: Placeholder for featured playlists with limit {limit}.")
return []
def get_playlists_by_category(self, category_id: str, limit: int = 10) -> List[Playlist]: except Exception as e:
""" print(f"An error occurred while fetching the track: {e}")
Fetches playlists for a specific category. return None
: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 []
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: # non generic method implementations:
def get_profile(self) -> Optional[Profile]: def get_profile(self) -> Optional[Profile]:
@@ -506,14 +554,17 @@ class SpotifyClient(MusicProviderClient):
except Exception as e: except Exception as e:
print(f"An error occurred while fetching account attributes: {e}") print(f"An error occurred while fetching account attributes: {e}")
return None 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. Fetch all browse sections with cards.
:param page_limit: Maximum number of pages to fetch. :param kwargs: Keyword arguments. Supported:
:param section_limit: Maximum number of sections per page. - 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. :return: A list of BrowseSection objects.
""" """
page_limit = kwargs.get('page_limit', 50)
section_limit = kwargs.get('section_limit', 99)
query_parameters = { query_parameters = {
"operationName": "browseAll", "operationName": "browseAll",
"variables": json.dumps({ "variables": json.dumps({
@@ -541,22 +592,23 @@ class SpotifyClient(MusicProviderClient):
print(f"An error occurred while fetching browse sections: {e}") print(f"An error occurred while fetching browse sections: {e}")
return [] 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. :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 = { query_parameters = {
"operationName": "browsePage", "operationName": "browsePage",
"variables": json.dumps({ "variables": json.dumps({
"pagePagination": {"offset": 0, "limit": 10}, "pagePagination": {"offset": 0, "limit": 10},
"sectionPagination": {"offset": 0, "limit": 10}, "sectionPagination": {"offset": 0, "limit": 10},
"uri": card.uri "uri": uri
}), }),
"extensions": json.dumps({ "extensions": json.dumps({
"persistedQuery": { "persistedQuery": {

View File

@@ -1,6 +1,7 @@
from datetime import datetime,timezone from datetime import datetime,timezone
import logging import logging
import subprocess import subprocess
from typing import List
from sqlalchemy import insert from sqlalchemy import insert
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id 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 os
import redis import redis
from celery import current_task,signals 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) redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
def acquire_lock(lock_name, expiration=60): def acquire_lock(lock_name, expiration=60):
@@ -96,7 +97,8 @@ def download_missing_tracks(self):
client_secret = app.config['SPOTIFY_CLIENT_SECRET'] client_secret = app.config['SPOTIFY_CLIENT_SECRET']
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD'] 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) total_tracks = len(undownloaded_tracks)
if not undownloaded_tracks: if not undownloaded_tracks:
app.logger.info("No undownloaded tracks found.") app.logger.info("No undownloaded tracks found.")
@@ -113,7 +115,7 @@ def download_missing_tracks(self):
# region search before download # region search before download
if search_before_download: if search_before_download:
app.logger.info(f"Searching for track in Jellyfin: {track.name}") 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 # at first try to find the track without fingerprinting it
best_match = find_best_match_from_jellyfin(track) best_match = find_best_match_from_jellyfin(track)
if best_match: 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})") app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})")
if track.filesystem_path != best_match['Path']: if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path'] track.filesystem_path = best_match['Path']
db.session.commit()
db.session.commit() processed_tracks+=1
processed_tracks+=1
continue continue
# region search with fingerprinting # region search with fingerprinting
if spotify_track: # as long as there is no endpoint found providing a preview url, we can't use this feature
preview_url = spotify_track.get('preview_url') # if spotify_track:
if not preview_url: # preview_url = spotify_track.get('preview_url')
app.logger.error(f"Preview URL not found for track {track.name}.") # if not preview_url:
# Decide whether to skip or proceed to download # app.logger.error(f"Preview URL not found for track {track.name}.")
# For now, we'll proceed to download # # Decide whether to skip or proceed to download
else: # # For now, we'll proceed to download
# Get the list of Spotify artist names # else:
spotify_artists = [artist['name'] for artist in spotify_track['artists']] # # Get the list of Spotify artist names
# spotify_artists = [artist['name'] for artist in spotify_track['artists']]
# Perform the search in Jellyfin # # Perform the search in Jellyfin
match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin( # match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
session_token=jellyfin_admin_token, # session_token=jellyfin_admin_token,
preview_url=preview_url, # preview_url=preview_url,
song_name=track.name, # song_name=track.name,
artist_names=spotify_artists # artist_names=spotify_artists
) # )
if match_found: # if match_found:
app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.") # app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
track.downloaded = True # track.downloaded = True
track.filesystem_path = jellyfin_file_path # track.filesystem_path = jellyfin_file_path
db.session.commit() # db.session.commit()
continue # continue
else: # else:
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.") # app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
else: # else:
app.logger.warning(f"spotify_track not set, see previous log messages") # app.logger.warning(f"spotify_track not set, see previous log messages")
#endregion #endregion
#endregion #endregion
@@ -240,7 +242,7 @@ def check_for_playlist_updates(self):
try: try:
app.logger.info('Starting playlist update check...') app.logger.info('Starting playlist update check...')
with app.app_context(): with app.app_context():
playlists = Playlist.query.all() playlists: List[Playlist] = Playlist.query.all()
total_playlists = len(playlists) total_playlists = len(playlists)
if not playlists: if not playlists:
app.logger.info("No playlists found.") app.logger.info("No playlists found.")
@@ -251,40 +253,28 @@ def check_for_playlist_updates(self):
for playlist in playlists: for playlist in playlists:
playlist.last_updated = datetime.now( timezone.utc) 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 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() 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: try:
#region Check for updates #region Check for updates
# Fetch all playlist data from Spotify
if full_update: 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} existing_tracks = {track.provider_track_id: track for track in playlist.tracks}
# Determine tracks to add and remove # Determine tracks to add and remove
tracks_to_add = [] tracks_to_add = []
for idx, track_info in spotify_tracks.items(): for idx, track_info in enumerate(provider_tracks):
if track_info: if track_info:
track_id = track_info['id'] track_id = track_info.track.id
if track_id not in existing_tracks: 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: 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.add(track)
db.session.commit() db.session.commit()
app.logger.info(f'Added new track: {track.name}') app.logger.info(f'Added new track: {track.name}')
@@ -293,7 +283,7 @@ def check_for_playlist_updates(self):
tracks_to_remove = [ tracks_to_remove = [
existing_tracks[track_id] existing_tracks[track_id]
for track_id in existing_tracks 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: if tracks_to_add or tracks_to_remove:
@@ -321,7 +311,7 @@ def check_for_playlist_updates(self):
#endregion #endregion
#region Update Playlist Items and Metadata #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( ordered_tracks = db.session.execute(
db.select(Track, playlist_tracks.c.track_order) db.select(Track, playlist_tracks.c.track_order)
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id) .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): def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}") 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)) search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))
spotify_track = None provider_track = None
try: try:
best_match = None best_match = None
best_quality_score = -1 # Initialize with the lowest possible score best_quality_score = -1 # Initialize with the lowest possible score
for result in search_results: for result in search_results:
app.logger.debug(f"Processing search result: {result['Id']}, Path = {result['Path']}") 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']) quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE'])
try: try:
spotify_track = functions.get_cached_spotify_track(track.provider_track_id) provider_track = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
spotify_track_name = spotify_track['name'].lower() provider_track_name = provider_track.name.lower()
spotify_artists = [artist['name'].lower() for artist in spotify_track['artists']] provider_artists = [artist.name.lower() for artist in provider_track.artists]
except Exception as e: except Exception as e:
app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}") app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}")
continue continue
jellyfin_track_name = result.get('Name', '').lower() 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("\tTrack details to compare: ")
app.logger.debug(f"\t\tJellyfin-Trackname : {jellyfin_track_name}") 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 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: 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']})") 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 if (provider_track_name.lower() == jellyfin_track_name and
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)): (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']}]") app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
best_match = result best_match = result
break break
if (spotify_track_name.lower() == jellyfin_track_name and if (provider_track_name.lower() == jellyfin_track_name and
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)): (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']}]") app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
if quality_score > best_quality_score: if quality_score > best_quality_score: