Compare commits
29 Commits
v0.1.10-de
...
1e18576a88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e18576a88 | ||
|
|
ff99475fab | ||
|
|
54d012009e | ||
|
|
de7b58d7b2 | ||
|
|
68f17e84ed | ||
|
|
f48186bcf6 | ||
|
|
ed8fb70500 | ||
|
|
96b5cd5928 | ||
|
|
006a3ce32e | ||
|
|
a08c9c7800 | ||
|
|
e00513ba52 | ||
|
|
f04657a86c | ||
|
|
7a6d238610 | ||
|
|
ce50e1a8f8 | ||
|
|
32c860fbb9 | ||
|
|
bf725c6b24 | ||
|
|
bb195b1c77 | ||
|
|
e66208b49e | ||
|
|
9e675f8cf4 | ||
|
|
8a883edf07 | ||
|
|
e9fa5f8994 | ||
|
|
1b91110768 | ||
|
|
7af86c926f | ||
|
|
580906dc78 | ||
|
|
917ec9542f | ||
|
|
b9530a159c | ||
|
|
fffeac8c74 | ||
|
|
4d06b257cb | ||
|
|
8c9fb43f01 |
@@ -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}"
|
||||
|
||||
2
.pylintrc
Normal file
2
.pylintrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=logging-fstring-interpolation,broad-exception-raised
|
||||
@@ -203,9 +203,16 @@ else:
|
||||
spotify_client = SpotifyClient()
|
||||
|
||||
spotify_client.authenticate()
|
||||
app.logger.info('spotify auth successful')
|
||||
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
|
||||
|
||||
@@ -97,3 +97,21 @@ def jellyfin_link(jellyfin_id: str) -> Markup:
|
||||
|
||||
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
||||
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 .deezer import DeezerClient
|
||||
|
||||
__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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category
|
||||
import requests
|
||||
import base64
|
||||
|
||||
import json
|
||||
import requests
|
||||
@@ -11,8 +10,38 @@ from typing import List, Dict, Optional
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
import logging
|
||||
|
||||
from typing import Callable, Tuple
|
||||
import hmac
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
l = logging.getLogger(__name__)
|
||||
|
||||
_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
|
||||
|
||||
def generate_totp(
|
||||
secret: bytes = _TOTP_SECRET,
|
||||
algorithm: Callable[[], object] = hashlib.sha1,
|
||||
digits: int = 6,
|
||||
counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
|
||||
) -> Tuple[str, int]:
|
||||
counter = counter_factory()
|
||||
hmac_result = hmac.new(
|
||||
secret, counter.to_bytes(8, byteorder="big"), algorithm # type: ignore
|
||||
).digest()
|
||||
|
||||
offset = hmac_result[-1] & 15
|
||||
truncated_value = (
|
||||
(hmac_result[offset] & 127) << 24
|
||||
| (hmac_result[offset + 1] & 255) << 16
|
||||
| (hmac_result[offset + 2] & 255) << 8
|
||||
| (hmac_result[offset + 3] & 255)
|
||||
)
|
||||
return (
|
||||
str(truncated_value % (10**digits)).zfill(digits),
|
||||
counter * 30_000,
|
||||
) # (30 * 1000)
|
||||
|
||||
class SpotifyClient(MusicProviderClient):
|
||||
"""
|
||||
Spotify implementation of the MusicProviderClient.
|
||||
@@ -23,9 +52,10 @@ class SpotifyClient(MusicProviderClient):
|
||||
|
||||
def __init__(self, cookie_file: Optional[str] = None):
|
||||
self.base_url = "https://api-partner.spotify.com"
|
||||
self.session_data = None
|
||||
self.config_data = None
|
||||
self.app_server_config_data = None
|
||||
self.client_token = None
|
||||
self.access_token = None
|
||||
self.client_id = None
|
||||
self.cookies = None
|
||||
if cookie_file:
|
||||
self._load_cookies(cookie_file)
|
||||
@@ -52,16 +82,19 @@ class SpotifyClient(MusicProviderClient):
|
||||
"""
|
||||
if self.cookies:
|
||||
l.debug("Authenticating using cookies.")
|
||||
self.session_data, self.config_data = self._fetch_session_data()
|
||||
self.client_token = self._fetch_client_token()
|
||||
self.app_server_config_data = self._fetch_app_server_config_data()
|
||||
else:
|
||||
l.debug("Authenticating without cookies.")
|
||||
self.session_data, self.config_data = self._fetch_session_data(fetch_with_cookies=False)
|
||||
self.app_server_config_data = self._fetch_app_server_config_data(fetch_with_cookies=False)
|
||||
|
||||
self._get_access_token_and_client_id()
|
||||
self.client_token = self._fetch_client_token()
|
||||
|
||||
def _fetch_session_data(self, fetch_with_cookies: bool = True):
|
||||
def _fetch_app_server_config_data(self, fetch_with_cookies: bool = True):
|
||||
"""
|
||||
Fetch session data from Spotify.
|
||||
Fetch app_server_config data from Spotify.
|
||||
|
||||
We will use the correlationId from app_server_config_data for a successful _fetch_client_token call
|
||||
|
||||
:param fetch_with_cookies: Whether to include cookies in the request.
|
||||
:return: Tuple containing session and config data.
|
||||
@@ -75,13 +108,14 @@ class SpotifyClient(MusicProviderClient):
|
||||
response = requests.get(url, headers=headers, cookies=cookies)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
session_script = soup.find('script', {'id': 'session'})
|
||||
config_script = soup.find('script', {'id': 'config'})
|
||||
if session_script and config_script:
|
||||
l.debug("fetched session and config scripts")
|
||||
return json.loads(session_script.string), json.loads(config_script.string)
|
||||
app_server_config_script = soup.find('script', {'id': 'appServerConfig'}).text # decode JWT to obtain correlation required for obtain Bearer Token
|
||||
decoded_app_server_config_script = base64.b64decode(app_server_config_script) # base64 decode
|
||||
decoded_app_server_config_script = decoded_app_server_config_script.decode().strip("b'") # decode from byte object to string object
|
||||
if decoded_app_server_config_script:
|
||||
l.debug("fetched app_server_config_script scripts")
|
||||
return json.loads(decoded_app_server_config_script)
|
||||
else:
|
||||
raise ValueError("Failed to fetch session or config data.")
|
||||
raise ValueError("Failed to fetch app_server_config data.")
|
||||
|
||||
def _fetch_client_token(self):
|
||||
"""
|
||||
@@ -99,22 +133,49 @@ class SpotifyClient(MusicProviderClient):
|
||||
payload = {
|
||||
"client_data": {
|
||||
"client_version": "1.2.52.404.gcb99a997",
|
||||
"client_id": self.session_data.get("clientId", ""),
|
||||
"client_id": self.client_id,
|
||||
"js_sdk_data": {
|
||||
"device_brand": "unknown",
|
||||
"device_model": "unknown",
|
||||
"os": "windows",
|
||||
"os_version": "NT 10.0",
|
||||
"device_id": self.config_data.get("correlationId", ""),
|
||||
"device_id": self.app_server_config_data.get("correlationId", ""),
|
||||
"device_type": "computer"
|
||||
}
|
||||
}
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
|
||||
response.raise_for_status()
|
||||
l.debug("fetched granted_token")
|
||||
l.debug("fetched client_token (granted_token)")
|
||||
return response.json().get("granted_token", "")
|
||||
|
||||
def _get_access_token_and_client_id(self, fetch_with_cookies: bool = True):
|
||||
"""
|
||||
Fetch the Access Token and Client ID by making GET call to open.spotify.com/get_access_token
|
||||
"""
|
||||
url = f'https://open.spotify.com/get_access_token'
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'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',
|
||||
}
|
||||
|
||||
totp, timestamp = generate_totp()
|
||||
|
||||
query_params = {
|
||||
"reason": "init",
|
||||
"productType": "web-player",
|
||||
"totp": totp,
|
||||
"totpVer": 5,
|
||||
"ts": timestamp,
|
||||
}
|
||||
cookies = self.cookies if fetch_with_cookies else None
|
||||
|
||||
response = requests.get(url, params=query_params, headers=headers, cookies=self.cookies)
|
||||
response.raise_for_status()
|
||||
self.client_id = response.json().get("clientId", "")
|
||||
self.access_token = response.json().get("accessToken", "")
|
||||
l.debug("fetched access_token and client_id")
|
||||
|
||||
def _make_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""
|
||||
Helper method to make authenticated requests to Spotify APIs.
|
||||
@@ -122,8 +183,8 @@ class SpotifyClient(MusicProviderClient):
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'app-platform': 'WebPlayer',
|
||||
'authorization': f'Bearer {self.session_data.get("accessToken", "")}',
|
||||
'client-token': self.client_token.get('token',''),
|
||||
'authorization': f'Bearer {self.access_token}',
|
||||
'client-token': self.client_token.get('token','')
|
||||
}
|
||||
l.debug(f"starting request: {self.base_url}/{endpoint}")
|
||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||
@@ -131,7 +192,7 @@ class SpotifyClient(MusicProviderClient):
|
||||
if response.status_code == 401:
|
||||
l.debug("reauthenticating")
|
||||
self.authenticate()
|
||||
headers['authorization'] = f'Bearer {self.session_data.get("accessToken", "")}'
|
||||
headers['authorization'] = f'Bearer {self.access_token}'
|
||||
headers['client-token'] = self.client_token.get('token','')
|
||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||
|
||||
|
||||
@@ -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/<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'])
|
||||
@functions.jellyfin_admin_required
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "v0.1.9"
|
||||
__version__ = "v0.1.10"
|
||||
@@ -22,7 +22,7 @@ class Config:
|
||||
DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true'
|
||||
CACHE_TYPE = 'redis'
|
||||
CACHE_REDIS_PORT = 6379
|
||||
CACHE_REDIS_HOST = 'redis'
|
||||
CACHE_REDIS_HOST = os.getenv('CACHE_REDIS_HOST','redis')
|
||||
CACHE_REDIS_DB = 0
|
||||
CACHE_DEFAULT_TIMEOUT = 3600
|
||||
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
|
||||
@@ -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
|
||||
|
||||
@@ -119,6 +119,23 @@ class JellyfinClient:
|
||||
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}'
|
||||
params = {
|
||||
@@ -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,30 +273,38 @@ 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'
|
||||
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 = {
|
||||
'ids': ','.join(song_ids), # Comma-separated song IDs
|
||||
'ids': ','.join(batch), # Comma-separated song IDs
|
||||
'userId': user_id
|
||||
}
|
||||
self.logger.debug(f"Url={url} - Adding batch: {batch}")
|
||||
|
||||
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)
|
||||
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}")
|
||||
|
||||
# Check for success
|
||||
if response.status_code == 204: # 204 No Content indicates success
|
||||
return {"status": "success", "message": "Songs added to playlist successfully"}
|
||||
else:
|
||||
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):
|
||||
"""
|
||||
Remove songs from an existing playlist.
|
||||
@@ -286,20 +312,26 @@ class JellyfinClient:
|
||||
:param song_ids: A list of song IDs to remove.
|
||||
: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'
|
||||
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}")
|
||||
|
||||
if response.status_code == 204: # 204 No Content indicates success for updating
|
||||
return {"status": "success", "message": "Songs removed from playlist successfully"}
|
||||
else:
|
||||
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):
|
||||
"""
|
||||
Remove an existing playlist by its ID.
|
||||
|
||||
@@ -18,3 +18,8 @@ eventlet
|
||||
pydub
|
||||
fuzzywuzzy
|
||||
pyyaml
|
||||
click
|
||||
pycryptodomex
|
||||
mutagen
|
||||
requests
|
||||
deezer-py
|
||||
@@ -5,7 +5,7 @@
|
||||
{% set logs = "Logfile empty or not found" %}
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<h1>Log Viewer</h1>
|
||||
|
||||
@@ -7,8 +7,32 @@
|
||||
<h1>{{ item.name }}</h1>
|
||||
<p>{{ item.description }}</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' %}
|
||||
</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>
|
||||
@@ -6,7 +6,6 @@
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Duration</th>
|
||||
<th scope="col">{{provider_id}}</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Jellyfin</th>
|
||||
</tr>
|
||||
@@ -25,18 +24,7 @@
|
||||
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
|
||||
</a>
|
||||
</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>
|
||||
{% if not track.downloaded %}
|
||||
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
|
||||
<!-- Card Image -->
|
||||
<div style="position: relative;">
|
||||
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}">
|
||||
|
||||
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card Body -->
|
||||
@@ -30,17 +33,10 @@
|
||||
<p class="card-text">{{ item.description }}</p>
|
||||
</div>
|
||||
<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"
|
||||
title="View Playlist details">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
{%endif%}
|
||||
{% include 'partials/_add_remove_button.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "v0.1.9"
|
||||
__version__ = "v0.1.10"
|
||||
|
||||
Reference in New Issue
Block a user