diff --git a/app/__init__.py b/app/__init__.py index 110a309..33a62bc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -163,4 +163,10 @@ app.logger.debug(f"Debug logging active") 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 + tasks.release_lock("download_missing_tracks_lock") + +from app import filters # Import the filters dictionary + +# Register all filters +for name, func in filters.filters.items(): + app.jinja_env.filters[name] = func \ No newline at end of file diff --git a/app/classes.py b/app/classes.py new file mode 100644 index 0000000..56a9bec --- /dev/null +++ b/app/classes.py @@ -0,0 +1,79 @@ +import subprocess +import json +from flask import current_app as app # Adjust this based on your app's structure +from typing import Optional + + +class AudioProfile: + def __init__(self, path: str, bitrate: int = 0, sample_rate: int = 0, channels: int = 0) -> None: + """ + Initialize an AudioProfile instance. + + Args: + path (str): The file path of the audio file. + bitrate (int): The audio bitrate in kbps. Default is 0. + sample_rate (int): The sample rate in Hz. Default is 0. + channels (int): The number of audio channels. Default is 0. + """ + self.path: str = path + self.bitrate: int = bitrate # in kbps + self.sample_rate: int = sample_rate # in Hz + self.channels: int = channels + + @staticmethod + def analyze_audio_quality_with_ffprobe(filepath: str) -> Optional['AudioProfile']: + """ + Static method to analyze audio quality using ffprobe and return an AudioProfile instance. + + Args: + filepath (str): Path to the audio file to analyze. + + Returns: + Optional[AudioProfile]: An instance of AudioProfile if analysis is successful, None otherwise. + """ + try: + # ffprobe command to extract bitrate, sample rate, and channel count + cmd = [ + 'ffprobe', '-v', 'error', '-select_streams', 'a:0', + '-show_entries', 'stream=bit_rate,sample_rate,channels', + '-show_format', + '-of', 'json', filepath + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + app.logger.error(f"ffprobe error for file {filepath}: {result.stderr}") + return None + + # Parse ffprobe output + data = json.loads(result.stdout) + stream = data.get('streams', [{}])[0] + bitrate: int = int(stream.get('bit_rate', 0)) // 1000 # Convert to kbps + if bitrate == 0: # Fallback if no bit_rate in stream + bitrate = int(data.get('format').get('bit_rate', 0)) // 1000 + sample_rate: int = int(stream.get('sample_rate', 0)) # Hz + channels: int = int(stream.get('channels', 0)) + + # Create an AudioProfile instance + return AudioProfile(filepath, bitrate, sample_rate, channels) + except Exception as e: + app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}") + return None + + def compute_quality_score(self) -> int: + """ + Compute a quality score based on bitrate, sample rate, and channels. + + Returns: + int: The computed quality score. + """ + return self.bitrate + (self.sample_rate // 1000) + (self.channels * 10) + + def __repr__(self) -> str: + """ + Representation of the AudioProfile instance. + + Returns: + str: A string representation of the AudioProfile instance. + """ + return (f"AudioProfile(path='{self.path}', bitrate={self.bitrate} kbps, " + f"sample_rate={self.sample_rate} Hz, channels={self.channels})") diff --git a/app/filters.py b/app/filters.py new file mode 100644 index 0000000..1bc4ec7 --- /dev/null +++ b/app/filters.py @@ -0,0 +1,48 @@ +import os +import re +from markupsafe import Markup + +from app.classes import AudioProfile + +filters = {} + +def template_filter(name): + """Decorator to register a Jinja2 filter.""" + def decorator(func): + filters[name] = func + return func + return decorator + +@template_filter('highlight') +def highlight_search(text, search_query): + if not search_query: + return text + search_query_escaped = re.escape(search_query) + highlighted_text = re.sub( + f"({search_query_escaped})", + r'\1', + text, + flags=re.IGNORECASE + ) + return Markup(highlighted_text) + + +@template_filter('audioprofile') +def audioprofile(text: str, path: str) -> Markup: + if not path or not os.path.exists(path): + return Markup() # Return the original text if the file does not exist + + # Create the AudioProfile instance using the static method + audio_profile = AudioProfile.analyze_audio_quality_with_ffprobe(path) + if not audio_profile: + return Markup(f"ERROR") + + # Create a nicely formatted HTML representation + audio_profile_html = ( + f"Bitrate: {audio_profile.bitrate} kbps
" + f"Sample Rate: {audio_profile.sample_rate} Hz
" + f"Channels: {audio_profile.channels}
" + f"Quality Score: {audio_profile.compute_quality_score()}" + f"" + ) + return Markup(audio_profile_html) diff --git a/app/jellyfin_routes.py b/app/jellyfin_routes.py index 1f99f39..90978d0 100644 --- a/app/jellyfin_routes.py +++ b/app/jellyfin_routes.py @@ -208,5 +208,5 @@ def search_jellyfin(): 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 render_template('partials/_jf_search_results.html', results=results,spotify_id= spotify_id,search_query = search_query) return jsonify({'error': 'No search query provided'}), 400 \ No newline at end of file diff --git a/config.py b/config.py index fc0436d..3fc6dae 100644 --- a/config.py +++ b/config.py @@ -17,6 +17,7 @@ class Config: JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD') START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"false").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = os.getenv('REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK',"false").lower() == 'true' + DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true' CACHE_TYPE = 'redis' CACHE_REDIS_PORT = 6379 CACHE_REDIS_HOST = 'redis' diff --git a/static/css/styles.css b/static/css/styles.css index 2ef654b..faedd5a 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -52,4 +52,9 @@ body { } .logo img{ width: 100%; +} +@media screen and (min-width: 1600px) { + .modal-dialog { + max-width: 90%; /* New width for default modal */ + } } \ No newline at end of file diff --git a/templates/partials/_jf_search_results.html b/templates/partials/_jf_search_results.html index c50346f..b517eef 100644 --- a/templates/partials/_jf_search_results.html +++ b/templates/partials/_jf_search_results.html @@ -7,6 +7,10 @@ Name Artist(s) Path + Container + {% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %} + + {% endif %} @@ -14,17 +18,21 @@ {% for track in results %} - {{ track.Name }} + {{ track.Name | highlight(search_query) }} {{ ', '.join(track.Artists) }} {{ track.Path}} + {{ track.Container }} + {% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %} + {{track.Path | audioprofile(track.Path) }} + {% endif %} - diff --git a/templates/partials/_track_table.html b/templates/partials/_track_table.html index eec4663..972e2b4 100644 --- a/templates/partials/_track_table.html +++ b/templates/partials/_track_table.html @@ -71,7 +71,7 @@