Merge dev 0.1.9 into main

Merge dev into main
This commit is contained in:
Kamil Kosek
2024-12-14 00:13:41 +01:00
committed by GitHub
27 changed files with 1063 additions and 110 deletions

View File

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

View File

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

View File

@@ -32,9 +32,21 @@ jobs:
- name: Extract Version
id: extract_version
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
- 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
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -56,13 +68,17 @@ jobs:
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
ghcr.io/${{ github.repository }}:dev
ghcr.io/${{ github.repository }}:${{ env.VERSION }}
ghcr.io/${{ github.repository }}:${{ env.BRANCH_NAME }}
ghcr.io/${{ github.repository }}:${{ env.VERSION }}-${{ env.BRANCH_NAME}}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
name: Dev Release ${{ env.VERSION }}
tag_name: |
${{ env.VERSION }}-${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}
name: |
${{ env.BRANCH_NAME }} Release ${{ env.VERSION }}
body: |
${{ env.CHANGELOG_CONTENT }}
generate_release_notes: true
make_latest: false

1
.gitignore vendored
View File

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

View File

@@ -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"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,6 +1,8 @@
from logging.handlers import RotatingFileHandler
import os
import threading
import time
import yaml
from flask_socketio import SocketIO
import sys
@@ -97,15 +99,38 @@ app = Flask(__name__, template_folder="../templates", static_folder='../static')
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:
app.logger.removeHandler(handler)
log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) # Default to DEBUG if invalid
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)
# 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)
@@ -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"]}')
from lidarr.client import LidarrClient
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)}")
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]:
is_admin = session.get('is_admin', False)
@@ -245,3 +261,7 @@ def get_latest_release(tag_name :str):
except requests.exceptions.RequestException as e:
app.logger.error(f"Error fetching latest version: {str(e)}")
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):
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())
@@ -59,8 +59,11 @@ class Track(db.Model):
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'<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 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
@@ -34,7 +34,10 @@ def jellyfin_playlists():
combined_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
combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data:
@@ -50,6 +53,13 @@ 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()
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:
@@ -119,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)

View File

@@ -3,7 +3,7 @@ import json
import os
import re
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.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult
@@ -68,13 +68,13 @@ 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)
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')
@functions.jellyfin_admin_required
def link_issues():
@@ -107,6 +107,81 @@ 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),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'])
@@ -116,6 +191,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 +199,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)
@@ -180,7 +259,7 @@ def openPlaylist():
try:
provider_client = MusicProviderRegistry.get_provider(provider_id)
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)
if combined_data:
@@ -217,8 +296,8 @@ def browse_page(page_id):
@functions.jellyfin_login_required
def monitored_playlists():
# 1. Get all Playlists from the Database.
all_playlists = Playlist.query.all()
# 1. Get all Playlists from the Database and order them by Id
all_playlists = Playlist.query.order_by(Playlist.id).all()
# 2. Group them by provider
playlists_by_provider = defaultdict(list)
@@ -236,7 +315,7 @@ def monitored_playlists():
combined_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
combined_data = functions.prepPlaylistData(provider_playlist)
if combined_data:
@@ -380,13 +459,95 @@ def associate_track():
@app.route("/unlock_key",methods = ['POST'])
@functions.jellyfin_admin_required
def unlock_key():
key_name = request.form.get('inputLockKey')
if key_name:
tasks.release_lock(key_name)
tasks.task_manager.release_lock(key_name)
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')
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')
def test():

View File

@@ -21,7 +21,7 @@ from lidarr.classes import Artist
@signals.celeryd_init.connect
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_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.")
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:
task_manager.release_lock(lock_key)
else:
@@ -125,6 +128,7 @@ def download_missing_tracks(self):
return {'status': 'No undownloaded tracks found'}
app.logger.info(f"Found {total_tracks} tracks to download.")
app.logger.debug(f"output_dir: {output_dir}")
processed_tracks = 0
failed_downloads = 0
for track in undownloaded_tracks:
@@ -136,11 +140,39 @@ def download_missing_tracks(self):
'failed': failed_downloads
})
# 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
if search_before_download:
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
best_match = find_best_match_from_jellyfin(track)
if best_match:
@@ -186,13 +218,13 @@ def download_missing_tracks(self):
#endregion
#endregion
if os.path.exists(file_path):
app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.")
track.downloaded = True
track.filesystem_path = file_path
db.session.commit()
continue
if file_path:
if os.path.exists(file_path):
app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.")
track.downloaded = True
track.filesystem_path = file_path
db.session.commit()
continue
@@ -211,12 +243,17 @@ def download_missing_tracks(self):
app.logger.debug(f"Found {cookie_file}, using it for spotDL")
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)
if result.returncode == 0 and os.path.exists(file_path):
if result.returncode == 0:
track.downloaded = True
track.filesystem_path = file_path
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
if file_path:
track.filesystem_path = file_path
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
else:
app.logger.error(f"Download failed for track {track.name}.")
if result.stdout:
@@ -248,6 +285,9 @@ def download_missing_tracks(self):
'processed': processed_tracks,
'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:
task_manager.release_lock(lock_key)
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.")
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:
task_manager.release_lock(lock_key)
else:
@@ -375,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)
@@ -393,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']:
@@ -402,10 +453,11 @@ 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:
app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
spotify_track = None
@@ -415,11 +467,14 @@ def update_jellyfin_id_for_downloaded_tracks(self):
processed_tracks += 1
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})
app.logger.info("Finished updating Jellyfin IDs for all 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:
task_manager.release_lock(lock_key)
else:
@@ -502,6 +557,9 @@ def request_lidarr(self):
app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}')
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:
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']}]")
best_match = result
best_quality_score = quality_score
break
@@ -563,7 +622,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)}")
@@ -587,8 +648,8 @@ def compute_quality_score(result, use_ffprobe=False) -> float:
if result.get('HasLyrics'):
score += 10
runtime_ticks = result.get('RunTimeTicks', 0)
score += runtime_ticks / 1e6
#runtime_ticks = result.get('RunTimeTicks', 0)
#score += runtime_ticks / 1e6
if use_ffprobe:
path = result.get('Path')
@@ -619,7 +680,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:

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 sys
class Config:
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
SECRET_KEY = os.getenv('SECRET_KEY')
@@ -33,6 +32,9 @@ class Config:
LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true'
MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH')
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',
@@ -42,7 +44,8 @@ class Config:
}
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})
@classmethod

View File

@@ -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):
"""
@@ -367,8 +396,8 @@ 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')
if content_type not in ['image/jpeg', 'image/png', 'application/octet-stream']:
content_type = response.headers.get('Content-Type').lower()
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':
@@ -454,6 +483,18 @@ class JellyfinClient:
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):
"""
Search for a track in Jellyfin by comparing the preview audio to tracks in the library.

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ The easiest way to start is by using docker and compose.
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
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
@@ -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
### 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
@@ -73,13 +76,13 @@ services:
- jellyplist-network
postgres:
container_name: postgres-jellyplist
image: postgres
image: postgres:17.2
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /data/postgres
volumes:
- postgres:/data/postgres
- /jellyplist_pgdata/postgres:/data/postgres
ports:
- "5432:5432"
networks:
@@ -88,7 +91,7 @@ services:
jellyplist:
container_name: jellyplist
image: ghcr.io/kamilkosek/jellyplist:latest
image: ${IMAGE}
depends_on:
- postgres
- redis
@@ -97,51 +100,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`

View File

@@ -17,3 +17,4 @@ psycopg2-binary
eventlet
pydub
fuzzywuzzy
pyyaml

View File

@@ -58,7 +58,7 @@ body {
}
@media screen and (min-width: 1600px) {
.modal-dialog {
.modal-xl {
max-width: 90%;
/* 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">
<div class="collapse navbar-collapse" id="navbarNav">
<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
{% include 'partials/_unlinked_tracks_badge.html' %}</a>
</li>
@@ -14,6 +18,9 @@
<li class="nav-item">
<a class="nav-link" href="/admin/lidarr">Lidarr</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/logs?name=logs">Logs</a>
</li>
</ul>
</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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<title>Jellyplist {{ title }}</title>
<!-- Bootstrap CSS -->
<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') }}">
@@ -17,7 +17,8 @@
<script src="https://unpkg.com/htmx.org"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
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>
<body>
@@ -55,10 +56,9 @@
class="fa-solid fa-tower-observation"></i> Monitored</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
Playlists</a>
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
</li>
{% if session.get('is_admin') and session.get('debug') %}
{% if session.get('is_admin') %}
<li class="nav-item">
<a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a>
</li>
@@ -100,8 +100,7 @@
class="fa-solid fa-tower-observation"></i> Monitored </a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
Playlists</a>
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
</li>
{% if session.get('is_admin') %}
<li class="nav-item">
@@ -150,8 +149,7 @@
</div>
<div id="alerts"></div>
<!-- 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>
document.addEventListener("showToastMessages", function () {
console.log("showToastMessages")

View File

@@ -1,9 +1,91 @@
{% 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"
data-bs-toggle="tooltip" title="Add to my Jellyfin"
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
<i class="fa-solid fa-circle-plus"> </i>
</button>
{% endif %}
{% elif item.can_remove %}
<span id="item-can-remove-{{ item.id }}" >
@@ -13,9 +95,110 @@
</button>
{% endif %}
{% if session['is_admin'] and item.can_remove %}
<button class="btn btn-danger " hx-delete="{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}"
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>
<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">
<i class="fa-solid fa-trash"> </i>
</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>
{% 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"