Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c5f7093e | ||
|
|
34ae3c5680 | ||
|
|
56d937a21f | ||
|
|
3a78e710ae | ||
|
|
1b95f201be | ||
|
|
b7de39e501 | ||
|
|
9c46be1701 | ||
|
|
43adf12755 | ||
|
|
9de58731c0 | ||
|
|
969eca4a04 | ||
|
|
9667b71d24 | ||
|
|
4d9e6162fc | ||
|
|
b877ee04e3 | ||
|
|
d6a702b606 | ||
|
|
d615bafd1f | ||
|
|
a44c5b5209 | ||
|
|
67d2b3cb9e | ||
|
|
6248c54829 | ||
|
|
2da69fc330 | ||
|
|
0c57912053 | ||
|
|
423ffbb608 | ||
|
|
d9302434c2 | ||
|
|
debe273cfb | ||
|
|
f9e8be1824 | ||
|
|
cdf7d8ffe9 | ||
|
|
41c62a5376 | ||
|
|
4f06f81e93 | ||
|
|
754f7f9204 | ||
|
|
01cc78eb93 | ||
|
|
79c9554ce2 | ||
|
|
500a049976 | ||
|
|
477c869107 | ||
|
|
731d2db083 | ||
|
|
3862730203 | ||
|
|
11bd25e5be | ||
|
|
c78ceef508 | ||
|
|
aa201c3be2 | ||
|
|
6f3f5b9623 | ||
|
|
4106524710 | ||
|
|
be37d4cffe | ||
|
|
4deb7387aa | ||
|
|
6129bee98c | ||
|
|
23d121e58f | ||
|
|
7676189625 | ||
|
|
798c4ae28d | ||
|
|
eeb6ad9172 | ||
|
|
92e8963727 | ||
|
|
d54100cbc4 | ||
|
|
e559b1cf11 | ||
|
|
631b2a35f7 | ||
|
|
ad5957b539 | ||
|
|
e2bea2c151 | ||
|
|
181eff22ef | ||
|
|
a20f1733f1 | ||
|
|
0f4d599308 | ||
|
|
9acf3bde84 | ||
|
|
804b2bfe7e | ||
|
|
4c675e814c | ||
|
|
1509c37cd9 | ||
|
|
24ba4a0b70 | ||
|
|
7a7ef8d7bc | ||
|
|
be9a72701e | ||
|
|
c5de8d9841 | ||
|
|
671b813e6c | ||
|
|
b29a7bbbe3 | ||
|
|
d4c3a67249 | ||
|
|
4be027bb35 | ||
|
|
39e44e0606 | ||
|
|
8c30c6183d | ||
|
|
9e7f331c49 | ||
|
|
bb856c96a1 |
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.7
|
||||
current_version = v0.1.9
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
|
||||
@@ -12,6 +12,6 @@ __pycache__/
|
||||
|
||||
# Ignore Git files
|
||||
.git
|
||||
cookies*
|
||||
*cookies*
|
||||
set_env.sh
|
||||
jellyplist.code-workspace
|
||||
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
@@ -1,9 +1,12 @@
|
||||
name: Build and Release on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to build the Docker image from'
|
||||
required: true
|
||||
default: 'main'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
@@ -73,5 +76,7 @@ jobs:
|
||||
name: Release ${{ env.VERSION }}
|
||||
body: |
|
||||
${{ env.CHANGELOG_CONTENT }}
|
||||
generate_release_notes: true
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
42
.github/workflows/manual-build.yml
vendored
42
.github/workflows/manual-build.yml
vendored
@@ -18,12 +18,6 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: Extract Version
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(python3 -c "import version; print(f'dev-{version.__version__}')")
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
# Extract branch name and latest commit SHA
|
||||
- name: Extract branch name and commit SHA
|
||||
id: branch_info
|
||||
@@ -35,6 +29,23 @@ jobs:
|
||||
- name: Create DEV_BUILD file
|
||||
run: |
|
||||
echo "${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}" > DEV_BUILD
|
||||
- name: Extract Version
|
||||
id: extract_version
|
||||
run: |
|
||||
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
|
||||
@@ -56,7 +67,20 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
ghcr.io/${{ github.repository }}:dev
|
||||
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
|
||||
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 }}-${{ 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
1
.gitignore
vendored
@@ -79,3 +79,4 @@ set_env.sh
|
||||
notes.md
|
||||
DEV_BUILD
|
||||
payload.json
|
||||
settings.yaml
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -3,7 +3,8 @@ import re
|
||||
from markupsafe import Markup
|
||||
|
||||
from app.classes import AudioProfile
|
||||
from app import app
|
||||
from app import app, functions, read_dev_build_file
|
||||
from .version import __version__
|
||||
|
||||
filters = {}
|
||||
|
||||
@@ -55,6 +56,37 @@ def audioprofile(text: str, path: str) -> Markup:
|
||||
)
|
||||
return Markup(audio_profile_html)
|
||||
|
||||
@template_filter('version_check')
|
||||
def version_check(version: str) -> Markup:
|
||||
version = f"{__version__}{read_dev_build_file()}"
|
||||
# if version contains a dash and the text after the dash is LOCAL, return version as a blue badge
|
||||
if app.config['CHECK_FOR_UPDATES']:
|
||||
if '-' in version and version.split('-')[1] == 'LOCAL':
|
||||
return Markup(f"<span class='badge rounded-pill bg-primary'>{version}</span>")
|
||||
# else if the version string contains a dash and the text after the dash is not LOCAL, check whether it contains another dash (like in e.g. v0.1.7-dev-89a1bc2) and split both parts
|
||||
elif '-' in version and version.split('-')[1] != 'LOCAL' :
|
||||
branch, commit_sha = version.split('-')[1], version.split('-')[2]
|
||||
nra,url = functions.get_latest_dev_releases(branch_name = branch, commit_sha = commit_sha)
|
||||
if nra:
|
||||
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update for the {branch} branch is available.'>{version}</span></a>")
|
||||
else:
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-secondary'>{version}</span>")
|
||||
else:
|
||||
nra,url = functions.get_latest_release(version)
|
||||
if nra:
|
||||
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update is available.'>{version}</span></a>")
|
||||
|
||||
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-primary'>{version}</span>")
|
||||
else:
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-info'>{version}</span>")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@template_filter('jellyfin_link')
|
||||
def jellyfin_link(jellyfin_id: str) -> Markup:
|
||||
|
||||
@@ -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)
|
||||
@@ -206,4 +222,46 @@ def get_longest_substring(input_string):
|
||||
pattern = "[" + re.escape("".join(special_chars)) + "]"
|
||||
substrings = re.split(pattern, input_string)
|
||||
longest_substring = max(substrings, key=len, default="")
|
||||
return longest_substring
|
||||
return longest_substring
|
||||
|
||||
@cache.memoize(timeout=3600*2)
|
||||
def get_latest_dev_releases(branch_name :str, commit_sha : str):
|
||||
try:
|
||||
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
latest_release = None
|
||||
for release in data:
|
||||
if branch_name in release['tag_name']:
|
||||
if latest_release is None or release['published_at'] > latest_release['published_at']:
|
||||
latest_release = release
|
||||
|
||||
if latest_release:
|
||||
response = requests.get(f'https://api.github.com/repos/kamilkosek/jellyplist/git/ref/tags/{latest_release["tag_name"]}')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if commit_sha != data['object']['sha'][:7]:
|
||||
return True, latest_release['html_url']
|
||||
|
||||
|
||||
return False, ''
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return False, ''
|
||||
|
||||
@cache.memoize(timeout=3600*2)
|
||||
def get_latest_release(tag_name :str):
|
||||
try:
|
||||
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases/latest')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data['tag_name'] != tag_name:
|
||||
return True, data['html_url']
|
||||
return False, ''
|
||||
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}")
|
||||
@@ -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())
|
||||
@@ -58,9 +58,12 @@ class Track(db.Model):
|
||||
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
|
||||
download_status = db.Column(db.String(2048), nullable=True)
|
||||
provider_id = db.Column(db.String(20))
|
||||
|
||||
|
||||
# Many-to-Many relationship with Playlists
|
||||
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
|
||||
|
||||
lidarr_processed = db.Column(db.Boolean(), default=False)
|
||||
quality_score = db.Column(db.Float(), default=0)
|
||||
def __repr__(self):
|
||||
return f'<Track {self.name}:{self.provider_track_id}>'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
112
app/tasks.py
112
app/tasks.py
@@ -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,17 +128,51 @@ 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:
|
||||
app.logger.info(f"Processing track: {track.name} [{track.provider_track_id}]")
|
||||
|
||||
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
|
||||
'current': processed_tracks,
|
||||
'total': total_tracks,
|
||||
'percent': (processed_tracks / total_tracks) * 100 if processed_tracks > 0 else 0,
|
||||
'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:
|
||||
@@ -181,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
|
||||
|
||||
|
||||
|
||||
@@ -206,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:
|
||||
@@ -229,7 +271,7 @@ def download_missing_tracks(self):
|
||||
progress = (processed_tracks / total_tracks) * 100
|
||||
db.session.commit()
|
||||
|
||||
self.update_state(state='PROGRESS', meta={
|
||||
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
|
||||
'current': processed_tracks,
|
||||
'total': total_tracks,
|
||||
'percent': progress,
|
||||
@@ -243,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']:
|
||||
@@ -355,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:
|
||||
@@ -370,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)
|
||||
@@ -388,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']:
|
||||
@@ -397,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
|
||||
@@ -410,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:
|
||||
@@ -497,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)
|
||||
|
||||
@@ -548,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
|
||||
|
||||
|
||||
@@ -558,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)}")
|
||||
@@ -582,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')
|
||||
@@ -614,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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.7"
|
||||
__version__ = "v0.1.9"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Whats up in Jellyplist 0.1.6?
|
||||
# Whats up in Jellyplist 0.1.7?
|
||||
### Major overhaul
|
||||
I´ve been working the past week to make this project work again, after [Spotify announced to deprecate](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) the playlist discover API´s , which were a crucial part of this project.
|
||||
I also took this opportunity at the same time to do a major overhaul, on how Jellyplist gathers data from a music provider. Music provider API implementations must now implement defined abstract classes to work with Jellyplist, think of it like _plugins_. Jellyplist now, in theory, can gather data from any music provider - just the _plugins_ must be written. It also doesn´t matter, if it have 1,2 or 10 Music Providers to playlists. So stay tuned for more to come.
|
||||
@@ -50,6 +50,9 @@ MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where
|
||||
# Must be the same value as your music library in jellyfin
|
||||
```
|
||||
|
||||
### ⚠️ Breaking change
|
||||
As some table columns has been renamed, make sure to wipe your existing Jellyplist pgdata. Sorry for the inconvenience.
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- UI/UX: The index page now has content. From there you can directly drop a playlist link
|
||||
- UI/UX: The Search bar now works with the new API implementation
|
||||
|
||||
15
changelogs/0.1.8.md
Normal file
15
changelogs/0.1.8.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Whats up in Jellyplist 0.1.8?
|
||||
Not much this time, just some small fixes and one enhancement.
|
||||
|
||||
### 🆕Jellyplist now checks for updates
|
||||
Jellyplist now checks the GitHub releases for new version.
|
||||
If a new version is available, you will notice the small badge on the lower left will pulsate slighty, so you don´t miss any new release :smile:
|
||||
|
||||
If you don´t like that Jellyplist is doing this, you can opt out by setting this env var in your `.env` file
|
||||
```bash
|
||||
CHECK_FOR_UPDATES = false
|
||||
```
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- Fix for #30 , where the output path for spotDL wasn´t created correctly
|
||||
|
||||
132
changelogs/v0.1.9.md
Normal file
132
changelogs/v0.1.9.md
Normal 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
|
||||
|
||||
11
config.py
11
config.py
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
class Config:
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
@@ -32,7 +31,10 @@ class Config:
|
||||
LIDARR_URL = os.getenv('LIDARR_URL','')
|
||||
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',
|
||||
@@ -41,7 +43,10 @@ class Config:
|
||||
'threads': 12
|
||||
}
|
||||
if os.getenv('MUSIC_STORAGE_BASE_PATH'):
|
||||
SPOTDL_CONFIG['output_file'] = 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
|
||||
def validate_env_vars(cls):
|
||||
|
||||
@@ -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.
|
||||
@@ -622,4 +663,4 @@ class JellyfinClient:
|
||||
|
||||
similarity = (1 - best_score) * 100 # Convert to percentage
|
||||
|
||||
return similarity, best_offset
|
||||
return similarity, best_offset
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
48
readme.md
48
readme.md
@@ -26,6 +26,7 @@ The easiest way to start is by using docker and compose.
|
||||
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
|
||||
4. Prepare a `.env` File
|
||||
```
|
||||
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
|
||||
POSTGRES_USER = jellyplist
|
||||
POSTGRES_PASSWORD = jellyplist
|
||||
SECRET_KEY = Keykeykesykykesky # Secret key for session management
|
||||
@@ -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`
|
||||
|
||||
@@ -16,4 +16,5 @@ Unidecode==1.3.8
|
||||
psycopg2-binary
|
||||
eventlet
|
||||
pydub
|
||||
fuzzywuzzy
|
||||
fuzzywuzzy
|
||||
pyyaml
|
||||
@@ -3,16 +3,18 @@ body {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #1a1d21;
|
||||
background-color: #1a1d21;
|
||||
height: 100vh;
|
||||
padding-top: 20px;
|
||||
padding-left: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background-color: #1a1d21;
|
||||
background-color: #1a1d21;
|
||||
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
color: white;
|
||||
padding-left: 15px;
|
||||
@@ -50,47 +52,51 @@ body {
|
||||
width: 140px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.logo img{
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1600px) {
|
||||
.modal-dialog {
|
||||
max-width: 90%; /* New width for default modal */
|
||||
.modal-xl {
|
||||
max-width: 90%;
|
||||
/* New width for default modal */
|
||||
}
|
||||
}
|
||||
.searchbar{
|
||||
|
||||
.searchbar {
|
||||
margin-bottom: auto;
|
||||
margin-top: auto;
|
||||
height: 60px;
|
||||
background-color: #353b48;
|
||||
border-radius: 30px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.search_input{
|
||||
.search_input {
|
||||
color: white;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: none;
|
||||
width: 450px;
|
||||
caret-color:transparent;
|
||||
caret-color: transparent;
|
||||
line-height: 40px;
|
||||
transition: width 0.4s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.searchbar:hover > .search_input{
|
||||
.searchbar:hover>.search_input {
|
||||
/* padding: 0 10px; */
|
||||
width: 450px;
|
||||
caret-color:red;
|
||||
caret-color: red;
|
||||
/* transition: width 0.4s linear; */
|
||||
}
|
||||
}
|
||||
|
||||
.searchbar:hover > .search_icon{
|
||||
.searchbar:hover>.search_icon {
|
||||
background: white;
|
||||
color: #e74c3c;
|
||||
}
|
||||
}
|
||||
|
||||
.search_icon{
|
||||
.search_icon {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
float: right;
|
||||
@@ -98,6 +104,24 @@ body {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
color:white;
|
||||
text-decoration:none;
|
||||
}
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-pulsing {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
35
supervisord.conf
Normal file
35
supervisord.conf
Normal 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
|
||||
@@ -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>
|
||||
|
||||
163
templates/admin/logview.html
Normal file
163
templates/admin/logview.html
Normal 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 %}
|
||||
20
templates/admin/settings.html
Normal file
20
templates/admin/settings.html
Normal 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 %}
|
||||
@@ -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">
|
||||
@@ -116,7 +115,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<span class="fixed-bottom m-3">{{version}}</span>
|
||||
<span class="fixed-bottom m-3 ms-5">{{version | version_check}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main content with toggle button for mobile sidebar -->
|
||||
@@ -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")
|
||||
|
||||
@@ -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%}
|
||||
{% 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 %}
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.7"
|
||||
__version__ = "v0.1.9"
|
||||
|
||||
Reference in New Issue
Block a user