delete songs from playlist and separate worker thread class

This commit is contained in:
billypom on debian 2025-08-03 15:25:19 -04:00
parent 5adb760d83
commit 511b118790
7 changed files with 217 additions and 112 deletions

View File

@ -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,10 +505,24 @@ 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)
if self.selected_playlist_id:
question_dialog = QuestionBoxDetails(
title="Delete songs",
description="Remove these songs from the library?",
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()
@ -517,7 +533,6 @@ class MusicTable(QTableView):
if self.qapp:
threadpool = self.qapp.threadpool
threadpool.start(worker)
return
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"

View File

@ -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:
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),
)
self.reload_playlists()
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"""

91
main.py
View File

@ -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():

81
utils/Worker.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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