Compare commits
34 Commits
v0.1.9
...
de7b58d7b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7b58d7b2 | ||
|
|
68f17e84ed | ||
|
|
f48186bcf6 | ||
|
|
ed8fb70500 | ||
|
|
96b5cd5928 | ||
|
|
006a3ce32e | ||
|
|
a08c9c7800 | ||
|
|
e00513ba52 | ||
|
|
f04657a86c | ||
|
|
7a6d238610 | ||
|
|
ce50e1a8f8 | ||
|
|
32c860fbb9 | ||
|
|
bf725c6b24 | ||
|
|
bb195b1c77 | ||
|
|
e66208b49e | ||
|
|
9e675f8cf4 | ||
|
|
8a883edf07 | ||
|
|
e9fa5f8994 | ||
|
|
1b91110768 | ||
|
|
61604950c3 | ||
|
|
58674d4c26 | ||
|
|
d146e78132 | ||
|
|
3ceae962b1 | ||
|
|
6f051cb167 | ||
|
|
42325742f0 | ||
|
|
8ad5ff0860 | ||
|
|
92407a2ee0 | ||
|
|
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}"
|
||||
|
||||
@@ -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
2
.pylintrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=logging-fstring-interpolation,broad-exception-raised
|
||||
@@ -206,6 +206,12 @@ spotify_client.authenticate()
|
||||
from .registry import MusicProviderRegistry
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
15
app/tasks.py
15
app/tasks.py
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "v0.1.9"
|
||||
__version__ = "v0.1.10"
|
||||
22
config.py
22
config.py
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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