37 Commits
v0.1.9 ... main

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 Kosek
61604950c3 Merge pull request #67 from Ofirfr/debug_spotdl
spotDL exec command debug log
2025-01-21 09:31:55 +01:00
Kamil Kosek
58674d4c26 Merge pull request #66 from Ofirfr/docker_ignore
make docker image slimmer
2025-01-21 09:30:36 +01:00
Ofirfr
d146e78132 upgrade spotdl for better performence 2025-01-16 20:26:11 +02:00
Ofirfr
3ceae962b1 adjust for cookie_file might not be set 2025-01-13 22:02:51 +02:00
Ofirfr
6f051cb167 notify about performence effects of params 2025-01-13 22:02:37 +02:00
Ofirfr
42325742f0 sanitize config 2025-01-13 22:02:14 +02:00
Ofirfr
8ad5ff0860 spotDL exec command debug log 2025-01-11 23:30:27 +02:00
Ofirfr
92407a2ee0 make docker image slimmer 2025-01-11 22:01:21 +02: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
20 changed files with 611 additions and 100 deletions

View File

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

View File

@@ -11,7 +11,12 @@ __pycache__/
.DS_Store
# Ignore Git files
.git
.git*
*cookies*
set_env.sh
jellyplist.code-workspace
# Ignore GitHub page related files
changelogs
readme.md
screenshots

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

View File

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

View File

@@ -1,3 +1,4 @@
from .spotify import SpotifyClient
#from .deezer import DeezerClient
__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
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)

View File

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

View File

@@ -113,8 +113,8 @@ def download_missing_tracks(self):
app.logger.info("Starting track download job...")
with app.app_context():
spotdl_config = app.config['SPOTDL_CONFIG']
cookie_file = spotdl_config['cookie_file']
spotdl_config: dict = app.config['SPOTDL_CONFIG']
cookie_file = spotdl_config.get('cookie_file', None)
output_dir = spotdl_config['output']
client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
@@ -239,7 +239,7 @@ def download_missing_tracks(self):
"--client-id", client_id,
"--client-secret", client_secret
]
if os.path.exists(cookie_file):
if cookie_file and os.path.exists(cookie_file):
app.logger.debug(f"Found {cookie_file}, using it for spotDL")
command.append("--cookie-file")
command.append(cookie_file)
@@ -248,6 +248,7 @@ def download_missing_tracks(self):
command.append("--proxy")
command.append(app.config['SPOTDL_PROXY'])
app.logger.info(f"Executing the spotDL command: {' '.join(command)}")
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
if result.returncode == 0:
track.downloaded = True
@@ -344,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]
@@ -385,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:

View File

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

View File

@@ -1,6 +1,8 @@
import os
import sys
import app
class Config:
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
SECRET_KEY = os.getenv('SECRET_KEY')
@@ -20,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')
@@ -35,19 +37,29 @@ 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 = {
'cookie_file': '/jellyplist/cookies.txt',
# combine the path provided in MUSIC_STORAGE_BASE_PATH with the following path __jellyplist/{track-id} to get the value for output
'threads': 12
}
# combine the path provided in MUSIC_STORAGE_BASE_PATH with the SPOTDL_OUTPUT_FORMAT to get the value for output
if os.getenv('MUSIC_STORAGE_BASE_PATH'):
# Ensure MUSIC_STORAGE_BASE_PATH ends with "__jellyplist"
if not MUSIC_STORAGE_BASE_PATH.endswith("__jellyplist"):
MUSIC_STORAGE_BASE_PATH += "__jellyplist"
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,SPOTDL_OUTPUT_FORMAT)
# Ensure SPOTDL_OUTPUT_FORMAT does not start with "/"
normalized_spotdl_output_format = SPOTDL_OUTPUT_FORMAT.lstrip("/").replace(" ", "_")
# Join the paths
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH, normalized_spotdl_output_format)
SPOTDL_CONFIG.update({'output': output_path})
if SPOTIFY_COOKIE_FILE:
SPOTDL_CONFIG.update({'cookie_file': SPOTIFY_COOKIE_FILE})
@classmethod
def validate_env_vars(cls):
required_vars = {

View File

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

View File

@@ -44,13 +44,13 @@ MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where your musi
# SPOTDL_PROXY = http://proxy:8080
# SPOTDL_OUTPUT_FORMAT = "/{artist}/{artists} - {title}" # Supported variables: {title}, {artist},{artists}, {album}, Will be joined with to get a complete path
# SEARCH_JELLYFIN_BEFORE_DOWNLOAD = false # defaults to true, before attempting to do a download with spotDL , the song will be searched first in the local library
# SEARCH_JELLYFIN_BEFORE_DOWNLOAD = false # defaults to true, before attempting to do a download with spotDL , the song will be searched first in the local library ("true" MAY INCURE PERFORMENCE ISSUES)
# START_DOWNLOAD_AFTER_PLAYLIST_ADD = true # defaults to false, If a new Playlist is added, the Download Task will be scheduled immediately
# FIND_BEST_MATCH_USE_FFPROBE = true # Use ffprobe to gather quality details from a file to calculate quality score. Otherwise jellyplist will use details provided by jellyfin. defaults to false.
#REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = true # jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library. Defaults to false.
#REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = true # jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library. Defaults to false. ("true" MAY INCURE PERFORMENCE ISSUES)
# LOG_LEVEL = DEBUG # Defaults to INFO

View File

@@ -9,7 +9,7 @@ numpy==2.1.3
pyacoustid==1.3.0
redis==5.1.1
Requests==2.32.3
spotdl==4.2.10
spotdl==4.2.11
spotipy==2.24.0
SQLAlchemy==2.0.35
Unidecode==1.3.8
@@ -18,3 +18,8 @@ eventlet
pydub
fuzzywuzzy
pyyaml
click
pycryptodomex
mutagen
requests
deezer-py

View File

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

View File

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

View File

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

View File

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

View File

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