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

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
# Ignore Python cache files
__pycache__/
*.pyc
*.pyo
# Ignore environment files
.env
*.env
# Ignore system files
.DS_Store
# Ignore Git files
.git
cookies*
set_env.sh
jellyplist.code-workspace

78
.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
*.egg
*.egg-info/
dist/
build/
.eggs/
# Flask instance folder
instance/
# Flask static files
staticfiles/
# Flask cache and logs
*.log
*.pot
*.pyc
*.pyo
*.pyd
*.sqlite3
*.db
*.log
__pycache__/
# Flask session files
*.session
# Pytest
.cache/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
nosetests.xml
coverage/
*.coveragerc
*.coveragerc.bak
# Jupyter Notebook
.ipynb_checkpoints
# dotenv
.env
.venv
.env.local
.env.*.local
# IDE specific
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# macOS
.DS_Store
.cache
cookies*.txt
*.code-workspace
set_env.sh

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Use the official Python image as a base image
FROM python:3.12-slim
# Set the working directory
WORKDIR /jellyplist
# Copy requirements file and install dependencies
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN apt update
RUN apt install ffmpeg netcat-openbsd -y
# Copy the application code
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose the port the app runs on
EXPOSE 5055
# Set the entrypoint
ENTRYPOINT ["/entrypoint.sh"]
# Run the application
CMD ["python", "run.py"]

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"

BIN
celerybeat-schedule Normal file

Binary file not shown.

54
config.py Normal file
View File

@@ -0,0 +1,54 @@
import os
import sys
class Config:
SECRET_KEY = os.getenv('SECRET_KEY')
JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL')
JELLYFIN_ACCESS_TOKEN = os.getenv('JELLYFIN_ACCESS_TOKEN')
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')
JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST')
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
CACHE_TYPE = 'redis'
CACHE_REDIS_PORT = 6379
CACHE_REDIS_HOST = 'redis'
CACHE_REDIS_DB = 0
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
# 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 = {
'SECRET_KEY': cls.SECRET_KEY,
'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL,
'JELLYFIN_ACCESS_TOKEN': cls.JELLYFIN_ACCESS_TOKEN,
'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,
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,
'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER,
'JELLYPLIST_DB_PASSWORD' : cls.JELLYPLIST_DB_PASSWORD,
'REDIS_URL': cls.REDIS_URL
}
missing_vars = [var for var, value in required_vars.items() if not value]
if missing_vars:
missing = ', '.join(missing_vars)
sys.stderr.write(f"Error: The following environment variables are not set: {missing}\n")
sys.exit(1)

40
entrypoint.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/sh
# Exit immediately if a command exits with a non-zero status
set -e
# Function to wait for a service to be ready
wait_for_service() {
local host="$1"
local port="$2"
local retries=30
local wait=2
until nc -z "$host" "$port" || [ "$retries" -eq 0 ]; do
echo "Waiting for $host:$port..."
sleep "$wait"
retries=$((retries - 1))
done
if [ "$retries" -eq 0 ]; then
echo "Service $host:$port is not available after waiting."
exit 1
fi
}
# Ensure the required environment variable is set
if [ -z "$JELLYPLIST_DB_HOST" ]; then
echo "Environment variable JELLYPLIST_DB_HOST is not set. Exiting."
exit 1
fi
# Wait for PostgreSQL to be ready using the environment variable
wait_for_service "$JELLYPLIST_DB_HOST" 5432
# Apply database migrations
echo "Applying database migrations..."
flask db upgrade
# Start the Flask application
echo "Starting Flask application..."
exec "$@"

536
jellyfin/client.py Normal file
View File

@@ -0,0 +1,536 @@
import os
import re
import subprocess
import tempfile
import numpy as np
import requests
import base64
from urllib.parse import quote
import acoustid
import chromaprint
from jellyfin.objects import PlaylistMetadata
def _clean_query(query):
# Regex to match any word containing problematic characters: ', `, or ´
pattern = re.compile(r"[`´']")
# Split the query into words and filter out words with problematic characters
cleaned_words = [word for word in query.split() if not pattern.search(word)]
# Join the cleaned words back into a query string
cleaned_query = " ".join(cleaned_words)
return cleaned_query
class JellyfinClient:
def __init__(self, base_url):
"""
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
def _get_headers(self, session_token: str):
"""
Get the authentication headers for requests.
:return: A dictionary of headers
"""
return {
'X-Emby-Token': session_token,
}
def login_with_password(self, username: str, password: str, device_id = 'JellyPlist'):
"""
Log in to Jellyfin using a username and password.
:param username: The username of the user.
:param password: The password of the user.
:return: Access token and user ID
"""
login_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"'
}
data = {
'Username': username,
'Pw': password
}
response = requests.post(login_url, json=data, headers=headers)
if response.status_code == 200:
result = response.json()
return result['AccessToken'], result['User']['Id'], result['User']['Name'],result['User']['Policy']['IsAdministrator']
else:
raise Exception(f"Login failed: {response.content}")
def create_music_playlist(self, session_token: str, name: str, song_ids, user_id : str):
"""
Create a new music playlist.
:param access_token: The access token of the authenticated user.
:param user_id: The user ID.
:param name: The name of the playlist.
: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'
data = {
'Name': name,
'UserId': user_id,
'MediaType': 'Audio',
'Ids': ','.join(song_ids), # Join song IDs with commas
'IsPublic' : False
}
response = requests.post(create_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to create playlist: {response.content}")
def update_music_playlist(self, session_token: str, playlist_id: str, song_ids):
"""
Update an existing music playlist by adding or removing songs.
:param playlist_id: The ID of the playlist to update.
: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'
data = {
'Ids': ','.join(song_ids) # Join song IDs with commas
}
response = requests.post(update_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
if response.status_code == 204: # 204 No Content indicates success for updating
return {"status": "success", "message": "Playlist updated successfully"}
else:
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}'
params = {
'UserId' : user_id
}
response = requests.get(playlist_metadata_url, headers=self._get_headers(session_token=session_token), timeout=10, params = params)
if response.status_code != 200:
raise Exception(f"Failed to fetch playlist metadata: {response.content}")
return PlaylistMetadata( response.json())
def update_playlist_metadata(self, session_token: str, user_id: str, playlist_id: str, updates: PlaylistMetadata):
"""
Update the metadata of an existing playlist using a PlaylistMetadata object.
:param session: The user's session containing the Jellyfin access token.
:param user_id: the user id, since we are updating the playlist using an api key, we do it with the user id of the first logged in admin
:param playlist_id: The ID of the playlist to update.
:param updates: A PlaylistMetadata object containing the metadata to update.
:return: Response data indicating the result of the update operation.
"""
# Fetch the existing metadata for the playlist
params = {
'UserId' : user_id
}
# Initialize PlaylistMetadata with current data and apply updates
metadata_obj = self.get_playlist_metadata(session_token=session_token, user_id= user_id, playlist_id= playlist_id)
# Update only the provided fields in the updates object
for key, value in updates.to_dict().items():
if value is not None:
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)
if response.status_code == 204:
return {"status": "success", "message": "Playlist metadata updated successfully"}
else:
raise Exception(f"Failed to update playlist metadata: {response.content} \nReason: {response.reason}")
def get_playlists(self, session_token: str):
"""
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'
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)
if response.status_code == 200:
return response.json()['Items']
else:
raise Exception(f"Failed to get playlists: {response.content}")
def search_music_tracks(self, session_token: str, search_query: str):
"""
Search for music tracks by title, song name, and optionally Spotify-ID.
:param search_query: The search term (title or song name).
:return: A list of matching songs.
"""
search_url = f'{self.base_url}/Items'
params = {
'SearchTerm': _clean_query(search_query),
'IncludeItemTypes': 'Audio', # Search only for audio items
'Recursive': 'true', # Search within all folders
'Fields': 'Name,Id,Album,Artists,Path' # Retrieve the name and ID of the song
}
response = requests.get(search_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
if response.status_code == 200:
return response.json()['Items']
else:
raise Exception(f"Failed to search music tracks: {response.content}")
def add_songs_to_playlist(self, session_token: str, user_id: str, playlist_id: str, song_ids: list[str]):
"""
Add songs to an existing playlist.
:param playlist_id: The ID of the playlist to update.
:param song_ids: A list of song IDs to add.
:return: A success message.
"""
# Construct the API URL with query parameters
add_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = {
'ids': ','.join(song_ids), # Comma-separated song IDs
'userId': user_id
}
# 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)
# Check for success
if response.status_code == 204: # 204 No Content indicates success
return {"status": "success", "message": "Songs added to playlist successfully"}
else:
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}")
def remove_songs_from_playlist(self, session_token: str, playlist_id: str, song_ids):
"""
Remove songs from an existing playlist.
:param playlist_id: The ID of the playlist to update.
:param song_ids: A list of song IDs to remove.
:return: A success message.
"""
remove_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
params = {
'EntryIds': ','.join(song_ids) # Join song IDs with commas
}
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
if response.status_code == 204: # 204 No Content indicates success for updating
return {"status": "success", "message": "Songs removed from playlist successfully"}
else:
raise Exception(f"Failed to remove songs from playlist: {response.content}")
def remove_item(self, session_token: str, playlist_id: str):
"""
Remove an existing playlist by its ID.
:param playlist_id: The ID of the playlist to remove.
:return: A success message upon successful deletion.
"""
remove_url = f'{self.base_url}/Items/{playlist_id}'
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), timeout=10)
if response.status_code == 204: # 204 No Content indicates successful deletion
return {"status": "success", "message": "Playlist removed successfully"}
else:
raise Exception(f"Failed to remove playlist: {response.content}")
def remove_user_from_playlist(self, session_token: str, playlist_id: str, user_id: str):
"""
Remove a user from a playlist.
:param session: The user's session containing the Jellyfin access token.
:param playlist_id: The ID of the playlist from which to remove the user.
:param user_id: The ID of the user to be removed from the playlist.
:return: Success message or raises an exception on failure.
"""
# Construct the API endpoint URL
url = f'{self.base_url}/Playlists/{playlist_id}/Users/{user_id}'
# 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)
if response.status_code == 204:
# 204 No Content indicates the user was successfully removed
return {"status": "success", "message": f"User {user_id} removed from playlist {playlist_id}"}
else:
# Raise an exception if the request failed
raise Exception(f"Failed to remove user from playlist: {response.content}")
def set_playlist_cover_image(self, session_token: str, playlist_id: str, spotify_image_url: str):
"""
Set the cover image of a playlist in Jellyfin using an image URL from Spotify.
:param session: The user's session containing the Jellyfin access token.
:param playlist_id: The ID of the playlist in Jellyfin.
:param spotify_image_url: The URL of the image from Spotify.
: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)
if response.status_code != 200:
raise Exception(f"Failed to download image from Spotify: {response.content}")
# Step 2: Check the image content type (assume it's JPEG or PNG based on the content type from the response)
content_type = response.headers.get('Content-Type')
if content_type not in ['image/jpeg', 'image/png', 'application/octet-stream']:
raise Exception(f"Unsupported image format: {content_type}")
# Todo:
if content_type == 'application/octet-stream':
content_type = 'image/jpeg'
# Step 3: Encode the image content as Base64
image_base64 = base64.b64encode(response.content).decode('utf-8')
# Step 4: Prepare the headers for the Jellyfin API request
headers = self._get_headers(session_token= session_token)
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'
# Send the Base64-encoded image data
upload_response = requests.post(upload_url, headers=headers, data=image_base64, timeout=10)
if upload_response.status_code == 204: # 204 No Content indicates success
return {"status": "success", "message": "Playlist cover image updated successfully"}
else:
raise Exception(f"Failed to upload image to Jellyfin: {upload_response.status_code} - {upload_response.content}")
def add_users_to_playlist(self, session_token: str,user_id: str, playlist_id: str, user_ids: list[str], can_edit: bool = False):
"""
Add users to a Jellyfin playlist with no editing rights by default.
:param session: The user's session containing the Jellyfin access token.
:param playlist_id: The ID of the playlist in Jellyfin.
:param user_ids: List of user IDs to add to the playlist.
:param can_edit: Set to True if users should have editing rights (default is False).
:return: Success message or raises an exception on failure.
"""
# FOr some reason when updating the users, all metadata gets wiped
metadata = self.get_playlist_metadata(session_token= session_token, user_id= user_id, playlist_id= playlist_id)
# Construct the API URL
url = f'{self.base_url}/Playlists/{playlist_id}'
users_data = [{'UserId': user_id, 'CanEdit': can_edit} for user_id in user_ids]
# get current users:
current_users = self.get_playlist_users(session_token=session_token, playlist_id= playlist_id)
for cu in current_users:
users_data.append({'UserId': cu['UserId'], 'CanEdit': cu['CanEdit']})
data = {
'Users' : users_data
}
# Prepare the headers
headers = self._get_headers(session_token=session_token)
# Send the request to Jellyfin API
response = requests.post(url, headers=headers, json=data,timeout = 10)
# Check for success
if response.status_code == 204:
self.update_playlist_metadata(session_token= session_token, user_id= user_id, playlist_id= playlist_id , updates= metadata)
return {"status": "success", "message": f"Users added to playlist {playlist_id}."}
else:
raise Exception(f"Failed to add users to playlist: {response.status_code} - {response.content}")
def get_me(self, session_token: str):
"""
"""
me_url = f'{self.base_url}/Users/Me'
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = 10)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get playlists: {response.content}")
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)
if response.status_code != 200:
raise Exception(f"Failed to fetch playlist metadata: {response.content}")
return response.json()
def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list):
"""
Search for a track in Jellyfin by comparing the preview audio to tracks in the library.
:param session_token: The session token for Jellyfin API access.
:param preview_url: The URL to the Spotify preview audio.
:param song_name: The name of the song to search for.
:param artist_names: A list of artist names.
:return: Tuple (match_found: bool, jellyfin_file_path: Optional[str])
"""
try:
# Download the Spotify preview audio
tmp = self.download_preview_to_tempfile(preview_url=preview_url)
if tmp is None:
return False, None
# Convert the preview file to a normalized WAV file
tmp_wav = self.convert_to_wav(tmp)
if tmp_wav is None:
os.remove(tmp)
return False, None
# Fingerprint the normalized preview WAV file
_, 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)
# Search for matching tracks in Jellyfin using only the song name
search_query = song_name # Only use the song name in the search query
jellyfin_results = self.search_music_tracks(session_token, search_query)
matches = []
# Prepare the list of Spotify artists in lowercase
spotify_artists = [artist.lower() for artist in artist_names]
for result in jellyfin_results:
jellyfin_artists = [artist.lower() for artist in result.get("Artists", [])]
# Check for matching artists u
artist_match = any(artist in spotify_artists for artist in jellyfin_artists)
if not artist_match:
continue # Skip if no artist matches
jellyfin_file_path = result.get("Path")
if not jellyfin_file_path:
continue
# Convert the full Jellyfin track to a normalized WAV file
jellyfin_wav = self.convert_to_wav(jellyfin_file_path)
if jellyfin_wav is None:
continue
# Fingerprint the normalized Jellyfin WAV file
_, full_fp = acoustid.fingerprint_file(jellyfin_wav)
full_fp_dec, version2 = chromaprint.decode_fingerprint(full_fp)
full_fp_dec = np.array(full_fp_dec, dtype=np.uint32)
# Compare fingerprints using the sliding similarity function
sim, best_offset = self.sliding_fingerprint_similarity(full_fp_dec, tmp_fp_dec)
# Clean up temporary files
os.remove(jellyfin_wav)
# Store the match data
matches.append({
'jellyfin_file_path': jellyfin_file_path,
'similarity': sim,
'best_offset': best_offset,
'track_name': result.get('Name'),
'artists': jellyfin_artists,
})
# Clean up the preview files
os.remove(tmp_wav)
os.remove(tmp)
# After processing all tracks, select the best match
if matches:
best_match = max(matches, key=lambda x: x['similarity'])
if best_match['similarity'] > 60: # Adjust the threshold as needed
return True, best_match['jellyfin_file_path']
else:
return False, None
else:
return False, None
except Exception as e:
# Log the error (assuming you have a logging mechanism)
print(f"Error in search_track_in_jellyfin: {str(e)}")
return False, None
# Helper methods used in search_track_in_jellyfin
def download_preview_to_tempfile(self, preview_url):
try:
response = requests.get(preview_url, timeout=10)
if response.status_code != 200:
return None
# Save to a temporary file
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3")
tmp_file.write(response.content)
tmp_file.close()
return tmp_file.name
except Exception as e:
print(f"Error downloading preview: {str(e)}")
return None
def convert_to_wav(self, input_file_path):
try:
output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
output_file.close()
# Use ffmpeg to convert to WAV and normalize audio
command = [
"ffmpeg", "-y", "-i", input_file_path,
"-acodec", "pcm_s16le", "-ar", "44100",
"-ac", "2", output_file.name
]
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
os.remove(output_file.name)
return None
return output_file.name
except Exception as e:
print(f"Error converting to WAV: {str(e)}")
return None
def sliding_fingerprint_similarity(self, full_fp, preview_fp):
len_full = len(full_fp)
len_preview = len(preview_fp)
best_score = float('inf')
best_offset = 0
max_offset = len_full - len_preview
if max_offset < 0:
return 0, 0
total_bits = len_preview * 32 # Total bits in the preview fingerprint
for offset in range(max_offset + 1):
segment = full_fp[offset:offset + len_preview]
xored = np.bitwise_xor(segment, preview_fp)
diff_bits = np.unpackbits(xored.view(np.uint8)).sum()
score = diff_bits / total_bits # Lower score is better
if score < best_score:
best_score = score
best_offset = offset
similarity = (1 - best_score) * 100 # Convert to percentage
return similarity, best_offset

89
jellyfin/objects.py Normal file
View File

@@ -0,0 +1,89 @@
from ast import List
class PlaylistMetadata:
def __init__(self, playlist_data=None):
# Initialize with existing data if available, otherwise use default values
self.Id = playlist_data.get("Id") if playlist_data else None
self.Name = playlist_data.get("Name", "") if playlist_data else None
self.OriginalTitle = playlist_data.get("OriginalTitle", "") if playlist_data else None
self.ForcedSortName = playlist_data.get("ForcedSortName", "") if playlist_data else None
self.CommunityRating = playlist_data.get("CommunityRating", "") if playlist_data else None
self.CriticRating = playlist_data.get("CriticRating", "") if playlist_data else None
self.IndexNumber = playlist_data.get("IndexNumber", None) if playlist_data else None
self.AirsBeforeSeasonNumber = playlist_data.get("AirsBeforeSeasonNumber", "") if playlist_data else None
self.AirsAfterSeasonNumber = playlist_data.get("AirsAfterSeasonNumber", "") if playlist_data else None
self.AirsBeforeEpisodeNumber = playlist_data.get("AirsBeforeEpisodeNumber", "") if playlist_data else None
self.ParentIndexNumber = playlist_data.get("ParentIndexNumber", None) if playlist_data else None
self.DisplayOrder = playlist_data.get("DisplayOrder", "") if playlist_data else None
self.Album = playlist_data.get("Album", "") if playlist_data else None
self.AlbumArtists = playlist_data.get("AlbumArtists", []) if playlist_data else []
self.ArtistItems = playlist_data.get("ArtistItems", []) if playlist_data else []
self.Overview = playlist_data.get("Overview", "") if playlist_data else None
self.Status = playlist_data.get("Status", "") if playlist_data else None
self.AirDays = playlist_data.get("AirDays", []) if playlist_data else []
self.AirTime = playlist_data.get("AirTime", "") if playlist_data else None
self.Genres = playlist_data.get("Genres", []) if playlist_data else []
self.Tags = playlist_data.get("Tags", []) if playlist_data else list[str]
self.Studios = playlist_data.get("Studios", []) if playlist_data else []
self.PremiereDate = playlist_data.get("PremiereDate", None) if playlist_data else None
self.DateCreated = playlist_data.get("DateCreated", None) if playlist_data else None
self.EndDate = playlist_data.get("EndDate", None) if playlist_data else None
self.ProductionYear = playlist_data.get("ProductionYear", "") if playlist_data else None
self.Height = playlist_data.get("Height", "") if playlist_data else None
self.AspectRatio = playlist_data.get("AspectRatio", "") if playlist_data else None
self.Video3DFormat = playlist_data.get("Video3DFormat", "") if playlist_data else None
self.OfficialRating = playlist_data.get("OfficialRating", "") if playlist_data else None
self.CustomRating = playlist_data.get("CustomRating", "") if playlist_data else None
self.People = playlist_data.get("People", []) if playlist_data else []
self.LockData = playlist_data.get("LockData", False) if playlist_data else False
self.LockedFields = playlist_data.get("LockedFields", []) if playlist_data else []
self.ProviderIds = playlist_data.get("ProviderIds", {}) if playlist_data else {}
self.PreferredMetadataLanguage = playlist_data.get("PreferredMetadataLanguage", "") if playlist_data else None
self.PreferredMetadataCountryCode = playlist_data.get("PreferredMetadataCountryCode", "") if playlist_data else None
self.Taglines = playlist_data.get("Taglines", []) if playlist_data else []
def to_dict(self):
"""
Converts the PlaylistMetadata object to a dictionary.
"""
return {
"Id": self.Id,
"Name": self.Name,
"OriginalTitle": self.OriginalTitle,
"ForcedSortName": self.ForcedSortName,
"CommunityRating": self.CommunityRating,
"CriticRating": self.CriticRating,
"IndexNumber": self.IndexNumber,
"AirsBeforeSeasonNumber": self.AirsBeforeSeasonNumber,
"AirsAfterSeasonNumber": self.AirsAfterSeasonNumber,
"AirsBeforeEpisodeNumber": self.AirsBeforeEpisodeNumber,
"ParentIndexNumber": self.ParentIndexNumber,
"DisplayOrder": self.DisplayOrder,
"Album": self.Album,
"AlbumArtists": self.AlbumArtists,
"ArtistItems": self.ArtistItems,
"Overview": self.Overview,
"Status": self.Status,
"AirDays": self.AirDays,
"AirTime": self.AirTime,
"Genres": self.Genres,
"Tags": self.Tags,
"Studios": self.Studios,
"PremiereDate": self.PremiereDate,
"DateCreated": self.DateCreated,
"EndDate": self.EndDate,
"ProductionYear": self.ProductionYear,
"Height": self.Height,
"AspectRatio": self.AspectRatio,
"Video3DFormat": self.Video3DFormat,
"OfficialRating": self.OfficialRating,
"CustomRating": self.CustomRating,
"People": self.People,
"LockData": self.LockData,
"LockedFields": self.LockedFields,
"ProviderIds": self.ProviderIds,
"PreferredMetadataLanguage": self.PreferredMetadataLanguage,
"PreferredMetadataCountryCode": self.PreferredMetadataCountryCode,
"Taglines": self.Taglines,
}

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,75 @@
"""Added Playlist and Track models, relationships
Revision ID: 05f2ef26e1a8
Revises: dfcca9d99ce7
Create Date: 2024-10-10 12:09:05.659154
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '05f2ef26e1a8'
down_revision = 'dfcca9d99ce7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('jellyfin_user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('jellyfin_user_id', sa.String(length=120), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('jellyfin_user_id'),
sa.UniqueConstraint('name')
)
op.create_table('playlist',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('spotify_playlist_id', sa.String(length=120), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('spotify_playlist_id')
)
op.create_table('track',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('spotify_track_id', sa.String(length=120), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('spotify_track_id')
)
op.create_table('playlist_tracks',
sa.Column('playlist_id', sa.Integer(), nullable=False),
sa.Column('track_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
sa.ForeignKeyConstraint(['track_id'], ['track.id'], ),
sa.PrimaryKeyConstraint('playlist_id', 'track_id')
)
op.create_table('user_playlists',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['jellyfin_user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'playlist_id')
)
op.drop_table('user')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=120), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='user_pkey'),
sa.UniqueConstraint('email', name='user_email_key')
)
op.drop_table('user_playlists')
op.drop_table('playlist_tracks')
op.drop_table('track')
op.drop_table('playlist')
op.drop_table('jellyfin_user')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Add jellyfin_id to Track and user_playlists
Revision ID: 2b8f75ed0a54
Revises: 70b2970ce195
Create Date: 2024-10-22 17:16:37.844073
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2b8f75ed0a54'
down_revision = '70b2970ce195'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add track_order
Revision ID: 30e948342792
Revises: 95f1a12a7da3
Create Date: 2024-10-23 13:56:43.511626
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30e948342792'
down_revision = '95f1a12a7da3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist_tracks', schema=None) as batch_op:
batch_op.add_column(sa.Column('track_order', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist_tracks', schema=None) as batch_op:
batch_op.drop_column('track_order')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""extend download status
Revision ID: 41cfd15a9050
Revises: a1478d4d2c47
Create Date: 2024-10-26 11:35:42.853550
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '41cfd15a9050'
down_revision = 'a1478d4d2c47'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.alter_column('download_status',
existing_type=sa.VARCHAR(length=200),
type_=sa.String(length=2048),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.alter_column('download_status',
existing_type=sa.String(length=2048),
type_=sa.VARCHAR(length=200),
existing_nullable=True)
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Added spotify_u12ri
Revision ID: 52d71c294c8f
Revises: 8843534fa7ea
Create Date: 2024-10-10 13:38:07.247311
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '52d71c294c8f'
down_revision = '8843534fa7ea'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('downloaded', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('filesystem_path', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('filesystem_path')
batch_op.drop_column('downloaded')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Add jellyfin_id to Playlists, remove jellyfin_id from user_playlists, the second again
Revision ID: 5a06403b57cf
Revises: 6089eb604dbf
Create Date: 2024-10-23 08:32:11.962812
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5a06403b57cf'
down_revision = '6089eb604dbf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.VARCHAR(length=120), autoincrement=False, nullable=True))
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Add jellyfin_id to Track and user_playlists, second
Revision ID: 6089eb604dbf
Revises: 7cc06fc32cc7
Create Date: 2024-10-22 21:08:41.605515
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6089eb604dbf'
down_revision = '7cc06fc32cc7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.VARCHAR(length=120), autoincrement=False, nullable=True))
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Add track_count and tracks_available to Playlist
Revision ID: 70b2970ce195
Revises: 52d71c294c8f
Create Date: 2024-10-10 21:37:22.419162
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '70b2970ce195'
down_revision = '52d71c294c8f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('track_count', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('tracks_available', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.drop_column('tracks_available')
batch_op.drop_column('track_count')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Add jellyfin_id to Playlists, remove jellyfin_id from user_playlists
Revision ID: 7cc06fc32cc7
Revises: 2b8f75ed0a54
Create Date: 2024-10-22 17:24:39.761794
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7cc06fc32cc7'
down_revision = '2b8f75ed0a54'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('jellyfin_id', sa.VARCHAR(length=120), autoincrement=False, nullable=True))
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.drop_column('jellyfin_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,42 @@
"""Added spotify_uri
Revision ID: 8843534fa7ea
Revises: 05f2ef26e1a8
Create Date: 2024-10-10 13:27:34.675770
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8843534fa7ea'
down_revision = '05f2ef26e1a8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('spotify_uri', sa.String(length=120), nullable=False))
batch_op.create_unique_constraint(None, ['spotify_uri'])
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('spotify_uri', sa.String(length=120), nullable=False))
batch_op.create_unique_constraint(None, ['spotify_uri'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='unique')
batch_op.drop_column('spotify_uri')
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='unique')
batch_op.drop_column('spotify_uri')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Add last_updated and last_changed fields to Playlist model
Revision ID: 8fcd403f0c45
Revises: 41cfd15a9050
Create Date: 2024-11-21 16:09:39.875467
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8fcd403f0c45'
down_revision = '41cfd15a9050'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.add_column(sa.Column('last_updated', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('last_changed', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist', schema=None) as batch_op:
batch_op.drop_column('last_changed')
batch_op.drop_column('last_updated')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add is_admin
Revision ID: 95f1a12a7da3
Revises: 5a06403b57cf
Create Date: 2024-10-23 09:55:36.772967
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '95f1a12a7da3'
down_revision = '5a06403b57cf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('jellyfin_user', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('jellyfin_user', schema=None) as batch_op:
batch_op.drop_column('is_admin')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add download_Status
Revision ID: a1478d4d2c47
Revises: 30e948342792
Create Date: 2024-10-25 19:37:41.898682
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1478d4d2c47'
down_revision = '30e948342792'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('download_status', sa.String(length=200), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('download_status')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Initial
Revision ID: dfcca9d99ce7
Revises:
Create Date: 2024-10-08 21:18:33.666046
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dfcca9d99ce7'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
# ### end Alembic commands ###

0
readme.md Normal file
View File

20
requirements.txt Normal file
View File

@@ -0,0 +1,20 @@
alembic==1.13.3
celery==5.4.0
Flask==3.1.0
Flask_Caching==2.3.0
Flask_Migrate==4.0.7
Flask_SocketIO==5.4.1
flask_sqlalchemy==3.1.1
numpy==2.1.3
pyacoustid==1.3.0
redis==5.1.1
Requests==2.32.3
spotdl==4.2.10
spotipy==2.24.0
SQLAlchemy==2.0.35
Unidecode==1.3.8
chromaprint
psycopg2-binary
eventlet
pydub
fuzzywuzzy

5
run.py Normal file
View File

@@ -0,0 +1,5 @@
from app import app, socketio
if __name__ == '__main__':
#app.run(debug=True, port=5001,host="0.0.0.0")
socketio.run(app,host="0.0.0.0", port = 5055)

55
static/css/styles.css Normal file
View File

@@ -0,0 +1,55 @@
body {
/* background-color: #141A32; */
}
.sidebar {
background-color: #1a1d21;
height: 100vh;
padding-top: 20px;
padding-left: 10px;
color: white;
}
.top-bar {
background-color: #1a1d21;
}
.sidebar h3 {
color: white;
padding-left: 15px;
}
.nav-link {
color: #c2c7d0;
display: flex;
align-items: center;
padding: 10px 15px;
}
.nav-link:hover {
background-color: #343a40;
border-radius: 5px;
}
.nav-link.active {
background-color: #6f42c1;
border-radius: 5px;
color: white;
}
.nav-link i {
margin-right: 10px;
}
.sidebar-logo {
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-logo img {
width: 140px;
margin-right: 10px;
}
.logo img{
width: 100%;
}

BIN
static/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

86
static/js/preview.js Normal file
View File

@@ -0,0 +1,86 @@
// Initialize all tooltips on the page
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// Function to open the search modal and trigger the search automatically
function openSearchModal(trackTitle, spotify_id) {
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
const searchQueryInput = document.getElementById('search-query');
const spotifyIdInput = document.getElementById('spotify-id');
// Pre-fill the input fields
searchQueryInput.value = trackTitle;
spotifyIdInput.value = spotify_id;
// Show the modal
modal.show();
setTimeout(() => {
searchQueryInput.form.requestSubmit(); // Trigger the form submission
}, 200); // Delay the search slightly to ensure the modal is visible before searching
}
let currentAudio = null;
let currentButton = null;
function playPreview(button, previewUrl) {
if (currentAudio) {
currentAudio.pause();
if (currentButton) {
currentButton.innerHTML = '<i class="fas fa-play"></i>';
}
}
if (currentAudio && currentAudio.src === previewUrl) {
currentAudio = null;
currentButton = null;
} else {
currentAudio = new Audio(previewUrl);
currentAudio.play();
currentButton = button;
button.innerHTML = '<i class="fas fa-pause"></i>';
currentAudio.onended = function () {
button.innerHTML = '<i class="fas fa-play"></i>';
currentAudio = null;
currentButton = null;
};
}
}
function playJellyfinTrack(button, jellyfinId) {
if (currentAudio && currentButton === button) {
currentAudio.pause();
currentAudio = null;
currentButton.innerHTML = '<i class="fas fa-play"></i>';
currentButton = null;
return;
}
if (currentAudio) {
currentAudio.pause();
if (currentButton) {
currentButton.innerHTML = '<i class="fas fa-play"></i>';
}
}
fetch(`/get_jellyfin_stream/${jellyfinId}`)
.then(response => response.json())
.then(data => {
const streamUrl = data.stream_url;
currentAudio = new Audio(streamUrl);
currentAudio.play();
currentButton = button;
button.innerHTML = '<i class="fas fa-stop"></i>';
currentAudio.onended = function () {
button.innerHTML = '<i class="fas fa-play"></i>';
currentAudio = null;
currentButton = null;
};
})
.catch(error => console.error('Error fetching Jellyfin stream URL:', error));
}

20
templates/admin.html Normal file
View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block content %}
<nav class="navbar navbar-expand-lg navbar-dark border-bottom">
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="/admin/link_issues">Link Issues
{% include 'partials/_unlinked_tracks_badge.html' %}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/tasks">Tasks</a>
</li>
</ul>
</div>
</nav>
{% block admin_content %}
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "admin.html" %}
{% block admin_content %}
<h4>Resolve Issues which occured during the linking between a spotify track and jellyfin track</h4>
{% include 'partials/_track_table.html' %}
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "admin.html" %}
{% block admin_content %}
<div class="container mt-5">
<!-- Tabelle für den Task-Status -->
<table class="table">
<thead>
<tr>
<th>Task Name</th>
<th>Status</th>
<th>Progress</th>
<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>
{% endblock %}

171
templates/base.html Normal file
View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Icons (optional) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
<script src="https://unpkg.com/htmx.org"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
</head>
<body>
<!-- Sidebar container with static sidebar for larger screens and offcanvas for mobile -->
<div class="d-flex">
<!-- Offcanvas sidebar for mobile screens -->
<div class="offcanvas offcanvas-start d-md-none" tabindex="-1" id="mobileSidebar"
aria-labelledby="mobileSidebarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="mobileSidebarLabel">Jellyplist</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<nav>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/categories"><i class="fa-solid fa-layer-group"></i>
Categories</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/playlists/monitored"><i
class="fa-solid fa-tower-observation"></i> Monitored</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
Playlists</a>
</li>
{% if session.get('is_admin') and session.get('debug') %}
<li class="nav-item">
<a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/logout"><i class="fas fa-sign-in-alt"></i> Logout</a>
</li>
</ul>
</nav>
</div>
</div>
<!-- Sidebar for larger screens -->
<div class="d-none d-md-block sidebar p-4 vh-100 sticky-top">
<nav>
<div class="sidebar-logo mb-4 text-center text-white">
<img src="/static/images/logo_large.png" alt="Logo">
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link text-white" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/categories"><i class="fa-solid fa-layer-group"></i>
Categories</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/playlists/monitored"><i
class="fa-solid fa-tower-observation"></i> Monitored </a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
Playlists</a>
</li>
{% if session.get('is_admin') %}
<li class="nav-item">
<a class="nav-link text-white" href="/admin"><i class="fas fa-flask"></i> Admin
{% include 'partials/_unlinked_tracks_badge.html' %}
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link text-white" href="/logout"><i class="fas fa-sign-in-alt"></i> Logout</a>
</li>
</ul>
</nav>
<span class="fixed-bottom m-3">{{version}}</span>
</div>
<!-- Main content with toggle button for mobile sidebar -->
<div class="container-fluid p-4 ">
<div class="d-flex justify-content-between align-items-center">
<!-- Toggle button for mobile sidebar -->
<button class="btn btn-primary d-md-none" type="button" data-bs-toggle="offcanvas"
data-bs-target="#mobileSidebar" aria-controls="mobileSidebar">
<i class="fas fa-bars"></i>
</button>
<h1 class="mb-4 ms-3">{{ title }}</h1>
<div class="d-flex align-items-center ">
<form action="/search" method="GET" class="w-100">
<div class="input-group">
<input
type="search"
class="form-control"
name="query"
placeholder="Search Spotify..."
aria-label="Search"
>
<button class="btn btn-primary" type="submit">Search</button>
</div>
</form>
<div class="ms-4">
<!-- Display Initials Badge -->
<span >{{ session.get('jellyfin_user_name') }}</span>
</div>
</div>
</div>
{% block content %}{% endblock %}
</div>
</div>
<div id="alerts"></div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener("showToastMessages", function () {
console.log("showToastMessages")
var toastElements = document.querySelectorAll('.toast');
console.log(toastElements)
toastElements.forEach(function (toastNode) {
var toast = new bootstrap.Toast(toastNode);
console.log(toast);
toast.show();
});
});
document.addEventListener("htmx:afterSwap", (event) => {
console.log("htmx.afterswap")
var toastElements = document.querySelectorAll('.toast');
console.log(toastElements)
toastElements.forEach(function (toastNode) {
var toast = new bootstrap.Toast(toastNode);
console.log(toast);
toast.show();
});
});
</script>
<script src="/static/js/preview.js"></script>
{% block scripts %}
{% endblock %}
</body>
</html>

6
templates/index.html Normal file
View File

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
{% endblock %}

12
templates/items.html Normal file
View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1 class="mb-4">{{ items_title }}</h1>
<h6 class="mb-4">{{ items_subtitle }}</h6>
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
{% include 'partials/_spotify_items.html' %}
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<h1 >Your subscribed Jellyfin Playlists</h1>
<h6 ></h6>
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
{% for item in playlists %}
{% include 'partials/_spotify_item.html' %}
{% endfor %}
</div>
{% endblock %}

55
templates/login.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jellyplist Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
</head>
<body>
<section class="vh-100" >
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card shadow " >
<div class="card-header logo">
<img src="/static/images/logo_large.png" alt="Logo">
</div>
<div class="card-body p-5 text-center">
<h4 class="mb-5">Login using your Jellyfin Credentials</h4>
<form method="POST" action="/login">
<div class="mb-4">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control form-control-lg" id="username" name="username" required>
</div>
<div class="mb-4">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control form-control-lg" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-lg">Login</button>
</form>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="alert alert-danger mt-3" role="alert">
{{ messages[0][1] }}
</div>
{% endif %}
{% endwith %}
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{% if item.can_add %}
<button class="btn btn-success" hx-post="/addplaylist" hx-include="this" hx-swap="outerHTML" hx-target="this"
data-bs-toggle="tooltip" title="Add to my Jellyfin"
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']) }}"
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 %}

View File

@@ -0,0 +1,37 @@
<div id="empty"></div>
<div class="search-results">
{% if results %}
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Artist(s)</th>
<th>Path</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{% for track in results %}
<tr>
<td>{{ track.Name }}</td>
<td>{{ ', '.join(track.Artists) }}</td>
<td>{{ track.Path}}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="playJellyfinTrack(this, '{{ track.Id }}')">
<i class="fas fa-play"></i>
</button>
</td>
<td>
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","spotify_id": "{{ spotify_id}}"}'>
Associate Track
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No results found.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,14 @@
<div class="d-flex align-items-center row sticky-top py-3 mb-3 bg-dark" style="top: 0; z-index: 1000;">
<div class="col-6">
<img src="{{ playlist_cover }}" class="figure-img">
</div>
<div class="col-6">
<div class="playlist-info">
<h1>{{ playlist_name }}</h1>
<p>{{ playlist_description }}</p>
<p>{{ track_count }} songs, {{ total_duration }}</p>
<p>Last Updated: {{ last_updated}} | Last Change: {{ last_changed}}</p>
{% include 'partials/_add_remove_button.html' %}
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
{% if playlists %}
<h1 class="mb-4">Playlists</h1>
<div class="row row-cols-1 row-cols-md-4 g-4">
{% for item in playlists %}
{% include 'partials/_spotify_item.html' %}
{% endfor %}
</div>
{% endif %}
{% if albums %}
<h1 class="mb-4">Albums</h1>
<div class="row row-cols-1 row-cols-md-4 g-4">
{% for item in albums %}
{% include 'partials/_spotify_item.html' %}
{% endfor %}
</div>
{% endif %}
{% if artists %}
<h1 class="mb-4">Artists</h1>
<div class="row row-cols-1 row-cols-md-4 g-4">
{% for item in artists %}
{% include 'partials/_spotify_item.html' %}
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,49 @@
<div class="col">
<div class="card h-100 d-flex flex-column position-relative">
<!-- Badge: Only show if status is available (i.e., playlist has been requested) -->
{% if item.status %}
<span style="z-index: 99;" class="badge position-absolute top-0 end-0 m-2
{% if item.status == 'green' %} bg-success
{% elif item.status == 'yellow' %} bg-warning text-dark
{% else %} bg-danger {% endif %}" data-bs-toggle="tooltip" title="{% if item.track_count > 0 %}
{{ item.tracks_available }} Track Available / {{ item.tracks_linked}} Tracks linked/ {{ item.track_count}} Total
{%endif%}
">
{% if item.track_count > 0 %}
{{ item.tracks_available }} / {{ item.tracks_linked}} / {{ item.track_count}}
{% else %}
not Available
{% endif %}
</span>
{% endif %}
<!-- Card Image -->
<div style="position: relative;">
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
</div>
<!-- Card Body -->
<div class="card-body d-flex flex-column justify-content-between">
<div>
<h5 class="card-title">{{ item.name }}</h5>
<p class="card-text">{{ item.description }}</p>
</div>
<div class="mt-auto pt-3">
{% if item.type == 'category'%}
<a href="{{ item.url }}" class="btn btn-primary" data-bs-toggle="tooltip" title="View Playlists">
<i class="fa-solid fa-eye"></i>
</a>
{%else%}
<a href="/playlist/view/{{ item.id }}" class="btn btn-primary" data-bs-toggle="tooltip"
title="View Playlist details">
<i class="fa-solid fa-eye"></i>
</a>
{%endif%}
{% include 'partials/_add_remove_button.html' %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
{% for item in items %}
{% include 'partials/_spotify_item.html' %}
{% endfor %}
{% if next_offset < total_items %}
<div hx-get="{{ endpoint }}?offset={{ next_offset }}{{ additional_query }}"
hx-trigger="revealed"
hx-swap="beforeend"
hx-indicator=".loading-indicator"
hx-target="#items-container"
class="loading-indicator text-center">
Loading more items...
</div>
{% endif %}
<script>
// Show the loading indicator only when it is active
document.querySelectorAll('.loading-indicator').forEach(indicator => {
indicator.addEventListener('htmx:afterRequest', () => {
indicator.style.display = 'none'; // Hide the indicator after the request completes
});
});
</script>

View File

@@ -0,0 +1,22 @@
{% for task_name, task in tasks.items() %}
<tr id="task-row-{{ task_name }}">
<td>{{ task_name }}</td>
<td>{{ task.state }}</td>
<td>
{% if task.info.percent %}
{{ task.info.percent }}%
{% else %}
N/A
{% endif %}
</td>
<td>
<div>
<button hx-post="/run_task/{{ task_name }}" hx-target="#task-row-{{ task_name }}" hx-swap="outerHTML"
class="btn btn-primary">
Run Task
</button>
</div>
</td>
</tr>
{% endfor %}

View File

@@ -0,0 +1,18 @@
<div class="toast align-items-center text-white {{ 'bg-success' if success else 'bg-danger' }} border-0" role="alert"
aria-live="assertive" aria-atomic="true" style="position: fixed; bottom: 20px; right: 20px; z-index: 1000;">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
<script>
var toastElList = [].slice.call(document.querySelectorAll('.toast'))
var toastList = toastElList.map(function (toastEl) {
return new bootstrap.Toast(toastEl)
})
toastList.forEach(toast => toast.show());
</script>

View File

@@ -0,0 +1,94 @@
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Artist</th>
<th scope="col">Duration</th>
<th scope="col">Spotify</th>
<th scope="col">Preview</th>
<th scope="col">Status</th>
<th scope="col">Jellyfin</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<th scope="row">{{ loop.index }}</th>
<td>{{ track.title }}</td>
<td>{{ track.artist }}</td>
<td>{{ track.duration }}</td>
<td>
<a href="{{ track.url }}" target="_blank" class="text-success" data-bs-toggle="tooltip" title="Open in Spotify">
<i class="fab fa-spotify fa-lg"></i>
</a>
</td>
<td>
{% if track.preview_url %}
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')" data-bs-toggle="tooltip" title="Play Preview">
<i class="fas fa-play"></i>
</button>
{% else %}
<span data-bs-toggle="tooltip" title="No Preview Available">
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
</span>
{% endif %}
</td>
<td>
{% if not track.downloaded %}
<button class="btn btn-sm btn-danger"
data-bs-toggle="tooltip" title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
<i class="fa-solid fa-triangle-exclamation"></i>
</button>
{% else %}
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Track downloaded">
<i class="fa-solid fa-circle-check"></i>
</button>
{% endif %}
</td>
<td>
{% 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">
<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>
</span>
{% else %}
<span data-bs-toggle="tooltip" title="Not Available">
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- htmx-enabled form -->
<form id="search-form" hx-get="/search_jellyfin" hx-target="#search-results" hx-trigger="submit">
<div class="input-group mb-3">
<input type="text" class="form-control" id="search-query" name="search_query" placeholder="Search for a track...">
<input type="hidden" class="form-control" id="spotify-id" name="spotify_id" >
<button class="btn btn-primary" type="submit">Search</button>
</div>
</form>
<div id="search-results">
<!-- Search results will be inserted here by htmx -->
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
{% if unlinked_track_count > 0 %}
<span class="badge rounded-pill bg-danger ms-2 mb-2">
{{unlinked_track_count}}
<span class="visually-hidden">Unlinked Tracks</span>
</span>
{% endif %}

View File

@@ -0,0 +1,17 @@
<div id="alerts" hx-swap-oob="true">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast align-items-center text-bg-{{ 'success' if category == 'success' else 'danger' }}" role="alert"
aria-live="assertive" aria-atomic="true" style="position: fixed; bottom: 20px; right: 20px; z-index: 1000;">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>

11
templates/search.html Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div id="search-container">
{% if query %}
<h2>Search Results for "{{ query }}"</h2>
{% include 'partials/_searchresults.html' %}
{% else %}
<p>No search query provided.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content%}
<div class="container">
{% include 'partials/_playlist_info.html' %}
{% include 'partials/_track_table.html' %}
{% endblock %}