delete songs from playlist and separate worker thread class
This commit is contained in:
parent
5adb760d83
commit
511b118790
@ -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"
|
||||
|
||||
@ -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"""
|
||||
|
||||
91
main.py
91
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():
|
||||
|
||||
81
utils/Worker.py
Normal file
81
utils/Worker.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
59
utils/batch_delete_filepaths_from_playlist.py
Normal file
59
utils/batch_delete_filepaths_from_playlist.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user