diff --git a/app/playlistmodel.py b/app/playlistmodel.py index d7f3a46..1c4c329 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -11,7 +11,6 @@ import re from PyQt6.QtCore import ( QAbstractTableModel, QModelIndex, - QObject, QRegularExpression, QSortFilterProxyModel, Qt, @@ -25,7 +24,6 @@ from PyQt6.QtGui import ( ) # Third party imports -import line_profiler from sqlalchemy.orm.session import Session import obswebsocket # type: ignore @@ -72,18 +70,14 @@ class PlaylistModel(QAbstractTableModel): database. """ - def __init__( - self, - playlist_id: int, - is_template: bool, - *args: Optional[QObject], - **kwargs: Optional[QObject], - ) -> None: + def __init__(self, playlist_id: int, is_template: bool,) -> None: + + super().__init__() + log.debug("PlaylistModel.__init__()") self.playlist_id = playlist_id self.is_template = is_template - super().__init__(*args, **kwargs) self.playlist_rows: dict[int, RowAndTrack] = {} self.signals = MusicMusterSignals() @@ -101,13 +95,17 @@ class PlaylistModel(QAbstractTableModel): def __repr__(self) -> str: return ( - f"" + f"" ) def active_section_header(self) -> int: """ - Return the row number of the first header that has either unplayed tracks - or currently being played track below it. + Return the row number of the first header that has any of the following below it: + - unplayed tracks + - the currently being played track + - the track marked as next to play """ header_row = 0 @@ -119,23 +117,20 @@ class PlaylistModel(QAbstractTableModel): if not self.is_played_row(row_number): break - # If track is played, we need to check it's not the current - # next or previous track because we don't want to scroll them - # out of view + # Here means that row_number points to a played track. The + # current track will be marked as played when we start + # playing it. It's also possible that the track marked as + # next has already been played. Check for either of those. - for ts in [ - track_sequence.next, - track_sequence.current, - ]: + for ts in [track_sequence.next, track_sequence.current]: if ( ts and ts.row_number == row_number and ts.playlist_id == self.playlist_id ): - break - else: - continue # continue iterating over playlist_rows - break # current row is in one of the track sequences + # We've found the current or next track, so return + # the last-found header row + return header_row return header_row @@ -152,31 +147,34 @@ class PlaylistModel(QAbstractTableModel): try: rat = self.playlist_rows[row_number] except KeyError: - log.error( + raise ApplicationError( f"{self}: KeyError in add_track_to_header ({row_number=}, {track_id=})" ) - return if rat.path: - log.error( + raise ApplicationError( f"{self}: Header row already has track associated ({rat=}, {track_id=})" ) - return with db.Session() as session: playlistrow = session.get(PlaylistRows, rat.playlistrow_id) - if playlistrow: - # Add track to PlaylistRows - playlistrow.track_id = track_id - # Add any further note (header will already have a note) - if note: - playlistrow.note += "\n" + note - # Update local copy - self.refresh_row(session, row_number) - # Repaint row - self.invalidate_row(row_number) - session.commit() + if not playlistrow: + raise ApplicationError( + f"{self}: Failed to retrieve playlist row ({rat.playlistrow_id=}" + ) + # Add track to PlaylistRows + playlistrow.track_id = track_id + # Add any further note (header will already have a note) + if note: + playlistrow.note += " " + note + session.commit() + + # Update local copy + self.refresh_row(session, row_number) + # Repaint row + self.invalidate_row(row_number) self.signals.resize_rows_signal.emit(self.playlist_id) + # @line_profiler.profile def background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush: """Return background setting""" @@ -257,26 +255,28 @@ class PlaylistModel(QAbstractTableModel): - update track times """ + log.debug(f"{self}: current_track_started()") + if not track_sequence.current: return row_number = track_sequence.current.row_number # Check for OBS scene change - log.debug(f"{self}: Call OBS scene change") self.obs_scene_change(row_number) # Sanity check that we have a track_id - if not track_sequence.current.track_id: - log.error( - f"{self}: current_track_started() called with {track_sequence.current.track_id=}" + track_id = track_sequence.current.track_id + if not track_id: + raise ApplicationError( + f"{self}: current_track_started() called with {track_id=}" ) - return with db.Session() as session: # Update Playdates in database - log.debug(f"{self}: update playdates") - Playdates(session, track_sequence.current.track_id) + log.debug(f"{self}: update playdates {track_id=}") + Playdates(session, track_id) + session.commit() # Mark track as played in playlist log.debug(f"{self}: Mark track as played") @@ -315,14 +315,25 @@ class PlaylistModel(QAbstractTableModel): if next_row is not None: self.set_next_row(next_row) - session.commit() - + # @line_profiler.profile def data( self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole - ) -> QVariant: + ) -> QVariant | QFont | QBrush | str: """Return data to view""" - if not index.isValid() or not (0 <= index.row() < len(self.playlist_rows)): + if ( + not index.isValid() + or not (0 <= index.row() < len(self.playlist_rows)) + or role in [ + Qt.ItemDataRole.DecorationRole, + Qt.ItemDataRole.StatusTipRole, + Qt.ItemDataRole.WhatsThisRole, + Qt.ItemDataRole.SizeHintRole, + Qt.ItemDataRole.TextAlignmentRole, + Qt.ItemDataRole.CheckStateRole, + Qt.ItemDataRole.InitialSortOrderRole, + ] + ): return QVariant() row = index.row() @@ -330,32 +341,21 @@ class PlaylistModel(QAbstractTableModel): # rat for playlist row data as it's used a lot rat = self.playlist_rows[row] - # Dispatch to role-specific functions - dispatch_table = { - int(Qt.ItemDataRole.BackgroundRole): self.background_role, - int(Qt.ItemDataRole.DisplayRole): self.display_role, - int(Qt.ItemDataRole.EditRole): self.edit_role, - int(Qt.ItemDataRole.FontRole): self.font_role, - int(Qt.ItemDataRole.ForegroundRole): self.foreground_role, - int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role, - } + # These are ordered in approximately the frequency with which + # they are called + if role == Qt.ItemDataRole.BackgroundRole: + return self.background_role(row, column, rat) + elif role == Qt.ItemDataRole.DisplayRole: + return self.display_role(row, column, rat) + elif role == Qt.ItemDataRole.EditRole: + return self.edit_role(row, column, rat) + elif role == Qt.ItemDataRole.FontRole: + return self.font_role(row, column, rat) + elif role == Qt.ItemDataRole.ForegroundRole: + return self.foreground_role(row, column, rat) + elif role == Qt.ItemDataRole.ToolTipRole: + return self.tooltip_role(row, column, rat) - if role in dispatch_table: - return QVariant(dispatch_table[role](row, column, rat)) - - # Document other roles but don't use them - if role in [ - Qt.ItemDataRole.DecorationRole, - Qt.ItemDataRole.StatusTipRole, - Qt.ItemDataRole.WhatsThisRole, - Qt.ItemDataRole.SizeHintRole, - Qt.ItemDataRole.TextAlignmentRole, - Qt.ItemDataRole.CheckStateRole, - Qt.ItemDataRole.InitialSortOrderRole, - ]: - return QVariant() - - # Fall through to no-op return QVariant() def delete_rows(self, row_numbers: list[int]) -> None: @@ -385,8 +385,10 @@ class PlaylistModel(QAbstractTableModel): super().endRemoveRows() self.reset_track_sequence_row_numbers() + self.update_track_times() - def display_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: + # @line_profiler.profile + def display_role(self, row: int, column: int, rat: RowAndTrack) -> str: """ Return text for display """ @@ -406,45 +408,45 @@ class PlaylistModel(QAbstractTableModel): if column == HEADER_NOTES_COLUMN: header_text = self.header_text(rat) if not header_text: - return QVariant(Config.TEXT_NO_TRACK_NO_NOTE) + return Config.SECTION_HEADER else: formatted_header = self.header_text(rat) trimmed_header = self.remove_section_timer_markers(formatted_header) - return QVariant(trimmed_header) + return trimmed_header else: - return QVariant("") + return "" if column == Col.START_TIME.value: start_time = rat.forecast_start_time if start_time: - return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT)) - return QVariant() + return start_time.strftime(Config.TRACK_TIME_FORMAT) + return "" if column == Col.END_TIME.value: end_time = rat.forecast_end_time if end_time: - return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT)) - return QVariant() + return end_time.strftime(Config.TRACK_TIME_FORMAT) + return "" if column == Col.INTRO.value: if rat.intro: - return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}") + return f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}" else: - return QVariant("") + return "" - dispatch_table = { - Col.ARTIST.value: QVariant(rat.artist), - Col.BITRATE.value: QVariant(rat.bitrate), - Col.DURATION.value: QVariant(ms_to_mmss(rat.duration)), - Col.LAST_PLAYED.value: QVariant(get_relative_date(rat.lastplayed)), - Col.NOTE.value: QVariant(rat.note), - Col.START_GAP.value: QVariant(rat.start_gap), - Col.TITLE.value: QVariant(rat.title), + dispatch_table: dict[int, str] = { + Col.ARTIST.value: rat.artist, + Col.BITRATE.value: str(rat.bitrate), + Col.DURATION.value: ms_to_mmss(rat.duration), + Col.LAST_PLAYED.value: get_relative_date(rat.lastplayed), + Col.NOTE.value: rat.note, + Col.START_GAP.value: str(rat.start_gap), + Col.TITLE.value: rat.title, } if column in dispatch_table: return dispatch_table[column] - return QVariant() + return "" def end_reset_model(self, playlist_id: int) -> None: """ @@ -461,7 +463,8 @@ class PlaylistModel(QAbstractTableModel): super().endResetModel() self.reset_track_sequence_row_numbers() - def edit_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: + # @line_profiler.profile + def edit_role(self, row: int, column: int, rat: RowAndTrack) -> str: """ Return text for editing """ @@ -469,19 +472,20 @@ class PlaylistModel(QAbstractTableModel): # If this is a header row and we're being asked for the # HEADER_NOTES_COLUMN, return the note value if self.is_header_row(row) and column == HEADER_NOTES_COLUMN: - return QVariant(rat.note) + return rat.note if column == Col.INTRO.value: - return QVariant(rat.intro) + return str(rat.intro or "") if column == Col.TITLE.value: - return QVariant(rat.title) + return rat.title if column == Col.ARTIST.value: - return QVariant(rat.artist) + return rat.artist if column == Col.NOTE.value: - return QVariant(rat.note) + return rat.note - return QVariant() + return "" + # @line_profiler.profile def foreground_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush: """Return header foreground colour or QBrush() if none""" @@ -518,19 +522,20 @@ class PlaylistModel(QAbstractTableModel): return default - def font_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: + # @line_profiler.profile + def font_role(self, row: int, column: int, rat: RowAndTrack) -> QFont: """ Return font """ # Notes column is never bold if column == Col.NOTE.value: - return QVariant() + return QFont() boldfont = QFont() boldfont.setBold(not self.playlist_rows[row].played) - return QVariant(boldfont) + return boldfont def get_duplicate_rows(self) -> list[int]: """ @@ -729,7 +734,8 @@ class PlaylistModel(QAbstractTableModel): self.reset_track_sequence_row_numbers() self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows)))) - @line_profiler.profile + # Keep this decorator for now + # @line_profiler.profile def invalidate_row(self, modified_row: int) -> None: """ Signal to view to refresh invalidated row @@ -742,7 +748,8 @@ class PlaylistModel(QAbstractTableModel): self.index(modified_row, self.columnCount() - 1), ) - @line_profiler.profile + # Keep this decorator for now + # @line_profiler.profile def invalidate_rows(self, modified_rows: list[int]) -> None: """ Signal to view to refresh invlidated rows @@ -1558,19 +1565,20 @@ class PlaylistModel(QAbstractTableModel): def supportedDropActions(self) -> Qt.DropAction: return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction - def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: + # @line_profiler.profile + def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str: """ Return tooltip. Currently only used for last_played column. """ if column != Col.LAST_PLAYED.value: - return QVariant() + return "" with db.Session() as session: track_id = self.playlist_rows[row].track_id if not track_id: - return QVariant() + return "" playdates = Playdates.last_playdates(session, track_id) - return QVariant( + return ( "
".join( [ a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)