diff --git a/components/MusicTable.py b/components/MusicTable.py index d1f8573..3d3e0a0 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -19,6 +19,7 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import ( QAbstractItemModel, QModelIndex, + QThreadPool, Qt, pyqtSignal, QTimer, @@ -49,17 +50,21 @@ class MusicTable(QTableView): enterKey = pyqtSignal() deleteKey = pyqtSignal() refreshMusicTable = pyqtSignal() + handleProgressSignal = pyqtSignal(str) + getThreadPoolSignal = pyqtSignal() - def __init__(self, parent): + def __init__(self, parent=None, application_window=None): super().__init__(parent) + self.application_window = application_window + # FIXME: why does this give me pyright errors self.model = QStandardItemModel() self.setModel(self.model) - # self.model: QAbstractItemModel | None = QAbstractItemModel(self) # Config self.config = configparser.ConfigParser() self.config.read("config.ini") + self.threadpool = QThreadPool # gui names of headers self.table_headers = [ "title", @@ -98,12 +103,12 @@ class MusicTable(QTableView): self.deleteKey.connect(self.delete_songs) self.model.dataChanged.connect(self.on_cell_data_changed) # editing cells self.model.layoutChanged.connect(self.restore_scroll_position) - self.load_music_table() self.setup_keyboard_shortcuts() - def contextMenuEvent(self, event): + def contextMenuEvent(self, a0): """Right-click context menu for rows in Music Table""" + assert a0 is not None menu = QMenu(self) add_to_playlist_action = QAction("Add to playlist", self) add_to_playlist_action.triggered.connect(self.add_selected_files_to_playlist) @@ -130,7 +135,7 @@ class MusicTable(QTableView): menu.addAction(delete_action) # show self.set_selected_song_filepath() - menu.exec_(event.globalPos()) + menu.exec_(a0.globalPos()) def show_id3_tags_debug_menu(self): """Shows ID3 tags for a specific .mp3 file""" @@ -151,6 +156,10 @@ class MusicTable(QTableView): QMessageBox.Yes, ) if reply: + try: + self.model.dataChanged.disconnect(self.on_cell_data_changed) + except Exception as e: + pass selected_filepaths = self.get_selected_songs_filepaths() selected_indices = self.get_selected_rows() for file in selected_filepaths: @@ -159,14 +168,16 @@ class MusicTable(QTableView): "SELECT id FROM song WHERE filepath = ?", (file,) )[0][0] delete_song_id_from_database(song_id) - self.model.dataChanged.disconnect(self.on_cell_data_changed) for index in selected_indices: try: self.model.removeRow(index) except Exception as e: logging.info(f" delete_songs() failed | {e}") + try: + self.model.dataChanged.connect(self.on_cell_data_changed) + except Exception: + pass self.load_music_table() - self.model.dataChanged.connect(self.on_cell_data_changed) def open_directory(self): """Opens the currently selected song in the system file manager""" @@ -237,19 +248,44 @@ class MusicTable(QTableView): if e is None: return data = e.mimeData() + logging.info(f"dropEvent data: {data}") if data and data.hasUrls(): + directories = [] files = [] for url in data.urls(): if url.isLocalFile(): - files.append(url.path()) + path = url.toLocalFile() + if os.path.isdir(path): + # append 1 directory + directories.append(path) + else: + # append 1 file + files.append(path) e.accept() - worker = Worker(add_files_to_library, files) - worker.signals.signal_progress.connect(self.handle_progress) - worker.signals.signal_finished.connect(self.load_music_table) - self.threadpool.start(worker) + print(f"directories: {directories}") + print(f"files: {files}") + if directories: + worker = Worker(self.get_audio_files_recursively, directories) + worker.signals.signal_progress.connect(self.handle_progress) + worker.signals.signal_result.connect(self.on_recursive_search_finished) + worker.signals.signal_finished.connect(self.load_music_table) + if self.qapp: + threadpool = self.qapp.threadpool + threadpool.start(worker) + if files: + self.add_files(files) else: e.ignore() + def on_recursive_search_finished(self, result): + """file search completion handler""" + if result: + self.add_files(result) + + def handle_progress(self, data): + """Emits data to main""" + self.handleProgressSignal.emit(data) + def keyPressEvent(self, e): """Press a key. Do a thing""" if not e: @@ -287,27 +323,28 @@ class MusicTable(QTableView): def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex): """Handles updating ID3 tags when data changes in a cell""" logging.info("on_cell_data_changed") - id_index = self.model.index(topLeft.row(), 0) # ID is column 0, always - song_id = self.model.data(id_index, Qt.UserRole) - # filepath is always the last column - filepath_column_idx = self.model.columnCount() - 1 - filepath_index = self.model.index(topLeft.row(), filepath_column_idx) - # exact index of the edited cell in 2d space - filepath = self.model.data(filepath_index) # filepath - # update the ID3 information - user_input_data = topLeft.data() - edited_column_name = self.database_columns[topLeft.column()] - logging.info(f"edited column name: {edited_column_name}") - response = set_id3_tag(filepath, edited_column_name, user_input_data) - if response: - # Update the library with new metadata - update_song_in_database(song_id, edited_column_name, user_input_data) + if isinstance(self.model, QStandardItemModel): + id_index = self.model.index(topLeft.row(), 0) # ID is column 0, always + song_id = self.model.data(id_index, Qt.UserRole) + # filepath is always the last column + filepath_column_idx = self.model.columnCount() - 1 + filepath_index = self.model.index(topLeft.row(), filepath_column_idx) + # exact index of the edited cell in 2d space + filepath = self.model.data(filepath_index) # filepath + # update the ID3 information + user_input_data = topLeft.data() + edited_column_name = self.database_columns[topLeft.column()] + logging.info(f"edited column name: {edited_column_name}") + response = set_id3_tag(filepath, edited_column_name, user_input_data) + if response: + # Update the library with new metadata + update_song_in_database(song_id, edited_column_name, user_input_data) def handle_reorganize_selected_files(self): """""" worker = Worker(self.reorganize_selected_files) worker.signals.signal_progress.connect(self.qapp.handle_progress) - self.threadpool.start(worker) + self.qapp.threadpool.start(worker) def reorganize_selected_files(self, progress_callback): """Ctrl+Shift+R = Reorganize""" @@ -352,9 +389,7 @@ class MusicTable(QTableView): f"reorganize_selected_files() | Error moving file: {filepath} | {e}" ) # Draw the rest of the owl - # self.model.dataChanged.disconnect(self.on_cell_data_changed) self.load_music_table() - # self.model.dataChanged.connect(self.on_cell_data_changed) QMessageBox.information( self, "Reorganization complete", "Files successfully reorganized" ) @@ -365,12 +400,21 @@ class MusicTable(QTableView): self.set_current_song_filepath() self.playPauseSignal.emit() - def add_files(self, files, progress_callback) -> None: - """When song(s) added to the library, update the tableview model + def add_files(self, files) -> None: + """Thread handles adding songs to library - Drag & Drop song(s) on tableView - File > Open > List of song(s) """ - add_files_to_library(files, progress_callback) + logging.info(f"add files, files: {files}") + worker = Worker(add_files_to_library, files) + worker.signals.signal_progress.connect(self.handle_progress) + worker.signals.signal_finished.connect(self.load_music_table) + if self.qapp: + threadpool = self.qapp.threadpool + threadpool.start(worker) + else: + logging.warning("Application window could not be found") + # add_files_to_library(files, progress_callback) return def load_music_table(self, *playlist_id): @@ -384,7 +428,6 @@ class MusicTable(QTableView): # Loading the table also causes cell data to change, technically # so we must disconnect the dataChanged trigger before loading # then re-enable after we are done loading - pass self.model.dataChanged.disconnect(self.on_cell_data_changed) except Exception as e: logging.info( @@ -394,7 +437,6 @@ class MusicTable(QTableView): self.vertical_scroll_position = ( self.verticalScrollBar().value() ) # Get my scroll position before clearing - # temporarily disconnect the datachanged signal to avoid EVERY SONG getting triggered self.model.clear() self.model.setHorizontalHeaderLabels(self.table_headers) if playlist_id: @@ -434,10 +476,13 @@ class MusicTable(QTableView): item.setData(id, Qt.UserRole) self.model.layoutChanged.emit() # emits a signal that the view should be updated try: - # self.model.dataChanged.connect(self.on_cell_data_changed) self.restore_scroll_position() except Exception: pass + try: + self.model.dataChanged.connect(self.on_cell_data_changed) + except Exception: + pass def restore_scroll_position(self) -> None: """Restores the scroll position""" @@ -447,6 +492,19 @@ class MusicTable(QTableView): lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position), ) + def get_audio_files_recursively(self, directories, progress_callback=None): + """Scans a directories for files""" + extensions = self.config.get("settings", "extensions").split(",") + audio_files = [] + for directory in directories: + for root, _, files in os.walk(directory): + for file in files: + if any(file.lower().endswith(ext) for ext in extensions): + audio_files.append(os.path.join(root, file)) + if progress_callback: + progress_callback.emit(file) + return audio_files + def get_selected_rows(self) -> list[int]: """Returns a list of indexes for every selected row""" selection_model = self.selectionModel() @@ -509,5 +567,5 @@ class MusicTable(QTableView): ) def load_qapp(self, qapp) -> None: - """Necessary for talking between components...""" + """Necessary for using members and methods of main application window""" self.qapp = qapp diff --git a/main.py b/main.py index d6e1d5e..21b8fee 100644 --- a/main.py +++ b/main.py @@ -78,8 +78,8 @@ class WorkerSignals(QObject): # (i.e. lists of filepaths) signal_started = pyqtSignal() - signal_finished = pyqtSignal() signal_result = pyqtSignal(object) + signal_finished = pyqtSignal() signal_progress = pyqtSignal(str) @@ -117,13 +117,17 @@ class Worker(QRunnable): """ self.signals.signal_started.emit() try: - self.fn(*self.args, **self.kwargs) + result = self.fn(*self.args, **self.kwargs) except Exception as e: traceback.print_exc() exctype, value = sys.exc_info()[:2] self.signals.signal_finished.emit((exctype, value, traceback.format_exc())) - finally: - self.signals.signal_finished.emit() + else: + if result: + self.signals.signal_finished.emit() + self.signals.signal_result.emit(result) + else: + self.signals.signal_finished.emit() class ApplicationWindow(QMainWindow, Ui_MainWindow): @@ -153,8 +157,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.audio_visualizer: AudioVisualizer = AudioVisualizer(self.player) self.current_volume: int = 50 self.qapp = qapp - self.tableView.load_qapp(self.qapp) - self.albumGraphicsView.load_qapp(self.qapp) + self.tableView.load_qapp(self) + self.albumGraphicsView.load_qapp(self) self.config.read("config.ini") # Initialization self.timer = QTimer(self) # Audio timing things @@ -209,7 +213,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.actionDeleteLibrary.triggered.connect(self.clear_database) self.actionDeleteDatabase.triggered.connect(self.delete_database) - ## tableView triggers + ## CONNECTIONS + # tableView self.tableView.doubleClicked.connect( self.play_audio_file ) # Listens for the double click event, then plays the song @@ -220,7 +225,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.on_play_clicked ) # Spacebar toggle play/pause signal - ## Playlist triggers + # playlistTreeView self.playlistTreeView.playlistChoiceSignal.connect( self.tableView.load_music_table ) @@ -233,9 +238,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.albumGraphicsView.albumArtDeleted.connect( self.delete_album_art_for_selected_songs ) + # multithreading + # whatever self.tableView.viewport().installEventFilter( self ) # for drag & drop functionality + self.tableView.handleProgressSignal.connect(self.handle_progress) # set column widths table_view_column_widths = str(self.config["table"]["column_widths"]).split(",") for i in range(self.tableView.model.columnCount()): @@ -244,6 +252,14 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.tableView.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.tableView.horizontalHeader().setStretchLastSection(False) + def reload_config(self) -> None: + """does what it says""" + self.config.read("config.ini") + + def get_thread_pool(self) -> QThreadPool: + """Returns the threadpool instance""" + return self.threadpool + def closeEvent(self, a0: QCloseEvent | None) -> None: """Save settings when closing the application""" # MusicTable/tableView column widths @@ -483,22 +499,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): worker = Worker(add_files_to_library, filenames) worker.signals.signal_finished.connect(self.tableView.load_music_table) worker.signals.signal_progress.connect(self.handle_progress) - # try: - # worker.signals.signal_started.connect( - # lambda: self.tableView.model.dataChanged.disconnect( - # self.tableView.on_cell_data_changed - # ) - # ) - # except: - # pass - # try: - # worker.signals.signal_finished.connect( - # lambda: self.tableView.model.dataChanged.connect( - # self.tableView.on_cell_data_changed - # ) - # ) - # except: - # pass self.threadpool.start(worker) def handle_progress(self, data): diff --git a/utils/add_files_to_library.py b/utils/add_files_to_library.py index 5ca68f9..5302ff8 100644 --- a/utils/add_files_to_library.py +++ b/utils/add_files_to_library.py @@ -8,7 +8,7 @@ config = ConfigParser() config.read("config.ini") -def add_files_to_library(files, progress_callback): +def add_files_to_library(files, progress_callback=None): """Adds audio file(s) to the sqllite db files = list() of fully qualified paths to audio file(s) Returns a list of dictionaries of metadata