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 (
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"<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:
"""
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:
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 += "\n" + note
playlistrow.note += " " + note
session.commit()
# Update local copy
self.refresh_row(session, row_number)
# Repaint row
self.invalidate_row(row_number)
session.commit()
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,36 +315,16 @@ 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)):
return QVariant()
row = index.row()
column = index.column()
# 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,
}
if role in dispatch_table:
return QVariant(dispatch_table[role](row, column, rat))
# Document other roles but don't use them
if role in [
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,
@ -352,10 +332,30 @@ class PlaylistModel(QAbstractTableModel):
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.InitialSortOrderRole,
]:
]
):
return QVariant()
# Fall through to no-op
row = index.row()
column = index.column()
# rat for playlist row data as it's used a lot
rat = self.playlist_rows[row]
# 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)
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 (
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)