diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..704f86b --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6b08f4 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa5c96c --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 82801c4..c5fbf76 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,189 +1,151 @@ -import atexit from logging.handlers import RotatingFileHandler import os -from flask import Flask +import time +from flask_socketio import SocketIO + +import sys +from flask import Flask, has_request_context from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate +from psycopg2 import OperationalError import spotipy -from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials +from spotipy.oauth2 import SpotifyClientCredentials from celery import Celery +from celery.schedules import crontab +from sqlalchemy import create_engine from config import Config from jellyfin.client import JellyfinClient import logging -from spotdl import Spotdl from spotdl.utils.config import DEFAULT_CONFIG +from flask_caching import Cache +from .version import __version__ + + +def check_db_connection(db_uri, retries=5, delay=5): + """ + Check if the database is reachable. + + Args: + db_uri (str): The database URI. + retries (int): Number of retry attempts. + delay (int): Delay between retries in seconds. + + Raises: + SystemExit: If the database is not reachable after retries. + """ + for attempt in range(1, retries + 1): + try: + engine = create_engine(db_uri) + connection = engine.connect() + connection.close() + app.logger.info("Successfully connected to the database.") + return + except OperationalError as e: + app.logger.error(f"Database connection failed on attempt {attempt}/{retries}: {e}") + if attempt < retries: + app.logger.info(f"Retrying in {delay} seconds...") + time.sleep(delay) + else: + app.logger.critical("Could not connect to the database. Exiting application.") + sys.exit(1) + +# Celery setup +def make_celery(app): + celery = Celery( + app.import_name, + backend=app.config['result_backend'], + broker=app.config['CELERY_BROKER_URL'], + include=['app.tasks'] + ) + celery.conf.update(app.config) + # Configure Celery Beat schedule + celery.conf.beat_schedule = { + 'download-missing-tracks-schedule': { + 'task': 'app.tasks.download_missing_tracks', + 'schedule': crontab(minute='30'), + }, + 'check-playlist-updates-schedule': { + 'task': 'app.tasks.check_for_playlist_updates', + 'schedule': crontab(minute='25'), + }, + 'update_all_playlists_track_status-schedule': { + 'task': 'app.tasks.update_all_playlists_track_status', + 'schedule': crontab(minute='*/2'), + + }, + 'update_jellyfin_id_for_downloaded_tracks-schedule': { + 'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks', + 'schedule': crontab(minute='*/10'), + + } + } + + celery.conf.timezone = 'UTC' + return celery + +log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s : %(message)s') + +# Why this ? Because we are using the same admin login for web, worker and beat we need to distinguish the device_id´s +device_id = f'JellyPlist_{'_'.join(sys.argv)}' + +# Initialize Flask app +app = Flask(__name__, template_folder="../templates", static_folder='../static') +# log_file = 'app.log' +# handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3) +# handler.setLevel(logging.DEBUG) +# handler.setFormatter(log_formatter) +# stream_handler = logging.StreamHandler(sys.stdout) +# stream_handler.setLevel(logging.DEBUG) +# stream_handler.setFormatter(log_formatter) + + +# # app.logger.addHandler(handler) +# app.logger.addHandler(stream_handler) -app = Flask(__name__, template_folder="../templates") app.config.from_object(Config) -sp = spotipy.Spotify(auth_manager= SpotifyClientCredentials(client_id=app.config['SPOTIPY_CLIENT_ID'], client_secret=app.config['SPOTIPY_CLIENT_SECRET'])) +app.logger.setLevel(logging.DEBUG) +Config.validate_env_vars() +cache = Cache(app) + + +# Spotify, Jellyfin, and Spotdl setup +app.logger.info(f"setting up spotipy") +sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials( + client_id=app.config['SPOTIPY_CLIENT_ID'], + client_secret=app.config['SPOTIPY_CLIENT_SECRET'] +)) + +app.logger.info(f"setting up jellyfin client") + jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL']) -spotdl_config = DEFAULT_CONFIG +jellyfin_admin_token, jellyfin_admin_id, jellyfin_admin_name, jellyfin_admin_is_admin = jellyfin.login_with_password( + app.config['JELLYFIN_ADMIN_USER'], + app.config['JELLYFIN_ADMIN_PASSWORD'], device_id= device_id +) -spotdl_config['cookie_file'] = '/jellyplist/cookies.txt' -spotdl_config['output'] = '/storage/media/music/_spotify_playlists/{track-id}' -spotdl_config['threads'] = 12 -spotdl = Spotdl( app.config['SPOTIPY_CLIENT_ID'],app.config['SPOTIPY_CLIENT_SECRET'], downloader_settings=spotdl_config) - - - -# Configurations -app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://jellyplist:jellyplist@192.168.178.14/jellyplist' +# SQLAlchemy and Migrate setup +app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}") +check_db_connection(f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}/jellyplist',retries=5,delay=2) +app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - db = SQLAlchemy(app) +app.logger.info(f"applying db migrations") migrate = Migrate(app, db) -# Configure Logging -log_file = 'app.log' -handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3) # 100KB per file, with 3 backups -handler.setLevel(logging.INFO) - -app.logger.info('Application started') - -from app import routes, models -from app.models import JellyfinUser,Track,Playlist -from apscheduler.schedulers.background import BackgroundScheduler +# Celery Configuration (Updated) +app.config.update( + CELERY_BROKER_URL=app.config['REDIS_URL'], + result_backend=app.config['REDIS_URL'] +) +app.logger.info(f"initializing celery") +celery = make_celery(app) +socketio = SocketIO(app, message_queue=app.config['REDIS_URL'], async_mode='eventlet') +celery.set_default() -# Initialize the scheduler -scheduler = BackgroundScheduler() - -def update_all_playlists_track_status(): - """ - Update track_count and tracks_available for all playlists in the database. - For each track, check if the file exists on the filesystem. If not, reset the downloaded flag and filesystem_path. - """ - with app.app_context(): - playlists = Playlist.query.all() - - if not playlists: - app.logger.info("No playlists found.") - return - - for playlist in playlists: - total_tracks = 0 - available_tracks = 0 - - for track in playlist.tracks: - total_tracks += 1 - - # Check if the file exists - if track.filesystem_path and os.path.exists(track.filesystem_path): - available_tracks += 1 - else: - # If the file doesn't exist, reset the 'downloaded' flag and 'filesystem_path' - track.downloaded = False - track.filesystem_path = None - db.session.commit() - - # Update playlist fields - playlist.track_count = total_tracks - playlist.tracks_available = available_tracks - - db.session.commit() - - app.logger.info("All playlists' track statuses updated.") - -def download_missing_tracks(): - app.logger.info("Starting track download job...") - with app.app_context(): - # Get all tracks that are not downloaded - undownloaded_tracks = Track.query.filter_by(downloaded=False).all() - - if not undownloaded_tracks: - app.logger.info("No undownloaded tracks found.") - return - - for track in undownloaded_tracks: - app.logger.info(f"Trying to downloading track: {track.name} ({track.spotify_track_id})") - - try: - # Download track using spotDL - s_url = f"https://open.spotify.com/track/{track.spotify_track_id}" - search = spotdl.search([s_url]) - if search: - song = search[0] - dl_request = spotdl.download(song) - # Assuming spotDL downloads files to the './downloads' directory, set the filesystem path - file_path = dl_request[1].__str__() # Adjust according to naming conventions - - if os.path.exists(file_path): - # Update the track's downloaded status and filesystem path - track.downloaded = True - track.filesystem_path = file_path - db.session.commit() - - app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.") - else: - app.logger.error(f"Download failed for track {track.name}: file not found.") - else: - app.logger.warning(f"{track.name} ({track.spotify_track_id}) not Found") - - except Exception as e: - app.logger.error(f"Error downloading track {track.name}: {str(e)}") - - app.logger.info("Track download job finished.") - update_all_playlists_track_status() - -def check_for_playlist_updates(): - app.logger.info('Starting playlist update check...') - with app.app_context(): - try: - playlists = Playlist.query.all() # Get all users - for playlist in playlists: - app.logger.info(f'Checking updates for playlist: {playlist.name}') - try: - # Fetch the latest data from the Spotify API - playlist_data = sp.playlist(playlist.spotify_playlist_id) - spotify_tracks = {track['track']['id']: track['track'] for track in playlist_data['tracks']['items']} - existing_tracks = {track.spotify_track_id: track for track in playlist.tracks} - - # Tracks to add - tracks_to_add = [] - for track_id, track_info in spotify_tracks.items(): - if track_id not in existing_tracks: - track = Track.query.filter_by(spotify_track_id=track_id).first() - if not track: - track = Track(name=track_info['name'], spotify_track_id=track_id, spotify_uri=track_info['uri'],downloaded= False) - db.session.add(track) - db.session.commit() - app.logger.info(f'Added new track: {track.name}') - tracks_to_add.append(track) - - # Tracks to remove - tracks_to_remove = [existing_tracks[track_id] for track_id in existing_tracks if track_id not in spotify_tracks] - - if tracks_to_add: - for track in tracks_to_add: - playlist.tracks.append(track) - db.session.commit() - app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}') - - if tracks_to_remove: - for track in tracks_to_remove: - playlist.tracks.remove(track) - db.session.commit() - app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}') - - except Exception as e: - app.logger.error(f"Error updating playlist {playlist.name}: {str(e)}") - - except Exception as e: - app.logger.error(f"Error in check_for_playlist_updates: {str(e)}") - - app.logger.info('Finished playlist update check.') - update_all_playlists_track_status() - - - -# Add the job to run every 10 minutes (customize the interval as needed) -#scheduler.add_job(download_missing_tracks, 'interval', seconds=30, max_instances=1) -#scheduler.add_job(check_for_playlist_updates, 'interval', minutes=10, max_instances=1) -#download_missing_tracks() -#check_for_playlist_updates() -# Start the scheduler -scheduler.start() - -# Ensure the scheduler shuts down properly when the app stops -atexit.register(lambda: scheduler.shutdown()) \ No newline at end of file +app.logger.info(f'Jellyplist {__version__} started') +from app import routes +from app import jellyfin_routes, tasks +if "worker" in sys.argv: + tasks.release_lock("download_missing_tracks_lock") \ No newline at end of file diff --git a/app/functions.py b/app/functions.py new file mode 100644 index 0000000..7f19b35 --- /dev/null +++ b/app/functions.py @@ -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 \ No newline at end of file diff --git a/app/jellyfin_routes.py b/app/jellyfin_routes.py new file mode 100644 index 0000000..ee08655 --- /dev/null +++ b/app/jellyfin_routes.py @@ -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/', 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/') +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 \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..ead38dc --- /dev/null +++ b/app/models.py @@ -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'' + +# 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'' + +# 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'' diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..b2a9073 --- /dev/null +++ b/app/routes.py @@ -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/', 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/') +@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 '' \ No newline at end of file diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000..3dc6071 --- /dev/null +++ b/app/tasks.py @@ -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'} diff --git a/app/version.py b/app/version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/app/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/celerybeat-schedule b/celerybeat-schedule new file mode 100644 index 0000000..075e672 Binary files /dev/null and b/celerybeat-schedule differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..1d5d958 --- /dev/null +++ b/config.py @@ -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) \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..8698b44 --- /dev/null +++ b/entrypoint.sh @@ -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 "$@" diff --git a/jellyfin/client.py b/jellyfin/client.py new file mode 100644 index 0000000..8f1c35a --- /dev/null +++ b/jellyfin/client.py @@ -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 \ No newline at end of file diff --git a/jellyfin/objects.py b/jellyfin/objects.py new file mode 100644 index 0000000..58eb9e9 --- /dev/null +++ b/jellyfin/objects.py @@ -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, + } diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/05f2ef26e1a8_added_playlist_and_track_models_.py b/migrations/versions/05f2ef26e1a8_added_playlist_and_track_models_.py new file mode 100644 index 0000000..2325c3d --- /dev/null +++ b/migrations/versions/05f2ef26e1a8_added_playlist_and_track_models_.py @@ -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 ### diff --git a/migrations/versions/2b8f75ed0a54_add_jellyfin_id_to_track_and_user_.py b/migrations/versions/2b8f75ed0a54_add_jellyfin_id_to_track_and_user_.py new file mode 100644 index 0000000..3d1f7a1 --- /dev/null +++ b/migrations/versions/2b8f75ed0a54_add_jellyfin_id_to_track_and_user_.py @@ -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 ### diff --git a/migrations/versions/30e948342792_add_track_order.py b/migrations/versions/30e948342792_add_track_order.py new file mode 100644 index 0000000..f5e01fc --- /dev/null +++ b/migrations/versions/30e948342792_add_track_order.py @@ -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 ### diff --git a/migrations/versions/41cfd15a9050_extend_download_status.py b/migrations/versions/41cfd15a9050_extend_download_status.py new file mode 100644 index 0000000..e6ffdf0 --- /dev/null +++ b/migrations/versions/41cfd15a9050_extend_download_status.py @@ -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 ### diff --git a/migrations/versions/52d71c294c8f_added_spotify_u12ri.py b/migrations/versions/52d71c294c8f_added_spotify_u12ri.py new file mode 100644 index 0000000..857f657 --- /dev/null +++ b/migrations/versions/52d71c294c8f_added_spotify_u12ri.py @@ -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 ### diff --git a/migrations/versions/5a06403b57cf_add_jellyfin_id_to_playlists_remove_.py b/migrations/versions/5a06403b57cf_add_jellyfin_id_to_playlists_remove_.py new file mode 100644 index 0000000..d50aa35 --- /dev/null +++ b/migrations/versions/5a06403b57cf_add_jellyfin_id_to_playlists_remove_.py @@ -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 ### diff --git a/migrations/versions/6089eb604dbf_add_jellyfin_id_to_track_and_user_.py b/migrations/versions/6089eb604dbf_add_jellyfin_id_to_track_and_user_.py new file mode 100644 index 0000000..d8b80a2 --- /dev/null +++ b/migrations/versions/6089eb604dbf_add_jellyfin_id_to_track_and_user_.py @@ -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 ### diff --git a/migrations/versions/70b2970ce195_add_track_count_and_tracks_available_to_.py b/migrations/versions/70b2970ce195_add_track_count_and_tracks_available_to_.py new file mode 100644 index 0000000..efa4fcc --- /dev/null +++ b/migrations/versions/70b2970ce195_add_track_count_and_tracks_available_to_.py @@ -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 ### diff --git a/migrations/versions/7cc06fc32cc7_add_jellyfin_id_to_playlists_remove_.py b/migrations/versions/7cc06fc32cc7_add_jellyfin_id_to_playlists_remove_.py new file mode 100644 index 0000000..2efc4b7 --- /dev/null +++ b/migrations/versions/7cc06fc32cc7_add_jellyfin_id_to_playlists_remove_.py @@ -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 ### diff --git a/migrations/versions/8843534fa7ea_added_spotify_uri.py b/migrations/versions/8843534fa7ea_added_spotify_uri.py new file mode 100644 index 0000000..ca7ddaa --- /dev/null +++ b/migrations/versions/8843534fa7ea_added_spotify_uri.py @@ -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 ### diff --git a/migrations/versions/8fcd403f0c45_add_last_updated_and_last_changed_.py b/migrations/versions/8fcd403f0c45_add_last_updated_and_last_changed_.py new file mode 100644 index 0000000..0dc0745 --- /dev/null +++ b/migrations/versions/8fcd403f0c45_add_last_updated_and_last_changed_.py @@ -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 ### diff --git a/migrations/versions/95f1a12a7da3_add_is_admin.py b/migrations/versions/95f1a12a7da3_add_is_admin.py new file mode 100644 index 0000000..78ee0be --- /dev/null +++ b/migrations/versions/95f1a12a7da3_add_is_admin.py @@ -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 ### diff --git a/migrations/versions/a1478d4d2c47_add_download_status.py b/migrations/versions/a1478d4d2c47_add_download_status.py new file mode 100644 index 0000000..f3d9993 --- /dev/null +++ b/migrations/versions/a1478d4d2c47_add_download_status.py @@ -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 ### diff --git a/migrations/versions/dfcca9d99ce7_initial.py b/migrations/versions/dfcca9d99ce7_initial.py new file mode 100644 index 0000000..a2e8097 --- /dev/null +++ b/migrations/versions/dfcca9d99ce7_initial.py @@ -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 ### diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6233a78 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..c8edc51 --- /dev/null +++ b/run.py @@ -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) diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..2ef654b --- /dev/null +++ b/static/css/styles.css @@ -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%; +} \ No newline at end of file diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000..e012f89 Binary files /dev/null and b/static/images/favicon.ico differ diff --git a/static/images/logo.png b/static/images/logo.png new file mode 100644 index 0000000..03ac157 Binary files /dev/null and b/static/images/logo.png differ diff --git a/static/images/logo_large.png b/static/images/logo_large.png new file mode 100644 index 0000000..0baf573 Binary files /dev/null and b/static/images/logo_large.png differ diff --git a/static/js/preview.js b/static/js/preview.js new file mode 100644 index 0000000..4affc87 --- /dev/null +++ b/static/js/preview.js @@ -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 = ''; + } + } + + if (currentAudio && currentAudio.src === previewUrl) { + currentAudio = null; + currentButton = null; + } else { + currentAudio = new Audio(previewUrl); + currentAudio.play(); + currentButton = button; + button.innerHTML = ''; + + currentAudio.onended = function () { + button.innerHTML = ''; + currentAudio = null; + currentButton = null; + }; + } +} + +function playJellyfinTrack(button, jellyfinId) { + if (currentAudio && currentButton === button) { + currentAudio.pause(); + currentAudio = null; + currentButton.innerHTML = ''; + currentButton = null; + return; + } + + if (currentAudio) { + currentAudio.pause(); + if (currentButton) { + currentButton.innerHTML = ''; + } + } + + 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 = ''; + + currentAudio.onended = function () { + button.innerHTML = ''; + currentAudio = null; + currentButton = null; + }; + }) + .catch(error => console.error('Error fetching Jellyfin stream URL:', error)); +} \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..6fe3064 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block content %} + + {% block admin_content %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/admin/link_issues.html b/templates/admin/link_issues.html new file mode 100644 index 0000000..1febafd --- /dev/null +++ b/templates/admin/link_issues.html @@ -0,0 +1,8 @@ +{% extends "admin.html" %} +{% block admin_content %} +

Resolve Issues which occured during the linking between a spotify track and jellyfin track

+ + + {% include 'partials/_track_table.html' %} + +{% endblock %} diff --git a/templates/admin/tasks.html b/templates/admin/tasks.html new file mode 100644 index 0000000..4815c4c --- /dev/null +++ b/templates/admin/tasks.html @@ -0,0 +1,21 @@ +{% extends "admin.html" %} +{% block admin_content %} +
+ + + + + + + + + + + + + + {% include 'partials/_task_status.html' %} + +
Task NameStatusProgressAction
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..6203a42 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,171 @@ + + + + + + + {{ title }} + + + + + + + + + + + + + + +
+ +
+
+
Jellyplist
+ +
+
+ +
+
+ + + + + +
+
+ + + +

{{ title }}

+ +
+
+
+ + +
+
+
+ + {{ session.get('jellyfin_user_name') }} +
+
+ +
+ + {% block content %}{% endblock %} +
+
+
+ + + + + + {% block scripts %} + {% endblock %} + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a6a944c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block content %} + + +{% endblock %} diff --git a/templates/items.html b/templates/items.html new file mode 100644 index 0000000..7effd26 --- /dev/null +++ b/templates/items.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} +

{{ items_title }}

+
{{ items_subtitle }}
+
+ {% include 'partials/_spotify_items.html' %} + +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/jellyfin_playlists.html b/templates/jellyfin_playlists.html new file mode 100644 index 0000000..7696eb8 --- /dev/null +++ b/templates/jellyfin_playlists.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block content %} +

Your subscribed Jellyfin Playlists

+
+
+ {% for item in playlists %} + {% include 'partials/_spotify_item.html' %} + +{% endfor %} + +
+ +{% endblock %} + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..b021036 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,55 @@ + + + + + + + Jellyplist Login + + + + + + +
+
+
+
+
+ +
+

Login using your Jellyfin Credentials

+
+
+ + +
+
+ + +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + + {% endif %} + {% endwith %} +
+
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/templates/partials/_add_remove_button.html b/templates/partials/_add_remove_button.html new file mode 100644 index 0000000..a8b8dfd --- /dev/null +++ b/templates/partials/_add_remove_button.html @@ -0,0 +1,12 @@ +{% if item.can_add %} + +{% elif item.can_remove %} + +{% endif %} \ No newline at end of file diff --git a/templates/partials/_jf_search_results.html b/templates/partials/_jf_search_results.html new file mode 100644 index 0000000..c50346f --- /dev/null +++ b/templates/partials/_jf_search_results.html @@ -0,0 +1,37 @@ +
+
+ {% if results %} + + + + + + + + + + + + {% for track in results %} + + + + + + + + {% endfor %} + +
NameArtist(s)Path
{{ track.Name }}{{ ', '.join(track.Artists) }}{{ track.Path}} + + + +
+ {% else %} +

No results found.

+ {% endif %} +
diff --git a/templates/partials/_playlist_info.html b/templates/partials/_playlist_info.html new file mode 100644 index 0000000..0ad7a66 --- /dev/null +++ b/templates/partials/_playlist_info.html @@ -0,0 +1,14 @@ +
+
+ +
+
+
+

{{ playlist_name }}

+

{{ playlist_description }}

+

{{ track_count }} songs, {{ total_duration }}

+

Last Updated: {{ last_updated}} | Last Change: {{ last_changed}}

+ {% include 'partials/_add_remove_button.html' %} +
+
+
\ No newline at end of file diff --git a/templates/partials/_searchresults.html b/templates/partials/_searchresults.html new file mode 100644 index 0000000..bde1017 --- /dev/null +++ b/templates/partials/_searchresults.html @@ -0,0 +1,31 @@ + + + + {% if playlists %} +

Playlists

+
+ {% for item in playlists %} + {% include 'partials/_spotify_item.html' %} + {% endfor %} +
+ {% endif %} + + {% if albums %} +

Albums

+
+ + {% for item in albums %} + {% include 'partials/_spotify_item.html' %} + {% endfor %} +
+ {% endif %} + {% if artists %} +

Artists

+
+ + {% for item in artists %} + {% include 'partials/_spotify_item.html' %} + {% endfor %} +
+ {% endif %} + diff --git a/templates/partials/_spotify_item.html b/templates/partials/_spotify_item.html new file mode 100644 index 0000000..af6a5f5 --- /dev/null +++ b/templates/partials/_spotify_item.html @@ -0,0 +1,49 @@ +
+
+ + + {% if item.status %} + + {% if item.track_count > 0 %} + {{ item.tracks_available }} / {{ item.tracks_linked}} / {{ item.track_count}} + {% else %} + not Available + {% endif %} + + {% endif %} + + +
+ {{ item.name }} +
+ + +
+
+
{{ item.name }}
+

{{ item.description }}

+
+
+ {% if item.type == 'category'%} + + + + {%else%} + + + + + {%endif%} + {% include 'partials/_add_remove_button.html' %} +
+
+ +
+
\ No newline at end of file diff --git a/templates/partials/_spotify_items.html b/templates/partials/_spotify_items.html new file mode 100644 index 0000000..896008b --- /dev/null +++ b/templates/partials/_spotify_items.html @@ -0,0 +1,24 @@ +{% for item in items %} + {% include 'partials/_spotify_item.html' %} + +{% endfor %} + +{% if next_offset < total_items %} +
+ Loading more items... +
+{% endif %} + + \ No newline at end of file diff --git a/templates/partials/_task_status.html b/templates/partials/_task_status.html new file mode 100644 index 0000000..05f22fe --- /dev/null +++ b/templates/partials/_task_status.html @@ -0,0 +1,22 @@ +{% for task_name, task in tasks.items() %} + + {{ task_name }} + {{ task.state }} + + {% if task.info.percent %} + {{ task.info.percent }}% + {% else %} + N/A + {% endif %} + + +
+ + +
+ + +{% endfor %} \ No newline at end of file diff --git a/templates/partials/_toast.html b/templates/partials/_toast.html new file mode 100644 index 0000000..fa4e494 --- /dev/null +++ b/templates/partials/_toast.html @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/templates/partials/_track_table.html b/templates/partials/_track_table.html new file mode 100644 index 0000000..5d9baac --- /dev/null +++ b/templates/partials/_track_table.html @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + {% for track in tracks %} + + + + + + + + + + + {% endfor %} + +
#TitleArtistDurationSpotifyPreviewStatusJellyfin
{{ loop.index }}{{ track.title }}{{ track.artist }}{{ track.duration }} + + + + + {% if track.preview_url %} + + {% else %} + + + + {% endif %} + + {% if not track.downloaded %} + + {% else %} + + {% endif %} + + {% if track.jellyfin_id %} + + {% elif track.downloaded %} + + {% set title = track.title | replace("'","") %} + + + {% else %} + + + + {% endif %} +
+ + diff --git a/templates/partials/_unlinked_tracks_badge.html b/templates/partials/_unlinked_tracks_badge.html new file mode 100644 index 0000000..de24591 --- /dev/null +++ b/templates/partials/_unlinked_tracks_badge.html @@ -0,0 +1,6 @@ +{% if unlinked_track_count > 0 %} + + {{unlinked_track_count}} + Unlinked Tracks + + {% endif %} \ No newline at end of file diff --git a/templates/partials/alerts.jinja2 b/templates/partials/alerts.jinja2 new file mode 100644 index 0000000..8b548f2 --- /dev/null +++ b/templates/partials/alerts.jinja2 @@ -0,0 +1,17 @@ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
\ No newline at end of file diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..c9ba9f8 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +
+ {% if query %} +

Search Results for "{{ query }}"

+ {% include 'partials/_searchresults.html' %} + {% else %} +

No search query provided.

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/tracks_table.html b/templates/tracks_table.html new file mode 100644 index 0000000..e7f45c8 --- /dev/null +++ b/templates/tracks_table.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content%} +
+ {% include 'partials/_playlist_info.html' %} + + {% include 'partials/_track_table.html' %} + + +{% endblock %}