diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 49df775..cea658a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.8 +current_version = v0.1.9 commit = True tag = True diff --git a/.dockerignore b/.dockerignore index 704f86b..036bd21 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,6 @@ __pycache__/ # Ignore Git files .git -cookies* +*cookies* set_env.sh jellyplist.code-workspace \ No newline at end of file diff --git a/.github/workflows/manual-build.yml b/.github/workflows/manual-build.yml index 3f2de0b..e27df60 100644 --- a/.github/workflows/manual-build.yml +++ b/.github/workflows/manual-build.yml @@ -32,9 +32,21 @@ jobs: - name: Extract Version id: extract_version run: | - version=$(python3 -c "import version; print(f'${{ env.BRANCH_NAME}}-{version.__version__}')") + version=$(python3 -c "import version; print(f'{version.__version__}')") echo "VERSION=$version" >> $GITHUB_ENV + - name: Read Changelog + id: changelog + run: | + if [ -f changelogs/${{ env.VERSION }}.md ]; then + changelog_content=$(cat changelogs/${{ env.VERSION }}.md) + echo "CHANGELOG_CONTENT<> $GITHUB_ENV + echo "$changelog_content" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + else + echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV + fi + # Set up Docker - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 @@ -56,15 +68,19 @@ jobs: push: true tags: | ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }} - ghcr.io/${{ github.repository }}:dev - ghcr.io/${{ github.repository }}:${{ env.VERSION }} + ghcr.io/${{ github.repository }}:${{ env.BRANCH_NAME }} + ghcr.io/${{ github.repository }}:${{ env.VERSION }}-${{ env.BRANCH_NAME}} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ env.VERSION }} - name: Dev Release ${{ env.VERSION }} + tag_name: | + ${{ env.VERSION }}-${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }} + name: | + ${{ env.BRANCH_NAME }} Release ${{ env.VERSION }} + body: | + ${{ env.CHANGELOG_CONTENT }} generate_release_notes: true make_latest: false - \ No newline at end of file + diff --git a/.gitignore b/.gitignore index a93ac84..121acd9 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ set_env.sh notes.md DEV_BUILD payload.json +settings.yaml diff --git a/Dockerfile b/Dockerfile index fa5c96c..f1c4ac5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /jellyplist COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt RUN apt update -RUN apt install ffmpeg netcat-openbsd -y +RUN apt install ffmpeg netcat-openbsd supervisor -y # Copy the application code COPY . . COPY entrypoint.sh /entrypoint.sh @@ -16,6 +16,7 @@ RUN chmod +x /entrypoint.sh # Expose the port the app runs on EXPOSE 5055 +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf # Set the entrypoint @@ -23,4 +24,4 @@ ENTRYPOINT ["/entrypoint.sh"] # Run the application -CMD ["python", "run.py"] \ No newline at end of file +CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index bb36dc0..3aaf1ed 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,8 @@ from logging.handlers import RotatingFileHandler import os +import threading import time +import yaml from flask_socketio import SocketIO import sys @@ -97,15 +99,38 @@ app = Flask(__name__, template_folder="../templates", static_folder='../static') app.config.from_object(Config) +app.config['runtime_settings'] = {} +yaml_file = 'settings.yaml' +def load_yaml_settings(): + with open(yaml_file, 'r') as f: + app.config['runtime_settings'] = yaml.safe_load(f) +def save_yaml_settings(): + with open(yaml_file, 'w') as f: + yaml.dump(app.config['runtime_settings'], f) + + for handler in app.logger.handlers: app.logger.removeHandler(handler) + log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) # Default to DEBUG if invalid app.logger.setLevel(log_level) -FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(levelname)7s - %(message)s" +FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)36s() ] %(levelname)7s - %(message)s" logging.basicConfig(format=FORMAT) +# Add RotatingFileHandler to log to a file +# if worker is in sys.argv, we are running a celery worker, so we log to a different file +if 'worker' in sys.argv: + log_file = os.path.join("/var/log/", 'jellyplist_worker.log') +elif 'beat' in sys.argv: + log_file = os.path.join("/var/log/", 'jellyplist_beat.log') +else: + log_file = os.path.join("/var/log/", 'jellyplist.log') +file_handler = RotatingFileHandler(log_file, maxBytes=2 * 1024 * 1024, backupCount=10) +file_handler.setFormatter(logging.Formatter(FORMAT)) +app.logger.addHandler(file_handler) + Config.validate_env_vars() cache = Cache(app) redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True) @@ -185,3 +210,28 @@ if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']: app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}') from lidarr.client import LidarrClient lidarr_client = LidarrClient(app.config['LIDARR_URL'], app.config['LIDARR_API_KEY']) + + + + + +if os.path.exists(yaml_file): + app.logger.info('Loading runtime settings from settings.yaml') + load_yaml_settings() + # def watch_yaml_file(yaml_file, interval=30): + # last_mtime = os.path.getmtime(yaml_file) + # while True: + # time.sleep(interval) + # current_mtime = os.path.getmtime(yaml_file) + # if current_mtime != last_mtime: + # last_mtime = current_mtime + # yaml_settings = load_yaml_settings(yaml_file) + # app.config.update(yaml_settings) + # app.logger.info(f"Reloaded YAML settings from {yaml_file}") + + # watcher_thread = threading.Thread( + # target=watch_yaml_file, + # args=('settings.yaml',), + # daemon=True + # ) + # watcher_thread.start() \ No newline at end of file diff --git a/app/functions.py b/app/functions.py index ea9ce53..d0cc7e5 100644 --- a/app/functions.py +++ b/app/functions.py @@ -112,6 +112,22 @@ def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track: app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}") return None +@cache.memoize(timeout=3600) +def get_cached_provider_playlist(playlist_id : str,provider_id : str)-> base.Playlist: + """ + Fetches a playlist by its ID, utilizing caching to minimize API calls. + + :param playlist_id: The playlist ID. + :return: Playlist data as a dictionary, or None if an error occurs. + """ + try: + # get the provider from the registry + provider = MusicProviderRegistry.get_provider(provider_id) + playlist_data = provider.get_playlist(playlist_id) + return playlist_data + except Exception as e: + app.logger.error(f"Error fetching playlist {playlist_id} from {provider_id}: {str(e)}") + return None def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]: is_admin = session.get('is_admin', False) @@ -244,4 +260,8 @@ def get_latest_release(tag_name :str): return False, '' except requests.exceptions.RequestException as e: app.logger.error(f"Error fetching latest version: {str(e)}") - return False,'' \ No newline at end of file + return False,'' + +def set_log_level(level): + app.logger.setLevel(level) + app.logger.info(f"Log level set to {level}") \ No newline at end of file diff --git a/app/models.py b/app/models.py index 42f4dc4..ca8a89e 100644 --- a/app/models.py +++ b/app/models.py @@ -50,7 +50,7 @@ playlist_tracks = db.Table('playlist_tracks', class Track(db.Model): id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(150), nullable=False) + name = db.Column(db.String(200), nullable=False) provider_track_id = db.Column(db.String(120), unique=True, nullable=False) provider_uri = db.Column(db.String(120), unique=True, nullable=False) downloaded = db.Column(db.Boolean()) @@ -58,9 +58,12 @@ class Track(db.Model): jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field download_status = db.Column(db.String(2048), nullable=True) provider_id = db.Column(db.String(20)) + # Many-to-Many relationship with Playlists playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') + lidarr_processed = db.Column(db.Boolean(), default=False) + quality_score = db.Column(db.Float(), default=0) def __repr__(self): return f'' diff --git a/app/routes/jellyfin_routes.py b/app/routes/jellyfin_routes.py index 6f52970..a74cd5c 100644 --- a/app/routes/jellyfin_routes.py +++ b/app/routes/jellyfin_routes.py @@ -9,7 +9,7 @@ from app.tasks import task_manager from app.registry.music_provider_registry import MusicProviderRegistry from jellyfin.objects import PlaylistMetadata -from app.routes import pl_bp +from app.routes import pl_bp, routes @app.route('/jellyfin_playlists') @functions.jellyfin_login_required @@ -34,7 +34,10 @@ def jellyfin_playlists(): combined_playlists = [] for pl in playlists: - provider_playlist = provider_client.get_playlist(pl.provider_playlist_id) + # Use the cached provider_playlist_id to fetch the playlist from the provider + provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id) + #provider_playlist = provider_client.get_playlist(pl.provider_playlist_id) + # 4. Convert the playlists to CombinedPlaylistData combined_data = functions.prepPlaylistData(provider_playlist) if combined_data: @@ -50,6 +53,13 @@ def jellyfin_playlists(): def add_playlist(): playlist_id = request.form.get('item_id') playlist_name = request.form.get('item_name') + additional_users = None + if not playlist_id and request.data: + # get data convert from json to dict + data = request.get_json() + playlist_id = data.get('item_id') + playlist_name = data.get('item_name') + additional_users = data.get('additional_users') # also get the provider id from the query params provider_id = request.args.get('provider') if not playlist_id: @@ -119,6 +129,13 @@ def add_playlist(): "can_remove":True, "jellyfin_id" : playlist.jellyfin_id } + if additional_users and session['is_admin']: + db.session.commit() + app.logger.debug(f"Additional users: {additional_users}") + for user_id in additional_users: + routes.add_jellyfin_user_to_playlist_internal(user_id,playlist.jellyfin_id) + + return render_template('partials/_add_remove_button.html',item= item) diff --git a/app/routes/routes.py b/app/routes/routes.py index c45252c..1592844 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -3,7 +3,7 @@ import json import os import re from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g -from app import app, db, functions, jellyfin, read_dev_build_file, tasks +from app import app, db, functions, jellyfin, read_dev_build_file, tasks, save_yaml_settings from app.classes import AudioProfile, CombinedPlaylistData from app.models import JellyfinUser,Playlist,Track from celery.result import AsyncResult @@ -68,13 +68,13 @@ def save_lidarr_config(): @functions.jellyfin_admin_required def task_manager(): statuses = {} + lock_keys = [] for task_name, task_id in tasks.task_manager.tasks.items(): statuses[task_name] = tasks.task_manager.get_task_status(task_name) + lock_keys.append(f"{task_name}_lock") + lock_keys.append('full_update_jellyfin_ids_lock') + return render_template('admin/tasks.html', tasks=statuses,lock_keys = lock_keys) - - return render_template('admin/tasks.html', tasks=statuses) - -@app.route('/admin') @app.route('/admin/link_issues') @functions.jellyfin_admin_required def link_issues(): @@ -107,6 +107,81 @@ def link_issues(): return render_template('admin/link_issues.html' , tracks = tracks ) +@app.route('/admin/logs') +@functions.jellyfin_admin_required +def view_logs(): + # parse the query parameter + log_name = request.args.get('name') + logs = [] + if log_name == 'logs' or not log_name and os.path.exists('/var/log/jellyplist.log'): + with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f: + logs = f.readlines() + if log_name == 'worker' and os.path.exists('/var/log/jellyplist_worker.log'): + with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f: + logs = f.readlines() + if log_name == 'beat' and os.path.exists('/var/log/jellyplist_beat.log'): + with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f: + logs = f.readlines() + return render_template('admin/logview.html', logs=str.join('',logs),name=log_name) + +@app.route('/admin/setloglevel', methods=['POST']) +@functions.jellyfin_admin_required +def set_log_level(): + loglevel = request.form.get('logLevel') + if loglevel: + if loglevel in ['DEBUG','INFO','WARNING','ERROR','CRITICAL']: + functions.set_log_level(loglevel) + flash(f'Log level set to {loglevel}', category='success') + return redirect(url_for('view_logs')) + +@app.route('/admin/logs/getLogsForIssue') +@functions.jellyfin_admin_required +def get_logs_for_issue(): + # get the last 200 lines of all log files + last_lines = -300 + logs = [] + logs += f'## Logs and Details for Issue ##\n' + logs += f'Version: *{__version__}{read_dev_build_file()}*\n' + if os.path.exists('/var/log/jellyplist.log'): + with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f: + logs += f'### jellyfin.log\n' + logs += f'```log\n' + logs += f.readlines()[last_lines:] + logs += f'```\n' + + if os.path.exists('/var/log/jellyplist_worker.log'): + with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f: + logs += f'### jellyfin_worker.log\n' + logs += f'```log\n' + logs += f.readlines()[last_lines:] + logs += f'```\n' + + if os.path.exists('/var/log/jellyplist_beat.log'): + with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f: + logs += f'### jellyplist_beat.log\n' + logs += f'```log\n' + logs += f.readlines()[last_lines:] + logs += f'```\n' + # in the logs array, anonymize IP addresses + logs = [re.sub(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', 'xxx.xxx.xxx.xxx', log) for log in logs] + + return jsonify({'logs': logs}) + +@app.route('/admin') +@app.route('/admin/settings') +@app.route('/admin/settings/save' , methods=['POST']) +@functions.jellyfin_admin_required +def admin_settings(): + # if the request is a POST request, save the settings + if request.method == 'POST': + # from the form, get all values from default_playlist_users and join them to one array of strings + + app.config['runtime_settings']['default_playlist_users'] = request.form.getlist('default_playlist_users') + save_yaml_settings() + flash('Settings saved', category='success') + return redirect('/admin/settings') + return render_template('admin/settings.html',jellyfin_users = jellyfin.get_users(session_token=functions._get_api_token())) + @app.route('/run_task/', methods=['POST']) @@ -116,6 +191,7 @@ def run_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) @@ -123,12 +199,15 @@ def run_task(task_name): @functions.jellyfin_admin_required def task_status(): statuses = {} + lock_keys = [] for task_name, task_id in tasks.task_manager.tasks.items(): statuses[task_name] = tasks.task_manager.get_task_status(task_name) + lock_keys.append(f"{task_name}_lock") + lock_keys.append('full_update_jellyfin_ids_lock') # Render the HTML partial template instead of returning JSON - return render_template('partials/_task_status.html', tasks=statuses) + return render_template('partials/_task_status.html', tasks=statuses, lock_keys = lock_keys) @@ -180,7 +259,7 @@ def openPlaylist(): try: provider_client = MusicProviderRegistry.get_provider(provider_id) extracted_playlist_id = provider_client.extract_playlist_id(playlist) - provider_playlist = provider_client.get_playlist(extracted_playlist_id) + provider_playlist = functions.get_cached_provider_playlist(extracted_playlist_id, provider_id) combined_data = functions.prepPlaylistData(provider_playlist) if combined_data: @@ -217,8 +296,8 @@ def browse_page(page_id): @functions.jellyfin_login_required def monitored_playlists(): - # 1. Get all Playlists from the Database. - all_playlists = Playlist.query.all() + # 1. Get all Playlists from the Database and order them by Id + all_playlists = Playlist.query.order_by(Playlist.id).all() # 2. Group them by provider playlists_by_provider = defaultdict(list) @@ -236,7 +315,7 @@ def monitored_playlists(): combined_playlists = [] for pl in playlists: - provider_playlist = provider_client.get_playlist(pl.provider_playlist_id) + provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id) # 4. Convert the playlists to CombinedPlaylistData combined_data = functions.prepPlaylistData(provider_playlist) if combined_data: @@ -380,13 +459,95 @@ def associate_track(): @app.route("/unlock_key",methods = ['POST']) @functions.jellyfin_admin_required def unlock_key(): - key_name = request.form.get('inputLockKey') if key_name: - tasks.release_lock(key_name) + tasks.task_manager.release_lock(key_name) flash(f'Lock {key_name} released', category='success') return '' +@app.route("/admin/getJellyfinUsers",methods = ['GET']) +@functions.jellyfin_admin_required +def get_jellyfin_users(): + users = jellyfin.get_users(session_token=functions._get_api_token()) + return jsonify({'users': users}) + + +@app.route("/admin/getJellyfinPlaylistUsers",methods = ['GET']) +@functions.jellyfin_admin_required +def get_jellyfin_playlist_users(): + playlist_id = request.args.get('playlist') + if not playlist_id: + return jsonify({'error': 'Playlist not specified'}), 400 + users = jellyfin.get_playlist_users(session_token=functions._get_api_token(), playlist_id=playlist_id) + all_users = jellyfin.get_users(session_token=functions._get_api_token()) + # extend users with the username from all_users + for user in users: + user['Name'] = next((u['Name'] for u in all_users if u['Id'] == user['UserId']), None) + + # from all_users remove the users that are already in the playlist + all_users = [u for u in all_users if u['Id'] not in [user['UserId'] for user in users]] + + + return jsonify({'assigned_users': users, 'remaining_users': all_users}) + +@app.route("/admin/removeJellyfinUserFromPlaylist", methods= ['GET']) +@functions.jellyfin_admin_required +def remove_jellyfin_user_from_playlist(): + playlist_id = request.args.get('playlist') + user_id = request.args.get('user') + if not playlist_id or not user_id: + return jsonify({'error': 'Playlist or User not specified'}), 400 + # remove this playlist also from the user in the database + # get the playlist from the db + playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first() + user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first() + if not user: + # Add the user to the database if they don't exist + jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id) + user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator']) + db.session.add(user) + db.session.commit() + + if not playlist or not user: + return jsonify({'error': 'Playlist or User not found'}), 400 + if playlist in user.playlists: + user.playlists.remove(playlist) + db.session.commit() + + jellyfin.remove_user_from_playlist2(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=user_id, admin_user_id=functions._get_admin_id()) + return jsonify({'success': True}) + +@app.route('/admin/addJellyfinUserToPlaylist') +@functions.jellyfin_admin_required +def add_jellyfin_user_to_playlist(): + playlist_id = request.args.get('playlist') + user_id = request.args.get('user') + return add_jellyfin_user_to_playlist_internal(user_id, playlist_id) + + +def add_jellyfin_user_to_playlist_internal(user_id, playlist_id): + # assign this playlist also to the user in the database + # get the playlist from the db + playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first() + user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first() + if not user: + # Add the user to the database if they don't exist + jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id) + user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator']) + db.session.add(user) + db.session.commit() + + if not playlist or not user: + return jsonify({'error': 'Playlist or User not found'}), 400 + if playlist not in user.playlists: + user.playlists.append(playlist) + db.session.commit() + + + if not playlist_id or not user_id: + return jsonify({'error': 'Playlist or User not specified'}), 400 + jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=functions._get_admin_id(), user_ids=[user_id]) + return jsonify({'success': True}) @pl_bp.route('/test') def test(): diff --git a/app/tasks.py b/app/tasks.py index 6ffd39e..c4506dc 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -21,7 +21,7 @@ from lidarr.classes import Artist @signals.celeryd_init.connect def setup_log_format(sender, conf, **kwargs): - FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s" + FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)42s() ] %(levelname)7s - %(message)s" conf.worker_log_format = FORMAT.strip().format(sender) conf.worker_task_log_format = FORMAT.format(sender) @@ -94,6 +94,9 @@ def update_all_playlists_track_status(self): app.logger.info("All playlists' track statuses updated.") return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists} + except Exception as e: + app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True) + return {'status': 'Error downloading tracks'} finally: task_manager.release_lock(lock_key) else: @@ -125,6 +128,7 @@ def download_missing_tracks(self): return {'status': 'No undownloaded tracks found'} app.logger.info(f"Found {total_tracks} tracks to download.") + app.logger.debug(f"output_dir: {output_dir}") processed_tracks = 0 failed_downloads = 0 for track in undownloaded_tracks: @@ -136,11 +140,39 @@ def download_missing_tracks(self): 'failed': failed_downloads }) # Check if the track already exists in the output directory - file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}.mp3" + if os.getenv('SPOTDL_OUTPUT_FORMAT') == '__jellyplist/{track-id}': + file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}" + else: + # if the output format is other than the default, we need to fetch the track first! + spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify") + # spotify_track has name, artists, album and id + # name needs to be mapped to {title} + # artist[0] needs to be mapped to {artist} + # artists needs to be mapped to {artists} + # album needs to be mapped to {album} , but needs to be checked if it is set or not, because it is Optional + # id needs to be mapped to {track-id} + # the output format is then used to create the file path + if spotify_track: + + file_path = output_dir.replace("{title}",spotify_track.name) + file_path = file_path.replace("{artist}",spotify_track.artists[0].name) + file_path = file_path.replace("{artists}",",".join([artist.name for artist in spotify_track.artists])) + file_path = file_path.replace("{album}",spotify_track.album.name if spotify_track.album else "") + file_path = file_path.replace("{track-id}",spotify_track.id) + app.logger.debug(f"File path: {file_path}") + + if not file_path: + app.logger.error(f"Error creating file path for track {track.name}.") + failed_downloads += 1 + track.download_status = "Error creating file path" + db.session.commit() + continue + + + # region search before download if search_before_download: app.logger.info(f"Searching for track in Jellyfin: {track.name}") - spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify") # at first try to find the track without fingerprinting it best_match = find_best_match_from_jellyfin(track) if best_match: @@ -186,13 +218,13 @@ def download_missing_tracks(self): #endregion #endregion - - 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 file_path: + 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 @@ -211,12 +243,17 @@ def download_missing_tracks(self): app.logger.debug(f"Found {cookie_file}, using it for spotDL") command.append("--cookie-file") command.append(cookie_file) + if app.config['SPOTDL_PROXY']: + app.logger.debug(f"Using proxy: {app.config['SPOTDL_PROXY']}") + command.append("--proxy") + command.append(app.config['SPOTDL_PROXY']) result = subprocess.run(command, capture_output=True, text=True, timeout=90) - if result.returncode == 0 and os.path.exists(file_path): + if result.returncode == 0: track.downloaded = True - track.filesystem_path = file_path - app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.") + if file_path: + 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}.") if result.stdout: @@ -248,6 +285,9 @@ def download_missing_tracks(self): 'processed': processed_tracks, 'failed': failed_downloads } + except Exception as e: + app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True) + return {'status': 'Error downloading tracks'} finally: task_manager.release_lock(lock_key) if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']: @@ -360,6 +400,9 @@ def check_for_playlist_updates(self): app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.") return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists} + except Exception as e: + app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True) + return {'status': 'Error downloading tracks'} finally: task_manager.release_lock(lock_key) else: @@ -375,11 +418,18 @@ def update_jellyfin_id_for_downloaded_tracks(self): app.logger.info("Starting Jellyfin ID update for tracks...") with app.app_context(): - downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all() + downloaded_tracks = Track.query.filter( + Track.downloaded == True, + Track.jellyfin_id == None, + (Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None) + ).all() if task_manager.acquire_lock(full_update_key, expiration=60*60*24): app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)") - downloaded_tracks = Track.query.all() + app.logger.info(f"\tQUALITY_SCORE_THRESHOLD = {app.config['QUALITY_SCORE_THRESHOLD']}") + downloaded_tracks = Track.query.filter( + (Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None) + ).all() else: app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None") total_tracks = len(downloaded_tracks) @@ -393,6 +443,7 @@ def update_jellyfin_id_for_downloaded_tracks(self): for track in downloaded_tracks: try: best_match = find_best_match_from_jellyfin(track) + if best_match: track.downloaded = True if track.jellyfin_id != best_match['Id']: @@ -402,10 +453,11 @@ def update_jellyfin_id_for_downloaded_tracks(self): track.filesystem_path = best_match['Path'] app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})") - + track.quality_score = best_match['quality_score'] db.session.commit() else: + app.logger.warning(f"No matching track found in Jellyfin for {track.name}.") spotify_track = None @@ -415,11 +467,14 @@ def update_jellyfin_id_for_downloaded_tracks(self): processed_tracks += 1 progress = (processed_tracks / total_tracks) * 100 + self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', 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} - + except Exception as e: + app.logger.error(f"Error updating jellyfin ids: {str(e)}", exc_info=True) + return {'status': 'Error updating jellyfin ids '} finally: task_manager.release_lock(lock_key) else: @@ -502,6 +557,9 @@ def request_lidarr(self): app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}') return {'status': 'Request sent to Lidarr'} + except Exception as e: + app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True) + return {'status': 'Error downloading tracks'} finally: task_manager.release_lock(lock_key) @@ -553,6 +611,7 @@ def find_best_match_from_jellyfin(track: Track): app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]") best_match = result + best_quality_score = quality_score break @@ -563,7 +622,9 @@ def find_best_match_from_jellyfin(track: Track): if quality_score > best_quality_score: best_match = result best_quality_score = quality_score - + # attach the quality_score to the best_match + if best_match: + best_match['quality_score'] = best_quality_score return best_match except Exception as e: app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}") @@ -587,8 +648,8 @@ def compute_quality_score(result, use_ffprobe=False) -> float: if result.get('HasLyrics'): score += 10 - runtime_ticks = result.get('RunTimeTicks', 0) - score += runtime_ticks / 1e6 + #runtime_ticks = result.get('RunTimeTicks', 0) + #score += runtime_ticks / 1e6 if use_ffprobe: path = result.get('Path') @@ -619,7 +680,7 @@ class TaskManager: raise ValueError(f"Task {task_name} is not defined.") task = globals()[task_name].delay(*args, **kwargs) self.tasks[task_name] = task.id - return task.id + return task.id,'STARTED' def get_task_status(self, task_name): if task_name not in self.tasks: diff --git a/app/version.py b/app/version.py index 9cb17e7..4dea151 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1 @@ -__version__ = "0.1.8" +__version__ = "v0.1.9" diff --git a/changelogs/v0.1.9.md b/changelogs/v0.1.9.md new file mode 100644 index 0000000..0617a77 --- /dev/null +++ b/changelogs/v0.1.9.md @@ -0,0 +1,132 @@ +# Whats up in Jellyplist v0.1.9? +## ⚠️ BREAKING CHANGE: docker-compose.yml +>[!WARNING] +>In this release I´ve done some rework so now the setup is a bit easier, because you don´t have to spin up the -worker -beat container, these are now all in the default container and managed via supervisor. This means you have to update your `docker-compose.yml` when updating! + +So now your compose file should look more or less like this + +```yaml +services: + redis: + image: redis:7-alpine + container_name: redis + volumes: + - redis_data:/data + networks: + - jellyplist-network + postgres: + container_name: postgres-jellyplist + image: postgres:17.2 + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATA: /data/postgres + volumes: + - /jellyplist_pgdata/postgres:/data/postgres + networks: + - jellyplist-network + restart: unless-stopped + + jellyplist: + container_name: jellyplist + image: ${IMAGE} + depends_on: + - postgres + - redis + ports: + - "5055:5055" + networks: + - jellyplist-network + volumes: + - /jellyplist/cookies.txt:/jellyplist/cookies.txt + - /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt + - ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} + - /my/super/cool/storage/jellyplist/settings.yaml:/jellyplist/settings.yaml + env_file: + - .env + +networks: + jellyplist-network: + driver: bridge + +volumes: + postgres: + redis_data: +``` +And the `.env` File +```env +IMAGE = ghcr.io/kamilkosek/jellyplist:latest +POSTGRES_USER = jellyplist +POSTGRES_PASSWORD = jellyplist +SECRET_KEY = supersecretkey # Secret key for session management +JELLYFIN_SERVER_URL = http://:8096 # Default to local Jellyfin server +JELLYFIN_ACCESS_TOKEN = +JELLYFIN_ADMIN_USER = +JELLYFIN_ADMIN_PASSWORD = +SPOTIFY_CLIENT_ID = +SPOTIFY_CLIENT_SECRET = +JELLYPLIST_DB_HOST = postgres-jellyplist +JELLYPLIST_DB_USER = jellyplist +JELLYPLIST_DB_PASSWORD = jellyplist +LOG_LEVEL = INFO +LIDARR_API_KEY = +LIDARR_URL = http://:8686 +LIDARR_MONITOR_ARTISTS = false +SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt' +MUSIC_STORAGE_BASE_PATH = '/storage/media/music' + +``` +### 🆕 Log Viewer +Under the `Admin` Page there is now a tab called `Logs` from where you can view the current logs, change the log-level on demand and copy a prepared markdown snippet ready to be pasted into a GitHub issue. + +### 🆕 New env var´s, a bit more control over spotDL +#### `SPOTDL_PROXY` +Set a Proxy for spotDL. See [https://spotdl.readthedocs.io/en/latest/usage/#command-line-options](https://spotdl.readthedocs.io/en/latest/usage/#command-line-options) +#### `SPOTDL_OUTPUT_FORMAT` +Set the output folder and file name format for downloaded tracks via spotDL. Not all variables, which are supported by spotDL are supported by Jellyplist. +- `{title}` +- `{artist}` +- `{artists}` +- `{album}` + +This way you will have a bit more controler over how the files are stored. +The complete output path is joined from `MUSIC_STORAGE_BASE_PATH` and `SPOTDL_OUTPUT_FORMAT` + +_*Example:*_ + +`MUSIC_STORAGE_BASE_PATH = /storage/media/music` + +and + +`SPOTDL_OUTPUT_FORMAT = /{artist}/{album}/{title}` + +The Track is _All I Want for Christmas Is You by Mariah Carey_ this will result in the following folder structure: + +`/storage/media/music/Mariah Carey/Merry Christmas/All I Want for Christmas Is You.mp3` + +### 🆕 Admin Users can now add Playlists to multiple Users +Sometimes I want to add a playlist to several users at once, because it´s either a _generic_ one or because my wife doesn´t want to bother with the technical stuff 😬 +So now, when logged in as an admin user, when adding a playlist you can select users from your Jellyfin server which will also receive it. +Under `Admin` you can also select users which will be preselected by default. These will be stored in the file `settings.yaml`. +You can or should map this file to a file outside the container, so it will persist accross image updates (see compose sample above) + +### 🆕 New `env` var `QUALITY_SCORE_THRESHOLD` +Get a better control over the `update_jellyfin_id_for_downloaded_tracks()` behaviour. +Until now this tasks performed a __full update__ every 24h: This means, every track from every playlist was searched through the Jellyfin API with the hope of finding the same track but with a better quality. While this is ok and works fine for small libraries, this tasks eats a lot of power on large libraries and also takes time. +So there is now the new `env` variable `QUALITY_SCORE_THRESHOLD` (default: `1000.0`). When a track was once found with a quality score above 1000.0, Jellyplist wont try to perform another `quality update` anymore on this track. +In order to be able to classify it a little better, here are a few common quality scores: +- spotDL downloaded track without yt-music premium: `< 300` +- spotDL downloaded track **with** yt-music premium: `< 450` +- flac `> 1000` + +>[!TIP] +>Want to know what quality score (and many other details) a track has ? Just double-click the table row in the playlist details view to get all the info´s! + +### Other changes, improvements and fixes +- Fix for #38 and #22 , where the manual task starting was missing a return value +- Fixed an issue where the content-type of a playlist cover image, would cause the Jellyfin API Client to fail. Thanks @artyorsh +- Fixed missing lock keys to task manager and task status rendering +- Pinned postgres version to 17.2 +- Enhanced error logging in tasks +- several fixes and improvements for the Jellyfin API Client + diff --git a/config.py b/config.py index b996c99..8dc1b97 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,6 @@ import os import sys - class Config: LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() SECRET_KEY = os.getenv('SECRET_KEY') @@ -33,6 +32,9 @@ class Config: LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true' MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH') CHECK_FOR_UPDATES = os.getenv('CHECK_FOR_UPDATES','true').lower() == 'true' + SPOTDL_PROXY = os.getenv('SPOTDL_PROXY',None) + SPOTDL_OUTPUT_FORMAT = os.getenv('SPOTDL_OUTPUT_FORMAT','__jellyplist/{artist}-{title}.mp3') + QUALITY_SCORE_THRESHOLD = float(os.getenv('QUALITY_SCORE_THRESHOLD',1000.0)) # SpotDL specific configuration SPOTDL_CONFIG = { 'cookie_file': '/jellyplist/cookies.txt', @@ -42,7 +44,8 @@ class Config: } if os.getenv('MUSIC_STORAGE_BASE_PATH'): - output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,'__jellyplist/{track-id}') + output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,SPOTDL_OUTPUT_FORMAT) + SPOTDL_CONFIG.update({'output': output_path}) @classmethod diff --git a/jellyfin/client.py b/jellyfin/client.py index 2eb84d7..0b6749b 100644 --- a/jellyfin/client.py +++ b/jellyfin/client.py @@ -2,6 +2,7 @@ import os import re import subprocess import tempfile +from typing import Optional import numpy as np import requests import base64 @@ -350,6 +351,34 @@ class JellyfinClient: # Raise an exception if the request failed raise Exception(f"Failed to remove user from playlist: {response.content}") + def remove_user_from_playlist2(self, session_token: str, playlist_id: str, user_id: str, admin_user_id : str): + #TODO: This is a workaround for the issue where the above method does not work + metadata = self.get_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id) + # Construct the API URL + url = f'{self.base_url}/Playlists/{playlist_id}' + users_data = [] + current_users = self.get_playlist_users(session_token=session_token, playlist_id= playlist_id) + for cu in current_users: + # This way we remove the user + if cu['UserId'] != user_id: + 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 = self.timeout) + + # Check for success + if response.status_code == 204: + self.update_playlist_metadata(session_token= session_token, user_id= admin_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 set_playlist_cover_image(self, session_token: str, playlist_id: str, provider_image_url: str): """ @@ -367,8 +396,8 @@ class JellyfinClient: 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']: + content_type = response.headers.get('Content-Type').lower() + if content_type not in ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream']: raise Exception(f"Unsupported image format: {content_type}") # Todo: if content_type == 'application/octet-stream': @@ -454,6 +483,18 @@ class JellyfinClient: return response.json() + def get_users(self, session_token: str, user_id: Optional[str] = None): + url = f'{self.base_url}/Users' + if user_id: + url = f'{url}/{user_id}' + + response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout) + + if response.status_code != 200: + raise Exception(f"Failed to fetch users: {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. @@ -622,4 +663,4 @@ class JellyfinClient: similarity = (1 - best_score) * 100 # Convert to percentage - return similarity, best_offset \ No newline at end of file + return similarity, best_offset diff --git a/migrations/versions/2777a1885a6b_add_quality_score_to_track.py b/migrations/versions/2777a1885a6b_add_quality_score_to_track.py new file mode 100644 index 0000000..2aa6654 --- /dev/null +++ b/migrations/versions/2777a1885a6b_add_quality_score_to_track.py @@ -0,0 +1,32 @@ +"""Add quality score to Track + +Revision ID: 2777a1885a6b +Revises: 46a65ecc9904 +Create Date: 2024-12-11 20:02:00.303765 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2777a1885a6b' +down_revision = '46a65ecc9904' +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('quality_score', sa.Float(), 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('quality_score') + + # ### end Alembic commands ### diff --git a/migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py b/migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py new file mode 100644 index 0000000..e5c080c --- /dev/null +++ b/migrations/versions/46a65ecc9904_change_track_name_lenght_maximum_to_200.py @@ -0,0 +1,38 @@ +"""Change track name lenght maximum to 200 + +Revision ID: 46a65ecc9904 +Revises: d13088ebddc5 +Create Date: 2024-12-11 19:35:47.617811 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '46a65ecc9904' +down_revision = 'd13088ebddc5' +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('name', + existing_type=sa.VARCHAR(length=150), + type_=sa.String(length=200), + existing_nullable=False) + + # ### 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('name', + existing_type=sa.String(length=200), + type_=sa.VARCHAR(length=150), + existing_nullable=False) + + # ### end Alembic commands ### diff --git a/readme.md b/readme.md index 3ba8886..7422ab3 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,7 @@ The easiest way to start is by using docker and compose. 3. Get your cookie-file from open.spotify.com , this works the same way as in step 2. 4. Prepare a `.env` File ``` +IMAGE = ghcr.io/kamilkosek/jellyplist:latest POSTGRES_USER = jellyplist POSTGRES_PASSWORD = jellyplist SECRET_KEY = Keykeykesykykesky # Secret key for session management @@ -40,6 +41,8 @@ JELLYPLIST_DB_PASSWORD = jellyplist MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where your music library is located. Must be the same value as your music library in jellyfin ### Optional: +# SPOTDL_PROXY = http://proxy:8080 +# SPOTDL_OUTPUT_FORMAT = "/{artist}/{artists} - {title}" # Supported variables: {title}, {artist},{artists}, {album}, Will be joined with to get a complete path # SEARCH_JELLYFIN_BEFORE_DOWNLOAD = false # defaults to true, before attempting to do a download with spotDL , the song will be searched first in the local library @@ -73,13 +76,13 @@ services: - jellyplist-network postgres: container_name: postgres-jellyplist - image: postgres + image: postgres:17.2 environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: /data/postgres volumes: - - postgres:/data/postgres + - /jellyplist_pgdata/postgres:/data/postgres ports: - "5432:5432" networks: @@ -88,7 +91,7 @@ services: jellyplist: container_name: jellyplist - image: ghcr.io/kamilkosek/jellyplist:latest + image: ${IMAGE} depends_on: - postgres - redis @@ -97,51 +100,18 @@ services: networks: - jellyplist-network volumes: - # Map Your cookies.txt file to exac - - /your/local/path/cookies.txt:/jellyplist/cookies.txt # - - /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt - - ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin + - /jellyplist/cookies.txt:/jellyplist/cookies.txt + - /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt + - ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} env_file: - .env - # The jellyplist-worker is used to perform background tasks, such as downloads and playlist updates. - # It is the same container, but with a different command - jellyplist-worker: - container_name: jellyplist-worker - image: ghcr.io/kamilkosek/jellyplist:latest - command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"] - volumes: - # Map Your cookies.txt file to exac - - /your/local/path/cookies.txt:/jellyplist/cookies.txt - - /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt - - ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin - env_file: - - .env - depends_on: - - postgres - - redis - networks: - - jellyplist-network - # jellyplist-beat is used to schedule the background tasks - jellyplist-beat: - container_name: jellyplist-beat - image: ghcr.io/kamilkosek/jellyplist:latest - command: ["celery", "-A", "app.celery", "beat", "--loglevel=info"] - env_file: - - .env - depends_on: - - postgres - - redis - networks: - - jellyplist-network - networks: jellyplist-network: driver: bridge volumes: postgres: - pgadmin: redis_data: ``` 5. Start your stack with `docker compose up -d` diff --git a/requirements.txt b/requirements.txt index cb5cbbf..624a3d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ Unidecode==1.3.8 psycopg2-binary eventlet pydub -fuzzywuzzy \ No newline at end of file +fuzzywuzzy +pyyaml \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index 7425b90..bf5cf81 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -58,7 +58,7 @@ body { } @media screen and (min-width: 1600px) { - .modal-dialog { + .modal-xl { max-width: 90%; /* New width for default modal */ } diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..5bb0b37 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,35 @@ +[supervisord] +nodaemon=true + +[program:jellyplist] +command=python run.py +autostart=true +autorestart=true +stdout_events_enabled=true +stderr_events_enabled=true +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr + +[program:celery_worker] +command=celery -A app.celery worker +autostart=true +autorestart=true +stdout_events_enabled=true +stderr_events_enabled=true +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr + +[program:celery_beat] +command=celery -A app.celery beat +autostart=true +autorestart=true +stdout_events_enabled=true +stderr_events_enabled=true +stdout_logfile_maxbytes=0 +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html index 7c55275..dc7c01e 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -4,7 +4,11 @@