From 435f903b942698a8295a8cefa947b310bb8eb9a8 Mon Sep 17 00:00:00 2001 From: Kamil Date: Fri, 22 Nov 2024 12:29:29 +0000 Subject: [PATCH] v0.1.0 --- .dockerignore | 17 + .gitignore | 78 +++ Dockerfile | 26 + app/__init__.py | 298 +++++----- app/functions.py | 295 ++++++++++ app/jellyfin_routes.py | 176 ++++++ app/models.py | 62 ++ app/routes.py | 265 +++++++++ app/tasks.py | 376 ++++++++++++ app/version.py | 1 + celerybeat-schedule | Bin 0 -> 524288 bytes config.py | 54 ++ entrypoint.sh | 40 ++ jellyfin/client.py | 536 ++++++++++++++++++ jellyfin/objects.py | 89 +++ migrations/README | 1 + migrations/alembic.ini | 50 ++ migrations/env.py | 113 ++++ migrations/script.py.mako | 24 + ...26e1a8_added_playlist_and_track_models_.py | 75 +++ ...0a54_add_jellyfin_id_to_track_and_user_.py | 38 ++ .../versions/30e948342792_add_track_order.py | 32 ++ .../41cfd15a9050_extend_download_status.py | 38 ++ .../52d71c294c8f_added_spotify_u12ri.py | 34 ++ ...cf_add_jellyfin_id_to_playlists_remove_.py | 38 ++ ...4dbf_add_jellyfin_id_to_track_and_user_.py | 38 ++ ...dd_track_count_and_tracks_available_to_.py | 34 ++ ...c7_add_jellyfin_id_to_playlists_remove_.py | 38 ++ .../8843534fa7ea_added_spotify_uri.py | 42 ++ ...0c45_add_last_updated_and_last_changed_.py | 34 ++ .../versions/95f1a12a7da3_add_is_admin.py | 32 ++ .../a1478d4d2c47_add_download_status.py | 32 ++ migrations/versions/dfcca9d99ce7_initial.py | 34 ++ readme.md | 0 requirements.txt | 20 + run.py | 5 + static/css/styles.css | 55 ++ static/images/favicon.ico | Bin 0 -> 15406 bytes static/images/logo.png | Bin 0 -> 21078 bytes static/images/logo_large.png | Bin 0 -> 46363 bytes static/js/preview.js | 86 +++ templates/admin.html | 20 + templates/admin/link_issues.html | 8 + templates/admin/tasks.html | 21 + templates/base.html | 171 ++++++ templates/index.html | 6 + templates/items.html | 12 + templates/jellyfin_playlists.html | 15 + templates/login.html | 55 ++ templates/partials/_add_remove_button.html | 12 + templates/partials/_jf_search_results.html | 37 ++ templates/partials/_playlist_info.html | 14 + templates/partials/_searchresults.html | 31 + templates/partials/_spotify_item.html | 49 ++ templates/partials/_spotify_items.html | 24 + templates/partials/_task_status.html | 22 + templates/partials/_toast.html | 18 + templates/partials/_track_table.html | 94 +++ .../partials/_unlinked_tracks_badge.html | 6 + templates/partials/alerts.jinja2 | 17 + templates/search.html | 11 + templates/tracks_table.html | 9 + 62 files changed, 3690 insertions(+), 168 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/functions.py create mode 100644 app/jellyfin_routes.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/tasks.py create mode 100644 app/version.py create mode 100644 celerybeat-schedule create mode 100644 config.py create mode 100644 entrypoint.sh create mode 100644 jellyfin/client.py create mode 100644 jellyfin/objects.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/05f2ef26e1a8_added_playlist_and_track_models_.py create mode 100644 migrations/versions/2b8f75ed0a54_add_jellyfin_id_to_track_and_user_.py create mode 100644 migrations/versions/30e948342792_add_track_order.py create mode 100644 migrations/versions/41cfd15a9050_extend_download_status.py create mode 100644 migrations/versions/52d71c294c8f_added_spotify_u12ri.py create mode 100644 migrations/versions/5a06403b57cf_add_jellyfin_id_to_playlists_remove_.py create mode 100644 migrations/versions/6089eb604dbf_add_jellyfin_id_to_track_and_user_.py create mode 100644 migrations/versions/70b2970ce195_add_track_count_and_tracks_available_to_.py create mode 100644 migrations/versions/7cc06fc32cc7_add_jellyfin_id_to_playlists_remove_.py create mode 100644 migrations/versions/8843534fa7ea_added_spotify_uri.py create mode 100644 migrations/versions/8fcd403f0c45_add_last_updated_and_last_changed_.py create mode 100644 migrations/versions/95f1a12a7da3_add_is_admin.py create mode 100644 migrations/versions/a1478d4d2c47_add_download_status.py create mode 100644 migrations/versions/dfcca9d99ce7_initial.py create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 static/css/styles.css create mode 100644 static/images/favicon.ico create mode 100644 static/images/logo.png create mode 100644 static/images/logo_large.png create mode 100644 static/js/preview.js create mode 100644 templates/admin.html create mode 100644 templates/admin/link_issues.html create mode 100644 templates/admin/tasks.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/items.html create mode 100644 templates/jellyfin_playlists.html create mode 100644 templates/login.html create mode 100644 templates/partials/_add_remove_button.html create mode 100644 templates/partials/_jf_search_results.html create mode 100644 templates/partials/_playlist_info.html create mode 100644 templates/partials/_searchresults.html create mode 100644 templates/partials/_spotify_item.html create mode 100644 templates/partials/_spotify_items.html create mode 100644 templates/partials/_task_status.html create mode 100644 templates/partials/_toast.html create mode 100644 templates/partials/_track_table.html create mode 100644 templates/partials/_unlinked_tracks_badge.html create mode 100644 templates/partials/alerts.jinja2 create mode 100644 templates/search.html create mode 100644 templates/tracks_table.html 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 0000000000000000000000000000000000000000..075e6727316bf4abf6c8d492b0284053fea666e7 GIT binary patch literal 524288 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 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 0000000000000000000000000000000000000000..e012f89874cc0188dd41f5f82ef11dad767c1b1f GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/static/images/logo.png b/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03ac157d9945377f45310e3331f722401bd79406 GIT binary patch literal 21078 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 z z0k8#7hdh#8#wY_f_Ut{~w0vA=(c6!WzGGMl6)ALn@4K*|dkAxW+M7-ye4#P~ru6g} z?f{G2S=_k1Dv#O^QU8kFw_Lf?ecB7+M7`*20}^M-M_$8!4Lhbgq2z3Fn-6lCBEK~< z(S9i*)^`--_-#ULlOJ=eO{1GD-~4tFtk|0zrU_g-SaNttxzImu{ZP?KZ@Q7{X)s+P zrH4L3GBQ4p;jols^a#il9R&taq^CbmH9?ec(*xm(`N5TKjIW8MgN#OeDsjdd56v4G z!&E=_*wjgI#(a+$i!j7EPap2UW-1M-?3-4#R4Y5M#qk6vB(m@ypTsz0!Gr`$ra6OL zzT+K8x_rEEg(CJBFvvP80b|=7EzVB07O2?>N>caGb(!JMFEKMk;N)k)V*^r(S&R8f z3g2$Z8QAUdor_IF%-<9{5%hWnXGIXPAjYWieoAvX>1qDOr>>ZC<{Z=XT!BZ8bA*1T zLkU8i_`CS;@)x;ouK1PJ+ROf~XcSGK+?`e2WyZj@6~$bhLA;}`QY`M@E^u)CBwFh) z`e2xrE13H3T3K5y3%PasjuWdTLhHcMpp@Tw_RBVNZk3=0tu7yJ-`n7GdO`vRS+=5A zu(X;tmPV7`WG3IfVOS%*FIVUBb0Y28E%6S~@~>Od@mgx+4tpNPh@}ypSUryKaTA9q zgzv@XvPdnaf1`gTf#EZ-XJ<@+& zymS2N>xaiv1k%eME~wpPEskBZmgO$f7QZHt2AHLQMy7i2ejyJ*Fg~_=oW{m}cIULi z6+YGmV&hrpIqRO`n=)WZD4C>>l$uy;sF*yTV-*J9r|-`k7Na|td;7bgTQ1Rt?)O?F zwv{Z0oGzn<&t>#HVvOMw!0(6j4l8PjE#ps~LO6KinE@|KP#P>+kcZvJJWKA{k2qD+4wZ@wYHPt+kkBR@P4sDu#u7H4Ozl+3Iu zkr=#5QP2S_F&$TZ?2oY8R>Tx3DaM~>f_e|IE<&~i!S{)^}7wkY`0I^45fK~MD zJe9q3wCpC0M9%{XB7XAp=Q1~2SuQyh-AR93jCRB<`t{NZ0q&+aPX&5!1JCws0OfRh zMybb{g+>mp((GG$5#@Li``*JPs%p}|EreqZEocs7$w_yaFlHUE7`0W*WSC>h;BC0I ztgu28%AT;5y=r+-hm?9~+mkEfr9~F9S{8?K5fOFKJFzM*J+OWtF|l7=k2 zFp_Z=T%_NtprOEI)M3|hq0X058v3C{PZ_m5Ho%w&&AlnQc>QsDE73|f17eTp`ZHTL zZ4Zny9yY~OxwItli~S!qCizFZmN&M)krT1>mB9Il3pZ14Y2PFC?MN!(&H}s347+HV za$Thf7ic&Q+WeF}6_3HWk9Ru6)#kFew?;q$|2`rp5@P=0_^t4qcZXQpV|*3iv%_+j z-;5*DzQRqeuuOiBHE)0}9g_m)mxpZX_;~5)pHJ zS>DYx0YUGCZk}tiPv>(RJS9j9q4j}TkA@;vy zyp6k72$=e0pZS4%+vQW{hsVQ&{YHfyZ+V(25U(p3>b&jCUIE@QNkR#Fp;wH>OBuTR zC!+#DG=r_A9Nh7sG%NdAO3-m6>#QC*D_rAs7bpi+K=Y)<{0f%aM8V6@Jls1RV3n>N zrd9gzRKekCu)hsLZwVzD&R9|o(c|q=8Ndb4V%wk|UAk8UhiGwu<$*_`^nq7DZty%b zr~3kEa5FLanj)r%GGzPZQl6>i9A`gqeJe{>S+9qe}t@44$PbuR3A@MeKaf; zIH<1!i~Z#R;|uPlrX(5zgW&3~`Ei$qGpfH4t&HFWksrCA{;U)?{qT8%PPv&&nxp))F7q53;Zc&zT7$LIQ1?v;~)kZf`mNg61L68TO8@%<}!u%VqbKb&+0HF((p2NP2g5vesi@co|)9Fvd zZyhzEceE-`djdds`+O-HYGRT`E8rjGS1TL>to2u{#fF_>)r7Ha{fX5s4}OZUGW<4k zY`Oq((<;A%tna|vA2j+I(_%KEL>zpy7H@oYSK}}W_Jq#V-_TPuRQ;6fYR4T7!N?s7 zuy1++02Oy z!jiY~QnjWuO`mC2kFG?6)BIC1%6`E6C9 zmgLQ-Cg|;Rwf6rwRThollO^xx#X3b^{g_Tj6Y>qP6H@opI9Gj^w7hg|KzW5`PA$lk z;%-N4ns=#bmhg7*V(Dy8*5+AMptBX>GAV#RO54wob=V(87#UN+Y9GO=dg+nZ;3D&L z3`}|3IQ=r`#aqvNwXeplPH~VmS%O2}w(PK(BD?~8{dWX)6k_n%A2vR%awLmaSy7JX z0_g1mH_rWIZC&?ev%-rN<64l8CYbqX7GGFb7L(fr!{Y3zcui$M!K#<7da`G@$9QPw zwQjx7NcR>Rc+TUgy9%QoV=X#E%}(~!x1wl?Aike?cxUa6+tNgNbGoU;h7}N_#Lw}; zPfBal3s%S` zDgpB`VZr^8j(dGWc6@jj`Y=XDx&T49)YnfRn>be+XpzoB5d}qF+LRs&{QVUdDWI?Q zWByX|Q11~phXJgCMhfGJ$BDNe5psaWBsMklzoFMNsotR+G1&Cst6$Y0^mHDaah1r& zunb_6w_-DUxapi0lP!y0j<^GU!li=u!G$w|RLI_aaR9jm3tUVZD(Y=*459~C;e2z&3EQ~KS$N7ja{eIp`Ena)fkZP zvz(zuSShPasTyrjFUY~5vuw~VUews!bO)?GY{$bM-z|u>TrEXN^3?nEUOv*ZQ=zyt z|3ws&HdlN(y4Z0DBaRsMInR!%L3=SZ2@5?`=0Z`-*KZmdf%vKWh-5DeOAS1)$fR&!dXui!|@wU z;v~t>Ekkns7j(C89rQh~_!c`^CG04bEs}|UKmjkeSawnoJ5@BVKF=YW)|twCa)FQf z7KG1^64kGC2gERAP5H_DZaDj>ER=REY+(%6PrNQFItsNj9Pw)T6`0&R?Ov4W38)4Z zO{}yX8+t!0=hzu9c;P}y#qo2lf=}|wJ#^Agwv;S(P61G@K1v10lra|`;UA$zmrhf_M3Rf#PH@dk!dirymztZ6%ce7X>oW% z!bZ@58duGmTx39%&Ex?;ws{ip^HM->{;dR{3dW^f(?_33_IPTQ>76R&c9|+k8|Au6 zGbrbqx;E??M8r4|3ga@D^P!C%A6?L4N;GVEovxb7-+i%HKa)eJvqVNyf(m>uW}3>| zy^K}c^2R+zS%7r`ALBL6JS<{jpriEr7 zv@Jg7H^k51|0H{iJr?Hg5?$2s1{Sf}(lVn{jem1ZKaxXqw(xb3$k?*SXO(gbr;^8x zVL>ej7Y0_yf1i1D* z?*jptIY5gWi+_SSl%*T08m+ZDZgw}Q$OG%SCqkZ){o6|H`6^mjV0MQ$d|e48>5ewh zx|A^)NNu|+V67k9HQfax94+Mq-0!_9E!fnuES47P1BH`>WrP^s^0ZDRRv$A8kF~YS zF*7-HB`Y8oBg=hj$v3y3r} zTE|EaUx;{eb*JMViUQqZ)Y>Lw+D(l0`Z zrC<@7zqC$-A231mcX!F;J?+iNNjEq@wM@aB4sRycY`7lC8Jy#L)G76DRg?IBY&(h- zFVU7u^z5RxW7GWLC$ud2an8Xl9kr(`0z3(Z&5w<;FM7h_>=#)$4Y#u0V*1|&dD=jl zMXWJf7r(6)E`Vq(4z6h-Y%=3=`hM6%!SQ?)6Y@qiCJVdY$)dz6+z84;^Rovq18utd z-mEPxU?A9IElQ%Y0S|5=qzgKGU$O=zIL{!yDUdUw;^l5av+g8gnhkeQ!fmgoD#{Yn z2sHZ4#k)q$C(LP>1Ngw4*Mw{RpvqSJj!vk0Bz=C};XF~)KvOpBLA!>~uE-a9j|)~} z={^4^6@@VHUIp>ldH=5Ub*rf0vv}Mbw?vhE_bMi-4LYo^nZBY&?+o;22f&1ngePL@ zRekI{%2Q`?g^{AZi6Kfxv|{AeYu#I`LJa)z_FEB7d-r>ui#r4TGwL+{HgwX}LpLiD z^KU6M?ChNOP;#mXSv_Fr2Q)M>mml9|f=uo%E&R82cX|+;M9Xg39B3t+PbnSWun$|q z(X+Hhq)IvU7BB7y9-zJf;gYw>{`e^oaSAo*RgPY_Bl=fqc`xwW0h|f=Id?guj*Le( zvWCmLx8gPVO)#+siny87tTh|y^SiTscNJp`dBTy_?6nERi)2Kxo9fGrya&Oj9lc2n z^N9|WPK!8}eadJ&u#YYhX`!NVyDebrcStbLoT6Hd1IA>$vxf8Jg{oazgGtTejkXMpU{)C?Q_Rjh8|18SvEuhg-UW zbn3diQ7Hg|$~M}=J1^k_65jgIalT)3a+bI$_NeOEu95s)@^s4jZ6I9dP;yhZ2| ziaO`pv9%Vk=cUIE%w1z~kGk(g2Ww;Q)^YKdc)dPtxRq{|MvV$&*oY3V3Rn;1~}wV11fzIyrEDruJr@;+`@ov%G%*Pp%MDiHClcrEp^Z}Xm!h~2%z`>rrD{%@KgUOAKO{Jf-)BW_Dy zj5QUVlq;^*0)GJqgpXO|N%53lf~TiGo*E?D&1FMqEY7T$R!z zG6NK>b`{d)0!CX#k%(C4>#^D-c5LCWS}4PFpT4o12GlvU{g|&hW1$17&W#A6vT?XM z2Ho@a`O_kUUR0Rc*^VQ2e%i+53Rv8j{fExHq@>PE;T=0b3?5f1ZWI0^7YMwxCxw3_M8%%VJF& zF5gStcjkL7UGEp(!rUg4@=W$1?W z{-A|ud0_e4vK4);Tn_yU^}IU~=l%3tqO{)ec<-*mNQ%W> zuNIyXx=hD@4v|A(<)Gd92=n0wA^j+r=yH=Cf&G(M(lZ15#~y;TTR^JzxIe<& z(yoeIzp730dE}NR3PzZ249tcO=lnRa%DOVVKQ*>KroED4j;9nIkO;BsOg7#X4>{9l1J&FOv~?TF!_af8j|Wn2gw;kw>$5dOTsVD%8}QjU=mOM4 zhV1ru3NT)wf3DWN+pC%@kc(>dT$g*~!56)J8PVOh#~hJ0K>7{~it}9}#aMs@O2Qnc zLVf_DO?5zQ$@M5@w<&y00ahFbe=lFf$t%*>@2z#Vq-3(x+O6^DF%jazL6=6rhC^ktGV(W5C#+yp0C3lEQH$NrFAVRL)G7}S#OFld;@zHe{?Gf3>w*ugz4qGczJGPUpAlfNp2R6TimjPW##al`5xLC$h_M`a;%|Il%DX8rHxxAv;U0n7Gph?VlE7YAt^=WnYGu$=VEh04O{*>4T3~~ zj1*1f`a2SHR|Ji3JRoNsmV6BOVFB|_DfwkicZ4J(vs zcSC76pW^5^Z1LXz@Y0nl%a}@wWO9W`MW0RAo&nC2Acwa#>I3lqd&j|&&7uYAEx=2T zGsDn8wt8&)O0<7cb`3O))W%rb|uoVBK$8w*ajTotFx~NL{3(<4ga1Uto4pNJ- zO}U??pUatNs^Q?Vpl25OeiWUiR&^ksGdwVxoj#{W+djA|ng&y@1aHk9ao^l|Pvm%e%L^0|1J zm(m2z@zh=n&Yf<43LV*SX4BF4W2;z&xUu{1GKs>L`ON;*d}C8fp7>>((RhpvLJ^wz z(r}*@c9fc>=(UK7nhvaF@E}Ah`g9PblKY=92<;kEcD-%9m-*D$01U1G@L(^!&nSR5 zU7yCi%`uV@IWG^kA(*ESDxnuc!vW*eJD57tZSIG znG!Il9~P^Jct4^`ez&$o`Z>O^FpSOF$0)W>ojs#DA&8;b(x)0HcmJzIAV6998oqaZ z2=0B&(2}&Lm+%j(4r)6?^bFAWIu{zrC3-iXZv*BoxU@r0dr6+XNMFinv? zWzl!R2<3f@3se6W_OCzprdv&Rw0S;KD3jO{15JPB)W-DX-1zbKk`d#Oxx=? znAgrnkx!0!l}DHGUohntCXA4i_lMdch0)w+Uhmp?or~txX{$!Af}(k6YyV}i!9jaX z*iA-=o3Fngn(}n^Q&+QP2zfGP2KQy;n63R?HBrl=q7V_Nx`mP95GfPRec~*;IlbBX z^P$CZ=#BW|@ldL(iAou+eZmmt260HTvF*f?>m-qItc~UT^rYiJ4g(Ls7z{{y&OwRN zdHn5vw)*#LuIasspq?QkK6CA1?ZZm9a>5)nX-$&`3p{m5O0DSVB+#EW6zLH6Z@sQW zkk#z%u0}hcQ^6u=Mc7dD7T}CaM@*8bfgg>j4GUh}&NiPD zsUq>H;8nM0b^QUCRK|_Ix5D zx}9^EIgB3!1o`qz{I16zvl%9`ch&q*{pz@98ny?)%D0wQW%Dbf)t+8~uPkN@j@M3J zwpf>xp{-W770e{gtR7YzbhzHEr5P~rz32_x!9B+2^qo)PoM#%qK668|2m;nOvF-w%c)!q;^c6n;7!>E*;H}%OLU|cLYQ&JcwNky6CllO zdCrjTBS_o75U463Lj({|aWPJD4D{yJl@3|b_NT}%hA?Cm+L3y8x4NRXv(0LZDD=Fu zEGgs&srxkDe-phakI8YhOgNoV&S`%!5)*@*qL2ayIw=;^rr$5loZ0dpa;Q zClk*BGD~yHY=yhG?T==iihri+q@Wb0)o_w2O>whxBj>NGG|@<0Q+)N)5k`{)Yv2c5 z04L+<>iyfU+R`if!wx^iiSrBX+ZwAmQ8a38A!e2sO>#Evx(#c{&WbIrqqrj~Eh=jo zx~qp=Jl;Xa8!O%(@1~O(X^>5lmyz_O7PQDYKwcEhmGtmj%qfpAD=9_qgxs`C{&cGR zLPllwil}An)!?!co&UEr{N00kKvyuuteLQg?2N2?~b3&k0^59KkO*|tvaXg z5&gpgFXrY{FtkkgMCFVMsKvlCL1munV)`cOXfrF zleJ5%6?0j$9FdCy)bOV$=@#|L+hb9}0IokXA#uJ+0G&v`Zb@@+j^Gr7oYf{Zl!#)p zGCt_D$PULCiElytiM|3ZP=p_k_vhX|QQrt|@{m{Yok&E_Q*gL$*eOKC+yLTC@&NQ} z0^#0#bSp~Ho|w&nJNqIv!D|vbrtJoT?5^_r2!t`X4`*NO&BU9fW%6?b`2M7sy>*Rl zHh*@P-cVO!*sk{nvNnI|sY*qd{t2$OZ5BuM9_$hgpv61U@rfZI&*!O4VT{8;n~s{(k8Pk_XDkLdWK^}BQy5)x~~Q|jwWGrC#PoFRgGx^r(n|0 zI6{32ql!k9{sBfoWqsPq;UehIf;U+F#clPs3`x^(EeK=U?CB!?Dd}lvXBcKUw}U7m z;8y=u-FaDPGON|- zxq4MNEV&0hh?6@^@0@xpPF11@gqw}??{axui4$;9h8!-ENSD- zT7^PJD=SOYFc%JHl9<5^fk5-yL*3^^|qOh>n`6ZWkRn);B!GKBmUk zcm$-6aXi%24*Lb$dJPv)V4CQ4h;!@mLr_9AiaXmD*V|>l>5~+9LEsI1aB!cEevH=H zsBgm3hk+MjLTBq<;)e#>T&|k|QM_%QQd^dAC$x&ZpzCQOV=5~^pt_tmnj>V9rVSF34-&2`{Tl*lI%H1Z(hrR+2)3_ppy7^j$hiB2|M;St2q z>2mLC0`zx+oCPl}$YEynbll0<$ac37##1h8yH5Mzf7>MGc)%vxgzbwcN4yao8_tg7 zl5{%5>)^7-A=1sWZULa|-+h4ZTGjw1V|~Yox@Y~yIvzDy>O3l1K0$E*;5B)@m@+n9 zN_J}E7~5=YeWBJtB~`7mkHRlg?~~L!l_c~NePNFTo~nbxoo>PE{nqD#Rz^D<{kLIU zL6JSpZw?>Ver)>Kq?2=H`*XZ@LEOos)B_u&K5!7PCzZyGsJWp4Hnw~Ga{|l?3=;~b z(UDVQD!$a0?`;n&A5_)&Dncuxol_`pL#n3KH1<2q#18(qWeOO6r>^sP2zT+NzmNh3 z;s<+dxJrrGwR~^e=EQp=)u)}H5d&mwA%eb&W}S0aLmx)17y ztdL|IYG%U`hn(GDgbqo~#`!YJDV3oNo!~`f=^hOeA1h;8T%_k-^G}*-J917ZZa!B# zvUsP*Kvl*mS%<&!o+go4Fvr#ScOsqNdN7?|&W||)U4xp^u%@6VOZOQ7<_LM@hbR&a zjEhqpkLQGFBA)Nqv8&W4YGNT*%18Wx+E4jmeud(|*}r^oQBA#r>sU(`~)F%+p!X5fq8o^~{kY0Ca-Vd|3cJ&5WvUcfxK`%jlJWVVbp4 zDatTb7^$^V3L5RbKyiS z+xwa;NR#gA@15KTXv{L>N*36`hOZ zC&6kU{1MZahMrbu<`0ld$3D$u`SD*J&3kPGkfZPzHJr3^yAIy;cKcZXk>a89-3Z@m zaRdD0Ylz~<{KJ0hZ2Uuh7Mu~TudYG%s+q%wk3H@zSq?_xsOk(!Yjq2B7QLcSX1|m6 zR$I3oD1rH#FYjyC;((avP3T0?G{gWFZ~YM?-FG?q$6n{eT|2dei`V&;pvxJ>A7FJ< zEgpXJ?(BRA!+(GT77>fgQNOepE_uTWF~V2oL<$$-+lTBY*&JdflTUCTd2NNYbWja| zG*NRv@{E#(s1|1~6ex zY+|IEUJ=yymZsJ-;%X1LP!fq+lC4^PQHByc9+>fObHUnt? ziu|!RNPEs~JAX1cc+`qpWu9);8t<;krHP^urhGb`wb2! zKWVfWH>nOBzwUdR^cOGm7!cuU!0oAZH5k^;6n;3z9-yOoHVO1~c$9B>0PLJiCV_+7 z{#df}w`B5@R(c(pN74bSM-E3vl_i8scB@U+`u}NtClGe#W6h{Z)QaB*xCLM0Ap4Ak zYL9Wx@>%#+9k6HM`J^F<)r4r^j3qF!OJNEU(2l|CuNs_|ykJp??}EpbBDo%mUJ`An zS2JSv6)=9QMc|!oSXG5A@6v9zPuJ}b$N3IKTE*YVAb|x*(GQ0o-(I@41Sqc3DB|!t zHFzDTQ-U8X=f5cTHp4b|8+T#?6o}mD5Xq7auScxCd%zW`A^W!RwbF&Jx`A{zVA1}0 zfCkHOk&(YybYVTh6k+iDyDBv%I|644hyH*2u|8&xSbM(v!Xfh`EnhzLu>7JNnMX$H zxysa}Cg&EmsDBj%B>kyw6I_GWVkYGCAHsUis_zw;?h0LhnMizC-)Zokackacfypy! z4c|%s@8wA2)L8sXAk*o`8E(3-BQqN0YyWCB8z`W$hNOz)e1V#6HSx4?_{ch;#kEYO zK#1P&j)3-8@Fp!4MI65ZZX7H5D94X}l@UHo*ZW3uf_z;x)!gRdj1ZIxBugq!%N^;+M-w@l3M^M7Z!Tr6kXD@FAM$0Sme@IO*YC@FV$i9m3E0aa zms71@1;amMF1qV+x}H+;gsnnR)KtL#GB1j@loa|7Nd&C%^Ut{FDKp~;tQZT980t!t zeARd0LqyfuJOzyOgEnPDCq%A?R#tm+j91+PcyYQX0dEaaZQ%C=i|3d3ql@^#Z8qUP zOC^LLs=kkm-aX{i7;jvVGt}=I0`5CY2(gPuflmBMj0E(v|MyOPtGAMmM~)zcMqE+F zeSq;tI@nPR$Y|Zu7$jG?`3q>Laud7O&_2wqs&b|?0_w^i(5sVV559rZ&wK1j8@A$S zeRzYLNk1gPVrT{+V(dfPM-l9^w9AY@%PJT!BO zr%Azl{5}2r9d4X2>JVMdA-0Tqf@@129kIB2AA*yT@;7`Ta|_BRH%AWYzvB2#vB(lb z(`3y;7n?}|9P}&7tCm87aLt+*J1%NqMtjbkIq{rcrMKKCwG^&cCl(fq&=P4GDy7Dx z#}QX3;JgbHwLr^fPSMBZGj4Pd^jZF)(O4ob*rWfsLsD1yO{EyrlPdvSGsb9UOwD{TCb0~K4}G!5DeJ-O>gayC zTaqsRGWkQ^n=sDyO0GWG!-ehel2!Dr9S?VE3K^o^QB$)-XkZ@60S09SuRqfqAnF>W zBdTHjn&7Hf6l~TOro<4xvTClmI69N_zpXMv09ml>BlYZXnU(?>M#_C9qVZcGt6@l< zm%sC5w4q9IzrAC)#YIQmVxFK%YS2}3_kn?U{!Utfl1FL>g=*<9z`9WO$|t-?cYV=Q zZ_D`;tra7;GjR9n03F*NuNO!BhvOGbd!2d90t>7$X};nH4LQUQk%oGck)K8kGt+C2 z^8mj}+0rFvAB-Dd7~LTCo=EJsA2mzFWJwM`@#}25b!j#qaEJFLGwovxSNT1h9-2<4 z%~?I_M@=Dq6`T`0wdVjWjm5t$&3ZICRM?AbwU{wNZS-z`B&hwSH#xM9wz}smC`apA zzCTbD+oJCc(}r%hZeiB?q375841oNYPkM@td4yzC(!zjI@^Nm%;AMi1@;ASx&FFw+ zTU%{lS3sH8i_?=p04E0zzuF+@&p>!ab$RQw)yI^6e`i+6*6u2^s3wr*1PVs&=8kc{ z_N4!@fBGg)Jm=YAL&_Pma6A2ki$wUBO%s%vrfd5$kQvL)sYB7cR0)Vf_jY+syCa-!r=+<;`SbjxME>~7zCy{sUo2bJs8(S2fU zEt;+jRdP>-E`%{aAL-|n`yY74n<%EFXf=0UxB@aR(hb|f27vA$_4Ih)_EVFAVPy>%OlJ-i*HaC)h}@9`rxR$p}! z5LO;l;UHJuj6B0ab~hX*>$`O$9mYZj8twW7k8UcnUC+368R&kkrQ(DNdOcbZtm8Pd z;i+ME^soLNG<-P+Z~_ZLbID&hb~7E4D-)ZJbo611?ABC;uPJ_$tZ+8^IIBmM>FkRU zZdA0EtRx75lP$k7dJe~;qzWdu@XrqbGkGe*`=!Oy6e?;u@-x zn%**R@cw-y#UNLfr@L)VJ?~VNK~@m2xE>WX4fxz6T^$}qqXgWb0L>cP#s()h1dt@C znlJy#rjPPKA*AiHveil*=Ci*%rDk^BFlVW}SpbB2Osvi0zyvcYejV#)c?+aINsN@x zN)9r6V`ZeI`JC~WSj&Xc8Thl{8qhS%V{CBJt?~HlS%_Gy%NkWX(rAxgN()d-OkU96 z%8U+&z!8JLb#h!^>?Vy;)s=nYuks(hq`mZW7QNlyDxq>@tLYALKNXTbvzkC1+qz{E zRmNMY**f};FROQOikZx&WmGcNwfrF7+2xnqkp8W6Gtl6_NwhoJOH62IK0aMH`8_Yy z>|=JdlfjMdIDFemHT%%&6~(sNBQ;M%18eW1OcGv5 z;i#Ruq>1=S*O860@jYaCcKIkE=7X4yLC4VqK5-(Ks-S3vkx%6rbB!GkiD_t6&kZ*! z2^(KN@}pa|h6C71_b4V$S=x&NQ^vG>)wWySE?6;#3;;FG|IK8CEOA~8XbdK9fiC!e zKR}u_akGvHgWe;%8o{l0E-EKGZkmIXuxNvUwDxpBga>UV{t1sIv;zZ?$nd2LX~K@L z${^95@cn*fa!nZ3m)t60nuyCk1Y3NqcR_R1z8hXhPT0wm4cEOSk0F1wF9md@+e1m-f zJV`umNJ=8aw2@ZZ(O)ZbVtUh7{CfXWjKB2GJXHz1 zn?>9%OzzUet}Fam_YR96AQLt*$`ink6G;PfgWBFSE?2+0X0>)}<#hpIN-3+pWt~}$ zrsSL*3Wt_Gh{Eh`2BUXlGUjsvXRT5j!=)yP669R4nx+Ki$H95iyiI=7=TT)<51aPb z8UYuBfIZV08!ii4HBvAmM7?%zch=+AmuTnhLzBW8t~srEXPR=rmJ4Vc7nyOd(LTfC zLbfh==@RG}m$PN2Pok6xXVcWmT+Go6R;z*V;2rh^Y{28cM!b+%9~JiD;9RO%>9JZB z5Ff-e#(j6eCxCVHa7J?b<#do)1?@>cDhcsxk|fupRHH`n-;(49*z<2p@=NfNvoMNh z7tPXzWL6jtBq9(`VOH>U$tlrzN7SP14PN-hRlQG`x2*vRDo&9Ut3l4CW(htzAleo@ z_uz?@J9#<#EUo(^;2)Y`kpb*Os;h*wyRueBFyMP~`Rn~&#bgL8ZRx{yu%j#IY>1a# zjM3CAfayi8>#xqZaz~Uc%u%_h1VB%Xtk|0Ixf$R3VIO5LKN(}@8+wzqT}GWu9@Px5 zuju~O)6hQdD#Ik-egVV>vTjtlbzxkTfsJZ@AB{ZAx2Df`ugK7B5OTJBsjO$UBqJJN z=IlDk3(0PI8NQLlop1f|zdoh@sAr^mQ6^SG>^m>LJz93SlRlBW=0Mhrs$lzmI#QQnm9+u8`4uqv)_B&yq* zNLC%JW$&l?in!A=P)~H)nqJ>L15NjwODdinbx#6vHu}w;Y2^1G#Ij}3Y##jT1L@&~ z%g#5$3&~^eA7K%5`w-kd>b$1Z4L_sU35`n{FiOCtiv`zDQ>Jn5^0m3hrD3KjS}@7q zI*YPqYCfIxCJwN}EAI#8-qnAA4emfPL=TrB(;qJR2rCX+LYMjD?H0W3UzrtSV7+|W z1=ymcF-LLQH?_EmY=6jNN{B<#vErDM0sp9_sWAJImikBV!r6{tVx5J05`UIIEP8s$ zYQz(D33zE^VsnbY=?^!D)8>LmKWhWk34t?<_nmX&zs4!+JU}ZC$h84&MssUm^+7EE zw>wI+*rDcc`CX40Wneb|sZfw&>#`(Pg&}~eposGuhBD^}PZ;kDc=1Gs^l8`9`wFqV zbMBGGeAI&tS@Yir66Q>2itaaRqj)=drHRBb{(K5MKG^e?5!0-W_=zkIg!BwI zrsx(=pI57X)EzLHgKl~*99wcXHVV@#GXQbtGNO+1GbYMo8aYO6z4A#&UP!E6@1RWg z1LKv^ZAqB95Ij4rQ3in71%X*W>EI;3U?o}=TXG@+>Nt2><>Z%%JwR-oAZ<|fli?w~ zS=qvrPCG>p(G@`CJ$mC4Wv8f=l$gEZ<9in%qM&DS#DKmbf2mDWt(NdQ0VEx-`gfv;AiD@r*@=@xD-+xL4f8%YV z-m=Mll%KEYbz(`y2KV%wc?dw28|WfGu0ZwmJ7Y+g(Gmnr77^-U{bR_YW>vY>`^`P; z&jSX?fZDwBO3{FW;BNvka%yuy0oj_ME%Q*dQh8M9U-u?Q<+C?Ljwix&sVv!S1hEO2 zH~Rs2DsPoxNw*&Wg>6U&G4Yz~1%=p>P;mFl;iW*o+pRz@#1_}YlaOaZFWy1q2XSEU@;7l7h9iW zho)JNc*E>EGI*j=#Mtb^V6)s5e)zmICzfGAJpRfQ-M#5&YA{BsnB{uwc;W2zd{l^$ z47gMcH%`vk>6$U=udH;SU@)wiB&_8lmOV8z&aUGbx4!qgB zKh8bnpXj|`Bpa=rW1a9nkq0wF)H=7bPYjHbf`Pk#hJlU5SGEE|3SXx){AwP?prWAz3E6(PG+@uOZIIbpmJ}Q0Ek-v zI!KpZm$KG#_BFmYG%P7+x2QP->b%d?KDYqe+9MGFPBJqX>3zp}7&^ADT>j~U!4X9! zn@*s)VQdRz29i??pXdt)ELCRA2b9wu8 zQXa`Uh0M8U=@p$?M2EjyrS>1LuH>!GypjMS18x4CWGRB_!uc6Q8@sT6s^83!E6|uf zLy-`(=9+{i>pdSB9>*kBBLE7~XC4`YU}}Dd!0p?u1VYK0(b3CnLc)vk!@u%nxMDgU z9bvKk(ITiYRp@$1%rE~}*#dm#;9=uM5M|VtzbCqpVU{4>cV{2!N;jfNd&eIa?U%pg z@vW7?CsrKL-98Jg_#65tv4swaBS7yer_%8>u3ZBK_db2>-?0T$P25%Tk@&@ zczi5m(A{x|gv6%5=RPQWW4Oa@C6?#f=SND*#w##CLs(xuHhPGAp#CjQJx@}Fw+EdH z7q?P{(;S=OZ2gox%^b`ZV9WI}(Y-x?fDq!xO2&%_nz|ldYVn^j^#5e=nRVNM1a}$7 ztfu^WwR<1#8r2o1**JqQUJN8!xi;4?J*J~A>+5&mKXA1AM(<1k|LFdHNCK@6UimXs z5{Wnb&|W}y_gV(8;;oZE-wv|Hk@zu(>EmMB`Kzsj#``NOfz4;~D8 z{6ZQJEylxX74*F6(x0WuE|+Jf+y!e$9wl8&2u(S%jL=+H#sDaiF%~OEbMdN!7u<(p z2!L|tSr9=3z9=94gU)H4^ObeisCmNrzP65n_%_Cj#{MF}wKULliT@tZqO$x@wo&G8 zxh`G2SMte$DK+kaT2@V(Z-|q)0S~eFAbI*I)n-i2H4xF*d~lrNpIRhpq66l352fDo z_-<20#F*j5olMKw62+g@4CF_*CpNI+os~+T9gXDpx)-ql3Yb^_)6uD%gir^Z*hVFp zq89WbhV3-7*yq|oBI-IXAIj*iUa*#^JyF|Iiz@+=(5WQb+uAu0;NQ6pQ(s7KtZFa6 z-{m=|zdUmFd3#c}nP5Uy?|epR*rTOkpt`#Lb)aLPVY<_MWgD}byPmomUd^4>Esl(? zE|+|`XpL|=8@2hhe6YFq1*I>Swj&*ULAm{`P|RuAli_ZVT;)EXNgd7LORQ@F#wYU=5G{^!Rldml#X zYCx^-NyuRZPz&J`(d&0l0dduN_75r7+R%)y=reZq(ZbIdiSTT)Na4d+V5UcVIBh(YX^?TrIVpLG2WEU*X*9!RXj5l5gilM11Z3WL$n0!c2pWXT|Ee_AF zX8^I!&VKw=iB5yIk6eaO&0ln*XNAXX$f8q9u)(~NM_6BRrOXx{>^j&ME&pL9hPtpe z=($l)hp&QXi=HK8p}Su_&Bt1<;r?b2KFPLIEFAF%d(uyx%QgA#*t}=mb(}WmRrg$L z(Zk2TGMx|0=05&Ho`T|2~M!uGvafDhc6= z(YmRL{;pa0cX=x-ZCFzC-`nR)2T z&+E|kX0R$+%aCMyF;9esip$g1m6y409%Iso>a)MFME}b^eI-2fkOnQpU^x(pLSszy z`|;rpnnjYaT_;@tj%i8fhp@?)`~IvxT2FR(DPMZjx}{mCMpx9@GIfjn4`K5Tta_Vo ztDWNoQXZ%yMLlJe9-919B_BDQEqg@U+s;pWe1@Dw0~%u0X!geCxA)`|T27myA`|N3{fwyy^m$PmTqaS3na~4>*I0iJAfAvz`Pg0S>H| zH{na5W>u9n7VCoi zNF9fl8ImChjR_56mA zrCqzj;Ob#4*TeK%+0BN5|`+NPE<~4r{5# zc$h2lI4|MZ!0`pQrYk1HLNe!yvL+P2h05vmHoq#_E@n&oSk#BLi2av{HFx&NHcY{F zFE9ZWFc2ZGi#X6X;!4o8u2+TIM%>b7A_G9juJnmF@Cq#`V_sYtOMYg4f!`gj33eFU zxLrQrWAD2J81RUmqtEq*Mh4o9VJtomm_Ty`O!G2CO28LZzHh zAiET9xb|@YgPpI=D5bt8!h|R{d!(J=W@GWeC~q>Z6`@{h=NgC^joP*IKo;hQuLtVW z;>7ApJOpl$*c12VT|3WFJMgnxAj_SoWp~DRq#i_7_xb@418LdG2WyE8HW!K8K=rH5} z4WiN@2^#pck!T*{xF*O_6*%MTlHi^!|gu2)@>B-fTHcul^QUOxLN_o9!GqxMC ztYG>kY}A6F;WSe^$L{WOTp8{UID&B6=I}Rg^i)@H9 z8U+Mnhv!?;D=zJw10-@{s_kCuj#NJ345?Jb7`TPqTgKPaPVZ@K(L=nA64$QIvzmPj{3d^71*wrS5ft+dlNWUV;A~B60t8WfdloaAb~fL_XCAZ z^3#rg+2<;(TX%&c`18UpkLJk-3OmO7YLRdUxw}9q1zw8(9u859Xq?)YKI;}hmanM^ zGy0XHPH^ih21-*3e;+6==~reV%pudS9vjYfc=eL z%pD@U2v~FTenHRXL(M!o(VLSNPc7f^TpgKfs?sUSei+6~#`cskedVL3&0mLc4EvLJ zkKW%P`)cG(hky9eDdm-*D@i9^d|HgV{YC6FQ*C9?aJ**tSQMq%$(g*55b?|FvK~5A zA~Ely3kCMQR}_B8Simwc7i`xp&Xe57YJhnm>7L865#L^!6Nw?Adi?4^nm?>H&BRvQxYL0HG^O#{fl638WjRe8O|;cqDaji+Jt@yEJ7KiZzH%87>zT@zv1J;?B9hab=8?9GhKR%^O^r!_^J z>rOLA{;hk3RY&oWa#qQvRe(U_6dHK27tKts`dU$r6)!zq_3QBXTPqKSiX|WG;$xf_ zh0y(0Ec#JJtIVbzDJYRM4Nkgm_|FVhMH0D#_xs=w$FIG zOQgmeR7;qQ|E)CgHB3E3TD-FH$yd`r4v{+&jqC8VnFQB*>U!uoq4$su(46k-9t|@o^u=*<$xWBy}6tbwVn-T%)qY`VY{-&HS2Bw{!YiFDMiBE?u zd76IEG-BIVRn4^Fs+KmPndlJJGT(6e)#9h{Mr2F))U8tlI-b-Q@90)cFaki%{&5;k zo4PMz-?c6pY5$ZYSW!{8BT!xWIY&K<(yUR_S3bph^Y4o_v3LIEX+EftSgd9Jw}o03;0b~)&NGPJ^N$>A61|u$Hx`%7 z-ouBdDyU_S)yX!fhhO;V#{Xl}5Ncbr3K*uDt;y1K2Jup}K@RIa$_*00Ei}_GzS8jC zRF;**wSkspmh%}m41oy_cUhDOn<_sD?-H=g^Mzfa3(aHEChW|HI0Nr=p30S_5)>$-j z5yseOv7>kOSV%kWB#DhbwBcq4D_wb7dg=z<6cgg9dav7_U=7uHQ+e}OF}}PDMD$r& zl>Lcj+Ss*7i7}EDm-7#zUT>lJ%0?;MIJ`6Vr1H0*VB1j=P1FrAkt99fW(S9V8kl>> zcy@p)OcR+(oHtI=5%K;~)n_$gGL_j$$s&KZWwoqgnSq3xlx-`b{B8LeD_i!AFZiY@ z;wxG(b6uYGtb8?W$KgpOeV+Pq0`%Zh6rhRZ%H_80f<0DeRTtNJpW=g=zTXZQh?Ba~ zjYTXvU0h!+bxu#7v|C&U?b_LDU*1Ez76S9}puzNJwiBMTdO&*b;A$L&AgrEbtEcyY zC$1l{Wmu6#1fyJ3-?|Ep;v#p>G2V(PV}aa9Il^rEEF&4|V4<*+vBK!P3EUYfF=Epp zB{8mx$A`~K2)+Wqh!OM2>#=Dz`G)}h7*QWsB=jf4pC^R-mCAcC@A)|}zkMWb zMCTL!M?Wmi5-fqD2^-6~{1US`?JDEkCtA)%-fzFU^5QzVfC#cv-suzlB!aT2YjL( zB(#63Mb1!qf4{7;@aZlkeNpgY7Pmzj*MHL;r8K(emJQ1ReOFY?{Clwqw#QDzJTL=Q z0^YVzUaHzF+g*jy{M;!z8XR8Ly5B&0!(n$+z2aug3$paYK6WxjmbO`8WwQ3_OXq_>v+cJ?G|pW~ye7d|ojs zCRj_kP3K=+=IW#qUQmG1x#+%2Lcxx(4{0L?i|)NPE^yl(L87q;(_bT zn$rX%s#Vq;`DO|~)^+=^9teXx{ZHD@Z)kJeB%1TClI{aTDIM~;k8oW2~VxbI^9_N@dgDlh5eYwg| zjT;OXKM_ai#JLC1$kHG3Q(^m2(>W9Sm8*>|Lb9nSPaIppSw)Y=xc@RH?iUbQ!dcOS z7^0v6AYQ`c`b7KSr6ey8wT&mMJ%{KmszmZKpLek2%^Pu-lN>qYi;XED?g3?mBa}j! zP&x`c%id;MtHsicgIfPaJ4vdzeAhDvtB8rDV4NrUnCw*4^V#FxcHI_ngHNfEK0NLa zLK3?I94Q?2*ObitbClC1P_m(dFJ9pq622;Z2>=73H`OSc|3{MQbo9Svswre*(e#14N4ON7{HCTZvWdgrlXg?Q;6jPm$3;)YV%?mX%Li^kCVA$YSo!ZiGZ zmVbnSAMnpHyKotAQp{v6H zc~>=L_IAR^)feAFRM@5#&sm3kT|1c5Td-&D#`J#)k5tCu9x?|Mj>4=rS^^mD5kaiNT32$|4~?e7sQJd?!HrR<(baN(`|rS34sN z$}$0lV8w&%YfXQBdU2UGNlrt+`-P1=&A(&H()HyBR+lqg*z}ur?2l0&EkFBO8L4;- zH8VN67Yp-nHm!TIHS>AN_yOm9w{JP z{OC<$Om!?bT|@I|X~q~T{|T;VG^A3D`*=Em%SZnvk0ZkEkeJ(BDX&v6U%Pc|$` z&pr^*$F3R>6>moV3gX(E9r7~J+<3{IY`XQ9Zji~sFzTx`6motQ^=J4guP^*RdvE1; ztDD%0CXTLIZfJA|q*v9uD(O}ifLgsn80%@PBu^{MNFKBRik{UVn-|i7NyFiqzL!hq z55Csoh8&kOZUQ$j%?4%9zJTHCF6|@WnDzmA^_M)ue!2Qh0)GN)Kg}fN2sQ^)N#*s@ zG#~Ke>(wtz43p3mgVnj@T9R{ja9^oQi~HQGMMC@*ff>5Q)O9|heu+gEF|>faB*>GSep?%FLjf2#Yj&-ZliHY&c7a-y}1Z}d|m?ksE4 zOX6@~jEv6q(rec=ViV-m{Xlx?9nQQYkf0gNQ;2$%jB)eg8tJ-q08}8r02)`idEy&p z`wHYm0l;YF1F_b=nCTbZ=iWN0KcMP?_C{A39hhC2xi0nXl_w1x5!8-d)XT~X^;kEF zW5YzQ_Zb6Mi;tDo9yiksr^v0cgxzo&czS4Zy!&8e)OZryGqasQev7_3!8|W7Sxv7f zvAQYG^@fMf!rI{?afq{84u_SiuixL@Tit01WZbwlsGpGPs-30e0O`#9{r!)g!^bgl zi-l=wD_4fJaVy@~y+1p+z4=iqgX^NQB+lMWpd_K%b)Q1oh=F4dp%aNUbJEb4JaCwS za9Q%{0|YXurx~5)02~=$VYTk7yJl;%mE|7gNHA=u?Xm`QfH&VVp=#I!w)3=r=`83X z=yeGZy{KyV=a2Yle)YX_Nf43f^CxtZUBDG1)keMXbs63*?5mbR zEG&np+-@3~2KBb(t?qX4 z+O_0-lw_!@5h1LS)XE?&i%m843dSi$8s{gagk_DP-_xb~r`X=MHIHl|4>+6ekqTM~ zx#GqYFr(#*KIe6bU);YE(|j(u!f^Oq)n>*{ayt{AqjWQ;$`(6~^Y6EclQqQcbU`uVY`Ky_RanCXTG4F!bB3}D7viUh17m7cCzKH@|1 z^XE~-&!V|GD&Cl!fLt}Ep8Oz42O{v-`3iDL>;fa_j?mHs&P=3b-UeSDG;>>MF0K+( z4gX;{Mawg4IfhMB{r8U(8t!P^sYJC@SKJH(iPS+*igm-Mw*(q%?xkEdnAiq`To(2@wzp2}zL#MOsD-5tt|< zok}-I2yQeeI9l4#jB=E0^mEWV{_ma_&zpxA`vBWcT-W(s=bS6P-&{enFgl`S(>+BB zJmNG25>sL{{2HW1Z~bL2*GN&}j#gDPSZGLGv0Gpc8=a+=d1`TdKk7As?SXodwyyA# zGR=@GCd?}`0MkLeYpj!6lcyBAQUPT2sG@<_A>^NetY*A9k+;YJEd#DlqeHMMJ<+%L z-FV>+uz2A7%n#$ty=T!&(Pv+l59T2@5$0}0L&>@>ww1E=4Zx9G*Ke<$@r+w+g1nFK z=|Qfd71;G1MR~1&Jl0#2paqF>#Ja-APS#p_R7249?&`@uxP1F37M%dTYM zZWDd4fqIm1amI{BM(d>wQ)Mq%{@qAf!@ZW1#N)3YWk@Uvt0jG8jZlUr$z2HsLAG3S z+<>exsZpX-?34{9Z3(}f((GG&>&N;%tU5+U;Z8>6UI@dfMKUI&pMfo`R_3zl7gN*6 z(R+WNfxnV7<%SJpBJSK7L$a-@rUj5bM4qs1JY0G)P6`h3elIt91EV}Edk#CNg2!- z_t={a9A}Ii(5%hQ?d#xC;v?WdFl{^`^SL>8#~+g8?!L~So_Zcs=aGpGCN3{{+t}_m zGln^;$E^0KMH6zTK|S?htoi%-Et2uVkDd{(-0f%@DgE)w$(?HY+}obHbHlv-9PUp_ zM`^mA8yxeg4+Q@W>|uI-{_Riz9Hu0^Zi{MP>ut}98|7HGEW0bkumJM=q_aQoltofPb6ZXkis6Ms^TM<99p*BFhuT+yozT*_{7`zH1SN7@4tEA6$X zD<%23X>q=_@d__67FxqKlA6UHDR)gv5YB{XoUDDukYYnW%}(kf9bzQkyFqwQ+mCIO zFmB*h)KH*r1peYn(QmgdXf?t^Q*4#BRWid%)e@xTwZ8M!%^eBB--#;yY_)J^ZyNge zLmYkB@&iDYKhI1@c4C*zMIR#NN_N8(D>4J`2|Sp?p)-C1lzio4YP$`|z(sWC!cQOM z_KgqK_3P}`Ngka1Rl~Z^8;oARX1g#r3u7hM!Z#%4?P_;-K$#?$z-i$$!sdxYv~+T! zM*YfRv;0QU*0)lK;h-Q$1}JH1vN zfO_C&k3{#6RHU-iiRL7Nw=+i4wZ@9M%-|bF9frAX z4?S&awHpY^&KoPA?MJtMALI-0ef8z;>fthFAiw(sZ)l-x*y!sjQ{qOMl(yRMT>%eP z%LDUof1X6*R@b95l}Y@`TT26v&^`5ta5vcAVGatu*BU&1ye#tTw$ek^!!_+gtQxAk zZpy)cv*cQ_wL%D$rx;w3Th9Q#5kj)dRI@DqVD+hH$mxr`sk=2+pjvR7Wma1xuEf=U zcIkx>+#&Iiq1lQ1K|&&buFOcyaC9m}Ko9Bv(7V{Gj2&hI@!EdcU0aQfhQTH>JJ-IB z!`v}#jJU5xcAU(_uI|zIvI*s0h>1K0q3*2tJ9Y<9p{$bNY;Vn_En41snIK$xh4C;M z8`3>nJ1h5&>xJ0o+pkYw*(MbK8nU_Bg*A$z3tm*YjOBjQzIgKXpmJe#y^QxLqrIV? z=h{1kw=_@6jEsM*RqfPapB4s)`kV`fqmaz~R@YATB0pYCp`i*-$vNd$=?fh6<3I5> zea`+Q`DJ{k;FLqlUa8QUbzmQiTHOl%JVSUrAo}2P4*j&vb}>Rp032!>cKx?u!ZrAWQA=rcE;ECrJgfqCxA^Kr?E0Ea z6VyEutHrrz^Vv%>6|ZzqJYsUJV;tPI^V6asdvqmUHMaC;U&fonr>Y?i6#$3F*tPh; zg*-x8eQs5`X5R*d&}8=Bn^jl{>u3XXm+~GQ9Gq`8TW6~6M85HIYf%nez|#u|C(+{h zFw_{az@M2_24e)7$5$afC!U_~2(wB0^X^=>UieW=%X=}i<-SMdedDEO$M!xuAt(DW zYnouy(q>NwJq<_eQ<+@oz+5d3F8WEi`Q)=}(FfAFMS2KnQi^zz{zsR)*Vlbk$n!2& zW?a2G_Z^2SpA@`i!n*qwHx;0M6pSW3U{4@sy7cv(uD&YGVQAe92$w90DBr(3WHf%3B$5KY7G7VDatQJSUr0 zq~w|d84<#lRLd#OAjQi&40@1VS*RSdu0aE?Yf1feru<1TmJ7y1k_8A)@VQ>|Rx zXM5{|C2^-(*}ZUj9$}A@8=N@syc)L7KJO2@8-FyV*#L$VW)S^ZXN|X$o zMi}2-mwdFsJodv_;9zaFuc+2o+4gMRK3rq+#eZ%S0?wrg3CoNC#%2gCk;1L3VOfqp z{v19ngi;+w!`>Lnjflu2d?n2tbKdR>6Q_uJK2C0iilpzR5`!1ZRrWxON4^)@>yEGq zLxi@LQEbnTnwT%O!qoHXt<&SlCRLQ77|OC}^(307Y=py%v|R+bl=!O&?(>B>2(?$b zs8&=>^S&H$9vnc)Lr~)v3>vPFwN$oYMK8!6MUQU{UeYOV!{X9C`%G!}aJ0OY_+wPJ zisk73F@ml}3IDKm`vB$mfrbkt^L3R9BbU*gQYX-$$^#zDsJjHq{JcY}^G&laP2^Vn zg11i|dq?3c*Sv;z#9qJLF^CykR+R4RARp7jcS(DSEIrv$GSH1#=I)wViX6JkL^lUz zt}k4<{8n5ah~IoZ96^}5O|7tY)5*m9+P=k;<$BXM>vD&HPH4+8DWGyX=-OFj*?_=N zw!XD7XYt17ywNN@C(HM6X}0fUj|X3iSF{vYlveO3vIT=W(}>UF0Cg54+b7TH9wECC zhT3adU>5S@;iq+%!dEo2Ud%w349VVK$IpI@$mC{Muy6`1E(nh38(NjUwhk3*?n}*Bee*Opb4uvZIcn06tqygashQS0~VO%os5K!{&J9k z_gt%6b$4iZf)DB+k82Z+vwH9`M=u@QqG1&|#rMW7 zjNueEc>kAv)9K0D`%iD{YSg04w%3I2x!iZ;`&k_Z`AnLByxp`AgmbvF*wD#X+=QGv zzKs2N@?vLehXz&L^GU!Eh;@19>ar!%RjxWJ{hTll+qOXZhAaTc6Vus1VG^oU0y7EY zOuGdN1dirKs(-Gh!b#hx>Q%bpBPQVouim!-@|pCr@|V@gHbAQ*OE-z6W2H1n<5`=4 zNrrSXkcUJvj3_@>^svE_q)j9WdLXtUvG&~^MwT}ymZ$asD#Q)123h?+|w_v z_~50dJ<(S3Pw~ei*=_n{+!~K65rX&qY$O(V%)-0YE>0P3#hQrJ+j&uFy%4)_=xx(R z17KL50q@-#nDEpvZgncg+~6uj?U(t`<@AN-aqTWInOJF;3c~z`k9|4SqEMl{PhNXl z8wndD@CaJ9s3vbOy9ZvbcHnoic-332v&)A{`49_`o;*&R;uO6<^ZJXhjPEHK2366r zlR+7Xtp@3$-zQJpQD!-lF8BB$MPq!B`enB{Qg9<1E4Tg0`J;Vx zRqZVIm#o8_d#R+VBOmV)*IQ8ye~~UKCCLwI9a!80jYlc83T8@S_2=q;;Et;-o)h5G z|L*j)zbgZgs&CRuoo{k6DU*cK@@Y4O$Yty?0E3};xppU3(|lI9_tQ4IblEo}bOP-- zK3<1y=L@iHq{+d*8L8v^MOE^c^ZOCAp}-OICb7%meh`b@4dp8+;j6PDOmjT4Wtga) zwc3K~XQSZRJnn7Zpx`c>!9?q4U&Y5Q-72x~dioCeQv!yDE&t463kD)`))3Uh$MM9R)A}7m_}4RI!iaTxJ5g?t|#s{VGOEM#6n?h$b!b8s#oVA)@GB z-F8ItdhJ~R>uYV1w);ahe9EK@rF3`y#^~F#A*9^B3o^0-J!a!n`*Aj5K^V#u>*%@l z0g49a4SN9&aCNUd$0~PnVxwEXUN54?LWi%|CUOMH~dN_p# z2PkI;vI&nOKo0=q7%M~XP8ZB2!|VDsOrF-}YP#F+4;m zfiNWgQGA|Dkvj)hDM6SK#>o9b4y$uFXAjr1@aKyWnZ7lRIOtF0uBZH!oocBGpUqzfe zQon2&ngjA>8UwT>_8SW)Y)-dsv$QA>jh|d^?A@50Sp~(Kki>nxa&=h8+p^SL|itUy9-{##vICA_z4$nG<;X*m zP}A0jJBHi%r=*C>5v$(WmhA@A7yS{{SaHl)qqdY^kI{z5488-o_f5i59No}5`t0piUE4;USkf&O3OGcxGk_N3H5s+0QLg1|R( zhu{Y0L!j)~qQTe0NJJLJ)a1d_D+`>+Ygm?XJAlsBsyff;tx8_j`FL{NcVitv6`;AO z0~PBb8(GVWZ6)#5Y4$r%Gr9~U(*9|MtU-_4+tscoW6s#;AN2$JK_>EV;+l>z6ws{4 zMk36h59{;2=M1akMpnj1W2w@EEMn_)!PQnCQO8MTDg?P_-a17tcA@@uMp!3+%LYw< zCq|TqocC@jl$|HU*cdW+8a(@Z4pkobeTNZ!t@lB6QuXsZQ%Uv{1BUDEU|CwD;T2FG6dFtaP~y?MTjXxX2eRA|UW-I3GH zGCK(PD4Wiw<8L7&ePkRj#(Yb+NkV$SDq4g;dsrek-u~8kuwHD5&kGa`IqYBVAb{+6sFCI2Ix2;HsbTyOIn602suh21z*KEAb zMa@0WXzKKavg8*#1~g}4(zw)ulVV&OLl38INg&D@we7)N)E1(u0N4=ZE7lm0v>E;! zTv_(-n7Lv~;5lOZ&IP7kXJ}L=NeX|^WMlnWuTEU}J6_ypx96B8*sf@tH+p>@xItwO zEujagzco?R4QQ7;;{avZP|TB-Vou4T$Jm{n@gZ&i~b8dos# zC@=-7-GV<4g?oZ+IA?lrFRNBQ+Sew`Tx&ir_NCv#&#}V{XMDh@}($L z7G%4$XxhYu6o#lTz3jch0-&RkX6_jHepZOfR^X>oVob(M&a2r^O@(g8#O1-8!MpY6@^xQ-Jl z&M4<(!ub;MUiLd?dP~I~2;DyhzJLc$eahP#3M$aLwmz4>-9597Y=+;dkz^usErM~6 zK#8}4+rr+Bk-(V%jvZRBOJd0K_(wfgNT8(umS zk#P%7h7pq6*2mYA#s9aMH0gqyamRhuaj-!%o##U349N z>WBkKQpXC{%HQ#JUSHZBxvlfaQF)#mSU2@p1j$Sv_w70Ay1(-#?~n zJBBTo7>__QMWSB!wMF_k5sH=Myi^#QdqwJtv)KJ-4BHhgfVYOAQo(4)MGU(F*}%_e{5jN0Ebul>f%B7*_O|< z+&#+}hCPJ0q!E2cM~3?mTTwx0WDLJuMJ&g{{6HXPi`B&uS6BvR1hiW_+M?t%g{u|t+JFlq(MG=o>*M^Z5#~VL7bohah9jbwxmY>;#TjxNX z85DbnYfd9Owi}^oC^+k5Ii-eBugA5OjIOjrW-)FjA zvS<^v$?(_}P?A~yM>}TEvHR#2wujEBdQS9WNbK+F2@ou77M~~9L)PTu?o~;*zYT1t zAk9B=fUb2)%uBo~>qoYc$ftSi7C@@t?%5`qho*M@;$bWgSl=2FX71>7Yu!PdZwMy4 z&-mw%{KbpBCPGkdKC(Odmu*03y_!_q*+yXCdej|#jh3jz1|qGhOu<@3kZ%ofpSX_3 zDbtwWT>=c10Yn+n>5E(}HU)&}`!xe8wR67-^u&XwkKl#ZiwWYN0j=V$;CSU&Mm~f_%BtDXdy}HqVl*WfgEkEccDxljHp1fYoy(=|LieCAO zHX*KUTBQ<6e9B-aPYV#!@|ENtFBkr{_(t&VkK9G%ZtYADeC6%r-7TK@kGV@>Uvy6As(cH!BW)t_0siRKz*wN1{yBJ9%bHchx6Rwi8q8)6&p!4K8vGehwOmZEa`#gSPI8qaO`%U7h}BBe-L(+mIZABdzz@%8ai$c>V=!SW#O$Q5t+g>*q8N>s z{4QPNKTU#mO|N*RMx_7^iCSa&zYg+{cR)lbOng4K;EqGWg{fs9jj4$|^Gxx~v;Q%x z&5tHHt2AdZw+QvD5z=8SP)yL&=A2E~niq?_ob-E}{ypWY=)B$U=g~Okt@ihyeG2R^ zmD%+~T)GD3N+R)$Fn_lT`ytVx)7At_w*V8Cbs=IWQH)uoxWg|1?q2V*j~CYI|DH4V zwkiWZpYv^6B0`%^byMZM-j=;9K2)Vx>rgm7OxTX&QhPFk=3sF zb%yW2#VZ*ME2-LY720ZJ&t#gy#1(vDVd9@`hP=j73KS2uP|Vi+sut6`n|xX1_mII$ z)GB?0t_A?hRnex-J+7r>t?FZ?Wta2CBitpcHrj12@!DuH$jy3&~ z^eNpGgp00HcM|hHGFRtU?NXUk^Rs5?BvzLGt+lh4g{W>012pcmv-66q-&juUJ^z;6(B%yi*5&D<%)(CWE90l%Y9GOA9!+&Iydky?9V(nu z?NTevYSPI*rIe$YZ}xA`{byxxzvc%@yaKGzGDJL^zBMv%OCRHhvBa2y4p5y}FcKwm zI3`cg?0VsJ*9g7(eiqwxvozo0;5-(j`n~ayNw-tC52ITvHnBpf&QwZ=>1UzvNlzLl zZvp-5P`$2Yy|er1?oC;vCLND27$F@cuZCz+5=Hq~7fURFq>U$*MG?#0h zx=CZ6O)pZ@$<%oH-}&spq<)Utz^NV`aT=B~<71dV)`{0h`^eO=?ivcfJv0}8;M0_) zjsKHcJdm0T9T5RzNj=-#E=pmCSc21^$RBL~bz}GG<*3V(&JX4HUtlz|jZS-Vk1g0j z@)^1-Qle~N*gh~1_^;1zkZ$cPe(g$C^^2hJ+HG_E>Unm=`!kO;pzClzvOUJK^bmuu z;6CkF^-*lsWS1eXH%o&wJ>;cq>LTo$ggxn^R^IbPYQrQfADR_WD|J1vu8-=-=zr=W zM1W~mB+ayqC_J;_znkU3YPiwQ0%{LbMDn>s`%CCwEpLP1WjAZA06jgDqPJ$kO4bK| zOoQ|tKEqgXD>5d{WcS`tB;8<=N+21^&c+`hZu}>Kg!6u;k~LSN@^rkoVU9r7hMZ@I zPhp*ptAKlGr4a%!Y$K@LFlHbNrCp%FC@m;$Fp#O!R#f)z-+2zIQ!94?0Gxt)!fb*& zrI%y6$-Z%m=-qX7W1UXeKL<-rd7`Csqh@Qwpur4WLP_%86fS0%-Z76+=Lq;`T51Vg z1MPpslYl&=S=g6;0vq+%$q|oUa$wQaf`~NJ;e4+KTNba;Q5lt|v zpir}woOeHHQ&#bucX;7q;-x!QvIqYSO`9%Q5VXRR6qYSQo9^HT0P)l8q9x}73npGV z8O8H+ogA|O{hQ2vtFFeLW>I84Sp3g2d<_%OyY*g|mZ+CTnnX_4rJK`TMK};$J|M9; zuWckjuH)8bRU%~8SJR@!>ua2TDs;l$XC9e3c?uA-ly8izhW3S<`>~Ll(4)8&GQu{K zdnNxz@PyHuJ!A+hk|rHGr`q3CA!fBs`BA!K)f<55#%!*+IKSAW!Gx#C9r93zZDZC0 zIOPcCR8UI)2rTF5Rhy_Skh)+&qxy6fvRYW%hXQInUtr<-lWAmEFX_tVEj*{swt5?j zcW44j=<(ROJgt}U@5xN2(uu?dx6MFo=)a{}#goD$SOpH#ldZdvs4yuKeu&5Y`L))ER4tXt)JsnddlY=ur%7VlDy=wb|26nIE(YON zk>Fa%Nt@eirf#B+u27ucjqyT{Fdf}sPm@glt{_L?h>nB==?L$5aL0WX)6+|+2ENq4 z_4fW=9P?}<7g#5sfptb3?Q|Z*-`!ZlO2W$o;Jl$5 zPXMjG9tEqjs$R}vJy#b4+p_lm>_DwqWJtEz!;|v`^wEuKX|2g4QAjiGu4&1tl{HAd zIy#bSZFz+&R8!fDc3N8-oy|mg_+^~`-&G{TF#FCH%H>2bL@amtKy2~`5&K;qy^&~e z1^5--;GqaE=hQy~PB*V|rZ|85;^f2Wy{kz5fQSFzeU|^sJEwaq<2rGq&K&W;PGd%1hnb-&#DU7?${=4D+kcnM{A?OnG6ue`}2A zpAnH&a!7orBial)rHUH5ld4v8dpzUD2Cy{3*G9>eH~on+aDhg`3xH=uPrr4Xae(eC zv>IF9PkiTgV3|dX`hTlP&4^hO|CY<({R9E6_Y5FSl$@5X6hPW)2~W^1?Kkf@3Gp7u zp{1L?*xM8)&Lka^IYOv}i-z|UJrkqdRvnF#ILqTv@(sDV$ zB-^%PjpJQ9X8=xH<7n+QK9DWD_=8UOAma4ZIT<~lI7?e+J)0jbq*<(4<1+Vrj?P@6 ze{AP4xJP^q7l69bM4^yjSyrkd_2anXH#uIVvH)uJC#3N(baW*nUKb%=`jN29K|INX z+hicfN=;P7f^9*JjdO|n>;n9EYdH=?s++elL>MUyZ}-*Huq1PcSRBVkr`&=hG7S2K zjsC{=rQPep^Z=Ie(Ldh--+s+U26Y9APi~<~&s{uhYZ`h(5w2w4r~{G}P9rm*n1xBc ziT8hx7xZN;@xi2ohYV`fTmz+!+>6A#pI|Bi*`gn}f%@2 zFj4DFwEchEYq-$DCVl1Exd5HXC3mt1HB*}}sihrxh5%uW6Ri6R=!2~7 zQpj33)f&up-yBm8&bgfbnK88lZor)e#QWF}l<-n{T zQq;V|9%e=e|JS#D6k9M4@FiDp_kO2k_am76PS~~W+pmqlnslfN{Lh&G)#v}8|I^^k az@5f5 { + 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 %}