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/infotabs.py b/app/infotabs.py
index 1f14f95..a82e359 100644
--- a/app/infotabs.py
+++ b/app/infotabs.py
@@ -22,6 +22,13 @@ class InfoTabs(QTabWidget):
self.last_update: Dict[QWebEngineView, datetime] = {}
self.tabtitles: Dict[int, str] = {}
+ # 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()
+ _ = self.addTab(widget, "")
+
def open_in_songfacts(self, title):
"""Search Songfacts for title"""
diff --git a/app/models.py b/app/models.py
index 3df1f69..739a568 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)
@@ -639,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")
@@ -704,13 +708,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 b5eaad0..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
@@ -1637,7 +1640,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
),
@@ -1784,8 +1787,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("")
@@ -1954,7 +1956,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
diff --git a/app/playlists.py b/app/playlists.py
index 477f598..ee93595 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: 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 +110,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:
@@ -134,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,
@@ -192,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)
@@ -259,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)
@@ -267,13 +276,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)
@@ -1070,6 +1088,31 @@ 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())
+ self._add_context_menu("Undo sort", self._sort_undo, self.sort_undo is None)
+
+ # Build submenu
+
+ # ----------------------
+ self.menu.addSeparator()
+
# Info
if track_row:
self._add_context_menu("Info", lambda: self._info_row(track_id))
@@ -1282,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"""
@@ -1366,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.
"""
@@ -1627,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"""
@@ -1653,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"""
@@ -1751,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:
@@ -1823,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"""
@@ -2027,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:
"""
@@ -2166,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
@@ -2181,6 +2244,106 @@ 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()
+ 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)
+ ):
+ 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
+ """
+
+ if not self._sortable():
+ return
+
+ # Check selection is contiguous
+ selectionModel = self.selectionModel()
+ if not selectionModel:
+ return
+ source_row_numbers = [a.row() for a in selectionModel.selectedRows()]
+ # Copy (row-number, sort-field) to a list
+ 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()
+
+ # 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)
+
+ # 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:
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)