From 6d48bcc9d07a738b01843c752fe9fca9bf749bb5 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 6 Oct 2023 10:52:28 +0100 Subject: [PATCH 1/9] Remove double ampersand in last track header --- app/musicmuster.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index b5eaad0..9427e72 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1784,8 +1784,7 @@ class Window(QMainWindow, Ui_MainWindow): if self.previous_track.title and self.previous_track.artist: self.hdrPreviousTrack.setText( - f"{self.previous_track.title.replace('&', '&&')} - " - f"{self.previous_track.artist.replace('&', '&&')}" + f"{self.previous_track.title} - {self.previous_track.artist}" ) else: self.hdrPreviousTrack.setText("") From b3905e062d612934d07bc44c10d15cd118fe24e3 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 6 Oct 2023 10:58:15 +0100 Subject: [PATCH 2/9] Improve artist search Replicate recent changes in title search to artist search --- app/models.py | 14 ++++++++++++-- app/musicmuster.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/models.py b/app/models.py index 3df1f69..278706f 100644 --- a/app/models.py +++ b/app/models.py @@ -704,13 +704,23 @@ class Tracks(Base): @classmethod def search_artists(cls, session: scoped_session, text: str) -> List["Tracks"]: - """Search case-insenstively for artists containing str""" + """ + Search case-insenstively for artists containing str + + The query performs an outer join with 'joinedload' to populate the results + from the Playdates table at the same time. unique() needed; see + https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading + """ return ( session.execute( - select(cls).where(cls.artist.ilike(f"%{text}%")).order_by(cls.title) + select(cls) + .options(joinedload(Tracks.playdates)) + .where(cls.artist.ilike(f"%{text}%")) + .order_by(cls.title) ) .scalars() + .unique() .all() ) diff --git a/app/musicmuster.py b/app/musicmuster.py index 9427e72..308f92b 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1953,7 +1953,7 @@ class DbDialog(QDialog): if self.ui.radioTitle.isChecked(): matches = Tracks.search_titles(self.session, "%" + s) else: - matches = Tracks.search_artists(self.session, s) + matches = Tracks.search_artists(self.session, "%" + s) if matches: for track in matches: last_played = None From da8272b29b4ebfb58f12cdf6d0dcb950ed386bfb Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 6 Oct 2023 18:04:11 +0100 Subject: [PATCH 3/9] Fix flickering when first marking a track as next Pre-create the infotabs as adding the first one caused the flicker. --- app/infotabs.py | 8 ++++++++ app/musicmuster.py | 2 +- app/ui/main_window.ui | 3 +++ app/ui/main_window_ui.py | 3 ++- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/infotabs.py b/app/infotabs.py index 1f14f95..c216f3d 100644 --- a/app/infotabs.py +++ b/app/infotabs.py @@ -21,6 +21,14 @@ class InfoTabs(QTabWidget): # re-use the oldest one later) self.last_update: Dict[QWebEngineView, datetime] = {} self.tabtitles: Dict[int, str] = {} + count = Config.MAX_INFO_TABS + while count: + widget = QWebEngineView() + widget.setZoomFactor(Config.WEB_ZOOM_FACTOR) + self.last_update[widget] = datetime.now() + tab_index = self.addTab(widget, "") + count -= 1 + def open_in_songfacts(self, title): """Search Songfacts for title""" diff --git a/app/musicmuster.py b/app/musicmuster.py index 308f92b..52d471a 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1637,7 +1637,7 @@ class Window(QMainWindow, Ui_MainWindow): # because it isn't quick if self.next_track.title: QTimer.singleShot( - 1, + 0, lambda: self.tabInfolist.open_in_wikipedia( self.next_track.title ), diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index 94cee40..538a6d6 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -363,6 +363,9 @@ padding-left: 8px; true + + false + diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index b062af1..40adf63 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'app/ui/main_window.ui' # -# Created by: PyQt6 UI code generator 6.5.1 +# Created by: PyQt6 UI code generator 6.5.2 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -183,6 +183,7 @@ class Ui_MainWindow(object): self.tabInfolist.setDocumentMode(False) self.tabInfolist.setTabsClosable(True) self.tabInfolist.setMovable(True) + self.tabInfolist.setTabBarAutoHide(False) self.tabInfolist.setObjectName("tabInfolist") self.gridLayout_4.addWidget(self.splitter, 4, 0, 1, 1) self.InfoFooterFrame = QtWidgets.QFrame(parent=self.centralwidget) From c078fa69e76966a60b9171a4f6bf1d41725bf94e Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 6 Oct 2023 19:10:19 +0100 Subject: [PATCH 4/9] Only create one infotab at initialisation. --- app/infotabs.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/infotabs.py b/app/infotabs.py index c216f3d..aba9280 100644 --- a/app/infotabs.py +++ b/app/infotabs.py @@ -21,13 +21,13 @@ class InfoTabs(QTabWidget): # re-use the oldest one later) self.last_update: Dict[QWebEngineView, datetime] = {} self.tabtitles: Dict[int, str] = {} - count = Config.MAX_INFO_TABS - while count: - widget = QWebEngineView() - widget.setZoomFactor(Config.WEB_ZOOM_FACTOR) - self.last_update[widget] = datetime.now() - tab_index = self.addTab(widget, "") - count -= 1 + + # Create one tab which (for some reason) creates flickering if + # done later + widget = QWebEngineView() + widget.setZoomFactor(Config.WEB_ZOOM_FACTOR) + self.last_update[widget] = datetime.now() + tab_index = self.addTab(widget, "") def open_in_songfacts(self, title): From ee391e42e7e803a2593955d42cdf5aa7627f688d Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 10 Oct 2023 01:27:13 +0100 Subject: [PATCH 5/9] Minor tidying --- app/infotabs.py | 3 +-- app/playlists.py | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/infotabs.py b/app/infotabs.py index aba9280..a82e359 100644 --- a/app/infotabs.py +++ b/app/infotabs.py @@ -27,8 +27,7 @@ class InfoTabs(QTabWidget): widget = QWebEngineView() widget.setZoomFactor(Config.WEB_ZOOM_FACTOR) self.last_update[widget] = datetime.now() - tab_index = self.addTab(widget, "") - + _ = self.addTab(widget, "") def open_in_songfacts(self, title): """Search Songfacts for title""" diff --git a/app/playlists.py b/app/playlists.py index 477f598..5c1de4c 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -92,7 +92,7 @@ class EscapeDelegate(QStyledItemDelegate): super().__init__(parent) def createEditor( - self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex + self, parent: Optional[QWidget], option: QStyleOptionViewItem, index: QModelIndex ): """ Intercept createEditor call and make row just a little bit taller @@ -107,9 +107,12 @@ class EscapeDelegate(QStyledItemDelegate): return QPlainTextEdit(parent) return super().createEditor(parent, option, index) - def eventFilter(self, editor: QObject, event: QEvent) -> bool: + def eventFilter(self, editor: Optional[QObject], event: Optional[QEvent]) -> bool: """By default, QPlainTextEdit doesn't handle enter or return""" + if editor is None or event is None: + return False + if event.type() == QEvent.Type.KeyPress: key_event = cast(QKeyEvent, event) if key_event.key() == Qt.Key.Key_Return: From 8e2edb6af36662d07050fc95642d8de5b72bd845 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 10 Oct 2023 01:27:36 +0100 Subject: [PATCH 6/9] Add sort selection --- app/playlists.py | 115 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/app/playlists.py b/app/playlists.py index 5c1de4c..0a6d20b 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -270,13 +270,22 @@ class PlaylistTab(QTableWidget): self.hide_or_show_played_tracks() def _add_context_menu( - self, text: str, action: Callable, disabled: bool = False - ) -> QAction: + self, + text: str, + action: Callable, + disabled: bool = False, + parent_menu: Optional[QMenu] = None, + ) -> Optional[QAction]: """ Add item to self.menu """ - menu_item = self.menu.addAction(text) + 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) @@ -1073,6 +1082,30 @@ class PlaylistTab(QTableWidget): # ---------------------- self.menu.addSeparator() + # Sort + sort_menu = self.menu.addMenu("Sort") + self._add_context_menu( + "by title", lambda: self._sort_selection(TITLE), parent_menu=sort_menu + ) + self._add_context_menu( + "by artist", lambda: self._sort_selection(ARTIST), parent_menu=sort_menu + ) + self._add_context_menu( + "by duration", lambda: self._sort_selection(DURATION), parent_menu=sort_menu + ) + self._add_context_menu( + "by last played", + lambda: self._sort_selection(LASTPLAYED), + parent_menu=sort_menu, + ) + if sort_menu: + sort_menu.setEnabled(self._sortable()) + + # Build submenu + + # ---------------------- + self.menu.addSeparator() + # Info if track_row: self._add_context_menu("Info", lambda: self._info_row(track_id)) @@ -2184,6 +2217,82 @@ class PlaylistTab(QTableWidget): return item + def _sortable(self) -> bool: + """ + Return True if the selection is sortable. That means: + - at least two rows selected + - selected rows are contiguous + - selected rows do not include any header rows + """ + + selectionModel = self.selectionModel() + if not selectionModel: + return False + source_rows = selectionModel.selectedRows() + sorted_source_rows = sorted([a.row() for a in source_rows]) + if len(sorted_source_rows) < 2: + return False + + if sorted_source_rows != list( + range(min(sorted_source_rows), max(sorted_source_rows) + 1) + ): + return False + + for row in sorted_source_rows: + if self._get_row_track_id(row) == 0: + return False + + return True + + def _sort_selection(self, sort_column: int) -> None: + """ + Algorithm: + - check row selection is contiguous; return if not + - copy (row-number, sort-field) to a list + - sort the list by sort-field + - create a new row after the selection + - iterate the list and move items to new row + - create another new row and repeat until all rows moved + - delete old rows + """ + + # Check selection is contiguous + selectionModel = self.selectionModel() + if not selectionModel: + return + source_rows = selectionModel.selectedRows() + if not self._sortable(): + return + + # Copy (row-number, sort-field) to a list + sorted_rows = [] + for index in source_rows: + sort_item = self.item(index.row(), sort_column) + if sort_item: + sorted_rows.append((index.row(), sort_item.text())) + print(f"{sort_item.text()=}") + + # Sort the list + sorted_rows.sort(key=lambda row: row[1]) + + # Move rows + source_row_numbers = [a[0] for a in sorted_rows] + next_row = max(source_row_numbers) + 1 + for source_row_number in source_row_numbers: + self.insertRow(next_row) + for column in range(self.columnCount()): + self.setItem(next_row, column, self.takeItem(source_row_number, column)) + next_row += 1 + + # Remove source rows + for i in reversed(source_rows): + self.removeRow(i.row()) + + # Save playlist + # with Session() as session: + # self.save_playlist(session) + # self._update_start_end_times(session) + def _track_time_between_rows( self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows ) -> int: From 06e457a3da42d3314111147d077fdc5db3c1b41b Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 10 Oct 2023 01:28:31 +0100 Subject: [PATCH 7/9] Save sorted selection --- app/playlists.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/playlists.py b/app/playlists.py index 0a6d20b..9110b7f 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -2270,7 +2270,6 @@ class PlaylistTab(QTableWidget): sort_item = self.item(index.row(), sort_column) if sort_item: sorted_rows.append((index.row(), sort_item.text())) - print(f"{sort_item.text()=}") # Sort the list sorted_rows.sort(key=lambda row: row[1]) @@ -2289,9 +2288,9 @@ class PlaylistTab(QTableWidget): self.removeRow(i.row()) # Save playlist - # with Session() as session: - # self.save_playlist(session) - # self._update_start_end_times(session) + with Session() as session: + self.save_playlist(session) + self._update_start_end_times(session) def _track_time_between_rows( self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows From 87ab97343944e3e731b7c483adc0df46dac18122 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Thu, 12 Oct 2023 02:43:17 +0100 Subject: [PATCH 8/9] Implement playlist range sort and unsort --- app/config.py | 2 + app/helpers.py | 2 +- app/models.py | 6 +- app/musicmuster.py | 3 + app/playlists.py | 202 ++++++++++++++++++++++++++++----------------- 5 files changed, 138 insertions(+), 77 deletions(-) diff --git a/app/config.py b/app/config.py index 64a67cc..d0cf01a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,4 @@ +import datetime import logging import os from typing import List, Optional @@ -46,6 +47,7 @@ class Config(object): DEBUG_MODULES: List[Optional[str]] = ['dbconfig'] DEFAULT_COLUMN_WIDTH = 200 DISPLAY_SQL = False + EPOCH = datetime.datetime(1970, 1, 1) ERRORS_FROM = ['noreply@midnighthax.com'] ERRORS_TO = ['kae@midnighthax.com'] FADE_CURVE_BACKGROUND = "lightyellow" diff --git a/app/helpers.py b/app/helpers.py index b01d2dc..1f72d85 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -119,7 +119,7 @@ def get_relative_date( @return: string """ - if not past_date: + if not past_date or past_date == Config.EPOCH: return "Never" if not reference_date: reference_date = datetime.now() diff --git a/app/models.py b/app/models.py index 278706f..318ec6d 100644 --- a/app/models.py +++ b/app/models.py @@ -2,6 +2,7 @@ import re +from config import Config from dbconfig import scoped_session from datetime import datetime @@ -153,7 +154,7 @@ class Playdates(Base): session.commit() @staticmethod - def last_played(session: scoped_session, track_id: int) -> Optional[datetime]: + def last_played(session: scoped_session, track_id: int) -> datetime: """Return datetime track last played or None""" last_played = session.execute( @@ -166,7 +167,7 @@ class Playdates(Base): if last_played: return last_played[0] else: - return None + return Config.EPOCH @staticmethod def played_after(session: scoped_session, since: datetime) -> List["Playdates"]: @@ -194,6 +195,7 @@ class Playlists(Base): name: str = Column(String(32), nullable=False, unique=True) last_used = Column(DateTime, default=None, nullable=True) tab = Column(Integer, default=None, nullable=True, unique=True) + # TODO sort_column is unused sort_column = Column(Integer, default=None, nullable=True, unique=False) is_template: bool = Column(Boolean, default=False, nullable=False) query = Column(String(256), default=None, nullable=True, unique=False) diff --git a/app/musicmuster.py b/app/musicmuster.py index 52d471a..0891c27 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1103,6 +1103,9 @@ class Window(QMainWindow, Ui_MainWindow): visible_tab.remove_rows(rows_to_delete) visible_tab.save_playlist(session) + # Disable sort undo + self.sort_undo = None + # Update destination playlist_tab if visible (if not visible, it # will be re-populated when it is opened) destination_playlist_tab = None diff --git a/app/playlists.py b/app/playlists.py index 9110b7f..2b3c26b 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -8,7 +8,7 @@ import obsws_python as obs # type: ignore from collections import namedtuple from datetime import datetime, timedelta -from typing import Callable, cast, List, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING, Union from PyQt6.QtCore import ( QEvent, @@ -92,7 +92,10 @@ class EscapeDelegate(QStyledItemDelegate): super().__init__(parent) def createEditor( - self, parent: Optional[QWidget], option: QStyleOptionViewItem, index: QModelIndex + self, + parent: Optional[QWidget], + option: QStyleOptionViewItem, + index: QModelIndex, ): """ Intercept createEditor call and make row just a little bit taller @@ -137,6 +140,7 @@ class PlaylistTab(QTableWidget): PLAYLISTROW_ID = Qt.ItemDataRole.UserRole + 2 TRACK_PATH = Qt.ItemDataRole.UserRole + 3 PLAYED = Qt.ItemDataRole.UserRole + 4 + ROW_LAST_PLAYED = Qt.ItemDataRole.UserRole + 5 def __init__( self, @@ -195,8 +199,8 @@ class PlaylistTab(QTableWidget): self.viewport().installEventFilter(self) self.search_text: str = "" + self.sort_undo: List[int] = [] self.edit_cell_type: Optional[int] - self.selecting_in_progress = False # Connect signals self.horizontalHeader().sectionResized.connect(self._column_resize) @@ -262,6 +266,8 @@ class PlaylistTab(QTableWidget): # Reset drag mode to allow row selection by dragging self.setDragEnabled(False) + # Disable sort undo + self.sort_undo = None with Session() as session: self.save_playlist(session) @@ -1100,6 +1106,7 @@ class PlaylistTab(QTableWidget): ) if sort_menu: sort_menu.setEnabled(self._sortable()) + self._add_context_menu("Undo sort", self._sort_undo, self.sort_undo is None) # Build submenu @@ -1318,6 +1325,11 @@ class PlaylistTab(QTableWidget): else: return int(duration_udata) + def _get_row_last_played(self, row_number: int) -> Optional[datetime]: + """Return last played datetime associated with this row_number""" + + return self._get_row_userdata(row_number, self.ROW_LAST_PLAYED) + def _get_row_note(self, row_number: int) -> str: """Return note on this row_number or null string if none""" @@ -1402,9 +1414,7 @@ class PlaylistTab(QTableWidget): else: return str(path) - def _get_row_userdata( - self, row_number: int, role: int - ) -> Optional[Union[str, int]]: + def _get_row_userdata(self, row_number: int, role: int) -> Optional[Any]: """ Return the specified userdata, if any. """ @@ -1663,6 +1673,25 @@ class PlaylistTab(QTableWidget): # Set track start/end times after track list is populated self._update_start_end_times(session) + def _reorder_rows(self, source_row_numbers: List[int]) -> None: + """ + Take the list of source row numbers and put those playlist rows in that order. + + Algorithm: create new rows below the source rows and copy source rows in + the correct order. When complete, delete source rows. + """ + + next_row = max(source_row_numbers) + 1 + for source_row_number in source_row_numbers: + self.insertRow(next_row) + for column in range(self.columnCount()): + self.setItem(next_row, column, self.takeItem(source_row_number, column)) + next_row += 1 + + # Remove source rows + for i in reversed(sorted(source_row_numbers)): + self.removeRow(i) + def _rescan(self, row_number: int, track_id: int) -> None: """Rescan track""" @@ -1689,6 +1718,43 @@ class PlaylistTab(QTableWidget): self._update_start_end_times(session) self.clear_selection() + def _reset_next(self, old_plrid: int, new_plrid: int) -> None: + """ + Called when set_next_track_signal signal received. + + Actions required: + - If old_plrid points to this playlist: + - Remove existing next track + - If new_plrid points to this playlist: + - Set track as next + - Display row as next track + - Update start/stop times + """ + + with Session() as session: + # Get plrs + old_plr = new_plr = None + if old_plrid: + old_plr = session.get(PlaylistRows, old_plrid) + + # Unmark next track + if old_plr and old_plr.playlist_id == self.playlist_id: + self._set_row_colour_default(old_plr.plr_rownum) + + # Mark next track + if new_plrid: + new_plr = session.get(PlaylistRows, new_plrid) + if not new_plr: + log.error(f"_reset_next({new_plrid=}): plr not found") + return + if new_plr.playlist_id == self.playlist_id: + self._set_row_colour_next(new_plr.plr_rownum) + + # Update start/stop times + self._update_start_end_times(session) + + self.clear_selection() + def _run_subprocess(self, args): """Run args in subprocess""" @@ -1787,10 +1853,6 @@ class PlaylistTab(QTableWidget): If multiple rows are selected, display sum of durations in status bar. """ - # If we are in the process of selecting multiple tracks, no-op here - if self.selecting_in_progress: - return - selected_rows = self._get_selected_rows() # If no rows are selected, we have nothing to do if len(selected_rows) == 0: @@ -1859,43 +1921,6 @@ class PlaylistTab(QTableWidget): return item - def _reset_next(self, old_plrid: int, new_plrid: int) -> None: - """ - Called when set_next_track_signal signal received. - - Actions required: - - If old_plrid points to this playlist: - - Remove existing next track - - If new_plrid points to this playlist: - - Set track as next - - Display row as next track - - Update start/stop times - """ - - with Session() as session: - # Get plrs - old_plr = new_plr = None - if old_plrid: - old_plr = session.get(PlaylistRows, old_plrid) - - # Unmark next track - if old_plr and old_plr.playlist_id == self.playlist_id: - self._set_row_colour_default(old_plr.plr_rownum) - - # Mark next track - if new_plrid: - new_plr = session.get(PlaylistRows, new_plrid) - if not new_plr: - log.error(f"_reset_next({new_plrid=}): plr not found") - return - if new_plr.playlist_id == self.playlist_id: - self._set_row_colour_next(new_plr.plr_rownum) - - # Update start/stop times - self._update_start_end_times(session) - - self.clear_selection() - def _set_played_row(self, session: scoped_session, row_number: int) -> None: """Mark this row as played""" @@ -2063,13 +2088,15 @@ class PlaylistTab(QTableWidget): self._set_row_colour(row_number, note_colour) def _set_row_last_played_time( - self, row_number: int, last_played: Optional[datetime] + self, row_number: int, last_played: datetime ) -> QTableWidgetItem: - """Set row last played time""" + """Set row last played time. Also set in row metadata""" - last_played_str = get_relative_date(last_played) + self._set_row_userdata(row_number, self.ROW_LAST_PLAYED, last_played) - return self._set_item_text(row_number, LASTPLAYED, last_played_str) + return self._set_item_text( + row_number, LASTPLAYED, get_relative_date(last_played) + ) def _set_row_note_colour(self, session: scoped_session, row_number: int) -> None: """ @@ -2202,7 +2229,7 @@ class PlaylistTab(QTableWidget): return self._set_row_userdata(row_number, self.TRACK_PATH, path) def _set_row_userdata( - self, row_number: int, role: int, value: Optional[Union[str, int]] + self, row_number: int, role: int, value: Any ) -> QTableWidgetItem: """ Set passed userdata in USERDATA column @@ -2229,10 +2256,10 @@ class PlaylistTab(QTableWidget): if not selectionModel: return False source_rows = selectionModel.selectedRows() - sorted_source_rows = sorted([a.row() for a in source_rows]) - if len(sorted_source_rows) < 2: + if len(source_rows) < 2: return False + sorted_source_rows = sorted([a.row() for a in source_rows]) if sorted_source_rows != list( range(min(sorted_source_rows), max(sorted_source_rows) + 1) ): @@ -2256,42 +2283,69 @@ class PlaylistTab(QTableWidget): - delete old rows """ + if not self._sortable(): + return + # Check selection is contiguous selectionModel = self.selectionModel() if not selectionModel: return - source_rows = selectionModel.selectedRows() - if not self._sortable(): - return - + source_row_numbers = [a.row() for a in selectionModel.selectedRows()] # Copy (row-number, sort-field) to a list - sorted_rows = [] - for index in source_rows: - sort_item = self.item(index.row(), sort_column) - if sort_item: - sorted_rows.append((index.row(), sort_item.text())) + sorted_rows: List[tuple[int, Any]] = [] + for row in source_row_numbers: + if sort_column == DURATION: + sorted_rows.append((row, self._get_row_duration(row))) + elif sort_column == LASTPLAYED: + sorted_rows.append((row, self._get_row_last_played(row))) + else: + sort_item = self.item(row, sort_column) + if sort_item: + sorted_rows.append((row, sort_item.text())) + else: + sorted_rows.append((row, None)) # Sort the list sorted_rows.sort(key=lambda row: row[1]) + if sort_column == LASTPLAYED: + sorted_rows.reverse() - # Move rows - source_row_numbers = [a[0] for a in sorted_rows] - next_row = max(source_row_numbers) + 1 - for source_row_number in source_row_numbers: - self.insertRow(next_row) - for column in range(self.columnCount()): - self.setItem(next_row, column, self.takeItem(source_row_number, column)) - next_row += 1 + # Reorder rows + new_order = [a[0] for a in sorted_rows] + self.sort_undo = [ + new_order.index(x) + min(new_order) + for x in range(min(new_order), max(new_order) + 1) + ] + self._reorder_rows(new_order) - # Remove source rows - for i in reversed(source_rows): - self.removeRow(i.row()) + # Reset drag mode to allow row selection by dragging + self.setDragEnabled(False) # Save playlist with Session() as session: self.save_playlist(session) self._update_start_end_times(session) + def _sort_undo(self): + """Undo last sort""" + + if not self.sort_undo: + return + + new_order = self.sort_undo + + self._reorder_rows(new_order) + + self.sort_undo = [ + new_order.index(x) + min(new_order) + for x in range(min(new_order), max(new_order) + 1) + ] + + + # Reset drag mode to allow row selection by dragging + self.setDragEnabled(False) + + def _track_time_between_rows( self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows ) -> int: From c8a7ae7f734abc04bac63f33a7c058fcbb92eff8 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Thu, 12 Oct 2023 08:55:26 +0100 Subject: [PATCH 9/9] Black formatting --- app/models.py | 4 +++- app/playlists.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models.py b/app/models.py index 318ec6d..739a568 100644 --- a/app/models.py +++ b/app/models.py @@ -641,7 +641,9 @@ class Tracks(Base): path: str = Column(String(2048), index=False, nullable=False, unique=True) mtime = Column(Float, index=True) bitrate = Column(Integer, nullable=True, default=None) - playlistrows: List[PlaylistRows] = relationship("PlaylistRows", back_populates="track") + playlistrows: List[PlaylistRows] = relationship( + "PlaylistRows", back_populates="track" + ) playlists = association_proxy("playlistrows", "playlist") playdates: List[Playdates] = relationship("Playdates", back_populates="track") diff --git a/app/playlists.py b/app/playlists.py index 2b3c26b..ee93595 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -2340,12 +2340,10 @@ class PlaylistTab(QTableWidget): new_order.index(x) + min(new_order) for x in range(min(new_order), max(new_order) + 1) ] - # Reset drag mode to allow row selection by dragging self.setDragEnabled(False) - def _track_time_between_rows( self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows ) -> int: