29 Commits

Author SHA1 Message Date
kuelteabmas
1e18576a88 bugfix(spotify): Fixed reauthentication issue
* Reinserting self.authenticate() back into 401 reauthentication flow
2025-05-18 22:03:02 -04:00
kuelteabmas
ff99475fab bugfix(spotify): Updated functionality to fetch access_token and client_id required for making requests
* Removed deprecated session_data and its logic
* Added TOTP for new _get_access_token_and_client_id method
* Added some loggers
2025-05-18 20:05:21 -04:00
kuelteabmas
54d012009e bugfix(spotify): Updated script to be parsed for session_data from config to appServerConfig script
- config script is no longer returned from Fetch session data call from Spotify
- it's now called appServerConfig which contains the correlationId needed to make Spotify requests
-
2025-05-15 17:03:03 -04:00
Kamil Kosek
de7b58d7b2 Merge pull request #74 from kamilkosek/dev
Dev
2025-02-11 13:49:54 +01:00
Kamil Kosek
68f17e84ed Merge pull request #75 from kamilkosek/main
main to dev
2025-02-11 13:38:30 +01:00
Kamil
f48186bcf6 Update spotdl version to 4.2.11 in requirements.txt 2025-02-11 12:32:46 +00:00
Kamil Kosek
ed8fb70500 Merge pull request #71 from Daniel-Boluda/patch-1
add CACHE_REDIS_HOST as env variable to config.py
2025-02-11 11:26:16 +01:00
Kamil
96b5cd5928 . 2025-02-11 09:35:35 +00:00
Kamil
006a3ce32e Bump version: "0.1.9" → 0.1.10 2025-02-11 09:35:10 +00:00
Kamil
a08c9c7800 . 2025-02-11 09:35:07 +00:00
Kamil
e00513ba52 . 2025-02-11 09:34:42 +00:00
Kamil
f04657a86c . 2025-02-11 09:33:49 +00:00
Kamil
7a6d238610 . 2025-02-11 09:33:01 +00:00
Kamil
ce50e1a8f8 . 2025-02-11 09:28:55 +00:00
Kamil
32c860fbb9 fix: remove unnecessary regex anchors in bumpversion configuration 2025-02-11 09:26:40 +00:00
Kamil
bf725c6b24 fix: improve regex for version parsing in bumpversion configuration 2025-02-11 09:25:32 +00:00
Kamil
bb195b1c77 fix: update version regex in bumpversion configuration 2025-02-11 09:24:31 +00:00
Kamil
e66208b49e add pylintrc configuration to control message settings 2025-02-11 09:24:24 +00:00
Kamil
9e675f8cf4 feat: add batch processing for adding and removing songs in playlists 2025-02-11 09:15:32 +00:00
Kamil
8a883edf07 feat: handle empty datetime input in human-readable filter 2025-02-11 09:15:07 +00:00
Kamil
e9fa5f8994 Preperation for deezer 2025-02-11 08:32:36 +00:00
Daniel Boluda
1b91110768 add CACHE_REDIS_HOST as env variable to config.py
This is usefull for hosts not being "redis"
2025-01-26 14:37:15 +01:00
Kamil
7af86c926f refactor: remove preview button from track table template 2024-12-18 09:23:41 +00:00
Kamil
580906dc78 feat: add Jellyfin link button filter and integrate into playlist info template 2024-12-18 09:22:34 +00:00
Kamil
917ec9542f feat: add human-readable datetime filter and update playlist info template 2024-12-18 09:00:12 +00:00
Kamil
b9530a159c feat: make playlist item image clickable to view details 2024-12-17 17:51:22 +00:00
Kamil
fffeac8c74 fix: update Monaco Editor version in log view template 2024-12-17 17:45:15 +00:00
Kamil
4d06b257cb feat: add refresh playlist functionality 2024-12-17 17:45:06 +00:00
Kamil
8c9fb43f01 fix: update track order in playlist when moving tracks
Fixes #53
2024-12-17 17:43:31 +00:00
18 changed files with 580 additions and 85 deletions

View File

@@ -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
View File

@@ -0,0 +1,2 @@
[MESSAGES CONTROL]
disable=logging-fstring-interpolation,broad-exception-raised

View File

@@ -203,9 +203,16 @@ else:
spotify_client = SpotifyClient() spotify_client = SpotifyClient()
spotify_client.authenticate() spotify_client.authenticate()
app.logger.info('spotify auth successful')
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

View File

@@ -96,4 +96,22 @@ def jellyfin_link(jellyfin_id: str) -> Markup:
return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>") return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
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')

View File

@@ -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
View 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

View File

@@ -1,7 +1,6 @@
from dataclasses import dataclass
import os import os
from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category
import requests import base64
import json import json
import requests import requests
@@ -11,8 +10,38 @@ from typing import List, Dict, Optional
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
import logging import logging
from typing import Callable, Tuple
import hmac
import time
import hashlib
l = logging.getLogger(__name__) 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): class SpotifyClient(MusicProviderClient):
""" """
Spotify implementation of the MusicProviderClient. Spotify implementation of the MusicProviderClient.
@@ -23,9 +52,10 @@ class SpotifyClient(MusicProviderClient):
def __init__(self, cookie_file: Optional[str] = None): def __init__(self, cookie_file: Optional[str] = None):
self.base_url = "https://api-partner.spotify.com" self.base_url = "https://api-partner.spotify.com"
self.session_data = None self.app_server_config_data = None
self.config_data = None
self.client_token = None self.client_token = None
self.access_token = None
self.client_id = None
self.cookies = None self.cookies = None
if cookie_file: if cookie_file:
self._load_cookies(cookie_file) self._load_cookies(cookie_file)
@@ -52,16 +82,19 @@ class SpotifyClient(MusicProviderClient):
""" """
if self.cookies: if self.cookies:
l.debug("Authenticating using cookies.") l.debug("Authenticating using cookies.")
self.session_data, self.config_data = self._fetch_session_data() self.app_server_config_data = self._fetch_app_server_config_data()
self.client_token = self._fetch_client_token()
else: else:
l.debug("Authenticating without cookies.") 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.client_token = self._fetch_client_token()
def _fetch_session_data(self, fetch_with_cookies: bool = True): self._get_access_token_and_client_id()
self.client_token = self._fetch_client_token()
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. :param fetch_with_cookies: Whether to include cookies in the request.
:return: Tuple containing session and config data. :return: Tuple containing session and config data.
@@ -75,13 +108,14 @@ class SpotifyClient(MusicProviderClient):
response = requests.get(url, headers=headers, cookies=cookies) response = requests.get(url, headers=headers, cookies=cookies)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
session_script = soup.find('script', {'id': 'session'}) app_server_config_script = soup.find('script', {'id': 'appServerConfig'}).text # decode JWT to obtain correlation required for obtain Bearer Token
config_script = soup.find('script', {'id': 'config'}) decoded_app_server_config_script = base64.b64decode(app_server_config_script) # base64 decode
if session_script and config_script: decoded_app_server_config_script = decoded_app_server_config_script.decode().strip("b'") # decode from byte object to string object
l.debug("fetched session and config scripts") if decoded_app_server_config_script:
return json.loads(session_script.string), json.loads(config_script.string) l.debug("fetched app_server_config_script scripts")
return json.loads(decoded_app_server_config_script)
else: else:
raise ValueError("Failed to fetch session or config data.") raise ValueError("Failed to fetch app_server_config data.")
def _fetch_client_token(self): def _fetch_client_token(self):
""" """
@@ -99,22 +133,49 @@ class SpotifyClient(MusicProviderClient):
payload = { payload = {
"client_data": { "client_data": {
"client_version": "1.2.52.404.gcb99a997", "client_version": "1.2.52.404.gcb99a997",
"client_id": self.session_data.get("clientId", ""), "client_id": self.client_id,
"js_sdk_data": { "js_sdk_data": {
"device_brand": "unknown", "device_brand": "unknown",
"device_model": "unknown", "device_model": "unknown",
"os": "windows", "os": "windows",
"os_version": "NT 10.0", "os_version": "NT 10.0",
"device_id": self.config_data.get("correlationId", ""), "device_id": self.app_server_config_data.get("correlationId", ""),
"device_type": "computer" "device_type": "computer"
} }
} }
} }
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies) response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
response.raise_for_status() response.raise_for_status()
l.debug("fetched granted_token") l.debug("fetched client_token (granted_token)")
return response.json().get("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: def _make_request(self, endpoint: str, params: dict = None) -> dict:
""" """
Helper method to make authenticated requests to Spotify APIs. Helper method to make authenticated requests to Spotify APIs.
@@ -122,8 +183,8 @@ class SpotifyClient(MusicProviderClient):
headers = { headers = {
'accept': 'application/json', 'accept': 'application/json',
'app-platform': 'WebPlayer', 'app-platform': 'WebPlayer',
'authorization': f'Bearer {self.session_data.get("accessToken", "")}', 'authorization': f'Bearer {self.access_token}',
'client-token': self.client_token.get('token',''), 'client-token': self.client_token.get('token','')
} }
l.debug(f"starting request: {self.base_url}/{endpoint}") l.debug(f"starting request: {self.base_url}/{endpoint}")
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies) 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: if response.status_code == 401:
l.debug("reauthenticating") l.debug("reauthenticating")
self.authenticate() 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','') headers['client-token'] = self.client_token.get('token','')
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies) response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)

View File

@@ -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

View File

@@ -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:

View File

@@ -1 +1 @@
__version__ = "v0.1.9" __version__ = "v0.1.10"

View File

@@ -22,7 +22,7 @@ class Config:
DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true' DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true'
CACHE_TYPE = 'redis' CACHE_TYPE = 'redis'
CACHE_REDIS_PORT = 6379 CACHE_REDIS_PORT = 6379
CACHE_REDIS_HOST = 'redis' CACHE_REDIS_HOST = os.getenv('CACHE_REDIS_HOST','redis')
CACHE_REDIS_DB = 0 CACHE_REDIS_DB = 0
CACHE_DEFAULT_TIMEOUT = 3600 CACHE_DEFAULT_TIMEOUT = 3600
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0') REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
@@ -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
@@ -80,4 +82,4 @@ class Config:
if missing_vars: if missing_vars:
missing = ', '.join(missing_vars) missing = ', '.join(missing_vars)
sys.stderr.write(f"Error: The following environment variables are not set: {missing}\n") sys.stderr.write(f"Error: The following environment variables are not set: {missing}\n")
sys.exit(1) sys.exit(1)

View File

@@ -118,6 +118,23 @@ class JellyfinClient:
return {"status": "success", "message": "Playlist updated successfully"} return {"status": "success", "message": "Playlist updated successfully"}
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}'
@@ -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,29 +273,37 @@ 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'
params = { batch_size = 50
'ids': ','.join(song_ids), # Comma-separated song IDs total_songs = len(song_ids)
'userId': user_id self.logger.debug(f"Total songs to add: {total_songs}")
}
self.logger.debug(f"Url={url}") for i in range(0, total_songs, batch_size):
batch = song_ids[i:i + batch_size]
# Send the request to Jellyfin API with query parameters params = {
response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout) 'ids': ','.join(batch), # Comma-separated song IDs
self.logger.debug(f"Response = {response.status_code}") 'userId': user_id
}
self.logger.debug(f"Url={url} - Adding batch: {batch}")
# Check for success response = requests.post(
if response.status_code == 204: # 204 No Content indicates success url,
return {"status": "success", "message": "Songs added to playlist successfully"} headers=self._get_headers(session_token=session_token),
else: params=params,
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}") 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): 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. :param song_ids: A list of song IDs to remove.
:return: A success message. :return: A success message.
""" """
url = f'{self.base_url}/Playlists/{playlist_id}/Items' batch_size = 50
params = { total_songs = len(song_ids)
'EntryIds': ','.join(song_ids) # Join song IDs with commas self.logger.debug(f"Total songs to remove: {total_songs}")
}
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}")
if response.status_code == 204: # 204 No Content indicates success for updating for i in range(0, total_songs, batch_size):
return {"status": "success", "message": "Songs removed from playlist successfully"} batch = song_ids[i:i + batch_size]
else: url = f'{self.base_url}/Playlists/{playlist_id}/Items'
raise Exception(f"Failed to remove songs from playlist: {response.content}") 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): def remove_item(self, session_token: str, playlist_id: str):
""" """

View File

@@ -17,4 +17,9 @@ psycopg2-binary
eventlet eventlet
pydub pydub
fuzzywuzzy fuzzywuzzy
pyyaml pyyaml
click
pycryptodomex
mutagen
requests
deezer-py

View File

@@ -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>

View File

@@ -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' %}
<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> </div>

View File

@@ -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"

View File

@@ -20,7 +20,10 @@
<!-- Card Image --> <!-- Card Image -->
<div style="position: relative;"> <div style="position: relative;">
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}"> <a href="/playlist/view/{{ item.id }}?provider={{provider_id}}">
<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>

View File

@@ -1 +1 @@
__version__ = "v0.1.9" __version__ = "v0.1.10"