38 Commits
0.1.4 ... 0.1.6

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

View File

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

View File

@@ -42,12 +42,35 @@ 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 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -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

View File

@@ -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)
@@ -111,22 +118,23 @@ cache = Cache(app)
# Spotify, Jellyfin, and Spotdl setup
app.logger.info(f"setting up spotipy")
sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(
client_id=app.config['SPOTIPY_CLIENT_ID'],
client_secret=app.config['SPOTIPY_CLIENT_SECRET']
client_id=app.config['SPOTIFY_CLIENT_ID'],
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:

View File

@@ -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

View File

@@ -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>')

View File

@@ -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 ''

View File

@@ -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
@@ -78,8 +91,8 @@ def download_missing_tracks(self):
spotdl_config = app.config['SPOTDL_CONFIG']
cookie_file = spotdl_config['cookie_file']
output_dir = spotdl_config['output']
client_id = app.config['SPOTIPY_CLIENT_ID']
client_secret = app.config['SPOTIPY_CLIENT_SECRET']
client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD']
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
@@ -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,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
# Add and remove tracks while maintaining order
if tracks_to_add:
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}')
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

View File

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

60
changelogs/0.1.6.md Normal file
View File

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

View File

@@ -3,16 +3,20 @@ import sys
class Config:
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')
SPOTIPY_CLIENT_ID = os.getenv('SPOTIPY_CLIENT_ID')
SPOTIPY_CLIENT_SECRET = os.getenv('SPOTIPY_CLIENT_SECRET')
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,8 +39,9 @@ class Config:
'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL,
'JELLYFIN_ADMIN_USER': cls.JELLYFIN_ADMIN_USER,
'JELLYFIN_ADMIN_PASSWORD': cls.JELLYFIN_ADMIN_PASSWORD,
'SPOTIPY_CLIENT_ID': cls.SPOTIPY_CLIENT_ID,
'SPOTIPY_CLIENT_SECRET': cls.SPOTIPY_CLIENT_SECRET,
'SPOTIFY_CLIENT_ID': cls.SPOTIFY_CLIENT_ID,
'SPOTIFY_CLIENT_SECRET': cls.SPOTIFY_CLIENT_SECRET,
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,
'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER,
'JELLYPLIST_DB_PASSWORD' : cls.JELLYPLIST_DB_PASSWORD,

View File

@@ -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):

View File

@@ -1,7 +1,7 @@
![Jellyplist Logo](./static/images/logo_large.png)
> [!WARNING]
> Jellyplist is still at a very early stage: expect Bugs and weird behaviour
> Jellyplist is still at a very early stage: expect Bugs and weird behaviour. Especially the UI and UX are a bit clunky and unresponsive
## What is Jellyplist ?
Jellyplist aims to be a companion app for your self-hosted [Jellyfin](https://jellyfin.org/) Server. With Jellyplist you will be able to replicate/sync playlists from Spotify to your local Jellyfin account. Under the hood, it uses [SpotDL](https://spotdl.readthedocs.io/en/latest/) for downloading the corresponding tracks from the available sources if a track isn´t found in your local library.
@@ -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
@@ -30,11 +32,16 @@ SECRET_KEY = Keykeykesykykesky # Secret key for session management
JELLYFIN_SERVER_URL = http://192.168.178.14:8096 # local Jellyfin server
JELLYFIN_ADMIN_USER = admin # due to api limitations jellyplist uses user authentication rather than api tokens
JELLYFIN_ADMIN_PASSWORD = admin_password_for_your_jellyifn_admin
SPOTIPY_CLIENT_ID = <Client ID from Step 1>
SPOTIPY_CLIENT_SECRET = <Secret from Step 1>
SPOTIFY_CLIENT_ID = <Client ID from Step 1>
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.
@@ -158,7 +166,25 @@ Jellyplist will cache requests where possible. Especially the `/tracks` endpoint
- When logged in as admin, you will see the admin section in the sidebar. From there you can some kind of `batch linking`. All unlinked tracks will be displayed at once.
> [!TIP]
> Linking of tracks had to be done only once. If a different playlist has the same track, Jellyplist will reuse the link
![Jellyplist batch link](./screenshots/batch_link.png)
> [!TIP]
> Linking of tracks had to be done only once. If a different playlist has the same track, Jellyplist will reuse the link
#### After you added your first playlist´s, the worker and scheduler will take over from this point.
The default schedules are:
| **Schedule Name** | **Task** | **Schedule** |
|--------------------------------------------|----------------------------------------------|----------------------|
| `download-missing-tracks-schedule` | `app.tasks.download_missing_tracks` | Every day at minute 30 |
| `check-playlist-updates-schedule` | `app.tasks.check_for_playlist_updates` | Every day at minute 25 |
| `update_all_playlists_track_status-schedule`| `app.tasks.update_all_playlists_track_status`| Every 2 minutes |
| `update_jellyfin_id_for_downloaded_tracks-schedule` | `app.tasks.update_jellyfin_id_for_downloaded_tracks` | Every 10 minutes |
For now the schedules aren´t configurable, but this is subject to change.
> [!TIP]
> Please be patient after you added your first batch of playlists! Jellyplist currently processes one track at a time, and this means it can take some time for you to see the first results.
Then let Jellyplist do it´s work, after some time you should be able to see the playlist in Jellyfin.
Have Fun ✌🏽

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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 %}

View File

@@ -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 %}
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
<span id="item-can-remove-{{ item.id }}" >
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
hx-include="this" hx-swap="outerHTML" hx-target="this" data-bs-toggle="tooltip" title="Remove from Jellyfin">
<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%}

View File

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

View File

@@ -4,16 +4,27 @@
<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"
class="btn btn-primary">
<button hx-post="/run_task/{{ task_name }}"
hx-target="#task-row-{{ task_name }}"
hx-swap="outerHTML"
class="btn btn-primary">
Run Task
</button>
</div>

View File

@@ -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>

View File

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