@@ -1,12 +1,12 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = v0.1.9
|
current_version = 0.1.10
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
|
|
||||||
[bumpversion:file:app/version.py]
|
[bumpversion:file:app/version.py]
|
||||||
search = __version__ = "{current_version}"
|
search = __version__ = "v{current_version}"
|
||||||
replace = __version__ = "{new_version}"
|
replace = __version__ = "v{new_version}"
|
||||||
|
|
||||||
[bumpversion:file:version.py]
|
[bumpversion:file:version.py]
|
||||||
search = __version__ = "{current_version}"
|
search = __version__ = "v{current_version}"
|
||||||
replace = __version__ = "{new_version}"
|
replace = __version__ = "v{new_version}"
|
||||||
|
|||||||
2
.pylintrc
Normal file
2
.pylintrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable=logging-fstring-interpolation,broad-exception-raised
|
||||||
@@ -206,6 +206,12 @@ spotify_client.authenticate()
|
|||||||
from .registry import MusicProviderRegistry
|
from .registry import MusicProviderRegistry
|
||||||
MusicProviderRegistry.register_provider(spotify_client)
|
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']:
|
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
|
||||||
app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}')
|
app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}')
|
||||||
from lidarr.client import LidarrClient
|
from lidarr.client import LidarrClient
|
||||||
|
|||||||
@@ -97,3 +97,21 @@ def jellyfin_link(jellyfin_id: str) -> Markup:
|
|||||||
|
|
||||||
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
||||||
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
|
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
|
||||||
|
|
||||||
|
@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"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
|
||||||
|
|
||||||
|
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
||||||
|
return Markup(f'<a href="{link}" class="btn btn-primary mt-2" target="_blank">Open in Jellyfin</a>')
|
||||||
|
|
||||||
|
|
||||||
|
# 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')
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from .spotify import SpotifyClient
|
from .spotify import SpotifyClient
|
||||||
|
#from .deezer import DeezerClient
|
||||||
|
|
||||||
__all__ = ["SpotifyClient"]
|
__all__ = ["SpotifyClient"]
|
||||||
320
app/providers/deezer.py
Normal file
320
app/providers/deezer.py
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -170,6 +170,37 @@ def delete_playlist(playlist_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Failed to remove item: {str(e)}')
|
flash(f'Failed to remove item: {str(e)}')
|
||||||
|
|
||||||
|
@app.route('/refresh_playlist/<playlist_id>', 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/<playlist_id>', methods=['DELETE'])
|
@app.route('/wipe_playlist/<playlist_id>', methods=['DELETE'])
|
||||||
@functions.jellyfin_admin_required
|
@functions.jellyfin_admin_required
|
||||||
|
|||||||
@@ -345,6 +345,13 @@ def check_for_playlist_updates(self):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
app.logger.info(f'Added new track: {track.name}')
|
app.logger.info(f'Added new track: {track.name}')
|
||||||
tracks_to_add.append((track, idx))
|
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 = [
|
tracks_to_remove = [
|
||||||
existing_tracks[track_id]
|
existing_tracks[track_id]
|
||||||
@@ -386,6 +393,7 @@ def check_for_playlist_updates(self):
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None]
|
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)
|
jellyfin.add_songs_to_playlist(session_token=jellyfin_admin_token, user_id=jellyfin_admin_id, playlist_id=playlist.jellyfin_id, song_ids=tracks)
|
||||||
#endregion
|
#endregion
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "v0.1.9"
|
__version__ = "v0.1.10"
|
||||||
@@ -37,6 +37,8 @@ class Config:
|
|||||||
SPOTDL_PROXY = os.getenv('SPOTDL_PROXY',None)
|
SPOTDL_PROXY = os.getenv('SPOTDL_PROXY',None)
|
||||||
SPOTDL_OUTPUT_FORMAT = os.getenv('SPOTDL_OUTPUT_FORMAT','__jellyplist/{artist}-{title}.mp3')
|
SPOTDL_OUTPUT_FORMAT = os.getenv('SPOTDL_OUTPUT_FORMAT','__jellyplist/{artist}-{title}.mp3')
|
||||||
QUALITY_SCORE_THRESHOLD = float(os.getenv('QUALITY_SCORE_THRESHOLD',1000.0))
|
QUALITY_SCORE_THRESHOLD = float(os.getenv('QUALITY_SCORE_THRESHOLD',1000.0))
|
||||||
|
|
||||||
|
ENABLE_DEEZER = os.getenv('ENABLE_DEEZER','false').lower() == 'true'
|
||||||
# SpotDL specific configuration
|
# SpotDL specific configuration
|
||||||
SPOTDL_CONFIG = {
|
SPOTDL_CONFIG = {
|
||||||
'threads': 12
|
'threads': 12
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ class JellyfinClient:
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"Failed to update playlist: {response.content}")
|
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:
|
def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata:
|
||||||
url = f'{self.base_url}/Items/{playlist_id}'
|
url = f'{self.base_url}/Items/{playlist_id}'
|
||||||
params = {
|
params = {
|
||||||
@@ -240,7 +257,8 @@ class JellyfinClient:
|
|||||||
|
|
||||||
'IncludeItemTypes': 'Audio', # Search only for audio items
|
'IncludeItemTypes': 'Audio', # Search only for audio items
|
||||||
'Recursive': 'true', # Search within all folders
|
'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}")
|
self.logger.debug(f"Url={url}")
|
||||||
|
|
||||||
@@ -255,30 +273,38 @@ class JellyfinClient:
|
|||||||
|
|
||||||
def add_songs_to_playlist(self, session_token: str, user_id: str, playlist_id: str, song_ids: list[str]):
|
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 playlist_id: The ID of the playlist to update.
|
||||||
:param song_ids: A list of song IDs to add.
|
:param song_ids: A list of song IDs to add.
|
||||||
:return: A success message.
|
: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'
|
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||||
|
batch_size = 50
|
||||||
|
total_songs = len(song_ids)
|
||||||
|
self.logger.debug(f"Total songs to add: {total_songs}")
|
||||||
|
|
||||||
|
for i in range(0, total_songs, batch_size):
|
||||||
|
batch = song_ids[i:i + batch_size]
|
||||||
params = {
|
params = {
|
||||||
'ids': ','.join(song_ids), # Comma-separated song IDs
|
'ids': ','.join(batch), # Comma-separated song IDs
|
||||||
'userId': user_id
|
'userId': user_id
|
||||||
}
|
}
|
||||||
|
self.logger.debug(f"Url={url} - Adding batch: {batch}")
|
||||||
|
|
||||||
self.logger.debug(f"Url={url}")
|
response = requests.post(
|
||||||
|
url,
|
||||||
# Send the request to Jellyfin API with query parameters
|
headers=self._get_headers(session_token=session_token),
|
||||||
response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout)
|
params=params,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
self.logger.debug(f"Response = {response.status_code}")
|
self.logger.debug(f"Response = {response.status_code}")
|
||||||
|
|
||||||
# Check for success
|
if response.status_code != 204: # 204 No Content indicates 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}")
|
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):
|
def remove_songs_from_playlist(self, session_token: str, playlist_id: str, song_ids):
|
||||||
"""
|
"""
|
||||||
Remove songs from an existing playlist.
|
Remove songs from an existing playlist.
|
||||||
@@ -286,20 +312,26 @@ class JellyfinClient:
|
|||||||
:param song_ids: A list of song IDs to remove.
|
:param song_ids: A list of song IDs to remove.
|
||||||
:return: A success message.
|
:return: A success message.
|
||||||
"""
|
"""
|
||||||
|
batch_size = 50
|
||||||
|
total_songs = len(song_ids)
|
||||||
|
self.logger.debug(f"Total songs to remove: {total_songs}")
|
||||||
|
|
||||||
|
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'
|
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||||
params = {
|
params = {
|
||||||
'EntryIds': ','.join(song_ids) # Join song IDs with commas
|
'EntryIds': ','.join(batch) # Join song IDs with commas
|
||||||
}
|
}
|
||||||
self.logger.debug(f"Url={url}")
|
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)
|
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}")
|
self.logger.debug(f"Response = {response.status_code}")
|
||||||
|
|
||||||
if response.status_code == 204: # 204 No Content indicates success for updating
|
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}")
|
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):
|
def remove_item(self, session_token: str, playlist_id: str):
|
||||||
"""
|
"""
|
||||||
Remove an existing playlist by its ID.
|
Remove an existing playlist by its ID.
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ eventlet
|
|||||||
pydub
|
pydub
|
||||||
fuzzywuzzy
|
fuzzywuzzy
|
||||||
pyyaml
|
pyyaml
|
||||||
|
click
|
||||||
|
pycryptodomex
|
||||||
|
mutagen
|
||||||
|
requests
|
||||||
|
deezer-py
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
{% set logs = "Logfile empty or not found" %}
|
{% set logs = "Logfile empty or not found" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set log_level = config['LOG_LEVEL'] %}
|
{% set log_level = config['LOG_LEVEL'] %}
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.21.2/min/vs/loader.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs/loader.js"></script>
|
||||||
|
|
||||||
<div class="container-fluid mt-5">
|
<div class="container-fluid mt-5">
|
||||||
<h1>Log Viewer</h1>
|
<h1>Log Viewer</h1>
|
||||||
|
|||||||
@@ -7,8 +7,32 @@
|
|||||||
<h1>{{ item.name }}</h1>
|
<h1>{{ item.name }}</h1>
|
||||||
<p>{{ item.description }}</p>
|
<p>{{ item.description }}</p>
|
||||||
<p>{{ item.track_count }} songs, {{ total_duration }}</p>
|
<p>{{ item.track_count }} songs, {{ total_duration }}</p>
|
||||||
<p>Last Updated: {{ item.last_updated}} | Last Change: {{ item.last_changed}}</p>
|
<p>Last Updated: {{ item.last_updated | human_datetime}} | Last Change: {{ item.last_changed | human_datetime}}</p>
|
||||||
{% include 'partials/_add_remove_button.html' %}
|
{% include 'partials/_add_remove_button.html' %}
|
||||||
</div>
|
<p>
|
||||||
|
{{item.jellyfin_id | jellyfin_link_button}}
|
||||||
|
{% if session['is_admin'] and item.jellyfin_id %}
|
||||||
|
<button id="refresh-playlist-btn" class="btn btn-primary mt-2">Refresh Playlist in Jellyfin</button>
|
||||||
|
<script>
|
||||||
|
document.getElementById('refresh-playlist-btn').addEventListener('click', function() {
|
||||||
|
fetch(`/refresh_playlist/{{item.jellyfin_id}}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Playlist refreshed successfully');
|
||||||
|
} else {
|
||||||
|
alert('Failed to refresh playlist');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while refreshing the playlist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
<th scope="col">Artist</th>
|
<th scope="col">Artist</th>
|
||||||
<th scope="col">Duration</th>
|
<th scope="col">Duration</th>
|
||||||
<th scope="col">{{provider_id}}</th>
|
<th scope="col">{{provider_id}}</th>
|
||||||
<th scope="col">Preview</th>
|
|
||||||
<th scope="col">Status</th>
|
<th scope="col">Status</th>
|
||||||
<th scope="col">Jellyfin</th>
|
<th scope="col">Jellyfin</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -25,18 +24,7 @@
|
|||||||
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
|
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{% if track.preview_url %}
|
|
||||||
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')"
|
|
||||||
data-bs-toggle="tooltip" title="Play Preview">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<span data-bs-toggle="tooltip" title="No Preview Available">
|
|
||||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if not track.downloaded %}
|
{% if not track.downloaded %}
|
||||||
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
|
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
|
|
||||||
<!-- Card Image -->
|
<!-- Card Image -->
|
||||||
<div style="position: relative;">
|
<div style="position: relative;">
|
||||||
|
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}">
|
||||||
|
|
||||||
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
|
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Body -->
|
<!-- Card Body -->
|
||||||
@@ -30,17 +33,10 @@
|
|||||||
<p class="card-text">{{ item.description }}</p>
|
<p class="card-text">{{ item.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto pt-3">
|
<div class="mt-auto pt-3">
|
||||||
{% if item.type == 'category'%}
|
|
||||||
<a href="{{ item.url }}" class="btn btn-primary" data-bs-toggle="tooltip" title="View Playlist">
|
|
||||||
<i class="fa-solid fa-eye"></i>
|
|
||||||
</a>
|
|
||||||
{%else%}
|
|
||||||
|
|
||||||
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}" class="btn btn-primary" data-bs-toggle="tooltip"
|
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}" class="btn btn-primary" data-bs-toggle="tooltip"
|
||||||
title="View Playlist details">
|
title="View Playlist details">
|
||||||
<i class="fa-solid fa-eye"></i>
|
<i class="fa-solid fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{%endif%}
|
|
||||||
{% include 'partials/_add_remove_button.html' %}
|
{% include 'partials/_add_remove_button.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "v0.1.9"
|
__version__ = "v0.1.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user