diff --git a/README.md b/README.md index 8feb894..65fe049 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ python3 main.py ## Todo: -- [ ] Delete songs from library (del key || right-click delete) - [x] Right-click menu +- [x] Editable lyrics textbox +- [ ] Delete songs from library (del key || right-click delete) - [ ] .wav, .ogg, .flac convertor -- [ ] Editable lyrics textbox +- [ ] Alternatives to Gstreamer? diff --git a/components/MusicTable.py b/components/MusicTable.py index 3a43d02..cc5d121 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -16,16 +16,14 @@ from PyQt5.QtWidgets import ( QMessageBox, QAbstractItemView, ) -from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, QTimer +from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSignal, QTimer from components.LyricsWindow import LyricsWindow from utils import add_files_to_library from utils import update_song_in_library from utils import get_id3_tags from utils import get_album_art from utils import set_id3_tag -from utils import delete_and_create_library_database from subprocess import Popen -from sqlite3 import OperationalError import logging import configparser import os @@ -35,12 +33,13 @@ import shutil class MusicTable(QTableView): playPauseSignal = pyqtSignal() enterKey = pyqtSignal() + deleteKey = pyqtSignal() - def __init__(self, parent=None): + def __init__(self: QTableView, parent=None): # QTableView.__init__(self, parent) super().__init__(parent) - self.model = QStandardItemModel(self) # Necessary for actions related to cell values + self.model = QStandardItemModel(self) self.setModel(self.model) # Same as above self.config = configparser.ConfigParser() self.config.read("config.ini") @@ -56,9 +55,9 @@ class MusicTable(QTableView): ] # id3 names of headers self.id3_headers = [ - "title", - "artist", - "album", + "TIT2", + "TPE1", + "TALB", "content_type", None, None, @@ -75,6 +74,7 @@ class MusicTable(QTableView): # doubleClicked is a built in event for QTableView - we listen for this event and run set_current_song_filepath self.doubleClicked.connect(self.set_current_song_filepath) self.enterKey.connect(self.set_current_song_filepath) + self.deleteKey.connect(self.delete_songs) self.fetch_library() self.setup_keyboard_shortcuts() self.model.dataChanged.connect(self.on_cell_data_changed) # editing cells @@ -104,7 +104,7 @@ class MusicTable(QTableView): reply = QMessageBox.question( self, "Confirmation", - "Are you sure you want to delete these songs?", + "Remove these songs from the library? (Files stay on your computer)", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) @@ -139,11 +139,11 @@ class MusicTable(QTableView): if selected_song_filepath is None: return current_song = self.get_selected_song_metadata() - print(f"MusicTable.py | show_lyrics_menu | current song: {current_song}") + # print(f"MusicTable.py | show_lyrics_menu | current song: {current_song}") try: lyrics = current_song["USLT::XXX"].text except Exception as e: - print(f'MusicTable.py | show_lyrics_menu | could not retrieve lyrics | {e}') + print(f"MusicTable.py | show_lyrics_menu | could not retrieve lyrics | {e}") lyrics = "" lyrics_window = LyricsWindow(selected_song_filepath, lyrics) lyrics_window.exec_() @@ -180,6 +180,33 @@ class MusicTable(QTableView): else: e.ignore() + def keyPressEvent(self, event): + """Press a key. Do a thing""" + key = event.key() + if key == Qt.Key_Space: # Spacebar to play/pause + self.toggle_play_pause() + elif key == Qt.Key_Up: # Arrow key navigation + current_index = self.currentIndex() + new_index = self.model.index( + current_index.row() - 1, current_index.column() + ) + if new_index.isValid(): + self.setCurrentIndex(new_index) + elif key == Qt.Key_Down: # Arrow key navigation + current_index = self.currentIndex() + new_index = self.model.index( + current_index.row() + 1, current_index.column() + ) + if new_index.isValid(): + self.setCurrentIndex(new_index) + elif key in (Qt.Key_Return, Qt.Key_Enter): + if self.state() != QAbstractItemView.EditingState: + self.enterKey.emit() # Enter key detected + else: + super().keyPressEvent(event) + else: # Default behavior + super().keyPressEvent(event) + def setup_keyboard_shortcuts(self): """Setup shortcuts here""" shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self) @@ -222,8 +249,10 @@ class MusicTable(QTableView): try: # Read file metadata audio = ID3(filepath) - artist = audio["TIT2"].text[0] if not '' or None else 'Unknown Artist' - album = audio["TALB"].text[0] if not '' or None else 'Unknown Album' + artist = ( + audio["TIT2"].text[0] if not "" or None else "Unknown Artist" + ) + album = audio["TALB"].text[0] if not "" or None else "Unknown Album" # Determine the new path that needs to be made new_path = os.path.join( target_dir, artist, album, os.path.basename(filepath) @@ -249,33 +278,6 @@ class MusicTable(QTableView): self, "Reorganization complete", "Files successfully reorganized" ) - def keyPressEvent(self, event): - """Press a key. Do a thing""" - key = event.key() - if key == Qt.Key_Space: # Spacebar to play/pause - self.toggle_play_pause() - elif key == Qt.Key_Up: # Arrow key navigation - current_index = self.currentIndex() - new_index = self.model.index( - current_index.row() - 1, current_index.column() - ) - if new_index.isValid(): - self.setCurrentIndex(new_index) - elif key == Qt.Key_Down: # Arrow key navigation - current_index = self.currentIndex() - new_index = self.model.index( - current_index.row() + 1, current_index.column() - ) - if new_index.isValid(): - self.setCurrentIndex(new_index) - elif key in (Qt.Key_Return, Qt.Key_Enter): - if self.state() != QAbstractItemView.EditingState: - self.enterKey.emit() # Enter key detected - else: - super().keyPressEvent(event) - else: # Default behavior - super().keyPressEvent(event) - def toggle_play_pause(self): """Toggles the currently playing song by emitting a signal""" if not self.current_song_filepath: diff --git a/components/PreferencesWindow.py b/components/PreferencesWindow.py index 9b7f3de..578005b 100644 --- a/components/PreferencesWindow.py +++ b/components/PreferencesWindow.py @@ -1,4 +1,4 @@ -from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton +from PyQt5.QtWidgets import QDialog, QFrame, QVBoxLayout, QLabel, QLineEdit, QPushButton from PyQt5.QtGui import QFont @@ -6,15 +6,20 @@ class PreferencesWindow(QDialog): def __init__(self, config): super(PreferencesWindow, self).__init__() self.setWindowTitle("Preferences") + self.setMinimumSize(400, 400) self.config = config layout = QVBoxLayout() - label = QLabel("Preferences Window") + label = QLabel("Preferences") + label.setFont(QFont("Sans", weight=QFont.Bold)) layout.addWidget(label) # Labels & input fields self.input_fields = {} for category in self.config.sections(): + separator = QFrame() + separator.setFrameShape(QFrame.HLine) + layout.addWidget(separator) category_label = QLabel(f"{category}") category_label.setFont(QFont("Sans", weight=QFont.Bold)) # bold category category_label.setStyleSheet( diff --git a/utils/add_files_to_library.py b/utils/add_files_to_library.py index 5da38d9..79421fc 100644 --- a/utils/add_files_to_library.py +++ b/utils/add_files_to_library.py @@ -14,7 +14,6 @@ def add_files_to_library(files): """ if not files: return [] - # print(f"utils/add_files_to_library: {files}") extensions = config.get("settings", "extensions").split(",") insert_data = [] # To store data for batch insert for filepath in files: @@ -59,7 +58,7 @@ def add_files_to_library(files): genre, filename.split(".")[-1], date, - bitrate + bitrate, ) ) # Check if batch size is reached diff --git a/utils/create_waveform_from_file.py b/utils/create_waveform_from_file.py index b3065e6..8b89ce1 100644 --- a/utils/create_waveform_from_file.py +++ b/utils/create_waveform_from_file.py @@ -9,7 +9,7 @@ def create_waveform_from_file(file): Args: file (_type_): _description_ - """ + """ # Read the MP3 file audio = AudioSegment.from_file(file) # Convert to mono and get frame rate and number of channels @@ -26,30 +26,42 @@ def create_waveform_from_file(file): start_index = 0 while start_index < len(audio_data): end_index = start_index + process_chunk_size - signal = audio_data[start_index:end_index].astype(float) # Get chunk and convert to float - + signal = audio_data[start_index:end_index].astype( + float + ) # Get chunk and convert to float + # Take mean of absolute values per 0.5 seconds sub_waveform = np.nanmean( - np.pad(np.absolute(signal), (0, ((downsample - (signal.size % downsample)) % downsample)), mode='constant', constant_values=np.NaN).reshape(-1, downsample), - axis=1 + np.pad( + np.absolute(signal), + (0, ((downsample - (signal.size % downsample)) % downsample)), + mode="constant", + constant_values=np.NaN, + ).reshape(-1, downsample), + axis=1, ) waveform = np.concatenate((waveform, sub_waveform)) start_index = end_index - + # Plot waveforms plt.figure(1) - plt.plot(waveform, color='blue') - plt.plot(-waveform, color='blue') # Mirrored waveform + plt.plot(waveform, color="blue") + plt.plot(-waveform, color="blue") # Mirrored waveform # Fill in area - plt.fill_between(np.arange(len(waveform)), waveform, -waveform, color='blue', alpha=0.5) + plt.fill_between( + np.arange(len(waveform)), waveform, -waveform, color="blue", alpha=0.5 + ) # Remove decorations, labels, axes, etc - plt.axis('off') + plt.axis("off") # Graph goes to ends of pic plt.xlim(0, len(waveform)) # Save pic - plt.savefig('assets/now_playing_waveform.png', dpi=64, bbox_inches='tight', pad_inches=0) + plt.savefig( + "assets/now_playing_waveform.png", dpi=64, bbox_inches="tight", pad_inches=0 + ) # Show me tho # plt.show() - -# create_waveform_from_file(sys.argv[1]) \ No newline at end of file + + +# create_waveform_from_file(sys.argv[1]) diff --git a/utils/fft_analyser.py b/utils/fft_analyser.py index 07b4700..ab024eb 100644 --- a/utils/fft_analyser.py +++ b/utils/fft_analyser.py @@ -19,7 +19,7 @@ class FFTAnalyser(QtCore.QThread): calculated_visual = QtCore.pyqtSignal(np.ndarray) - def __init__(self, player: 'MusicPlayer'): # noqa: F821 + def __init__(self, player): # noqa: F821 super().__init__() self.player = player self.reset_media() @@ -32,7 +32,7 @@ class FFTAnalyser(QtCore.QThread): def reset_media(self): """Resets the media to the currently playing song.""" audio_file = self.player.currentMedia().canonicalUrl().path() - if os.name == 'nt' and audio_file.startswith('/'): + if os.name == "nt" and audio_file.startswith("/"): audio_file = audio_file[1:] if audio_file: try: @@ -52,13 +52,15 @@ class FFTAnalyser(QtCore.QThread): """Calculates the amplitudes used for visualising the media.""" sample_count = int(self.song.frame_rate * 0.05) - start_index = int((self.player.position()/1000) * self.song.frame_rate) - v_sample = self.samples[start_index:start_index+sample_count] # samples to analyse + start_index = int((self.player.position() / 1000) * self.song.frame_rate) + v_sample = self.samples[ + start_index : start_index + sample_count + ] # samples to analyse # use FFTs to analyse frequency and amplitudes fourier = np.fft.fft(v_sample) freq = np.fft.fftfreq(fourier.size, d=0.05) - amps = 2/v_sample.size * np.abs(fourier) + amps = 2 / v_sample.size * np.abs(fourier) data = np.array([freq, amps]).T point_range = 1 / self.resolution @@ -73,17 +75,26 @@ class FFTAnalyser(QtCore.QThread): if not amps.size: point_samples.append(0) else: - point_samples.append(amps.max()*((1+self.sensitivity/10+(self.sensitivity-1)/10)**(n/50))) + point_samples.append( + amps.max() + * ( + (1 + self.sensitivity / 10 + (self.sensitivity - 1) / 10) + ** (n / 50) + ) + ) # Add the point_samples to the self.points array, the reason we have a separate # array (self.bars) is so that we can fade out the previous amplitudes from # the past for n, amp in enumerate(point_samples): - amp *= 2 - if (self.points[n] > 0 and amp < self.points[n] or - self.player.state() in (self.player.PausedState, self.player.StoppedState)): + if ( + self.points[n] > 0 + and amp < self.points[n] + or self.player.state() + in (self.player.PausedState, self.player.StoppedState) + ): self.points[n] -= self.points[n] / 10 # fade out elif abs(self.points[n] - amp) > self.visual_delta_threshold: self.points[n] = amp @@ -111,4 +122,4 @@ class FFTAnalyser(QtCore.QThread): except ValueError: self.calculated_visual.emit(np.zeros(self.resolution)) self.start_animate = False - time.sleep(0.025) \ No newline at end of file + time.sleep(0.025) diff --git a/utils/get_id3_tags.py b/utils/get_id3_tags.py index 441278b..215e476 100644 --- a/utils/get_id3_tags.py +++ b/utils/get_id3_tags.py @@ -14,7 +14,7 @@ def get_id3_tags(file): try: audio = ID3(file) - except: + except Exception: audio = {} # Check if all tags are empty @@ -24,10 +24,10 @@ def get_id3_tags(file): frame = TIT2(encoding=3, text=[title]) audio["TIT2"] = frame except Exception as e: - print(f'get_id3_tags.py | Exception: {e}') + print(f"get_id3_tags.py | Exception: {e}") pass try: audio.save() # type: ignore - except: + except Exception: pass return audio diff --git a/utils/scan_for_music.py b/utils/scan_for_music.py index b07c22b..1462095 100644 --- a/utils/scan_for_music.py +++ b/utils/scan_for_music.py @@ -1,7 +1,7 @@ import os import DBA from configparser import ConfigParser -from utils import get_id3_tags +from utils import add_files_to_library, get_id3_tags from utils import safe_get config = ConfigParser() @@ -10,57 +10,7 @@ config.read("config.ini") def scan_for_music(): root_dir = config.get("directories", "library") - extensions = config.get("settings", "extensions").split(",") - insert_data = [] # To store data for batch insert - for dirpath, dirnames, filenames in os.walk(root_dir): - for filename in filenames: - if any(filename.lower().endswith(ext) for ext in extensions): - filepath = os.path.join(dirpath, filename) - audio = get_id3_tags(filepath) - if "title" not in audio: - return - - # Append data tuple to insert_data list - insert_data.append( - ( - filepath, - safe_get(audio, "title", [])[0], - safe_get(audio, "album", [])[0] if "album" in audio else None, - safe_get(audio, "artist", [])[0] if "artist" in audio else None, - ",".join(safe_get(audio, "genre", [])) if "genre" in audio else None, - filename.split(".")[-1], - safe_get(audio, "date", [])[0] if "date" in audio else None, - safe_get(audio, "bitrate", [])[0] if "birate" in audio else None, - ) - ) - - # Check if batch size is reached - if len(insert_data) >= 1000: - with DBA.DBAccess() as db: - db.executemany( - "INSERT OR IGNORE INTO library (filepath, title, album, artist, genre, codec, album_date, bitrate) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - insert_data, - ) - insert_data = [] # Reset the insert_data list - - # Insert any remaining data - if insert_data: - with DBA.DBAccess() as db: - db.executemany( - "INSERT OR IGNORE INTO library (filepath, title, album, artist, genre, codec, album_date, bitrate) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - insert_data, - ) - - -# id int unsigned auto_increment, -# title varchar(255), -# album varchar(255), -# artist varchar(255), -# genre varchar(255), -# codec varchar(15), -# album_date date, -# bitrate int unsigned, -# date_added TIMESTAMP default CURRENT_TIMESTAMP, - -# scan_for_music(config.get('directories', 'library1')) + # for dirpath, dirnames, filenames ... + for _, _, filenames in os.walk(root_dir): + add_files_to_library(filenames) diff --git a/utils/set_id3_tag.py b/utils/set_id3_tag.py index 889a2a6..54caab7 100644 --- a/utils/set_id3_tag.py +++ b/utils/set_id3_tag.py @@ -1,11 +1,8 @@ from components.ErrorDialog import ErrorDialog -from utils.get_id3_tags import get_id3_tags from utils.handle_year_and_date_id3_tag import handle_year_and_date_id3_tag from mutagen.id3 import ID3 from mutagen.id3._util import ID3NoHeaderError -from mutagen.mp3 import MP3 -from mutagen.easyid3 import EasyID3 -from mutagen.id3 import ( +from mutagen.id3._frames import ( Frame, TIT2, TPE1,