34 Commits
0.1.5 ... 0.1.6

Author SHA1 Message Date
Kamil Kosek
6ec7e223ce Update main.yml 2024-11-26 16:53:14 +01:00
Kamil Kosek
2a5d1dd425 Merge pull request #19 from kamilkosek/dev
Merge Dev into 0.1.6
2024-11-26 16:46:32 +01:00
Kamil
4f82ba6aab updated github workflow 2024-11-26 15:42:34 +00:00
Kamil
b64d9bf8fc Bump version: 0.1.5 → 0.1.6 2024-11-26 15:40:58 +00:00
Kamil
78d96c2ccc changelog and readme update 2024-11-26 15:40:56 +00:00
Kamil
a436a0ad91 allow manual track relinking
fixes #13
2024-11-26 15:23:08 +00:00
Kamil
0ca91b7d7b Added Progressbar for task status 2024-11-26 15:14:30 +00:00
Kamil
af662df434 - Added unlock_key endpoint
- Added skip_endpoints so the alert part isnt rendered on task_status query
2024-11-26 15:14:06 +00:00
Kamil
7e24016788 Fix: Increment processed_tracks when best_match is found during download task 2024-11-26 15:11:13 +00:00
Kamil
16e1a8a58d Unblock redis locks 2024-11-26 15:09:29 +00:00
Kamil
0fe45483dc small fixes 2024-11-26 13:23:09 +00:00
Kamil
b010c8950e tasks rework:
- more verbose logging
- better handling of already downloaded files
- perform upgrades if file with better quality is found in jellyfin
2024-11-25 17:39:22 +00:00
Kamil
bdab83e464 UI update to be able to wipe playlist 2024-11-25 17:20:30 +00:00
Kamil
f0bffe92ae updated jellyfin api client:
- more verbose debug logs
2024-11-25 17:20:07 +00:00
Kamil
fce13015ea - add_playlist() now adds the whole playlist instead of first 100 items
- added wipe_playlist endpoint to completely remove playlist from jellyplist and jellyfin
2024-11-25 17:18:16 +00:00
Kamil
b49e7cc05c added get_longest_substring() to perform better search on jellyfin api 2024-11-25 17:17:07 +00:00
Kamil
e497b33ccd change update_all_playlists_track_status-schedule to every 5 minutes 2024-11-25 17:16:37 +00:00
Kamil
543a1359f2 added FIND_BEST_MATCH_USE_FFPROBE env var 2024-11-25 17:16:16 +00:00
Kamil
8392e37592 Added: JELLYPLIST_DB_PORT env var
Fixes #8
2024-11-25 09:11:06 +00:00
Kamil
0401e6481e 😣 2024-11-24 22:45:02 +00:00
Kamil
996daf700a some logging adjustments 2024-11-24 22:44:05 +00:00
Kamil
4d9f9462f5 fix copy pasta errors due to tiredness 2024-11-24 22:10:29 +00:00
Kamil
a84ae01e55 indicate dev build 2024-11-24 22:10:03 +00:00
Kamil
7de92f01ec remove unnecessary requirement 2024-11-24 21:47:30 +00:00
Kamil
3c006ed031 fix error 2024-11-24 21:45:49 +00:00
Kamil
3e593bf475 updated logging on jellyfin api client 2024-11-24 21:39:40 +00:00
Kamil
3f5318a17b Updated build yaml for dev container 2024-11-24 21:15:04 +00:00
Kamil
e5416ed800 Added LOG_LEVEL env var, to make it configurable through .env 2024-11-24 21:14:47 +00:00
Kamil
961e175a7d Missed some timeout parameters 2024-11-24 21:07:34 +00:00
Kamil
810febbec2 Added JELLYFIN_REQUEST_TIMEOUT env var 2024-11-24 21:05:11 +00:00
Kamil
82390455d0 Remove cookies.txt requirement
fixes #5
2024-11-24 16:23:54 +00:00
Kamil
e3d37576ed Added REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK
resolves #10
2024-11-24 16:17:30 +00:00
Kamil
6fe5c0fae7 Updated readme 2024-11-24 16:14:29 +00:00
Kamil
68ab47c443 Updated readme 2024-11-24 16:14:21 +00:00
21 changed files with 655 additions and 192 deletions

View File

@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.5 current_version = 0.1.6
commit = True commit = True
tag = True tag = True

View File

@@ -42,12 +42,35 @@ jobs:
ghcr.io/${{ github.repository }}:${{ env.VERSION }} ghcr.io/${{ github.repository }}:${{ env.VERSION }}
ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:latest
# Read changelog for the current version
- 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
# Generate auto-generated release notes
#- name: Generate Auto Release Notes
# id: release_notes
# run: |
# auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --dry-run --json body --jq .body)
# echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV
# echo "$auto_notes" >> $GITHUB_ENV
# echo "EOF" >> $GITHUB_ENV
# Create a release on GitHub # Create a release on GitHub
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.VERSION }} tag_name: ${{ env.VERSION }}
name: Release ${{ env.VERSION }} name: Release ${{ env.VERSION }}
generate_release_notes: true body: |
${{ env.CHANGELOG_CONTENT }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,6 +18,23 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
ref: ${{ github.event.inputs.branch }} 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
run: |
echo "BRANCH_NAME=${{ github.event.inputs.branch }}" >> $GITHUB_ENV
echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Create a file indicating this is a dev build
- name: Create DEV_BUILD file
run: |
echo "${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}" > DEV_BUILD
# Set up Docker # Set up Docker
- name: Set up Docker Buildx - name: Set up Docker Buildx

View File

@@ -70,7 +70,7 @@ def make_celery(app):
}, },
'update_all_playlists_track_status-schedule': { 'update_all_playlists_track_status-schedule': {
'task': 'app.tasks.update_all_playlists_track_status', 'task': 'app.tasks.update_all_playlists_track_status',
'schedule': crontab(minute='*/2'), 'schedule': crontab(minute='*/5'),
}, },
'update_jellyfin_id_for_downloaded_tracks-schedule': { 'update_jellyfin_id_for_downloaded_tracks-schedule': {
@@ -83,8 +83,6 @@ def make_celery(app):
celery.conf.timezone = 'UTC' celery.conf.timezone = 'UTC'
return celery return celery
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s : %(message)s')
# Why this ? Because we are using the same admin login for web, worker and beat we need to distinguish the device_id´s # Why this ? Because we are using the same admin login for web, worker and beat we need to distinguish the device_id´s
device_id = f'JellyPlist_{'_'.join(sys.argv)}' device_id = f'JellyPlist_{'_'.join(sys.argv)}'
@@ -102,8 +100,17 @@ app = Flask(__name__, template_folder="../templates", static_folder='../static')
# # app.logger.addHandler(handler) # # app.logger.addHandler(handler)
# app.logger.addHandler(stream_handler) # app.logger.addHandler(stream_handler)
app.config.from_object(Config) app.config.from_object(Config)
app.logger.setLevel(logging.DEBUG) 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"
logging.basicConfig(format=FORMAT)
Config.validate_env_vars() Config.validate_env_vars()
cache = Cache(app) cache = Cache(app)
@@ -115,18 +122,19 @@ sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(
client_secret=app.config['SPOTIFY_CLIENT_SECRET'] client_secret=app.config['SPOTIFY_CLIENT_SECRET']
)) ))
app.logger.info(f"setting up jellyfin client") app.logger.info(f"setting up jellyfin client, BaseUrl = {app.config['JELLYFIN_SERVER_URL']}, timeout = {app.config['JELLYFIN_REQUEST_TIMEOUT']}")
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL']) jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'], app.config['JELLYFIN_REQUEST_TIMEOUT'])
jellyfin_admin_token, jellyfin_admin_id, jellyfin_admin_name, jellyfin_admin_is_admin = jellyfin.login_with_password( jellyfin_admin_token, jellyfin_admin_id, jellyfin_admin_name, jellyfin_admin_is_admin = jellyfin.login_with_password(
app.config['JELLYFIN_ADMIN_USER'], app.config['JELLYFIN_ADMIN_USER'],
app.config['JELLYFIN_ADMIN_PASSWORD'], device_id= device_id app.config['JELLYFIN_ADMIN_PASSWORD'], device_id= device_id
) )
# SQLAlchemy and Migrate setup # SQLAlchemy and Migrate setup
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}") app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}:{app.config['JELLYPLIST_DB_PORT']}")
check_db_connection(f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}/jellyplist',retries=5,delay=2) db_uri = f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}:{app.config['JELLYPLIST_DB_PORT']}/jellyplist'
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist' check_db_connection(db_uri=db_uri,retries=5,delay=2)
app.config['SQLALCHEMY_DATABASE_URI'] = db_uri
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app) db = SQLAlchemy(app)
app.logger.info(f"applying db migrations") app.logger.info(f"applying db migrations")
@@ -138,13 +146,20 @@ app.config.update(
result_backend=app.config['REDIS_URL'] result_backend=app.config['REDIS_URL']
) )
def read_dev_build_file(file_path="/jellyplist/DEV_BUILD"):
if os.path.exists(file_path):
with open(file_path, "r") as file:
content = file.read().strip()
return f"-{content}"
else:
return ''
app.logger.info(f"initializing celery") app.logger.info(f"initializing celery")
celery = make_celery(app) celery = make_celery(app)
socketio = SocketIO(app, message_queue=app.config['REDIS_URL'], async_mode='eventlet') socketio = SocketIO(app, message_queue=app.config['REDIS_URL'], async_mode='eventlet')
celery.set_default() celery.set_default()
app.logger.info(f'Jellyplist {__version__} started') app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started')
app.logger.debug(f"Debug logging active")
from app import routes from app import routes
from app import jellyfin_routes, tasks from app import jellyfin_routes, tasks
if "worker" in sys.argv: if "worker" in sys.argv:

View File

@@ -5,6 +5,7 @@ from functools import wraps
from celery.result import AsyncResult from celery.result import AsyncResult
from app.tasks import download_missing_tracks,check_for_playlist_updates, update_all_playlists_track_status, update_jellyfin_id_for_downloaded_tracks from app.tasks import download_missing_tracks,check_for_playlist_updates, update_all_playlists_track_status, update_jellyfin_id_for_downloaded_tracks
from jellyfin.objects import PlaylistMetadata from jellyfin.objects import PlaylistMetadata
import re
TASK_STATUS = { TASK_STATUS = {
'update_all_playlists_track_status': None, 'update_all_playlists_track_status': None,
@@ -12,6 +13,14 @@ TASK_STATUS = {
'check_for_playlist_updates': None, 'check_for_playlist_updates': None,
'update_jellyfin_id_for_downloaded_tracks' : None 'update_jellyfin_id_for_downloaded_tracks' : None
} }
LOCK_KEYS = [
'update_all_playlists_track_status_lock',
'download_missing_tracks_lock',
'check_for_playlist_updates_lock',
'update_jellyfin_id_for_downloaded_tracks_lock' ,
'full_update_jellyfin_ids'
]
def manage_task(task_name): def manage_task(task_name):
task_id = TASK_STATUS.get(task_name) task_id = TASK_STATUS.get(task_name)
@@ -36,6 +45,9 @@ def manage_task(task_name):
def prepPlaylistData(data): def prepPlaylistData(data):
playlists = [] playlists = []
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first() jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not jellyfin_user:
app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again")
return None
if not data.get('playlists'): if not data.get('playlists'):
data['playlists']= {} data['playlists']= {}
@@ -293,3 +305,11 @@ def _get_logged_in_user():
def _get_admin_id(): def _get_admin_id():
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id #return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id
return jellyfin_admin_id return jellyfin_admin_id
def get_longest_substring(input_string):
special_chars = ["'", "", "", "", "`", "´", ""]
pattern = "[" + re.escape("".join(special_chars)) + "]"
substrings = re.split(pattern, input_string)
longest_substring = max(substrings, key=len, default="")
return longest_substring

View File

@@ -1,7 +1,7 @@
import time import time
from flask import Flask, jsonify, render_template, request, redirect, url_for, session, flash from flask import Flask, jsonify, render_template, request, redirect, url_for, session, flash
from sqlalchemy import insert from sqlalchemy import insert
from app import app, db, jellyfin, functions, device_id from app import app, db, jellyfin, functions, device_id,sp
from app.models import Playlist,Track, playlist_tracks from app.models import Playlist,Track, playlist_tracks
@@ -76,9 +76,18 @@ def add_playlist():
user = functions._get_logged_in_user() user = functions._get_logged_in_user()
playlist.tracks_available = 0 playlist.tracks_available = 0
# Add tracks to the playlist with track order spotify_tracks = {}
for idx, track_data in enumerate(playlist_data['tracks']['items']): offset = 0
track_info = track_data['track'] while True:
playlist_items = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100)
items = playlist_items['items']
spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']})
if len(items) < 100: # No more tracks to fetch
break
offset += 100 # Move to the next batch
for idx, track_data in spotify_tracks.items():
track_info = track_data
if not track_info: if not track_info:
continue continue
track = Track.query.filter_by(spotify_track_id=track_info['id']).first() track = Track.query.filter_by(spotify_track_id=track_info['id']).first()
@@ -127,6 +136,7 @@ def add_playlist():
except Exception as e: except Exception as e:
flash(str(e)) flash(str(e))
return ''
@app.route('/delete_playlist/<playlist_id>', methods=['DELETE']) @app.route('/delete_playlist/<playlist_id>', methods=['DELETE'])
@@ -154,7 +164,33 @@ def delete_playlist(playlist_id):
flash(f'Failed to remove item: {str(e)}') flash(f'Failed to remove item: {str(e)}')
@app.route('/wipe_playlist/<playlist_id>', methods=['DELETE'])
@functions.jellyfin_admin_required
def wipe_playlist(playlist_id):
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
name = ""
id = ""
jf_id = ""
try:
jellyfin.remove_item(session_token=functions._get_api_token(), playlist_id=playlist_id)
except Exception as e:
flash(f"Jellyfin API Error: {str(e)}")
if playlist:
# Delete the playlist
name = playlist.name
id = playlist.spotify_playlist_id
jf_id = playlist.jellyfin_id
db.session.delete(playlist)
db.session.commit()
flash('Playlist Deleted', category='info')
item = {
"name" : name,
"id" : id,
"can_add":True,
"can_remove":False,
"jellyfin_id" : jf_id
}
return render_template('partials/_add_remove_button.html',item= item)
@functions.jellyfin_login_required @functions.jellyfin_login_required
@app.route('/get_jellyfin_stream/<string:jellyfin_id>') @app.route('/get_jellyfin_stream/<string:jellyfin_id>')

View File

@@ -1,5 +1,5 @@
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash
from app import app, db, functions, sp, jellyfin, celery, jellyfin_admin_token, jellyfin_admin_id,device_id, cache from app import app, db, functions, sp, jellyfin, celery, jellyfin_admin_token, jellyfin_admin_id,device_id, cache, read_dev_build_file, tasks
from app.models import JellyfinUser,Playlist,Track from app.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult from celery.result import AsyncResult
from .version import __version__ from .version import __version__
@@ -7,12 +7,16 @@ from .version import __version__
@app.context_processor @app.context_processor
def add_context(): def add_context():
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all()) unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
version = f"v{__version__}" version = f"v{__version__}{read_dev_build_file()}"
return dict(unlinked_track_count = unlinked_track_count, version = version) return dict(unlinked_track_count = unlinked_track_count, version = version)
# this feels wrong
skip_endpoints = ['task_status']
@app.after_request @app.after_request
def render_messages(response: Response) -> Response: def render_messages(response: Response) -> Response:
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
if request.endpoint not in skip_endpoints:
messages = render_template("partials/alerts.jinja2") messages = render_template("partials/alerts.jinja2")
response.headers['HX-Trigger'] = 'showToastMessages' response.headers['HX-Trigger'] = 'showToastMessages'
response.data = response.data + messages.encode("utf-8") response.data = response.data + messages.encode("utf-8")
@@ -31,7 +35,7 @@ def task_manager():
else: else:
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}} statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
return render_template('admin/tasks.html', tasks=statuses) return render_template('admin/tasks.html', tasks=statuses,lock_keys = functions.LOCK_KEYS)
@app.route('/admin') @app.route('/admin')
@app.route('/admin/link_issues') @app.route('/admin/link_issues')
@@ -262,6 +266,17 @@ def associate_track():
return '' return ''
@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)
flash(f'Lock {key_name} released', category='success')
return ''
@app.route('/test') @app.route('/test')
def test(): def test():
return '' return ''

View File

@@ -1,4 +1,5 @@
from datetime import datetime,timezone from datetime import datetime,timezone
import logging
import subprocess import subprocess
from sqlalchemy import insert from sqlalchemy import insert
@@ -7,7 +8,7 @@ from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token,
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
import os import os
import redis import redis
from celery import current_task from celery import current_task,signals
import asyncio import asyncio
import requests import requests
@@ -17,6 +18,16 @@ def acquire_lock(lock_name, expiration=60):
def release_lock(lock_name): def release_lock(lock_name):
redis_client.delete(lock_name) redis_client.delete(lock_name)
def prepare_logger():
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(message)s"
logging.basicConfig(format=FORMAT)
@signals.celeryd_init.connect
def setup_log_format(sender, conf, **kwargs):
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s"
conf.worker_log_format = FORMAT.strip().format(sender)
conf.worker_task_log_format = FORMAT.format(sender)
@celery.task(bind=True) @celery.task(bind=True)
def update_all_playlists_track_status(self): def update_all_playlists_track_status(self):
@@ -37,11 +48,13 @@ def update_all_playlists_track_status(self):
for playlist in playlists: for playlist in playlists:
total_tracks = 0 total_tracks = 0
available_tracks = 0 available_tracks = 0
app.logger.debug(f"Current Playlist: {playlist.name} [{playlist.id}:{playlist.spotify_playlist_id}]" )
for track in playlist.tracks: for track in playlist.tracks:
total_tracks += 1 total_tracks += 1
if track.filesystem_path and os.path.exists(track.filesystem_path): if track.filesystem_path and os.path.exists(track.filesystem_path):
available_tracks += 1 available_tracks += 1
track.downloaded = True
else: else:
track.downloaded = False track.downloaded = False
track.filesystem_path = None track.filesystem_path = None
@@ -92,23 +105,29 @@ def download_missing_tracks(self):
processed_tracks = 0 processed_tracks = 0
failed_downloads = 0 failed_downloads = 0
for track in undownloaded_tracks: for track in undownloaded_tracks:
app.logger.info(f"Processing track: {track.name} ({track.spotify_track_id})") app.logger.info(f"Processing track: {track.name} [{track.spotify_track_id}]")
# Check if the track already exists in the output directory # Check if the track already exists in the output directory
file_path = f"{output_dir.replace('{track-id}', track.spotify_track_id)}.mp3" file_path = f"{output_dir.replace('{track-id}', track.spotify_track_id)}.mp3"
# region search before download
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 search_before_download is enabled, perform matching
if search_before_download: if search_before_download:
app.logger.info(f"Searching for track in Jellyfin: {track.name}") app.logger.info(f"Searching for track in Jellyfin: {track.name}")
# Retrieve the Spotify track and preview URL
spotify_track = functions.get_cached_spotify_track(track.spotify_track_id) spotify_track = functions.get_cached_spotify_track(track.spotify_track_id)
# at first try to find the track without fingerprinting it
best_match = find_best_match_from_jellyfin(track)
if best_match:
track.downloaded = True
if track.jellyfin_id != best_match['Id']:
track.jellyfin_id = best_match['Id']
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.spotify_track_id})")
if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path']
db.session.commit()
processed_tracks+=1
continue
# region search with fingerprinting
preview_url = spotify_track.get('preview_url') preview_url = spotify_track.get('preview_url')
if not preview_url: if not preview_url:
app.logger.error(f"Preview URL not found for track {track.name}.") app.logger.error(f"Preview URL not found for track {track.name}.")
@@ -133,6 +152,18 @@ def download_missing_tracks(self):
continue continue
else: else:
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.") app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
#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
# Attempt to download the track using spotdl # Attempt to download the track using spotdl
try: try:
@@ -142,10 +173,13 @@ def download_missing_tracks(self):
command = [ command = [
"spotdl", "download", s_url, "spotdl", "download", s_url,
"--output", output_dir, "--output", output_dir,
"--cookie-file", cookie_file,
"--client-id", client_id, "--client-id", client_id,
"--client-secret", client_secret "--client-secret", client_secret
] ]
if os.path.exists(cookie_file):
app.logger.debug(f"Found {cookie_file}, using it for spotDL")
command.append("--cookie-file")
command.append(cookie_file)
result = subprocess.run(command, capture_output=True, text=True, timeout=90) result = subprocess.run(command, capture_output=True, text=True, timeout=90)
if result.returncode == 0 and os.path.exists(file_path): if result.returncode == 0 and os.path.exists(file_path):
@@ -154,6 +188,10 @@ def download_missing_tracks(self):
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.") app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
else: else:
app.logger.error(f"Download failed for track {track.name}.") app.logger.error(f"Download failed for track {track.name}.")
if result.stdout:
app.logger.error(f"\t stdout: {result.stdout}")
if result.stderr:
app.logger.error(f"\t stderr: {result.stderr} ")
failed_downloads += 1 failed_downloads += 1
track.download_status = result.stdout[:2048] track.download_status = result.stdout[:2048]
except Exception as e: except Exception as e:
@@ -181,6 +219,11 @@ def download_missing_tracks(self):
} }
finally: finally:
release_lock(lock_key) release_lock(lock_key)
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
libraries = jellyfin.get_libraries(jellyfin_admin_token)
for lib in libraries:
if lib['CollectionType'] == 'music':
jellyfin.refresh_library(jellyfin_admin_token, lib['ItemId'])
else: else:
app.logger.info("Skipping task. Another instance is already running.") app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'} return {'status': 'Task skipped, another instance is running'}
@@ -205,15 +248,16 @@ def check_for_playlist_updates(self):
for playlist in playlists: for playlist in playlists:
playlist.last_updated = datetime.now( timezone.utc) playlist.last_updated = datetime.now( timezone.utc)
sp_playlist = sp.playlist(playlist.spotify_playlist_id) sp_playlist = sp.playlist(playlist.spotify_playlist_id)
full_update = True
app.logger.info(f'Checking updates for playlist: {playlist.name}, s_snapshot = {sp_playlist['snapshot_id']}') app.logger.info(f'Checking updates for playlist: {playlist.name}, s_snapshot = {sp_playlist['snapshot_id']}')
db.session.commit() db.session.commit()
if sp_playlist['snapshot_id'] == playlist.snapshot_id: if sp_playlist['snapshot_id'] == playlist.snapshot_id:
app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}') app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}')
continue full_update = False
try: try:
#region Check for updates #region Check for updates
# Fetch all playlist data from Spotify # Fetch all playlist data from Spotify
if full_update:
spotify_tracks = {} spotify_tracks = {}
offset = 0 offset = 0
playlist.snapshot_id = sp_playlist['snapshot_id'] playlist.snapshot_id = sp_playlist['snapshot_id']
@@ -306,62 +350,44 @@ def check_for_playlist_updates(self):
@celery.task(bind=True) @celery.task(bind=True)
def update_jellyfin_id_for_downloaded_tracks(self): def update_jellyfin_id_for_downloaded_tracks(self):
lock_key = "update_jellyfin_id_for_downloaded_tracks_lock" lock_key = "update_jellyfin_id_for_downloaded_tracks_lock"
full_update_key = 'full_update_jellyfin_ids'
if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
try: try:
app.logger.info("Starting Jellyfin ID update for downloaded tracks...") app.logger.info("Starting Jellyfin ID update for tracks...")
with app.app_context(): with app.app_context():
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all() downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
if 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()
else:
app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None")
total_tracks = len(downloaded_tracks) total_tracks = len(downloaded_tracks)
if not downloaded_tracks: if not downloaded_tracks:
app.logger.info("No downloaded tracks without Jellyfin ID found.") app.logger.info("No downloaded tracks without Jellyfin ID found.")
return {'status': 'No tracks to update'} return {'status': 'No tracks to update'}
app.logger.info(f"Found {total_tracks} tracks to update with Jellyfin IDs.") app.logger.info(f"Found {total_tracks} tracks to update ")
processed_tracks = 0 processed_tracks = 0
for track in downloaded_tracks: for track in downloaded_tracks:
app.logger.info(f"Fetching track details from Spotify: {track.name} ({track.spotify_track_id})")
search_results = jellyfin.search_music_tracks(jellyfin_admin_token,track.name)
spotify_track = None
try: try:
best_match = None best_match = find_best_match_from_jellyfin(track)
for result in search_results:
# if there is only one result , assume it´s the right track.
if len(search_results) == 1:
best_match = result
break
# Ensure the result is structured as expected
jellyfin_track_name = result.get('Name', '').lower()
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
jellyfin_path = result.get('Path','')
if jellyfin_path == track.filesystem_path:
best_match = result
break
elif not spotify_track:
try:
spotify_track = functions.get_cached_spotify_track(track.spotify_track_id)
spotify_track_name = spotify_track['name']
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
spotify_album = spotify_track['album']['name']
except Exception as e:
app.logger.error(f"Error fetching track details from Spotify for {track.name}: {str(e)}")
continue
# Compare name, artists, and album (case-insensitive comparison)
if (spotify_track_name.lower() == jellyfin_track_name and
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists) ):
best_match = result
break # Stop when a match is found
# Step 4: If a match is found, update jellyfin_id
if best_match: if best_match:
track.downloaded = True
if track.jellyfin_id != best_match['Id']:
track.jellyfin_id = best_match['Id'] track.jellyfin_id = best_match['Id']
db.session.commit()
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.spotify_track_id})") app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.spotify_track_id})")
if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path']
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.spotify_track_id})")
db.session.commit()
else: else:
app.logger.info(f"No matching track found in Jellyfin for {track.name}.") app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
spotify_track = None spotify_track = None
@@ -380,3 +406,116 @@ def update_jellyfin_id_for_downloaded_tracks(self):
else: else:
app.logger.info("Skipping task. Another instance is already running.") app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'} return {'status': 'Task skipped, another instance is running'}
def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}")
search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))
spotify_track = None
try:
best_match = None
best_quality_score = -1 # Initialize with the lowest possible score
for result in search_results:
quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE'])
if len(search_results) == 1:
app.logger.debug(f"Only 1 search_result, assuming best match: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
best_match = result
break
jellyfin_path = result.get('Path', '')
# if jellyfin_path == track.filesystem_path:
# app.logger.debug(f"Best match found through equal file-system paths: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
# best_match = result
# break
if not spotify_track:
try:
spotify_track = functions.get_cached_spotify_track(track.spotify_track_id)
spotify_track_name = spotify_track['name']
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
except Exception as e:
app.logger.error(f"Error fetching track details from Spotify for {track.name}: {str(e)}")
continue
jellyfin_track_name = result.get('Name', '').lower()
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
if (spotify_track_name.lower() == jellyfin_track_name and
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)):
app.logger.debug(f"Quality score for track {result['Name']}: {quality_score} [{result['Path']}]")
if quality_score > best_quality_score:
best_match = result
best_quality_score = quality_score
return best_match
except Exception as e:
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
return None
def compute_quality_score(result, use_ffprobe=False) -> float:
"""
Compute a quality score for a track based on its metadata or detailed analysis using ffprobe.
"""
score = 0
container = result.get('Container', '').lower()
if container == 'flac':
score += 100
elif container == 'wav':
score += 50
elif container == 'mp3':
score += 10
elif container == 'aac':
score += 5
if result.get('HasLyrics'):
score += 10
runtime_ticks = result.get('RunTimeTicks', 0)
score += runtime_ticks / 1e6
if use_ffprobe:
path = result.get('Path')
if path:
ffprobe_score = analyze_audio_quality_with_ffprobe(path)
score += ffprobe_score
else:
app.logger.warning(f"No valid file path for track {result.get('Name')} - Skipping ffprobe analysis.")
return score
def analyze_audio_quality_with_ffprobe(filepath):
"""
Use ffprobe to extract quality attributes from an audio file and compute a score.
"""
try:
# ffprobe command to extract bitrate, sample rate, and channel count
cmd = [
'ffprobe', '-v', 'error', '-select_streams', 'a:0',
'-show_entries', 'stream=bit_rate,sample_rate,channels',
'-show_format',
'-of', 'json', filepath
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
app.logger.error(f"ffprobe error for file {filepath}: {result.stderr}")
return 0
# Parse ffprobe output
import json
data = json.loads(result.stdout)
stream = data.get('streams', [{}])[0]
bitrate = int(stream.get('bit_rate', 0)) // 1000 # Convert to kbps
if bitrate == 0:
bitrate = int(data.get('format')['bit_rate']) // 1000
sample_rate = int(stream.get('sample_rate', 0)) # Hz
channels = int(stream.get('channels', 0))
# Compute score based on extracted quality parameters
score = bitrate + (sample_rate // 1000) + (channels * 10) # Example scoring formula
return score
except Exception as e:
app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}")
return 0

View File

@@ -1 +1 @@
__version__ = "0.1.5" __version__ = "0.1.6"

60
changelogs/0.1.6.md Normal file
View File

@@ -0,0 +1,60 @@
# Whats up in Jellyplist 0.1.6?
### 🆕Better Linking (in preparation for Lidarr integration)
During the link-task `(update_jellyfin_id_for_downloaded_tracks)`, where Jellyplist tries to link a `Spotify-Track-Id` to a `Jellyfin-Track-Id` it performs now a search and tries to find a best match from the results also considering quality aspects of a file.
You can also make use of `ffprobe`, so jellyplist get´s more detailed information about the quality profile of a file.
To use `ffprobe` set the environment variable `FIND_BEST_MATCH_USE_FFPROBE` to `true` otherwise jellyplist will use quality information provided by the Jellyfin API.
Fixes #14
In the Debug logs it will look like this:
```log
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 4410.866669999999 [/storage/media/music/Bronski Beat/The Age of Reason (2017)/CD 01/Bronski Beat - The Age of Reason - 05 - Smalltown Boy.flac]
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 4100.6 [/storage/media/music/Bronski Beat/The Age of Consent (1984)/CD 01/Bronski Beat - The Age of Consent - 06 - Smalltown Boy.flac]
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 3240.48 [/storage/media/music/__jellyplist/5vmRQ3zELMLUQPo2FLQ76x.mp3]
```
**What´s the benefit?**
Once a day, the task `update_jellyfin_id_for_downloaded_tracks` will do a full update on all tracks. This way you can listen to tracks and make use of the playlists until Lidarr provides you the same track but with better audio quality.
### 🆕Added REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK
When setting the new environment variable `REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK` to `true` , jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library.
Fixes #10
### 🆕Removed cookies.txt requirement
No need to use `cookies.txt` file to download tracks via spotDL
>[!IMPORTANT]
> Not using a cookies.txt file will limit the bitrate of downloaded tracks to `128kbit/s` 📻
### 🆕Added LOG_LEVEL
Via the environment variable `LOG_LEVEL` you can control the log output now. The default python log levels are available:
- CRITICAL
- FATAL
- ERROR
- WARNING
- INFO
- DEBUG
- NOTSET
### 🆕Added the possibility for admins to release task lock´s
When a task will crash or whatsoever , the lock won´t be released and you have to wait for it to expire until you can run it manually. Now you can release it manually, in case you need it.
>[!IMPORTANT]
>You must be logged in as an admin
### 🆕Added the possibility for admins to remove playlists completely
This way the playlist will be removed from "monitoring" and also be removed from jellyfin.
>[!IMPORTANT]
>You must be logged in as an admin
### 🆕Allow manual track re-linking
In case something went wrong and you want to assign another Jellyfin track to a Spotify-Track-Id you can do it now manually.
Just go to "View Playlist Details", in the table where the tracks are listed, hold the `CTRL` Key while clicking on the Play from Jellyfin button. You will be presented with the search modal and can choose whatever track you like.
Fixex #13
### 🆕Added a badge on the lower left corner indicating the current version
### ⚒Overall improvements in logging
Changed log format and also added debug logging where (I think) it´s appropriate.
### 🐛 Bugfixes
- Fixed a bug where playlists weren´t updated until the `snapshot-id` of a playlist changed. Fixes #9
- Fixed a dependency error, which caused `chromaprint` fingerprinting to error out. Fixes #12
- Fixed a paging error, which caused that only the first 100 elements of a playlists were added

View File

@@ -3,16 +3,20 @@ import sys
class Config: class Config:
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv('SECRET_KEY')
JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL') JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL')
JELLYFIN_ADMIN_USER = os.getenv('JELLYFIN_ADMIN_USER') JELLYFIN_ADMIN_USER = os.getenv('JELLYFIN_ADMIN_USER')
JELLYFIN_ADMIN_PASSWORD = os.getenv('JELLYFIN_ADMIN_PASSWORD') JELLYFIN_ADMIN_PASSWORD = os.getenv('JELLYFIN_ADMIN_PASSWORD')
JELLYFIN_REQUEST_TIMEOUT = int(os.getenv('JELLYFIN_REQUEST_TIMEOUT','10'))
SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID') SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID')
SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET') SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET')
JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST') JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST')
JELLYPLIST_DB_PORT = int(os.getenv('JELLYPLIST_DB_PORT','5432'))
JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER') JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER')
JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD') JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD')
START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"true").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"false").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately
REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = os.getenv('REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK',"false").lower() == 'true'
CACHE_TYPE = 'redis' CACHE_TYPE = 'redis'
CACHE_REDIS_PORT = 6379 CACHE_REDIS_PORT = 6379
CACHE_REDIS_HOST = 'redis' CACHE_REDIS_HOST = 'redis'
@@ -20,15 +24,14 @@ class Config:
CACHE_DEFAULT_TIMEOUT = 3600 CACHE_DEFAULT_TIMEOUT = 3600
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0') REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true' SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true'
FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true'
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = True
# SpotDL specific configuration # SpotDL specific configuration
SPOTDL_CONFIG = { SPOTDL_CONFIG = {
'cookie_file': '/jellyplist/cookies.txt', 'cookie_file': '/jellyplist/cookies.txt',
'output': '/jellyplist_downloads/__jellyplist/{track-id}', 'output': '/jellyplist_downloads/__jellyplist/{track-id}',
'threads': 12 'threads': 12
} }
@classmethod @classmethod
def validate_env_vars(cls): def validate_env_vars(cls):
required_vars = { required_vars = {
@@ -36,6 +39,7 @@ class Config:
'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL, 'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL,
'JELLYFIN_ADMIN_USER': cls.JELLYFIN_ADMIN_USER, 'JELLYFIN_ADMIN_USER': cls.JELLYFIN_ADMIN_USER,
'JELLYFIN_ADMIN_PASSWORD': cls.JELLYFIN_ADMIN_PASSWORD, 'JELLYFIN_ADMIN_PASSWORD': cls.JELLYFIN_ADMIN_PASSWORD,
'SPOTIFY_CLIENT_ID': cls.SPOTIFY_CLIENT_ID, 'SPOTIFY_CLIENT_ID': cls.SPOTIFY_CLIENT_ID,
'SPOTIFY_CLIENT_SECRET': cls.SPOTIFY_CLIENT_SECRET, 'SPOTIFY_CLIENT_SECRET': cls.SPOTIFY_CLIENT_SECRET,
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST, 'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,

View File

@@ -5,10 +5,9 @@ import tempfile
import numpy as np import numpy as np
import requests import requests
import base64 import base64
from urllib.parse import quote
import acoustid import acoustid
import chromaprint import chromaprint
import logging
from jellyfin.objects import PlaylistMetadata from jellyfin.objects import PlaylistMetadata
def _clean_query(query): def _clean_query(query):
@@ -23,12 +22,18 @@ def _clean_query(query):
return cleaned_query return cleaned_query
class JellyfinClient: class JellyfinClient:
def __init__(self, base_url): def __init__(self, base_url, timeout = 10):
""" """
Initialize the Jellyfin client with the base URL of the server. Initialize the Jellyfin client with the base URL of the server.
:param base_url: The base URL of the Jellyfin server (e.g., 'http://localhost:8096') :param base_url: The base URL of the Jellyfin server (e.g., 'http://localhost:8096')
""" """
self.base_url = base_url self.base_url = base_url
self.timeout = timeout
self.logger = logging.getLogger(self.__class__.__name__)
self.logger.setLevel(os.getenv('LOG_LEVEL', 'INFO').upper())
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s"
logging.basicConfig(format=FORMAT)
self.logger.debug(f"Initialized Jellyfin API Client. Base = '{self.base_url}', timeout = {timeout}")
def _get_headers(self, session_token: str): def _get_headers(self, session_token: str):
""" """
@@ -46,7 +51,7 @@ class JellyfinClient:
:param password: The password of the user. :param password: The password of the user.
:return: Access token and user ID :return: Access token and user ID
""" """
login_url = f'{self.base_url}/Users/AuthenticateByName' url = f'{self.base_url}/Users/AuthenticateByName'
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Emby-Authorization': f'MediaBrowser Client="JellyPlist", Device="Web", DeviceId="{device_id}", Version="1.0"' 'X-Emby-Authorization': f'MediaBrowser Client="JellyPlist", Device="Web", DeviceId="{device_id}", Version="1.0"'
@@ -55,8 +60,9 @@ class JellyfinClient:
'Username': username, 'Username': username,
'Pw': password 'Pw': password
} }
self.logger.debug(f"Url={url}")
response = requests.post(login_url, json=data, headers=headers) response = requests.post(url, json=data, headers=headers)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
@@ -73,7 +79,7 @@ class JellyfinClient:
:param song_ids: A list of song IDs to include in the playlist. :param song_ids: A list of song IDs to include in the playlist.
:return: The newly created playlist object :return: The newly created playlist object
""" """
create_url = f'{self.base_url}/Playlists' url = f'{self.base_url}/Playlists'
data = { data = {
'Name': name, 'Name': name,
'UserId': user_id, 'UserId': user_id,
@@ -81,8 +87,10 @@ class JellyfinClient:
'Ids': ','.join(song_ids), # Join song IDs with commas 'Ids': ','.join(song_ids), # Join song IDs with commas
'IsPublic' : False 'IsPublic' : False
} }
self.logger.debug(f"Url={url}")
response = requests.post(create_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10) response = requests.post(url, json=data, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
@@ -96,12 +104,14 @@ class JellyfinClient:
:param song_ids: A list of song IDs to include in the playlist. :param song_ids: A list of song IDs to include in the playlist.
:return: The updated playlist object :return: The updated playlist object
""" """
update_url = f'{self.base_url}/Playlists/{playlist_id}/Items' url = f'{self.base_url}/Playlists/{playlist_id}/Items'
data = { data = {
'Ids': ','.join(song_ids) # Join song IDs with commas 'Ids': ','.join(song_ids) # Join song IDs with commas
} }
self.logger.debug(f"Url={url}")
response = requests.post(update_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10) response = requests.post(url, json=data, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204: # 204 No Content indicates success for updating if response.status_code == 204: # 204 No Content indicates success for updating
return {"status": "success", "message": "Playlist updated successfully"} return {"status": "success", "message": "Playlist updated successfully"}
@@ -109,11 +119,14 @@ class JellyfinClient:
raise Exception(f"Failed to update playlist: {response.content}") raise Exception(f"Failed to update playlist: {response.content}")
def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata: def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata:
playlist_metadata_url = f'{self.base_url}/Items/{playlist_id}' url = f'{self.base_url}/Items/{playlist_id}'
params = { params = {
'UserId' : user_id 'UserId' : user_id
} }
response = requests.get(playlist_metadata_url, headers=self._get_headers(session_token=session_token), timeout=10, params = params) self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout, params = params)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"Failed to fetch playlist metadata: {response.content}") raise Exception(f"Failed to fetch playlist metadata: {response.content}")
@@ -144,8 +157,11 @@ class JellyfinClient:
setattr(metadata_obj, key, value) setattr(metadata_obj, key, value)
# Send the updated metadata to Jellyfin # Send the updated metadata to Jellyfin
update_url = f'{self.base_url}/Items/{playlist_id}' url = f'{self.base_url}/Items/{playlist_id}'
response = requests.post(update_url, json=metadata_obj.to_dict(), headers=self._get_headers(session_token= session_token), timeout=10, params = params) self.logger.debug(f"Url={url}")
response = requests.post(url, json=metadata_obj.to_dict(), headers=self._get_headers(session_token= session_token), timeout = self.timeout, params = params)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204: if response.status_code == 204:
return {"status": "success", "message": "Playlist metadata updated successfully"} return {"status": "success", "message": "Playlist metadata updated successfully"}
@@ -158,20 +174,57 @@ class JellyfinClient:
Get all music playlists for the currently authenticated user. Get all music playlists for the currently authenticated user.
:return: A list of the user's music playlists :return: A list of the user's music playlists
""" """
playlists_url = f'{self.base_url}/Items' url = f'{self.base_url}/Items'
params = { params = {
'IncludeItemTypes': 'Playlist', # Retrieve only playlists 'IncludeItemTypes': 'Playlist', # Retrieve only playlists
'Recursive': 'true', # Include nested playlists 'Recursive': 'true', # Include nested playlists
'Fields': 'OpenAccess' # Fields we want 'Fields': 'OpenAccess' # Fields we want
} }
response = requests.get(playlists_url, headers=self._get_headers(session_token=session_token), params=params , timeout = 10) self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), params=params , timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200: if response.status_code == 200:
return response.json()['Items'] return response.json()['Items']
else: else:
raise Exception(f"Failed to get playlists: {response.content}") raise Exception(f"Failed to get playlists: {response.content}")
def get_libraries(self, session_token: str):
url = f'{self.base_url}/Library/VirtualFolders'
params = {
}
self.logger.debug(f"Url={url}")
response = requests.get(url, headers=self._get_headers(session_token=session_token), params=params , timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get playlists: {response.content}")
def refresh_library(self, session_token: str, library_id: str) -> bool:
url = f'{self.base_url}/Items/{library_id}/Refresh'
params = {
"Recursive": "true",
"ImageRefreshMode": "Default",
"MetadataRefreshMode": "Default",
"ReplaceAllImages": "false",
"RegenerateTrickplay": "false",
"ReplaceAllMetadata": "false"
}
self.logger.debug(f"Url={url}")
response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params , timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204:
return True
else:
raise Exception(f"Failed to update library: {response.content}")
def search_music_tracks(self, session_token: str, search_query: str): def search_music_tracks(self, session_token: str, search_query: str):
@@ -180,7 +233,7 @@ class JellyfinClient:
:param search_query: The search term (title or song name). :param search_query: The search term (title or song name).
:return: A list of matching songs. :return: A list of matching songs.
""" """
search_url = f'{self.base_url}/Items' url = f'{self.base_url}/Items'
params = { params = {
'SearchTerm': search_query.replace('\'',"´").replace('','´'), 'SearchTerm': search_query.replace('\'',"´").replace('','´'),
@@ -188,8 +241,11 @@ class JellyfinClient:
'Recursive': 'true', # Search within all folders 'Recursive': 'true', # Search within all folders
'Fields': 'Name,Id,Album,Artists,Path' # Retrieve the name and ID of the song 'Fields': 'Name,Id,Album,Artists,Path' # Retrieve the name and ID of the song
} }
self.logger.debug(f"Url={url}")
response = requests.get(search_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
response = requests.get(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 200: if response.status_code == 200:
return response.json()['Items'] return response.json()['Items']
@@ -204,14 +260,17 @@ class JellyfinClient:
:return: A success message. :return: A success message.
""" """
# Construct the API URL with query parameters # Construct the API URL with query parameters
add_url = f'{self.base_url}/Playlists/{playlist_id}/Items' url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = { params = {
'ids': ','.join(song_ids), # Comma-separated song IDs 'ids': ','.join(song_ids), # Comma-separated song IDs
'userId': user_id 'userId': user_id
} }
self.logger.debug(f"Url={url}")
# Send the request to Jellyfin API with query parameters # Send the request to Jellyfin API with query parameters
response = requests.post(add_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10) response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
# Check for success # Check for success
if response.status_code == 204: # 204 No Content indicates success if response.status_code == 204: # 204 No Content indicates success
@@ -226,12 +285,14 @@ class JellyfinClient:
:param song_ids: A list of song IDs to remove. :param song_ids: A list of song IDs to remove.
:return: A success message. :return: A success message.
""" """
remove_url = f'{self.base_url}/Playlists/{playlist_id}/Items' url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = { params = {
'EntryIds': ','.join(song_ids) # Join song IDs with commas 'EntryIds': ','.join(song_ids) # Join song IDs with commas
} }
self.logger.debug(f"Url={url}")
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10) response = requests.delete(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204: # 204 No Content indicates success for updating if response.status_code == 204: # 204 No Content indicates success for updating
return {"status": "success", "message": "Songs removed from playlist successfully"} return {"status": "success", "message": "Songs removed from playlist successfully"}
@@ -244,9 +305,11 @@ class JellyfinClient:
:param playlist_id: The ID of the playlist to remove. :param playlist_id: The ID of the playlist to remove.
:return: A success message upon successful deletion. :return: A success message upon successful deletion.
""" """
remove_url = f'{self.base_url}/Items/{playlist_id}' url = f'{self.base_url}/Items/{playlist_id}'
self.logger.debug(f"Url={url}")
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), timeout=10) response = requests.delete(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204: # 204 No Content indicates successful deletion if response.status_code == 204: # 204 No Content indicates successful deletion
return {"status": "success", "message": "Playlist removed successfully"} return {"status": "success", "message": "Playlist removed successfully"}
@@ -264,9 +327,11 @@ class JellyfinClient:
""" """
# Construct the API endpoint URL # Construct the API endpoint URL
url = f'{self.base_url}/Playlists/{playlist_id}/Users/{user_id}' url = f'{self.base_url}/Playlists/{playlist_id}/Users/{user_id}'
self.logger.debug(f"Url={url}")
# Send the DELETE request to remove the user from the playlist # Send the DELETE request to remove the user from the playlist
response = requests.delete(url, headers=self._get_headers(session_token= session_token), timeout=10) response = requests.delete(url, headers=self._get_headers(session_token= session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if response.status_code == 204: if response.status_code == 204:
# 204 No Content indicates the user was successfully removed # 204 No Content indicates the user was successfully removed
@@ -286,7 +351,7 @@ class JellyfinClient:
:return: Success message or raises an exception on failure. :return: Success message or raises an exception on failure.
""" """
# Step 1: Download the image from the Spotify URL # Step 1: Download the image from the Spotify URL
response = requests.get(spotify_image_url, timeout=10) response = requests.get(spotify_image_url, timeout = self.timeout)
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"Failed to download image from Spotify: {response.content}") raise Exception(f"Failed to download image from Spotify: {response.content}")
@@ -306,11 +371,13 @@ class JellyfinClient:
headers['Content-Type'] = content_type # Set to the correct image type headers['Content-Type'] = content_type # Set to the correct image type
headers['Accept'] = '*/*' headers['Accept'] = '*/*'
# Step 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body # url 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body
upload_url = f'{self.base_url}/Items/{playlist_id}/Images/Primary' url = f'{self.base_url}/Items/{playlist_id}/Images/Primary'
self.logger.debug(f"Url={url}")
# Send the Base64-encoded image data # Send the Base64-encoded image data
upload_response = requests.post(upload_url, headers=headers, data=image_base64, timeout=10) upload_response = requests.post(url, headers=headers, data=image_base64, timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}")
if upload_response.status_code == 204: # 204 No Content indicates success if upload_response.status_code == 204: # 204 No Content indicates success
return {"status": "success", "message": "Playlist cover image updated successfully"} return {"status": "success", "message": "Playlist cover image updated successfully"}
@@ -346,7 +413,7 @@ class JellyfinClient:
headers = self._get_headers(session_token=session_token) headers = self._get_headers(session_token=session_token)
# Send the request to Jellyfin API # Send the request to Jellyfin API
response = requests.post(url, headers=headers, json=data,timeout = 10) response = requests.post(url, headers=headers, json=data,timeout = self.timeout)
# Check for success # Check for success
if response.status_code == 204: if response.status_code == 204:
@@ -360,7 +427,7 @@ class JellyfinClient:
""" """
me_url = f'{self.base_url}/Users/Me' me_url = f'{self.base_url}/Users/Me'
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = 10) response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
@@ -370,7 +437,7 @@ class JellyfinClient:
def get_playlist_users(self, session_token: str, playlist_id: str): def get_playlist_users(self, session_token: str, playlist_id: str):
url = f'{self.base_url}/Playlists/{playlist_id}/Users' url = f'{self.base_url}/Playlists/{playlist_id}/Users'
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout=10) response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"Failed to fetch playlist metadata: {response.content}") raise Exception(f"Failed to fetch playlist metadata: {response.content}")
@@ -389,20 +456,30 @@ class JellyfinClient:
""" """
try: try:
# Download the Spotify preview audio # Download the Spotify preview audio
self.logger.debug(f"Downloading preview {preview_url} to tmp file")
tmp = self.download_preview_to_tempfile(preview_url=preview_url) tmp = self.download_preview_to_tempfile(preview_url=preview_url)
if tmp is None: if tmp is None:
self.logger.error(f"Downloading preview {preview_url} to tmp file failed, not continuing")
return False, None return False, None
# Convert the preview file to a normalized WAV file # Convert the preview file to a normalized WAV file
self.logger.debug(f"Converting preview to WAV file")
tmp_wav = self.convert_to_wav(tmp) tmp_wav = self.convert_to_wav(tmp)
if tmp_wav is None: if tmp_wav is None:
self.logger.error(f"Converting preview to WAV failed, not continuing")
os.remove(tmp) os.remove(tmp)
return False, None return False, None
# Fingerprint the normalized preview WAV file # Fingerprint the normalized preview WAV file
self.logger.debug(f"Performing fingerprinting on preview {tmp_wav}")
_, tmp_fp = acoustid.fingerprint_file(tmp_wav) _, tmp_fp = acoustid.fingerprint_file(tmp_wav)
tmp_fp_dec, version = chromaprint.decode_fingerprint(tmp_fp) tmp_fp_dec, version = chromaprint.decode_fingerprint(tmp_fp)
tmp_fp_dec = np.array(tmp_fp_dec, dtype=np.uint32) tmp_fp_dec = np.array(tmp_fp_dec, dtype=np.uint32)
self.logger.debug(f"decoded fingerprint for preview: {tmp_fp_dec[:5]}")
# Search for matching tracks in Jellyfin using only the song name # Search for matching tracks in Jellyfin using only the song name
search_query = song_name # Only use the song name in the search query search_query = song_name # Only use the song name in the search query
@@ -474,7 +551,7 @@ class JellyfinClient:
# Helper methods used in search_track_in_jellyfin # Helper methods used in search_track_in_jellyfin
def download_preview_to_tempfile(self, preview_url): def download_preview_to_tempfile(self, preview_url):
try: try:
response = requests.get(preview_url, timeout=10) response = requests.get(preview_url, timeout = self.timeout)
if response.status_code != 200: if response.status_code != 200:
return None return None
@@ -498,14 +575,18 @@ class JellyfinClient:
"-acodec", "pcm_s16le", "-ar", "44100", "-acodec", "pcm_s16le", "-ar", "44100",
"-ac", "2", output_file.name "-ac", "2", output_file.name
] ]
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
self.logger.error(f"Error converting to WAV, subprocess exitcode: {result.returncode} , input_file_path = {input_file_path}")
self.logger.error(f"\tprocess stdout: {result.stdout}")
self.logger.error(f"\tprocess stderr: {result.stderr}")
os.remove(output_file.name) os.remove(output_file.name)
return None return None
return output_file.name return output_file.name
except Exception as e: except Exception as e:
print(f"Error converting to WAV: {str(e)}") self.logger.error(f"Error converting to WAV: {str(e)}")
return None return None
def sliding_fingerprint_similarity(self, full_fp, preview_fp): def sliding_fingerprint_similarity(self, full_fp, preview_fp):

View File

@@ -14,6 +14,8 @@ It´s definitely not a general Playlist Manager for Jellyfin.
- **View Monitored Playlists**: View playlists which are already synced by the server, adding these to your Jellyfin account will make them available immediately - **View Monitored Playlists**: View playlists which are already synced by the server, adding these to your Jellyfin account will make them available immediately
- **Search Playlist**: Search for playlists - **Search Playlist**: Search for playlists
- **No Sign-Up or User-Accounts**: Jellyplist uses your local Jellyfin server for authentication - **No Sign-Up or User-Accounts**: Jellyplist uses your local Jellyfin server for authentication
- **Automatically keep track of changes**: Changes in order, added or removed songs will be tracked and synced with Jellyfin.
- **Metadata Sync**: Playlist Metadata will be available at your Jellyfin Server
## Getting Started ## Getting Started
@@ -35,6 +37,11 @@ SPOTIFY_CLIENT_SECRET = <Secret from Step 1>
JELLYPLIST_DB_HOST = postgres-jellyplist #Hostname of the db Container JELLYPLIST_DB_HOST = postgres-jellyplist #Hostname of the db Container
JELLYPLIST_DB_USER = jellyplist JELLYPLIST_DB_USER = jellyplist
JELLYPLIST_DB_PASSWORD = jellyplist JELLYPLIST_DB_PASSWORD = jellyplist
# Optional:
# 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
# START_DOWNLOAD_AFTER_PLAYLIST_ADD = true # defaults to false, If a new Playlist is added, the Download Task will be scheduled immediately
#
``` ```
4. Prepare a `docker-compose.yml` 4. Prepare a `docker-compose.yml`
@@ -124,6 +131,7 @@ volumes:
## Technical Details/FAQ ## Technical Details/FAQ
- _Why have I to provide a Jellyfin Admin and Password instead of a API Token ?_ - _Why have I to provide a Jellyfin Admin and Password instead of a API Token ?_
Its because of some limitations in the Jellyfin API. The goal of Jellyplist was to always maintain only one copy of a playlist in Jellyfin and to use SharedPlaylists which are "owned" by one admin user. Its because of some limitations in the Jellyfin API. The goal of Jellyplist was to always maintain only one copy of a playlist in Jellyfin and to use SharedPlaylists which are "owned" by one admin user.

View File

@@ -13,7 +13,6 @@ spotdl==4.2.10
spotipy==2.24.0 spotipy==2.24.0
SQLAlchemy==2.0.35 SQLAlchemy==2.0.35
Unidecode==1.3.8 Unidecode==1.3.8
chromaprint
psycopg2-binary psycopg2-binary
eventlet eventlet
pydub pydub

View File

@@ -84,3 +84,13 @@ function playJellyfinTrack(button, jellyfinId) {
}) })
.catch(error => console.error('Error fetching Jellyfin stream URL:', error)); .catch(error => console.error('Error fetching Jellyfin stream URL:', error));
} }
function handleJellyfinClick(event, jellyfinId, trackTitle, spotifyId) {
if (event.ctrlKey) {
// CTRL key is pressed, open the search modal
openSearchModal(trackTitle, spotifyId);
} else {
// CTRL key is not pressed, play the track
playJellyfinTrack(event.target, jellyfinId);
}
}

View File

@@ -1,8 +1,6 @@
{% extends "admin.html" %} {% extends "admin.html" %}
{% block admin_content %} {% block admin_content %}
<div class="container mt-5"> <div class="container mt-5">
<!-- Tabelle für den Task-Status -->
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -12,10 +10,27 @@
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<!-- Das Partial wird dynamisch über HTMX geladen -->
<tbody id="task-status" hx-get="/task_status" hx-trigger="every 1s" hx-swap="innerHTML"> <tbody id="task-status" hx-get="/task_status" hx-trigger="every 1s" hx-swap="innerHTML">
{% include 'partials/_task_status.html' %} {% include 'partials/_task_status.html' %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div>
<form hx-post="/unlock_key" hx-swap="outerHTML" hx-target="#empty">
<div class="mb-3">
<label for="inputLockKey" class="form-label">Lock Key Name</label>
<select class="form-select form-select-lg mb-3" aria-label="Select task lock to unlock" id="inputLockKey" name="inputLockKey">
{% for value in lock_keys %}
<option value="{{value}}">{{value}}</option>
{% endfor %}
</select>
<div id="inputLockKeyHelp" class="form-text">Provide a key name to to reset a lock. </div>
</div>
<button type="submit" class="btn btn-primary">Unlock</button>
</form>
</div>
<div id="empty"></div>
{% endblock %} {% endblock %}

View File

@@ -4,9 +4,18 @@
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'> hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
<i class="fa-solid fa-circle-plus"> </i> <i class="fa-solid fa-circle-plus"> </i>
</button> </button>
{% elif item.can_remove %} {% elif item.can_remove %}
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}" <span id="item-can-remove-{{ item.id }}" >
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
hx-include="this" hx-swap="outerHTML" hx-target="this" data-bs-toggle="tooltip" title="Remove from Jellyfin"> hx-include="this" hx-swap="outerHTML" hx-target="this" data-bs-toggle="tooltip" title="Remove from Jellyfin">
<i class="fa-solid fa-circle-minus"> </i> <i class="fa-solid fa-circle-minus"> </i>
</button> </button>
{% endif %} {% 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>
</span>
{% endif%}

View File

@@ -1,4 +1,4 @@
<div class="col"> <div class="col" id="item-id-{{ item.id }}">
<div class="card shadow h-100 d-flex flex-column position-relative"> <div class="card shadow h-100 d-flex flex-column position-relative">
<!-- Badge: Only show if status is available (i.e., playlist has been requested) --> <!-- Badge: Only show if status is available (i.e., playlist has been requested) -->

View File

@@ -4,15 +4,26 @@
<td>{{ task.state }}</td> <td>{{ task.state }}</td>
<td> <td>
{% if task.info.percent %} {% if task.info.percent %}
{{ task.info.percent }}% <div class="progress" style="height: 20px;">
<div
class="progress-bar {% if task.info.percent|round(0) == 100 %}bg-success{% else %}bg-primary{% endif %}"
role="progressbar"
style="width: {{ task.info.percent|round(2) }}%;"
aria-valuenow="{{ task.info.percent|round(2) }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.info.percent|round(2) }}%
</div>
</div>
{% else %} {% else %}
N/A <span class="text-muted">N/A</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
<div> <div>
<button hx-post="/run_task/{{ task_name }}"
<button hx-post="/run_task/{{ task_name }}" hx-target="#task-row-{{ task_name }}" hx-swap="outerHTML" hx-target="#task-row-{{ task_name }}"
hx-swap="outerHTML"
class="btn btn-primary"> class="btn btn-primary">
Run Task Run Task
</button> </button>

View File

@@ -47,13 +47,14 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% set title = track.title | replace("'","") %}
{% if track.jellyfin_id %} {% if track.jellyfin_id %}
<button class="btn btn-sm btn-success" onclick="playJellyfinTrack(this, '{{ track.jellyfin_id }}')" data-bs-toggle="tooltip" title="Play from Jellyfin"> <button class="btn btn-sm btn-success" onclick="handleJellyfinClick(event, '{{ track.jellyfin_id }}', '{{ title }}', '{{ track.spotify_id }}')" data-bs-toggle="tooltip" title="Play from Jellyfin (Hold CTRL Key to reassing a new track)">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</button> </button>
{% elif track.downloaded %} {% elif track.downloaded %}
<span data-bs-toggle="tooltip" title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually"> <span data-bs-toggle="tooltip" title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
{% set title = track.title | replace("'","") %}
<button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')"> <button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')">
<i class="fas fa-triangle-exclamation"></i> <i class="fas fa-triangle-exclamation"></i>
</button> </button>

View File

@@ -1 +1 @@
__version__ = "0.1.5" __version__ = "0.1.6"