From 511b118790fe6d0b7136dc03b1593119cd232dda Mon Sep 17 00:00:00 2001 From: billypom on debian Date: Sun, 3 Aug 2025 15:25:19 -0400 Subject: [PATCH] delete songs from playlist and separate worker thread class --- components/MusicTable.py | 62 ++++++++----- components/PlaylistsPane.py | 34 ++++--- main.py | 91 +++---------------- show_id3_tags.py => tests/show_id3_tags.py | 0 utils/Worker.py | 81 +++++++++++++++++ utils/__init__.py | 2 + utils/batch_delete_filepaths_from_playlist.py | 59 ++++++++++++ 7 files changed, 217 insertions(+), 112 deletions(-) rename show_id3_tags.py => tests/show_id3_tags.py (100%) create mode 100644 utils/Worker.py create mode 100644 utils/batch_delete_filepaths_from_playlist.py diff --git a/components/MusicTable.py b/components/MusicTable.py index efdd651..3acabcf 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -37,9 +37,9 @@ from components.MetadataWindow import MetadataWindow from components.QuestionBoxDetails import QuestionBoxDetails from components.HeaderTags import HeaderTags -from main import Worker from utils import ( batch_delete_filepaths_from_database, + batch_delete_filepaths_from_playlist, delete_song_id_from_database, add_files_to_database, get_reorganize_vars, @@ -48,6 +48,7 @@ from utils import ( id3_remap, get_tags, set_tag, + Worker ) from subprocess import Popen from logging import debug, error @@ -133,6 +134,7 @@ class MusicTable(QTableView): self.current_song_filepath = "" self.current_song_db_id = None self.current_song_qmodel_index: QModelIndex + self.selected_playlist_id: int | None = None # Properties self.setAcceptDrops(True) @@ -503,21 +505,34 @@ class MusicTable(QTableView): # or provide extra questionbox option # | Delete from playlist & lib | Delete from playlist only | Cancel | selected_filepaths = self.get_selected_songs_filepaths() - formatted_selected_filepaths = "\n".join(selected_filepaths) - question_dialog = QuestionBoxDetails( - title="Delete songs", - description="Remove these songs from the library?", - data=selected_filepaths, - ) - reply = question_dialog.execute() - if reply: - worker = Worker(batch_delete_filepaths_from_database, selected_filepaths) - worker.signals.signal_progress.connect(self.qapp.handle_progress) - worker.signals.signal_finished.connect(self.delete_selected_row_indices) - if self.qapp: - threadpool = self.qapp.threadpool - threadpool.start(worker) - return + if self.selected_playlist_id: + question_dialog = QuestionBoxDetails( + title="Delete songs", + description="Remove these songs from the playlist?", + data=selected_filepaths, + ) + reply = question_dialog.execute() + if reply: + worker = Worker(batch_delete_filepaths_from_playlist, selected_filepaths, self.selected_playlist_id) + worker.signals.signal_progress.connect(self.qapp.handle_progress) + worker.signals.signal_finished.connect(self.delete_selected_row_indices) + if self.qapp: + threadpool = self.qapp.threadpool + threadpool.start(worker) + else: + question_dialog = QuestionBoxDetails( + title="Delete songs", + description="Remove these songs from the library?\n(This will remove the songs from all playlists)", + data=selected_filepaths, + ) + reply = question_dialog.execute() + if reply: + worker = Worker(batch_delete_filepaths_from_database, selected_filepaths) + worker.signals.signal_progress.connect(self.qapp.handle_progress) + worker.signals.signal_finished.connect(self.delete_selected_row_indices) + if self.qapp: + threadpool = self.qapp.threadpool + threadpool.start(worker) def delete_selected_row_indices(self): """ @@ -532,7 +547,7 @@ class MusicTable(QTableView): except Exception as e: debug(f"delete_selected_row_indices() failed | {e}") self.connect_data_changed() - self.load_music_table() + self.load_music_table(self.selected_playlist_id) def edit_selected_files_metadata(self): """Opens a form with metadata from the selected audio files""" @@ -702,8 +717,9 @@ class MusicTable(QTableView): else "" ) params = "" - if playlist_id: # Load a playlist - selected_playlist_id = playlist_id[0] + # Load a playlist + if playlist_id: + self.selected_playlist_id = playlist_id[0] try: with DBA.DBAccess() as db: query = f"SELECT id, {fields} FROM song JOIN song_playlist sp ON id = sp.song_id WHERE sp.playlist_id = ?" @@ -715,14 +731,16 @@ class MusicTable(QTableView): query = f"{query} WHERE {search_clause};" else: query = f"{query} AND {search_clause};" - data = db.query(query, (selected_playlist_id, params)) + data = db.query(query, (self.selected_playlist_id, params)) else: - data = db.query(query, (selected_playlist_id,)) + data = db.query(query, (self.selected_playlist_id,)) except Exception as e: error(f"load_music_table() | Unhandled exception 1: {e}") return - else: # Load the library + # Load the entire library + else: + self.selected_playlist_id = None try: with DBA.DBAccess() as db: query = f"SELECT id, {fields} FROM song" diff --git a/components/PlaylistsPane.py b/components/PlaylistsPane.py index 7357cac..af6b93b 100644 --- a/components/PlaylistsPane.py +++ b/components/PlaylistsPane.py @@ -7,9 +7,10 @@ from PyQt5.QtWidgets import ( QTreeWidget, QTreeWidgetItem, ) -from PyQt5.QtCore import pyqtSignal, Qt, QPoint +from PyQt5.QtCore import QThreadPool, pyqtSignal, Qt, QPoint import DBA from logging import debug +from utils import Worker from components import CreatePlaylistWindow @@ -42,8 +43,12 @@ class PlaylistsPane(QTreeWidget): self.customContextMenuRequested.connect(self.showContextMenu) self.currentItemChanged.connect(self.playlist_clicked) self.playlist_db_id_choice: int | None = None + self.threadpool: QThreadPool | None = None - def reload_playlists(self): + def set_threadpool(self, threadpool: QThreadPool): + self.threadpool = threadpool + + def reload_playlists(self, progress_callback=None): """ Clears and reinitializes the playlists tree each playlist is a branch/child of root node `Playlists` @@ -53,8 +58,9 @@ class PlaylistsPane(QTreeWidget): # NOTE: implement user sorting by adding a column to playlist db table for 'rank' or something with DBA.DBAccess() as db: playlists = db.query( - "SELECT id, name FROM playlist ORDER BY date_created DESC LIMIT 1;", () + "SELECT id, name FROM playlist ORDER BY date_created DESC;", () ) + debug(f'PlaylistsPane: | playlists = {playlists}') for playlist in playlists: branch = PlaylistWidgetItem(self, playlist[0], playlist[1]) self._playlists_root.addChild(branch) @@ -95,13 +101,17 @@ class PlaylistsPane(QTreeWidget): if len(text) > 64: QMessageBox.warning(self, "WARNING", "Name must not exceed 64 characters") return - if ok: - with DBA.DBAccess() as db: - db.execute( - "UPDATE playlist SET name = ? WHERE id = ?;", - (text, self.playlist_db_id_choice), - ) - self.reload_playlists() + if not ok: + return + # Proceed with renaming the playlist + with DBA.DBAccess() as db: + db.execute( + "UPDATE playlist SET name = ? WHERE id = ?;", + (text, self.playlist_db_id_choice), + ) + worker = Worker(self.reload_playlists) + if self.threadpool: + self.threadpool.start(worker) def delete_playlist(self, *args): """Deletes a playlist""" @@ -118,7 +128,9 @@ class PlaylistsPane(QTreeWidget): "DELETE FROM playlist WHERE id = ?;", (self.playlist_db_id_choice,) ) # reload - self.reload_playlists() + worker = Worker(self.reload_playlists) + if self.threadpool: + self.threadpool.start(worker) def playlist_clicked(self, item): """Specific playlist pane index was clicked""" diff --git a/main.py b/main.py index 194e232..b9f2662 100644 --- a/main.py +++ b/main.py @@ -4,12 +4,11 @@ import logging from PyQt5 import QtCore import qdarktheme import typing -import traceback -import DBA +# import DBA from subprocess import run -from pyqtgraph import mkBrush +# from pyqtgraph import mkBrush from mutagen.id3 import ID3 -from mutagen.id3._frames import APIC +# from mutagen.id3._frames import APIC from configparser import ConfigParser from pathlib import Path from appdirs import user_config_dir @@ -70,6 +69,8 @@ from components import ( SearchLineEdit, ) from utils.get_album_art import get_album_art +# from utils.Worker import Worker +from utils import Worker # 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 @@ -78,78 +79,6 @@ from utils.get_album_art import get_album_art # 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 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() - signal_result = pyqtSignal(object) - signal_finished = pyqtSignal() - signal_progress = pyqtSignal(str) - - -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, 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) -> None: # type: ignore - """ - 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() - try: - result = self.fn(*self.args, **self.kwargs) - except Exception: - traceback.print_exc() - exctype, value = sys.exc_info()[:2] - self.signals.signal_finished.emit((exctype, value, traceback.format_exc())) - error(f"Worker failed: {exctype} | {value} | {traceback.format_exc()}") - 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): reloadConfigSignal = pyqtSignal() reloadDatabaseSignal = pyqtSignal() @@ -163,8 +92,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): / "config.ini" ) self.config.read(self.cfg_file) - print("main config:") - print(self.config) + debug(f"\tmain config: {self.config}") self.threadpool: QThreadPool = QThreadPool() # UI self.setupUi(self) @@ -286,6 +214,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.tableView.load_music_table ) self.playlistTreeView.allSongsSignal.connect(self.tableView.load_music_table) + self.playlistTreeView.set_threadpool(self.threadpool) # albumGraphicsView self.albumGraphicsView.albumArtDropped.connect( @@ -777,9 +706,13 @@ if __name__ == "__main__": handlers = [file_handler, stdout_handler] basicConfig( level=DEBUG, - format="[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s", + # format="[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s", + format="{%(filename)s:%(lineno)d} %(levelname)s - %(message)s", handlers=handlers, ) + debug(f'--------- musicpom debug started') + debug(f'---------------------| ') + debug(f'----------------------> {handlers} ') # Initialization config: ConfigParser = update_config_file() if not update_database_file(): diff --git a/show_id3_tags.py b/tests/show_id3_tags.py similarity index 100% rename from show_id3_tags.py rename to tests/show_id3_tags.py diff --git a/utils/Worker.py b/utils/Worker.py new file mode 100644 index 0000000..75ff6f1 --- /dev/null +++ b/utils/Worker.py @@ -0,0 +1,81 @@ +from PyQt5.QtCore import ( + pyqtSignal, + QObject, + pyqtSlot, + QRunnable, +) +from logging import error +from sys import exc_info +from traceback import format_exc, print_exc + + +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() + signal_result = pyqtSignal(object) + signal_finished = pyqtSignal() + signal_progress = pyqtSignal(str) + + +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, 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) -> None: # type: ignore + """ + 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() + try: + result = self.fn(*self.args, **self.kwargs) + except Exception: + print_exc() + exctype, value = exc_info()[:2] + self.signals.signal_finished.emit((exctype, value, format_exc())) + error(f"Worker failed: {exctype} | {value} | {format_exc()}") + else: + if result: + self.signals.signal_finished.emit() + self.signals.signal_result.emit(result) + else: + self.signals.signal_finished.emit() diff --git a/utils/__init__.py b/utils/__init__.py index ec52b89..59f4199 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -8,6 +8,7 @@ from .get_reorganize_vars import get_reorganize_vars from .set_tag import set_tag from .delete_song_id_from_database import delete_song_id_from_database from .batch_delete_filepaths_from_database import batch_delete_filepaths_from_database +from .batch_delete_filepaths_from_playlist import batch_delete_filepaths_from_playlist from .delete_and_create_library_database import delete_and_create_library_database from .update_song_in_database import update_song_in_database from .scan_for_music import scan_for_music @@ -15,3 +16,4 @@ from .add_files_to_database import add_files_to_database from .convert_date_str_to_tyer_tdat_id3_tag import convert_date_str_to_tyer_tdat_id3_tag from .set_album_art import set_album_art from .delete_album_art import delete_album_art +from .Worker import Worker diff --git a/utils/batch_delete_filepaths_from_playlist.py b/utils/batch_delete_filepaths_from_playlist.py new file mode 100644 index 0000000..6e782ad --- /dev/null +++ b/utils/batch_delete_filepaths_from_playlist.py @@ -0,0 +1,59 @@ +import DBA +from logging import error +from components.ErrorDialog import ErrorDialog + + +def batch_delete_filepaths_from_playlist( + files: list[str], playlist_id: int, chunk_size=1000, progress_callback=None +) -> bool: + """ + Handles deleting many songs from a playlist, in the database by filepath + Accounts for playlists and other song-linked tables + Args: + files: a list of absolute filepaths to songs + chunk_size: how many files to process at once in DB + progress_callback: emit this signal for user feedback + + Returns True on success + False on failure/error + """ + # Get song IDs from filepaths + try: + with DBA.DBAccess() as db: + placeholders = ", ".join("?" for _ in files) + query = f"SELECT id FROM song WHERE filepath in ({placeholders});" + result = db.query(query, files) + song_ids = [item[0] for item in result] + except Exception as e: + error( + f"batch_delete_filepaths_from_database.py | An error occurred during retrieval of song_ids: {e}" + ) + dialog = ErrorDialog( + f"batch_delete_filepaths_from_database.py | An error occurred during retrieval of song_ids: {e}" + ) + dialog.exec_() + return False + try: + with DBA.DBAccess() as db: + # Batch delete in chunks + for i in range(0, len(song_ids), chunk_size): + chunk = song_ids[i: i + chunk_size] + placeholders = ", ".join("?" for _ in chunk) + + # Delete from playlists + query = f"DELETE FROM song_playlist as sp WHERE sp.playlist_id = {playlist_id} AND sp.song_id IN ({placeholders});" + db.execute(query, chunk) + + if progress_callback: + progress_callback.emit(f"Deleting songs: {i}") + + except Exception as e: + error( + f"batch_delete_filepaths_from_database.py | An error occurred during batch processing: {e}" + ) + dialog = ErrorDialog( + f"batch_delete_filepaths_from_database.py | An error occurred during batch processing: {e}" + ) + dialog.exec_() + return False + return True