88 Commits

Author SHA1 Message Date
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
Kamil Kosek
69c5f7093e Merge dev 0.1.9 into main
Merge dev into main
2024-12-14 00:13:41 +01:00
Kamil
34ae3c5680 update v0.1.9 changelog 2024-12-13 22:58:01 +00:00
Kamil
56d937a21f fix: improve logging for Jellyfin ID updates and error handling 2024-12-13 22:40:56 +00:00
Kamil
3a78e710ae chore: add settings.yaml to .gitignore 2024-12-13 14:39:56 +00:00
Kamil
1b95f201be feat: add YAML settings management and admin settings page, add default_playlist_users 2024-12-13 08:27:55 +00:00
Kamil
b7de39e501 chore: update requirements.txts add pyyaml 2024-12-13 08:27:11 +00:00
Kamil
9c46be1701 fix: include commit SHA in tag name for GitHub release workflow 2024-12-12 12:23:50 +00:00
Kamil
9de58731c0 chore: update version format to include 'v' prefix and adjust related configurations 2024-12-12 12:14:41 +00:00
Kamil
969eca4a04 fix: remove backdrop from select user dialog 2024-12-12 12:05:06 +00:00
Kamil
9667b71d24 feat: update page title to include 'Jellyplist' prefix 2024-12-12 12:04:37 +00:00
Kamil
4d9e6162fc feat: add quality_score field to Track model and update related functionality
Fixes #51
2024-12-11 20:33:13 +00:00
Kamil
b877ee04e3 fix: increase maximum length of track name to 200 characters 2024-12-11 19:36:26 +00:00
Kamil
d6a702b606 feat: initialize additional_users variable in add_playlist function 2024-12-11 19:33:41 +00:00
Kamil
d615bafd1f feat: add admin functionality to dynamically load and manage users for playlist addition 2024-12-11 14:15:40 +00:00
Kamil
a44c5b5209 feat: refactor add_jellyfin_user_to_playlist to use internal method for user assignment 2024-12-11 14:15:28 +00:00
Kamil
67d2b3cb9e feat: enhance add_playlist function to support JSON input and manage additional users for playlists 2024-12-11 14:15:15 +00:00
Kamil
6248c54829 fix: update modal dialog class to improve responsiveness on larger screens 2024-12-11 12:54:47 +00:00
Kamil
2da69fc330 feat: add workaround method to remove user from playlist and enhance get_users method with optional user_id 2024-12-11 12:54:28 +00:00
Kamil
0c57912053 feat: implement Jellyfin user management routes for playlists 2024-12-11 12:54:18 +00:00
Kamil
423ffbb608 fix: clean up user management modal script and improve badge styling 2024-12-11 12:54:04 +00:00
Kamil
d9302434c2 feat: add user management functionality for playlists with dynamic loading and confirmation prompts 2024-12-11 08:52:31 +00:00
Kamil
debe273cfb fix: remove unnecessary .mp3 extension checks in download_missing_tracks method 2024-12-11 00:02:14 +00:00
Kamil
f9e8be1824 fix: update playlist link text to "My Playlists" and simplify admin access condition 2024-12-10 22:25:08 +00:00
Kamil
cdf7d8ffe9 Revert "fix: update playlist link text and simplify admin access condition"
This reverts commit 41c62a5376.
2024-12-10 22:24:17 +00:00
Kamil
41c62a5376 fix: update playlist link text and simplify admin access condition 2024-12-10 22:15:28 +00:00
Kamil
4f06f81e93 fix: update unlock_key route to use task_manager for releasing locks 2024-12-10 22:10:46 +00:00
Kamil
754f7f9204 feat: add get_users method to fetch users from Jellyfin API 2024-12-10 22:10:10 +00:00
Kamil
01cc78eb93 fix: improve log rendering by removing unnecessary replacements and ensure safe HTML output 2024-12-10 21:48:18 +00:00
Kamil
79c9554ce2 fix: enhance error handling and logging in tasks 2024-12-10 20:56:01 +00:00
Kamil
500a049976 fix: update supervisord configuration to use stdout and stderr for logging 2024-12-10 20:55:38 +00:00
Kamil
477c869107 fix: correct typo in output directory variable name in download_missing_tracks function 2024-12-10 20:30:55 +00:00
Kamil Kosek
731d2db083 Update manual-build.yml 2024-12-10 17:31:52 +01:00
Kamil Kosek
3862730203 Update manual-build.yml 2024-12-10 17:27:17 +01:00
Kamil
11bd25e5be Bump version: 0.1.8 → 0.1.9 2024-12-10 15:54:28 +00:00
Kamil
c78ceef508 add changelog reading step to manual build workflow for release notes 2024-12-10 15:51:06 +00:00
Kamil
aa201c3be2 add new environment variables for spotDL configuration and output format control 2024-12-10 15:47:38 +00:00
Kamil
6f3f5b9623 implement caching for provider playlists to optimize API calls and improve performance 2024-12-10 15:44:03 +00:00
Kamil
4106524710 add caching for fetching provider playlists to reduce API calls and increase loading speed in UI 2024-12-10 15:41:44 +00:00
Kamil
be37d4cffe update log format to increase function name width in logging output 2024-12-10 15:41:04 +00:00
Kamil
4deb7387aa enhance track download process with dynamic file path generation and output format configuration. 2024-12-10 14:57:42 +00:00
Kamil
6129bee98c pinned postgres to 17.2 2024-12-10 12:27:18 +00:00
Kamil
23d121e58f add proxy support for SpotDL in download process
Fixes #35
2024-12-10 12:23:16 +00:00
Kamil
7676189625 add log viewer features and set log level functionality in admin panel
add "Get Logs for a new Release", which will create preformatted markdown text you can paste directly to the issue
2024-12-10 11:44:01 +00:00
Kamil
798c4ae28d update readme.md to define IMAGE variable and adjust volume mappings for jellyplist services and prepare compose sample for 0.1.9 release 2024-12-09 20:38:38 +00:00
Kamil
eeb6ad9172 update .dockerignore to ignore all cookie files 2024-12-09 20:37:50 +00:00
Kamil
92e8963727 add rotating file handler for logging based on worker type 2024-12-09 10:25:44 +00:00
Kamil
d54100cbc4 add supervisor support to Dockerfile and create supervisord configuration 2024-12-09 10:20:04 +00:00
Kamil
e559b1cf11 return task info on manual start
Fixes #38
Fixes #22
2024-12-09 10:16:58 +00:00
Kamil
631b2a35f7 Merge branch 'dev' of https://github.com/kamilkosek/jellyplist into dev 2024-12-09 09:52:31 +00:00
Kamil Kosek
ad5957b539 Merge pull request #37 from artyorsh/patch-1
case-insensitive image formats
2024-12-09 10:51:56 +01:00
Artur Y
181eff22ef add image/webp to the list of supported cover image types 2024-12-07 13:27:45 +01:00
Artur Y
a20f1733f1 case-insensitive image formats
Sometimes the image format comes as `image/JPEG`, which results in an `Unsupported image format: image/JPEG`
2024-12-06 15:33:22 +01:00
Kamil Kosek
0f4d599308 Merge pull request #34 from kamilkosek/main
merge back
2024-12-06 11:56:18 +01:00
Kamil
9acf3bde84 Fixed missing lock keys to task manager and task status rendering 2024-12-06 08:23:00 +00:00
34 changed files with 1582 additions and 180 deletions

View File

@@ -1,12 +1,12 @@
[bumpversion] [bumpversion]
current_version = 0.1.8 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}"

View File

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

View File

@@ -32,9 +32,21 @@ jobs:
- name: Extract Version - name: Extract Version
id: extract_version id: extract_version
run: | run: |
version=$(python3 -c "import version; print(f'${{ env.BRANCH_NAME}}-{version.__version__}')") version=$(python3 -c "import version; print(f'{version.__version__}')")
echo "VERSION=$version" >> $GITHUB_ENV echo "VERSION=$version" >> $GITHUB_ENV
- name: Read Changelog
id: changelog
run: |
if [ -f changelogs/${{ env.VERSION }}.md ]; then
changelog_content=$(cat changelogs/${{ env.VERSION }}.md)
echo "CHANGELOG_CONTENT<<EOF" >> $GITHUB_ENV
echo "$changelog_content" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV
fi
# Set up Docker # Set up Docker
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
@@ -56,13 +68,17 @@ jobs:
push: true push: true
tags: | tags: |
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }} ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
ghcr.io/${{ github.repository }}:dev ghcr.io/${{ github.repository }}:${{ env.BRANCH_NAME }}
ghcr.io/${{ github.repository }}:${{ env.VERSION }} ghcr.io/${{ github.repository }}:${{ env.VERSION }}-${{ env.BRANCH_NAME}}
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.VERSION }} tag_name: |
name: Dev Release ${{ env.VERSION }} ${{ env.VERSION }}-${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}
name: |
${{ env.BRANCH_NAME }} Release ${{ env.VERSION }}
body: |
${{ env.CHANGELOG_CONTENT }}
generate_release_notes: true generate_release_notes: true
make_latest: false make_latest: false

1
.gitignore vendored
View File

@@ -79,3 +79,4 @@ set_env.sh
notes.md notes.md
DEV_BUILD DEV_BUILD
payload.json payload.json
settings.yaml

2
.pylintrc Normal file
View File

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

View File

@@ -8,7 +8,7 @@ WORKDIR /jellyplist
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN apt update RUN apt update
RUN apt install ffmpeg netcat-openbsd -y RUN apt install ffmpeg netcat-openbsd supervisor -y
# Copy the application code # Copy the application code
COPY . . COPY . .
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
@@ -16,6 +16,7 @@ RUN chmod +x /entrypoint.sh
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 5055 EXPOSE 5055
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Set the entrypoint # Set the entrypoint
@@ -23,4 +24,4 @@ ENTRYPOINT ["/entrypoint.sh"]
# Run the application # Run the application
CMD ["python", "run.py"] CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,6 +1,8 @@
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
import os import os
import threading
import time import time
import yaml
from flask_socketio import SocketIO from flask_socketio import SocketIO
import sys import sys
@@ -97,15 +99,38 @@ app = Flask(__name__, template_folder="../templates", static_folder='../static')
app.config.from_object(Config) app.config.from_object(Config)
app.config['runtime_settings'] = {}
yaml_file = 'settings.yaml'
def load_yaml_settings():
with open(yaml_file, 'r') as f:
app.config['runtime_settings'] = yaml.safe_load(f)
def save_yaml_settings():
with open(yaml_file, 'w') as f:
yaml.dump(app.config['runtime_settings'], f)
for handler in app.logger.handlers: for handler in app.logger.handlers:
app.logger.removeHandler(handler) app.logger.removeHandler(handler)
log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) # Default to DEBUG if invalid log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) # Default to DEBUG if invalid
app.logger.setLevel(log_level) app.logger.setLevel(log_level)
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(levelname)7s - %(message)s" FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)36s() ] %(levelname)7s - %(message)s"
logging.basicConfig(format=FORMAT) logging.basicConfig(format=FORMAT)
# Add RotatingFileHandler to log to a file
# if worker is in sys.argv, we are running a celery worker, so we log to a different file
if 'worker' in sys.argv:
log_file = os.path.join("/var/log/", 'jellyplist_worker.log')
elif 'beat' in sys.argv:
log_file = os.path.join("/var/log/", 'jellyplist_beat.log')
else:
log_file = os.path.join("/var/log/", 'jellyplist.log')
file_handler = RotatingFileHandler(log_file, maxBytes=2 * 1024 * 1024, backupCount=10)
file_handler.setFormatter(logging.Formatter(FORMAT))
app.logger.addHandler(file_handler)
Config.validate_env_vars() Config.validate_env_vars()
cache = Cache(app) cache = Cache(app)
redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True) redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True)
@@ -181,7 +206,38 @@ spotify_client.authenticate()
from .registry import MusicProviderRegistry from .registry import MusicProviderRegistry
MusicProviderRegistry.register_provider(spotify_client) MusicProviderRegistry.register_provider(spotify_client)
if app.config['ENABLE_DEEZER']:
from .providers import DeezerClient
deezer_client = DeezerClient()
deezer_client.authenticate()
MusicProviderRegistry.register_provider(deezer_client)
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']: if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}') app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}')
from lidarr.client import LidarrClient from lidarr.client import LidarrClient
lidarr_client = LidarrClient(app.config['LIDARR_URL'], app.config['LIDARR_API_KEY']) lidarr_client = LidarrClient(app.config['LIDARR_URL'], app.config['LIDARR_API_KEY'])
if os.path.exists(yaml_file):
app.logger.info('Loading runtime settings from settings.yaml')
load_yaml_settings()
# def watch_yaml_file(yaml_file, interval=30):
# last_mtime = os.path.getmtime(yaml_file)
# while True:
# time.sleep(interval)
# current_mtime = os.path.getmtime(yaml_file)
# if current_mtime != last_mtime:
# last_mtime = current_mtime
# yaml_settings = load_yaml_settings(yaml_file)
# app.config.update(yaml_settings)
# app.logger.info(f"Reloaded YAML settings from {yaml_file}")
# watcher_thread = threading.Thread(
# target=watch_yaml_file,
# args=('settings.yaml',),
# daemon=True
# )
# watcher_thread.start()

View File

@@ -97,3 +97,21 @@ def jellyfin_link(jellyfin_id: str) -> Markup:
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}" link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>') return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
@template_filter('jellyfin_link_button')
def jellyfin_link_btn(jellyfin_id: str) -> Markup:
jellyfin_server_url = app.config.get('JELLYFIN_SERVER_URL')
if not jellyfin_server_url:
return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
return Markup(f'<a href="{link}" class="btn btn-primary mt-2" target="_blank">Open in Jellyfin</a>')
# A template filter for displaying a datetime in a human-readable format
@template_filter('human_datetime')
def human_datetime(dt) -> str:
if not dt:
return 'No date provided'
return dt.strftime('%Y-%m-%d %H:%M:%S')

View File

@@ -112,6 +112,22 @@ def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:
app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}") app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}")
return None return None
@cache.memoize(timeout=3600)
def get_cached_provider_playlist(playlist_id : str,provider_id : str)-> base.Playlist:
"""
Fetches a playlist by its ID, utilizing caching to minimize API calls.
:param playlist_id: The playlist ID.
:return: Playlist data as a dictionary, or None if an error occurs.
"""
try:
# get the provider from the registry
provider = MusicProviderRegistry.get_provider(provider_id)
playlist_data = provider.get_playlist(playlist_id)
return playlist_data
except Exception as e:
app.logger.error(f"Error fetching playlist {playlist_id} from {provider_id}: {str(e)}")
return None
def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]: def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
is_admin = session.get('is_admin', False) is_admin = session.get('is_admin', False)
@@ -245,3 +261,7 @@ def get_latest_release(tag_name :str):
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
app.logger.error(f"Error fetching latest version: {str(e)}") app.logger.error(f"Error fetching latest version: {str(e)}")
return False,'' return False,''
def set_log_level(level):
app.logger.setLevel(level)
app.logger.info(f"Log level set to {level}")

View File

@@ -50,7 +50,7 @@ playlist_tracks = db.Table('playlist_tracks',
class Track(db.Model): class Track(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False) name = db.Column(db.String(200), nullable=False)
provider_track_id = db.Column(db.String(120), unique=True, nullable=False) provider_track_id = db.Column(db.String(120), unique=True, nullable=False)
provider_uri = db.Column(db.String(120), unique=True, nullable=False) provider_uri = db.Column(db.String(120), unique=True, nullable=False)
downloaded = db.Column(db.Boolean()) downloaded = db.Column(db.Boolean())
@@ -59,8 +59,11 @@ class Track(db.Model):
download_status = db.Column(db.String(2048), nullable=True) download_status = db.Column(db.String(2048), nullable=True)
provider_id = db.Column(db.String(20)) provider_id = db.Column(db.String(20))
# Many-to-Many relationship with Playlists # Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
lidarr_processed = db.Column(db.Boolean(), default=False) lidarr_processed = db.Column(db.Boolean(), default=False)
quality_score = db.Column(db.Float(), default=0)
def __repr__(self): def __repr__(self):
return f'<Track {self.name}:{self.provider_track_id}>' return f'<Track {self.name}:{self.provider_track_id}>'

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

@@ -9,7 +9,7 @@ from app.tasks import task_manager
from app.registry.music_provider_registry import MusicProviderRegistry from app.registry.music_provider_registry import MusicProviderRegistry
from jellyfin.objects import PlaylistMetadata from jellyfin.objects import PlaylistMetadata
from app.routes import pl_bp from app.routes import pl_bp, routes
@app.route('/jellyfin_playlists') @app.route('/jellyfin_playlists')
@functions.jellyfin_login_required @functions.jellyfin_login_required
@@ -34,7 +34,10 @@ def jellyfin_playlists():
combined_playlists = [] combined_playlists = []
for pl in playlists: for pl in playlists:
provider_playlist = provider_client.get_playlist(pl.provider_playlist_id) # Use the cached provider_playlist_id to fetch the playlist from the provider
provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id)
#provider_playlist = provider_client.get_playlist(pl.provider_playlist_id)
# 4. Convert the playlists to CombinedPlaylistData # 4. Convert the playlists to CombinedPlaylistData
combined_data = functions.prepPlaylistData(provider_playlist) combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data: if combined_data:
@@ -50,6 +53,13 @@ def jellyfin_playlists():
def add_playlist(): def add_playlist():
playlist_id = request.form.get('item_id') playlist_id = request.form.get('item_id')
playlist_name = request.form.get('item_name') playlist_name = request.form.get('item_name')
additional_users = None
if not playlist_id and request.data:
# get data convert from json to dict
data = request.get_json()
playlist_id = data.get('item_id')
playlist_name = data.get('item_name')
additional_users = data.get('additional_users')
# also get the provider id from the query params # also get the provider id from the query params
provider_id = request.args.get('provider') provider_id = request.args.get('provider')
if not playlist_id: if not playlist_id:
@@ -119,6 +129,13 @@ def add_playlist():
"can_remove":True, "can_remove":True,
"jellyfin_id" : playlist.jellyfin_id "jellyfin_id" : playlist.jellyfin_id
} }
if additional_users and session['is_admin']:
db.session.commit()
app.logger.debug(f"Additional users: {additional_users}")
for user_id in additional_users:
routes.add_jellyfin_user_to_playlist_internal(user_id,playlist.jellyfin_id)
return render_template('partials/_add_remove_button.html',item= item) return render_template('partials/_add_remove_button.html',item= item)
@@ -153,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

@@ -3,7 +3,7 @@ import json
import os import os
import re import re
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g
from app import app, db, functions, jellyfin, read_dev_build_file, tasks from app import app, db, functions, jellyfin, read_dev_build_file, tasks, save_yaml_settings
from app.classes import AudioProfile, CombinedPlaylistData from app.classes import AudioProfile, CombinedPlaylistData
from app.models import JellyfinUser,Playlist,Track from app.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult from celery.result import AsyncResult
@@ -68,13 +68,13 @@ def save_lidarr_config():
@functions.jellyfin_admin_required @functions.jellyfin_admin_required
def task_manager(): def task_manager():
statuses = {} statuses = {}
lock_keys = []
for task_name, task_id in tasks.task_manager.tasks.items(): for task_name, task_id in tasks.task_manager.tasks.items():
statuses[task_name] = tasks.task_manager.get_task_status(task_name) statuses[task_name] = tasks.task_manager.get_task_status(task_name)
lock_keys.append(f"{task_name}_lock")
lock_keys.append('full_update_jellyfin_ids_lock')
return render_template('admin/tasks.html', tasks=statuses,lock_keys = lock_keys)
return render_template('admin/tasks.html', tasks=statuses)
@app.route('/admin')
@app.route('/admin/link_issues') @app.route('/admin/link_issues')
@functions.jellyfin_admin_required @functions.jellyfin_admin_required
def link_issues(): def link_issues():
@@ -107,6 +107,81 @@ def link_issues():
return render_template('admin/link_issues.html' , tracks = tracks ) return render_template('admin/link_issues.html' , tracks = tracks )
@app.route('/admin/logs')
@functions.jellyfin_admin_required
def view_logs():
# parse the query parameter
log_name = request.args.get('name')
logs = []
if log_name == 'logs' or not log_name and os.path.exists('/var/log/jellyplist.log'):
with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f:
logs = f.readlines()
if log_name == 'worker' and os.path.exists('/var/log/jellyplist_worker.log'):
with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f:
logs = f.readlines()
if log_name == 'beat' and os.path.exists('/var/log/jellyplist_beat.log'):
with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f:
logs = f.readlines()
return render_template('admin/logview.html', logs=str.join('',logs),name=log_name)
@app.route('/admin/setloglevel', methods=['POST'])
@functions.jellyfin_admin_required
def set_log_level():
loglevel = request.form.get('logLevel')
if loglevel:
if loglevel in ['DEBUG','INFO','WARNING','ERROR','CRITICAL']:
functions.set_log_level(loglevel)
flash(f'Log level set to {loglevel}', category='success')
return redirect(url_for('view_logs'))
@app.route('/admin/logs/getLogsForIssue')
@functions.jellyfin_admin_required
def get_logs_for_issue():
# get the last 200 lines of all log files
last_lines = -300
logs = []
logs += f'## Logs and Details for Issue ##\n'
logs += f'Version: *{__version__}{read_dev_build_file()}*\n'
if os.path.exists('/var/log/jellyplist.log'):
with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f:
logs += f'### jellyfin.log\n'
logs += f'```log\n'
logs += f.readlines()[last_lines:]
logs += f'```\n'
if os.path.exists('/var/log/jellyplist_worker.log'):
with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f:
logs += f'### jellyfin_worker.log\n'
logs += f'```log\n'
logs += f.readlines()[last_lines:]
logs += f'```\n'
if os.path.exists('/var/log/jellyplist_beat.log'):
with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f:
logs += f'### jellyplist_beat.log\n'
logs += f'```log\n'
logs += f.readlines()[last_lines:]
logs += f'```\n'
# in the logs array, anonymize IP addresses
logs = [re.sub(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', 'xxx.xxx.xxx.xxx', log) for log in logs]
return jsonify({'logs': logs})
@app.route('/admin')
@app.route('/admin/settings')
@app.route('/admin/settings/save' , methods=['POST'])
@functions.jellyfin_admin_required
def admin_settings():
# if the request is a POST request, save the settings
if request.method == 'POST':
# from the form, get all values from default_playlist_users and join them to one array of strings
app.config['runtime_settings']['default_playlist_users'] = request.form.getlist('default_playlist_users')
save_yaml_settings()
flash('Settings saved', category='success')
return redirect('/admin/settings')
return render_template('admin/settings.html',jellyfin_users = jellyfin.get_users(session_token=functions._get_api_token()))
@app.route('/run_task/<task_name>', methods=['POST']) @app.route('/run_task/<task_name>', methods=['POST'])
@@ -116,6 +191,7 @@ def run_task(task_name):
# Rendere nur die aktualisierte Zeile der Task # Rendere nur die aktualisierte Zeile der Task
task_info = {task_name: {'state': status, 'info': info}} task_info = {task_name: {'state': status, 'info': info}}
return render_template('partials/_task_status.html', tasks=task_info) return render_template('partials/_task_status.html', tasks=task_info)
@@ -123,12 +199,15 @@ def run_task(task_name):
@functions.jellyfin_admin_required @functions.jellyfin_admin_required
def task_status(): def task_status():
statuses = {} statuses = {}
lock_keys = []
for task_name, task_id in tasks.task_manager.tasks.items(): for task_name, task_id in tasks.task_manager.tasks.items():
statuses[task_name] = tasks.task_manager.get_task_status(task_name) statuses[task_name] = tasks.task_manager.get_task_status(task_name)
lock_keys.append(f"{task_name}_lock")
lock_keys.append('full_update_jellyfin_ids_lock')
# Render the HTML partial template instead of returning JSON # Render the HTML partial template instead of returning JSON
return render_template('partials/_task_status.html', tasks=statuses) return render_template('partials/_task_status.html', tasks=statuses, lock_keys = lock_keys)
@@ -180,7 +259,7 @@ def openPlaylist():
try: try:
provider_client = MusicProviderRegistry.get_provider(provider_id) provider_client = MusicProviderRegistry.get_provider(provider_id)
extracted_playlist_id = provider_client.extract_playlist_id(playlist) extracted_playlist_id = provider_client.extract_playlist_id(playlist)
provider_playlist = provider_client.get_playlist(extracted_playlist_id) provider_playlist = functions.get_cached_provider_playlist(extracted_playlist_id, provider_id)
combined_data = functions.prepPlaylistData(provider_playlist) combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data: if combined_data:
@@ -217,8 +296,8 @@ def browse_page(page_id):
@functions.jellyfin_login_required @functions.jellyfin_login_required
def monitored_playlists(): def monitored_playlists():
# 1. Get all Playlists from the Database. # 1. Get all Playlists from the Database and order them by Id
all_playlists = Playlist.query.all() all_playlists = Playlist.query.order_by(Playlist.id).all()
# 2. Group them by provider # 2. Group them by provider
playlists_by_provider = defaultdict(list) playlists_by_provider = defaultdict(list)
@@ -236,7 +315,7 @@ def monitored_playlists():
combined_playlists = [] combined_playlists = []
for pl in playlists: for pl in playlists:
provider_playlist = provider_client.get_playlist(pl.provider_playlist_id) provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id)
# 4. Convert the playlists to CombinedPlaylistData # 4. Convert the playlists to CombinedPlaylistData
combined_data = functions.prepPlaylistData(provider_playlist) combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data: if combined_data:
@@ -380,13 +459,95 @@ def associate_track():
@app.route("/unlock_key",methods = ['POST']) @app.route("/unlock_key",methods = ['POST'])
@functions.jellyfin_admin_required @functions.jellyfin_admin_required
def unlock_key(): def unlock_key():
key_name = request.form.get('inputLockKey') key_name = request.form.get('inputLockKey')
if key_name: if key_name:
tasks.release_lock(key_name) tasks.task_manager.release_lock(key_name)
flash(f'Lock {key_name} released', category='success') flash(f'Lock {key_name} released', category='success')
return '' return ''
@app.route("/admin/getJellyfinUsers",methods = ['GET'])
@functions.jellyfin_admin_required
def get_jellyfin_users():
users = jellyfin.get_users(session_token=functions._get_api_token())
return jsonify({'users': users})
@app.route("/admin/getJellyfinPlaylistUsers",methods = ['GET'])
@functions.jellyfin_admin_required
def get_jellyfin_playlist_users():
playlist_id = request.args.get('playlist')
if not playlist_id:
return jsonify({'error': 'Playlist not specified'}), 400
users = jellyfin.get_playlist_users(session_token=functions._get_api_token(), playlist_id=playlist_id)
all_users = jellyfin.get_users(session_token=functions._get_api_token())
# extend users with the username from all_users
for user in users:
user['Name'] = next((u['Name'] for u in all_users if u['Id'] == user['UserId']), None)
# from all_users remove the users that are already in the playlist
all_users = [u for u in all_users if u['Id'] not in [user['UserId'] for user in users]]
return jsonify({'assigned_users': users, 'remaining_users': all_users})
@app.route("/admin/removeJellyfinUserFromPlaylist", methods= ['GET'])
@functions.jellyfin_admin_required
def remove_jellyfin_user_from_playlist():
playlist_id = request.args.get('playlist')
user_id = request.args.get('user')
if not playlist_id or not user_id:
return jsonify({'error': 'Playlist or User not specified'}), 400
# remove this playlist also from the user in the database
# get the playlist from the db
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first()
if not user:
# Add the user to the database if they don't exist
jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id)
user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator'])
db.session.add(user)
db.session.commit()
if not playlist or not user:
return jsonify({'error': 'Playlist or User not found'}), 400
if playlist in user.playlists:
user.playlists.remove(playlist)
db.session.commit()
jellyfin.remove_user_from_playlist2(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=user_id, admin_user_id=functions._get_admin_id())
return jsonify({'success': True})
@app.route('/admin/addJellyfinUserToPlaylist')
@functions.jellyfin_admin_required
def add_jellyfin_user_to_playlist():
playlist_id = request.args.get('playlist')
user_id = request.args.get('user')
return add_jellyfin_user_to_playlist_internal(user_id, playlist_id)
def add_jellyfin_user_to_playlist_internal(user_id, playlist_id):
# assign this playlist also to the user in the database
# get the playlist from the db
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first()
if not user:
# Add the user to the database if they don't exist
jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id)
user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator'])
db.session.add(user)
db.session.commit()
if not playlist or not user:
return jsonify({'error': 'Playlist or User not found'}), 400
if playlist not in user.playlists:
user.playlists.append(playlist)
db.session.commit()
if not playlist_id or not user_id:
return jsonify({'error': 'Playlist or User not specified'}), 400
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=functions._get_admin_id(), user_ids=[user_id])
return jsonify({'success': True})
@pl_bp.route('/test') @pl_bp.route('/test')
def test(): def test():

View File

@@ -21,7 +21,7 @@ from lidarr.classes import Artist
@signals.celeryd_init.connect @signals.celeryd_init.connect
def setup_log_format(sender, conf, **kwargs): def setup_log_format(sender, conf, **kwargs):
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s" FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)42s() ] %(levelname)7s - %(message)s"
conf.worker_log_format = FORMAT.strip().format(sender) conf.worker_log_format = FORMAT.strip().format(sender)
conf.worker_task_log_format = FORMAT.format(sender) conf.worker_task_log_format = FORMAT.format(sender)
@@ -94,6 +94,9 @@ def update_all_playlists_track_status(self):
app.logger.info("All playlists' track statuses updated.") app.logger.info("All playlists' track statuses updated.")
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists} return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally: finally:
task_manager.release_lock(lock_key) task_manager.release_lock(lock_key)
else: else:
@@ -110,8 +113,8 @@ def download_missing_tracks(self):
app.logger.info("Starting track download job...") app.logger.info("Starting track download job...")
with app.app_context(): with app.app_context():
spotdl_config = app.config['SPOTDL_CONFIG'] spotdl_config: dict = app.config['SPOTDL_CONFIG']
cookie_file = spotdl_config['cookie_file'] cookie_file = spotdl_config.get('cookie_file', None)
output_dir = spotdl_config['output'] output_dir = spotdl_config['output']
client_id = app.config['SPOTIFY_CLIENT_ID'] client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET'] client_secret = app.config['SPOTIFY_CLIENT_SECRET']
@@ -125,6 +128,7 @@ def download_missing_tracks(self):
return {'status': 'No undownloaded tracks found'} return {'status': 'No undownloaded tracks found'}
app.logger.info(f"Found {total_tracks} tracks to download.") app.logger.info(f"Found {total_tracks} tracks to download.")
app.logger.debug(f"output_dir: {output_dir}")
processed_tracks = 0 processed_tracks = 0
failed_downloads = 0 failed_downloads = 0
for track in undownloaded_tracks: for track in undownloaded_tracks:
@@ -136,11 +140,39 @@ def download_missing_tracks(self):
'failed': failed_downloads 'failed': failed_downloads
}) })
# Check if the track already exists in the output directory # Check if the track already exists in the output directory
file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}.mp3" if os.getenv('SPOTDL_OUTPUT_FORMAT') == '__jellyplist/{track-id}':
file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}"
else:
# if the output format is other than the default, we need to fetch the track first!
spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify")
# spotify_track has name, artists, album and id
# name needs to be mapped to {title}
# artist[0] needs to be mapped to {artist}
# artists needs to be mapped to {artists}
# album needs to be mapped to {album} , but needs to be checked if it is set or not, because it is Optional
# id needs to be mapped to {track-id}
# the output format is then used to create the file path
if spotify_track:
file_path = output_dir.replace("{title}",spotify_track.name)
file_path = file_path.replace("{artist}",spotify_track.artists[0].name)
file_path = file_path.replace("{artists}",",".join([artist.name for artist in spotify_track.artists]))
file_path = file_path.replace("{album}",spotify_track.album.name if spotify_track.album else "")
file_path = file_path.replace("{track-id}",spotify_track.id)
app.logger.debug(f"File path: {file_path}")
if not file_path:
app.logger.error(f"Error creating file path for track {track.name}.")
failed_downloads += 1
track.download_status = "Error creating file path"
db.session.commit()
continue
# region search before download # region search before download
if search_before_download: if search_before_download:
app.logger.info(f"Searching for track in Jellyfin: {track.name}") app.logger.info(f"Searching for track in Jellyfin: {track.name}")
spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify")
# at first try to find the track without fingerprinting it # at first try to find the track without fingerprinting it
best_match = find_best_match_from_jellyfin(track) best_match = find_best_match_from_jellyfin(track)
if best_match: if best_match:
@@ -186,13 +218,13 @@ def download_missing_tracks(self):
#endregion #endregion
#endregion #endregion
if file_path:
if os.path.exists(file_path): if os.path.exists(file_path):
app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.") app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.")
track.downloaded = True track.downloaded = True
track.filesystem_path = file_path track.filesystem_path = file_path
db.session.commit() db.session.commit()
continue continue
@@ -207,16 +239,22 @@ def download_missing_tracks(self):
"--client-id", client_id, "--client-id", client_id,
"--client-secret", client_secret "--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") app.logger.debug(f"Found {cookie_file}, using it for spotDL")
command.append("--cookie-file") command.append("--cookie-file")
command.append(cookie_file) command.append(cookie_file)
if app.config['SPOTDL_PROXY']:
app.logger.debug(f"Using proxy: {app.config['SPOTDL_PROXY']}")
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) result = subprocess.run(command, capture_output=True, text=True, timeout=90)
if result.returncode == 0 and os.path.exists(file_path): if result.returncode == 0:
track.downloaded = True track.downloaded = True
track.filesystem_path = file_path if file_path:
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.") track.filesystem_path = file_path
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
else: else:
app.logger.error(f"Download failed for track {track.name}.") app.logger.error(f"Download failed for track {track.name}.")
if result.stdout: if result.stdout:
@@ -248,6 +286,9 @@ def download_missing_tracks(self):
'processed': processed_tracks, 'processed': processed_tracks,
'failed': failed_downloads 'failed': failed_downloads
} }
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally: finally:
task_manager.release_lock(lock_key) task_manager.release_lock(lock_key)
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']: if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
@@ -304,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]
@@ -345,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:
@@ -360,6 +409,9 @@ def check_for_playlist_updates(self):
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.") app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists} return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally: finally:
task_manager.release_lock(lock_key) task_manager.release_lock(lock_key)
else: else:
@@ -375,11 +427,18 @@ def update_jellyfin_id_for_downloaded_tracks(self):
app.logger.info("Starting Jellyfin ID update for tracks...") app.logger.info("Starting Jellyfin ID update for tracks...")
with app.app_context(): with app.app_context():
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
downloaded_tracks = Track.query.filter(
Track.downloaded == True,
Track.jellyfin_id == None,
(Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None)
).all()
if task_manager.acquire_lock(full_update_key, expiration=60*60*24): if task_manager.acquire_lock(full_update_key, expiration=60*60*24):
app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)") app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)")
downloaded_tracks = Track.query.all() app.logger.info(f"\tQUALITY_SCORE_THRESHOLD = {app.config['QUALITY_SCORE_THRESHOLD']}")
downloaded_tracks = Track.query.filter(
(Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None)
).all()
else: else:
app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None") app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None")
total_tracks = len(downloaded_tracks) total_tracks = len(downloaded_tracks)
@@ -393,6 +452,7 @@ def update_jellyfin_id_for_downloaded_tracks(self):
for track in downloaded_tracks: for track in downloaded_tracks:
try: try:
best_match = find_best_match_from_jellyfin(track) best_match = find_best_match_from_jellyfin(track)
if best_match: if best_match:
track.downloaded = True track.downloaded = True
if track.jellyfin_id != best_match['Id']: if track.jellyfin_id != best_match['Id']:
@@ -402,10 +462,11 @@ def update_jellyfin_id_for_downloaded_tracks(self):
track.filesystem_path = best_match['Path'] track.filesystem_path = best_match['Path']
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})") app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})")
track.quality_score = best_match['quality_score']
db.session.commit() db.session.commit()
else: else:
app.logger.warning(f"No matching track found in Jellyfin for {track.name}.") app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
spotify_track = None spotify_track = None
@@ -415,11 +476,14 @@ def update_jellyfin_id_for_downloaded_tracks(self):
processed_tracks += 1 processed_tracks += 1
progress = (processed_tracks / total_tracks) * 100 progress = (processed_tracks / total_tracks) * 100
self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress}) self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
app.logger.info("Finished updating Jellyfin IDs for all tracks.") app.logger.info("Finished updating Jellyfin IDs for all tracks.")
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks} return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
except Exception as e:
app.logger.error(f"Error updating jellyfin ids: {str(e)}", exc_info=True)
return {'status': 'Error updating jellyfin ids '}
finally: finally:
task_manager.release_lock(lock_key) task_manager.release_lock(lock_key)
else: else:
@@ -502,6 +566,9 @@ def request_lidarr(self):
app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}') app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}')
return {'status': 'Request sent to Lidarr'} return {'status': 'Request sent to Lidarr'}
except Exception as e:
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
return {'status': 'Error downloading tracks'}
finally: finally:
task_manager.release_lock(lock_key) task_manager.release_lock(lock_key)
@@ -553,6 +620,7 @@ def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]") app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
best_match = result best_match = result
best_quality_score = quality_score
break break
@@ -563,7 +631,9 @@ def find_best_match_from_jellyfin(track: Track):
if quality_score > best_quality_score: if quality_score > best_quality_score:
best_match = result best_match = result
best_quality_score = quality_score best_quality_score = quality_score
# attach the quality_score to the best_match
if best_match:
best_match['quality_score'] = best_quality_score
return best_match return best_match
except Exception as e: except Exception as e:
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}") app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
@@ -587,8 +657,8 @@ def compute_quality_score(result, use_ffprobe=False) -> float:
if result.get('HasLyrics'): if result.get('HasLyrics'):
score += 10 score += 10
runtime_ticks = result.get('RunTimeTicks', 0) #runtime_ticks = result.get('RunTimeTicks', 0)
score += runtime_ticks / 1e6 #score += runtime_ticks / 1e6
if use_ffprobe: if use_ffprobe:
path = result.get('Path') path = result.get('Path')
@@ -619,7 +689,7 @@ class TaskManager:
raise ValueError(f"Task {task_name} is not defined.") raise ValueError(f"Task {task_name} is not defined.")
task = globals()[task_name].delay(*args, **kwargs) task = globals()[task_name].delay(*args, **kwargs)
self.tasks[task_name] = task.id self.tasks[task_name] = task.id
return task.id return task.id,'STARTED'
def get_task_status(self, task_name): def get_task_status(self, task_name):
if task_name not in self.tasks: if task_name not in self.tasks:

View File

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

132
changelogs/v0.1.9.md Normal file
View File

@@ -0,0 +1,132 @@
# Whats up in Jellyplist v0.1.9?
## ⚠️ BREAKING CHANGE: docker-compose.yml
>[!WARNING]
>In this release I´ve done some rework so now the setup is a bit easier, because you don´t have to spin up the -worker -beat container, these are now all in the default container and managed via supervisor. This means you have to update your `docker-compose.yml` when updating!
So now your compose file should look more or less like this
```yaml
services:
redis:
image: redis:7-alpine
container_name: redis
volumes:
- redis_data:/data
networks:
- jellyplist-network
postgres:
container_name: postgres-jellyplist
image: postgres:17.2
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /data/postgres
volumes:
- /jellyplist_pgdata/postgres:/data/postgres
networks:
- jellyplist-network
restart: unless-stopped
jellyplist:
container_name: jellyplist
image: ${IMAGE}
depends_on:
- postgres
- redis
ports:
- "5055:5055"
networks:
- jellyplist-network
volumes:
- /jellyplist/cookies.txt:/jellyplist/cookies.txt
- /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH}
- /my/super/cool/storage/jellyplist/settings.yaml:/jellyplist/settings.yaml
env_file:
- .env
networks:
jellyplist-network:
driver: bridge
volumes:
postgres:
redis_data:
```
And the `.env` File
```env
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
POSTGRES_USER = jellyplist
POSTGRES_PASSWORD = jellyplist
SECRET_KEY = supersecretkey # Secret key for session management
JELLYFIN_SERVER_URL = http://<jellyfin_server>:8096 # Default to local Jellyfin server
JELLYFIN_ACCESS_TOKEN = <jellyfin access token>
JELLYFIN_ADMIN_USER = <jellyfin admin username>
JELLYFIN_ADMIN_PASSWORD = <jellyfin admin password>
SPOTIFY_CLIENT_ID = <spotify client id>
SPOTIFY_CLIENT_SECRET = <spotify client secret>
JELLYPLIST_DB_HOST = postgres-jellyplist
JELLYPLIST_DB_USER = jellyplist
JELLYPLIST_DB_PASSWORD = jellyplist
LOG_LEVEL = INFO
LIDARR_API_KEY = <lidarr api key>
LIDARR_URL = http://<lidarr server>:8686
LIDARR_MONITOR_ARTISTS = false
SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt'
MUSIC_STORAGE_BASE_PATH = '/storage/media/music'
```
### 🆕 Log Viewer
Under the `Admin` Page there is now a tab called `Logs` from where you can view the current logs, change the log-level on demand and copy a prepared markdown snippet ready to be pasted into a GitHub issue.
### 🆕 New env var´s, a bit more control over spotDL
#### `SPOTDL_PROXY`
Set a Proxy for spotDL. See [https://spotdl.readthedocs.io/en/latest/usage/#command-line-options](https://spotdl.readthedocs.io/en/latest/usage/#command-line-options)
#### `SPOTDL_OUTPUT_FORMAT`
Set the output folder and file name format for downloaded tracks via spotDL. Not all variables, which are supported by spotDL are supported by Jellyplist.
- `{title}`
- `{artist}`
- `{artists}`
- `{album}`
This way you will have a bit more controler over how the files are stored.
The complete output path is joined from `MUSIC_STORAGE_BASE_PATH` and `SPOTDL_OUTPUT_FORMAT`
_*Example:*_
`MUSIC_STORAGE_BASE_PATH = /storage/media/music`
and
`SPOTDL_OUTPUT_FORMAT = /{artist}/{album}/{title}`
The Track is _All I Want for Christmas Is You by Mariah Carey_ this will result in the following folder structure:
`/storage/media/music/Mariah Carey/Merry Christmas/All I Want for Christmas Is You.mp3`
### 🆕 Admin Users can now add Playlists to multiple Users
Sometimes I want to add a playlist to several users at once, because it´s either a _generic_ one or because my wife doesn´t want to bother with the technical stuff 😬
So now, when logged in as an admin user, when adding a playlist you can select users from your Jellyfin server which will also receive it.
Under `Admin` you can also select users which will be preselected by default. These will be stored in the file `settings.yaml`.
You can or should map this file to a file outside the container, so it will persist accross image updates (see compose sample above)
### 🆕 New `env` var `QUALITY_SCORE_THRESHOLD`
Get a better control over the `update_jellyfin_id_for_downloaded_tracks()` behaviour.
Until now this tasks performed a __full update__ every 24h: This means, every track from every playlist was searched through the Jellyfin API with the hope of finding the same track but with a better quality. While this is ok and works fine for small libraries, this tasks eats a lot of power on large libraries and also takes time.
So there is now the new `env` variable `QUALITY_SCORE_THRESHOLD` (default: `1000.0`). When a track was once found with a quality score above 1000.0, Jellyplist wont try to perform another `quality update` anymore on this track.
In order to be able to classify it a little better, here are a few common quality scores:
- spotDL downloaded track without yt-music premium: `< 300`
- spotDL downloaded track **with** yt-music premium: `< 450`
- flac `> 1000`
>[!TIP]
>Want to know what quality score (and many other details) a track has ? Just double-click the table row in the playlist details view to get all the info´s!
### Other changes, improvements and fixes
- Fix for #38 and #22 , where the manual task starting was missing a return value
- Fixed an issue where the content-type of a playlist cover image, would cause the Jellyfin API Client to fail. Thanks @artyorsh
- Fixed missing lock keys to task manager and task status rendering
- Pinned postgres version to 17.2
- Enhanced error logging in tasks
- several fixes and improvements for the Jellyfin API Client

View File

@@ -1,6 +1,7 @@
import os import os
import sys import sys
import app
class Config: class Config:
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
@@ -21,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')
@@ -33,18 +34,32 @@ class Config:
LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true' LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true'
MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH') MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH')
CHECK_FOR_UPDATES = os.getenv('CHECK_FOR_UPDATES','true').lower() == 'true' CHECK_FOR_UPDATES = os.getenv('CHECK_FOR_UPDATES','true').lower() == 'true'
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 specific configuration
SPOTDL_CONFIG = { 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 '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'): 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"
# 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)
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,'__jellyplist/{track-id}')
SPOTDL_CONFIG.update({'output': output_path}) SPOTDL_CONFIG.update({'output': output_path})
if SPOTIFY_COOKIE_FILE:
SPOTDL_CONFIG.update({'cookie_file': SPOTIFY_COOKIE_FILE})
@classmethod @classmethod
def validate_env_vars(cls): def validate_env_vars(cls):
required_vars = { required_vars = {

View File

@@ -2,6 +2,7 @@ import os
import re import re
import subprocess import subprocess
import tempfile import tempfile
from typing import Optional
import numpy as np import numpy as np
import requests import requests
import base64 import base64
@@ -118,6 +119,23 @@ class JellyfinClient:
else: else:
raise Exception(f"Failed to update playlist: {response.content}") raise Exception(f"Failed to update playlist: {response.content}")
def get_music_playlist(self, session_token : str, playlist_id: str):
"""
Get a music playlist by its ID.
:param playlist_id: The ID of the playlist to fetch.
:return: The playlist object
"""
url = f'{self.base_url}/Playlists/{playlist_id}'
self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get playlist: {response.content}")
def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata: def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata:
url = f'{self.base_url}/Items/{playlist_id}' url = f'{self.base_url}/Items/{playlist_id}'
params = { params = {
@@ -239,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}")
@@ -254,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]
params = {
'ids': ','.join(batch), # Comma-separated song IDs
'userId': user_id
}
self.logger.debug(f"Url={url} - Adding batch: {batch}")
# Send the request to Jellyfin API with query parameters response = requests.post(
response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout) url,
self.logger.debug(f"Response = {response.status_code}") 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
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"}
else: return {"status": "success", "message": "Songs added to playlist successfully"}
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}")
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):
""" """
@@ -285,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) for i in range(0, total_songs, batch_size):
self.logger.debug(f"Response = {response.status_code}") batch = song_ids[i:i + batch_size]
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = {
'EntryIds': ','.join(batch) # Join song IDs with commas
}
self.logger.debug(f"Url={url} - Removing batch: {batch}")
if response.status_code == 204: # 204 No Content indicates success for updating response = requests.delete(url, headers=self._get_headers(session_token=session_token), params=params, timeout=self.timeout)
return {"status": "success", "message": "Songs removed from playlist successfully"} self.logger.debug(f"Response = {response.status_code}")
else:
raise Exception(f"Failed to remove songs from playlist: {response.content}") 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):
""" """
@@ -350,6 +383,34 @@ class JellyfinClient:
# Raise an exception if the request failed # Raise an exception if the request failed
raise Exception(f"Failed to remove user from playlist: {response.content}") raise Exception(f"Failed to remove user from playlist: {response.content}")
def remove_user_from_playlist2(self, session_token: str, playlist_id: str, user_id: str, admin_user_id : str):
#TODO: This is a workaround for the issue where the above method does not work
metadata = self.get_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id)
# Construct the API URL
url = f'{self.base_url}/Playlists/{playlist_id}'
users_data = []
current_users = self.get_playlist_users(session_token=session_token, playlist_id= playlist_id)
for cu in current_users:
# This way we remove the user
if cu['UserId'] != user_id:
users_data.append({'UserId': cu['UserId'], 'CanEdit': cu['CanEdit']})
data = {
'Users' : users_data
}
# Prepare the headers
headers = self._get_headers(session_token=session_token)
# Send the request to Jellyfin API
response = requests.post(url, headers=headers, json=data,timeout = self.timeout)
# Check for success
if response.status_code == 204:
self.update_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id , updates= metadata)
return {"status": "success", "message": f"Users added to playlist {playlist_id}."}
else:
raise Exception(f"Failed to add users to playlist: {response.status_code} - {response.content}")
def set_playlist_cover_image(self, session_token: str, playlist_id: str, provider_image_url: str): def set_playlist_cover_image(self, session_token: str, playlist_id: str, provider_image_url: str):
""" """
@@ -367,8 +428,8 @@ class JellyfinClient:
raise Exception(f"Failed to download image from Spotify: {response.content}") raise Exception(f"Failed to download image from Spotify: {response.content}")
# Step 2: Check the image content type (assume it's JPEG or PNG based on the content type from the response) # Step 2: Check the image content type (assume it's JPEG or PNG based on the content type from the response)
content_type = response.headers.get('Content-Type') content_type = response.headers.get('Content-Type').lower()
if content_type not in ['image/jpeg', 'image/png', 'application/octet-stream']: if content_type not in ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream']:
raise Exception(f"Unsupported image format: {content_type}") raise Exception(f"Unsupported image format: {content_type}")
# Todo: # Todo:
if content_type == 'application/octet-stream': if content_type == 'application/octet-stream':
@@ -454,6 +515,18 @@ class JellyfinClient:
return response.json() return response.json()
def get_users(self, session_token: str, user_id: Optional[str] = None):
url = f'{self.base_url}/Users'
if user_id:
url = f'{url}/{user_id}'
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code != 200:
raise Exception(f"Failed to fetch users: {response.content}")
return response.json()
def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list): def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list):
""" """
Search for a track in Jellyfin by comparing the preview audio to tracks in the library. Search for a track in Jellyfin by comparing the preview audio to tracks in the library.

View File

@@ -0,0 +1,32 @@
"""Add quality score to Track
Revision ID: 2777a1885a6b
Revises: 46a65ecc9904
Create Date: 2024-12-11 20:02:00.303765
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2777a1885a6b'
down_revision = '46a65ecc9904'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('quality_score', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('quality_score')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Change track name lenght maximum to 200
Revision ID: 46a65ecc9904
Revises: d13088ebddc5
Create Date: 2024-12-11 19:35:47.617811
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '46a65ecc9904'
down_revision = 'd13088ebddc5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=sa.VARCHAR(length=150),
type_=sa.String(length=200),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=sa.String(length=200),
type_=sa.VARCHAR(length=150),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -26,6 +26,7 @@ The easiest way to start is by using docker and compose.
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2. 3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
4. Prepare a `.env` File 4. Prepare a `.env` File
``` ```
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
POSTGRES_USER = jellyplist POSTGRES_USER = jellyplist
POSTGRES_PASSWORD = jellyplist POSTGRES_PASSWORD = jellyplist
SECRET_KEY = Keykeykesykykesky # Secret key for session management SECRET_KEY = Keykeykesykykesky # Secret key for session management
@@ -40,14 +41,16 @@ JELLYPLIST_DB_PASSWORD = jellyplist
MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where your music library is located. Must be the same value as your music library in jellyfin MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where your music library is located. Must be the same value as your music library in jellyfin
### Optional: ### Optional:
# 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 # 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. # 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 # LOG_LEVEL = DEBUG # Defaults to INFO
@@ -73,13 +76,13 @@ services:
- jellyplist-network - jellyplist-network
postgres: postgres:
container_name: postgres-jellyplist container_name: postgres-jellyplist
image: postgres image: postgres:17.2
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /data/postgres PGDATA: /data/postgres
volumes: volumes:
- postgres:/data/postgres - /jellyplist_pgdata/postgres:/data/postgres
ports: ports:
- "5432:5432" - "5432:5432"
networks: networks:
@@ -88,7 +91,7 @@ services:
jellyplist: jellyplist:
container_name: jellyplist container_name: jellyplist
image: ghcr.io/kamilkosek/jellyplist:latest image: ${IMAGE}
depends_on: depends_on:
- postgres - postgres
- redis - redis
@@ -97,51 +100,18 @@ services:
networks: networks:
- jellyplist-network - jellyplist-network
volumes: volumes:
# Map Your cookies.txt file to exac - /jellyplist/cookies.txt:/jellyplist/cookies.txt
- /your/local/path/cookies.txt:/jellyplist/cookies.txt # - /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
- /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt - ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH}
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin
env_file: env_file:
- .env - .env
# The jellyplist-worker is used to perform background tasks, such as downloads and playlist updates.
# It is the same container, but with a different command
jellyplist-worker:
container_name: jellyplist-worker
image: ghcr.io/kamilkosek/jellyplist:latest
command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
volumes:
# Map Your cookies.txt file to exac
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
- /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin
env_file:
- .env
depends_on:
- postgres
- redis
networks:
- jellyplist-network
# jellyplist-beat is used to schedule the background tasks
jellyplist-beat:
container_name: jellyplist-beat
image: ghcr.io/kamilkosek/jellyplist:latest
command: ["celery", "-A", "app.celery", "beat", "--loglevel=info"]
env_file:
- .env
depends_on:
- postgres
- redis
networks:
- jellyplist-network
networks: networks:
jellyplist-network: jellyplist-network:
driver: bridge driver: bridge
volumes: volumes:
postgres: postgres:
pgadmin:
redis_data: redis_data:
``` ```
5. Start your stack with `docker compose up -d` 5. Start your stack with `docker compose up -d`

View File

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

View File

@@ -58,7 +58,7 @@ body {
} }
@media screen and (min-width: 1600px) { @media screen and (min-width: 1600px) {
.modal-dialog { .modal-xl {
max-width: 90%; max-width: 90%;
/* New width for default modal */ /* New width for default modal */
} }

35
supervisord.conf Normal file
View File

@@ -0,0 +1,35 @@
[supervisord]
nodaemon=true
[program:jellyplist]
command=python run.py
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:celery_worker]
command=celery -A app.celery worker
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:celery_beat]
command=celery -A app.celery beat
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

View File

@@ -4,7 +4,11 @@
<nav class="navbar navbar-expand-lg navbar-dark border-bottom mb-2"> <nav class="navbar navbar-expand-lg navbar-dark border-bottom mb-2">
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item active"> <li class="nav-item">
<a class="nav-link" href="/admin/settings">Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/link_issues">Link Issues <a class="nav-link" href="/admin/link_issues">Link Issues
{% include 'partials/_unlinked_tracks_badge.html' %}</a> {% include 'partials/_unlinked_tracks_badge.html' %}</a>
</li> </li>
@@ -14,6 +18,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/lidarr">Lidarr</a> <a class="nav-link" href="/admin/lidarr">Lidarr</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/admin/logs?name=logs">Logs</a>
</li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,163 @@
{% extends "admin.html" %}
{% block admin_content %}
{% if not logs %}
{% 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.52.0/min/vs/loader.js"></script>
<div class="container-fluid mt-5">
<h1>Log Viewer</h1>
<div class="mb-3 row">
<form action="/admin/setloglevel" method="post" class="d-inline">
<label for="logLevel" class="form-label">Log Level</label>
<select class="form-select" id="logLevel" name="logLevel" required aria-describedby="loglevelHelp">
<option value="DEBUG" {% if log_level=="DEBUG" %}selected{% endif %}>DEBUG</option>
<option value="INFO" {% if log_level=="INFO" %}selected{% endif %}>INFO</option>
<option value="WARNING" {% if log_level=="WARNING" %}selected{% endif %}>WARNING</option>
<option value="ERROR" {% if log_level=="ERROR" %}selected{% endif %}>ERROR</option>
<option value="CRITICAL" {% if log_level=="CRITICAL" %}selected{% endif %}>CRITICAL</option>
</select>
<div id="loglevelHelp" class="form-text">Set the log level on demand.</div>
<button type="submit" class="btn btn-primary mt-2">Set Log Level</button>
</form>
</div>
<div class="mb-5 mt-3 row">
<button type="button" class="btn btn-warning" onclick="openCreateIssueModal()">Get Logs for a new Issue</button>
<!-- Modal HTML -->
<div class="modal fade" id="createIssueModal" tabindex="-1" aria-labelledby="createIssueModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createIssueModalLabel">Create Issue</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<span class="m-2">Hit the copy button or copy this text manually and paste it to your GitHub
Issue.</span>
<div id="issue-text" style="height: 400px;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" id="copyText" class="btn btn-primary">Copy</button>
</div>
<script>
async function setClipboard(text) {
const type = "text/plain";
const blob = new Blob([text], { type });
const data = [new ClipboardItem({ [type]: blob })];
await navigator.clipboard.write(data);
}
document.getElementById('copyText').addEventListener('click', function () {
const issueEditor = monaco.editor.getModels()[1];
const issueText = issueEditor.getValue();
if(!window.isSecureContext){
alert('Clipboard API is not available in insecure context. Please use a secure context (HTTPS) or just copy the text manually.');
return;
}
setClipboard(issueText);
});
</script>
</div>
</div>
</div>
</div>
<div class="mb-5 mt-3 row">
<label for="logType" class="form-label">Select Logs</label>
<select class="form-select" id="logType" name="logType" required
onchange="location.href='/admin/logs?name=' + this.value;">
<option value="logs" {% if name=="logs" %}selected{% endif %}>Logs</option>
<option value="worker" {% if name=="worker" %}selected{% endif %}>Worker Logs</option>
<option value="beat" {% if name=="beat" %}selected{% endif %}>Beat Logs</option>
</select>
</div>
<div class="mt-3 row" id="editor" style="height: 700px;">
</div>
<script>
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
monaco.languages.register({ id: "jellyplistLog" });
// Register a tokens provider for the language
monaco.languages.setMonarchTokensProvider("jellyplistLog", {
tokenizer: {
root: [
[/ERROR -.*/, "custom-error"],
[/WARNING -/, "custom-notice"],
[/INFO -/, "custom-info"],
[/DEBUG -.*/, "custom-debug"],
[/^\[\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d{3}\]/, "custom-date"],
[/\s.*[a-zA-Z0-9_]+\.[a-z]{2,4}(?=:)/, "custom-filename"],
[/\d+(?= -)/, "custom-lineno"]
],
},
});
monaco.editor.defineTheme("jellyplistLogTheme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "custom-info", foreground: "808080" },
{ token: "custom-error", foreground: "ff0000", fontStyle: "bold" },
{ token: "custom-notice", foreground: "FFA500" },
{ token: "custom-debug", foreground: "851ea5" },
{ token: "custom-date", foreground: "90cc6f" },
{ token: "custom-filename", foreground: "d9d04f", fontStyle: "italic" },
{ token: "custom-lineno", foreground: "d9d04f", fontStyle: "light" },
],
colors: {
"editor.foreground": "#ffffff",
},
});
let editor = monaco.editor.create(document.getElementById('editor'), {
value: `{{logs | safe }}`,
language: 'jellyplistLog',
readOnly: true,
minimap: { enabled: false },
theme: 'jellyplistLogTheme',
automaticLayout: true
});
editor.revealLine(editor.getModel().getLineCount())
});
function openCreateIssueModal() {
const modal = new bootstrap.Modal(document.getElementById('createIssueModal'));
fetch('/admin/logs/getLogsForIssue')
.then(response => response.json())
.then(data => {
const issueText = data.logs;
const issueTextInput = document.getElementById('issue-text');
// before creating the new editor, remove the old one
while (issueTextInput.firstChild) {
issueTextInput.removeChild(issueTextInput.firstChild);
}
const issueEditor = monaco.editor.create(issueTextInput, {
value: issueText.join(''),
language: 'markdown',
minimap: { enabled: false },
automaticLayout: true
});
modal.show();
})
.catch(error => console.error('Error fetching issue logs:', error));
}
</script>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "admin.html" %}
{% block admin_content %}
<h2>Settings</h2>
<form action="/admin/settings/save" method="post">
<div class="mb-3">
<h3>Default Playlist Users</h3>
<div id="defaultPlaylistUsers">
{% for user in jellyfin_users %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="default_playlist_users" value="{{ user.Id }}" id="user-{{ user.Id }}"
{% if user.Id in config['runtime_settings']['default_playlist_users'] %}checked{% endif %}>
<label class="form-check-label" for="user-{{ user.Id }}">{{ user.Name }}</label>
</div>
{% endfor %}
</div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
{% endblock %}

View File

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title> <title>Jellyplist {{ title }}</title>
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
@@ -17,7 +17,8 @@
<script src="https://unpkg.com/htmx.org"></script> <script src="https://unpkg.com/htmx.org"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" <script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script> integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</head> </head>
<body> <body>
@@ -55,10 +56,9 @@
class="fa-solid fa-tower-observation"></i> Monitored</a> class="fa-solid fa-tower-observation"></i> Monitored</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin <a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
Playlists</a>
</li> </li>
{% if session.get('is_admin') and session.get('debug') %} {% if session.get('is_admin') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a> <a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a>
</li> </li>
@@ -100,8 +100,7 @@
class="fa-solid fa-tower-observation"></i> Monitored </a> class="fa-solid fa-tower-observation"></i> Monitored </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin <a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
Playlists</a>
</li> </li>
{% if session.get('is_admin') %} {% if session.get('is_admin') %}
<li class="nav-item"> <li class="nav-item">
@@ -150,8 +149,7 @@
</div> </div>
<div id="alerts"></div> <div id="alerts"></div>
<!-- Bootstrap JS --> <!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
document.addEventListener("showToastMessages", function () { document.addEventListener("showToastMessages", function () {
console.log("showToastMessages") console.log("showToastMessages")

View File

@@ -1,9 +1,91 @@
{% if item.can_add %} {% if item.can_add %}
{% if session['is_admin'] %}
<button class="btn btn-primary" id="add-playlist-admin-{{item['id']}}" data-bs-toggle="tooltip" title="Add Playlist for Users">
<i class="fa-solid fa-users"> </i>
</button>
<div class="modal fade" id="addPlaylistModal-{{item['id']}}" tabindex="-1" aria-labelledby="addPlaylistModal-{{item['id']}}Label" aria-hidden="true" data-bs-backdrop="false" >
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addPlaylistModal-{{item['id']}}Label">Select Additional Users</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="allUsers-{{item['id']}}">
<!-- All users will be dynamically loaded here with checkboxes -->
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-secondary" id="selectAllUsers-{{item['id']}}">Select All</button>
<button class="btn btn-success" id="addPlaylistButton-{{item['id']}}">Add Playlist</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById("add-playlist-admin-{{item['id']}}").addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById("addPlaylistModal-{{item['id']}}"));
modal.show();
loadAllUsers{{item['id']}}();
});
function loadAllUsers{{item['id']}}() {
fetch("/admin/getJellyfinUsers")
.then(response => response.json())
.then(data => {
const allUsersDiv = document.getElementById("allUsers-{{item['id']}}");
allUsersDiv.innerHTML = '';
data.users.forEach(user => {
const checkbox = document.createElement('div');
checkbox.classList.add('form-check');
const isChecked = {{ config['runtime_settings']['default_playlist_users']|safe }}.includes(user.Id) ? 'checked' : '';
checkbox.innerHTML = `<input class="form-check-input" type="checkbox" value="${user.Id}" id="user-${user.Id}" ${isChecked}>
<label class="form-check-label" for="user-${user.Id}">${user.Name}</label>`;
allUsersDiv.appendChild(checkbox);
});
});
}
document.getElementById("selectAllUsers-{{item['id']}}").addEventListener('click', function() {
document.querySelectorAll("#allUsers-{{item['id']}} .form-check-input").forEach(checkbox => {
checkbox.checked = true;
});
});
document.getElementById("addPlaylistButton-{{item['id']}}").addEventListener('click', function() {
const selectedUsers = Array.from(document.querySelectorAll("#allUsers-{{item['id']}} .form-check-input:checked")).map(checkbox => checkbox.value);
const hxVals = {
item_id: "{{ item.id }}",
item_name: "{{ item.name }}",
additional_users: selectedUsers
};
fetch("/addplaylist?provider={{provider_id}}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'HX-Request': 'true'
},
body: JSON.stringify(hxVals)
}).then(response => {
if (response.ok) {
location.reload();
}
});
});
</script>
{%else%}
<button class="btn btn-success" hx-post="/addplaylist?provider={{provider_id}}" hx-include="this" hx-swap="outerHTML" hx-target="this" <button class="btn btn-success" hx-post="/addplaylist?provider={{provider_id}}" hx-include="this" hx-swap="outerHTML" hx-target="this"
data-bs-toggle="tooltip" title="Add to my Jellyfin" data-bs-toggle="tooltip" title="Add to my Jellyfin"
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'> hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
<i class="fa-solid fa-circle-plus"> </i> <i class="fa-solid fa-circle-plus"> </i>
</button> </button>
{% endif %}
{% elif item.can_remove %} {% elif item.can_remove %}
<span id="item-can-remove-{{ item.id }}" > <span id="item-can-remove-{{ item.id }}" >
@@ -13,9 +95,110 @@
</button> </button>
{% endif %} {% endif %}
{% if session['is_admin'] and item.can_remove %} {% if session['is_admin'] and item.can_remove %}
<button class="btn btn-danger " hx-delete="{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}" <button class="btn btn-danger" id="confirm-delete-{{item['jellyfin_id']}}" data-bs-toggle="tooltip" title="Delete playlist from monitoring and remove (DELETE FOR ALL USERS) from Jellyfin">
hx-include="this" hx-swap="outerHTML" hx-target="#item-can-remove-{{ item.id }}" data-bs-toggle="tooltip" title="Delete playlist from monitoring and remove (DELETE FOR ALL USERS) from Jellyfin"> <i class="fa-solid fa-trash"> </i>
<i class="fa-solid fa-trash"> </i>
</button> </button>
<script>
document.getElementById("confirm-delete-{{item['jellyfin_id']}}").addEventListener('click', function() {
const button = this;
const icon = button.querySelector('i');
if (icon.classList.contains('fa-trash')) {
icon.classList.remove('fa-trash');
icon.classList.add('fa-check');
button.setAttribute('title', 'Click again to confirm deletion');
} else {
fetch("{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}", {
method: 'DELETE',
headers: {
'HX-Request': 'true'
}
}).then(response => {
if (response.ok) {
button.closest('#item-can-remove-{{ item.id }}').outerHTML = '';
}
});
}
});
</script>
</span> </span>
{% endif%} {% endif%}
{% if session['is_admin'] and item.can_remove %}
<button class="btn btn-info" id="manage-users-{{item['jellyfin_id']}}" data-bs-toggle="tooltip" title="Manage Users">
<i class="fa-solid fa-user"> </i>
</button>
<div class="modal fade" id="manageUsersModal-{{item['jellyfin_id']}}" tabindex="-1" aria-labelledby="manageUsersModal-{{item['jellyfin_id']}}Label" aria-hidden="true" data-bs-modal-backdrop="false">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manageUsersModal-{{item['jellyfin_id']}}Label">Manage Users</h5>
</div>
<div class="modal-body">
<div id="assignedUsers-{{item['jellyfin_id']}}">
<!-- Assigned users will be dynamically loaded here -->
</div>
<div class="input-group mt-3">
<select class="form-select" id="availableUsers-{{item['jellyfin_id']}}">
<!-- Available users will be dynamically loaded here -->
</select>
<button class="btn btn-primary" id="addUserButton-{{item['jellyfin_id']}}">Add User to Playlist</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById("manage-users-{{item['jellyfin_id']}}").addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById("manageUsersModal-{{item['jellyfin_id']}}"));
modal.show();
loadUsers{{item['jellyfin_id']}}();
});
function loadUsers{{item['jellyfin_id']}}() {
fetch("/admin/getJellyfinPlaylistUsers?playlist={{item['jellyfin_id']}}")
.then(response => response.json())
.then(data => {
console.log("jellyfin playlist id: {{item['jellyfin_id']}}");
const assignedUsersDiv = document.getElementById("assignedUsers-{{item['jellyfin_id']}}");
console.log(assignedUsersDiv);
assignedUsersDiv.innerHTML = '';
data.assigned_users.forEach(user => {
const badge = document.createElement('span');
badge.classList.add('badge', 'bg-primary', 'me-1', 'mb-1');
badge.innerHTML = `${user.Name} <button class="btn btn-danger btn-sm ms-1" onclick="removeUser{{item['jellyfin_id']}}('${user.UserId}')">×</button>`;
assignedUsersDiv.appendChild(badge);
});
const availableUsersSelect = document.getElementById("availableUsers-{{item['jellyfin_id']}}");
availableUsersSelect.innerHTML = '';
data.remaining_users.forEach(user => {
const option = document.createElement('option');
option.value = user.Id;
option.textContent = user.Name;
availableUsersSelect.appendChild(option);
});
});
}
function removeUser{{item['jellyfin_id']}}(userId) {
fetch(`/admin/removeJellyfinUserFromPlaylist?user=${userId}&playlist={{item['jellyfin_id']}}`)
.then(response => response.json())
.then(data => {
if (data.success) {
loadUsers{{item['jellyfin_id']}}();
}
});
}
document.getElementById("addUserButton-{{item['jellyfin_id']}}").addEventListener('click', function() {
const userId = document.getElementById("availableUsers-{{item['jellyfin_id']}}").value;
fetch(`/admin/addJellyfinUserToPlaylist?user=${userId}&playlist={{item['jellyfin_id']}}`)
.then(response => response.json())
.then(data => {
if (data.success) {
loadUsers{{item['jellyfin_id']}}();
}
});
});
</script>
{% endif %}

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__ = "0.1.8" __version__ = "v0.1.10"