From 9acf3bde84e035ea88e791164472b1e6f5499e35 Mon Sep 17 00:00:00 2001 From: Kamil Date: Fri, 6 Dec 2024 08:23:00 +0000 Subject: [PATCH 01/50] Fixed missing lock keys to task manager and task status rendering --- app/routes/routes.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/routes/routes.py b/app/routes/routes.py index c45252c..2e3863f 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -68,11 +68,12 @@ def save_lidarr_config(): @functions.jellyfin_admin_required def task_manager(): statuses = {} + lock_keys = [] for task_name, task_id in tasks.task_manager.tasks.items(): statuses[task_name] = tasks.task_manager.get_task_status(task_name) - - - return render_template('admin/tasks.html', tasks=statuses) + 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) @app.route('/admin') @app.route('/admin/link_issues') @@ -116,6 +117,7 @@ def run_task(task_name): # Rendere nur die aktualisierte Zeile der Task task_info = {task_name: {'state': status, 'info': info}} + return render_template('partials/_task_status.html', tasks=task_info) @@ -123,12 +125,15 @@ def run_task(task_name): @functions.jellyfin_admin_required def task_status(): statuses = {} + lock_keys = [] for task_name, task_id in tasks.task_manager.tasks.items(): 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 - return render_template('partials/_task_status.html', tasks=statuses) + return render_template('partials/_task_status.html', tasks=statuses, lock_keys = lock_keys) From a20f1733f19bd7628276dcfb7d649d5ce3b753a8 Mon Sep 17 00:00:00 2001 From: Artur Y <10753921+artyorsh@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:33:22 +0100 Subject: [PATCH 02/50] case-insensitive image formats Sometimes the image format comes as `image/JPEG`, which results in an `Unsupported image format: image/JPEG` --- jellyfin/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jellyfin/client.py b/jellyfin/client.py index 2eb84d7..951f28c 100644 --- a/jellyfin/client.py +++ b/jellyfin/client.py @@ -367,7 +367,7 @@ class JellyfinClient: 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) - 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']: raise Exception(f"Unsupported image format: {content_type}") # Todo: @@ -622,4 +622,4 @@ class JellyfinClient: similarity = (1 - best_score) * 100 # Convert to percentage - return similarity, best_offset \ No newline at end of file + return similarity, best_offset From 181eff22ef10b4f340f786112c9853e0267d2ba4 Mon Sep 17 00:00:00 2001 From: Artur Y Date: Sat, 7 Dec 2024 13:27:45 +0100 Subject: [PATCH 03/50] add image/webp to the list of supported cover image types --- jellyfin/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jellyfin/client.py b/jellyfin/client.py index 951f28c..d617bd0 100644 --- a/jellyfin/client.py +++ b/jellyfin/client.py @@ -368,7 +368,7 @@ class JellyfinClient: # 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').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}") # Todo: if content_type == 'application/octet-stream': From e559b1cf1192a147e4b1a8e1a46e7645a339083a Mon Sep 17 00:00:00 2001 From: Kamil Date: Mon, 9 Dec 2024 10:16:58 +0000 Subject: [PATCH 04/50] return task info on manual start Fixes #38 Fixes #22 --- app/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tasks.py b/app/tasks.py index 6ffd39e..131e92d 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -619,7 +619,7 @@ class TaskManager: raise ValueError(f"Task {task_name} is not defined.") task = globals()[task_name].delay(*args, **kwargs) self.tasks[task_name] = task.id - return task.id + return task.id,'STARTED' def get_task_status(self, task_name): if task_name not in self.tasks: From d54100cbc457f145179e14d616fa237b6bfc5109 Mon Sep 17 00:00:00 2001 From: Kamil Date: Mon, 9 Dec 2024 10:20:04 +0000 Subject: [PATCH 05/50] add supervisor support to Dockerfile and create supervisord configuration --- Dockerfile | 5 +++-- supervisord.conf | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 supervisord.conf diff --git a/Dockerfile b/Dockerfile index fa5c96c..f1c4ac5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /jellyplist COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt RUN apt update -RUN apt install ffmpeg netcat-openbsd -y +RUN apt install ffmpeg netcat-openbsd supervisor -y # Copy the application code COPY . . COPY entrypoint.sh /entrypoint.sh @@ -16,6 +16,7 @@ RUN chmod +x /entrypoint.sh # Expose the port the app runs on EXPOSE 5055 +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf # Set the entrypoint @@ -23,4 +24,4 @@ ENTRYPOINT ["/entrypoint.sh"] # Run the application -CMD ["python", "run.py"] \ No newline at end of file +CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..7ddc2f4 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,23 @@ +[supervisord] +nodaemon=true + +[program:jellyplist] +command=python run.py +autostart=true +autorestart=true +stderr_logfile=/var/log/jellyplist.supervisord.err.log +stdout_logfile=/var/log/jellyplist.supervisord.out.log + +[program:celery_worker] +command=celery -A app.celery worker +autostart=true +autorestart=true +stderr_logfile=/var/log/jellyplist.celery_worker.supervisord.err.log +stdout_logfile=/var/log/jellyplist.celery_worker.supervisord.out.log + +[program:celery_beat] +command=celery -A app.celery beat +autostart=true +autorestart=true +stderr_logfile=/var/log/jellyplist.celery_beat.supervisord.err.log +stdout_logfile=/var/log/jellyplist.celery_beat.supervisord.out.log \ No newline at end of file From 92e89637275a3d7b364bee718d8926b220e08fb2 Mon Sep 17 00:00:00 2001 From: Kamil Date: Mon, 9 Dec 2024 10:25:44 +0000 Subject: [PATCH 06/50] add rotating file handler for logging based on worker type --- app/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/__init__.py b/app/__init__.py index bb36dc0..bb0ef3c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -106,6 +106,18 @@ app.logger.setLevel(log_level) FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(levelname)7s - %(message)s" 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() cache = Cache(app) redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True) From eeb6ad9172b3a413a557053dfdabec9ba861e5ab Mon Sep 17 00:00:00 2001 From: Kamil Date: Mon, 9 Dec 2024 20:37:50 +0000 Subject: [PATCH 07/50] update .dockerignore to ignore all cookie files --- .dockerignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 704f86b..036bd21 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,6 @@ __pycache__/ # Ignore Git files .git -cookies* +*cookies* set_env.sh jellyplist.code-workspace \ No newline at end of file From 798c4ae28d290c1ad7869c4d0df3f91d72d076ed Mon Sep 17 00:00:00 2001 From: Kamil Date: Mon, 9 Dec 2024 20:38:38 +0000 Subject: [PATCH 08/50] update readme.md to define IMAGE variable and adjust volume mappings for jellyplist services and prepare compose sample for 0.1.9 release --- readme.md | 44 ++++++-------------------------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/readme.md b/readme.md index 3ba8886..e164e99 100644 --- a/readme.md +++ b/readme.md @@ -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. 4. Prepare a `.env` File ``` +IMAGE = ghcr.io/kamilkosek/jellyplist:latest POSTGRES_USER = jellyplist POSTGRES_PASSWORD = jellyplist SECRET_KEY = Keykeykesykykesky # Secret key for session management @@ -79,7 +80,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: /data/postgres volumes: - - postgres:/data/postgres + - /jellyplist_pgdata/postgres:/data/postgres ports: - "5432:5432" networks: @@ -88,7 +89,7 @@ services: jellyplist: container_name: jellyplist - image: ghcr.io/kamilkosek/jellyplist:latest + image: ${IMAGE} depends_on: - postgres - redis @@ -97,51 +98,18 @@ services: networks: - jellyplist-network 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 + - /jellyplist/cookies.txt:/jellyplist/cookies.txt + - /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt + - ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} env_file: - .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: jellyplist-network: driver: bridge volumes: postgres: - pgadmin: redis_data: ``` 5. Start your stack with `docker compose up -d` From 767618962545a4c7b274bbe701bb4d07e9d63915 Mon Sep 17 00:00:00 2001 From: Kamil Date: Tue, 10 Dec 2024 11:44:01 +0000 Subject: [PATCH 09/50] 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 --- app/functions.py | 6 +- app/routes/routes.py | 58 +++++++++++++ templates/admin.html | 3 + templates/admin/logview.html | 162 +++++++++++++++++++++++++++++++++++ templates/base.html | 6 +- 5 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 templates/admin/logview.html diff --git a/app/functions.py b/app/functions.py index ea9ce53..7f83cd8 100644 --- a/app/functions.py +++ b/app/functions.py @@ -244,4 +244,8 @@ def get_latest_release(tag_name :str): return False, '' except requests.exceptions.RequestException as e: app.logger.error(f"Error fetching latest version: {str(e)}") - return False,'' \ No newline at end of file + return False,'' + +def set_log_level(level): + app.logger.setLevel(level) + app.logger.info(f"Log level set to {level}") \ No newline at end of file diff --git a/app/routes/routes.py b/app/routes/routes.py index 2e3863f..a75e796 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -108,7 +108,65 @@ def link_issues(): 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).replace('<',"_").replace('>',"_"),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('/run_task/', methods=['POST']) @functions.jellyfin_admin_required diff --git a/templates/admin.html b/templates/admin.html index 7c55275..4680ec8 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -14,6 +14,9 @@ + diff --git a/templates/admin/logview.html b/templates/admin/logview.html new file mode 100644 index 0000000..1628875 --- /dev/null +++ b/templates/admin/logview.html @@ -0,0 +1,162 @@ +{% extends "admin.html" %} + +{% block admin_content %} +{% if not logs %} +{% set logs = "Logfile empty or not found" %} +{% endif %} +{% set log_level = config['LOG_LEVEL'] %} + + +
+

Log Viewer

+
+ +
+ + +
Set the log level on demand.
+ +
+ +
+
+ + + + +
+
+ + +
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 53d6a38..fcc968f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,7 +17,8 @@ - + + @@ -150,8 +151,7 @@
- - + -{% endif%} \ No newline at end of file +{% endif%} +{% if session['is_admin'] and item.can_remove %} + +{% endif %} + + + \ No newline at end of file From 423ffbb608d8e2ec14f1a417b187797d1bcc4ab9 Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 12:54:04 +0000 Subject: [PATCH 32/50] fix: clean up user management modal script and improve badge styling --- templates/partials/_add_remove_button.html | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/templates/partials/_add_remove_button.html b/templates/partials/_add_remove_button.html index 019cc25..056f4ea 100644 --- a/templates/partials/_add_remove_button.html +++ b/templates/partials/_add_remove_button.html @@ -45,7 +45,6 @@ document.getElementById("confirm-delete-{{item['jellyfin_id']}}").addEventListen -{% endif %} - \ No newline at end of file + +{% endif %} \ No newline at end of file From 0c57912053074071338125fbe9904025f2f3b1ae Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 12:54:18 +0000 Subject: [PATCH 33/50] feat: implement Jellyfin user management routes for playlists --- app/routes/routes.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/app/routes/routes.py b/app/routes/routes.py index 066a32c..069987b 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -449,6 +449,85 @@ def unlock_key(): flash(f'Lock {key_name} released', category='success') 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') + # 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') def test(): From 2da69fc330ebf5cbe7f355b027c39cbaa5dbceaa Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 12:54:28 +0000 Subject: [PATCH 34/50] feat: add workaround method to remove user from playlist and enhance get_users method with optional user_id --- jellyfin/client.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/jellyfin/client.py b/jellyfin/client.py index bff746b..0b6749b 100644 --- a/jellyfin/client.py +++ b/jellyfin/client.py @@ -2,6 +2,7 @@ import os import re import subprocess import tempfile +from typing import Optional import numpy as np import requests import base64 @@ -350,6 +351,34 @@ class JellyfinClient: # Raise an exception if the request failed 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): """ @@ -454,8 +483,10 @@ class JellyfinClient: return response.json() - def get_users(self, session_token: str): + 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) From 6248c548290aaca847af7dcd97052325722e5311 Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 12:54:47 +0000 Subject: [PATCH 35/50] fix: update modal dialog class to improve responsiveness on larger screens --- static/css/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/styles.css b/static/css/styles.css index 7425b90..bf5cf81 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -58,7 +58,7 @@ body { } @media screen and (min-width: 1600px) { - .modal-dialog { + .modal-xl { max-width: 90%; /* New width for default modal */ } From 67d2b3cb9e77fc6f8d6500da06841a41e4a993f7 Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 14:15:15 +0000 Subject: [PATCH 36/50] feat: enhance add_playlist function to support JSON input and manage additional users for playlists --- app/routes/jellyfin_routes.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/routes/jellyfin_routes.py b/app/routes/jellyfin_routes.py index f396db9..2e9ab42 100644 --- a/app/routes/jellyfin_routes.py +++ b/app/routes/jellyfin_routes.py @@ -9,7 +9,7 @@ from app.tasks import task_manager from app.registry.music_provider_registry import MusicProviderRegistry from jellyfin.objects import PlaylistMetadata -from app.routes import pl_bp +from app.routes import pl_bp, routes @app.route('/jellyfin_playlists') @functions.jellyfin_login_required @@ -53,6 +53,13 @@ def jellyfin_playlists(): def add_playlist(): playlist_id = request.form.get('item_id') playlist_name = request.form.get('item_name') + + 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 provider_id = request.args.get('provider') if not playlist_id: @@ -122,6 +129,13 @@ def add_playlist(): "can_remove":True, "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) From a44c5b52094503e19610a1a88ec28436cdd02b8b Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 14:15:28 +0000 Subject: [PATCH 37/50] feat: refactor add_jellyfin_user_to_playlist to use internal method for user assignment --- app/routes/routes.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/routes/routes.py b/app/routes/routes.py index 069987b..42dbc9c 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -506,6 +506,10 @@ def remove_jellyfin_user_from_playlist(): 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() @@ -522,8 +526,8 @@ def add_jellyfin_user_to_playlist(): 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]) From d615bafd1f73b2b5ddbb4ed54037f7cc72f24990 Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 14:15:40 +0000 Subject: [PATCH 38/50] feat: add admin functionality to dynamically load and manage users for playlist addition --- templates/partials/_add_remove_button.html | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/templates/partials/_add_remove_button.html b/templates/partials/_add_remove_button.html index 056f4ea..463167e 100644 --- a/templates/partials/_add_remove_button.html +++ b/templates/partials/_add_remove_button.html @@ -1,9 +1,88 @@ {% if item.can_add %} + + +{% if session['is_admin'] %} + + + + + +{%else%} +{% endif %} + {% elif item.can_remove %} From d6a702b60626b7a344d003e96f02d072d906b1f8 Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 19:33:41 +0000 Subject: [PATCH 39/50] feat: initialize additional_users variable in add_playlist function --- app/routes/jellyfin_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/jellyfin_routes.py b/app/routes/jellyfin_routes.py index 2e9ab42..a74cd5c 100644 --- a/app/routes/jellyfin_routes.py +++ b/app/routes/jellyfin_routes.py @@ -53,7 +53,7 @@ def jellyfin_playlists(): def add_playlist(): playlist_id = request.form.get('item_id') 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() From b877ee04e36a3d4461924d27e630493242cc798a Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 19:36:26 +0000 Subject: [PATCH 40/50] fix: increase maximum length of track name to 200 characters --- app/models.py | 2 +- ...change_track_name_lenght_maximum_to_200.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py diff --git a/app/models.py b/app/models.py index 42f4dc4..6400f4d 100644 --- a/app/models.py +++ b/app/models.py @@ -50,7 +50,7 @@ playlist_tracks = db.Table('playlist_tracks', class Track(db.Model): 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_uri = db.Column(db.String(120), unique=True, nullable=False) downloaded = db.Column(db.Boolean()) diff --git a/migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py b/migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py new file mode 100644 index 0000000..e5c080c --- /dev/null +++ b/migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py @@ -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 ### From 4d9e6162fc9af015fc921c361158cd4ae3eaddcb Mon Sep 17 00:00:00 2001 From: Kamil Date: Wed, 11 Dec 2024 20:33:13 +0000 Subject: [PATCH 41/50] feat: add quality_score field to Track model and update related functionality Fixes #51 --- app/models.py | 3 ++ app/tasks.py | 18 ++++++++--- config.py | 1 + ...2777a1885a6b_add_quality_score_to_track.py | 32 +++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/2777a1885a6b_add_quality_score_to_track.py diff --git a/app/models.py b/app/models.py index 6400f4d..ca8a89e 100644 --- a/app/models.py +++ b/app/models.py @@ -58,9 +58,12 @@ class Track(db.Model): jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field download_status = db.Column(db.String(2048), nullable=True) provider_id = db.Column(db.String(20)) + # Many-to-Many relationship with Playlists playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') + lidarr_processed = db.Column(db.Boolean(), default=False) + quality_score = db.Column(db.Float(), default=0) def __repr__(self): return f'' diff --git a/app/tasks.py b/app/tasks.py index f0a7208..eeea749 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -418,11 +418,18 @@ def update_jellyfin_id_for_downloaded_tracks(self): app.logger.info("Starting Jellyfin ID update for tracks...") 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): 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: app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None") total_tracks = len(downloaded_tracks) @@ -436,6 +443,7 @@ def update_jellyfin_id_for_downloaded_tracks(self): for track in downloaded_tracks: try: best_match = find_best_match_from_jellyfin(track) + if best_match: track.downloaded = True if track.jellyfin_id != best_match['Id']: @@ -445,7 +453,7 @@ def update_jellyfin_id_for_downloaded_tracks(self): track.filesystem_path = best_match['Path'] 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() else: @@ -611,7 +619,9 @@ def find_best_match_from_jellyfin(track: Track): if quality_score > best_quality_score: best_match = result 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 except Exception as e: app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}") diff --git a/config.py b/config.py index 500c55d..8dc1b97 100644 --- a/config.py +++ b/config.py @@ -34,6 +34,7 @@ class Config: 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)) # SpotDL specific configuration SPOTDL_CONFIG = { 'cookie_file': '/jellyplist/cookies.txt', diff --git a/migrations/versions/2777a1885a6b_add_quality_score_to_track.py b/migrations/versions/2777a1885a6b_add_quality_score_to_track.py new file mode 100644 index 0000000..2aa6654 --- /dev/null +++ b/migrations/versions/2777a1885a6b_add_quality_score_to_track.py @@ -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 ### From 9667b71d24d05cefbd6b0203fa272c3f69bdee65 Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 12 Dec 2024 12:04:37 +0000 Subject: [PATCH 42/50] feat: update page title to include 'Jellyplist' prefix --- templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/base.html b/templates/base.html index f05a163..f04c19a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ - {{ title }} + Jellyplist {{ title }} From 969eca4a049b1115bdd963a1b0295235debcce2a Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 12 Dec 2024 12:05:06 +0000 Subject: [PATCH 43/50] fix: remove backdrop from select user dialog --- templates/partials/_add_remove_button.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/partials/_add_remove_button.html b/templates/partials/_add_remove_button.html index 463167e..f56457f 100644 --- a/templates/partials/_add_remove_button.html +++ b/templates/partials/_add_remove_button.html @@ -6,11 +6,13 @@ -