diff --git a/.bumpversion.cfg b/.bumpversion.cfg index cea658a..92e3977 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,12 +1,12 @@ [bumpversion] -current_version = v0.1.9 +current_version = 0.1.10 commit = True tag = True [bumpversion:file:app/version.py] -search = __version__ = "{current_version}" -replace = __version__ = "{new_version}" +search = __version__ = "v{current_version}" +replace = __version__ = "v{new_version}" [bumpversion:file:version.py] -search = __version__ = "{current_version}" -replace = __version__ = "{new_version}" +search = __version__ = "v{current_version}" +replace = __version__ = "v{new_version}" diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..bb77f16 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[MESSAGES CONTROL] +disable=logging-fstring-interpolation,broad-exception-raised \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 3aaf1ed..a8e75bf 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -206,6 +206,12 @@ spotify_client.authenticate() from .registry import MusicProviderRegistry MusicProviderRegistry.register_provider(spotify_client) +if app.config['ENABLE_DEEZER']: + from .providers import DeezerClient + deezer_client = DeezerClient() + deezer_client.authenticate() + MusicProviderRegistry.register_provider(deezer_client) + if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']: app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}') from lidarr.client import LidarrClient diff --git a/app/filters.py b/app/filters.py index c4ff498..c3f1fc9 100644 --- a/app/filters.py +++ b/app/filters.py @@ -96,4 +96,22 @@ def jellyfin_link(jellyfin_id: str) -> Markup: return Markup(f"JELLYFIN_SERVER_URL not configured") link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}" - return Markup(f'{jellyfin_id}') \ No newline at end of file + return Markup(f'{jellyfin_id}') + +@template_filter('jellyfin_link_button') +def jellyfin_link_btn(jellyfin_id: str) -> Markup: + + jellyfin_server_url = app.config.get('JELLYFIN_SERVER_URL') + if not jellyfin_server_url: + return Markup(f"JELLYFIN_SERVER_URL not configured") + + link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}" + return Markup(f'Open in Jellyfin') + + +# A template filter for displaying a datetime in a human-readable format +@template_filter('human_datetime') +def human_datetime(dt) -> str: + if not dt: + return 'No date provided' + return dt.strftime('%Y-%m-%d %H:%M:%S') \ No newline at end of file diff --git a/app/providers/__init__.py b/app/providers/__init__.py index c8f2eb9..31705b8 100644 --- a/app/providers/__init__.py +++ b/app/providers/__init__.py @@ -1,3 +1,4 @@ from .spotify import SpotifyClient +#from .deezer import DeezerClient __all__ = ["SpotifyClient"] \ No newline at end of file diff --git a/app/providers/deezer.py b/app/providers/deezer.py new file mode 100644 index 0000000..3aa5397 --- /dev/null +++ b/app/providers/deezer.py @@ -0,0 +1,320 @@ +import time +from bs4 import BeautifulSoup +import deezer +import deezer.resources +import deezer.exceptions +import json +import requests +from typing import List, Optional, Dict +import logging +from deezer import Client + +from app.providers.base import ( + MusicProviderClient, + AccountAttributes, + Album, + Artist, + BrowseCard, + BrowseSection, + Image, + Owner, + Playlist, + PlaylistTrack, + Profile, + Track, + ExternalUrl, + Category, +) + +l = logging.getLogger(__name__) + +class DeezerClient(MusicProviderClient): + """ + Deezer implementation of the MusicProviderClient. + An abstraction layer of deezer-python + https://github.com/browniebroke/deezer-python library to work with Jellyplist. + """ + + @property + def _identifier(self) -> str: + return "Deezer" + + + + def __init__(self, access_token: Optional[str] = None): + """ + Initialize the Deezer client. + :param access_token: Optional access token for authentication. + """ + self._client = deezer.Client(access_token=access_token) + + #region Helper methods for parsing Deezer API responses + def _parse_track(self, track: deezer.resources.Track) -> Track: + """ + Parse a track object. + :param track: The track object from the Deezer API. + :return: A Track object. + """ + + l.debug(f"Track: {track}") + retrycount= 0 + max_retries = 3 + wait = .8 + while True: + try: + artists = [self._parse_artist(track.artist)] + if hasattr(track, 'contributors'): + artists = [self._parse_artist(artist) for artist in track.contributors] + return Track( + id=str(track.id), + name=track.title, + uri=f"deezer:track:{track.id}", + duration_ms=track.duration * 1000, + explicit=track.explicit_lyrics, + album=self._parse_album(track.album), + artists=artists, + external_urls=[], + ) + except deezer.exceptions.DeezerErrorResponse as e: + if e.json_data['error']['code'] == 4: + l.warning(f"Quota limit exceeded. Waiting for {wait} seconds before retrying...") + retrycount += 1 + if retrycount >= max_retries: + l.error("Maximum retries reached. Aborting.") + raise + time.sleep(wait) + else: + raise + def _parse_artist(self, artist: deezer.resources.Artist) -> Artist: + """ + Parse an artist object. + :param artist: The artist object from the Deezer API. + :return: An Artist object. + """ + return Artist( + id=str(artist.id), + name=artist.name, + uri=f"deezer:artist:{artist.id}", + external_urls=[], + ) + + def _parse_album(self, album: deezer.resources.Album) -> Album: + """ + Parse an album object. + :param album: The album object from the Deezer API. + :return: An Album object. + """ + #artists = [self._parse_artist(artist) for artist in album.contributors] + artists = [] + images = [Image(url=album.cover_xl, height=None, width=None)] + return Album( + id=str(album.id), + name=album.title, + uri=f"deezer:album:{album.id}", + external_urls=[], + artists=artists, + images=images + ) + def _parse_playlist(self, playlist: deezer.resources.Playlist) -> Playlist: + """ + Parse a playlist object. + :param playlist: The playlist object from the Deezer API. + :return: A Playlist object. + """ + images = [Image(url=playlist.picture_medium, height=None, width=None)] + tracks = [] + tracks = [PlaylistTrack(is_local=False, track=self._parse_track(playlist_track), added_at='', added_by='') for playlist_track in playlist.get_tracks()] + + + return Playlist( + id=str(playlist.id), + name=playlist.title, + uri=f"deezer:playlist:{playlist.id}", + external_urls=[ExternalUrl(url=playlist.link)], + description=playlist.description, + public=playlist.public, + collaborative=playlist.collaborative, + followers=playlist.fans, + images=images, + owner=Owner( + id=str(playlist.creator.id), + name=playlist.creator.name, + uri=f"deezer:user:{playlist.creator.id}", + external_urls=[ExternalUrl(url=playlist.creator.link)] + ), + tracks=tracks + ) + + #endregion + def authenticate(self, credentials: Optional[dict] = None) -> None: + """ + Authenticate with Deezer using an access token. + :param credentials: Optional dictionary containing 'access_token'. + """ + l.info("Authentication is handled by deezer-python.") + pass + + def extract_playlist_id(self, uri: str) -> str: + """ + Extract the playlist ID from a Deezer playlist URL or URI. + :param uri: The playlist URL or URI. + :return: The playlist ID. + """ + # TODO: Implement this method + return '' + + def get_playlist(self, playlist_id: str) -> Playlist: + """ + Fetch a playlist by its ID. + :param playlist_id: The ID of the playlist to fetch. + :return: A Playlist object. + """ + data = self._client.get_playlist(int(playlist_id)) + return self._parse_playlist(data) + + def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]: + """ + Search for playlists matching a query. + :param query: The search query. + :param limit: Maximum number of results to return. + :return: A list of Playlist objects. + """ + playlists = [] + search_results = self._client.search_playlists(query, strict=None, ordering=None) + for item in search_results: + images = [Image(url=item.picture_xl, height=None, width=None)] + tracks = [PlaylistTrack(is_local=False, track=self._parse_track(playlist_track), added_at='', added_by='') for playlist_track in item.tracks] + playlist = Playlist( + id=str(item.id), + name=item.title, + uri=f"deezer:playlist:{item.id}", + external_urls=[ExternalUrl(url=item.link)], + description=item.description, + public=item.public, + collaborative=item.collaborative, + followers=item.fans, + images=images, + owner=Owner( + id=str(item.creator.id), + name=item.create.name, + uri=f"deezer:user:{item.creator.id}", + external_urls=[ExternalUrl(url=item.creator.link)] + ), + tracks=tracks + ) + playlists.append(playlist) + return playlists + + + def get_track(self, track_id: str) -> Track: + """ + Fetch a track by its ID. + :param track_id: The ID of the track to fetch. + :return: A Track object. + """ + track = self._client.get_track(int(track_id)) + return self._parse_track(track) + + + def browse(self, **kwargs) -> List[BrowseSection]: + """ + Browse featured content. + :param kwargs: Additional parameters. + :return: A list of BrowseSection objects. + """ + # Deezer does not have a direct equivalent, but we can fetch charts + url = 'https://www.deezer.com/de/channels/explore/explore-tab' + headers = { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0', + 'sec-ch-ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + 'sec-ch-ua-mobile': '?0' + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'html.parser') + dzr_app_div = soup.find('div', id='dzr-app') + script_tag = dzr_app_div.find('script') + + script_content = script_tag.string.strip() + json_content = script_content.replace('window.__DZR_APP_STATE__ = ', '', 1) + data = json.loads(json_content) + + sections = [] + for section in data['sections']: + browse_section = None + if 'module_type=channel' in section['section_id']: + cards = [] + for item in section['items']: + if item['type'] == 'channel': + image_url = f"https://cdn-images.dzcdn.net/images/{item['image_linked_item']['type']}/{item['image_linked_item']['md5']}/256x256-000000-80-0-0.jpg" + card = BrowseCard( + title=item['title'], + uri=f"deezer:channel:{item['data']['slug']}", + artwork=[Image(url=image_url, height=None, width=None)], + background_color=item['data']['background_color'] + ) + cards.append(card) + browse_section = BrowseSection( + title=section['title'], + uri=f"deezer:section:{section['group_id']}", + items=cards + ) + if browse_section: + sections.append(browse_section) + return sections + + def browse_page(self, uri: str) -> List[Playlist]: + """ + Fetch playlists for a given browse page. + :param uri: The uri to query. + :return: A list of Playlist objects. + """ + # Deezer does not have a direct equivalent, but we can fetch charts + playlists = [] + slug = uri.split(':')[-1] + url = f'https://www.deezer.com/de/channels/{slug}' + headers = { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0', + 'sec-ch-ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + 'sec-ch-ua-mobile': '?0' + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'html.parser') + dzr_app_div = soup.find('div', id='dzr-app') + script_tag = dzr_app_div.find('script') + + script_content = script_tag.string.strip() + json_content = script_content.replace('window.__DZR_APP_STATE__ = ', '', 1) + data = json.loads(json_content) + for section in data['sections']: + for item in section['items']: + if item['type'] == 'playlist': + #playlist = self.get_playlist(item['data']['slug']) + image_url = f"https://cdn-images.dzcdn.net/images/{item['type']}/{item['data']['PLAYLIST_PICTURE']}/256x256-000000-80-0-0.jpg" + playlist = Playlist( + id=str(item['id']), + name=item['title'], + uri=f"deezer:playlist:{item['id']}", + external_urls=[ExternalUrl(url=f"https://www.deezer.com/playlist/{item['target']}")], + description=item.get('catption',''), + public=True, # TODO: Check if this is correct + collaborative=False, # TODO: Check if this is correct + followers=item['data']['NB_FAN'], + images=[Image(url=image_url, height=None, width=None)], + owner=Owner( + id=item['data'].get('PARENT_USERNAME',''), + name=item['data'].get('PARENT_USERNAME',''), + uri=f"deezer:user:{item['data'].get('PARENT_USERNAME','')}", + external_urls=[ExternalUrl(url='')] + ) + ) + playlists.append(playlist) + return playlists + \ No newline at end of file diff --git a/app/routes/jellyfin_routes.py b/app/routes/jellyfin_routes.py index a74cd5c..c1e0d57 100644 --- a/app/routes/jellyfin_routes.py +++ b/app/routes/jellyfin_routes.py @@ -170,6 +170,37 @@ def delete_playlist(playlist_id): except Exception as e: flash(f'Failed to remove item: {str(e)}') +@app.route('/refresh_playlist/', methods=['GET']) +@functions.jellyfin_admin_required +def refresh_playlist(playlist_id): + # get the playlist from the database using the playlist_id + playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first() + # if the playlist has a jellyfin_id, then fetch the playlist from Jellyfin + if playlist.jellyfin_id: + try: + app.logger.debug(f"removing all tracks from playlist {playlist.jellyfin_id}") + jellyfin_playlist = jellyfin.get_music_playlist(session_token=functions._get_api_token(), playlist_id=playlist.jellyfin_id) + jellyfin.remove_songs_from_playlist(session_token=functions._get_token_from_sessioncookie(), playlist_id=playlist.jellyfin_id, song_ids=[track for track in jellyfin_playlist['ItemIds']]) + ordered_tracks = db.session.execute( + db.select(Track, playlist_tracks.c.track_order) + .join(playlist_tracks, playlist_tracks.c.track_id == Track.id) + .where(playlist_tracks.c.playlist_id == playlist.id) + .order_by(playlist_tracks.c.track_order) + ).all() + + tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None] + #jellyfin.remove_songs_from_playlist(session_token=jellyfin_admin_token, playlist_id=playlist.jellyfin_id, song_ids=tracks) + jellyfin.add_songs_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(), playlist_id=playlist.jellyfin_id, song_ids=tracks) + # if the playlist is found, then update the playlist metadata + provider_playlist = MusicProviderRegistry.get_provider(playlist.provider_id).get_playlist(playlist.provider_playlist_id) + functions.update_playlist_metadata(playlist, provider_playlist) + flash('Playlist refreshed') + return jsonify({'success': True}) + + except Exception as e: + flash(f'Failed to refresh playlist: {str(e)}') + return jsonify({'success': False}) + @app.route('/wipe_playlist/', methods=['DELETE']) @functions.jellyfin_admin_required diff --git a/app/tasks.py b/app/tasks.py index 34c6ef2..fcfc60b 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -345,6 +345,13 @@ def check_for_playlist_updates(self): db.session.commit() app.logger.info(f'Added new track: {track.name}') tracks_to_add.append((track, idx)) + # else check if the track is already in the playlist and change the track_order in the playlist_tracks table + else: + app.logger.debug(f"track {track_info.track.name} moved to position {idx}") + track = existing_tracks[track_id] + stmt = playlist_tracks.update().where(playlist_tracks.c.playlist_id == playlist.id).where(playlist_tracks.c.track_id == track.id).values(track_order=idx) + db.session.execute(stmt) + db.session.commit() tracks_to_remove = [ existing_tracks[track_id] @@ -386,6 +393,7 @@ def check_for_playlist_updates(self): ).all() tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None] + #jellyfin.remove_songs_from_playlist(session_token=jellyfin_admin_token, playlist_id=playlist.jellyfin_id, song_ids=tracks) jellyfin.add_songs_to_playlist(session_token=jellyfin_admin_token, user_id=jellyfin_admin_id, playlist_id=playlist.jellyfin_id, song_ids=tracks) #endregion except Exception as e: diff --git a/app/version.py b/app/version.py index 4dea151..25dff36 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1 @@ -__version__ = "v0.1.9" +__version__ = "v0.1.10" \ No newline at end of file diff --git a/config.py b/config.py index 42bae17..0edd323 100644 --- a/config.py +++ b/config.py @@ -37,6 +37,8 @@ class Config: SPOTDL_PROXY = os.getenv('SPOTDL_PROXY',None) SPOTDL_OUTPUT_FORMAT = os.getenv('SPOTDL_OUTPUT_FORMAT','__jellyplist/{artist}-{title}.mp3') QUALITY_SCORE_THRESHOLD = float(os.getenv('QUALITY_SCORE_THRESHOLD',1000.0)) + + ENABLE_DEEZER = os.getenv('ENABLE_DEEZER','false').lower() == 'true' # SpotDL specific configuration SPOTDL_CONFIG = { 'threads': 12 diff --git a/jellyfin/client.py b/jellyfin/client.py index 0b6749b..255c0e4 100644 --- a/jellyfin/client.py +++ b/jellyfin/client.py @@ -118,6 +118,23 @@ class JellyfinClient: return {"status": "success", "message": "Playlist updated successfully"} else: raise Exception(f"Failed to update playlist: {response.content}") + + def get_music_playlist(self, session_token : str, playlist_id: str): + """ + Get a music playlist by its ID. + :param playlist_id: The ID of the playlist to fetch. + :return: The playlist object + """ + url = f'{self.base_url}/Playlists/{playlist_id}' + self.logger.debug(f"Url={url}") + + response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout) + self.logger.debug(f"Response = {response.status_code}") + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get playlist: {response.content}") def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata: url = f'{self.base_url}/Items/{playlist_id}' @@ -240,7 +257,8 @@ class JellyfinClient: 'IncludeItemTypes': 'Audio', # Search only for audio items 'Recursive': 'true', # Search within all folders - 'Fields': 'Name,Id,Album,Artists,Path' # Retrieve the name and ID of the song + 'Fields': 'Name,Id,Album,Artists,Path', # Retrieve the name and ID of the song + 'Limit': 100 } self.logger.debug(f"Url={url}") @@ -255,29 +273,37 @@ class JellyfinClient: def add_songs_to_playlist(self, session_token: str, user_id: str, playlist_id: str, song_ids: list[str]): """ - Add songs to an existing playlist. + Add songs to an existing playlist in batches to prevent URL length issues. :param playlist_id: The ID of the playlist to update. :param song_ids: A list of song IDs to add. :return: A success message. """ - # Construct the API URL with query parameters + # Construct the API URL without query parameters url = f'{self.base_url}/Playlists/{playlist_id}/Items' - params = { - 'ids': ','.join(song_ids), # Comma-separated song IDs - 'userId': user_id - } + batch_size = 50 + total_songs = len(song_ids) + self.logger.debug(f"Total songs to add: {total_songs}") - self.logger.debug(f"Url={url}") - - # Send the request to Jellyfin API with query parameters - response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout) - self.logger.debug(f"Response = {response.status_code}") + for i in range(0, total_songs, batch_size): + batch = song_ids[i:i + batch_size] + params = { + 'ids': ','.join(batch), # Comma-separated song IDs + 'userId': user_id + } + self.logger.debug(f"Url={url} - Adding batch: {batch}") - # Check for success - if response.status_code == 204: # 204 No Content indicates success - return {"status": "success", "message": "Songs added to playlist successfully"} - else: - raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}") + response = requests.post( + url, + headers=self._get_headers(session_token=session_token), + params=params, + timeout=self.timeout + ) + self.logger.debug(f"Response = {response.status_code}") + + if response.status_code != 204: # 204 No Content indicates success + raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}") + + return {"status": "success", "message": "Songs added to playlist successfully"} def remove_songs_from_playlist(self, session_token: str, playlist_id: str, song_ids): """ @@ -286,19 +312,25 @@ class JellyfinClient: :param song_ids: A list of song IDs to remove. :return: A success message. """ - url = f'{self.base_url}/Playlists/{playlist_id}/Items' - params = { - 'EntryIds': ','.join(song_ids) # Join song IDs with commas - } - self.logger.debug(f"Url={url}") - - response = requests.delete(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout) - self.logger.debug(f"Response = {response.status_code}") + batch_size = 50 + total_songs = len(song_ids) + self.logger.debug(f"Total songs to remove: {total_songs}") - if response.status_code == 204: # 204 No Content indicates success for updating - return {"status": "success", "message": "Songs removed from playlist successfully"} - else: - raise Exception(f"Failed to remove songs from playlist: {response.content}") + for i in range(0, total_songs, batch_size): + batch = song_ids[i:i + batch_size] + url = f'{self.base_url}/Playlists/{playlist_id}/Items' + params = { + 'EntryIds': ','.join(batch) # Join song IDs with commas + } + self.logger.debug(f"Url={url} - Removing batch: {batch}") + + response = requests.delete(url, headers=self._get_headers(session_token=session_token), params=params, timeout=self.timeout) + self.logger.debug(f"Response = {response.status_code}") + + if response.status_code != 204: # 204 No Content indicates success for updating + raise Exception(f"Failed to remove songs from playlist: {response.content}") + + return {"status": "success", "message": "Songs removed from playlist successfully"} def remove_item(self, session_token: str, playlist_id: str): """ diff --git a/requirements.txt b/requirements.txt index 7b5020f..dc05de4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,9 @@ psycopg2-binary eventlet pydub fuzzywuzzy -pyyaml \ No newline at end of file +pyyaml +click +pycryptodomex +mutagen +requests +deezer-py \ No newline at end of file diff --git a/templates/admin/logview.html b/templates/admin/logview.html index 1873595..f39c744 100644 --- a/templates/admin/logview.html +++ b/templates/admin/logview.html @@ -5,7 +5,7 @@ {% set logs = "Logfile empty or not found" %} {% endif %} {% set log_level = config['LOG_LEVEL'] %} - +

Log Viewer

diff --git a/templates/partials/_playlist_info.html b/templates/partials/_playlist_info.html index 656ae86..52d136c 100644 --- a/templates/partials/_playlist_info.html +++ b/templates/partials/_playlist_info.html @@ -7,8 +7,32 @@

{{ item.name }}

{{ item.description }}

{{ item.track_count }} songs, {{ total_duration }}

-

Last Updated: {{ item.last_updated}} | Last Change: {{ item.last_changed}}

+

Last Updated: {{ item.last_updated | human_datetime}} | Last Change: {{ item.last_changed | human_datetime}}

{% include 'partials/_add_remove_button.html' %} +

+ {{item.jellyfin_id | jellyfin_link_button}} + {% if session['is_admin'] and item.jellyfin_id %} + + + {% endif %} +

+ \ No newline at end of file diff --git a/templates/partials/_track_table.html b/templates/partials/_track_table.html index 4a0cb0e..8e127cc 100644 --- a/templates/partials/_track_table.html +++ b/templates/partials/_track_table.html @@ -6,7 +6,6 @@ Artist Duration {{provider_id}} - Preview Status Jellyfin @@ -25,18 +24,7 @@ - - {% if track.preview_url %} - - {% else %} - - - - {% endif %} - + {% if not track.downloaded %}