This commit is contained in:
Kamil
2024-11-22 12:29:29 +00:00
parent 4be8622bcd
commit 435f903b94
62 changed files with 3690 additions and 168 deletions

View File

@@ -1,189 +1,151 @@
import atexit
from logging.handlers import RotatingFileHandler
import os
from flask import Flask
import time
from flask_socketio import SocketIO
import sys
from flask import Flask, has_request_context
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from psycopg2 import OperationalError
import spotipy
from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials
from spotipy.oauth2 import SpotifyClientCredentials
from celery import Celery
from celery.schedules import crontab
from sqlalchemy import create_engine
from config import Config
from jellyfin.client import JellyfinClient
import logging
from spotdl import Spotdl
from spotdl.utils.config import DEFAULT_CONFIG
from flask_caching import Cache
from .version import __version__
def check_db_connection(db_uri, retries=5, delay=5):
"""
Check if the database is reachable.
Args:
db_uri (str): The database URI.
retries (int): Number of retry attempts.
delay (int): Delay between retries in seconds.
Raises:
SystemExit: If the database is not reachable after retries.
"""
for attempt in range(1, retries + 1):
try:
engine = create_engine(db_uri)
connection = engine.connect()
connection.close()
app.logger.info("Successfully connected to the database.")
return
except OperationalError as e:
app.logger.error(f"Database connection failed on attempt {attempt}/{retries}: {e}")
if attempt < retries:
app.logger.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
else:
app.logger.critical("Could not connect to the database. Exiting application.")
sys.exit(1)
# Celery setup
def make_celery(app):
celery = Celery(
app.import_name,
backend=app.config['result_backend'],
broker=app.config['CELERY_BROKER_URL'],
include=['app.tasks']
)
celery.conf.update(app.config)
# Configure Celery Beat schedule
celery.conf.beat_schedule = {
'download-missing-tracks-schedule': {
'task': 'app.tasks.download_missing_tracks',
'schedule': crontab(minute='30'),
},
'check-playlist-updates-schedule': {
'task': 'app.tasks.check_for_playlist_updates',
'schedule': crontab(minute='25'),
},
'update_all_playlists_track_status-schedule': {
'task': 'app.tasks.update_all_playlists_track_status',
'schedule': crontab(minute='*/2'),
},
'update_jellyfin_id_for_downloaded_tracks-schedule': {
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
'schedule': crontab(minute='*/10'),
}
}
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)}'
# Initialize Flask app
app = Flask(__name__, template_folder="../templates", static_folder='../static')
# log_file = 'app.log'
# handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3)
# handler.setLevel(logging.DEBUG)
# handler.setFormatter(log_formatter)
# stream_handler = logging.StreamHandler(sys.stdout)
# stream_handler.setLevel(logging.DEBUG)
# stream_handler.setFormatter(log_formatter)
# # app.logger.addHandler(handler)
# app.logger.addHandler(stream_handler)
app = Flask(__name__, template_folder="../templates")
app.config.from_object(Config)
sp = spotipy.Spotify(auth_manager= SpotifyClientCredentials(client_id=app.config['SPOTIPY_CLIENT_ID'], client_secret=app.config['SPOTIPY_CLIENT_SECRET']))
app.logger.setLevel(logging.DEBUG)
Config.validate_env_vars()
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']
))
app.logger.info(f"setting up jellyfin client")
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'])
spotdl_config = DEFAULT_CONFIG
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
)
spotdl_config['cookie_file'] = '/jellyplist/cookies.txt'
spotdl_config['output'] = '/storage/media/music/_spotify_playlists/{track-id}'
spotdl_config['threads'] = 12
spotdl = Spotdl( app.config['SPOTIPY_CLIENT_ID'],app.config['SPOTIPY_CLIENT_SECRET'], downloader_settings=spotdl_config)
# Configurations
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://jellyplist:jellyplist@192.168.178.14/jellyplist'
# 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.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
app.logger.info(f"applying db migrations")
migrate = Migrate(app, db)
# Configure Logging
log_file = 'app.log'
handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3) # 100KB per file, with 3 backups
handler.setLevel(logging.INFO)
app.logger.info('Application started')
from app import routes, models
from app.models import JellyfinUser,Track,Playlist
from apscheduler.schedulers.background import BackgroundScheduler
# Celery Configuration (Updated)
app.config.update(
CELERY_BROKER_URL=app.config['REDIS_URL'],
result_backend=app.config['REDIS_URL']
)
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()
# Initialize the scheduler
scheduler = BackgroundScheduler()
def update_all_playlists_track_status():
"""
Update track_count and tracks_available for all playlists in the database.
For each track, check if the file exists on the filesystem. If not, reset the downloaded flag and filesystem_path.
"""
with app.app_context():
playlists = Playlist.query.all()
if not playlists:
app.logger.info("No playlists found.")
return
for playlist in playlists:
total_tracks = 0
available_tracks = 0
for track in playlist.tracks:
total_tracks += 1
# Check if the file exists
if track.filesystem_path and os.path.exists(track.filesystem_path):
available_tracks += 1
else:
# If the file doesn't exist, reset the 'downloaded' flag and 'filesystem_path'
track.downloaded = False
track.filesystem_path = None
db.session.commit()
# Update playlist fields
playlist.track_count = total_tracks
playlist.tracks_available = available_tracks
db.session.commit()
app.logger.info("All playlists' track statuses updated.")
def download_missing_tracks():
app.logger.info("Starting track download job...")
with app.app_context():
# Get all tracks that are not downloaded
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
if not undownloaded_tracks:
app.logger.info("No undownloaded tracks found.")
return
for track in undownloaded_tracks:
app.logger.info(f"Trying to downloading track: {track.name} ({track.spotify_track_id})")
try:
# Download track using spotDL
s_url = f"https://open.spotify.com/track/{track.spotify_track_id}"
search = spotdl.search([s_url])
if search:
song = search[0]
dl_request = spotdl.download(song)
# Assuming spotDL downloads files to the './downloads' directory, set the filesystem path
file_path = dl_request[1].__str__() # Adjust according to naming conventions
if os.path.exists(file_path):
# Update the track's downloaded status and filesystem path
track.downloaded = True
track.filesystem_path = file_path
db.session.commit()
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
else:
app.logger.error(f"Download failed for track {track.name}: file not found.")
else:
app.logger.warning(f"{track.name} ({track.spotify_track_id}) not Found")
except Exception as e:
app.logger.error(f"Error downloading track {track.name}: {str(e)}")
app.logger.info("Track download job finished.")
update_all_playlists_track_status()
def check_for_playlist_updates():
app.logger.info('Starting playlist update check...')
with app.app_context():
try:
playlists = Playlist.query.all() # Get all users
for playlist in playlists:
app.logger.info(f'Checking updates for playlist: {playlist.name}')
try:
# Fetch the latest data from the Spotify API
playlist_data = sp.playlist(playlist.spotify_playlist_id)
spotify_tracks = {track['track']['id']: track['track'] for track in playlist_data['tracks']['items']}
existing_tracks = {track.spotify_track_id: track for track in playlist.tracks}
# Tracks to add
tracks_to_add = []
for track_id, track_info in spotify_tracks.items():
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)
# Tracks to remove
tracks_to_remove = [existing_tracks[track_id] for track_id in existing_tracks if track_id not in spotify_tracks]
if tracks_to_add:
for track in tracks_to_add:
playlist.tracks.append(track)
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}')
except Exception as e:
app.logger.error(f"Error updating playlist {playlist.name}: {str(e)}")
except Exception as e:
app.logger.error(f"Error in check_for_playlist_updates: {str(e)}")
app.logger.info('Finished playlist update check.')
update_all_playlists_track_status()
# Add the job to run every 10 minutes (customize the interval as needed)
#scheduler.add_job(download_missing_tracks, 'interval', seconds=30, max_instances=1)
#scheduler.add_job(check_for_playlist_updates, 'interval', minutes=10, max_instances=1)
#download_missing_tracks()
#check_for_playlist_updates()
# Start the scheduler
scheduler.start()
# Ensure the scheduler shuts down properly when the app stops
atexit.register(lambda: scheduler.shutdown())
app.logger.info(f'Jellyplist {__version__} started')
from app import routes
from app import jellyfin_routes, tasks
if "worker" in sys.argv:
tasks.release_lock("download_missing_tracks_lock")

295
app/functions.py Normal file
View File

@@ -0,0 +1,295 @@
from flask import flash, redirect, session, url_for
from app.models import JellyfinUser, Playlist,Track
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache
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
TASK_STATUS = {
'update_all_playlists_track_status': None,
'download_missing_tracks': None,
'check_for_playlist_updates': None,
'update_jellyfin_id_for_downloaded_tracks' : None
}
def manage_task(task_name):
task_id = TASK_STATUS.get(task_name)
if task_id:
result = AsyncResult(task_id)
if result.state in ['PENDING', 'STARTED']:
return result.state, result.info if result.info else {}
if task_name == 'update_all_playlists_track_status':
result = update_all_playlists_track_status.delay()
elif task_name == 'download_missing_tracks':
result = download_missing_tracks.delay()
elif task_name == 'check_for_playlist_updates':
result = check_for_playlist_updates.delay()
elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
result = update_jellyfin_id_for_downloaded_tracks.delay()
TASK_STATUS[task_name] = result.id
return result.state, result.info if result.info else {}
def prepPlaylistData(data):
playlists = []
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not data.get('playlists'):
data['playlists']= {}
data['playlists']['items'] = [data]
for playlist_data in data['playlists']['items']:
# Fetch the playlist from the database if it exists
db_playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_data['id']).first()
if db_playlist:
# If the playlist is in the database, use the stored values
if isinstance(playlist_data['tracks'],list):
track_count = len(playlist_data['tracks'] )
else:
track_count = playlist_data['tracks']['total'] or 0
tracks_available = db_playlist.tracks_available or 0
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) or 0
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
# Determine playlist status
if tracks_available == track_count and track_count > 0:
status = 'green' # Fully available
elif tracks_available > 0:
status = 'yellow' # Partially available
else:
status = 'red' # Not available
else:
# If the playlist is not in the database, initialize with 0
track_count = 0
tracks_available = 0
tracks_linked = 0
percent_available = 0
status = 'red' # Not requested yet
# Append playlist data to the list
playlists.append({
'name': playlist_data['name'],
'description': playlist_data['description'],
'image': playlist_data['images'][0]['url'] if playlist_data['images'] else 'default-image.jpg',
'url': playlist_data['external_urls']['spotify'],
'id': playlist_data['id'],
'jellyfin_id': db_playlist.jellyfin_id if db_playlist else '',
'can_add': (db_playlist not in jellyfin_user.playlists) if db_playlist else True,
'can_remove' : (db_playlist in jellyfin_user.playlists) if db_playlist else False,
'last_updated':db_playlist.last_updated if db_playlist else '',
'last_changed':db_playlist.last_changed if db_playlist else '',
'tracks_available': tracks_available,
'track_count': track_count,
'tracks_linked': tracks_linked,
'percent_available': percent_available,
'status': status # Red, yellow, or green based on availability
})
return playlists
def get_cached_spotify_playlists(playlist_ids):
"""
Fetches multiple Spotify playlists by their IDs, utilizing individual caching.
:param playlist_ids: A list of Spotify playlist IDs.
:return: A dictionary containing the fetched playlists.
"""
spotify_data = {'playlists': {'items': []}}
for playlist_id in playlist_ids:
playlist_data = get_cached_spotify_playlist(playlist_id)
if playlist_data:
spotify_data['playlists']['items'].append(playlist_data)
else:
app.logger.warning(f"Playlist data for ID {playlist_id} could not be retrieved.")
return spotify_data
@cache.memoize(timeout=3600)
def get_cached_spotify_playlist(playlist_id):
"""
Fetches a Spotify playlist by its ID, utilizing caching to minimize API calls.
:param playlist_id: The Spotify playlist ID.
:return: Playlist data as a dictionary, or None if an error occurs.
"""
try:
playlist_data = sp.playlist(playlist_id) # Fetch data from Spotify API
return playlist_data
except Exception as e:
app.logger.error(f"Error fetching playlist {playlist_id} from Spotify: {str(e)}")
return None
@cache.memoize(timeout=3600*24*10)
def get_cached_spotify_track(track_id):
"""
Fetches a Spotify track by its ID, utilizing caching to minimize API calls.
:param track_id: The Spotify playlist ID.
:return: Track data as a dictionary, or None if an error occurs.
"""
try:
track_data = sp.track(track_id=track_id) # Fetch data from Spotify API
return track_data
except Exception as e:
app.logger.error(f"Error fetching track {track_id} from Spotify: {str(e)}")
return None
def prepAlbumData(data):
items = []
for item in data['albums']['items']:
items.append({
'name': item['name'],
'description': f"Released: {item['release_date']}",
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
'url': item['external_urls']['spotify'],
'id' : item['id'],
'can_add' : False
})
return items
def prepArtistData(data):
items = []
for item in data['artists']['items']:
items.append({
'name': item['name'],
'description': f"Popularity: {item['popularity']}",
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
'url': item['external_urls']['spotify'],
'id' : item['id'],
'can_add' : False
})
return items
def getFeaturedPlaylists(country,offset):
playlists_data = sp.featured_playlists(country=country, limit=16, offset=offset)
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],'Featured Playlists'
def getCategoryPlaylists(category,offset):
playlists_data = sp.category_playlists(category_id=category, limit=16, offset=offset)
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],f"Category {playlists_data['message']}"
def getCategories(country,offset):
categories_data = sp.categories(limit=16, offset= offset)
categories = []
for cat in categories_data['categories']['items']:
categories.append({
'name': cat['name'],
'description': '',
'image': cat['icons'][0]['url'] if cat['icons'] else 'default-image.jpg',
'url': f"/playlists?cat={cat['id']}",
'id' : cat['id'],
'type':'category'
})
return categories, categories_data['categories']['total'],'Browse Categories'
def get_tracks_for_playlist(data):
results = data
tracks = []
is_admin = session.get('is_admin', False)
for idx, item in enumerate(results['tracks']):
track_data = item['track']
if track_data:
duration_ms = track_data['duration_ms']
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
track_db = Track.query.filter_by(spotify_track_id=track_data['id']).first()
if track_db:
downloaded = track_db.downloaded
filesystem_path = track_db.filesystem_path if is_admin else None
jellyfin_id = track_db.jellyfin_id
download_status = track_db.download_status
else:
downloaded = False
filesystem_path = None
jellyfin_id = None
download_status = None
tracks.append({
'title': track_data['name'],
'artist': ', '.join([artist['name'] for artist in track_data['artists']]),
'url': track_data['external_urls']['spotify'],
'duration': f'{minutes}:{seconds:02d}',
'preview_url': track_data['preview_url'],
'downloaded': downloaded,
'filesystem_path': filesystem_path,
'jellyfin_id': jellyfin_id,
'spotify_id': track_data['id'],
'duration_ms': duration_ms,
'download_status' : download_status
})
return tracks
def get_full_playlist_data(playlist_id):
playlist_data = get_cached_spotify_playlist(playlist_id)
all_tracks = []
offset = 0
while True:
response = sp.playlist_items(playlist_id, offset=offset, limit=100)
items = response['items']
all_tracks.extend(items)
if len(items) < 100:
break
offset += 100
playlist_data['tracks'] = all_tracks
playlist_data['prepped_data'] = prepPlaylistData(playlist_data)
return playlist_data
def jellyfin_login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'jellyfin_user_name' not in session:
flash('You need to log in using your Jellyfin Credentials to access this page.', 'warning')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def jellyfin_admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not session['is_admin']:
flash('You need to be a Jellyfin admin.', 'warning')
return 404 # Redirect to your login route
return f(*args, **kwargs)
return decorated_function
def update_playlist_metadata(playlist,spotify_playlist_data):
metadata = PlaylistMetadata()
metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available']
metadata.Overview = spotify_playlist_data['description']
jellyfin.update_playlist_metadata(session_token=_get_api_token(),playlist_id=playlist.jellyfin_id,updates= metadata , user_id= _get_admin_id())
if spotify_playlist_data['images'] != None:
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url'])
def _get_token_from_sessioncookie() -> str:
return session['jellyfin_access_token']
def _get_api_token() -> str:
#return app.config['JELLYFIN_ACCESS_TOKEN']
return jellyfin_admin_token
def _get_logged_in_user():
return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
def _get_admin_id():
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id
return jellyfin_admin_id

176
app/jellyfin_routes.py Normal file
View File

@@ -0,0 +1,176 @@
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.models import Playlist,Track, playlist_tracks
from jellyfin.objects import PlaylistMetadata
@app.route('/jellyfin_playlists')
@functions.jellyfin_login_required
def jellyfin_playlists():
try:
# Fetch playlists from Jellyfin
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
# Extract Spotify playlist IDs from the database
spotify_playlist_ids = []
for pl in playlists:
# Retrieve the playlist from the database using Jellyfin ID
from_db = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
if from_db and from_db.spotify_playlist_id:
spotify_playlist_ids.append(from_db.spotify_playlist_id)
else:
app.logger.warning(f"No database entry found for Jellyfin playlist ID: {pl['Id']}")
if not spotify_playlist_ids:
flash('No Spotify playlists found to display.', 'warning')
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
# Use the cached function to fetch Spotify playlists
spotify_data = functions.get_cached_spotify_playlists(spotify_playlist_ids)
# Prepare the data for the template
prepared_data = functions.prepPlaylistData(spotify_data)
return render_template('jellyfin_playlists.html', playlists=prepared_data)
except Exception as e:
app.logger.error(f"Error in /jellyfin_playlists route: {str(e)}")
flash('An error occurred while fetching playlists.', 'danger')
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
@app.route('/addplaylist', methods=['POST'])
@functions.jellyfin_login_required
def add_playlist():
playlist_id = request.form.get('item_id') # HTMX sends the form data
playlist_name = request.form.get('item_name') # Optionally retrieve playlist name from the form
if not playlist_id:
flash('No playlist ID provided')
return ''
try:
# Fetch playlist from Spotify API (or any relevant API)
playlist_data = functions.get_cached_spotify_playlist(playlist_id)
# Check if playlist already exists in the database
playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_id).first()
if not playlist:
# Add new playlist if it doesn't exist
# create the playlist via api key, with the first admin as 'owner'
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data['name'],[],functions._get_admin_id())['Id']
playlist = Playlist(name=playlist_data['name'], spotify_playlist_id=playlist_id,spotify_uri=playlist_data['uri'],track_count = playlist_data['tracks']['total'], tracks_available=0, jellyfin_id = fromJellyfin)
db.session.add(playlist)
db.session.commit()
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']:
functions.manage_task('download_missing_tracks')
# Get the logged-in user
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']
if not track_info:
continue
track = Track.query.filter_by(spotify_track_id=track_info['id']).first()
if not track:
# Add new track if it doesn't exist
track = Track(name=track_info['name'], spotify_track_id=track_info['id'], spotify_uri=track_info['uri'], downloaded=False)
db.session.add(track)
db.session.commit()
elif track.downloaded:
playlist.tracks_available += 1
db.session.commit()
# Add track to playlist with order if it's not already associated
if track not in playlist.tracks:
# Insert into playlist_tracks with track order
stmt = insert(playlist_tracks).values(
playlist_id=playlist.id,
track_id=track.id,
track_order=idx # Maintain the order of tracks
)
db.session.execute(stmt)
db.session.commit()
update_playlist_metadata(playlist,playlist_data)
if playlist not in user.playlists:
user.playlists.append(playlist)
db.session.commit()
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(),playlist_id = playlist.jellyfin_id,user_ids= [user.jellyfin_user_id])
flash(f'Playlist "{playlist_data["name"]}" successfully added','success')
else:
flash(f'Playlist "{playlist_data["name"]}" already in your list')
item = {
"name" : playlist_data["name"],
"id" : playlist_id,
"can_add":False,
"can_remove":True,
"jellyfin_id" : playlist.jellyfin_id
}
return render_template('partials/_add_remove_button.html',item= item)
except Exception as e:
flash(str(e))
@app.route('/delete_playlist/<playlist_id>', methods=['DELETE'])
@functions.jellyfin_login_required
def delete_playlist(playlist_id):
# Logic to delete the playlist using JellyfinClient
try:
user = functions._get_logged_in_user()
for pl in user.playlists:
if pl.jellyfin_id == playlist_id:
user.playlists.remove(pl)
playlist = pl
jellyfin.remove_user_from_playlist(session_token= functions._get_api_token(), playlist_id= playlist_id, user_id=user.jellyfin_user_id)
db.session.commit()
flash('Playlist removed')
item = {
"name" : playlist.name,
"id" : playlist.spotify_playlist_id,
"can_add":True,
"can_remove":False,
"jellyfin_id" : playlist.jellyfin_id
}
return render_template('partials/_add_remove_button.html',item= item)
except Exception as e:
flash(f'Failed to remove item: {str(e)}')
@functions.jellyfin_login_required
@app.route('/get_jellyfin_stream/<string:jellyfin_id>')
def get_jellyfin_stream(jellyfin_id):
user_id = session['jellyfin_user_id'] # Beispiel: dynamischer Benutzer
api_key = functions._get_token_from_sessioncookie() # Beispiel: dynamischer API-Schlüssel
stream_url = f"{app.config['JELLYFIN_SERVER_URL']}/Audio/{jellyfin_id}/universal?UserId={user_id}&DeviceId={device_id}&MaxStreamingBitrate=140000000&Container=opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=mp4&TranscodingProtocol=hls&AudioCodec=aac&api_key={api_key}&PlaySessionId={int(time.time())}&StartTimeTicks=0&EnableRedirection=true&EnableRemoteMedia=false"
return jsonify({'stream_url': stream_url})
@app.route('/search_jellyfin', methods=['GET'])
@functions.jellyfin_login_required
def search_jellyfin():
search_query = request.args.get('search_query')
spotify_id = request.args.get('spotify_id')
if search_query:
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
# Render only the search results section as response
return render_template('partials/_jf_search_results.html', results=results,spotify_id= spotify_id)
return jsonify({'error': 'No search query provided'}), 400

62
app/models.py Normal file
View File

@@ -0,0 +1,62 @@
from app import db
from sqlalchemy import select
class JellyfinUser(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
jellyfin_user_id = db.Column(db.String(120), unique=True, nullable=False)
is_admin = db.Column(db.Boolean, default=False, nullable=False) # New property
# Relationship with Playlist
playlists = db.relationship('Playlist', secondary='user_playlists', back_populates='users')
def __repr__(self):
return f'<JellyfinUser {self.name}:{self.jellyfin_user_id}>'
# Association table between Users and Playlists
user_playlists = db.Table('user_playlists',
db.Column('user_id', db.Integer, db.ForeignKey('jellyfin_user.id'), primary_key=True),
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id'), primary_key=True),
)
class Playlist(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
spotify_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
# Relationship with Tracks
tracks = db.relationship('Track', secondary='playlist_tracks', back_populates='playlists')
track_count = db.Column(db.Integer())
tracks_available = db.Column(db.Integer())
jellyfin_id = db.Column(db.String(120), nullable=True)
last_updated = db.Column(db.DateTime )
last_changed = db.Column(db.DateTime )
# Many-to-Many relationship with JellyfinUser
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
def __repr__(self):
return f'<Playlist {self.name}:{self.spotify_playlist_id}>'
# Association table between Playlists and Tracks
playlist_tracks = db.Table('playlist_tracks',
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id'), primary_key=True),
db.Column('track_id', db.Integer, db.ForeignKey('track.id'), primary_key=True),
db.Column('track_order', db.Integer, nullable=False) # New field for track order
)
class Track(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
spotify_track_id = db.Column(db.String(120), unique=True, nullable=False)
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
downloaded = db.Column(db.Boolean())
filesystem_path = db.Column(db.String(), nullable=True)
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
download_status = db.Column(db.String(2048), nullable=True)
# Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
def __repr__(self):
return f'<Track {self.name}:{self.spotify_track_id}>'

265
app/routes.py Normal file
View File

@@ -0,0 +1,265 @@
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.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult
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__}"
return dict(unlinked_track_count = unlinked_track_count, version = version)
@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")
return response
@app.route('/admin/tasks')
@functions.jellyfin_admin_required
def task_manager():
statuses = {}
for task_name, task_id in functions.TASK_STATUS.items():
if task_id:
result = AsyncResult(task_id)
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
else:
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
return render_template('admin/tasks.html', tasks=statuses)
@app.route('/admin')
@app.route('/admin/link_issues')
@functions.jellyfin_admin_required
def link_issues():
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
tracks = []
# for ult in unlinked_tracks:
# sp_track = functions.get_cached_spotify_track(ult.spotify_track_id)
# tracks.append({
# 'title': sp_track['name'],
# 'artist': ', '.join([artist['name'] for artist in sp_track['artists']]),
# 'url': sp_track['external_urls']['spotify'],
# 'duration': f'{minutes}:{seconds:02d}',
# 'preview_url': sp_track['preview_url'],
# 'downloaded': ult.downloaded,
# 'filesystem_path': utl.filesystem_path,
# 'jellyfin_id': ult.jellyfin_id,
# 'spotify_id': sp_track['id'],
# 'duration_ms': duration_ms,
# 'download_status' : download_status
# })
return render_template('admin/link_issues.html' , tracks = tracks )
@app.route('/run_task/<task_name>', methods=['POST'])
@functions.jellyfin_admin_required
def run_task(task_name):
status, info = functions.manage_task(task_name)
# Rendere nur die aktualisierte Zeile der Task
task_info = {task_name: {'state': status, 'info': info}}
return render_template('partials/_task_status.html', tasks=task_info)
@app.route('/task_status')
@functions.jellyfin_admin_required
def task_status():
statuses = {}
for task_name, task_id in functions.TASK_STATUS.items():
if task_id:
result = AsyncResult(task_id)
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
else:
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
# Render the HTML partial template instead of returning JSON
return render_template('partials/_task_status.html', tasks=statuses)
@app.route('/')
@functions.jellyfin_login_required
def index():
users = JellyfinUser.query.all()
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
try:
jellylogin = jellyfin.login_with_password(username=username, password=password)
if jellylogin:
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
session['debug'] = app.debug
# Check if the user already exists
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not user:
# Add the user to the database if they don't exist
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
db.session.add(new_user)
db.session.commit()
return redirect('/playlists')
except:
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
return redirect(url_for('login'))
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('jellyfin_user_name', None)
session.pop('jellyfin_access_token', None)
return redirect(url_for('login'))
@app.route('/playlists')
@app.route('/categories')
@app.route('/playlists/monitored')
@functions.jellyfin_login_required
def loaditems():
country = 'DE'
offset = int(request.args.get('offset', 0)) # Get the offset (default to 0 for initial load)
limit = 20 # Define a limit for pagination
additional_query = ''
items_subtitle = ''
if request.path == '/playlists/monitored':
# Step 1: Query the database for monitored playlists
db_playlists = db.session.query(Playlist).offset(offset).limit(limit).all()
max_items = db.session.query(Playlist).count()
# Collect Spotify Playlist IDs from the database
spotify_playlist_ids = [playlist.spotify_playlist_id for playlist in db_playlists]
spotify_data = functions.get_cached_spotify_playlists(tuple(spotify_playlist_ids))
# Step 3: Pass the Spotify data to prepPlaylistData for processing
data = functions.prepPlaylistData(spotify_data)
items_title = "Monitored Playlists"
items_subtitle = "This playlists are already monitored by the Server, if you add one of these to your Jellyfin account, they will be available immediately."
elif request.path == '/playlists':
cat = request.args.get('cat', None)
if cat is not None:
data, max_items, items_title = functions.getCategoryPlaylists(category=cat, offset=offset)
additional_query += f"&cat={cat}"
else:
data, max_items, items_title = functions.getFeaturedPlaylists(country=country, offset=offset)
elif request.path == '/categories':
data, max_items, items_title = functions.getCategories(country=country, offset=offset)
next_offset = offset + len(data)
total_items = max_items
context = {
'items': data,
'next_offset': next_offset,
'total_items': total_items,
'endpoint': request.path,
'items_title': items_title,
'items_subtitle' : items_subtitle,
'additional_query': additional_query
}
if request.headers.get('HX-Request'): # Check if the request is from HTMX
return render_template('partials/_spotify_items.html', **context)
else:
return render_template('items.html', **context)
@app.route('/search')
@functions.jellyfin_login_required
def searchResults():
query = request.args.get('query')
context = {}
if query:
# Add your logic here to perform the search on Spotify (or Jellyfin)
search_result = sp.search(q = query, type= 'track,album,artist,playlist')
context = {
'artists' : functions.prepArtistData(search_result ),
'playlists' : functions.prepPlaylistData(search_result ),
'albums' : functions.prepAlbumData(search_result ),
'query' : query
}
return render_template('search.html', **context)
else:
return render_template('search.html', query=None, results={})
@app.route('/playlist/view/<playlist_id>')
@functions.jellyfin_login_required
def get_playlist_tracks(playlist_id):
# Hol dir alle Tracks für die Playlist
data = functions.get_full_playlist_data(playlist_id) # Diese neue Funktion holt alle Tracks der Playlist
tracks = functions.get_tracks_for_playlist(data) # Deine Funktion, um Tracks zu holen
# Berechne die gesamte Dauer der Playlist
total_duration_ms = sum([track['track']['duration_ms'] for track in data['tracks'] if track['track']])
# Konvertiere die Gesamtdauer in ein lesbares Format
hours, remainder = divmod(total_duration_ms // 1000, 3600)
minutes, seconds = divmod(remainder, 60)
# Formatierung der Dauer
if hours > 0:
total_duration = f"{hours}h {minutes}min"
else:
total_duration = f"{minutes}min"
return render_template(
'tracks_table.html',
tracks=tracks,
total_duration=total_duration,
track_count=len(data['tracks']),
playlist_name=data['name'],
playlist_cover=data['images'][0]['url'],
playlist_description=data['description'],
last_updated = data['prepped_data'][0]['last_updated'],
last_changed = data['prepped_data'][0]['last_changed'],
item = data['prepped_data'][0],
)
@app.route('/associate_track', methods=['POST'])
@functions.jellyfin_login_required
def associate_track():
jellyfin_id = request.form.get('jellyfin_id')
spotify_id = request.form.get('spotify_id')
if not jellyfin_id or not spotify_id:
flash('Missing Jellyfin or Spotify ID')
# Retrieve the track by Spotify ID
track = Track.query.filter_by(spotify_track_id=spotify_id).first()
if not track:
flash('Track not found')
return ''
# Associate the Jellyfin ID with the track
track.jellyfin_id = jellyfin_id
try:
# Commit the changes to the database
db.session.commit()
flash("Track associated","success")
return ''
except Exception as e:
db.session.rollback() # Roll back the session in case of an error
flash(str(e))
return ''
@app.route('/test')
def test():
return ''

376
app/tasks.py Normal file
View File

@@ -0,0 +1,376 @@
from datetime import datetime,timezone
import subprocess
from sqlalchemy import insert
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
import os
import redis
from celery import current_task
import asyncio
import requests
redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
def acquire_lock(lock_name, expiration=60):
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
def release_lock(lock_name):
redis_client.delete(lock_name)
@celery.task(bind=True)
def update_all_playlists_track_status(self):
lock_key = "update_all_playlists_track_status_lock"
if acquire_lock(lock_key, expiration=600):
try:
with app.app_context():
playlists = Playlist.query.all()
total_playlists = len(playlists)
if not playlists:
app.logger.info("No playlists found.")
return {'status': 'No playlists found'}
app.logger.info(f"Found {total_playlists} playlists to update.")
processed_playlists = 0
for playlist in playlists:
total_tracks = 0
available_tracks = 0
for track in playlist.tracks:
total_tracks += 1
if track.filesystem_path and os.path.exists(track.filesystem_path):
available_tracks += 1
else:
track.downloaded = False
track.filesystem_path = None
db.session.commit()
playlist.track_count = total_tracks
playlist.tracks_available = available_tracks
db.session.commit()
processed_playlists += 1
progress = (processed_playlists / total_playlists) * 100
self.update_state(state='PROGRESS', meta={'current': processed_playlists, 'total': total_playlists, 'percent': progress})
if processed_playlists % 10 == 0 or processed_playlists == total_playlists:
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
app.logger.info("All playlists' track statuses updated.")
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
finally:
release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@celery.task(bind=True)
def download_missing_tracks(self):
lock_key = "download_missing_tracks_lock"
if acquire_lock(lock_key, expiration=1800):
try:
app.logger.info("Starting track download job...")
with app.app_context():
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']
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD']
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
total_tracks = len(undownloaded_tracks)
if not undownloaded_tracks:
app.logger.info("No undownloaded tracks found.")
return {'status': 'No undownloaded tracks found'}
app.logger.info(f"Found {total_tracks} tracks to download.")
processed_tracks = 0
failed_downloads = 0
for track in undownloaded_tracks:
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
if search_before_download:
app.logger.info(f"Searching for track in Jellyfin: {track.name}")
# Retrieve the Spotify track and preview URL
spotify_track = sp.track(track.spotify_track_id)
preview_url = spotify_track.get('preview_url')
if not preview_url:
app.logger.error(f"Preview URL not found for track {track.name}.")
# Decide whether to skip or proceed to download
# For now, we'll proceed to download
else:
# Get the list of Spotify artist names
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
# Perform the search in Jellyfin
match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
session_token=jellyfin_admin_token,
preview_url=preview_url,
song_name=track.name,
artist_names=spotify_artists
)
if match_found:
app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
track.downloaded = True
track.filesystem_path = jellyfin_file_path
db.session.commit()
continue
else:
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
# Attempt to download the track using spotdl
try:
app.logger.info(f"Trying to download track: {track.name} ({track.spotify_track_id}), spotdl timeout = 90")
s_url = f"https://open.spotify.com/track/{track.spotify_track_id}"
command = [
"spotdl", "download", s_url,
"--output", output_dir,
"--cookie-file", cookie_file,
"--client-id", client_id,
"--client-secret", client_secret
]
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
if result.returncode == 0 and os.path.exists(file_path):
track.downloaded = True
track.filesystem_path = file_path
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
else:
app.logger.error(f"Download failed for track {track.name}.")
failed_downloads += 1
track.download_status = result.stdout[:2048]
except Exception as e:
app.logger.error(f"Error downloading track {track.name}: {str(e)}")
failed_downloads += 1
track.download_status = str(e)[:2048]
processed_tracks += 1
progress = (processed_tracks / total_tracks) * 100
db.session.commit()
self.update_state(state='PROGRESS', meta={
'current': processed_tracks,
'total': total_tracks,
'percent': progress,
'failed': failed_downloads
})
app.logger.info("Track download job finished.")
return {
'status': 'download_missing_tracks finished',
'total': total_tracks,
'processed': processed_tracks,
'failed': failed_downloads
}
finally:
release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@celery.task(bind=True)
def check_for_playlist_updates(self):
lock_key = "check_for_playlist_updates_lock"
if acquire_lock(lock_key, expiration=600):
try:
app.logger.info('Starting playlist update check...')
with app.app_context():
playlists = Playlist.query.all()
total_playlists = len(playlists)
if not playlists:
app.logger.info("No playlists found.")
return {'status': 'No playlists found'}
app.logger.info(f"Found {total_playlists} playlists to check for updates.")
processed_playlists = 0
for playlist in playlists:
app.logger.info(f'Checking updates for playlist: {playlist.name}')
playlist.last_updated = datetime.now( timezone.utc)
db.session.commit()
try:
#region Check for updates
# Fetch all playlist data from Spotify
spotify_tracks = {}
offset = 0
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
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))
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)
# Add and remove tracks while maintaining order
if tracks_to_add:
for track, track_order in tracks_to_add:
stmt = insert(playlist_tracks).values(
playlist_id=playlist.id,
track_id=track.id,
track_order=track_order
)
db.session.execute(stmt)
db.session.commit()
app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}')
if tracks_to_remove:
for track in tracks_to_remove:
playlist.tracks.remove(track)
db.session.commit()
app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}')
#endregion
#region Update Playlist Items and Metadata
functions.update_playlist_metadata(playlist, sp.playlist(playlist.spotify_playlist_id))
ordered_tracks = db.session.execute(
db.select(Track, playlist_tracks.c.track_order)
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id)
.where(playlist_tracks.c.playlist_id == playlist.id)
.order_by(playlist_tracks.c.track_order)
).all()
tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None]
jellyfin.add_songs_to_playlist(session_token=jellyfin_admin_token, user_id=jellyfin_admin_id, playlist_id=playlist.jellyfin_id, song_ids=tracks)
#endregion
except Exception as e:
app.logger.error(f"Error updating playlist {playlist.name}: {str(e)}")
processed_playlists += 1
progress = (processed_playlists / total_playlists) * 100
# Update progress
# self.update_state(state='PROGRESS', meta={'current': processed_playlists, 'total': total_playlists, 'percent': progress})
if processed_playlists % 10 == 0 or processed_playlists == total_playlists:
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
finally:
release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@celery.task(bind=True)
def update_jellyfin_id_for_downloaded_tracks(self):
lock_key = "update_jellyfin_id_for_downloaded_tracks_lock"
if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
try:
app.logger.info("Starting Jellyfin ID update for downloaded tracks...")
with app.app_context():
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
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.")
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 = sp.track(track.spotify_track_id)
spotify_track_name = spotify_track['name']
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
spotify_album = spotify_track['album']['name']
except Exception as e:
app.logger.error(f"Error fetching track details from Spotify for {track.name}: {str(e)}")
continue
# Compare name, artists, and album (case-insensitive comparison)
if (spotify_track_name.lower() == jellyfin_track_name and
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists) ):
best_match = result
break # Stop when a match is found
# Step 4: If a match is found, update jellyfin_id
if best_match:
track.jellyfin_id = best_match['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}.")
spotify_track = None
except Exception as e:
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
processed_tracks += 1
progress = (processed_tracks / total_tracks) * 100
self.update_state(state='PROGRESS', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
app.logger.info("Finished updating Jellyfin IDs for all tracks.")
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
finally:
release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}

1
app/version.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.1.0"