diff --git a/app/__init__.py b/app/__init__.py index 841606d..12e751e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -161,10 +161,11 @@ celery.set_default() app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started') app.logger.debug(f"Debug logging active") -from app import routes -app.register_blueprint(routes.pl_bp) -from app import jellyfin_routes, tasks +from app.routes import pl_bp, routes, jellyfin_routes +app.register_blueprint(pl_bp) + +from . import tasks if "worker" in sys.argv: tasks.release_lock("download_missing_tracks_lock") diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..055d915 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,17 @@ +from flask import Blueprint, request, g +from app import app +from app.registry.music_provider_registry import MusicProviderRegistry + +pl_bp = Blueprint('playlist', __name__) + +@pl_bp.before_request +def set_active_provider(): + """ + Middleware to select the active provider based on request parameters. + """ + app.logger.debug(f"Setting active provider: {request.args.get('provider', 'Spotify')}") + provider_id = request.args.get('provider', 'Spotify') # Default to Spotify + try: + g.music_provider = MusicProviderRegistry.get_provider(provider_id) + except ValueError as e: + return {"error": str(e)}, 400 \ No newline at end of file diff --git a/app/routes/jellyfin_routes.py b/app/routes/jellyfin_routes.py new file mode 100644 index 0000000..a781440 --- /dev/null +++ b/app/routes/jellyfin_routes.py @@ -0,0 +1,202 @@ +from collections import defaultdict +import time +from flask import Blueprint, Flask, jsonify, render_template, request, redirect, url_for, session, flash +from sqlalchemy import insert +from app import app, db, jellyfin, functions, device_id,sp +from app.models import JellyfinUser, Playlist,Track, playlist_tracks +from spotipy.exceptions import SpotifyException + + +from app.registry.music_provider_registry import MusicProviderRegistry +from jellyfin.objects import PlaylistMetadata +from app.routes import pl_bp + +@app.route('/jellyfin_playlists') +@functions.jellyfin_login_required +def jellyfin_playlists(): + playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie()) + playlists_by_provider = defaultdict(list) + provider_playlists_data = {} + + for pl in playlists: + from_db : Playlist | None = Playlist.query.filter_by(jellyfin_id=pl['Id']).first() + if from_db and from_db.provider_playlist_id: + pl_id = from_db.provider_playlist_id + playlists_by_provider[from_db.provider_id].append(from_db) + + # 3. Fetch all Data from the provider using the get_playlist() method + for provider_id, playlists in playlists_by_provider.items(): + try: + provider_client = MusicProviderRegistry.get_provider(provider_id) + except ValueError: + flash(f"Provider {provider_id} not found.", "error") + continue + + combined_playlists = [] + for pl in playlists: + 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: + combined_playlists.append(combined_data) + + provider_playlists_data[provider_id] = combined_playlists + + # 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider + return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data,title="Jellyfin Playlists" , subtitle="Playlists you have added to Jellyfin") + +@pl_bp.route('/addplaylist', methods=['POST']) +@functions.jellyfin_login_required +def add_playlist(): + playlist_id = request.form.get('item_id') + playlist_name = request.form.get('item_name') + # also get the provider id from the query params + provider_id = request.args.get('provider') + if not playlist_id: + flash('No playlist ID provided') + return '' + # if no provider_id is provided, then show an error and return an empty string + if not provider_id: + flash('No provider ID provided') + return '' + try: + # get the playlist from the correct provider + provider_client = MusicProviderRegistry.get_provider(provider_id) + playlist_data = provider_client.get_playlist(playlist_id) + # Check if playlist already exists in the database, using the provider_id and the provider_playlist_id + playlist = Playlist.query.filter_by(provider_playlist_id=playlist_id, provider_id=provider_id).first() + # Add new playlist in the database if it doesn't exist + # create the playlist via api key, with the first admin as 'owner' + if not playlist: + fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data.name,[],functions._get_admin_id())['Id'] + playlist = Playlist(name=playlist_data.name, provider_playlist_id=playlist_id,provider_uri=playlist_data.uri,track_count = len(playlist_data.tracks), tracks_available=0, jellyfin_id = fromJellyfin, provider_id=provider_id) + 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 : JellyfinUser = functions._get_logged_in_user() + playlist.tracks_available = 0 + + for idx, track_data in enumerate(playlist_data.tracks): + + track = Track.query.filter_by(provider_track_id=track_data.track.id, provider_id=provider_id).first() + + if not track: + # Add new track if it doesn't exist + track = Track(name=track_data.track.name, provider_track_id=track_data.track.id, provider_uri=track_data.track.uri, downloaded=False,provider_id = provider_id) + 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() + + functions.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)) + return '' + + +@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.provider_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)}') + + +@app.route('/wipe_playlist/', methods=['DELETE']) +@functions.jellyfin_admin_required +def wipe_playlist(playlist_id): + playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first() + name = "" + id = "" + jf_id = "" + try: + jellyfin.remove_item(session_token=functions._get_api_token(), playlist_id=playlist_id) + except Exception as e: + flash(f"Jellyfin API Error: {str(e)}") + if playlist: + # Delete the playlist + name = playlist.name + id = playlist.provider_playlist_id + jf_id = playlist.jellyfin_id + db.session.delete(playlist) + db.session.commit() + flash('Playlist Deleted', category='info') + item = { + "name" : name, + "id" : id, + "can_add":True, + "can_remove":False, + "jellyfin_id" : jf_id + } + return render_template('partials/_add_remove_button.html',item= item) + +@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') + provider_track_id = request.args.get('provider_track_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,provider_track_id= provider_track_id,search_query = search_query) + return jsonify({'error': 'No search query provided'}), 400 \ No newline at end of file diff --git a/app/routes/routes.py b/app/routes/routes.py new file mode 100644 index 0000000..0ee8e3b --- /dev/null +++ b/app/routes/routes.py @@ -0,0 +1,412 @@ +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, sp, jellyfin, celery, jellyfin_admin_token, jellyfin_admin_id,device_id, cache, read_dev_build_file, tasks +from app.classes import AudioProfile, CombinedPlaylistData +from app.models import JellyfinUser,Playlist,Track +from celery.result import AsyncResult +from typing import List + +from app.providers import base +from app.providers.base import MusicProviderClient +from app.providers.spotify import SpotifyClient +from app.registry.music_provider_registry import MusicProviderRegistry +from ..version import __version__ +from spotipy.exceptions import SpotifyException +from collections import defaultdict +from app.routes import pl_bp + + +@app.context_processor +def add_context(): + unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all()) + version = f"v{__version__}{read_dev_build_file()}" + return dict(unlinked_track_count = unlinked_track_count, version = version, config = app.config , registered_providers = MusicProviderRegistry.list_providers()) + + +# this feels wrong +skip_endpoints = ['task_status'] +@app.after_request +def render_messages(response: Response) -> Response: + if request.headers.get("HX-Request"): + if request.endpoint not in skip_endpoints: + 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,lock_keys = functions.LOCK_KEYS) + +@app.route('/admin') +@app.route('/admin/link_issues') +@functions.jellyfin_admin_required +def link_issues(): + # add the ability to pass a query parameter to dislplay even undownloaded tracks + list_undownloaded = request.args.get('list_undownloaded') + if list_undownloaded: + unlinked_tracks = Track.query.filter_by(jellyfin_id=None).all() + else: + unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all() + tracks = [] + for ult in unlinked_tracks: + provider_track = functions.get_cached_provider_track(ult.provider_track_id, ult.provider_id) + duration_ms = provider_track.duration_ms + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + tracks.append({ + 'title': provider_track.name, + 'artist': ', '.join([artist.name for artist in provider_track.artists]), + 'url': provider_track.external_urls, + 'duration': f'{minutes}:{seconds:02d}', + 'preview_url': '', + 'downloaded': ult.downloaded, + 'filesystem_path': ult.filesystem_path, + 'jellyfin_id': ult.jellyfin_id, + 'provider_track_id': provider_track.id, + 'duration_ms': duration_ms, + 'download_status' : ult.download_status, + 'provider_id' : ult.provider_id + }) + + 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('/') + 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('/playlist/open',methods=['GET']) +@functions.jellyfin_login_required +def openPlaylist(): + playlist = request.args.get('playlist') + error = None + errdata= None + if playlist: + for provider_id in MusicProviderRegistry.list_providers(): + 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) + + combined_data = functions.prepPlaylistData(provider_playlist) + if combined_data: + # If the playlist is found, redirect to the playlist view, but also include the provider ID in the URL + return redirect(url_for('playlist.get_playlist_tracks', playlist_id=extracted_playlist_id, provider=provider_id)) + except Exception as e: + error = f"Error fetching playlist from {provider_id}: {str(e)}" + errdata = e + + return render_template('index.html',error_message = error, error_data = errdata) + +@pl_bp.route('/browse') +@functions.jellyfin_login_required +def browse(): + provider: MusicProviderClient = g.music_provider + + browse_data = provider.browse() + return render_template('browse.html', browse_data=browse_data,provider_id=provider._identifier) + +@pl_bp.route('/browse/page/') +@functions.jellyfin_login_required +def browse_page(page_id): + provider: MusicProviderClient = g.music_provider + combined_playlist_data : List[CombinedPlaylistData] = [] + + data = provider.browse_page(page_id) + for item in data: + cpd = functions.prepPlaylistData(item) + if cpd: + combined_playlist_data.append(cpd) + return render_template('browse_page.html', data=combined_playlist_data,provider_id=provider._identifier) + +@pl_bp.route('/playlists/monitored') +@functions.jellyfin_login_required +def monitored_playlists(): + + # 1. Get all Playlists from the Database. + all_playlists = Playlist.query.all() + + # 2. Group them by provider + playlists_by_provider = defaultdict(list) + for playlist in all_playlists: + playlists_by_provider[playlist.provider_id].append(playlist) + + provider_playlists_data = {} + # 3. Fetch all Data from the provider using the get_playlist() method + for provider_id, playlists in playlists_by_provider.items(): + try: + provider_client = MusicProviderRegistry.get_provider(provider_id) + except ValueError: + flash(f"Provider {provider_id} not found.", "error") + continue + + combined_playlists = [] + for pl in playlists: + 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: + combined_playlists.append(combined_data) + + provider_playlists_data[provider_id] = combined_playlists + + # 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider + return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data, title="Monitored Playlists", subtitle="Playlists which are already monitored by Jellyplist and are available immediately") + +@app.route('/search') +@functions.jellyfin_login_required +def searchResults(): + query = request.args.get('query') + context = {} + if query: + #iterate through every registered music provider and perform the search with it. + # Group the results by provider and display them using monitorerd_playlists.html + search_results = defaultdict(list) + for provider_id in MusicProviderRegistry.list_providers(): + try: + provider_client = MusicProviderRegistry.get_provider(provider_id) + results = provider_client.search_playlist(query) + for result in results: + search_results[provider_id].append(result) + except Exception as e: + flash(f"Error fetching search results from {provider_id}: {str(e)}", "error") + # the grouped search results, must be prepared using the prepPlaylistData function + for provider_id, playlists in search_results.items(): + combined_playlists = [] + for pl in playlists: + combined_data = functions.prepPlaylistData(pl) + if combined_data: + combined_playlists.append(combined_data) + search_results[provider_id] = combined_playlists + + context['provider_playlists_data'] = search_results + context['title'] = 'Search Results' + context['subtitle'] = 'Search results from all providers' + return render_template('monitored_playlists.html', **context) + +@pl_bp.route('/track_details/') +@functions.jellyfin_login_required +def track_details(track_id): + provider_id = request.args.get('provider') + if not provider_id: + return jsonify({'error': 'Provider not specified'}), 400 + + track = Track.query.filter_by(provider_track_id=track_id, provider_id=provider_id).first() + if not track: + return jsonify({'error': 'Track not found'}), 404 + + provider_track = functions.get_cached_provider_track(track.provider_track_id, track.provider_id) + # query also this track using the jellyfin id directly from jellyfin + if track.jellyfin_id: + jellyfin_track = jellyfin.get_item(session_token=functions._get_api_token(), item_id=track.jellyfin_id) + if jellyfin_track: + jellyfin_filesystem_path = jellyfin_track['Path'] + duration_ms = provider_track.duration_ms + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + + track_details = { + 'title': provider_track.name, + 'artist': ', '.join([artist.name for artist in provider_track.artists]), + 'url': provider_track.external_urls, + 'duration': f'{minutes}:{seconds:02d}', + 'downloaded': track.downloaded, + 'filesystem_path': track.filesystem_path, + 'jellyfin_id': track.jellyfin_id, + 'provider_track_id': provider_track.id, + 'provider_track_url': provider_track.external_urls[0].url if provider_track.external_urls else None, + 'duration_ms': duration_ms, + 'download_status': track.download_status, + 'provider_id': track.provider_id, + 'jellyfin_filesystem_path': jellyfin_filesystem_path if track.jellyfin_id else None, + } + + return render_template('partials/track_details.html', track=track_details) + +@pl_bp.route('/playlist/view/') +@functions.jellyfin_login_required +def get_playlist_tracks(playlist_id): + provider: MusicProviderClient = g.music_provider + playlist: base.Playlist = provider.get_playlist(playlist_id) + tracks = functions.get_tracks_for_playlist(playlist.tracks, provider_id=provider._identifier) + total_duration_ms = sum([track.duration_ms for track in tracks]) + + # Convert the total duration to a readable format + hours, remainder = divmod(total_duration_ms // 1000, 3600) + minutes, seconds = divmod(remainder, 60) + + # Format the duration + 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(tracks), + provider_id = provider._identifier, + item=functions.prepPlaylistData(playlist), + + ) + +@app.route('/associate_track', methods=['POST']) +@functions.jellyfin_login_required +def associate_track(): + jellyfin_id = request.form.get('jellyfin_id') + provider_track_id = request.form.get('provider_track_id') + + if not jellyfin_id or not provider_track_id: + flash('Missing Jellyfin or Spotify ID') + + # Retrieve the track by Spotify ID + track = Track.query.filter_by(provider_track_id=provider_track_id).first() + + if not track: + flash('Track not found') + return '' + + # Associate the Jellyfin ID with the track + track.jellyfin_id = jellyfin_id + track.downloaded = True + + + 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("/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) + flash(f'Lock {key_name} released', category='success') + return '' + + +@pl_bp.route('/test') +def test(): + #return '' + app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)") + downloaded_tracks : List[Track] = Track.query.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 ") + processed_tracks = 0 + + for track in downloaded_tracks: + try: + best_match = tasks.find_best_match_from_jellyfin(track) + if best_match: + track.downloaded = True + if track.jellyfin_id != best_match['Id']: + track.jellyfin_id = best_match['Id'] + app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})") + if track.filesystem_path != best_match['Path']: + track.filesystem_path = best_match['Path'] + app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})") + + + + db.session.commit() + else: + app.logger.warning(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=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}