@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.5
|
||||
current_version = 0.1.6
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
|
||||
28
.github/workflows/main.yml
vendored
28
.github/workflows/main.yml
vendored
@@ -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<<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
|
||||
- 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 }}
|
||||
|
||||
17
.github/workflows/manual-build.yml
vendored
17
.github/workflows/manual-build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']= {}
|
||||
@@ -293,3 +305,11 @@ def _get_logged_in_user():
|
||||
def _get_admin_id():
|
||||
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_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
|
||||
@@ -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/<playlist_id>', methods=['DELETE'])
|
||||
@@ -154,7 +164,33 @@ def delete_playlist(playlist_id):
|
||||
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
|
||||
@app.route('/get_jellyfin_stream/<string:jellyfin_id>')
|
||||
|
||||
@@ -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,12 +7,16 @@ 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"):
|
||||
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")
|
||||
@@ -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 ''
|
||||
247
app/tasks.py
247
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,6 +152,18 @@ 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:
|
||||
@@ -142,10 +173,13 @@ def download_missing_tracks(self):
|
||||
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,15 +248,16 @@ 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
|
||||
if full_update:
|
||||
spotify_tracks = {}
|
||||
offset = 0
|
||||
playlist.snapshot_id = sp_playlist['snapshot_id']
|
||||
@@ -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.downloaded = True
|
||||
if 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})")
|
||||
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:
|
||||
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
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
60
changelogs/0.1.6.md
Normal file
60
changelogs/0.1.6.md
Normal 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
|
||||
12
config.py
12
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,
|
||||
|
||||
@@ -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,8 +60,9 @@ 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()
|
||||
@@ -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,20 +174,57 @@ 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):
|
||||
@@ -180,7 +233,7 @@ class JellyfinClient:
|
||||
: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
|
||||
}
|
||||
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
|
||||
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}'
|
||||
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
|
||||
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):
|
||||
|
||||
@@ -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 = <Secret from Step 1>
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,3 +84,13 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
{% extends "admin.html" %}
|
||||
{% block admin_content %}
|
||||
<div class="container mt-5">
|
||||
|
||||
<!-- Tabelle für den Task-Status -->
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -12,10 +10,27 @@
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Das Partial wird dynamisch über HTMX geladen -->
|
||||
<tbody id="task-status" hx-get="/task_status" hx-trigger="every 1s" hx-swap="innerHTML">
|
||||
{% include 'partials/_task_status.html' %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 %}
|
||||
|
||||
@@ -4,9 +4,18 @@
|
||||
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
|
||||
<i class="fa-solid fa-circle-plus"> </i>
|
||||
</button>
|
||||
|
||||
{% elif item.can_remove %}
|
||||
<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">
|
||||
<i class="fa-solid fa-circle-minus"> </i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if session['is_admin'] and item.can_remove %}
|
||||
<button class="btn btn-danger " hx-delete="{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}"
|
||||
hx-include="this" hx-swap="outerHTML" hx-target="#item-can-remove-{{ item.id }}" data-bs-toggle="tooltip" title="Delete playlist from monitoring and remove (DELETE FOR ALL USERS) from Jellyfin">
|
||||
<i class="fa-solid fa-trash"> </i>
|
||||
</button>
|
||||
</span>
|
||||
{% endif%}
|
||||
@@ -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">
|
||||
|
||||
<!-- Badge: Only show if status is available (i.e., playlist has been requested) -->
|
||||
|
||||
@@ -4,15 +4,26 @@
|
||||
<td>{{ task.state }}</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
N/A
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
|
||||
<button hx-post="/run_task/{{ task_name }}" hx-target="#task-row-{{ task_name }}" hx-swap="outerHTML"
|
||||
<button hx-post="/run_task/{{ task_name }}"
|
||||
hx-target="#task-row-{{ task_name }}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-primary">
|
||||
Run Task
|
||||
</button>
|
||||
|
||||
@@ -47,13 +47,14 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set title = track.title | replace("'","") %}
|
||||
|
||||
{% 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>
|
||||
</button>
|
||||
{% 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">
|
||||
{% set title = track.title | replace("'","") %}
|
||||
<button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
Reference in New Issue
Block a user