musicpom/components/MusicTable.py
2025-09-28 18:08:23 -04:00

1062 lines
42 KiB
Python

import DBA
from PyQt5.QtGui import (
QColor,
QDragMoveEvent,
QPainter,
QPen,
QStandardItem,
QStandardItemModel,
QKeySequence,
QDragEnterEvent,
QDropEvent,
QResizeEvent,
)
from PyQt5.QtWidgets import (
QAction,
QApplication,
QHeaderView,
QMenu,
QTableView,
QShortcut,
QMessageBox,
QAbstractItemView,
)
from PyQt5.QtCore import (
QItemSelectionModel,
QSortFilterProxyModel,
Qt,
QModelIndex,
pyqtSignal,
)
from components.DebugWindow import DebugWindow
from components.LyricsWindow import LyricsWindow
from components.AddToPlaylistWindow import AddToPlaylistWindow
from components.MetadataWindow import MetadataWindow
from components.QuestionBoxDetails import QuestionBoxDetails
from components.HeaderTags import HeaderTags
from utils import (
batch_delete_filepaths_from_database,
batch_delete_filepaths_from_playlist,
add_files_to_database,
get_reorganize_vars,
update_song_in_database,
id3_remap,
get_tags,
set_tag,
Worker
)
from subprocess import Popen
from logging import debug, error
import os
import shutil
import typing
from pathlib import Path
from appdirs import user_config_dir
from configparser import ConfigParser
class MusicTable(QTableView):
playlistStatsSignal: pyqtSignal = pyqtSignal(str)
loadMusicTableSignal: pyqtSignal = pyqtSignal()
sortSignal: pyqtSignal = pyqtSignal()
playPauseSignal: pyqtSignal = pyqtSignal()
playSignal: pyqtSignal = pyqtSignal(str)
enterKey: pyqtSignal = pyqtSignal()
deleteKey: pyqtSignal = pyqtSignal()
refreshMusicTableSignal: pyqtSignal = pyqtSignal()
handleProgressSignal: pyqtSignal = pyqtSignal(str)
getThreadPoolSignal: pyqtSignal = pyqtSignal()
searchBoxSignal: pyqtSignal = pyqtSignal()
focusEnterSignal: pyqtSignal = pyqtSignal()
focusLeaveSignal: pyqtSignal = pyqtSignal()
def __init__(self, parent=None, application_window=None):
super().__init__(parent)
# why do i need this?
self.application_window = application_window
self.config = ConfigParser()
# Config
cfg_file = (
Path(user_config_dir(appname="musicpom", appauthor="billypom"))
/ "config.ini"
)
_ = self.config.read(cfg_file)
debug(f"music table config: {self.config}")
# NOTE:
# QTableView model2 = QSortFilterProxyModel(QStandardItemModel)
#
# wtf is actually going on here with the models?
# Create QStandardItemModel
# Create QSortFilterProxyModel
# Set QSortFilterProxyModel source to QStandardItemModel
# Set QTableView model to the Proxy model
# so it looks like the above note, i guess
# need a QStandardItemModel to do actions on cells
self.model2: QStandardItemModel = QStandardItemModel()
self.proxymodel: QSortFilterProxyModel = QSortFilterProxyModel()
self.search_string: str | None = None
self.headers = HeaderTags()
# db names of headers
self.database_columns: list[str] = str(
self.config["table"]["columns"]).split(",")
self.vertical_scroll_position = 0
self.selected_song_filepath = ""
self.selected_song_qmodel_index: QModelIndex
self.current_song_filepath = ""
self.current_song_db_id = None
self.current_song_qmodel_index: QModelIndex
self.selected_playlist_id: int | None = None
# proxy model for sorting i guess?
self.proxymodel.setSourceModel(self.model2)
self.proxymodel.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.setModel(self.proxymodel)
self.setSortingEnabled(True)
# Properties
self.setAcceptDrops(True)
self.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setEditTriggers(QAbstractItemView.EditTrigger.EditKeyPressed)
self.setAlternatingRowColors(True)
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
# header
self.horizontal_header: QHeaderView = self.horizontalHeader()
assert self.horizontal_header is not None # i hate look at linting errors
self.horizontal_header.setStretchLastSection(False)
self.horizontal_header.setSectionResizeMode(QHeaderView.Interactive)
self.horizontal_header.sortIndicatorChanged.connect(self.on_sort)
# dumb vertical estupido
self.vertical_header: QHeaderView = self.verticalHeader()
assert self.vertical_header is not None
self.vertical_header.setVisible(False)
# CONNECTIONS
self.clicked.connect(self.on_cell_clicked)
self.deleteKey.connect(self.delete_songs)
self.doubleClicked.connect(self.play_selected_audio_file)
self.enterKey.connect(self.play_selected_audio_file)
self.model2.dataChanged.connect(self.on_cell_data_changed) # editing cells
# self.model2.layoutChanged.connect(self.restore_scroll_position)
self.horizontal_header.sectionResized.connect(self.on_header_resized)
# Final actions
# self.load_music_table()
self.setup_keyboard_shortcuts()
self.load_header_widths()
# _________________
# | |
# | |
# | Built-in Events |
# | |
# |_________________|
def focusInEvent(self, e):
"""
Event filter: when self is focused
"""
self.focusEnterSignal.emit()
# arrow keys act normal
super().focusInEvent(e)
def focusOutEvent(self, e):
"""
Event filter: when self becomes unfocused
"""
self.focusLeaveSignal.emit()
def resizeEvent(self, e: typing.Optional[QResizeEvent]) -> None:
"""Do something when the QTableView is resized"""
if e is None:
raise Exception
super().resizeEvent(e)
def paintEvent(self, e):
"""Override paint event to highlight the current cell"""
# First do the default painting
super().paintEvent(e)
# Check if we have a current cell
current_index = self.currentIndex()
if current_index and current_index.isValid():
# Get the visual rect for the current cell
rect = self.visualRect(current_index)
# Create a painter for custom drawing
with QPainter(self.viewport()) as painter:
# Draw a border around the current cell
pen = QPen(QColor("#4a90e2"), 2) # Blue, 2px width
painter.setPen(pen)
painter.drawRect(rect.adjusted(1, 1, -1, -1))
def contextMenuEvent(self, a0):
"""Right-click context menu"""
menu = QMenu(self)
add_to_playlist_action = QAction("Add to playlist", self)
_ = add_to_playlist_action.triggered.connect(self.add_selected_files_to_playlist)
menu.addAction(add_to_playlist_action)
# edit metadata
edit_metadata_action = QAction("Edit metadata", self)
_ = edit_metadata_action.triggered.connect(self.edit_selected_files_metadata)
menu.addAction(edit_metadata_action)
# edit lyrics
edit_lyrics_action = QAction("Lyrics (View/Edit)", self)
_ = edit_lyrics_action.triggered.connect(self.show_lyrics_menu)
menu.addAction(edit_lyrics_action)
# jump to current song in table
jump_to_current_song_action = QAction("Jump to current song", self)
_ = jump_to_current_song_action.triggered.connect(self.jump_to_current_song)
menu.addAction(jump_to_current_song_action)
# open in file explorer
open_containing_folder_action = QAction(
"Open in system file manager", self)
_ = open_containing_folder_action.triggered.connect(self.open_directory)
menu.addAction(open_containing_folder_action)
# view id3 tags (debug)
view_id3_tags_debug = QAction("View ID3 tags (debug)", self)
_ = view_id3_tags_debug.triggered.connect(self.view_id3_tags_debug_menu)
menu.addAction(view_id3_tags_debug)
# delete song
delete_action = QAction("Delete", self)
_ = delete_action.triggered.connect(self.delete_songs)
menu.addAction(delete_action)
# show
self.set_selected_song_filepath()
if a0 is not None:
menu.exec_(a0.globalPos())
def dragEnterEvent(self, e: QDragEnterEvent | None):
if e is None:
return
data = e.mimeData()
if data and data.hasUrls():
e.accept()
else:
e.ignore()
def dragMoveEvent(self, e: QDragMoveEvent | None):
if e is None:
return
data = e.mimeData()
if data and data.hasUrls():
e.accept()
else:
e.ignore()
def dropEvent(self, e: QDropEvent | None):
if e is None:
return
data = e.mimeData()
debug("dropEvent")
if data and data.hasUrls():
directories: list[str] = []
files: list[str] = []
for url in data.urls():
if url.isLocalFile():
path = url.toLocalFile()
if os.path.isdir(path):
# append 1 directory
directories.append(path)
else:
# append 1 file
files.append(path)
e.accept()
if directories:
worker = Worker(self.get_audio_files_recursively, directories)
_ = worker.signals.signal_progress.connect(self.handle_progress)
_ = worker.signals.signal_result.connect(self.on_get_audio_files_recursively_finished)
_ = worker.signals.signal_finished.connect(self.load_music_table)
if self.qapp:
threadpool = self.qapp.threadpool
threadpool.start(worker)
if files:
self.add_files_to_library(files)
else:
e.ignore()
def keyPressEvent(self, e):
"""Press a key. Do a thing"""
if not e:
return
key = e.key()
if key == Qt.Key.Key_Space:
self.toggle_play_pause()
if key == Qt.Key.Key_Right:
index = self.currentIndex()
new_index = self.model2.index(index.row(), index.column() + 1)
if new_index.isValid():
self.setCurrentIndex(new_index)
self.viewport().update()
super().keyPressEvent(e)
return
elif key == Qt.Key.Key_Left:
index = self.currentIndex()
new_index = self.model2.index(index.row(), index.column() - 1)
if new_index.isValid():
self.setCurrentIndex(new_index)
self.viewport().update()
super().keyPressEvent(e)
return
elif key == Qt.Key.Key_Up:
index = self.currentIndex()
new_index = self.model2.index(index.row() - 1, index.column())
if new_index.isValid():
self.setCurrentIndex(new_index)
self.viewport().update()
super().keyPressEvent(e)
return
elif key == Qt.Key.Key_Down:
index = self.currentIndex()
new_index = self.model2.index(index.row() + 1, index.column())
if new_index.isValid():
self.setCurrentIndex(new_index)
self.viewport().update()
super().keyPressEvent(e)
return
elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if self.state() != QAbstractItemView.EditingState:
self.enterKey.emit()
else:
super().keyPressEvent(e)
else: # Default behavior
super().keyPressEvent(e)
# ____________________
# | |
# | |
# | On action handlers |
# | |
# |____________________|
def find_qmodel_index_by_value(self, model, column: int, value) -> QModelIndex:
for row in range(model.rowCount()):
index = model.index(row, column)
if index.data() == value:
return index
return QModelIndex() # Invalid index if not found
def on_sort(self):
debug("on_sort")
search_col_num = self.headers.user_fields.index("filepath")
selected_qmodel_index = self.find_qmodel_index_by_value(
self.proxymodel, search_col_num, self.selected_song_filepath
)
current_qmodel_index = self.find_qmodel_index_by_value(
self.proxymodel, search_col_num, self.current_song_filepath
)
# Update the 2 QModelIndexes that we track
self.set_selected_song_qmodel_index(selected_qmodel_index)
self.set_current_song_qmodel_index(current_qmodel_index)
self.jump_to_selected_song()
self.sortSignal.emit()
def on_cell_clicked(self, index):
"""
When a cell is clicked, do some stuff :)
- this func also runs when double click happens, fyi
"""
# print(index.row(), index.column())
self.set_selected_song_filepath()
self.set_selected_song_qmodel_index()
self.viewport().update() # type: ignore
def on_header_resized(self, logicalIndex: int, oldSize: int, newSize: int):
"""Handles keeping headers inside the viewport"""
# FIXME: how resize good
pass
# https://stackoverflow.com/questions/46775438/how-to-limit-qheaderview-size-when-resizing-sections
# col_count = self.model2.columnCount()
# qtableview_width = self.size().width()
# sum_of_cols = self.horizontal_header.length()
# debug(f'qtable_width: {qtableview_width}')
# debug(f'sum of cols: {sum_of_cols}')
# if sum_of_cols != qtableview_width: # check for discrepancy
# if logicalIndex < col_count: # if not the last header
# next_header_size = self.horizontal_header.sectionSize(logicalIndex + 1)
# if next_header_size > (sum_of_cols - qtableview_width): # if it should shrink
# self.horizontal_header.resizeSection(
# logicalIndex + 1,
# next_header_size - (sum_of_cols - qtableview_width),
# ) # shrink it
# else:
# self.horizontal_header.resizeSection(logicalIndex, oldSize) # block the resize
def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex):
"""Handles updating ID3 tags when data changes in a cell"""
# if isinstance(self.model2, QStandardItemModel):
debug("on_cell_data_changed")
# get the ID of the row that was edited
id_index = self.model2.index(topLeft.row(), 0)
# get the db song_id from the row
song_id: int = self.model2.data(id_index, Qt.ItemDataRole.UserRole)
user_index = self.headers.user_fields.index("filepath")
filepath = self.currentIndex().siblingAtColumn(user_index).data()
# update the ID3 information
user_input_data: str = topLeft.data()
edited_column_name: str = self.headers.user_fields[topLeft.column()]
debug(f"on_cell_data_changed | edited column name: {edited_column_name}")
response = set_tag(
filepath=filepath,
tag_name=edited_column_name,
value=user_input_data
)
if response:
# Update the library with new metadata
_ = update_song_in_database(
song_id, edited_column_name, user_input_data)
else:
error('ERROR: response failed')
return
def handle_progress(self, data: object):
"""Emits data to main"""
self.handleProgressSignal.emit(data)
def on_get_audio_files_recursively_finished(self, result: list[str]):
"""file search completion handler"""
if result:
self.add_files_to_library(result)
def on_add_files_to_database_finished(self, *args):
"""
Shows failed to import files and reasons
Runs after worker process signal_finished for add_files_to_database()
Args:
- args: ((return_data),)
- data returned from the original worker process function are returned here
as the first item in a tuple
"""
try:
_, details = args[0][:2]
details = dict(tuple(details)[0])
if details:
window = DebugWindow(details)
window.exec_()
except IndexError:
pass
except Exception as e:
debug(
f"on_add_files_to_database_finished() | Something went wrong: {e}")
# ____________________
# | |
# | |
# | Verbs |
# | |
# |____________________|
def set_qmodel_index(self, index: QModelIndex):
self.current_song_qmodel_index = index
def play_selected_audio_file(self):
"""
Sets the current song filepath
Emits a signal that the current song should start playback
"""
self.set_current_song_qmodel_index()
self.set_current_song_filepath()
self.playSignal.emit(self.current_song_filepath)
def add_files_to_library(self, files: list[str]) -> None:
"""
Spawns a worker thread - adds a list of filepaths to the library
- Drag & Drop song(s) on tableView
- File > Open > List of song(s)
"""
debug('add_files_to_library()')
worker = Worker(add_files_to_database, files, None)
_ = worker.signals.signal_progress.connect(self.qapp.handle_progress)
_ = worker.signals.signal_result.connect(self.on_add_files_to_database_finished)
_ = worker.signals.signal_finished.connect(self.load_music_table)
if self.qapp:
threadpool = self.qapp.threadpool
threadpool.start(worker)
else:
error("Application window could not be found")
def add_selected_files_to_playlist(self):
"""Opens a playlist choice menu and adds the currently selected files to the chosen playlist"""
playlist_choice_window = AddToPlaylistWindow(
self.get_selected_songs_db_ids())
playlist_choice_window.exec_()
def delete_songs(self):
"""Asks to delete the currently selected songs from the db and music table (not the filesystem)"""
# NOTE: provide extra questionbox option?
# | Delete from playlist & lib | Delete from playlist only | Cancel |
# Currently, this just deletes from the playlist, or the main lib & any playlists
selected_filepaths = self.get_selected_songs_filepaths()
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):
"""
Removes rows from the QTableView based on a list of indices
and then reload the table
"""
selected_indices = self.get_selected_rows()
self.disconnect_data_changed()
for index in selected_indices:
try:
self.model2.removeRow(index)
except Exception as e:
debug(f"delete_selected_row_indices() failed | {e}")
self.connect_data_changed()
self.load_music_table(self.selected_playlist_id)
def edit_selected_files_metadata(self):
"""Opens a form with metadata from the selected audio files"""
files = self.get_selected_songs_filepaths()
song_ids = self.get_selected_songs_db_ids()
window = MetadataWindow(
self.refreshMusicTableSignal, self.headers, files, song_ids
)
window.refreshMusicTableSignal.connect(self.load_music_table)
window.exec_() # Display the preferences window modally
def jump_to_selected_song(self):
"""Moves screen to the selected song, then selects the row"""
debug("jump_to_selected_song")
# get the proxy model index
proxy_index = self.proxymodel.mapFromSource(
self.selected_song_qmodel_index)
self.scrollTo(proxy_index)
self.selectRow(proxy_index.row())
def jump_to_current_song(self):
"""Moves screen to the currently playing song, then selects the row"""
debug("jump_to_current_song")
# get the proxy model index
debug(self.current_song_filepath)
debug(self.current_song_qmodel_index)
proxy_index = self.proxymodel.mapFromSource(
self.current_song_qmodel_index)
self.scrollTo(proxy_index)
self.selectRow(proxy_index.row())
def open_directory(self):
"""Opens the containing directory of the currently selected song, in the system file manager"""
if self.get_selected_song_filepath() is None:
QMessageBox.warning(
self,
"File does not exist",
"No file is selected, or the file does not exist",
QMessageBox.Ok,
QMessageBox.Ok,
)
return
filepath = self.get_selected_song_filepath().split("/")
filepath.pop()
path = "/".join(filepath)
Popen(["xdg-open", path])
def view_id3_tags_debug_menu(self):
"""Shows ID3 tags for a specific .mp3 file"""
if self.get_selected_song_filepath() is not None:
window = DebugWindow(dict(self.get_selected_song_metadata()))
window.exec_()
def show_lyrics_menu(self):
"""Shows the lyrics for the currently selected song"""
selected_song_filepath = self.get_selected_song_filepath()
if selected_song_filepath is None:
return
dic = id3_remap(get_tags(selected_song_filepath)[0])
lyrics = str(dic["lyrics"])
if not lyrics:
lyrics = ""
lyrics_window = LyricsWindow(selected_song_filepath, lyrics)
lyrics_window.exec_()
def setup_keyboard_shortcuts(self):
"""Setup shortcuts here"""
shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self)
shortcut.activated.connect(self.confirm_reorganize_files)
# Delete key?
shortcut = QShortcut(QKeySequence("Del"), self)
shortcut.activated.connect(self.delete_songs)
# Search box
shortcut = QShortcut(QKeySequence("Ctrl+F"), self)
shortcut.activated.connect(self.emit_search_box)
def emit_search_box(self):
self.searchBoxSignal.emit()
def confirm_reorganize_files(self) -> None:
"""
Ctrl+Shift+R = Reorganize
Asks user to confirm reorganizing files,
then starts a new thread to do the work
"""
filepaths = self.get_selected_songs_filepaths()
# Confirmation screen (yes, no)
reply = QMessageBox.question(
self,
"Confirmation",
"Are you sure you want to reorganize these files?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if reply == QMessageBox.Yes:
worker = Worker(self.reorganize_files, filepaths)
worker.signals.signal_progress.connect(self.handle_progress)
worker.signals.signal_finished.connect(self.load_music_table)
self.qapp.threadpool.start(worker)
def reorganize_files(self, filepaths, progress_callback=None):
"""
Reorganizes files into Artist/Album/Song,
based on self.config['settings'][reorganize_destination']
"""
debug("reorganizing files")
# FIXME: batch update, instead of doing 1 file at a time
# DBAccess is being instantiated for every file, boo
# NOTE:
# is that even possible with move file function?
# FIXME: change reorganize location in config, try to reorganize, failed - old reference
# Get target directory
target_dir = str(self.config["settings"]["reorganize_destination"])
for filepath in filepaths:
# Read file metadata
artist, album = get_reorganize_vars(filepath)
# Determine the new path that needs to be made
new_path = os.path.join(target_dir, artist, album, os.path.basename(filepath))
# Need to determine if filepath is equal
if new_path == filepath:
continue
try:
if progress_callback:
progress_callback.emit(f"Organizing: {filepath}")
# Create the directories if they dont exist
os.makedirs(os.path.dirname(new_path), exist_ok=True)
# Move the file to the new directory
shutil.move(filepath, new_path)
# Update the db
with DBA.DBAccess() as db:
db.query(
"UPDATE song SET filepath = ? WHERE filepath = ?",
(new_path, filepath),
)
# debug(f"reorganize_files() | Moved: {filepath} -> {new_path}")
except Exception as e:
error(
f"reorganize_files() | Error moving file: {filepath} | {e}")
# Draw the rest of the owl
# QMessageBox.information(
# self, "Reorganization complete", "Files successfully reorganized"
# )
def toggle_play_pause(self):
"""Toggles the currently playing song by emitting a Signal"""
if not self.current_song_filepath:
self.set_current_song_qmodel_index()
self.set_current_song_filepath()
self.playPauseSignal.emit()
def load_music_table(self, *playlist_id: int):
"""
Loads data into self (QTableView)
Loads all songs in library, by default
Loads songs from a playlist, if `playlist_id` is given
hint: You get a `playlist_id` from the signal emitted from PlaylistsPane as a tuple (1,)
"""
self.disconnect_data_changed()
self.disconnect_layout_changed()
self.vertical_scroll_position = self.verticalScrollBar().value()
self.model2.clear()
self.model2.setHorizontalHeaderLabels(
self.headers.get_user_gui_headers())
fields = ", ".join(self.headers.user_fields)
search_clause = (
"title LIKE ? OR artist LIKE ? OR album LIKE ?"
if self.search_string
else ""
)
params = ""
# 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 = ?"
# fulltext search
if self.search_string:
# params = 3 * [self.search_string]
params = ["%" + self.search_string + "%"] * 3
if query.find("WHERE") == -1:
query = f"{query} WHERE {search_clause};"
else:
query = f"{query} AND {search_clause};"
data = db.query(
query, (self.selected_playlist_id, params))
else:
data = db.query(query, (self.selected_playlist_id,))
except Exception as e:
error(f"load_music_table() | Unhandled exception 1: {e}")
return
# Load the entire library
else:
self.selected_playlist_id = None
try:
with DBA.DBAccess() as db:
query = f"SELECT id, {fields} FROM song"
# fulltext search
if self.search_string:
params = ["%" + self.search_string + "%"] * 3
if query.find("WHERE") == -1:
query = f"{query} WHERE {search_clause};"
else:
query = f"{query} AND {search_clause};"
data = db.query(
query,
(params),
)
except Exception as e:
error(f"load_music_table() | Unhandled exception 2: {e}")
return
# Populate the model
row_count: int = 0
# TODO: total time of playlist
# but how do i want to do this if user doesn't choose to see length field?
# spawn new thread and calculate myself?
total_time: int = 0 # total time of all songs in seconds
for row_data in data:
# print(row_data)
# if "length" in fields:
row_count += 1
id, *rest_of_data = row_data
# handle different datatypes
items = []
for item in rest_of_data:
if isinstance(item, int):
std_item = QStandardItem()
std_item.setData(item, Qt.ItemDataRole.DisplayRole)
std_item.setData(item, Qt.ItemDataRole.EditRole)
else:
std_item = QStandardItem(str(item) if item else "")
items.append(std_item)
# store database id in the row object using setData
# - useful for fast db fetching and other model operations
for item in items:
item.setData(id, Qt.ItemDataRole.UserRole)
self.model2.appendRow(items)
# reloading the model destroys and makes new indexes
# so we look for the new index of the current song on load
current_song_filepath = self.get_current_song_filepath()
debug(
f"load_music_table() | current filepath: {current_song_filepath}")
for row in range(self.model2.rowCount()):
real_index = self.model2.index(
row, self.headers.user_fields.index("filepath")
)
if real_index.data() == current_song_filepath:
self.current_song_qmodel_index = real_index
self.model2.layoutChanged.emit() # emits a signal that the view should be updated
db_name: str = self.config.get("settings", "db").split("/").pop()
db_filename = self.config.get("settings", "db")
self.playlistStatsSignal.emit(
f"Songs: {row_count} | Total time: {
total_time} | {db_name} | {db_filename}"
)
self.loadMusicTableSignal.emit()
self.connect_data_changed()
self.connect_layout_changed()
def load_header_widths(self):
"""
Loads the header widths from the last application close.
"""
table_view_column_widths = str(
self.config["table"]["column_widths"]).split(",")
debug(f"loaded header widths: {table_view_column_widths}")
if not isinstance(table_view_column_widths, list):
for i in range(self.model2.columnCount() - 1):
self.setColumnWidth(i, int(table_view_column_widths[i]))
def sort_table_by_multiple_columns(self):
"""
Sorts the data in QTableView (self) by multiple columns
as defined in config.ini
"""
# Disconnect these signals to prevent unnecessary reloads
# debug("sort_table_by_multiple_columns()")
# self.disconnect_data_changed()
# self.disconnect_layout_changed()
sort_orders = []
config_sort_orders: list[int] = [
int(x) for x in self.config["table"]["sort_orders"].split(",")
]
for order in config_sort_orders:
if order == 0:
sort_orders.append(None)
elif order == 1:
sort_orders.append(Qt.SortOrder.AscendingOrder)
elif order == 2:
sort_orders.append(Qt.SortOrder.DescendingOrder)
# QTableView sorts need to happen in reverse order
# The primary sort column is the last column sorted.
for i in reversed(range(len(sort_orders))):
if sort_orders[i] is not None:
# debug(f"sorting column {i} by {sort_orders[i]}")
self.sortByColumn(i, sort_orders[i])
# WARNING:
# sortByColumn calls a SELECT statement,
# and will do this for as many sorts that are needed
# maybe not a huge deal for a small music application...?
# `len(config_sort_orders)` number of SELECTs
# self.connect_data_changed()
# self.connect_layout_changed()
# self.model2.layoutChanged.emit()
# TODO: Rewrite this function to use self.load_music_table() with dynamic SQL queries
# in order to sort the data more effectively & have more control over UI refreshes.
def restore_scroll_position(self) -> None:
"""Restores the scroll position"""
debug("restore_scroll_position (inactive)")
# QTimer.singleShot(
# 100,
# lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position),
# )
def get_audio_files_recursively(self, directories: list[str], progress_callback=None) -> list[str]:
"""Scans a directories for files"""
extensions = self.config.get("settings", "extensions").split(",")
audio_files: list[str] = []
for directory in directories:
for root, _, files in os.walk(directory):
for file in files:
if any(file.lower().endswith(ext) for ext in extensions):
audio_files.append(os.path.join(root, file))
if progress_callback:
progress_callback.emit(f"Scanning {file}")
return audio_files
def get_selected_rows(self) -> list[int]:
"""
Returns a list of indexes for every selected row, agnostic of proxy model
This gets the actual index in the root table
"""
selection_model: QItemSelectionModel | None = self.selectionModel()
assert selection_model is not None # begone linter error
return [index.row() for index in selection_model.selectedRows()]
def get_selected_songs_filepaths(self) -> list[str]:
"""
Returns a list of the filepaths for the currently selected songs, based on the proxy model
(because things could be sorted differently)
"""
selected_rows = self.get_selected_rows()
filepaths = []
for row in selected_rows:
idx = self.proxymodel.index(
row, self.headers.user_fields.index("filepath"))
filepaths.append(idx.data())
return filepaths
def get_selected_song_filepath(self) -> str:
"""Returns the selected songs filepath"""
return self.selected_song_filepath
def get_selected_songs_db_ids(self) -> list:
"""Returns a list of id's for the selected songs"""
indexes = self.selectedIndexes()
if not indexes:
return []
selected_rows = set(index.row() for index in indexes)
id_list = [
self.proxymodel.data(
self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole
)
for row in selected_rows
]
return id_list
def get_current_song_filepath(self) -> str:
"""Returns the currently playing song filepath"""
return self.current_song_filepath
def get_current_song_metadata(self) -> dict:
"""Returns the currently playing song's ID3 tags"""
return id3_remap(get_tags(self.current_song_filepath)[0])
def get_selected_song_metadata(self) -> dict:
"""Returns the selected song's ID3 tags"""
return id3_remap(get_tags(self.selected_song_filepath)[0])
def set_selected_song_filepath(self) -> None:
"""Sets the filepath of the currently selected song"""
try:
user_index = self.headers.user_fields.index("filepath")
filepath = self.currentIndex().siblingAtColumn(user_index).data()
except ValueError:
# if the user doesnt have filepath selected as a header, retrieve the file from db
row = self.currentIndex().row()
id = self.proxymodel.data(
self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole
)
with DBA.DBAccess() as db:
filepath = db.query("SELECT filepath FROM song WHERE id = ?", (id,))[0][0]
self.selected_song_filepath = filepath
def set_current_song_filepath(self, filepath=None) -> None:
"""
- Sets the current song filepath to the value in column 'path'
from the current selected row index
"""
# update the filepath
if not filepath:
path = self.current_song_qmodel_index.siblingAtColumn(
self.headers.user_fields.index("filepath")
).data()
self.current_song_filepath: str = path
else:
self.current_song_filepath = filepath
def set_current_song_qmodel_index(self, index: QModelIndex | None = None):
"""
Takes in the proxy model index for current song - QModelIndex
converts to model2 index
stores it
"""
if index is None:
index = self.currentIndex()
# map proxy (sortable) model to the original model (used for interactions)
real_index: QModelIndex = self.proxymodel.mapToSource(index)
self.current_song_qmodel_index = real_index
def set_selected_song_qmodel_index(self, index: QModelIndex | None = None):
"""
Takes in the proxy model index for current song - QModelIndex
converts to model2 index
stores it
"""
if index is None:
index = self.currentIndex()
# map proxy (sortable) model to the original model (used for interactions)
real_index: QModelIndex = self.proxymodel.mapToSource(index)
self.selected_song_qmodel_index = real_index
def set_search_string(self, text: str):
"""set the search string"""
self.search_string = text
def load_qapp(self, qapp: QApplication) -> None:
"""Necessary for using members and methods of main application window"""
self.qapp: QApplication = qapp
# ____________________
# | |
# | |
# | Connection Mgmt |
# | |
# |____________________|
def disconnect_data_changed(self):
"""Disconnects the dataChanged signal from QTableView.model"""
try:
self.model2.dataChanged.disconnect()
except Exception:
pass
def connect_data_changed(self):
"""Connects the dataChanged signal from QTableView.model"""
try:
_ = self.model2.dataChanged.connect(self.on_cell_data_changed)
except Exception:
pass
def disconnect_layout_changed(self):
"""Disconnects the layoutChanged signal from QTableView.model"""
try:
self.model2.layoutChanged.disconnect()
except Exception:
pass
def connect_layout_changed(self):
"""Connects the layoutChanged signal from QTableView.model"""
try:
_ = self.model2.layoutChanged.connect(self.restore_scroll_position)
except Exception:
pass
# QT Roles
# In Qt, roles are used to specify different aspects or types of data associated with each item in a model. The roles are defined in the Qt.ItemDataRole enum. The three roles you asked about - DisplayRole, EditRole, and UserRole - are particularly important. Let's break them down:
#
# Qt.ItemDataRole.DisplayRole:
# This is the default role for displaying data in views. It determines how the data appears visually in the view.
#
# Purpose: To provide the text or image that will be shown in the view.
# Example: In a table view, this is typically the text you see in each cell.
#
#
# Qt.ItemDataRole.EditRole:
# This role is used when the item's data is being edited.
#
# Purpose: To provide the data in a format suitable for editing.
# Example: For a date field, DisplayRole might show "Jan 1, 2023", but EditRole could contain a QDate object or an ISO format string "2023-01-01".
#
#
# Qt.ItemDataRole.UserRole:
# This is a special role that serves as a starting point for custom roles defined by the user.
#
# Purpose: To store additional, custom data associated with an item that doesn't fit into the predefined roles.
# Example: Storing a unique identifier, additional metadata, or any other custom data you want to associate with the item.