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:
312
app/functions.py
312
app/functions.py
@@ -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']= {}
|
# Fetch the playlist from the database if it exists
|
||||||
data['playlists']['items'] = [data]
|
db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None
|
||||||
|
|
||||||
for playlist_data in data['playlists']['items']:
|
# Initialize default values
|
||||||
# Fetch the playlist from the database if it exists
|
track_count = db_playlist.track_count if db_playlist else 0
|
||||||
if playlist_data:
|
tracks_available = db_playlist.tracks_available if db_playlist else 0
|
||||||
db_playlist = Playlist.query.filter_by(provider_playlist_id=playlist_data['id']).first()
|
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
|
||||||
|
|
||||||
if db_playlist:
|
# Determine playlist status
|
||||||
# If the playlist is in the database, use the stored values
|
if tracks_available == track_count and track_count > 0:
|
||||||
if playlist_data.get('tracks'):
|
status = 'green' # Fully available
|
||||||
if isinstance(playlist_data['tracks'],list):
|
elif tracks_available > 0:
|
||||||
track_count = len(playlist_data['tracks'] )
|
status = 'yellow' # Partially available
|
||||||
else:
|
else:
|
||||||
track_count = playlist_data['tracks']['total'] or 0
|
status = 'red' # Not available
|
||||||
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
|
# Build and return the PlaylistResponse object
|
||||||
if not playlist_data.get('status'):
|
return CombinedPlaylistData(
|
||||||
if tracks_available == track_count and track_count > 0:
|
name=playlist.name,
|
||||||
playlist_data['status'] = 'green' # Fully available
|
description=playlist.description,
|
||||||
elif tracks_available > 0:
|
image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png',
|
||||||
playlist_data['status'] = 'yellow' # Partially available
|
url=playlist.external_urls[0].url if playlist.external_urls else '',
|
||||||
else:
|
id=playlist.id,
|
||||||
playlist_data['status'] = 'red' # Not available
|
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
|
||||||
|
)
|
||||||
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
||||||
def get_cached_spotify_playlists(playlist_ids):
|
|
||||||
"""
|
|
||||||
Fetches multiple Spotify playlists by their IDs, utilizing individual caching.
|
|
||||||
|
|
||||||
: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' : ''
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
@cache.memoize(timeout=3600*24*10)
|
||||||
def get_cached_spotify_track(track_id):
|
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.
|
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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 dictionary containing browse results
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
@abstractmethod
|
||||||
|
def browse_page(self, uri: str) -> List[Playlist]:
|
||||||
|
"""
|
||||||
|
Fetches a specific page of browse results.
|
||||||
|
:param uri: The uri to query.
|
||||||
:return: A list of Playlist objects.
|
:return: A list of Playlist objects.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_playlists_by_category(self, category_id: str, limit: int = 50) -> 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.
|
|
||||||
: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
|
|
||||||
@@ -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],
|
||||||
@@ -370,15 +380,60 @@ 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,36 +441,29 @@ 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:
|
||||||
@@ -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": {
|
||||||
|
|||||||
134
app/tasks.py
134
app/tasks.py
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user