From d351f3515e0800c3bf846f73a350c919d279fb76 Mon Sep 17 00:00:00 2001 From: tsi-billypom Date: Fri, 6 Sep 2024 11:41:29 -0400 Subject: [PATCH] multithreading working with add files to library --- .gitignore | 1 + components/MusicTable.py | 82 ++++++++------------ main.py | 140 +++++++++++++++++++--------------- show_id3_tags.py | 1 + utils/add_files_to_library.py | 7 +- utils/get_id3_tags.py | 35 +-------- utils/set_id3_tag.py | 10 +-- 7 files changed, 122 insertions(+), 154 deletions(-) diff --git a/.gitignore b/.gitignore index f7509a2..fe10cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /references /db config.ini +log # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/components/MusicTable.py b/components/MusicTable.py index 82ebca4..6915876 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -31,6 +31,7 @@ from components.AddToPlaylistWindow import AddToPlaylistWindow from components.MetadataWindow import MetadataWindow # from main import WorkerThread +from main import Worker from utils.delete_song_id_from_database import delete_song_id_from_database from utils.add_files_to_library import add_files_to_library from utils.get_reorganize_vars import get_reorganize_vars @@ -45,29 +46,6 @@ import os import shutil -class DropAddFilesThread(QThread): - signalStarted = pyqtSignal() - signalProgress = pyqtSignal(str) - signalFinished = pyqtSignal() - - def __init__(self, files, parent=None): - QThread.__init__(self, parent) - self.files = files - - def run(self) -> None: - self.add_files() - return - - def add_files(self) -> None: - """When song(s) added to the library, update the tableview model - - Drag & Drop song(s) on tableView - - File > Open > List of song(s) - """ - add_files_to_library(self.files) - self.signalFinished.emit() - return - - class MusicTable(QTableView): playPauseSignal = pyqtSignal() enterKey = pyqtSignal() @@ -191,7 +169,7 @@ class MusicTable(QTableView): try: model.removeRow(index) except Exception as e: - logging.info(f"MusicTable.py delete_songs() failed | {e}") + logging.info(f" delete_songs() failed | {e}") self.load_music_table() self.model.dataChanged.connect(self.on_cell_data_changed) @@ -237,7 +215,7 @@ class MusicTable(QTableView): else: raise RuntimeError("No USLT tags found in song metadata") except Exception as e: - print(f"MusicTable.py | show_lyrics_menu | could not retrieve lyrics | {e}") + logging.error(f"show_lyrics_menu() | could not retrieve lyrics | {e}") lyrics = "" lyrics_window = LyricsWindow(selected_song_filepath, lyrics) lyrics_window.exec_() @@ -261,6 +239,7 @@ class MusicTable(QTableView): e.ignore() def dropEvent(self, e: QDropEvent | None): + self.model.dataChanged.disconnect(self.on_cell_data_changed) if e is None: return data = e.mimeData() @@ -270,14 +249,13 @@ class MusicTable(QTableView): if url.isLocalFile(): files.append(url.path()) e.accept() - self.worker = DropAddFilesThread(files=files) - # self.model.dataChanged.disconnect(self.on_cell_data_changed) - # self.model.dataChanged.connect(self.on_cell_data_changed) - self.worker.signalFinished.connect(self.load_music_table) - self.worker.start() - # self.add_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) + self.threadpool.start(worker) else: e.ignore() + self.model.dataChanged.connect(self.on_cell_data_changed) def keyPressEvent(self, e): """Press a key. Do a thing""" @@ -315,7 +293,7 @@ class MusicTable(QTableView): def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex): """Handles updating ID3 tags when data changes in a cell""" - print("on_cell_data_changed") + 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 @@ -326,7 +304,7 @@ class MusicTable(QTableView): # update the ID3 information user_input_data = topLeft.data() edited_column_name = self.database_columns[topLeft.column()] - print(f"edited column name: {edited_column_name}") + 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 @@ -366,13 +344,12 @@ class MusicTable(QTableView): "UPDATE song SET filepath = ? WHERE filepath = ?", (new_path, filepath), ) - print(f"Moved: {filepath} -> {new_path}") + logging.info( + f"reorganize_selected_files() | Moved: {filepath} -> {new_path}" + ) except Exception as e: logging.warning( - f"MusicTable.py reorganize_selected_files() | Error moving file: {filepath} | {e}" - ) - print( - f"MusicTable.py reorganize_selected_files() | Error moving file: {filepath} | {e}" + f"reorganize_selected_files() | Error moving file: {filepath} | {e}" ) # Draw the rest of the owl # self.model.dataChanged.disconnect(self.on_cell_data_changed) @@ -388,6 +365,14 @@ 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 + - Drag & Drop song(s) on tableView + - File > Open > List of song(s) + """ + add_files_to_library(files, progress_callback) + return + def load_music_table(self, *playlist_id): """ Loads data into self (QTableView) @@ -401,8 +386,8 @@ class MusicTable(QTableView): # then re-enable after we are done loading self.model.dataChanged.disconnect(self.on_cell_data_changed) except Exception as e: - print( - f"MusicTable.py load_music_table() | could not disconnect on_cell_data_changed trigger: {e}" + logging.info( + f"load_music_table() | could not disconnect on_cell_data_changed trigger: {e}" ) pass self.vertical_scroll_position = ( @@ -413,7 +398,9 @@ class MusicTable(QTableView): self.model.setHorizontalHeaderLabels(self.table_headers) if playlist_id: selected_playlist_id = playlist_id[0] - print(f"selected_playlist_id: {selected_playlist_id}") + logging.info( + f"load_music_table() | selected_playlist_id: {selected_playlist_id}" + ) # Fetch playlist data try: with DBA.DBAccess() as db: @@ -422,10 +409,7 @@ class MusicTable(QTableView): (selected_playlist_id,), ) except Exception as e: - logging.warning( - f"MusicTable.py | load_music_table | Unhandled exception: {e}" - ) - print(f"MusicTable.py | load_music_table | Unhandled exception: {e}") + logging.warning(f"load_music_table() | Unhandled exception: {e}") return else: # Fetch library data @@ -436,9 +420,7 @@ class MusicTable(QTableView): (), ) except Exception as e: - logging.warning( - f"MusicTable.py | load_music_table | Unhandled exception: {e}" - ) + logging.warning(f"load_music_table() | Unhandled exception: {e}") return # Populate the model for row_data in data: @@ -458,7 +440,7 @@ class MusicTable(QTableView): def restore_scroll_position(self) -> None: """Restores the scroll position""" - print("restore_scroll_position") + logging.info("restore_scroll_position") QTimer.singleShot( 100, lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position), @@ -520,7 +502,7 @@ class MusicTable(QTableView): self.selected_song_filepath = ( self.currentIndex().siblingAtColumn(self.table_headers.index("path")).data() ) - print(self.selected_song_filepath) + logging.info(self.selected_song_filepath) def set_current_song_filepath(self) -> None: """Sets the filepath of the currently playing song""" diff --git a/main.py b/main.py index 22d6fa1..75388bd 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from pyqtgraph import mkBrush from mutagen.id3 import ID3 from mutagen.id3._frames import APIC from configparser import ConfigParser +import traceback import DBA from ui import Ui_MainWindow from PyQt5.QtWidgets import ( @@ -28,6 +29,7 @@ from PyQt5.QtCore import ( Qt, pyqtSignal, QObject, + QThread, pyqtSlot, QThreadPool, QRunnable, @@ -35,7 +37,12 @@ from PyQt5.QtCore import ( from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe from PyQt5.QtGui import QCloseEvent, QPixmap from uuid import uuid4, UUID -from utils import scan_for_music, delete_and_create_library_database, initialize_db +from utils import ( + scan_for_music, + delete_and_create_library_database, + initialize_db, + add_files_to_library, +) from components import ( PreferencesWindow, AudioVisualizer, @@ -49,6 +56,11 @@ from components import ( # good help with signals slots in threads # https://stackoverflow.com/questions/52993677/how-do-i-setup-signals-and-slots-in-pyqt-with-qthreads-in-both-directions + +# GOOD +# https://www.pythonguis.com/tutorials/multithreading-pyqt-applications-qthreadpool/ + + class WorkerSignals(QObject): """ How to use signals for a QRunnable class; unlike most cases where signals @@ -67,31 +79,52 @@ class WorkerSignals(QObject): # pandas DataFrames or numpy arrays. Instead, pass the minimum amount of information needed # (i.e. lists of filepaths) - signal_started = pyqtSignal(list) - signal_finished = pyqtSignal(UUID) + signal_started = pyqtSignal() + signal_finished = pyqtSignal() signal_progress = pyqtSignal(str) -class WorkerThread(QRunnable): +class Worker(QRunnable): """ This is the thread that is going to do the work so that the application doesn't freeze + + Inherits from QRunnable to handle worker thread setup, signals, and tear down + :param callback: the function callback to run on this worker thread. Supplied + arg and kwargs will be passed through to the runner + :type callback: function + :param args: Arguments to pass to the callback function + :param kwargs: Keywords to pass to the callback function """ - def __init__(self, worker_id: UUID) -> None: - super().__init__() - # allow for signals to be used - self.worker_id = worker_id + def __init__(self, fn, *args, **kwargs): + super(Worker, self).__init__() + # Store constructor arguments (re-used for processing) + self.fn = fn + self.args = args + self.kwargs = kwargs self.signals: WorkerSignals = WorkerSignals() + # Add a callback to our kwargs + self.kwargs["progress_callback"] = self.signals.signal_progress + + @pyqtSlot() def run(self): """ This is where the work is done. MUST be called run() in order for QRunnable to work + + Initialize the runner function with passed args & kwargs """ - self.signals.signal_started.emit("start worker") - self.signals.signal_progress.emit("worker progress") - self.signals.signal_finished.emit(self.worker_id) + self.signals.signal_started.emit() + try: + 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() class ApplicationWindow(QMainWindow, Ui_MainWindow): @@ -122,7 +155,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.audio_visualizer: AudioVisualizer = AudioVisualizer(self.player) self.current_volume: int = 50 self.qapp = qapp - # print(f'ApplicationWindow self.qapp: {self.qapp}') self.tableView.load_qapp(self.qapp) self.albumGraphicsView.load_qapp(self.qapp) self.config.read("config.ini") @@ -154,7 +186,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.playbackSlider.sliderReleased.connect( lambda: self.player.setPosition(self.playbackSlider.value()) ) # maybe sliderReleased works better than sliderMoved - self self.volumeSlider.sliderMoved[int].connect( lambda: self.volume_changed() ) # Move slider to adjust volume @@ -213,45 +244,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.tableView.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.tableView.horizontalHeader().setStretchLastSection(False) - def start_worker(self): - worker_id = uuid4() # generate an unique id for the worker - worker = WorkerThread(worker_id=worker_id) - - # Connect the signals to the slots - worker.signals.signal_started.connect(self.handle_started) - worker.signals.signal_progress.connect(self.handle_progress) - worker.signals.signal_finished.connect(self.handle_finished) - - # Store the worker in a dict so we can keep track of it - self.workers[worker_id] = worker - - # Start the worker - self.threadpool.start(worker) - - # Slots should have decorators whose arguments match the signals they are connected to - @pyqtSlot(list) - def handle_files_from_child_window(self, files: list[str]): - self.files_to_process = files - print(f"Files to process: {self.files_to_process}") - self.show_status_bar_message(f"Files to process: {self.files_to_process}") - - @pyqtSlot(int) - def handle_progress(self, n): - print(f"progress: {n}%") - self.show_status_bar_message(f"progress: {n}%") - - @pyqtSlot(UUID) - def handle_finished(self, worker_id): - print(f"worker {worker_id} finished") - self.show_status_bar_message(f"worker {worker_id} finished") - # remove the worker from the dict - self.workers.pop(worker_id) - assert worker_id not in self.workers - - @pyqtSlot(list) - def handle_started(self, files: list[str]): - print(f"Started processing files: {files}") - def closeEvent(self, a0: QCloseEvent | None) -> None: """Save settings when closing the application""" # MusicTable/tableView column widths @@ -355,7 +347,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): """Sets the ID3 tag APIC (album art) for all selected song filepaths""" selected_songs = self.tableView.get_selected_songs_filepaths() for song in selected_songs: - print(f"updating album art for {song}") + logging.info( + f"main.py set_album_art_for_selected_songs() | updating album art for {song}" + ) self.update_album_art_for_song(song, album_art_path) def update_album_art_for_song( @@ -401,7 +395,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): del audio["APIC"] audio.save() except Exception as e: - print(f"Error processing {file}: {e}") + logging.error( + f"main.py delete_album_art_for_selected_songs() | Error processing {file}: {e}" + ) def update_audio_visualization(self) -> None: """Handles upading points on the pyqtgraph visual""" @@ -445,7 +441,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.current_volume = self.volumeSlider.value() self.player.setVolume(self.current_volume) except Exception as e: - print(f"Changing volume error: {e}") + logging.error(f"main.py volume_changed() | Changing volume error: {e}") def on_play_clicked(self) -> None: """Updates the Play & Pause buttons when clicked""" @@ -462,17 +458,20 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): def on_previous_clicked(self) -> None: """""" - print("previous") + logging.info("main.py on_previous_clicked()") def on_next_clicked(self) -> None: - print("next") + logging.info("main.py on_next_clicked()") def add_latest_playlist_to_tree(self) -> None: """Refreshes the playlist tree""" self.playlistTreeView.add_latest_playlist_to_tree() def open_files(self) -> None: - """Opens the open files window""" + """ + Opens the open files window + - File > Open > List of song(s) + """ open_files_window = QFileDialog( self, "Open file(s)", ".", "Audio files (*.mp3)" ) @@ -480,7 +479,17 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): open_files_window.setFileMode(QFileDialog.ExistingFiles) open_files_window.exec_() filenames = open_files_window.selectedFiles() - self.tableView.add_files(filenames) + # Adds files to the library in a new thread + worker = Worker(add_files_to_library, filenames) + worker.signals.signal_progress.connect(self.handle_progress) + worker.signals.signal_finished.connect(self.tableView.load_music_table) + self.threadpool.start(worker) + + def handle_progress(self, data): + """ + updates the status bar when progress is emitted + """ + self.show_status_bar_message(data) # File @@ -584,16 +593,23 @@ if __name__ == "__main__": with open("utils/init.sql", "r") as file: lines = file.read() for statement in lines.split(";"): - print(f"executing [{statement}]") + logging.info(f"executing [{statement}]") db.execute(statement, ()) # logging setup - logging.basicConfig(filename="musicpom.log", encoding="utf-8", level=logging.DEBUG) + file_handler = logging.FileHandler(filename="log", encoding="utf-8") + stdout_handler = logging.StreamHandler(stream=sys.stdout) + handlers = [file_handler, stdout_handler] + # logging.basicConfig(filename="log", encoding="utf-8", level=logging.DEBUG) + logging.basicConfig( + level=logging.DEBUG, + format="[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s", + handlers=handlers, + ) # Allow for dynamic imports of my custom classes and utilities project_root = os.path.abspath(os.path.dirname(__file__)) sys.path.append(project_root) # Start the app app = QApplication(sys.argv) - # print(f"main.py app: {app}") # Dark theme >:3 qdarktheme.setup_theme() # Show the UI diff --git a/show_id3_tags.py b/show_id3_tags.py index 06b10a0..c2f4983 100644 --- a/show_id3_tags.py +++ b/show_id3_tags.py @@ -1,6 +1,7 @@ import sys from mutagen.id3 import ID3, APIC import os +import logging def print_id3_tags(file_path): diff --git a/utils/add_files_to_library.py b/utils/add_files_to_library.py index 04cc854..f6bae81 100644 --- a/utils/add_files_to_library.py +++ b/utils/add_files_to_library.py @@ -8,19 +8,20 @@ config = ConfigParser() config.read("config.ini") -def add_files_to_library(files): +def add_files_to_library(files, progress_callback): """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 """ - - # print("Running add_files_to_library.py") + logging.info("started function") if not files: return [] extensions = config.get("settings", "extensions").split(",") insert_data = [] # To store data for batch insert for filepath in files: if any(filepath.lower().endswith(ext) for ext in extensions): + if progress_callback: + progress_callback.emit(filepath) filename = filepath.split("/")[-1] audio = get_id3_tags(filepath) diff --git a/utils/get_id3_tags.py b/utils/get_id3_tags.py index 2784c24..921e4be 100644 --- a/utils/get_id3_tags.py +++ b/utils/get_id3_tags.py @@ -1,40 +1,11 @@ -# from mutagen.id3 import ID3 -# from mutagen.id3._frames import TIT2 -# import os -# -# -# def get_id3_tags(filename): -# """Get the ID3 tags for an audio file -# -# # Parameters -# `file` | str | Fully qualified path to file -# -# # Returns -# dict of all id3 tags -# at minimum we will get the filename as a title.[ID3:TIT2] -# """ -# -# # Check if all tags are empty -# # tags_are_empty = all(not values for values in audio.values()) -# audio = ID3() -# try: -# if not audio["TIT2"] or audio["TIT2"].text[0] == "": -# title = os.path.splitext(os.path.basename(filename))[0] -# frame = TIT2(encoding=3, text=[title]) -# audio["TIT2"] = frame -# audio.save() # type: ignore -# except Exception as e: -# print(f"get_id3_tags.py | Could not assign file ID3 tag: {e}") -# return audio - - import os +import logging from mutagen.id3 import ID3, TIT2, ID3NoHeaderError def get_id3_tags(filename): """Get the ID3 tags for an audio file""" - print(f"get id3 tags filename: {filename}") + logging.info(f"filename: {filename}") try: # Open the MP3 file and read its content @@ -59,6 +30,6 @@ def get_id3_tags(filename): audio.save() except Exception as e: - print(f"get_id3_tags.py | Could not assign file ID3 tag: {e}") + logging.error(f"Could not assign file ID3 tag: {e}") return audio diff --git a/utils/set_id3_tag.py b/utils/set_id3_tag.py index 56415e1..0fe0a8d 100644 --- a/utils/set_id3_tag.py +++ b/utils/set_id3_tag.py @@ -92,9 +92,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): Returns: True / False""" - print( - f"set_id3_tag.py | filepath: {filepath} | tag_name: {tag_name} | value: {value}" - ) + logging.info(f"filepath: {filepath} | tag_name: {tag_name} | value: {value}") try: try: # Load existing tags @@ -114,9 +112,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): try: audio = ID3(filepath) except Exception as e: - logging.debug( - f"set_id3_tag.py set_id3_tag() ran into an exception: {e}" - ) + logging.error(f"ran into an exception: {e}") audio = ID3() audio.delall("USLT") frame = USLT(encoding=3, text=value) @@ -128,7 +124,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): tag_name = id3_tag_mapping[tag_name] # Other if tag_name in mutagen_id3_tag_mapping: # Tag accounted for - print(f"set_id3_tag.py | tag_name = {tag_name}") + logging.info(f"tag_name = {tag_name}") tag_class = mutagen_id3_tag_mapping[tag_name] if issubclass(tag_class, Frame): frame = tag_class(encoding=3, text=[value])