multithreading working with add files to library

This commit is contained in:
tsi-billypom 2024-09-06 11:41:29 -04:00
parent 3bd2c539cf
commit d351f3515e
7 changed files with 122 additions and 154 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
/references
/db
config.ini
log
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -31,6 +31,7 @@ from components.AddToPlaylistWindow import AddToPlaylistWindow
from components.MetadataWindow import MetadataWindow
# from main import WorkerThread
from main import Worker
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
@ -45,29 +46,6 @@ import os
import shutil
class DropAddFilesThread(QThread):
signalStarted = pyqtSignal()
signalProgress = pyqtSignal(str)
signalFinished = pyqtSignal()
def __init__(self, files, parent=None):
QThread.__init__(self, parent)
self.files = files
def run(self) -> None:
self.add_files()
return
def add_files(self) -> None:
"""When song(s) added to the library, update the tableview model
- Drag & Drop song(s) on tableView
- File > Open > List of song(s)
"""
add_files_to_library(self.files)
self.signalFinished.emit()
return
class MusicTable(QTableView):
playPauseSignal = pyqtSignal()
enterKey = pyqtSignal()
@ -191,7 +169,7 @@ class MusicTable(QTableView):
try:
model.removeRow(index)
except Exception as e:
logging.info(f"MusicTable.py delete_songs() failed | {e}")
logging.info(f" delete_songs() failed | {e}")
self.load_music_table()
self.model.dataChanged.connect(self.on_cell_data_changed)
@ -237,7 +215,7 @@ class MusicTable(QTableView):
else:
raise RuntimeError("No USLT tags found in song metadata")
except Exception as e:
print(f"MusicTable.py | show_lyrics_menu | could not retrieve lyrics | {e}")
logging.error(f"show_lyrics_menu() | could not retrieve lyrics | {e}")
lyrics = ""
lyrics_window = LyricsWindow(selected_song_filepath, lyrics)
lyrics_window.exec_()
@ -261,6 +239,7 @@ class MusicTable(QTableView):
e.ignore()
def dropEvent(self, e: QDropEvent | None):
self.model.dataChanged.disconnect(self.on_cell_data_changed)
if e is None:
return
data = e.mimeData()
@ -270,14 +249,13 @@ class MusicTable(QTableView):
if url.isLocalFile():
files.append(url.path())
e.accept()
self.worker = DropAddFilesThread(files=files)
# self.model.dataChanged.disconnect(self.on_cell_data_changed)
# self.model.dataChanged.connect(self.on_cell_data_changed)
self.worker.signalFinished.connect(self.load_music_table)
self.worker.start()
# self.add_files(files)
worker = Worker(add_files_to_library, files)
worker.signals.signal_progress.connect(self.handle_progress)
worker.signals.signal_finished.connect(self.load_music_table)
self.threadpool.start(worker)
else:
e.ignore()
self.model.dataChanged.connect(self.on_cell_data_changed)
def keyPressEvent(self, e):
"""Press a key. Do a thing"""
@ -315,7 +293,7 @@ class MusicTable(QTableView):
def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex):
"""Handles updating ID3 tags when data changes in a cell"""
print("on_cell_data_changed")
logging.info("on_cell_data_changed")
id_index = self.model.index(topLeft.row(), 0) # ID is column 0, always
song_id = self.model.data(id_index, Qt.UserRole)
# filepath is always the last column
@ -326,7 +304,7 @@ class MusicTable(QTableView):
# update the ID3 information
user_input_data = topLeft.data()
edited_column_name = self.database_columns[topLeft.column()]
print(f"edited column name: {edited_column_name}")
logging.info(f"edited column name: {edited_column_name}")
response = set_id3_tag(filepath, edited_column_name, user_input_data)
if response:
# Update the library with new metadata
@ -366,13 +344,12 @@ class MusicTable(QTableView):
"UPDATE song SET filepath = ? WHERE filepath = ?",
(new_path, filepath),
)
print(f"Moved: {filepath} -> {new_path}")
logging.info(
f"reorganize_selected_files() | Moved: {filepath} -> {new_path}"
)
except Exception as e:
logging.warning(
f"MusicTable.py reorganize_selected_files() | Error moving file: {filepath} | {e}"
)
print(
f"MusicTable.py reorganize_selected_files() | Error moving file: {filepath} | {e}"
f"reorganize_selected_files() | Error moving file: {filepath} | {e}"
)
# Draw the rest of the owl
# self.model.dataChanged.disconnect(self.on_cell_data_changed)
@ -388,6 +365,14 @@ class MusicTable(QTableView):
self.set_current_song_filepath()
self.playPauseSignal.emit()
def add_files(self, files, progress_callback) -> None:
"""When song(s) added to the library, update the tableview model
- Drag & Drop song(s) on tableView
- File > Open > List of song(s)
"""
add_files_to_library(files, progress_callback)
return
def load_music_table(self, *playlist_id):
"""
Loads data into self (QTableView)
@ -401,8 +386,8 @@ class MusicTable(QTableView):
# then re-enable after we are done loading
self.model.dataChanged.disconnect(self.on_cell_data_changed)
except Exception as e:
print(
f"MusicTable.py load_music_table() | could not disconnect on_cell_data_changed trigger: {e}"
logging.info(
f"load_music_table() | could not disconnect on_cell_data_changed trigger: {e}"
)
pass
self.vertical_scroll_position = (
@ -413,7 +398,9 @@ class MusicTable(QTableView):
self.model.setHorizontalHeaderLabels(self.table_headers)
if playlist_id:
selected_playlist_id = playlist_id[0]
print(f"selected_playlist_id: {selected_playlist_id}")
logging.info(
f"load_music_table() | selected_playlist_id: {selected_playlist_id}"
)
# Fetch playlist data
try:
with DBA.DBAccess() as db:
@ -422,10 +409,7 @@ class MusicTable(QTableView):
(selected_playlist_id,),
)
except Exception as e:
logging.warning(
f"MusicTable.py | load_music_table | Unhandled exception: {e}"
)
print(f"MusicTable.py | load_music_table | Unhandled exception: {e}")
logging.warning(f"load_music_table() | Unhandled exception: {e}")
return
else:
# Fetch library data
@ -436,9 +420,7 @@ class MusicTable(QTableView):
(),
)
except Exception as e:
logging.warning(
f"MusicTable.py | load_music_table | Unhandled exception: {e}"
)
logging.warning(f"load_music_table() | Unhandled exception: {e}")
return
# Populate the model
for row_data in data:
@ -458,7 +440,7 @@ class MusicTable(QTableView):
def restore_scroll_position(self) -> None:
"""Restores the scroll position"""
print("restore_scroll_position")
logging.info("restore_scroll_position")
QTimer.singleShot(
100,
lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position),
@ -520,7 +502,7 @@ class MusicTable(QTableView):
self.selected_song_filepath = (
self.currentIndex().siblingAtColumn(self.table_headers.index("path")).data()
)
print(self.selected_song_filepath)
logging.info(self.selected_song_filepath)
def set_current_song_filepath(self) -> None:
"""Sets the filepath of the currently playing song"""

140
main.py
View File

@ -9,6 +9,7 @@ from pyqtgraph import mkBrush
from mutagen.id3 import ID3
from mutagen.id3._frames import APIC
from configparser import ConfigParser
import traceback
import DBA
from ui import Ui_MainWindow
from PyQt5.QtWidgets import (
@ -28,6 +29,7 @@ from PyQt5.QtCore import (
Qt,
pyqtSignal,
QObject,
QThread,
pyqtSlot,
QThreadPool,
QRunnable,
@ -35,7 +37,12 @@ from PyQt5.QtCore import (
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 utils import (
scan_for_music,
delete_and_create_library_database,
initialize_db,
add_files_to_library,
)
from components import (
PreferencesWindow,
AudioVisualizer,
@ -49,6 +56,11 @@ from components import (
# 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
# GOOD
# 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
@ -67,31 +79,52 @@ class WorkerSignals(QObject):
# 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_started = pyqtSignal()
signal_finished = pyqtSignal()
signal_progress = pyqtSignal(str)
class WorkerThread(QRunnable):
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, worker_id: UUID) -> None:
super().__init__()
# allow for signals to be used
self.worker_id = worker_id
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):
"""
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("start worker")
self.signals.signal_progress.emit("worker progress")
self.signals.signal_finished.emit(self.worker_id)
self.signals.signal_started.emit()
try:
self.fn(*self.args, **self.kwargs)
except Exception as e:
traceback.print_exc()
exctype, value = sys.exc_info()[:2]
self.signals.signal_finished.emit((exctype, value, traceback.format_exc()))
finally:
self.signals.signal_finished.emit()
class ApplicationWindow(QMainWindow, Ui_MainWindow):
@ -122,7 +155,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.audio_visualizer: AudioVisualizer = AudioVisualizer(self.player)
self.current_volume: int = 50
self.qapp = qapp
# print(f'ApplicationWindow self.qapp: {self.qapp}')
self.tableView.load_qapp(self.qapp)
self.albumGraphicsView.load_qapp(self.qapp)
self.config.read("config.ini")
@ -154,7 +186,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.playbackSlider.sliderReleased.connect(
lambda: self.player.setPosition(self.playbackSlider.value())
) # maybe sliderReleased works better than sliderMoved
self
self.volumeSlider.sliderMoved[int].connect(
lambda: self.volume_changed()
) # Move slider to adjust volume
@ -213,45 +244,6 @@ 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
@ -355,7 +347,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
"""Sets the ID3 tag APIC (album art) for all selected song filepaths"""
selected_songs = self.tableView.get_selected_songs_filepaths()
for song in selected_songs:
print(f"updating album art for {song}")
logging.info(
f"main.py set_album_art_for_selected_songs() | updating album art for {song}"
)
self.update_album_art_for_song(song, album_art_path)
def update_album_art_for_song(
@ -401,7 +395,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
del audio["APIC"]
audio.save()
except Exception as e:
print(f"Error processing {file}: {e}")
logging.error(
f"main.py delete_album_art_for_selected_songs() | Error processing {file}: {e}"
)
def update_audio_visualization(self) -> None:
"""Handles upading points on the pyqtgraph visual"""
@ -445,7 +441,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.current_volume = self.volumeSlider.value()
self.player.setVolume(self.current_volume)
except Exception as e:
print(f"Changing volume error: {e}")
logging.error(f"main.py volume_changed() | Changing volume error: {e}")
def on_play_clicked(self) -> None:
"""Updates the Play & Pause buttons when clicked"""
@ -462,17 +458,20 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def on_previous_clicked(self) -> None:
""""""
print("previous")
logging.info("main.py on_previous_clicked()")
def on_next_clicked(self) -> None:
print("next")
logging.info("main.py on_next_clicked()")
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"""
"""
Opens the open files window
- File > Open > List of song(s)
"""
open_files_window = QFileDialog(
self, "Open file(s)", ".", "Audio files (*.mp3)"
)
@ -480,7 +479,17 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
open_files_window.setFileMode(QFileDialog.ExistingFiles)
open_files_window.exec_()
filenames = open_files_window.selectedFiles()
self.tableView.add_files(filenames)
# Adds files to the library in a new thread
worker = Worker(add_files_to_library, filenames)
worker.signals.signal_progress.connect(self.handle_progress)
worker.signals.signal_finished.connect(self.tableView.load_music_table)
self.threadpool.start(worker)
def handle_progress(self, data):
"""
updates the status bar when progress is emitted
"""
self.show_status_bar_message(data)
# File
@ -584,16 +593,23 @@ if __name__ == "__main__":
with open("utils/init.sql", "r") as file:
lines = file.read()
for statement in lines.split(";"):
print(f"executing [{statement}]")
logging.info(f"executing [{statement}]")
db.execute(statement, ())
# logging setup
logging.basicConfig(filename="musicpom.log", encoding="utf-8", level=logging.DEBUG)
file_handler = logging.FileHandler(filename="log", encoding="utf-8")
stdout_handler = logging.StreamHandler(stream=sys.stdout)
handlers = [file_handler, stdout_handler]
# logging.basicConfig(filename="log", encoding="utf-8", level=logging.DEBUG)
logging.basicConfig(
level=logging.DEBUG,
format="[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s",
handlers=handlers,
)
# Allow for dynamic imports of my custom classes and utilities
project_root = os.path.abspath(os.path.dirname(__file__))
sys.path.append(project_root)
# Start the app
app = QApplication(sys.argv)
# print(f"main.py app: {app}")
# Dark theme >:3
qdarktheme.setup_theme()
# Show the UI

View File

@ -1,6 +1,7 @@
import sys
from mutagen.id3 import ID3, APIC
import os
import logging
def print_id3_tags(file_path):

View File

@ -8,19 +8,20 @@ config = ConfigParser()
config.read("config.ini")
def add_files_to_library(files):
def add_files_to_library(files, progress_callback):
"""Adds audio file(s) to the sqllite db
files = list() of fully qualified paths to audio file(s)
Returns a list of dictionaries of metadata
"""
# print("Running add_files_to_library.py")
logging.info("started function")
if not files:
return []
extensions = config.get("settings", "extensions").split(",")
insert_data = [] # To store data for batch insert
for filepath in files:
if any(filepath.lower().endswith(ext) for ext in extensions):
if progress_callback:
progress_callback.emit(filepath)
filename = filepath.split("/")[-1]
audio = get_id3_tags(filepath)

View File

@ -1,40 +1,11 @@
# from mutagen.id3 import ID3
# from mutagen.id3._frames import TIT2
# import os
#
#
# def get_id3_tags(filename):
# """Get the ID3 tags for an audio file
#
# # Parameters
# `file` | str | Fully qualified path to file
#
# # Returns
# dict of all id3 tags
# at minimum we will get the filename as a title.[ID3:TIT2]
# """
#
# # Check if all tags are empty
# # tags_are_empty = all(not values for values in audio.values())
# audio = ID3()
# try:
# if not audio["TIT2"] or audio["TIT2"].text[0] == "":
# title = os.path.splitext(os.path.basename(filename))[0]
# frame = TIT2(encoding=3, text=[title])
# audio["TIT2"] = frame
# audio.save() # type: ignore
# except Exception as e:
# print(f"get_id3_tags.py | Could not assign file ID3 tag: {e}")
# return audio
import os
import logging
from mutagen.id3 import ID3, TIT2, ID3NoHeaderError
def get_id3_tags(filename):
"""Get the ID3 tags for an audio file"""
print(f"get id3 tags filename: {filename}")
logging.info(f"filename: {filename}")
try:
# Open the MP3 file and read its content
@ -59,6 +30,6 @@ def get_id3_tags(filename):
audio.save()
except Exception as e:
print(f"get_id3_tags.py | Could not assign file ID3 tag: {e}")
logging.error(f"Could not assign file ID3 tag: {e}")
return audio

View File

@ -92,9 +92,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str):
Returns:
True / False"""
print(
f"set_id3_tag.py | filepath: {filepath} | tag_name: {tag_name} | value: {value}"
)
logging.info(f"filepath: {filepath} | tag_name: {tag_name} | value: {value}")
try:
try: # Load existing tags
@ -114,9 +112,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str):
try:
audio = ID3(filepath)
except Exception as e:
logging.debug(
f"set_id3_tag.py set_id3_tag() ran into an exception: {e}"
)
logging.error(f"ran into an exception: {e}")
audio = ID3()
audio.delall("USLT")
frame = USLT(encoding=3, text=value)
@ -128,7 +124,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str):
tag_name = id3_tag_mapping[tag_name]
# Other
if tag_name in mutagen_id3_tag_mapping: # Tag accounted for
print(f"set_id3_tag.py | tag_name = {tag_name}")
logging.info(f"tag_name = {tag_name}")
tag_class = mutagen_id3_tag_mapping[tag_name]
if issubclass(tag_class, Frame):
frame = tag_class(encoding=3, text=[value])