diff --git a/components/ExportPlaylistWindow.py b/components/ExportPlaylistWindow.py index 6a282f2..975550f 100644 --- a/components/ExportPlaylistWindow.py +++ b/components/ExportPlaylistWindow.py @@ -103,7 +103,7 @@ class ExportPlaylistWindow(QDialog): self.chosen_list_widget_item: QListWidgetItem | None = ( self.playlist_listWidget.currentItem() ) - # We don't care if its None + # We don't care if nothing is chosen if self.chosen_list_widget_item is None: return @@ -146,6 +146,7 @@ class ExportPlaylistWindow(QDialog): ) error_dialog.exec() self.close() + return # Gather playlist song paths write_paths = [] @@ -167,7 +168,9 @@ class ExportPlaylistWindow(QDialog): os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, "w") as f: f.writelines(write_paths) + self.close() + return def cancel(self) -> None: self.close() diff --git a/components/MusicTable.py b/components/MusicTable.py index 4a4e4ee..3e6db0c 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -24,6 +24,7 @@ from components.AddToPlaylistWindow import AddToPlaylistWindow from components.MetadataWindow import MetadataWindow 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 from utils.update_song_in_database import update_song_in_database from utils.get_id3_tags import get_id3_tags from utils.get_album_art import get_album_art @@ -311,21 +312,7 @@ class MusicTable(QTableView): for filepath in filepaths: try: # Read file metadata - audio = ID3(filepath) - try: - artist = audio["TPE1"].text[0] - if artist == "": - artist = "Unknown Artist" - except KeyError: - artist = "Unknown Artist" - - try: - album = audio["TALB"].text[0] - if album == "": - album = "Unknown Album" - except KeyError: - album = "Unknown Album" - + artist, album = get_reorganize_vars(filepath) # Determine the new path that needs to be made new_path = os.path.join( target_dir, artist, album, os.path.basename(filepath) diff --git a/main.py b/main.py index f39a31f..b96cbe4 100644 --- a/main.py +++ b/main.py @@ -13,16 +13,28 @@ import DBA from ui import Ui_MainWindow from PyQt5.QtWidgets import ( QFileDialog, + QLabel, QMainWindow, QApplication, QGraphicsScene, QHeaderView, QGraphicsPixmapItem, QMessageBox, + QStatusBar, +) +from PyQt5.QtCore import ( + QUrl, + QTimer, + Qt, + pyqtSignal, + QObject, + pyqtSlot, + QThreadPool, + QRunnable, ) -from PyQt5.QtCore import QUrl, QTimer, Qt, pyqtSignal 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 components import ( PreferencesWindow, @@ -35,6 +47,51 @@ from components import ( # pyuic5 ui.ui -o ui.py +class WorkerSignals(QObject): + """ + How to use signals for a QRunnable class; unlike most cases where signals + are defined as class attributes directly in the class, here we define a + class that inherits from QObject and define the signals as class + attributes in that class. Then we can instantiate that class and use it + as a signal object. + """ + + # 1) + # Use a naming convention for signals that makes it clear that they are signals + # and a corresponding naming convention for the slots that handle them. + # For example signal_* and handle_*. + # 2) + # And try to make the signal content as small as possible. DO NOT pass large objects through signals, like + # 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_progress = pyqtSignal(str) + + +class WorkerThread(QRunnable): + """ + This is the thread that is going to do the work so that the + application doesn't freeze + """ + + def __init__(self, worker_id: UUID) -> None: + super().__init__() + # allow for signals to be used + self.worker_id = worker_id + self.signals: WorkerSignals = WorkerSignals() + + def run(self): + """ + This is where the work is done. + MUST be called run() in order for QRunnable to work + """ + self.signals.signal_started.emit("start worker") + self.signals.signal_progress.emit("worker progress") + self.signals.signal_finished.emit(self.worker_id) + + class ApplicationWindow(QMainWindow, Ui_MainWindow): playlistCreatedSignal = pyqtSignal() @@ -42,8 +99,16 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): super(ApplicationWindow, self).__init__() global stopped stopped = False + # Multithreading stuff... + self.workers = dict[UUID, WorkerThread] = {} + self.threadpool = QThreadPool() + # UI self.setupUi(self) self.setWindowTitle("MusicPom") + self.status_bar = QStatusBar() + self.permanent_status_label = QLabel("Status...") + self.status_bar.addPermanentWidget(self.permanent_status_label) + self.setStatusBar(self.status_bar) self.selected_song_filepath: str | None = None self.current_song_filepath: str | None = None self.current_song_metadata: ID3 | dict | None = None @@ -146,6 +211,45 @@ 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 @@ -160,6 +264,21 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.config.write(configfile) super().closeEvent(a0) + def show_status_bar_message(self, message: str, timeout: int | None = None) -> None: + """ + Show a `message` in the status bar for a length of time - `timeout` in ms + """ + if timeout: + self.status_bar.showMessage(message, timeout) + else: + self.status_bar.showMessage(message) + + def set_permanent_status_bar_message(self, message: str) -> None: + """ + Sets the permanent message label in the status bar + """ + self.permanent_status_label.setText(message) + def play_audio_file(self) -> None: """Start playback of tableView.current_song_filepath track & moves playback slider""" self.current_song_metadata = ( @@ -346,6 +465,10 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): def on_next_clicked(self) -> None: print("next") + 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""" open_files_window = QFileDialog( @@ -357,16 +480,14 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): filenames = open_files_window.selectedFiles() self.tableView.add_files(filenames) + # File + def create_playlist(self) -> None: """Creates a database record for a playlist, given a name""" window = CreatePlaylistWindow(self.playlistCreatedSignal) window.playlistCreatedSignal.connect(self.add_latest_playlist_to_tree) window.exec_() - def add_latest_playlist_to_tree(self) -> None: - """Refreshes the playlist tree""" - self.playlistTreeView.add_latest_playlist_to_tree() - def import_playlist(self) -> None: """ Imports a .m3u file, given a base path attempts to match playlist files to @@ -382,14 +503,20 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): export_playlist_window = ExportPlaylistWindow() export_playlist_window.exec_() + # Edit + def open_preferences(self) -> None: """Opens the preferences window""" preferences_window = PreferencesWindow(self.config) preferences_window.exec_() # Display the preferences window modally + # Quick Actions + def scan_libraries(self) -> None: - """Scans for new files in the configured library folder - Refreshes the datagridview""" + """ + Scans for new files in the configured library folder + Refreshes the datagridview + """ scan_for_music() self.tableView.load_music_table() diff --git a/utils/add_files_to_library.py b/utils/add_files_to_library.py index eee1b59..04cc854 100644 --- a/utils/add_files_to_library.py +++ b/utils/add_files_to_library.py @@ -2,6 +2,7 @@ import DBA from configparser import ConfigParser from utils import get_id3_tags, id3_timestamp_to_datetime import logging +from PyQt5.QtCore import pyqtSignal config = ConfigParser() config.read("config.ini") @@ -12,6 +13,7 @@ def add_files_to_library(files): files = list() of fully qualified paths to audio file(s) Returns a list of dictionaries of metadata """ + # print("Running add_files_to_library.py") if not files: return [] diff --git a/utils/scan_for_music.py b/utils/scan_for_music.py index 449652f..255b2b4 100644 --- a/utils/scan_for_music.py +++ b/utils/scan_for_music.py @@ -1,6 +1,7 @@ import os from configparser import ConfigParser +from PyQt5.QtCore import pyqtSignal from utils.add_files_to_library import add_files_to_library config = ConfigParser()