From 58ec47517db28f4314c3f6620ec09aa1931f08fc Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 24 Feb 2023 19:31:38 +0000 Subject: [PATCH] WIP: playlists.py refactor --- app/musicmuster.py | 6 - app/playlists.py | 913 ++++++++++++++++++++++++++++----------------- 2 files changed, 574 insertions(+), 345 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index e9db2df..c3d0e82 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1546,16 +1546,10 @@ class Window(QMainWindow, Ui_MainWindow): self.next_track.set_plr(session, plr, playlist_tab) if self.next_track.playlist_tab: - self.next_track.playlist_tab.update_display(session) if self.current_track.playlist_tab != self.next_track.playlist_tab: self.set_tab_colour(self.next_track.playlist_tab, QColor(Config.COLOUR_NEXT_TAB)) - # If we've changed playlist tabs for next track, refresh old one - # to remove highligting of next track - if original_next_track_playlist_tab: - original_next_track_playlist_tab.update_display(session) - # Populate footer if we're not currently playing if not self.playing and self.next_track.track_id: self.label_track_length.setText( diff --git a/app/playlists.py b/app/playlists.py index c7ebae5..edcefd1 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -5,7 +5,7 @@ import threading from collections import namedtuple from datetime import datetime, timedelta -from typing import cast, List, Optional, TYPE_CHECKING +from typing import cast, List, Optional, TYPE_CHECKING, Union from PyQt5.QtCore import ( pyqtSignal, @@ -134,6 +134,7 @@ class PlaylistTab(QTableWidget): ROW_TRACK_ID = Qt.UserRole ROW_DURATION = Qt.UserRole + 1 PLAYLISTROW_ID = Qt.UserRole + 2 + TRACK_PATH = Qt.UserRole + 3 def __init__(self, musicmuster: "Window", session: scoped_session, @@ -248,7 +249,9 @@ class PlaylistTab(QTableWidget): with Session() as session: self.save_playlist(session) - self.update_display(session) + + # Update track times + self._update_start_end_times() def eventFilter(self, source, event): """Used to process context (right-click) menu, which is defined here""" @@ -457,6 +460,8 @@ class PlaylistTab(QTableWidget): self._set_row_start_time(row, start_time) else: self._set_row_start_time(row, None) + # Update display including note colour + self._set_row_note(session, row, new_text) else: track = None if track_id: @@ -487,10 +492,9 @@ class PlaylistTab(QTableWidget): play controls and update display. """ - # update_display to update start times, such as when a note has - # been edited - with Session() as session: - self.update_display(session) + # Update start times in case a start time in a note has been + # edited + self._update_start_end_times() self.edit_cell_type = None self.musicmuster.enable_play_next_controls() @@ -631,7 +635,7 @@ class PlaylistTab(QTableWidget): self.save_playlist(session) def insert_row(self, session: scoped_session, plr: PlaylistRows, - repaint: bool = True) -> None: + update_track_times: bool = True, played=False) -> None: """ Insert passed playlist row (plr) into playlist tab. """ @@ -643,58 +647,26 @@ class PlaylistTab(QTableWidget): self.insertRow(row) # Add row metadata to userdata column - userdata_item = QTableWidgetItem() - userdata_item.setData(self.PLAYLISTROW_ID, plr.id) - userdata_item.setData(self.ROW_TRACK_ID, plr.track_id) - self.setItem(row, USERDATA, userdata_item) + self._set_row_userdata(row, self.PLAYLISTROW_ID, plr.id) if plr.track_id: - # Add track details to items - try: - start_gap = plr.track.start_gap - except AttributeError: - return - start_gap_item = self._set_item_text( - row, START_GAP, str(start_gap)) + _ = self._set_row_userdata(row, self.ROW_TRACK_ID, plr.track_id) + _ = self._set_row_userdata(row, self.TRACK_PATH, plr.track.path) + _ = self._set_row_start_gap(row, plr.track.start_gap) + _ = self._set_row_title(row, plr.track.title) + _ = self._set_row_artist(row, plr.track.artist) + _ = self._set_row_duration(row, plr.track.duration) + _ = self._set_row_start_time(row, None) + _ = self._set_row_end_time(row, None) + _ = self._set_row_bitrate(row, plr.track.bitrate) + _ = self._set_row_note(session, row, plr.note) + _ = self._set_row_last_played( + row, Playdates.last_played(session, plr.track.id)) - track_title = plr.track.title - if not track_title: - track_title = "" - _ = self._set_item_text(row, TITLE, track_title) - - track_artist = plr.track.artist - if not track_artist: - track_artist = "" - _ = self._set_item_text(row, ARTIST, track_artist) - - _ = self._set_item_text(row, DURATION, - ms_to_mmss(plr.track.duration)) - if plr.track.duration: - self._set_row_duration(row, plr.track.duration) - - _ = self._set_item_text(row, START_TIME, "") - - _ = self._set_item_text(row, END_TIME, "") - - if plr.track.bitrate: - bitrate = str(plr.track.bitrate) - else: - bitrate = "" - _ = self._set_item_text(row, BITRATE, bitrate) - - # As we have a track_id, any notes should be contained in - # the notes column - plr_note = plr.note - if not plr_note: - plr_note = "" - _ = self._set_item_text(row, ROW_NOTES, plr_note) - - last_playtime = Playdates.last_played(session, plr.track.id) - last_played_str = get_relative_date(last_playtime) - _ = self._set_item_text(row, LASTPLAYED, last_played_str) - - # This is a new track so must be unplayed - self._set_row_bold(row) + if not file_is_readable(plr.track.path): + self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE)) + if not played: + self._set_row_bold(row) else: # This is a section header so it must have note text @@ -713,16 +685,20 @@ class PlaylistTab(QTableWidget): for i in range(1, len(columns)): if i == HEADER_NOTES_COLUMN: continue - self.setItem(row, i, QTableWidgetItem()) + self._set_item_text(row, i, None) + self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1) _ = self._set_item_text(row, HEADER_NOTES_COLUMN, plr.note) - # Save (or clear) track_id - userdata_item.setData(self.ROW_TRACK_ID, 0) + note_colour = NoteColours.get_colour(session, plr.note) + if note_colour: + self._set_row_colour(row, QColor(note_colour)) - if repaint: - # Schedule so that display can update with new row first - QTimer.singleShot(0, lambda: self.update_display(session)) + # Save (or clear) track_id + _ = self._set_row_userdata(row, self.ROW_TRACK_ID, 0) + + if update_track_times: + self._update_start_end_times() def insert_track(self, session: scoped_session, track: Tracks, note: Optional[str] = None, repaint: bool = True) -> None: @@ -761,7 +737,7 @@ class PlaylistTab(QTableWidget): # Build playlist_row object plr = PlaylistRows(session, self.playlist_id, track.id, row_number, note) - self.insert_row(session, plr, repaint) + self.insert_row(session, plr) # Let display update, then save playlist QTimer.singleShot(0, lambda: self.save_playlist(session)) @@ -772,7 +748,8 @@ class PlaylistTab(QTableWidget): Actions required: - Mark current row as played - Set next track - - Update display + - Display track as current + - Update start/stop times """ current_row = self._get_current_track_row_number() @@ -792,8 +769,12 @@ class PlaylistTab(QTableWidget): if next_row: self._set_next(session, next_row) - # Update display - self.update_display(session) + # Display row as current track + self._set_row_colour(current_row, + QColor(Config.COLOUR_CURRENT_PLAYLIST)) + + # Update start/stop times + self._update_start_end_times() def populate_display(self, session: scoped_session, playlist_id: int, scroll_to_top: bool = True) -> None: @@ -807,6 +788,9 @@ class PlaylistTab(QTableWidget): # Clear playlist self.setRowCount(0) + # Get played tracks + played_rows = self._get_played_rows(session) + # Add the rows playlist = session.get(Playlists, playlist_id) if not playlist: @@ -818,7 +802,10 @@ class PlaylistTab(QTableWidget): return for plr in playlist.rows: - self.insert_row(session, plr, repaint=False) + self.insert_row(session, plr, update_track_times=False, + played=plr.row_number in played_rows) + + self._update_start_end_times() # Scroll to top if scroll_to_top: @@ -833,6 +820,9 @@ class PlaylistTab(QTableWidget): # that it's processed after list is populated QTimer.singleShot(0, self.tab_visible) + # Set track start/end times after track list is populated + QTimer.singleShot(0, self._update_start_end_times) + def remove_rows(self, row_numbers: List[int]) -> None: """Remove passed rows from display""" @@ -1001,214 +991,213 @@ class PlaylistTab(QTableWidget): # Set row heights self.resizeRowsToContents() self.setColumnWidth(len(columns) - 1, 0) - with Session() as session: - self.update_display(session) - def update_display(self, session: scoped_session) -> None: - """ - Set row colours, fonts, etc + # def update_display(self, session: scoped_session) -> None: + # """ + # Set row colours, fonts, etc - Actions required: - - Render notes in correct colour - - Render current, next and unplayable tracks in correct colour - - Set start and end times - - Show unplayed tracks in bold - """ + # Actions required: + # - Render notes in correct colour + # - Render current, next and unplayable tracks in correct colour + # - Set start and end times + # - Show unplayed tracks in bold + # """ - current_row = self._get_current_track_row_number() - next_row = self._get_next_track_row_number() - played = [ - p.row_number for p in PlaylistRows.get_played_rows( - session, self.playlist_id) - ] + # current_row = self._get_current_track_row_number() + # next_row = self._get_next_track_row_number() + # played = [ + # p.row_number for p in PlaylistRows.get_played_rows( + # session, self.playlist_id) + # ] - next_start_time = None - section_start_plr = None - section_time = 0 + # # next_start_time = None + # section_start_plr = None + # section_time = 0 - # Start time calculations - # Don't change start times for tracks that have been played. - # For unplayed tracks, if there's a 'current' or 'next' - # track marked, populate start times from then onwards. A note - # with a start time will reset the next track start time. + # # Start time calculations + # # Don't change start times for tracks that have been played. + # # For unplayed tracks, if there's a 'current' or 'next' + # # track marked, populate start times from then onwards. A note + # # with a start time will reset the next track start time. - # Cycle through all rows - for row in range(self.rowCount()): + # # Cycle through all rows + # for row in range(self.rowCount()): - # Extract note text from database to ignore section timings - playlist_row = session.get(PlaylistRows, - self._get_playlistrow_id(row)) - if not playlist_row: - continue - note_text = playlist_row.note - note_colour = None - if not note_text: - note_text = "" - # Get note colour - else: - note_colour = NoteColours.get_colour(session, note_text) + # # Extract note text from database to ignore section timings + # playlist_row = session.get(PlaylistRows, + # self._get_playlistrow_id(row)) + # if not playlist_row: + # continue + # note_text = playlist_row.note + # note_colour = None + # if not note_text: + # note_text = "" + # # Get note colour + # else: + # note_colour = NoteColours.get_colour(session, note_text) - # Get track if there is one - track_id = self._get_row_track_id(row) - track = None - if track_id: - track = session.get(Tracks, track_id) - if not track: - # We have a track_id but we can't find the track. - # Update playlist_row accordingly - missing_track = playlist_row.track_id - playlist_row.track_id = None - if note_text: - note_text += f"track_id {missing_track} not found" - else: - note_text = f"track_id {missing_track} not found" - playlist_row.note = note_text - session.flush() - _ = self._set_item_text(row, HEADER_NOTES_COLUMN, - note_text) + # # Get track if there is one + # track_id = self._get_row_track_id(row) + # track = None + # if track_id: + # track = session.get(Tracks, track_id) + # if not track: + # # We have a track_id but we can't find the track. + # # Update playlist_row accordingly + # missing_track = playlist_row.track_id + # playlist_row.track_id = None + # if note_text: + # note_text += f"track_id {missing_track} not found" + # else: + # note_text = f"track_id {missing_track} not found" + # playlist_row.note = note_text + # session.flush() + # _ = self._set_item_text(row, HEADER_NOTES_COLUMN, + # note_text) - if track: - # Reset colour in case it was current/next/unplayable - self._set_row_colour(row, None) + # if track: + # # Reset colour in case it was current/next/unplayable + # self._set_row_colour(row, None) - # Render unplayable tracks in correct colour - if not file_is_readable(track.path): - self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE)) - self._set_row_bold(row) - continue + # # Render unplayable tracks in correct colour + # if not file_is_readable(track.path): + # self._set_row_colour(row, + # QColor(Config.COLOUR_UNREADABLE)) + # self._set_row_bold(row) + # continue - # Add track time to section time if in timed section - if section_start_plr is not None: - section_time += track.duration + # # Add track time to section time if in timed section + # if section_start_plr is not None: + # section_time += track.duration - # Colour any note - if note_colour: - notes_item = self.item(row, ROW_NOTES) - if notes_item: - notes_item.setBackground(QColor(note_colour)) + # # Colour any note + # if note_colour: + # notes_item = self.item(row, ROW_NOTES) + # if notes_item: + # notes_item.setBackground(QColor(note_colour)) - # Highlight low bitrates - if track.bitrate: - bitrate_str = str(track.bitrate) - bitrate_item = self._set_item_text( - row, BITRATE, str(track.bitrate)) - if bitrate_item: - if track.bitrate < Config.BITRATE_LOW_THRESHOLD: - cell_colour = Config.COLOUR_BITRATE_LOW - elif track.bitrate < Config.BITRATE_OK_THRESHOLD: - cell_colour = Config.COLOUR_BITRATE_MEDIUM - else: - cell_colour = Config.COLOUR_BITRATE_OK - brush = QBrush(QColor(cell_colour)) - bitrate_item.setBackground(brush) + # # Highlight low bitrates + # if track.bitrate: + # bitrate_str = str(track.bitrate) + # bitrate_item = self._set_item_text( + # row, BITRATE, str(track.bitrate)) + # if bitrate_item: + # if track.bitrate < Config.BITRATE_LOW_THRESHOLD: + # cell_colour = Config.COLOUR_BITRATE_LOW + # elif track.bitrate < Config.BITRATE_OK_THRESHOLD: + # cell_colour = Config.COLOUR_BITRATE_MEDIUM + # else: + # cell_colour = Config.COLOUR_BITRATE_OK + # brush = QBrush(QColor(cell_colour)) + # bitrate_item.setBackground(brush) - # Render playing track - if row == current_row: - # Set last played time to "Today" - self._set_item_text( - row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING) - # Calculate next_start_time - if track.duration: - next_start_time = self._calculate_end_time( - self.musicmuster.current_track.start_time, - track.duration - ) - # Set end time - self._set_row_end_time(row, next_start_time) - # Set colour - self._set_row_colour(row, QColor( - Config.COLOUR_CURRENT_PLAYLIST)) - # Make bold - self._set_row_bold(row) - continue + # # Render playing track + # if row == current_row: + # # Set last played time to "Today" + # self._set_item_text( + # row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING) + # # Calculate next_start_time + # # if track.duration: + # # next_start_time = self._calculate_end_time( + # # self.musicmuster.current_track.start_time, + # # track.duration + # # ) + # # Set end time + # # self._set_row_end_time(row, next_start_time) + # # Set colour + # self._set_row_colour(row, QColor( + # Config.COLOUR_CURRENT_PLAYLIST)) + # # Make bold + # self._set_row_bold(row) + # continue - # Render next track - if row == next_row: - # Set start time - # if there's a track playing, set start time from - # that. It may be on a different tab, so we get - # start time from musicmuster. - start_time = self.musicmuster.current_track.end_time - if start_time is None: - # No current track to base from, but don't change - # time if it's already set - start_time = self._get_row_start_time(row) - if not start_time: - start_time = next_start_time - self._set_row_start_time(row, start_time) - # Calculate next_start_time - if track.duration: - next_start_time = self._calculate_end_time( - start_time, track.duration) - # Set end time - self._set_row_end_time(row, next_start_time) - # Set colour - self._set_row_colour( - row, QColor(Config.COLOUR_NEXT_PLAYLIST)) - # Make bold - self._set_row_bold(row) - continue + # # Render next track + # if row == next_row: + # # Set start time + # # if there's a track playing, set start time from + # # that. It may be on a different tab, so we get + # # start time from musicmuster. + # # start_time = self.musicmuster.current_track.end_time + # # if start_time is None: + # # # No current track to base from, but don't change + # # # time if it's already set + # # start_time = self._get_row_start_time(row) + # # if not start_time: + # # start_time = next_start_time + # # self._set_row_start_time(row, start_time) + # # # Calculate next_start_time + # # if track.duration: + # # next_start_time = self._calculate_end_time( + # # start_time, track.duration) + # # # Set end time + # # self._set_row_end_time(row, next_start_time) + # # Set colour + # # self._set_row_colour( + # # row, QColor(Config.COLOUR_NEXT_PLAYLIST)) + # # Make bold + # self._set_row_bold(row) + # continue - if row in played: - # Played today, so update last played column - self._set_item_text( - row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING) - if self.musicmuster.hide_played_tracks: - self.hideRow(row) - else: - self.showRow(row) - self._set_row_not_bold(row) - else: - # Set start/end times as we haven't played it yet - if next_start_time: - self._set_row_start_time(row, next_start_time) - if track.duration: - next_start_time = self._calculate_end_time( - next_start_time, track.duration) - # Set end time - self._set_row_end_time(row, next_start_time) - else: - # Clear start and end time - self._set_row_start_time(row, None) - self._set_row_end_time(row, None) - # Don't dim unplayed tracks - self._set_row_bold(row) + # if row in played: + # # Played today, so update last played column + # self._set_item_text( + # row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING) + # if self.musicmuster.hide_played_tracks: + # self.hideRow(row) + # else: + # self.showRow(row) + # self._set_row_not_bold(row) + # else: + # # Set start/end times as we haven't played it yet + # # if next_start_time: + # # self._set_row_start_time(row, next_start_time) + # # if track.duration: + # # next_start_time = self._calculate_end_time( + # # next_start_time, track.duration) + # # # Set end time + # # self._set_row_end_time(row, next_start_time) + # # else: + # # # Clear start and end time + # # self._set_row_start_time(row, None) + # # self._set_row_end_time(row, None) + # # Don't dim unplayed tracks + # self._set_row_bold(row) - continue + # continue - # No track associated, so this row is a section header - # Does the note have a start time? - row_time = self._get_note_text_time(note_text) - if row_time: - next_start_time = row_time - # Does it delimit a section? - if section_start_plr is not None: - if note_text.endswith("-"): - self._update_note_text( - section_start_plr, - self._get_section_timing_string(section_time) - ) - section_start_plr = None - section_time = 0 - elif note_text.endswith("+"): - section_start_plr = playlist_row - section_time = 0 - if not note_colour: - note_colour = Config.COLOUR_NOTES_PLAYLIST - self._set_row_colour(row, QColor(note_colour)) - # Section headers are always bold - self._set_row_bold(row) - continue + # # No track associated, so this row is a section header + # # Does the note have a start time? + # # row_time = self._get_note_text_time(note_text) + # # if row_time: + # # next_start_time = row_time + # # Does it delimit a section? + # if section_start_plr is not None: + # if note_text.endswith("-"): + # self._update_note_text( + # section_start_plr, + # self._get_section_timing_string(section_time) + # ) + # section_start_plr = None + # section_time = 0 + # elif note_text.endswith("+"): + # section_start_plr = playlist_row + # section_time = 0 + # if not note_colour: + # note_colour = Config.COLOUR_NOTES_PLAYLIST + # self._set_row_colour(row, QColor(note_colour)) + # # Section headers are always bold + # self._set_row_bold(row) + # continue - # Set row heights - self.resizeRowsToContents() + # # Set row heights + # self.resizeRowsToContents() - # Have we had a section start but not end? - if section_start_plr is not None: - self._update_note_text( - section_start_plr, - self._get_section_timing_string(section_time, no_end=True) - ) + # # Have we had a section start but not end? + # if section_start_plr is not None: + # self._update_note_text( + # section_start_plr, + # self._get_section_timing_string(section_time, no_end=True) + # ) # # ########## Internally called functions ########## @@ -1237,19 +1226,11 @@ class PlaylistTab(QTableWidget): self.setSpan(row, column, 1, 1) # Update attributes of row - userdata_item = self.item(row, USERDATA) - if not userdata_item: - userdata_item = QTableWidgetItem() - userdata_item.setData(self.ROW_TRACK_ID, track.id) - - last_playtime = Playdates.last_played(session, track.id) - last_played_str = get_relative_date(last_playtime) - _ = self._set_item_text(row, LASTPLAYED, last_played_str) - - _ = self._set_item_text(row, ROW_NOTES, plr.note) - - start_gap_item = self._set_item_text(row, START_GAP, - str(track.start_gap)) + _ = self._set_row_userdata(row, self.ROW_TRACK_ID, plr.track_id) + _ = self._set_row_last_played( + row, Playdates.last_played(session, plr.track.id)) + _ = self._set_row_note(session, row, plr.note) + _ = self._set_row_start_gap(row, plr.track.start_gap) self._update_row(session, row, track) @@ -1383,6 +1364,17 @@ class PlaylistTab(QTableWidget): return None + def _get_current_track_end_time(self) -> Optional[datetime]: + """ + Return current track end time or None if no current track + """ + + current_track_row = self._get_current_track_row_number() + if current_track_row is None: + return None + + return self.musicmuster.current_track.end_time + def _get_current_track_row_number(self) -> Optional[int]: """Return current track row or None""" @@ -1418,14 +1410,23 @@ class PlaylistTab(QTableWidget): except ValueError: return None + def _get_played_rows(self, session: scoped_session) -> List[int]: + """ + Return a list of row numbers that have been played + """ + + return [ + p.row_number for p in PlaylistRows.get_played_rows( + session, self.playlist_id) if p.row_number is not None + ] + def _get_playlistrow_id(self, row: int) -> Optional[int]: """Return the playlistrow_id associated with this row""" - userdata_item = self.item(row, USERDATA) - if not userdata_item: + plrid = self._get_row_userdata(row, self.PLAYLISTROW_ID) + if plrid is None: return None - - return userdata_item.data(self.PLAYLISTROW_ID) + return int(plrid) def _get_playlistrow_object(self, session: scoped_session, row: int) -> Optional[PlaylistRows]: @@ -1440,10 +1441,6 @@ class PlaylistTab(QTableWidget): def _get_row_artist(self, row: int) -> Optional[str]: """Return artist on this row or None if none""" - track_id = self._get_row_track_id(row) - if not track_id: - return None - item_artist = self.item(row, ARTIST) if not item_artist: return None @@ -1453,15 +1450,11 @@ class PlaylistTab(QTableWidget): def _get_row_duration(self, row: int) -> int: """Return duration associated with this row""" - userdata_item = self.item(row, USERDATA) - if not userdata_item: + duration_userdata = self._get_row_userdata(row, self.ROW_DURATION) + if not duration_userdata: return 0 - - duration = userdata_item.data(self.ROW_DURATION) - if duration: - return duration else: - return 0 + return int(duration_userdata) def _get_row_note(self, row: int) -> Optional[str]: """Return note on this row or None if none""" @@ -1476,6 +1469,13 @@ class PlaylistTab(QTableWidget): return item_note.text() + def _get_row_path(self, row: int) -> Optional[str]: + """ + Return path of track associated with this row or None + """ + + return str(self._get_row_userdata(row, self.TRACK_PATH)) + def _get_row_start_time(self, row: int) -> Optional[datetime]: """Return row start time as string or None""" @@ -1492,29 +1492,64 @@ class PlaylistTab(QTableWidget): def _get_row_title(self, row: int) -> Optional[str]: """Return title on this row or None if none""" - track_id = self._get_row_track_id(row) - if not track_id: - return None - item_title = self.item(row, TITLE) if not item_title: return None return item_title.text() + def _get_row_track(self, session: scoped_session, + row: int) -> Optional[Tracks]: + """Return the track associated with this row or None""" + + track_id = self._get_row_track_id(row) + if track_id: + return session.get(Tracks, track_id) + else: + return None + def _get_row_track_id(self, row: int) -> int: """Return the track_id associated with this row or None""" + track_id = self._get_row_userdata(row, self.ROW_TRACK_ID) + if not track_id: + return 0 + else: + return int(track_id) + + def _get_row_userdata(self, row: int, + role: int) -> Optional[Union[str, int]]: + """ + Return the specified userdata, if any. + """ + userdata_item = self.item(row, USERDATA) if not userdata_item: - return 0 + return None - try: - track_id = userdata_item.data(self.ROW_TRACK_ID) - except AttributeError: - return 0 + return userdata_item.data(role) - return track_id + def _get_section_start_time(self, session: scoped_session, + row: int) -> Optional[datetime]: + """ + Parse section header for a start time. + Return None if: + - it's not a section header row or + - we can't parse a time from it + Otherwise return datetime from header. + """ + + # If we have a track_id, we're not a section header + if self._get_row_track_id(row): + return None + + # Check for start time in note. Extract note text from database + # to ignore section timings. + plr = session.get(PlaylistRows, self._get_playlistrow_id(row)) + if plr and plr.note: + return self._get_note_text_time(plr.note) + else: + return None def _get_selected_row(self) -> Optional[int]: """Return row number of first selected row, or None if none selected""" @@ -1584,9 +1619,7 @@ class PlaylistTab(QTableWidget): self.musicmuster.clear_next() self.clear_selection() - with Session() as session: - # TODO: or just reset row background - self.update_display(session) + self._set_row_colour(row, None) self.musicmuster.update_headers() def _mark_unplayed(self, plr: PlaylistRows) -> None: @@ -1594,12 +1627,13 @@ class PlaylistTab(QTableWidget): Mark passed row as unplayed in this playlist """ + if not plr.row_number: + return + with Session() as session: session.add(plr) plr.played = False - session.flush() - # TODO: or just reset row to bold - self.update_display(session) + self._set_row_colour(plr.row_number, None) def _move_row(self, session: scoped_session, plr: PlaylistRows, new_row_number: int) -> None: @@ -1692,15 +1726,15 @@ class PlaylistTab(QTableWidget): # Remove row duration self._set_row_duration(row, 0) # Remote track_id from row - userdata_item = self.item(row, USERDATA) - if userdata_item: - userdata_item.setData(self.ROW_TRACK_ID, 0) + _ = self._set_row_userdata(row, self.ROW_TRACK_ID, 0) # Span the rows self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1) # Set note text in correct column for section head _ = self._set_item_text(row, HEADER_NOTES_COLUMN, plr.note) - # And refresh display - self.update_display(session) + + note_colour = NoteColours.get_colour(session, plr.note) + if note_colour: + self._set_row_colour(row, QColor(note_colour)) def _rescan(self, row: int, track_id: int) -> None: """Rescan track""" @@ -1715,6 +1749,7 @@ class PlaylistTab(QTableWidget): return set_track_metadata(session, track) + # TODO: set readable/unreadable self._update_row(session, row, track) def _run_subprocess(self, args): @@ -1852,17 +1887,22 @@ class PlaylistTab(QTableWidget): else: self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH) - def _set_item_text(self, row, column, text) -> QTableWidgetItem: + def _set_item_text(self, row: int, column: int, + text: Optional[str]) -> QTableWidgetItem: """ Set text for item if it exists, else create it, and return item """ + if not text: + text = "" + item = self.item(row, column) if not item: item = QTableWidgetItem(text) self.setItem(row, column, item) else: item.setText(text) + return item def _set_next(self, session: scoped_session, row_number: int) -> None: @@ -1873,7 +1913,8 @@ class PlaylistTab(QTableWidget): - Check row has a track - Check track is readable - Notify musicmuster - - Update display + - Display row as next track + - Update start/stop times """ # Check row has a track @@ -1893,6 +1934,11 @@ class PlaylistTab(QTableWidget): if not file_is_readable(track.path): return None + # Clear any existing next track + next_track_row = self._get_next_track_row_number() + if next_track_row: + self._set_row_colour(next_track_row, None) + # Notify musicmuster plr = session.get(PlaylistRows, self._get_playlistrow_id(row_number)) if not plr: @@ -1900,9 +1946,12 @@ class PlaylistTab(QTableWidget): else: self.musicmuster.this_is_the_next_playlist_row(session, plr, self) - # Update display + # Display row as next track + self._set_row_colour(row_number, QColor(Config.COLOUR_NEXT_PLAYLIST)) + + # Update start/stop times self.clear_selection() - self.update_display(session) + self._update_start_end_times() def _set_played_row(self, session: scoped_session, row: int) -> None: """Mark this row as played""" @@ -1914,6 +1963,42 @@ class PlaylistTab(QTableWidget): plr.played = True session.flush() + def _set_row_artist(self, row: int, + artist: Optional[str]) -> QTableWidgetItem: + """ + Set row artist. + + Return QTableWidgetItem. + """ + + if not artist: + artist = "" + + return self._set_item_text(row, ARTIST, artist) + + def _set_row_bitrate(self, row: int, + bitrate: Optional[int]) -> QTableWidgetItem: + """Set bitrate of this row.""" + + if not bitrate: + bitrate_str = "" + # If no bitrate, flag it as too low + bitrate = Config.BITRATE_LOW_THRESHOLD - 1 + else: + bitrate_str = str(bitrate) + bitrate_item = self._set_item_text(row, BITRATE, bitrate_str) + + if bitrate < Config.BITRATE_LOW_THRESHOLD: + cell_colour = Config.COLOUR_BITRATE_LOW + elif bitrate < Config.BITRATE_OK_THRESHOLD: + cell_colour = Config.COLOUR_BITRATE_MEDIUM + else: + cell_colour = Config.COLOUR_BITRATE_OK + brush = QBrush(QColor(cell_colour)) + bitrate_item.setBackground(brush) + + return bitrate_item + def _set_row_bold(self, row: int, bold: bool = True) -> None: """ Make row bold (bold=True) or not bold. @@ -1949,37 +2034,116 @@ class PlaylistTab(QTableWidget): if item: item.setBackground(brush) - def _set_row_duration(self, row: int, ms: int) -> None: - """Set duration of this row in row metadata""" + def _set_row_duration(self, row: int, + ms: Optional[int]) -> QTableWidgetItem: + """Set duration of this row. Also set in row metadata""" - item = self.item(row, USERDATA) - if item: - item.setData(self.ROW_DURATION, ms) + duration_item = self._set_item_text(row, DURATION, ms_to_mmss(ms)) + self._set_row_userdata(row, self.ROW_DURATION, ms) - def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None: - """Set passed row end time to passed time""" + return duration_item - try: - time_str = time.strftime(Config.TRACK_TIME_FORMAT) # type: ignore - except AttributeError: + def _set_row_end_time(self, row: int, + time: Optional[datetime]) -> QTableWidgetItem: + """Set row end time""" + + if not time: time_str = "" + else: + try: + time_str = time.strftime(Config.TRACK_TIME_FORMAT) + except AttributeError: + time_str = "" - item = QTableWidgetItem(time_str) - self.setItem(row, END_TIME, item) + return self._set_item_text(row, END_TIME, time_str) + + def _set_row_last_played(self, row: int, last_played: Optional[datetime]) \ + -> QTableWidgetItem: + """Set row last played time""" + + last_played_str = get_relative_date(last_played) + + return self._set_item_text(row, LASTPLAYED, last_played_str) def _set_row_not_bold(self, row: int) -> None: """Set row to not be bold""" self._set_row_bold(row, False) - def _set_row_start_time(self, row: int, time: Optional[datetime]) -> None: - """Set passed row start time to passed time""" + def _set_row_note(self, session: scoped_session, row: int, + note_text: Optional[str]) -> QTableWidgetItem: + """Set row note""" - try: - time_str = time.strftime(Config.TRACK_TIME_FORMAT) # type: ignore - except AttributeError: + if not note_text: + note_text = "" + notes_item = self._set_item_text(row, ROW_NOTES, note_text) + + note_colour = NoteColours.get_colour(session, note_text) + if note_colour: + notes_item.setBackground(QColor(note_colour)) + + return notes_item + + def _set_row_start_gap(self, row: int, + start_gap: Optional[int]) -> QTableWidgetItem: + """ + Set start gap on row, set backgroud colour. + + Return QTableWidgetItem. + """ + + if not start_gap: + start_gap = 0 + start_gap_item = self._set_item_text(row, START_GAP, str(start_gap)) + if start_gap >= 500: + start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START)) + else: + start_gap_item.setBackground(QColor("white")) + + return start_gap_item + + def _set_row_start_time(self, row: int, + time: Optional[datetime]) -> QTableWidgetItem: + """Set row start time""" + + if not time: time_str = "" - _ = self._set_item_text(row, START_TIME, time_str) + else: + try: + time_str = time.strftime(Config.TRACK_TIME_FORMAT) + except AttributeError: + time_str = "" + + return self._set_item_text(row, START_TIME, time_str) + + def _set_row_title(self, row: int, + title: Optional[str]) -> QTableWidgetItem: + """ + Set row title. + + Return QTableWidgetItem. + """ + + if not title: + title = "" + + return self._set_item_text(row, TITLE, title) + + def _set_row_userdata(self, row: int, role: int, + value: Optional[Union[str, int]]) \ + -> QTableWidgetItem: + """ + Set passed userdata in USERDATA column + """ + + item = self.item(row, USERDATA) + if not item: + item = QTableWidgetItem() + self.setItem(row, USERDATA, item) + + item.setData(role, value) + + return item def _get_section_timing_string(self, ms: int, no_end: bool = False) -> str: @@ -1998,10 +2162,14 @@ class PlaylistTab(QTableWidget): self.musicmuster.tabInfolist.open_in_songfacts(title) - def _update_note_text(self, playlist_row: PlaylistRows, + def _update_note_text(self, session: scoped_session, + playlist_row: PlaylistRows, additional_text: str) -> None: """Append additional_text to row display""" + if not playlist_row.row_number: + return + # Column to update is either HEADER_NOTES_COLUMN for a section # header or the appropriate row_notes column for a track row if playlist_row.track_id: @@ -2015,27 +2183,94 @@ class PlaylistTab(QTableWidget): else: new_text = additional_text - _ = self._set_item_text(playlist_row.row_number, column, new_text) + _ = self._set_row_note(session, playlist_row.row_number, new_text) def _update_row(self, session, row: int, track: Tracks) -> None: """ Update the passed row with info from the passed track. """ - start_gap_item = self._set_item_text( - row, START_GAP, str(track.start_gap)) - if track.start_gap and track.start_gap >= 500: - start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START)) - else: - start_gap_item.setBackground(QColor("white")) - - _ = self._set_item_text(row, TITLE, track.title) - _ = self._set_item_text(row, ARTIST, track.artist) - _ = self._set_item_text(row, DURATION, ms_to_mmss(track.duration)) - _ = self._set_item_text(row, BITRATE, str(track.bitrate)) + _ = self._set_row_start_gap(row, track.start_gap) + _ = self._set_row_title(row, track.title) + _ = self._set_row_artist(row, track.artist) + _ = self._set_row_duration(row, track.duration) + _ = self._set_row_bitrate(row, track.bitrate) self.update_display(session) + def _update_start_end_times(self) -> None: + """ Update track start and end times """ + + next_start_time = None + + with Session() as session: + current_track_end_time = self._get_current_track_end_time() + current_track_row = self._get_current_track_row_number() + next_track_row = self._get_next_track_row_number() + played_rows = self._get_played_rows(session) + + for row in range(self.rowCount()): + # Don't change start times for tracks that have been + # played other than current/next row + if row in played_rows and row not in [ + current_track_row, next_track_row]: + continue + + a_track_row = self._get_row_track_id(row) > 0 + if not a_track_row: + note_time = self._get_section_start_time(session, row) + if note_time: + next_start_time = note_time + # We have any timings from note; there's no track; go to + # next row + continue + + # Here means we have a track. Skip if track is + # unreadable + if not file_is_readable(self._get_row_path(row)): + continue + + if row == next_track_row: + # If we have a current track, set this next track start + # time from it + if current_track_end_time: + self._set_row_start_time(row, current_track_end_time) + next_start_time = self._calculate_end_time( + current_track_end_time, + self._get_row_duration(row)) + self._set_row_end_time(row, next_start_time) + continue + # Fall through to set times if next_start_time is + # set + + if row == current_track_row: + track_start = self.musicmuster.current_track.start_time + if not track_start: + continue + self._set_row_start_time(row, track_start) + next_start_time = self._calculate_end_time( + track_start, self._get_row_duration(row)) + self._set_row_end_time(row, next_start_time) + continue + + if not next_start_time: + continue + + # If we're between the current and next row, zero out + # times + if ( + current_track_row and + next_track_row and + current_track_row < row < next_track_row + ): + self._set_row_start_time(row, None) + self._set_row_end_time(row, None) + else: + self._set_row_start_time(row, next_start_time) + next_start_time = self._calculate_end_time( + next_start_time, self._get_row_duration(row)) + self._set_row_end_time(row, next_start_time) + def _wikipedia(self, row_number: int) -> None: """Look up passed row title in Wikipedia and display info tab"""