diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b29a6b8..379d3e0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.5 +current_version = 0.1.6 commit = True tag = True diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a6eab51..eef75d7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,12 +42,38 @@ jobs: ghcr.io/${{ github.repository }}:${{ env.VERSION }} 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<> $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<> $GITHUB_ENV + echo "$auto_notes" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + # Create a release on GitHub - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.VERSION }} name: Release ${{ env.VERSION }} - generate_release_notes: true + body: | + ${{ env.CHANGELOG_CONTENT }} + + ## Auto-Generated Release Notes + ${{ env.AUTO_RELEASE_NOTES }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/manual-build.yml b/.github/workflows/manual-build.yml index ecb8b0c..beb7ef2 100644 --- a/.github/workflows/manual-build.yml +++ b/.github/workflows/manual-build.yml @@ -18,6 +18,23 @@ 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 + 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 - name: Set up Docker Buildx diff --git a/app/__init__.py b/app/__init__.py index 8ab2afd..110a309 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -70,7 +70,7 @@ def make_celery(app): }, 'update_all_playlists_track_status-schedule': { 'task': 'app.tasks.update_all_playlists_track_status', - 'schedule': crontab(minute='*/2'), + 'schedule': crontab(minute='*/5'), }, 'update_jellyfin_id_for_downloaded_tracks-schedule': { @@ -83,8 +83,6 @@ def make_celery(app): celery.conf.timezone = 'UTC' 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 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(stream_handler) + 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() cache = Cache(app) @@ -115,18 +122,19 @@ sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials( 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( app.config['JELLYFIN_ADMIN_USER'], app.config['JELLYFIN_ADMIN_PASSWORD'], device_id= device_id ) # SQLAlchemy and Migrate setup -app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}") -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) -app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist' +app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}:{app.config['JELLYPLIST_DB_PORT']}") +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' +check_db_connection(db_uri=db_uri,retries=5,delay=2) +app.config['SQLALCHEMY_DATABASE_URI'] = db_uri app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) app.logger.info(f"applying db migrations") @@ -138,13 +146,20 @@ app.config.update( 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") celery = make_celery(app) socketio = SocketIO(app, message_queue=app.config['REDIS_URL'], async_mode='eventlet') 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 jellyfin_routes, tasks if "worker" in sys.argv: diff --git a/app/functions.py b/app/functions.py index 7f19b35..3642417 100644 --- a/app/functions.py +++ b/app/functions.py @@ -5,6 +5,7 @@ from functools import wraps 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 jellyfin.objects import PlaylistMetadata +import re TASK_STATUS = { 'update_all_playlists_track_status': None, @@ -12,6 +13,14 @@ TASK_STATUS = { 'check_for_playlist_updates': 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): task_id = TASK_STATUS.get(task_name) @@ -36,6 +45,9 @@ def manage_task(task_name): def prepPlaylistData(data): playlists = [] 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'): data['playlists']= {} @@ -292,4 +304,12 @@ def _get_logged_in_user(): return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first() def _get_admin_id(): #return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id - return jellyfin_admin_id \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/jellyfin_routes.py b/app/jellyfin_routes.py index 23846ef..1f99f39 100644 --- a/app/jellyfin_routes.py +++ b/app/jellyfin_routes.py @@ -1,7 +1,7 @@ import time from flask import Flask, jsonify, render_template, request, redirect, url_for, session, flash 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 @@ -76,9 +76,18 @@ def add_playlist(): user = functions._get_logged_in_user() playlist.tracks_available = 0 - # Add tracks to the playlist with track order - for idx, track_data in enumerate(playlist_data['tracks']['items']): - track_info = track_data['track'] + spotify_tracks = {} + offset = 0 + 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: continue track = Track.query.filter_by(spotify_track_id=track_info['id']).first() @@ -127,6 +136,7 @@ def add_playlist(): except Exception as e: flash(str(e)) + return '' @app.route('/delete_playlist/', methods=['DELETE']) @@ -154,7 +164,33 @@ def delete_playlist(playlist_id): flash(f'Failed to remove item: {str(e)}') - +@app.route('/wipe_playlist/', 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 @app.route('/get_jellyfin_stream/') diff --git a/app/routes.py b/app/routes.py index 8c44be5..c3a2774 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,5 +1,5 @@ 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 celery.result import AsyncResult from .version import __version__ @@ -7,15 +7,19 @@ from .version import __version__ @app.context_processor def add_context(): 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) + +# this feels wrong +skip_endpoints = ['task_status'] @app.after_request def render_messages(response: Response) -> Response: if request.headers.get("HX-Request"): - messages = render_template("partials/alerts.jinja2") - response.headers['HX-Trigger'] = 'showToastMessages' - response.data = response.data + messages.encode("utf-8") + if request.endpoint not in skip_endpoints: + messages = render_template("partials/alerts.jinja2") + response.headers['HX-Trigger'] = 'showToastMessages' + response.data = response.data + messages.encode("utf-8") return response @@ -31,7 +35,7 @@ def task_manager(): else: 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/link_issues') @@ -262,6 +266,17 @@ def associate_track(): 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') def test(): return '' \ No newline at end of file diff --git a/app/tasks.py b/app/tasks.py index 081cef7..7fd7bf7 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -1,4 +1,5 @@ from datetime import datetime,timezone +import logging import subprocess 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 import os import redis -from celery import current_task +from celery import current_task,signals import asyncio import requests @@ -17,6 +18,16 @@ def acquire_lock(lock_name, expiration=60): def release_lock(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) def update_all_playlists_track_status(self): @@ -37,11 +48,13 @@ def update_all_playlists_track_status(self): for playlist in playlists: total_tracks = 0 available_tracks = 0 - + app.logger.debug(f"Current Playlist: {playlist.name} [{playlist.id}:{playlist.spotify_playlist_id}]" ) for track in playlist.tracks: total_tracks += 1 if track.filesystem_path and os.path.exists(track.filesystem_path): available_tracks += 1 + track.downloaded = True + else: track.downloaded = False track.filesystem_path = None @@ -92,23 +105,29 @@ def download_missing_tracks(self): processed_tracks = 0 failed_downloads = 0 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 file_path = f"{output_dir.replace('{track-id}', track.spotify_track_id)}.mp3" - - 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 + # region search before download if search_before_download: 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) + # 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') if not preview_url: app.logger.error(f"Preview URL not found for track {track.name}.") @@ -133,19 +152,34 @@ def download_missing_tracks(self): continue else: 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 try: app.logger.info(f"Trying to download track: {track.name} ({track.spotify_track_id}), spotdl timeout = 90") s_url = f"https://open.spotify.com/track/{track.spotify_track_id}" - + command = [ "spotdl", "download", s_url, "--output", output_dir, - "--cookie-file", cookie_file, "--client-id", client_id, "--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) 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}.") else: 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 track.download_status = result.stdout[:2048] except Exception as e: @@ -181,6 +219,11 @@ def download_missing_tracks(self): } finally: 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: app.logger.info("Skipping task. Another instance is already running.") return {'status': 'Task skipped, another instance is running'} @@ -205,72 +248,73 @@ def check_for_playlist_updates(self): for playlist in playlists: playlist.last_updated = datetime.now( timezone.utc) 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']}') db.session.commit() if sp_playlist['snapshot_id'] == playlist.snapshot_id: app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}') - continue + full_update = False try: #region Check for updates # Fetch all playlist data from Spotify - spotify_tracks = {} - offset = 0 - playlist.snapshot_id = sp_playlist['snapshot_id'] - while True: - playlist_data = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100) - items = playlist_data['items'] - spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']}) + if full_update: + spotify_tracks = {} + offset = 0 + playlist.snapshot_id = sp_playlist['snapshot_id'] + while True: + playlist_data = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100) + items = playlist_data['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 - if len(items) < 100: # No more tracks to fetch - break - offset += 100 # Move to the next batch - - existing_tracks = {track.spotify_track_id: track for track in playlist.tracks} + existing_tracks = {track.spotify_track_id: track for track in playlist.tracks} - # Determine tracks to add and remove - tracks_to_add = [] - for idx, track_info in spotify_tracks.items(): - if track_info: - track_id = track_info['id'] - if track_id not in existing_tracks: - track = Track.query.filter_by(spotify_track_id=track_id).first() - if not track: - track = Track(name=track_info['name'], spotify_track_id=track_id, spotify_uri=track_info['uri'], downloaded=False) - db.session.add(track) - db.session.commit() - app.logger.info(f'Added new track: {track.name}') - tracks_to_add.append((track, idx)) + # Determine tracks to add and remove + tracks_to_add = [] + for idx, track_info in spotify_tracks.items(): + if track_info: + track_id = track_info['id'] + if track_id not in existing_tracks: + track = Track.query.filter_by(spotify_track_id=track_id).first() + if not track: + track = Track(name=track_info['name'], spotify_track_id=track_id, spotify_uri=track_info['uri'], downloaded=False) + db.session.add(track) + db.session.commit() + app.logger.info(f'Added new track: {track.name}') + tracks_to_add.append((track, idx)) - tracks_to_remove = [ - existing_tracks[track_id] - for track_id in existing_tracks - if track_id not in {track['id'] for track in spotify_tracks.values() if track} - ] + tracks_to_remove = [ + existing_tracks[track_id] + for track_id in existing_tracks + if track_id not in {track['id'] for track in spotify_tracks.values() if track} + ] - if tracks_to_add or tracks_to_remove: - playlist.last_changed = datetime.now( timezone.utc) + if tracks_to_add or tracks_to_remove: + playlist.last_changed = datetime.now( timezone.utc) - # Add and remove tracks while maintaining order - - if tracks_to_add: + # Add and remove tracks while maintaining order - for track, track_order in tracks_to_add: - stmt = insert(playlist_tracks).values( - playlist_id=playlist.id, - track_id=track.id, - track_order=track_order - ) - db.session.execute(stmt) - db.session.commit() - app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}') + if tracks_to_add: + + for track, track_order in tracks_to_add: + stmt = insert(playlist_tracks).values( + playlist_id=playlist.id, + track_id=track.id, + track_order=track_order + ) + db.session.execute(stmt) + db.session.commit() + app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}') - if tracks_to_remove: - for track in tracks_to_remove: - playlist.tracks.remove(track) - db.session.commit() - app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}') - #endregion + if tracks_to_remove: + for track in tracks_to_remove: + playlist.tracks.remove(track) + db.session.commit() + app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}') + #endregion #region Update Playlist Items and Metadata functions.update_playlist_metadata(playlist, sp_playlist) @@ -306,62 +350,44 @@ def check_for_playlist_updates(self): @celery.task(bind=True) def update_jellyfin_id_for_downloaded_tracks(self): 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 try: - app.logger.info("Starting Jellyfin ID update for downloaded tracks...") + 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() + + 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) if not downloaded_tracks: app.logger.info("No downloaded tracks without Jellyfin ID found.") 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 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: - best_match = None - 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 + best_match = find_best_match_from_jellyfin(track) if best_match: - track.jellyfin_id = best_match['Id'] + 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'] + app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.spotify_track_id})") + + + db.session.commit() - app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.spotify_track_id})") 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 @@ -380,3 +406,116 @@ def update_jellyfin_id_for_downloaded_tracks(self): else: app.logger.info("Skipping task. Another instance is already 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 \ No newline at end of file diff --git a/app/version.py b/app/version.py index 1276d02..0a8da88 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6" diff --git a/changelogs/0.1.6.md b/changelogs/0.1.6.md new file mode 100644 index 0000000..186a28b --- /dev/null +++ b/changelogs/0.1.6.md @@ -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 diff --git a/config.py b/config.py index 7e28adc..fc0436d 100644 --- a/config.py +++ b/config.py @@ -3,16 +3,20 @@ import sys class Config: + LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() SECRET_KEY = os.getenv('SECRET_KEY') JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL') JELLYFIN_ADMIN_USER = os.getenv('JELLYFIN_ADMIN_USER') 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_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET') 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_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_REDIS_PORT = 6379 CACHE_REDIS_HOST = 'redis' @@ -20,15 +24,14 @@ class Config: CACHE_DEFAULT_TIMEOUT = 3600 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 = True + FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true' # SpotDL specific configuration SPOTDL_CONFIG = { 'cookie_file': '/jellyplist/cookies.txt', 'output': '/jellyplist_downloads/__jellyplist/{track-id}', 'threads': 12 } + @classmethod def validate_env_vars(cls): required_vars = { @@ -36,6 +39,7 @@ class Config: 'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL, 'JELLYFIN_ADMIN_USER': cls.JELLYFIN_ADMIN_USER, 'JELLYFIN_ADMIN_PASSWORD': cls.JELLYFIN_ADMIN_PASSWORD, + 'SPOTIFY_CLIENT_ID': cls.SPOTIFY_CLIENT_ID, 'SPOTIFY_CLIENT_SECRET': cls.SPOTIFY_CLIENT_SECRET, 'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST, diff --git a/jellyfin/client.py b/jellyfin/client.py index 7ed547b..e2e5c06 100644 --- a/jellyfin/client.py +++ b/jellyfin/client.py @@ -5,10 +5,9 @@ import tempfile import numpy as np import requests import base64 -from urllib.parse import quote import acoustid import chromaprint - +import logging from jellyfin.objects import PlaylistMetadata def _clean_query(query): @@ -23,12 +22,18 @@ def _clean_query(query): return cleaned_query 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. :param base_url: The base URL of the Jellyfin server (e.g., 'http://localhost:8096') """ 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): """ @@ -46,7 +51,7 @@ class JellyfinClient: :param password: The password of the user. :return: Access token and user ID """ - login_url = f'{self.base_url}/Users/AuthenticateByName' + url = f'{self.base_url}/Users/AuthenticateByName' headers = { 'Content-Type': 'application/json', 'X-Emby-Authorization': f'MediaBrowser Client="JellyPlist", Device="Web", DeviceId="{device_id}", Version="1.0"' @@ -55,9 +60,10 @@ class JellyfinClient: 'Username': username, 'Pw': password } - - response = requests.post(login_url, json=data, headers=headers) - + self.logger.debug(f"Url={url}") + response = requests.post(url, json=data, headers=headers) + self.logger.debug(f"Response = {response.status_code}") + if response.status_code == 200: result = response.json() return result['AccessToken'], result['User']['Id'], result['User']['Name'],result['User']['Policy']['IsAdministrator'] @@ -73,7 +79,7 @@ class JellyfinClient: :param song_ids: A list of song IDs to include in the playlist. :return: The newly created playlist object """ - create_url = f'{self.base_url}/Playlists' + url = f'{self.base_url}/Playlists' data = { 'Name': name, 'UserId': user_id, @@ -81,8 +87,10 @@ class JellyfinClient: 'Ids': ','.join(song_ids), # Join song IDs with commas '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: return response.json() @@ -96,12 +104,14 @@ class JellyfinClient: :param song_ids: A list of song IDs to include in the playlist. :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 = { '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 return {"status": "success", "message": "Playlist updated successfully"} @@ -109,11 +119,14 @@ class JellyfinClient: raise Exception(f"Failed to update playlist: {response.content}") 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 = { '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: raise Exception(f"Failed to fetch playlist metadata: {response.content}") @@ -144,8 +157,11 @@ class JellyfinClient: setattr(metadata_obj, key, value) # Send the updated metadata to Jellyfin - update_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) + url = f'{self.base_url}/Items/{playlist_id}' + 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: return {"status": "success", "message": "Playlist metadata updated successfully"} @@ -158,29 +174,66 @@ class JellyfinClient: Get all music playlists for the currently authenticated user. :return: A list of the user's music playlists """ - playlists_url = f'{self.base_url}/Items' + url = f'{self.base_url}/Items' params = { 'IncludeItemTypes': 'Playlist', # Retrieve only playlists 'Recursive': 'true', # Include nested playlists '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: return response.json()['Items'] else: 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): """ Search for music tracks by title, song name, and optionally Spotify-ID. :param search_query: The search term (title or song name). :return: A list of matching songs. """ - search_url = f'{self.base_url}/Items' + url = f'{self.base_url}/Items' params = { 'SearchTerm': search_query.replace('\'',"´").replace('’','´'), @@ -188,8 +241,11 @@ class JellyfinClient: 'Recursive': 'true', # Search within all folders '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: return response.json()['Items'] @@ -204,14 +260,17 @@ class JellyfinClient: :return: A success message. """ # 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 = { 'ids': ','.join(song_ids), # Comma-separated song IDs 'userId': user_id } + self.logger.debug(f"Url={url}") + # 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 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. :return: A success message. """ - remove_url = f'{self.base_url}/Playlists/{playlist_id}/Items' + url = f'{self.base_url}/Playlists/{playlist_id}/Items' params = { 'EntryIds': ','.join(song_ids) # Join song IDs with commas } - - response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10) + self.logger.debug(f"Url={url}") + + 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 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. :return: A success message upon successful deletion. """ - remove_url = f'{self.base_url}/Items/{playlist_id}' - - response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), timeout=10) + url = f'{self.base_url}/Items/{playlist_id}' + self.logger.debug(f"Url={url}") + + 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 return {"status": "success", "message": "Playlist removed successfully"} @@ -264,9 +327,11 @@ class JellyfinClient: """ # Construct the API endpoint URL 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 - 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: # 204 No Content indicates the user was successfully removed @@ -286,7 +351,7 @@ class JellyfinClient: :return: Success message or raises an exception on failure. """ # 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: 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['Accept'] = '*/*' - # Step 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 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body + url = f'{self.base_url}/Items/{playlist_id}/Images/Primary' + self.logger.debug(f"Url={url}") # 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 return {"status": "success", "message": "Playlist cover image updated successfully"} @@ -346,7 +413,7 @@ class JellyfinClient: headers = self._get_headers(session_token=session_token) # 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 if response.status_code == 204: @@ -360,7 +427,7 @@ class JellyfinClient: """ 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: return response.json() @@ -370,7 +437,7 @@ class JellyfinClient: def get_playlist_users(self, session_token: str, playlist_id: str): 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: raise Exception(f"Failed to fetch playlist metadata: {response.content}") @@ -389,20 +456,30 @@ class JellyfinClient: """ try: # 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) if tmp is None: + self.logger.error(f"Downloading preview {preview_url} to tmp file failed, not continuing") return False, None # 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) if tmp_wav is None: + self.logger.error(f"Converting preview to WAV failed, not continuing") os.remove(tmp) return False, None # 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_dec, version = chromaprint.decode_fingerprint(tmp_fp) 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_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 def download_preview_to_tempfile(self, preview_url): try: - response = requests.get(preview_url, timeout=10) + response = requests.get(preview_url, timeout = self.timeout) if response.status_code != 200: return None @@ -498,14 +575,18 @@ class JellyfinClient: "-acodec", "pcm_s16le", "-ar", "44100", "-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: + 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) return None return output_file.name except Exception as e: - print(f"Error converting to WAV: {str(e)}") + self.logger.error(f"Error converting to WAV: {str(e)}") return None def sliding_fingerprint_similarity(self, full_fp, preview_fp): diff --git a/readme.md b/readme.md index bddd35c..cceac52 100644 --- a/readme.md +++ b/readme.md @@ -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 - **Search Playlist**: Search for playlists - **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 @@ -35,6 +37,11 @@ SPOTIFY_CLIENT_SECRET = JELLYPLIST_DB_HOST = postgres-jellyplist #Hostname of the db Container JELLYPLIST_DB_USER = 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` @@ -124,6 +131,7 @@ volumes: ## Technical Details/FAQ + - _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. diff --git a/requirements.txt b/requirements.txt index 6233a78..cb5cbbf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,6 @@ spotdl==4.2.10 spotipy==2.24.0 SQLAlchemy==2.0.35 Unidecode==1.3.8 -chromaprint psycopg2-binary eventlet pydub diff --git a/static/js/preview.js b/static/js/preview.js index 4affc87..395be85 100644 --- a/static/js/preview.js +++ b/static/js/preview.js @@ -83,4 +83,14 @@ function playJellyfinTrack(button, jellyfinId) { }; }) .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); + } } \ No newline at end of file diff --git a/templates/admin/tasks.html b/templates/admin/tasks.html index 4815c4c..ef6670e 100644 --- a/templates/admin/tasks.html +++ b/templates/admin/tasks.html @@ -1,8 +1,6 @@ {% extends "admin.html" %} {% block admin_content %}
- - @@ -12,10 +10,27 @@ - {% include 'partials/_task_status.html' %}
Action
+
+
+
+ + +
Provide a key name to to reset a lock.
+
+ + +
+
+
+ {% endblock %} diff --git a/templates/partials/_add_remove_button.html b/templates/partials/_add_remove_button.html index a8b8dfd..cd91833 100644 --- a/templates/partials/_add_remove_button.html +++ b/templates/partials/_add_remove_button.html @@ -4,9 +4,18 @@ hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'> + {% elif item.can_remove %} - -{% endif %} \ No newline at end of file +{% endif %} +{% if session['is_admin'] and item.can_remove %} + + +{% endif%} \ No newline at end of file diff --git a/templates/partials/_spotify_item.html b/templates/partials/_spotify_item.html index 50df09f..0ffd45d 100644 --- a/templates/partials/_spotify_item.html +++ b/templates/partials/_spotify_item.html @@ -1,4 +1,4 @@ -
+
diff --git a/templates/partials/_task_status.html b/templates/partials/_task_status.html index 05f22fe..c32f89c 100644 --- a/templates/partials/_task_status.html +++ b/templates/partials/_task_status.html @@ -4,19 +4,30 @@ {{ task.state }} {% if task.info.percent %} - {{ task.info.percent }}% +
+
+ {{ task.info.percent|round(2) }}% +
+
{% else %} - N/A + N/A {% endif %}
- -
-{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/partials/_track_table.html b/templates/partials/_track_table.html index 5d9baac..eec4663 100644 --- a/templates/partials/_track_table.html +++ b/templates/partials/_track_table.html @@ -47,13 +47,14 @@ {% endif %} + {% set title = track.title | replace("'","") %} + {% if track.jellyfin_id %} - {% elif track.downloaded %} - {% set title = track.title | replace("'","") %} diff --git a/version.py b/version.py index 1276d02..0a8da88 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6"