WIP Issue 285

This commit is contained in:
Keith Edmunds 2025-03-08 12:02:07 +00:00
parent 3b004567df
commit 2f8afeb814

View File

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