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.QuestionBoxDetails import QuestionBoxDetails
from components.HeaderTags import HeaderTags from components.HeaderTags import HeaderTags
from main import Worker
from utils import ( from utils import (
batch_delete_filepaths_from_database, batch_delete_filepaths_from_database,
batch_delete_filepaths_from_playlist,
delete_song_id_from_database, delete_song_id_from_database,
add_files_to_database, add_files_to_database,
get_reorganize_vars, get_reorganize_vars,
@ -48,6 +48,7 @@ from utils import (
id3_remap, id3_remap,
get_tags, get_tags,
set_tag, set_tag,
Worker
) )
from subprocess import Popen from subprocess import Popen
from logging import debug, error from logging import debug, error
@ -133,6 +134,7 @@ class MusicTable(QTableView):
self.current_song_filepath = "" self.current_song_filepath = ""
self.current_song_db_id = None self.current_song_db_id = None
self.current_song_qmodel_index: QModelIndex self.current_song_qmodel_index: QModelIndex
self.selected_playlist_id: int | None = None
# Properties # Properties
self.setAcceptDrops(True) self.setAcceptDrops(True)
@ -503,10 +505,24 @@ class MusicTable(QTableView):
# or provide extra questionbox option # or provide extra questionbox option
# | Delete from playlist & lib | Delete from playlist only | Cancel | # | Delete from playlist & lib | Delete from playlist only | Cancel |
selected_filepaths = self.get_selected_songs_filepaths() selected_filepaths = self.get_selected_songs_filepaths()
formatted_selected_filepaths = "\n".join(selected_filepaths) if self.selected_playlist_id:
question_dialog = QuestionBoxDetails( question_dialog = QuestionBoxDetails(
title="Delete songs", 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, data=selected_filepaths,
) )
reply = question_dialog.execute() reply = question_dialog.execute()
@ -517,7 +533,6 @@ class MusicTable(QTableView):
if self.qapp: if self.qapp:
threadpool = self.qapp.threadpool threadpool = self.qapp.threadpool
threadpool.start(worker) threadpool.start(worker)
return
def delete_selected_row_indices(self): def delete_selected_row_indices(self):
""" """
@ -532,7 +547,7 @@ class MusicTable(QTableView):
except Exception as e: except Exception as e:
debug(f"delete_selected_row_indices() failed | {e}") debug(f"delete_selected_row_indices() failed | {e}")
self.connect_data_changed() self.connect_data_changed()
self.load_music_table() self.load_music_table(self.selected_playlist_id)
def edit_selected_files_metadata(self): def edit_selected_files_metadata(self):
"""Opens a form with metadata from the selected audio files""" """Opens a form with metadata from the selected audio files"""
@ -702,8 +717,9 @@ class MusicTable(QTableView):
else "" else ""
) )
params = "" params = ""
if playlist_id: # Load a playlist # Load a playlist
selected_playlist_id = playlist_id[0] if playlist_id:
self.selected_playlist_id = playlist_id[0]
try: try:
with DBA.DBAccess() as db: 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 = ?" 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};" query = f"{query} WHERE {search_clause};"
else: else:
query = f"{query} AND {search_clause};" query = f"{query} AND {search_clause};"
data = db.query(query, (selected_playlist_id, params)) data = db.query(query, (self.selected_playlist_id, params))
else: else:
data = db.query(query, (selected_playlist_id,)) data = db.query(query, (self.selected_playlist_id,))
except Exception as e: except Exception as e:
error(f"load_music_table() | Unhandled exception 1: {e}") error(f"load_music_table() | Unhandled exception 1: {e}")
return return
else: # Load the library # Load the entire library
else:
self.selected_playlist_id = None
try: try:
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
query = f"SELECT id, {fields} FROM song" query = f"SELECT id, {fields} FROM song"

View File

@ -7,9 +7,10 @@ from PyQt5.QtWidgets import (
QTreeWidget, QTreeWidget,
QTreeWidgetItem, QTreeWidgetItem,
) )
from PyQt5.QtCore import pyqtSignal, Qt, QPoint from PyQt5.QtCore import QThreadPool, pyqtSignal, Qt, QPoint
import DBA import DBA
from logging import debug from logging import debug
from utils import Worker
from components import CreatePlaylistWindow from components import CreatePlaylistWindow
@ -42,8 +43,12 @@ class PlaylistsPane(QTreeWidget):
self.customContextMenuRequested.connect(self.showContextMenu) self.customContextMenuRequested.connect(self.showContextMenu)
self.currentItemChanged.connect(self.playlist_clicked) self.currentItemChanged.connect(self.playlist_clicked)
self.playlist_db_id_choice: int | None = None 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 Clears and reinitializes the playlists tree
each playlist is a branch/child of root node `Playlists` 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 # NOTE: implement user sorting by adding a column to playlist db table for 'rank' or something
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
playlists = db.query( 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: for playlist in playlists:
branch = PlaylistWidgetItem(self, playlist[0], playlist[1]) branch = PlaylistWidgetItem(self, playlist[0], playlist[1])
self._playlists_root.addChild(branch) self._playlists_root.addChild(branch)
@ -95,13 +101,17 @@ class PlaylistsPane(QTreeWidget):
if len(text) > 64: if len(text) > 64:
QMessageBox.warning(self, "WARNING", "Name must not exceed 64 characters") QMessageBox.warning(self, "WARNING", "Name must not exceed 64 characters")
return return
if ok: if not ok:
return
# Proceed with renaming the playlist
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
db.execute( db.execute(
"UPDATE playlist SET name = ? WHERE id = ?;", "UPDATE playlist SET name = ? WHERE id = ?;",
(text, self.playlist_db_id_choice), (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): def delete_playlist(self, *args):
"""Deletes a playlist""" """Deletes a playlist"""
@ -118,7 +128,9 @@ class PlaylistsPane(QTreeWidget):
"DELETE FROM playlist WHERE id = ?;", (self.playlist_db_id_choice,) "DELETE FROM playlist WHERE id = ?;", (self.playlist_db_id_choice,)
) )
# reload # reload
self.reload_playlists() worker = Worker(self.reload_playlists)
if self.threadpool:
self.threadpool.start(worker)
def playlist_clicked(self, item): def playlist_clicked(self, item):
"""Specific playlist pane index was clicked""" """Specific playlist pane index was clicked"""

91
main.py
View File

@ -4,12 +4,11 @@ import logging
from PyQt5 import QtCore from PyQt5 import QtCore
import qdarktheme import qdarktheme
import typing import typing
import traceback # import DBA
import DBA
from subprocess import run from subprocess import run
from pyqtgraph import mkBrush # from pyqtgraph import mkBrush
from mutagen.id3 import ID3 from mutagen.id3 import ID3
from mutagen.id3._frames import APIC # from mutagen.id3._frames import APIC
from configparser import ConfigParser from configparser import ConfigParser
from pathlib import Path from pathlib import Path
from appdirs import user_config_dir from appdirs import user_config_dir
@ -70,6 +69,8 @@ from components import (
SearchLineEdit, SearchLineEdit,
) )
from utils.get_album_art import get_album_art 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 # 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 # 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/ # 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): class ApplicationWindow(QMainWindow, Ui_MainWindow):
reloadConfigSignal = pyqtSignal() reloadConfigSignal = pyqtSignal()
reloadDatabaseSignal = pyqtSignal() reloadDatabaseSignal = pyqtSignal()
@ -163,8 +92,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
/ "config.ini" / "config.ini"
) )
self.config.read(self.cfg_file) self.config.read(self.cfg_file)
print("main config:") debug(f"\tmain config: {self.config}")
print(self.config)
self.threadpool: QThreadPool = QThreadPool() self.threadpool: QThreadPool = QThreadPool()
# UI # UI
self.setupUi(self) self.setupUi(self)
@ -286,6 +214,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.tableView.load_music_table self.tableView.load_music_table
) )
self.playlistTreeView.allSongsSignal.connect(self.tableView.load_music_table) self.playlistTreeView.allSongsSignal.connect(self.tableView.load_music_table)
self.playlistTreeView.set_threadpool(self.threadpool)
# albumGraphicsView # albumGraphicsView
self.albumGraphicsView.albumArtDropped.connect( self.albumGraphicsView.albumArtDropped.connect(
@ -777,9 +706,13 @@ if __name__ == "__main__":
handlers = [file_handler, stdout_handler] handlers = [file_handler, stdout_handler]
basicConfig( basicConfig(
level=DEBUG, 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, handlers=handlers,
) )
debug(f'--------- musicpom debug started')
debug(f'---------------------| ')
debug(f'----------------------> {handlers} ')
# Initialization # Initialization
config: ConfigParser = update_config_file() config: ConfigParser = update_config_file()
if not update_database_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 .set_tag import set_tag
from .delete_song_id_from_database import delete_song_id_from_database 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_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 .delete_and_create_library_database import delete_and_create_library_database
from .update_song_in_database import update_song_in_database from .update_song_in_database import update_song_in_database
from .scan_for_music import scan_for_music 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 .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 .set_album_art import set_album_art
from .delete_album_art import delete_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