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 from logging.handlers import RotatingFileHandler
import os 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_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from psycopg2 import OperationalError
import spotipy import spotipy
from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials from spotipy.oauth2 import SpotifyClientCredentials
from celery import Celery from celery import Celery
from celery.schedules import crontab
from sqlalchemy import create_engine
from config import Config from config import Config
from jellyfin.client import JellyfinClient from jellyfin.client import JellyfinClient
import logging import logging
from spotdl import Spotdl
from spotdl.utils.config import DEFAULT_CONFIG 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) 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']) 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' # SQLAlchemy and Migrate setup
spotdl_config['output'] = '/storage/media/music/_spotify_playlists/{track-id}' app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}")
spotdl_config['threads'] = 12 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)
spotdl = Spotdl( app.config['SPOTIPY_CLIENT_ID'],app.config['SPOTIPY_CLIENT_SECRET'], downloader_settings=spotdl_config) app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist'
# Configurations
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://jellyplist:jellyplist@192.168.178.14/jellyplist'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app) db = SQLAlchemy(app)
app.logger.info(f"applying db migrations")
migrate = Migrate(app, db) migrate = Migrate(app, db)
# Configure Logging # Celery Configuration (Updated)
log_file = 'app.log' app.config.update(
handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3) # 100KB per file, with 3 backups CELERY_BROKER_URL=app.config['REDIS_URL'],
handler.setLevel(logging.INFO) result_backend=app.config['REDIS_URL']
)
app.logger.info('Application started')
from app import routes, models
from app.models import JellyfinUser,Track,Playlist
from apscheduler.schedulers.background import BackgroundScheduler
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 app.logger.info(f'Jellyplist {__version__} started')
scheduler = BackgroundScheduler() from app import routes
from app import jellyfin_routes, tasks
def update_all_playlists_track_status(): if "worker" in sys.argv:
""" tasks.release_lock("download_missing_tracks_lock")
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())

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