From 15bb83fc500583845997400ad9150e00ba649ecb Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Mon, 10 Feb 2025 08:00:13 +0000 Subject: [PATCH] WIP: query tabs --- app/config.py | 1 + app/musicmuster.py | 6 +- app/playlistmodel.py | 2 +- app/playlists.py | 9 +- app/querylistmodel.py | 562 +++++------------------------- app/querylists.py | 731 +-------------------------------------- app/ui/main_window_ui.py | 4 +- 7 files changed, 111 insertions(+), 1204 deletions(-) diff --git a/app/config.py b/app/config.py index 7344c01..bf98b0f 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,7 @@ class Config(object): COLOUR_NORMAL_TAB = "#000000" COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_ODD_PLAYLIST = "#f2f2f2" + COLOUR_QUERYLIST_CHECKED = "#d3ffd3" COLOUR_UNREADABLE = "#dc3545" COLOUR_WARNING_TIMER = "#ffc107" DBFS_SILENCE = -50 diff --git a/app/musicmuster.py b/app/musicmuster.py index 1da9181..e8d40d6 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -62,7 +62,7 @@ from log import log from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from music_manager import RowAndTrack, track_sequence from playlistmodel import PlaylistModel, PlaylistProxyModel -from querylistmodel import QuerylistModel, QuerylistProxyModel +from querylistmodel import QuerylistModel from playlists import PlaylistTab from querylists import QuerylistTab from ui import icons_rc # noqa F401 @@ -680,11 +680,9 @@ class Window(QMainWindow, Ui_MainWindow): # Create model and proxy model base_model = QuerylistModel(querylist.id) - proxy_model = QuerylistProxyModel() - proxy_model.setSourceModel(base_model) # Create tab - querylist_tab = QuerylistTab(musicmuster=self, model=proxy_model) + querylist_tab = QuerylistTab(musicmuster=self, model=base_model) idx = self.tabPlaylist.addTab(querylist_tab, querylist.name) log.debug(f"create_querylist_tab() returned: {idx=}") diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 53e174f..a075a74 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -497,7 +497,7 @@ class PlaylistModel(QAbstractTableModel): """ if not index.isValid(): - return Qt.ItemFlag.ItemIsDropEnabled + return Qt.ItemFlag.NoItemFlags default = ( Qt.ItemFlag.ItemIsEnabled diff --git a/app/playlists.py b/app/playlists.py index 80cdd5d..192f0ae 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -22,10 +22,7 @@ from PyQt6.QtWidgets import ( QFrame, QMenu, QMessageBox, - QProxyStyle, - QStyle, QStyledItemDelegate, - QStyleOption, QStyleOptionViewItem, QTableView, QTableWidgetItem, @@ -37,7 +34,7 @@ from PyQt6.QtWidgets import ( # App imports from audacity_controller import AudacityController -from classes import ApplicationError, QueryCol, MusicMusterSignals, PlaylistStyle, TrackInfo +from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -112,7 +109,7 @@ class PlaylistDelegate(QStyledItemDelegate): if self.current_editor: editor = self.current_editor else: - if index.column() == QueryCol.INTRO.value: + if index.column() == Col.INTRO.value: editor = QDoubleSpinBox(parent) editor.setDecimals(1) editor.setSingleStep(0.1) @@ -248,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate): self.original_model_data = self.base_model.data( edit_index, Qt.ItemDataRole.EditRole ) - if index.column() == QueryCol.INTRO.value: + if index.column() == Col.INTRO.value: if self.original_model_data.value(): editor.setValue(self.original_model_data.value() / 1000) else: diff --git a/app/querylistmodel.py b/app/querylistmodel.py index a16ac0c..7d42e7a 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -2,21 +2,18 @@ # Allow forward reference to PlaylistModel from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from operator import attrgetter -from random import shuffle -from typing import cast, Optional +from typing import cast import datetime as dt # PyQt imports from PyQt6.QtCore import ( QAbstractTableModel, QModelIndex, - QObject, QRegularExpression, QSortFilterProxyModel, Qt, - QTimer, QVariant, ) from PyQt6.QtGui import ( @@ -26,29 +23,22 @@ from PyQt6.QtGui import ( ) # Third party imports -import line_profiler -import obswebsocket # type: ignore # import snoop # type: ignore # App imports from classes import ( QueryCol, - MusicMusterSignals, ) from config import Config from helpers import ( - ask_yes_no, file_is_unreadable, - get_embedded_time, get_relative_date, ms_to_mmss, - remove_substring_case_insensitive, - set_track_metadata, ) from log import log -from models import db, NoteColours, Playdates, PlaylistRows, Tracks -from music_manager import RowAndTrack, track_sequence +from models import db, Playdates +from music_manager import RowAndTrack @dataclass @@ -82,10 +72,7 @@ class QuerylistModel(QAbstractTableModel): super().__init__() self.querylist_rows: dict[int, QueryRow] = {} - self.signals = MusicMusterSignals() - - self.signals.begin_reset_model_signal.connect(self.begin_reset_model) - self.signals.end_reset_model_signal.connect(self.end_reset_model) + self._selected_rows: set[int] = set() with db.Session() as session: # Populate self.playlist_rows @@ -96,32 +83,27 @@ class QuerylistModel(QAbstractTableModel): f"" ) - def background_role(self, row: int, column: int, qrow: QueryRow) -> QBrush: + def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: """Return background setting""" # Unreadable track file if file_is_unreadable(qrow.path): - return QBrush(QColor(Config.COLOUR_UNREADABLE)) + return QVariant(QColor(Config.COLOUR_UNREADABLE)) + + # Selected row + if row in self._selected_rows: + return QVariant(QColor(Config.COLOUR_QUERYLIST_CHECKED)) # Individual cell colouring if column == QueryCol.BITRATE.value: if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD: - return QBrush(QColor(Config.COLOUR_BITRATE_LOW)) + return QVariant(QColor(Config.COLOUR_BITRATE_LOW)) elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD: - return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM)) + return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM)) else: - return QBrush(QColor(Config.COLOUR_BITRATE_OK)) + return QVariant(QColor(Config.COLOUR_BITRATE_OK)) - return QBrush() - - def begin_reset_model(self, playlist_id: int) -> None: - """ - Reset model if playlist_id is ours - """ - - if playlist_id != self.playlist_id: - return - super().beginResetModel() + return QVariant() def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: """Standard function for view""" @@ -142,10 +124,10 @@ class QuerylistModel(QAbstractTableModel): qrow = self.querylist_rows[row] # Dispatch to role-specific functions - dispatch_table = { + dispatch_table: dict[int, Callable] = { int(Qt.ItemDataRole.BackgroundRole): self.background_role, int(Qt.ItemDataRole.DisplayRole): self.display_role, - int(Qt.ItemDataRole.EditRole): self.edit_role, + int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role, } if role in dispatch_table: @@ -153,8 +135,8 @@ class QuerylistModel(QAbstractTableModel): # Document other roles but don't use them if role in [ - Qt.ItemDataRole.CheckStateRole, Qt.ItemDataRole.DecorationRole, + Qt.ItemDataRole.EditRole, Qt.ItemDataRole.FontRole, Qt.ItemDataRole.ForegroundRole, Qt.ItemDataRole.InitialSortOrderRole, @@ -186,102 +168,60 @@ class QuerylistModel(QAbstractTableModel): return QVariant() - def end_reset_model(self, playlist_id: int) -> None: - """ - End model reset if this is our playlist - """ - - log.debug(f"{self}: end_reset_model({playlist_id=})") - - if playlist_id != self.playlist_id: - log.debug(f"{self}: end_reset_model: not us ({self.playlist_id=})") - return - with db.Session() as session: - self.refresh_data(session) - super().endResetModel() - self.reset_track_sequence_row_numbers() - - def edit_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: - """ - Return text for editing - """ - - if column == QueryCol.TITLE.value: - return QVariant(qrow.title) - if column == QueryCol.ARTIST.value: - return QVariant(qrow.artist) - - return QVariant() - def flags(self, index: QModelIndex) -> Qt.ItemFlag: """ Standard model flags """ - default = ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - ) - if index.column() in [ - QueryCol.TITLE.value, - QueryCol.ARTIST.value, - ]: - return default | Qt.ItemFlag.ItemIsEditable + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags - return default + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - def get_row_info(self, row_number: int) -> RowAndTrack: + def get_selected_track_ids(self) -> list[int]: """ - Return info about passed row + Return a list of track_ids from selected tracks """ - return self.querylist_rows[row_number] + return [self.querylist_rows[row].track_id for row in self._selected_rows] - def get_row_track_id(self, row_number: int) -> Optional[int]: + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> QVariant: """ - Return id of track associated with row or None if no track associated + Return text for headers """ - return self.querylist_rows[row_number].track_id + display_dispatch_table = { + QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE), + QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST), + QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION), + QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED), + QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE), + } - def get_row_track_path(self, row_number: int) -> str: - """ - Return path of track associated with row or empty string if no track associated - """ + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return display_dispatch_table[section] + else: + if Config.ROWS_FROM_ZERO: + return QVariant(str(section)) + else: + return QVariant(str(section + 1)) - return self.querylist_rows[row_number].path + elif role == Qt.ItemDataRole.FontRole: + boldfont = QFont() + boldfont.setBold(True) + return QVariant(boldfont) - def get_rows_duration(self, row_numbers: list[int]) -> int: - """ - Return the total duration of the passed rows - """ - - duration = 0 - for row_number in row_numbers: - duration += self.querylist_rows[row_number].duration - - return duration - - def invalidate_row(self, modified_row: int) -> None: - """ - Signal to view to refresh invalidated row - """ - - self.dataChanged.emit( - self.index(modified_row, 0), - self.index(modified_row, self.columnCount() - 1), - ) - - def invalidate_rows(self, modified_rows: list[int]) -> None: - """ - Signal to view to refresh invlidated rows - """ - - for modified_row in modified_rows: - self.invalidate_row(modified_row) + return QVariant() def load_data( - self, session: db.session, + self, + session: db.session, sql: str = """ SELECT tracks.*,playdates.lastplayed @@ -296,7 +236,7 @@ class QuerylistModel(QAbstractTableModel): MAX(playdates.lastplayed) < DATE_SUB(NOW(), INTERVAL 1 YEAR) ORDER BY tracks.title ; - """ + """, ) -> None: """ Load data from user-defined query. Can probably hard-code the SELECT part @@ -315,380 +255,50 @@ class QuerylistModel(QAbstractTableModel): results = session.execute(text(sql)).mappings().all() for result in results: queryrow = QueryRow( - artist=result['artist'], - bitrate=result['bitrate'], - duration=result['duration'], - lastplayed=result['lastplayed'], - path=result['path'], - title=result['title'], - track_id=result['id'], + artist=result["artist"], + bitrate=result["bitrate"], + duration=result["duration"], + lastplayed=result["lastplayed"], + path=result["path"], + title=result["title"], + track_id=result["id"], ) self.querylist_rows[row] = queryrow row += 1 - - -# # Note where each playlist_id is -# plid_to_row: dict[int, int] = {} -# for oldrow in self.playlist_rows: -# plrdata = self.playlist_rows[oldrow] -# plid_to_row[plrdata.playlistrow_id] = plrdata.row_number - -# # build a new playlist_rows -# new_playlist_rows: dict[int, RowAndTrack] = {} -# for p in PlaylistRows.get_playlist_rows(session, self.playlist_id): -# if p.id not in plid_to_row: -# new_playlist_rows[p.row_number] = RowAndTrack(p) -# else: -# new_playlist_rows[p.row_number] = self.playlist_rows[plid_to_row[p.id]] -# new_playlist_rows[p.row_number].row_number = p.row_number - -# # Copy to self.playlist_rows -# self.playlist_rows = new_playlist_rows - - def move_rows( - self, - from_rows: list[int], - to_row_number: int, - dummy_for_profiling: Optional[int] = None, - ) -> None: - """ - Move the playlist rows given to to_row and below. - """ - - log.debug(f"{self}: move_rows({from_rows=}, {to_row_number=}") - - # Build a {current_row_number: new_row_number} dictionary - row_map: dict[int, int] = {} - - # The destination row number will need to be reduced by the - # number of rows being move from above the destination row - # otherwise rows below the destination row will end up above the - # moved rows. - adjusted_to_row = to_row_number - len( - [a for a in from_rows if a < to_row_number] - ) - - # Put the from_row row numbers into the row_map. Ultimately the - # total number of elements in the playlist doesn't change, so - # check that adding the moved rows starting at to_row won't - # overshoot the end of the playlist. - if adjusted_to_row + len(from_rows) > len(self.querylist_rows): - next_to_row = len(self.querylist_rows) - len(from_rows) - else: - next_to_row = adjusted_to_row - - # zip iterates from_row and to_row simultaneously from the - # respective sequences inside zip() - for from_row, to_row in zip( - from_rows, range(next_to_row, next_to_row + len(from_rows)) - ): - row_map[from_row] = to_row - - # Move the remaining rows to the row_map. We want to fill it - # before (if there are gaps) and after (likewise) the rows that - # are moving. - # zip iterates old_row and new_row simultaneously from the - # respective sequences inside zip() - for old_row, new_row in zip( - [x for x in self.querylist_rows.keys() if x not in from_rows], - [y for y in range(len(self.querylist_rows)) if y not in row_map.values()], - ): - # Optimise: only add to map if there is a change - if old_row != new_row: - row_map[old_row] = new_row - - # For SQLAlchemy, build a list of dictionaries that map playlistrow_id to - # new row number: - sqla_map: list[dict[str, int]] = [] - for oldrow, newrow in row_map.items(): - playlistrow_id = self.querylist_rows[oldrow].playlistrow_id - sqla_map.append({"playlistrow_id": playlistrow_id, "row_number": newrow}) - - with db.Session() as session: - PlaylistRows.update_plr_row_numbers(session, self.playlist_id, sqla_map) - session.commit() - # Update playlist_rows - self.refresh_data(session) - - # Update display - self.reset_track_sequence_row_numbers() - self.invalidate_rows(list(row_map.keys())) - - @line_profiler.profile - def move_rows_between_playlists( - self, - from_rows: list[int], - to_row_number: int, - to_playlist_id: int, - dummy_for_profiling: Optional[int] = None, - ) -> None: - """ - Move the playlist rows given to to_row and below of to_playlist. - """ - - log.debug( - f"{self}: move_rows_between_playlists({from_rows=}, " - f"{to_row_number=}, {to_playlist_id=}" - ) - - # Row removal must be wrapped in beginRemoveRows .. - # endRemoveRows and the row range must be contiguous. Process - # the highest rows first so the lower row numbers are unchanged - row_groups = self._reversed_contiguous_row_groups(from_rows) - - # Prepare destination playlist for a reset - self.signals.begin_reset_model_signal.emit(to_playlist_id) - - with db.Session() as session: - for row_group in row_groups: - # Make room in destination playlist - max_destination_row_number = PlaylistRows.get_last_used_row( - session, to_playlist_id - ) - if ( - max_destination_row_number - and to_row_number <= max_destination_row_number - ): - # Move the destination playlist rows down to make room. - PlaylistRows.move_rows_down( - session, to_playlist_id, to_row_number, len(row_group) - ) - next_to_row = to_row_number - - super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group)) - for playlist_row in PlaylistRows.plrids_to_plrs( - session, - self.playlist_id, - [self.querylist_rows[a].playlistrow_id for a in row_group], - ): - if ( - track_sequence.current - and playlist_row.id == track_sequence.current.playlistrow_id - ): - # Don't move current track - continue - playlist_row.playlist_id = to_playlist_id - playlist_row.row_number = next_to_row - next_to_row += 1 - self.refresh_data(session) - super().endRemoveRows() - # We need to remove gaps in row numbers after tracks have - # moved. - PlaylistRows.fixup_rownumbers(session, self.playlist_id) - self.refresh_data(session) - session.commit() - - # Reset of model must come after session has been closed - self.reset_track_sequence_row_numbers() - self.signals.end_reset_model_signal.emit(to_playlist_id) - - def reset_track_sequence_row_numbers(self) -> None: - """ - Signal handler for when row ordering has changed. - - Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will - be correctly updated with change of row number, but track_sequence.next will still - contain row_number==4. This function fixes up the track_sequence row numbers by - looking up the playlistrow_id and retrieving the row number from the database. - """ - - log.debug(f"{self}: reset_track_sequence_row_numbers()") - - # Check the track_sequence.next, current and previous plrs and - # update the row number - with db.Session() as session: - for ts in [ - track_sequence.next, - track_sequence.current, - track_sequence.previous, - ]: - if ts: - ts.update_playlist_and_row(session) - - def _reversed_contiguous_row_groups( - self, row_numbers: list[int] - ) -> list[list[int]]: - """ - Take the list of row numbers and split into groups of contiguous rows. Return as a list - of lists with the highest row numbers first. - - Example: - input [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21] - return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]] - """ - - log.debug(f"{self}: _reversed_contiguous_row_groups({row_numbers=} called") - - result: list[list[int]] = [] - temp: list[int] = [] - last_value = row_numbers[0] - 1 - - for idx in range(len(row_numbers)): - if row_numbers[idx] != last_value + 1: - result.append(temp) - temp = [] - last_value = row_numbers[idx] - temp.append(last_value) - if temp: - result.append(temp) - result.reverse() - - log.debug(f"{self}: _reversed_contiguous_row_groups() returned: {result=}") - return result - def rowCount(self, index: QModelIndex = QModelIndex()) -> int: """Standard function for view""" return len(self.querylist_rows) - def selection_is_sortable(self, row_numbers: list[int]) -> bool: + def toggle_row_selection(self, row: int) -> None: + if row in self._selected_rows: + self._selected_rows.discard(row) + else: + self._selected_rows.add(row) + + # Emit dataChanged for the entire row + top_left = self.index(row, 0) + bottom_right = self.index(row, self.columnCount() - 1) + self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole]) + + def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: """ - Return True if the selection is sortable. That means: - - at least two rows selected - - selected rows are contiguous + Return tooltip. Currently only used for last_played column. """ - # at least two rows selected - if len(row_numbers) < 2: - return False - - # selected rows are contiguous - if sorted(row_numbers) != list(range(min(row_numbers), max(row_numbers) + 1)): - return False - - return True - - def setData( - self, - index: QModelIndex, - value: str | float, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - """ - Update model with edited data - """ - - if not index.isValid() or role != Qt.ItemDataRole.EditRole: - return False - - row_number = index.row() - column = index.column() - + if column != QueryCol.LAST_PLAYED.value: + return QVariant() with db.Session() as session: - playlist_row = session.get( - PlaylistRows, self.querylist_rows[row_number].playlistrow_id - ) - if not playlist_row: - log.error( - f"{self}: Error saving data: {row_number=}, {column=}, {value=}" + track_id = self.querylist_rows[row].track_id + if not track_id: + return QVariant() + playdates = Playdates.last_playdates(session, track_id) + return QVariant( + "
".join( + [ + a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) + for a in reversed(playdates) + ] ) - return False - - if column in [QueryCol.TITLE.value, QueryCol.ARTIST.value]: - track = session.get(Tracks, playlist_row.track_id) - if not track: - log.error(f"{self}: Error retreiving track: {playlist_row=}") - return False - if column == QueryCol.TITLE.value: - track.title = str(value) - elif column == QueryCol.ARTIST.value: - track.artist = str(value) - else: - log.error(f"{self}: Error updating track: {column=}, {value=}") - return False - - # commit changes before refreshing data - session.commit() - self.refresh_row(session, row_number) - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role]) - - return True - - def sort_by_artist(self, row_numbers: list[int]) -> None: - """ - Sort selected rows by artist - """ - - self.sort_by_attribute(row_numbers, "artist") - - def sort_by_attribute(self, row_numbers: list[int], attr_name: str) -> None: - """ - Sort selected rows by passed attribute name where 'attribute' is a - key in PlaylistRowData - """ - - # Create a subset of playlist_rows with the rows we are - # interested in - shortlist_rows = {k: self.querylist_rows[k] for k in row_numbers} - sorted_list = [ - playlist_row.row_number - for playlist_row in sorted( - shortlist_rows.values(), key=attrgetter(attr_name) ) - ] - self.move_rows(sorted_list, min(sorted_list)) - - def sort_by_duration(self, row_numbers: list[int]) -> None: - """ - Sort selected rows by duration - """ - - self.sort_by_attribute(row_numbers, "duration") - - def sort_by_lastplayed(self, row_numbers: list[int]) -> None: - """ - Sort selected rows by lastplayed - """ - - self.sort_by_attribute(row_numbers, "lastplayed") - - def sort_randomly(self, row_numbers: list[int]) -> None: - """ - Sort selected rows randomly - """ - - shuffle(row_numbers) - self.move_rows(row_numbers, min(row_numbers)) - - def sort_by_title(self, row_numbers: list[int]) -> None: - """ - Sort selected rows by title - """ - - self.sort_by_attribute(row_numbers, "title") - - -class QuerylistProxyModel(QSortFilterProxyModel): - """ - For searching and filtering - """ - - def __init__( - self, - ) -> None: - super().__init__() - - # Search all columns - self.setFilterKeyColumn(-1) - - def __repr__(self) -> str: - return f"" - - def set_incremental_search(self, search_string: str) -> None: - """ - Update search pattern - """ - - self.setFilterRegularExpression( - QRegularExpression( - search_string, QRegularExpression.PatternOption.CaseInsensitiveOption - ) - ) - - def sourceModel(self) -> QuerylistModel: - """ - Override sourceModel to return correct type - """ - - return cast(QuerylistModel, super().sourceModel()) diff --git a/app/querylists.py b/app/querylists.py index 42207a7..3724611 100644 --- a/app/querylists.py +++ b/app/querylists.py @@ -1,55 +1,28 @@ # Standard library imports -from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING +from typing import cast, Optional, TYPE_CHECKING # PyQt imports from PyQt6.QtCore import ( - QAbstractItemModel, - QEvent, - QModelIndex, - QObject, - QItemSelection, - QSize, - Qt, QTimer, ) -from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument from PyQt6.QtWidgets import ( - QAbstractItemDelegate, QAbstractItemView, - QApplication, - QDoubleSpinBox, - QFrame, - QMenu, - QMessageBox, - QProxyStyle, - QStyle, - QStyledItemDelegate, - QStyleOption, - QStyleOptionViewItem, QTableView, - QTableWidgetItem, - QTextEdit, - QWidget, ) # Third party imports -import line_profiler +# import line_profiler # App imports from audacity_controller import AudacityController -from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo +from classes import ApplicationError, MusicMusterSignals, PlaylistStyle from config import Config -from dialogs import TrackSelectDialog from helpers import ( - ask_yes_no, - ms_to_mmss, - show_OK, show_warning, ) from log import log from models import db, Settings -from music_manager import track_sequence -from querylistmodel import QuerylistModel, QuerylistProxyModel +from querylistmodel import QuerylistModel if TYPE_CHECKING: from musicmuster import Window @@ -60,13 +33,13 @@ class QuerylistTab(QTableView): The querylist view """ - def __init__(self, musicmuster: "Window", model: QuerylistProxyModel) -> None: + def __init__(self, musicmuster: "Window", model: QuerylistModel) -> None: super().__init__() # Save passed settings self.musicmuster = musicmuster - self.playlist_id = model.sourceModel().playlist_id + self.playlist_id = model.playlist_id # Set up widget self.setAlternatingRowColors(True) @@ -79,20 +52,17 @@ class QuerylistTab(QTableView): # here means we can click and drag to select rows. self.setDragEnabled(False) - # Prepare for context menu - self.menu = QMenu() - self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self._context_menu) - # Connect signals self.signals = MusicMusterSignals() self.signals.resize_rows_signal.connect(self.resize_rows) - self.signals.span_cells_signal.connect(self._span_cells) # Selection model self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + # Enable item editing for checkboxes + self.clicked.connect(self.handle_row_click) + # Set up for Audacity try: self.ac: Optional[AudacityController] = AudacityController() @@ -122,114 +92,6 @@ class QuerylistTab(QTableView): # ########## Overridden class functions ########## - def closeEditor( - self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint - ) -> None: - """ - Override closeEditor to enable play controls and update display. - """ - - self.musicmuster.action_Clear_selection.setEnabled(True) - - super(PlaylistTab, self).closeEditor(editor, hint) - - # Optimise row heights after increasing row height for editing - self.resize_rows() - - # Update start times in case a start time in a note has been - # edited - self.get_base_model().update_track_times() - - # Deselect edited line - self.clear_selection() - - @line_profiler.profile - def dropEvent( - self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None - ) -> None: - """ - Move dropped rows - """ - - if not event: - return - - if event.source() is not self or ( - event.dropAction() != Qt.DropAction.MoveAction - and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove - ): - return super().dropEvent(event) - - from_rows = self.selected_model_row_numbers() - to_index = self.indexAt(event.position().toPoint()) - - # The drop indicator can either be immediately below a row or - # immediately above a row. There's about a 1 pixel difference, - # but we always want to drop between rows regardless of where - # drop indicator is. - if ( - self.dropIndicatorPosition() - == QAbstractItemView.DropIndicatorPosition.BelowItem - ): - # Drop on the row below - next_row = to_index.row() + 1 - if next_row < self.model().rowCount(): # Ensure the row exists - destination_index = to_index.siblingAtRow(next_row) - else: - # Handle edge case where next_row is beyond the last row - destination_index = to_index - else: - destination_index = to_index - - to_model_row = self.model().mapToSource(destination_index).row() - log.debug( - f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}" - ) - - # Sanity check - base_model_row_count = self.get_base_model().rowCount() - if ( - 0 <= min(from_rows) <= base_model_row_count - and 0 <= to_model_row <= base_model_row_count - ): - # If we move a row to immediately under the current track, make - # that moved row the next track - set_next_row: Optional[int] = None - if ( - track_sequence.current - and to_model_row == track_sequence.current.row_number + 1 - ): - set_next_row = to_model_row - - self.get_base_model().move_rows(from_rows, to_model_row) - - # Reset drag mode to allow row selection by dragging - self.setDragEnabled(False) - - # Deselect rows - self.clear_selection() - - # Resize rows - self.resize_rows() - - # Set next row if we are immediately under current row - if set_next_row: - self.get_base_model().set_next_row(set_next_row) - - event.accept() - - def mouseReleaseEvent(self, event): - """ - Enable dragging if rows are selected - """ - - if self.selectedIndexes(): - self.setDragEnabled(True) - else: - self.setDragEnabled(False) - self.reset() - super().mouseReleaseEvent(event) - def resizeRowToContents(self, row): super().resizeRowToContents(row) self.verticalHeader().resizeSection(row, self.sizeHintForRow(row)) @@ -240,214 +102,7 @@ class QuerylistTab(QTableView): hint = self.sizeHintForRow(row) header.resizeSection(row, hint) - def selectionChanged( - self, selected: QItemSelection, deselected: QItemSelection - ) -> None: - """ - Toggle drag behaviour according to whether rows are selected - """ - - selected_rows = self.get_selected_rows() - self.musicmuster.current.selected_rows = selected_rows - - # If no rows are selected, we have nothing to do - if len(selected_rows) == 0: - self.musicmuster.lblSumPlaytime.setText("") - else: - if not self.musicmuster.disable_selection_timing: - selected_duration = self.get_base_model().get_rows_duration( - self.get_selected_rows() - ) - if selected_duration > 0: - self.musicmuster.lblSumPlaytime.setText( - f"Selected duration: {ms_to_mmss(selected_duration)}" - ) - else: - self.musicmuster.lblSumPlaytime.setText("") - else: - log.debug( - f"playlists.py.selectionChanged: {self.musicmuster.disable_selection_timing=}" - ) - - super().selectionChanged(selected, deselected) - # ########## Custom functions ########## - def _add_context_menu( - self, - text: str, - action: Callable, - disabled: bool = False, - parent_menu: Optional[QMenu] = None, - ) -> Optional[QAction]: - """ - Add item to self.menu - """ - - if parent_menu is None: - parent_menu = self.menu - - menu_item = parent_menu.addAction(text) - if not menu_item: - return None - menu_item.setDisabled(disabled) - menu_item.triggered.connect(action) - - return menu_item - - def _add_track(self) -> None: - """Add a track to a section header making it a normal track row""" - - model_row_number = self.source_model_selected_row_number() - if model_row_number is None: - return - - with db.Session() as session: - dlg = TrackSelectDialog( - parent=self.musicmuster, - session=session, - new_row_number=model_row_number, - base_model=self.get_base_model(), - add_to_header=True, - ) - dlg.exec() - session.commit() - - def _build_context_menu(self, item: QTableWidgetItem) -> None: - """Used to process context (right-click) menu, which is defined here""" - - self.menu.clear() - - index = self.model().index(item.row(), item.column()) - model_row_number = self.model().mapToSource(index).row() - base_model = self.get_base_model() - - header_row = self.get_base_model().is_header_row(model_row_number) - track_row = not header_row - if track_sequence.current: - this_is_current_row = model_row_number == track_sequence.current.row_number - else: - this_is_current_row = False - if track_sequence.next: - this_is_next_row = model_row_number == track_sequence.next.row_number - else: - this_is_next_row = False - track_path = base_model.get_row_info(model_row_number).path - - # Open/import in/from Audacity - if track_row and not this_is_current_row: - if self.ac and track_path == self.ac.path: - # This track was opened in Audacity - self._add_context_menu( - "Update from Audacity", - lambda: self._import_from_audacity(model_row_number), - ) - self._add_context_menu( - "Cancel Audacity", - lambda: self._cancel_audacity(), - ) - else: - self._add_context_menu( - "Open in Audacity", lambda: self._open_in_audacity(model_row_number) - ) - - # Rescan - if track_row and not this_is_current_row: - self._add_context_menu( - "Rescan track", lambda: self._rescan(model_row_number) - ) - self._add_context_menu("Mark for moving", lambda: self._mark_for_moving()) - if self.musicmuster.move_source_rows: - self._add_context_menu( - "Move selected rows here", lambda: self._move_selected_rows() - ) - - # ---------------------- - self.menu.addSeparator() - - # Delete row - if not this_is_current_row and not this_is_next_row: - self._add_context_menu("Delete row", lambda: self._delete_rows()) - - # Remove track from row - if track_row and not this_is_current_row and not this_is_next_row: - self._add_context_menu( - "Remove track from row", - lambda: base_model.remove_track(model_row_number), - ) - - # Remove comments - self._add_context_menu("Remove comments", lambda: self._remove_comments()) - - # Add track to section header (ie, make this a track row) - if header_row: - self._add_context_menu("Add a track", lambda: self._add_track()) - - # # ---------------------- - self.menu.addSeparator() - - # Mark unplayed - if track_row and base_model.is_played_row(model_row_number): - self._add_context_menu( - "Mark unplayed", - lambda: self._mark_as_unplayed(self.get_selected_rows()), - ) - - # Unmark as next - if this_is_next_row: - self._add_context_menu( - "Unmark as next track", lambda: self._unmark_as_next() - ) - - # ---------------------- - self.menu.addSeparator() - - # Sort - sort_menu = self.menu.addMenu("Sort") - self._add_context_menu( - "by title", - lambda: base_model.sort_by_title(self.get_selected_rows()), - parent_menu=sort_menu, - ) - self._add_context_menu( - "by artist", - lambda: base_model.sort_by_artist(self.get_selected_rows()), - parent_menu=sort_menu, - ) - self._add_context_menu( - "by duration", - lambda: base_model.sort_by_duration(self.get_selected_rows()), - parent_menu=sort_menu, - ) - self._add_context_menu( - "by last played", - lambda: base_model.sort_by_lastplayed(self.get_selected_rows()), - parent_menu=sort_menu, - ) - self._add_context_menu( - "randomly", - lambda: base_model.sort_randomly(self.get_selected_rows()), - parent_menu=sort_menu, - ) - - # Info - if track_row: - self._add_context_menu("Info", lambda: self._info_row(model_row_number)) - - # Track path - if track_row: - self._add_context_menu( - "Copy track path", lambda: self._copy_path(model_row_number) - ) - - def _cancel_audacity(self) -> None: - """ - Cancel Audacity editing. We don't do anything with Audacity, just "forget" - that we have an edit open. - """ - - if self.ac: - self.ac.path = None - def clear_selection(self) -> None: """Unselect all tracks and reset drag mode""" @@ -479,234 +134,16 @@ class QuerylistTab(QTableView): record.f_int = self.columnWidth(column_number) session.commit() - def _context_menu(self, pos): - """Display right-click menu""" + def handle_row_click(self, index): + self.model().toggle_row_selection(index.row()) + self.clearSelection() - item = self.indexAt(pos) - self._build_context_menu(item) - self.menu.exec(self.mapToGlobal(pos)) - - def _copy_path(self, row_number: int) -> None: - """ - If passed row_number has a track, copy the track path, single-quoted, - to the clipboard. Otherwise, return None. - """ - - track_path = self.get_base_model().get_row_info(row_number).path - if not track_path: - return - - replacements = [ - ("'", "\\'"), - (" ", "\\ "), - ("(", "\\("), - (")", "\\)"), - ] - for old, new in replacements: - track_path = track_path.replace(old, new) - - cb = QApplication.clipboard() - if cb: - cb.clear(mode=cb.Mode.Clipboard) - cb.setText(track_path, mode=cb.Mode.Clipboard) - - def current_track_started(self) -> None: - """ - Called when track starts playing - """ - - self.get_base_model().current_track_started() - # Scroll to current section if hide mode is by section - if ( - self.musicmuster.hide_played_tracks - and Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS - ): - # Hide section after delay - QTimer.singleShot( - Config.HIDE_AFTER_PLAYING_OFFSET + 100, - lambda: self.hide_played_sections(), - ) - - def _delete_rows(self) -> None: - """ - Delete mutliple rows - - Actions required: - - Confirm deletion should go ahead - - Pass to model to do the deed - """ - - rows_to_delete = self.get_selected_rows() - log.debug(f"_delete_rows({rows_to_delete=}") - row_count = len(rows_to_delete) - if row_count < 1: - return - - # Get confirmation - plural = "s" if row_count > 1 else "" - if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): - return - - base_model = self.get_base_model() - - base_model.delete_rows(self.selected_model_row_numbers()) - self.clear_selection() - - def get_base_model(self) -> QuerylistModel: - """ - Return the base model for this proxy model - """ - - return cast(QuerylistModel, self.model().sourceModel()) - - def get_selected_row_track_info(self) -> Optional[TrackInfo]: - """ - Return the track_id and row number of the selected - row. If no row selected or selected row does not have a track, - return None. - """ - - selected_row = self.get_selected_row() - if selected_row is None: - return None - - base_model = self.get_base_model() - model_row_number = self.source_model_selected_row_number() - - if model_row_number is None: - return None - else: - track_id = base_model.get_row_track_id(model_row_number) - if not track_id: - return None - else: - return TrackInfo(track_id, selected_row) - - def get_selected_row(self) -> Optional[int]: - """ - Return selected row number. If no rows or multiple rows selected, return None - """ - - selected = self.get_selected_rows() - if len(selected) == 1: - return selected[0] - else: - return None - - def get_selected_rows(self) -> List[int]: - """Return a list of model-selected row numbers sorted by row""" - - # Use a set to deduplicate result (a selected row will have all - # items in that row selected) - result = sorted( - list( - set([self.model().mapToSource(a).row() for a in self.selectedIndexes()]) - ) - ) - - log.debug(f"get_selected_rows() returned: {result=}") - return result - - def hide_played_sections(self) -> None: - """ - Scroll played sections off screen - """ - - self.scroll_to_top(self.get_base_model().active_section_header()) - - def _import_from_audacity(self, row_number: int) -> None: - """ - Import current Audacity track to passed row - """ - - if not self.ac: - return - try: - self.ac.export() - self._rescan(row_number) - except ApplicationError as e: - show_warning(self.musicmuster, "Audacity error", str(e)) - self._cancel_audacity() - - def _info_row(self, row_number: int) -> None: - """Display popup with info re row""" - - prd = self.get_base_model().get_row_info(row_number) - if prd: - txt = ( - f"Title: {prd.title}\n" - f"Artist: {prd.artist}\n" - f"Track ID: {prd.track_id}\n" - f"Track duration: {ms_to_mmss(prd.duration)}\n" - f"Track bitrate: {prd.bitrate}\n" - "\n\n" - f"Path: {prd.path}\n" - ) - else: - txt = f"Can't find info about row{row_number}" - - show_OK(self.musicmuster, "Track info", txt) - - def _mark_as_unplayed(self, row_numbers: List[int]) -> None: - """Mark row as unplayed""" - - self.get_base_model().mark_unplayed(row_numbers) - self.clear_selection() - - def _mark_for_moving(self) -> None: - """ - Mark selected rows for pasting - """ - - self.musicmuster.mark_rows_for_moving() - - def model(self) -> QuerylistProxyModel: + def model(self) -> QuerylistModel: """ Override return type to keep mypy happy in this module """ - return cast(QuerylistProxyModel, super().model()) - - def _move_selected_rows(self) -> None: - """ - Move selected rows here - """ - - self.musicmuster.paste_rows() - - def _open_in_audacity(self, row_number: int) -> None: - """ - Open track in passed row in Audacity - """ - - path = self.get_base_model().get_row_track_path(row_number) - if not path: - log.error(f"_open_in_audacity: can't get path for {row_number=}") - return - - try: - if not self.ac: - self.ac = AudacityController() - self.ac.open(path) - except ApplicationError as e: - show_warning(self.musicmuster, "Audacity error", str(e)) - - def _remove_comments(self) -> None: - """ - Remove comments from selected rows - """ - - row_numbers = self.selected_model_row_numbers() - if not row_numbers: - return - - self.get_base_model().remove_comments(row_numbers) - - def _rescan(self, row_number: int) -> None: - """Rescan track""" - - self.get_base_model().rescan_track(row_number) - self.clear_selection() + return cast(QuerylistModel, super().model()) def resize_rows(self, playlist_id: Optional[int] = None) -> None: """ @@ -731,85 +168,6 @@ class QuerylistTab(QTableView): # Start resizing from row 0, 10 rows at a time QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE)) - def scroll_to_top(self, row_number: int) -> None: - """ - Scroll to put passed row_number at the top of the displayed playlist. - """ - - if row_number is None: - return - - row_index = self.model().index(row_number, 0) - self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop) - - def select_duplicate_rows(self) -> None: - """ - Select the last of any rows with duplicate tracks in current playlist. - This allows the selection to typically come towards the end of the playlist away - from any show specific sections. - """ - - # Clear any selected rows to avoid confustion - self.clear_selection() - # We need to be in MultiSelection mode - self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) - # Get the duplicate rows - duplicate_rows = self.get_base_model().get_duplicate_rows() - # Select the rows - for duplicate_row in duplicate_rows: - self.selectRow(duplicate_row) - # Reset selection mode - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - - def source_model_selected_row_number(self) -> Optional[int]: - """ - Return the model row number corresponding to the selected row or None - """ - - selected_index = self._selected_row_index() - if selected_index is None: - return None - return self.model().mapToSource(selected_index).row() - - def selected_model_row_numbers(self) -> List[int]: - """ - Return a list of model row numbers corresponding to the selected rows or - an empty list. - """ - - selected_indexes = self._selected_row_indexes() - if selected_indexes is None: - return [] - - return [self.model().mapToSource(a).row() for a in selected_indexes] - - def _selected_row_index(self) -> Optional[QModelIndex]: - """ - Return the selected row index or None if none selected. - """ - - row_indexes = self._selected_row_indexes() - - if len(row_indexes) > 1: - show_warning( - self.musicmuster, "Multiple rows selected", "Select only one row" - ) - return None - elif not row_indexes: - return None - - return row_indexes[0] - - def _selected_row_indexes(self) -> List[QModelIndex]: - """ - Return a list of indexes of column 0 of selected rows - """ - - sm = self.selectionModel() - if sm and sm.hasSelection(): - return sm.selectedRows() - return [] - def _set_column_widths(self) -> None: """Column widths from settings""" @@ -829,64 +187,7 @@ class QuerylistTab(QTableView): else: self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH) - def set_row_as_next_track(self) -> None: - """ - Set selected row as next track - """ - - model_row_number = self.source_model_selected_row_number() - log.debug(f"set_row_as_next_track() {model_row_number=}") - if model_row_number is None: - return - self.get_base_model().set_next_row(model_row_number) - self.clearSelection() - - def _span_cells( - self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int - ) -> None: - """ - Implement spanning of cells, initiated by signal - - row and column are from the base model so we need to translate - the row into this display row - """ - - if playlist_id != self.playlist_id: - return - - base_model = self.get_base_model() - - cell_index = self.model().mapFromSource(base_model.createIndex(row, column)) - row = cell_index.row() - column = cell_index.column() - - # Don't set spanning if already in place because that is seen as - # a change to the view and thus it refreshes the data which - # again calls us here. - if ( - self.rowSpan(row, column) == rowSpan - and self.columnSpan(row, column) == columnSpan - ): - return - - self.setSpan(row, column, rowSpan, columnSpan) - def tab_live(self) -> None: - """ - Called when tab gets focus - """ + """Noop for query tabs""" - # Update musicmuster - self.musicmuster.current.playlist_id = self.playlist_id - self.musicmuster.current.selected_rows = self.get_selected_rows() - self.musicmuster.current.base_model = self.get_base_model() - self.musicmuster.current.proxy_model = self.model() - - self.resize_rows() - - def _unmark_as_next(self) -> None: - """Rescan track""" - - track_sequence.set_next(None) - self.clear_selection() - self.signals.next_track_changed_signal.emit() + return diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index e24cb3e..58a0728 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -686,5 +686,5 @@ class Ui_MainWindow(object): self.actionImport_files.setText(_translate("MainWindow", "Import files...")) self.actionOpenQuerylist.setText(_translate("MainWindow", "Open &querylist...")) self.actionManage_querylists.setText(_translate("MainWindow", "Manage querylists...")) -from infotabs import InfoTabs -from pyqtgraph import PlotWidget +from infotabs import InfoTabs # type: ignore +from pyqtgraph import PlotWidget # type: ignore