56 Commits

Author SHA1 Message Date
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
43adf12755 Enable generation of release notes in GitHub Actions workflow 2024-12-12 12:21:39 +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
Kamil Kosek
e2bea2c151 Update main.yml
Remove automatic Build
2024-12-09 10:51:20 +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
28 changed files with 1071 additions and 113 deletions

View File

@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.8 current_version = v0.1.9
commit = True commit = True
tag = True tag = True

View File

@@ -12,6 +12,6 @@ __pycache__/
# Ignore Git files # Ignore Git files
.git .git
cookies* *cookies*
set_env.sh set_env.sh
jellyplist.code-workspace jellyplist.code-workspace

View File

@@ -1,9 +1,12 @@
name: Build and Release on Tag name: Build and Release on Tag
on: on:
push: workflow_dispatch:
branches: inputs:
- main branch:
description: 'Branch to build the Docker image from'
required: true
default: 'main'
jobs: jobs:
build-and-publish: build-and-publish:
@@ -73,5 +76,7 @@ jobs:
name: Release ${{ env.VERSION }} name: Release ${{ env.VERSION }}
body: | body: |
${{ env.CHANGELOG_CONTENT }} ${{ env.CHANGELOG_CONTENT }}
generate_release_notes: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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,15 +68,19 @@ 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

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)
@@ -185,3 +210,28 @@ 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

@@ -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)
@@ -244,4 +260,8 @@ def get_latest_release(tag_name :str):
return False, '' return False, ''
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())
@@ -58,9 +58,12 @@ class Track(db.Model):
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
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

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

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:
@@ -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
@@ -211,12 +243,17 @@ def download_missing_tracks(self):
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'])
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 +285,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']:
@@ -360,6 +400,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 +418,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 +443,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 +453,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 +467,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 +557,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 +611,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 +622,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 +648,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 +680,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.9"

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,7 +1,6 @@
import os import os
import sys import sys
class Config: class Config:
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv('SECRET_KEY')
@@ -33,6 +32,9 @@ 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))
# SpotDL specific configuration # SpotDL specific configuration
SPOTDL_CONFIG = { SPOTDL_CONFIG = {
'cookie_file': '/jellyplist/cookies.txt', 'cookie_file': '/jellyplist/cookies.txt',
@@ -42,7 +44,8 @@ class Config:
} }
if os.getenv('MUSIC_STORAGE_BASE_PATH'): if os.getenv('MUSIC_STORAGE_BASE_PATH'):
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,'__jellyplist/{track-id}') output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,SPOTDL_OUTPUT_FORMAT)
SPOTDL_CONFIG.update({'output': output_path}) SPOTDL_CONFIG.update({'output': output_path})
@classmethod @classmethod

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
@@ -350,6 +351,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 +396,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 +483,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.
@@ -622,4 +663,4 @@ class JellyfinClient:
similarity = (1 - best_score) * 100 # Convert to percentage similarity = (1 - best_score) * 100 # Convert to percentage
return similarity, best_offset return similarity, best_offset

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,6 +41,8 @@ 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
@@ -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

@@ -16,4 +16,5 @@ Unidecode==1.3.8
psycopg2-binary psycopg2-binary
eventlet eventlet
pydub pydub
fuzzywuzzy fuzzywuzzy
pyyaml

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

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