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