Merge branch 'dev'
This commit is contained in:
commit
4c3dfd2a53
@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@ -46,6 +47,7 @@ class Config(object):
|
|||||||
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
|
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
|
||||||
DEFAULT_COLUMN_WIDTH = 200
|
DEFAULT_COLUMN_WIDTH = 200
|
||||||
DISPLAY_SQL = False
|
DISPLAY_SQL = False
|
||||||
|
EPOCH = datetime.datetime(1970, 1, 1)
|
||||||
ERRORS_FROM = ['noreply@midnighthax.com']
|
ERRORS_FROM = ['noreply@midnighthax.com']
|
||||||
ERRORS_TO = ['kae@midnighthax.com']
|
ERRORS_TO = ['kae@midnighthax.com']
|
||||||
FADE_CURVE_BACKGROUND = "lightyellow"
|
FADE_CURVE_BACKGROUND = "lightyellow"
|
||||||
|
|||||||
@ -119,7 +119,7 @@ def get_relative_date(
|
|||||||
@return: string
|
@return: string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not past_date:
|
if not past_date or past_date == Config.EPOCH:
|
||||||
return "Never"
|
return "Never"
|
||||||
if not reference_date:
|
if not reference_date:
|
||||||
reference_date = datetime.now()
|
reference_date = datetime.now()
|
||||||
|
|||||||
@ -22,6 +22,13 @@ class InfoTabs(QTabWidget):
|
|||||||
self.last_update: Dict[QWebEngineView, datetime] = {}
|
self.last_update: Dict[QWebEngineView, datetime] = {}
|
||||||
self.tabtitles: Dict[int, str] = {}
|
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):
|
def open_in_songfacts(self, title):
|
||||||
"""Search Songfacts for title"""
|
"""Search Songfacts for title"""
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from config import Config
|
||||||
from dbconfig import scoped_session
|
from dbconfig import scoped_session
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -153,7 +154,7 @@ class Playdates(Base):
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@staticmethod
|
@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"""
|
"""Return datetime track last played or None"""
|
||||||
|
|
||||||
last_played = session.execute(
|
last_played = session.execute(
|
||||||
@ -166,7 +167,7 @@ class Playdates(Base):
|
|||||||
if last_played:
|
if last_played:
|
||||||
return last_played[0]
|
return last_played[0]
|
||||||
else:
|
else:
|
||||||
return None
|
return Config.EPOCH
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def played_after(session: scoped_session, since: datetime) -> List["Playdates"]:
|
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)
|
name: str = Column(String(32), nullable=False, unique=True)
|
||||||
last_used = Column(DateTime, default=None, nullable=True)
|
last_used = Column(DateTime, default=None, nullable=True)
|
||||||
tab = Column(Integer, default=None, nullable=True, unique=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)
|
sort_column = Column(Integer, default=None, nullable=True, unique=False)
|
||||||
is_template: bool = Column(Boolean, default=False, nullable=False)
|
is_template: bool = Column(Boolean, default=False, nullable=False)
|
||||||
query = Column(String(256), default=None, nullable=True, unique=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)
|
path: str = Column(String(2048), index=False, nullable=False, unique=True)
|
||||||
mtime = Column(Float, index=True)
|
mtime = Column(Float, index=True)
|
||||||
bitrate = Column(Integer, nullable=True, default=None)
|
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")
|
playlists = association_proxy("playlistrows", "playlist")
|
||||||
playdates: List[Playdates] = relationship("Playdates", back_populates="track")
|
playdates: List[Playdates] = relationship("Playdates", back_populates="track")
|
||||||
|
|
||||||
@ -704,13 +708,23 @@ class Tracks(Base):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def search_artists(cls, session: scoped_session, text: str) -> List["Tracks"]:
|
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 (
|
return (
|
||||||
session.execute(
|
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()
|
.scalars()
|
||||||
|
.unique()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1103,6 +1103,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
visible_tab.remove_rows(rows_to_delete)
|
visible_tab.remove_rows(rows_to_delete)
|
||||||
visible_tab.save_playlist(session)
|
visible_tab.save_playlist(session)
|
||||||
|
|
||||||
|
# Disable sort undo
|
||||||
|
self.sort_undo = None
|
||||||
|
|
||||||
# Update destination playlist_tab if visible (if not visible, it
|
# Update destination playlist_tab if visible (if not visible, it
|
||||||
# will be re-populated when it is opened)
|
# will be re-populated when it is opened)
|
||||||
destination_playlist_tab = None
|
destination_playlist_tab = None
|
||||||
@ -1637,7 +1640,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
# because it isn't quick
|
# because it isn't quick
|
||||||
if self.next_track.title:
|
if self.next_track.title:
|
||||||
QTimer.singleShot(
|
QTimer.singleShot(
|
||||||
1,
|
0,
|
||||||
lambda: self.tabInfolist.open_in_wikipedia(
|
lambda: self.tabInfolist.open_in_wikipedia(
|
||||||
self.next_track.title
|
self.next_track.title
|
||||||
),
|
),
|
||||||
@ -1784,8 +1787,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
if self.previous_track.title and self.previous_track.artist:
|
if self.previous_track.title and self.previous_track.artist:
|
||||||
self.hdrPreviousTrack.setText(
|
self.hdrPreviousTrack.setText(
|
||||||
f"{self.previous_track.title.replace('&', '&&')} - "
|
f"{self.previous_track.title} - {self.previous_track.artist}"
|
||||||
f"{self.previous_track.artist.replace('&', '&&')}"
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.hdrPreviousTrack.setText("")
|
self.hdrPreviousTrack.setText("")
|
||||||
@ -1954,7 +1956,7 @@ class DbDialog(QDialog):
|
|||||||
if self.ui.radioTitle.isChecked():
|
if self.ui.radioTitle.isChecked():
|
||||||
matches = Tracks.search_titles(self.session, "%" + s)
|
matches = Tracks.search_titles(self.session, "%" + s)
|
||||||
else:
|
else:
|
||||||
matches = Tracks.search_artists(self.session, s)
|
matches = Tracks.search_artists(self.session, "%" + s)
|
||||||
if matches:
|
if matches:
|
||||||
for track in matches:
|
for track in matches:
|
||||||
last_played = None
|
last_played = None
|
||||||
|
|||||||
275
app/playlists.py
275
app/playlists.py
@ -8,7 +8,7 @@ import obsws_python as obs # type: ignore
|
|||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from datetime import datetime, timedelta
|
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 (
|
from PyQt6.QtCore import (
|
||||||
QEvent,
|
QEvent,
|
||||||
@ -92,7 +92,10 @@ class EscapeDelegate(QStyledItemDelegate):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
def createEditor(
|
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
|
Intercept createEditor call and make row just a little bit taller
|
||||||
@ -107,9 +110,12 @@ class EscapeDelegate(QStyledItemDelegate):
|
|||||||
return QPlainTextEdit(parent)
|
return QPlainTextEdit(parent)
|
||||||
return super().createEditor(parent, option, index)
|
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"""
|
"""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:
|
if event.type() == QEvent.Type.KeyPress:
|
||||||
key_event = cast(QKeyEvent, event)
|
key_event = cast(QKeyEvent, event)
|
||||||
if key_event.key() == Qt.Key.Key_Return:
|
if key_event.key() == Qt.Key.Key_Return:
|
||||||
@ -134,6 +140,7 @@ class PlaylistTab(QTableWidget):
|
|||||||
PLAYLISTROW_ID = Qt.ItemDataRole.UserRole + 2
|
PLAYLISTROW_ID = Qt.ItemDataRole.UserRole + 2
|
||||||
TRACK_PATH = Qt.ItemDataRole.UserRole + 3
|
TRACK_PATH = Qt.ItemDataRole.UserRole + 3
|
||||||
PLAYED = Qt.ItemDataRole.UserRole + 4
|
PLAYED = Qt.ItemDataRole.UserRole + 4
|
||||||
|
ROW_LAST_PLAYED = Qt.ItemDataRole.UserRole + 5
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -192,8 +199,8 @@ class PlaylistTab(QTableWidget):
|
|||||||
self.viewport().installEventFilter(self)
|
self.viewport().installEventFilter(self)
|
||||||
|
|
||||||
self.search_text: str = ""
|
self.search_text: str = ""
|
||||||
|
self.sort_undo: List[int] = []
|
||||||
self.edit_cell_type: Optional[int]
|
self.edit_cell_type: Optional[int]
|
||||||
self.selecting_in_progress = False
|
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self.horizontalHeader().sectionResized.connect(self._column_resize)
|
self.horizontalHeader().sectionResized.connect(self._column_resize)
|
||||||
@ -259,6 +266,8 @@ class PlaylistTab(QTableWidget):
|
|||||||
|
|
||||||
# Reset drag mode to allow row selection by dragging
|
# Reset drag mode to allow row selection by dragging
|
||||||
self.setDragEnabled(False)
|
self.setDragEnabled(False)
|
||||||
|
# Disable sort undo
|
||||||
|
self.sort_undo = None
|
||||||
|
|
||||||
with Session() as session:
|
with Session() as session:
|
||||||
self.save_playlist(session)
|
self.save_playlist(session)
|
||||||
@ -267,13 +276,22 @@ class PlaylistTab(QTableWidget):
|
|||||||
self.hide_or_show_played_tracks()
|
self.hide_or_show_played_tracks()
|
||||||
|
|
||||||
def _add_context_menu(
|
def _add_context_menu(
|
||||||
self, text: str, action: Callable, disabled: bool = False
|
self,
|
||||||
) -> QAction:
|
text: str,
|
||||||
|
action: Callable,
|
||||||
|
disabled: bool = False,
|
||||||
|
parent_menu: Optional[QMenu] = None,
|
||||||
|
) -> Optional[QAction]:
|
||||||
"""
|
"""
|
||||||
Add item to self.menu
|
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.setDisabled(disabled)
|
||||||
menu_item.triggered.connect(action)
|
menu_item.triggered.connect(action)
|
||||||
|
|
||||||
@ -1070,6 +1088,31 @@ class PlaylistTab(QTableWidget):
|
|||||||
# ----------------------
|
# ----------------------
|
||||||
self.menu.addSeparator()
|
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
|
# Info
|
||||||
if track_row:
|
if track_row:
|
||||||
self._add_context_menu("Info", lambda: self._info_row(track_id))
|
self._add_context_menu("Info", lambda: self._info_row(track_id))
|
||||||
@ -1282,6 +1325,11 @@ class PlaylistTab(QTableWidget):
|
|||||||
else:
|
else:
|
||||||
return int(duration_udata)
|
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:
|
def _get_row_note(self, row_number: int) -> str:
|
||||||
"""Return note on this row_number or null string if none"""
|
"""Return note on this row_number or null string if none"""
|
||||||
|
|
||||||
@ -1366,9 +1414,7 @@ class PlaylistTab(QTableWidget):
|
|||||||
else:
|
else:
|
||||||
return str(path)
|
return str(path)
|
||||||
|
|
||||||
def _get_row_userdata(
|
def _get_row_userdata(self, row_number: int, role: int) -> Optional[Any]:
|
||||||
self, row_number: int, role: int
|
|
||||||
) -> Optional[Union[str, int]]:
|
|
||||||
"""
|
"""
|
||||||
Return the specified userdata, if any.
|
Return the specified userdata, if any.
|
||||||
"""
|
"""
|
||||||
@ -1627,6 +1673,25 @@ class PlaylistTab(QTableWidget):
|
|||||||
# Set track start/end times after track list is populated
|
# Set track start/end times after track list is populated
|
||||||
self._update_start_end_times(session)
|
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:
|
def _rescan(self, row_number: int, track_id: int) -> None:
|
||||||
"""Rescan track"""
|
"""Rescan track"""
|
||||||
|
|
||||||
@ -1653,6 +1718,43 @@ class PlaylistTab(QTableWidget):
|
|||||||
self._update_start_end_times(session)
|
self._update_start_end_times(session)
|
||||||
self.clear_selection()
|
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):
|
def _run_subprocess(self, args):
|
||||||
"""Run args in subprocess"""
|
"""Run args in subprocess"""
|
||||||
|
|
||||||
@ -1751,10 +1853,6 @@ class PlaylistTab(QTableWidget):
|
|||||||
If multiple rows are selected, display sum of durations in status bar.
|
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()
|
selected_rows = self._get_selected_rows()
|
||||||
# If no rows are selected, we have nothing to do
|
# If no rows are selected, we have nothing to do
|
||||||
if len(selected_rows) == 0:
|
if len(selected_rows) == 0:
|
||||||
@ -1823,43 +1921,6 @@ class PlaylistTab(QTableWidget):
|
|||||||
|
|
||||||
return item
|
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:
|
def _set_played_row(self, session: scoped_session, row_number: int) -> None:
|
||||||
"""Mark this row as played"""
|
"""Mark this row as played"""
|
||||||
|
|
||||||
@ -2027,13 +2088,15 @@ class PlaylistTab(QTableWidget):
|
|||||||
self._set_row_colour(row_number, note_colour)
|
self._set_row_colour(row_number, note_colour)
|
||||||
|
|
||||||
def _set_row_last_played_time(
|
def _set_row_last_played_time(
|
||||||
self, row_number: int, last_played: Optional[datetime]
|
self, row_number: int, last_played: datetime
|
||||||
) -> QTableWidgetItem:
|
) -> 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:
|
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)
|
return self._set_row_userdata(row_number, self.TRACK_PATH, path)
|
||||||
|
|
||||||
def _set_row_userdata(
|
def _set_row_userdata(
|
||||||
self, row_number: int, role: int, value: Optional[Union[str, int]]
|
self, row_number: int, role: int, value: Any
|
||||||
) -> QTableWidgetItem:
|
) -> QTableWidgetItem:
|
||||||
"""
|
"""
|
||||||
Set passed userdata in USERDATA column
|
Set passed userdata in USERDATA column
|
||||||
@ -2181,6 +2244,106 @@ class PlaylistTab(QTableWidget):
|
|||||||
|
|
||||||
return item
|
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(
|
def _track_time_between_rows(
|
||||||
self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows
|
self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows
|
||||||
) -> int:
|
) -> int:
|
||||||
|
|||||||
@ -363,6 +363,9 @@ padding-left: 8px;</string>
|
|||||||
<property name="movable">
|
<property name="movable">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="tabBarAutoHide">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
|
# 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
|
# 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.
|
# 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.setDocumentMode(False)
|
||||||
self.tabInfolist.setTabsClosable(True)
|
self.tabInfolist.setTabsClosable(True)
|
||||||
self.tabInfolist.setMovable(True)
|
self.tabInfolist.setMovable(True)
|
||||||
|
self.tabInfolist.setTabBarAutoHide(False)
|
||||||
self.tabInfolist.setObjectName("tabInfolist")
|
self.tabInfolist.setObjectName("tabInfolist")
|
||||||
self.gridLayout_4.addWidget(self.splitter, 4, 0, 1, 1)
|
self.gridLayout_4.addWidget(self.splitter, 4, 0, 1, 1)
|
||||||
self.InfoFooterFrame = QtWidgets.QFrame(parent=self.centralwidget)
|
self.InfoFooterFrame = QtWidgets.QFrame(parent=self.centralwidget)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user