import re import stackprinter # type: ignore import subprocess import threading from collections import namedtuple from datetime import datetime, timedelta from typing import cast, List, Optional, TYPE_CHECKING, Union from PyQt5.QtCore import ( QEvent, QModelIndex, QObject, QSize, Qt, QTimer, ) from PyQt5.QtGui import ( QBrush, QColor, QFont, QDropEvent, QKeyEvent ) from PyQt5.QtWidgets import ( QAbstractItemDelegate, QAbstractItemView, QApplication, QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QStyledItemDelegate, QTableWidget, QTableWidgetItem, QWidget ) from config import Config from dbconfig import Session, scoped_session from helpers import ( ask_yes_no, file_is_readable, get_relative_date, ms_to_mmss, open_in_audacity, send_mail, set_track_metadata, ) from log import log from models import ( Playdates, Playlists, PlaylistRows, Settings, Tracks, NoteColours ) if TYPE_CHECKING: from musicmuster import Window, MusicMusterSignals start_time_re = re.compile(r"@\d\d:\d\d:\d\d") HEADER_NOTES_COLUMN = 2 # Columns Column = namedtuple("Column", ['idx', 'heading']) columns = {} columns["userdata"] = Column(idx=0, heading=Config.COLUMN_NAME_AUTOPLAY) columns["start_gap"] = Column(idx=1, heading=Config.COLUMN_NAME_LEADING_SILENCE) columns["title"] = Column(idx=2, heading=Config.COLUMN_NAME_TITLE) columns["artist"] = Column(idx=3, heading=Config.COLUMN_NAME_ARTIST) columns["duration"] = Column(idx=4, heading=Config.COLUMN_NAME_LENGTH) columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME) columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME) columns["lastplayed"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED) columns["bitrate"] = Column(idx=8, heading=Config.COLUMN_NAME_BITRATE) columns["row_notes"] = Column(idx=9, heading=Config.COLUMN_NAME_NOTES) USERDATA = columns["userdata"].idx START_GAP = columns["start_gap"].idx TITLE = columns["title"].idx ARTIST = columns["artist"].idx DURATION = columns["duration"].idx START_TIME = columns["start_time"].idx END_TIME = columns["end_time"].idx LASTPLAYED = columns["lastplayed"].idx BITRATE = columns["bitrate"].idx ROW_NOTES = columns["row_notes"].idx class NoSelectDelegate(QStyledItemDelegate): """ This originally used the following link to not select text on edit; however, using a QPlainTextBox means a) text isn't selected anyway and b) it provides a multiline edit. https://stackoverflow.com/questions/72790705/ dont-select-text-in-qtablewidget-cell-when-editing/72792962#72792962 Now this: - increases the height of a row when editing to make editing easier - closes the edit on control-return """ def createEditor(self, parent, option, index): if isinstance(index.data(), str): # Make row just a little bit taller row = index.row() row_height = self.parent().rowHeight(row) self.parent().setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT) return QPlainTextEdit(parent) return super().createEditor(parent, option, index) def eventFilter(self, editor: QObject, event: QEvent): """By default, QPlainTextEdit doesn't handle enter or return""" if event.type() == QEvent.KeyPress: key_event = cast(QKeyEvent, event) if key_event.key() == Qt.Key_Return: if key_event.modifiers() == Qt.ControlModifier: self.commitData.emit(editor) self.closeEditor.emit(editor) return super().eventFilter(editor, event) class PlaylistTab(QTableWidget): # Qt.UserRoles 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, playlist_id: int, signals: "MusicMusterSignals") -> None: super().__init__() self.musicmuster: Window = musicmuster self.playlist_id = playlist_id self.signals = signals # Set up widget self.menu: Optional[QMenu] = None self.setItemDelegate(NoSelectDelegate(self)) self.setEditTriggers(QAbstractItemView.DoubleClicked) self.setAlternatingRowColors(True) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.setRowCount(0) self.setColumnCount(len(columns)) self.v_header = self.verticalHeader() self.v_header.setMinimumSectionSize(Config.MINIMUM_ROW_HEIGHT) self.horizontalHeader().setStretchLastSection(True) # Header row for idx in [a for a in range(len(columns))]: item = QTableWidgetItem() self.setHorizontalHeaderItem(idx, item) self.horizontalHeader().setMinimumSectionSize(0) # Set column headings sorted by idx self.setHorizontalHeaderLabels( [a.heading for a in list(sorted(columns.values(), key=lambda item: item.idx))] ) self.horizontalHeader().sectionResized.connect( self.resizeRowsToContents) # Drag and drop setup self.setAcceptDrops(True) self.viewport().setAcceptDrops(True) self.setDragDropOverwriteMode(False) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.InternalMove) self.setDragEnabled(False) # This property defines how the widget shows a context menu self.setContextMenuPolicy(Qt.CustomContextMenu) # This signal is emitted when the widget's contextMenuPolicy is # Qt::CustomContextMenu, and the user has requested a context # menu on the widget. self.customContextMenuRequested.connect(self._context_menu) # Call self.eventFilter() for events self.viewport().installEventFilter(self) self.itemSelectionChanged.connect(self._select_event) self.search_text: str = "" self.edit_cell_type: Optional[int] self.selecting_in_progress = False # Connect signals self.horizontalHeader().sectionResized.connect(self._column_resize) self.signals.save_playlist_signal.connect(self._deferred_save) # Load playlist rows self.populate_display(session, self.playlist_id) def __repr__(self) -> str: return f"" # ########## Events other than cell editing ########## def dropEvent(self, event: QDropEvent) -> None: """ Handle drag/drop of rows https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget """ if not event.source() == self: return # We don't accept external drops drop_row: int = self._drop_on(event) rows: List = sorted(set(item.row() for item in self.selectedItems())) rows_to_move = [ [QTableWidgetItem( self.item(row_index, column_index) # type: ignore ) for column_index in range(self.columnCount())] for row_index in rows ] for row_index in reversed(rows): self.removeRow(row_index) if row_index < drop_row: drop_row -= 1 for row_index, data in enumerate(rows_to_move): row_index += drop_row self.insertRow(row_index) for column_index, column_data in enumerate(data): self.setItem(row_index, column_index, column_data) event.accept() # The above doesn't handle column spans, which we use in note # rows. Check and fix: for row in range(drop_row, drop_row + len(rows_to_move)): if not self._get_row_track_id(row): self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns)) # Scroll to drop zone self.scrollToItem(self.item(row, 1)) # Reset drag mode to allow row selection by dragging self.setDragEnabled(False) super().dropEvent(event) with Session() as session: self.save_playlist(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""" if (event.type() == QEvent.MouseButtonPress and # noqa W504 event.buttons() == Qt.RightButton and # noqa W504 source is self.viewport()): self.menu = QMenu(self) item = self.itemAt(event.pos()) if item is not None: with Session() as session: row_number = item.row() plr_id = self._get_playlistrow_id(row_number) plr = session.get(PlaylistRows, plr_id) track_id = plr.track_id track_row = track_id is not None header_row = not track_row if track_row: current = ( row_number == self._get_current_track_row_number() ) next_row = ( row_number == self._get_next_track_row_number()) else: current = next_row = False # Mark unplayed / un-next sep = False if plr.played: sep = True act_unplay = self.menu.addAction("Mark unplayed") act_unplay.triggered.connect( lambda: self._mark_unplayed(plr)) if next_row: sep = True act_unnext = self.menu.addAction( "Unmark as next track") act_unnext.triggered.connect( lambda: self.mark_unnext()) if sep: self.menu.addSeparator() # Cut/paste act_cut = self.menu.addAction( "Mark for moving") act_cut.triggered.connect( lambda: self.musicmuster.cut_rows()) act_paste = self.menu.addAction( "Paste") act_paste.setDisabled( self.musicmuster.selected_plrs is None) act_paste.triggered.connect( lambda: self.musicmuster.paste_rows()) self.menu.addSeparator() if track_row: # Info act_info = self.menu.addAction('Info') act_info.triggered.connect( lambda: self._info_row(track_id) ) act_copypath = self.menu.addAction("Copy track path") act_copypath.triggered.connect( lambda: self._copy_path(row_number)) self.menu.addSeparator() # Play with mplayer act_mplayer = self.menu.addAction( "Play with mplayer") act_mplayer.triggered.connect( lambda: self._mplayer_play(track_id)) # Set next if not current and not next_row: act_setnext = self.menu.addAction("Set next") act_setnext.triggered.connect( lambda: self._set_next(session, row_number)) if not current: # Open in Audacity act_audacity = self.menu.addAction( "Open in Audacity") act_audacity.triggered.connect( lambda: self._open_in_audacity(track_id)) # Rescan act_rescan = self.menu.addAction("Rescan") act_rescan.triggered.connect( lambda: self._rescan(row_number, track_id) ) # Remove track act_remove_track = self.menu.addAction( 'Remove track') act_remove_track.triggered.connect( lambda: self._remove_track(row_number) ) self.menu.addSeparator() # Look up in wikipedia act_wikip = self.menu.addAction("Wikipedia") act_wikip.triggered.connect( lambda: self._wikipedia(row_number) ) # Look up in songfacts act_songfacts = self.menu.addAction("Songfacts") act_songfacts.triggered.connect( lambda: self._songfacts(row_number) ) self.menu.addSeparator() if header_row: # Add track to section header (ie, make this a track # row) act_add_track = self.menu.addAction('Add track') act_add_track.triggered.connect( lambda: self._add_track(row_number)) if not current and not next_row: # Remove row act_delete = self.menu.addAction('Remove row') act_delete.triggered.connect(self._delete_rows) self.menu.addSeparator() if not current and not next_row: act_move = self.menu.addAction('Move to playlist...') act_move.triggered.connect( self.musicmuster.move_selected) self.menu.addSeparator() return super(PlaylistTab, self).eventFilter(source, event) def mouseReleaseEvent(self, event): """ Enable dragging if rows are selected """ if self.selectedItems(): self.setDragEnabled(True) else: self.setDragEnabled(False) super().mouseReleaseEvent(event) # ########## Cell editing ########## # # We only want to allow cell editing on tracks, artists and notes, # although notes may be section headers. # # Once editing starts, we need to disable play controls so that a # 'return' doesn't play the next track. # # Earlier in this file: # self.setEditTriggers(QAbstractItemView.DoubleClicked) - triggers # editing on double-click # # Call sequences: # Start editing: # edit() # _cell_edit_started() # End editing: # _cell_changed() (only if changes made) # closeEditor() # _cell_edit_ended() def _cell_changed(self, row: int, column: int) -> None: """Called when cell content has changed""" # Disable cell changed signal connection as note updates will # change cell again (metadata) self.cellChanged.disconnect(self._cell_changed) cell = self.item(row, column) if not cell: return new_text = cell.text().strip() # Update cell with strip()'d text cell.setText(new_text) track_id = self._get_row_track_id(row) # Determine cell type changed with Session() as session: # Get playlistrow object plr_id = self._get_playlistrow_id(row) plr_item = session.get(PlaylistRows, plr_id) if not plr_item: return # Note any updates needed to PlaylistTrack objects update_current = self.musicmuster.current_track.plr_id == plr_id update_next = self.musicmuster.next_track.plr_id == plr_id if self.edit_cell_type == ROW_NOTES: plr_item.note = new_text # Set/clear row start time accordingly start_time = self._get_note_text_time(new_text) if start_time: self._set_row_start_time(row, start_time) else: self._set_row_start_time(row, None) # Update note display self._set_row_note(session, row, new_text) # If this is a header row, ecalcuate track times in case # note added a start time if not track_id: self._update_start_end_times() else: track = None if track_id: track = session.get(Tracks, track_id) if track: if self.edit_cell_type == TITLE: track.title = new_text if update_current: self.musicmuster.current_track.title = new_text if update_next: self.musicmuster.next_track.title = new_text elif self.edit_cell_type == ARTIST: track.artist = new_text if update_current: self.musicmuster.current_track.artist = \ new_text if update_next: self.musicmuster.next_track.artist = new_text if update_next or update_current: self.musicmuster.update_headers() self.clear_selection() def closeEditor(self, editor: QWidget, hint: QAbstractItemDelegate.EndEditHint) -> None: """ Override PySide2.QAbstractItemView.closeEditor to enable play controls and update display. """ # 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() self.musicmuster.actionSetNext.setEnabled(True) super(PlaylistTab, self).closeEditor(editor, hint) def edit(self, index: QModelIndex, # type: ignore # FIXME trigger: QAbstractItemView.EditTrigger, event: QEvent) -> bool: """ Override PySide2.QAbstractItemView.edit to catch when editing starts """ result = super(PlaylistTab, self).edit(index, trigger, event) if result: # will only be true on double-clicke row = index.row() column = index.column() # Is this a track row? track_row = self._get_row_track_id(row) note_column = 0 if track_row: # If a track row, we only allow editing of title, artist and # note. Check that this column is one of those. if column in [TITLE, ARTIST, ROW_NOTES]: self.edit_cell_type = column else: # Can't edit other columns return False # Check whether we're editing a notes row for later if self.edit_cell_type == ROW_NOTES: note_column = ROW_NOTES else: # This is a section header. if column != HEADER_NOTES_COLUMN: return False note_column = HEADER_NOTES_COLUMN self.edit_cell_type = ROW_NOTES # Disable play controls so that keyboard input doesn't # disturb playing self.musicmuster.disable_play_next_controls() self.musicmuster.actionSetNext.setEnabled(False) # If this is a note cell, we need to remove any existing section # timing so user can't edit that. Keep it simple: refresh text # from database. Note column will only be non-zero if we are # editing a note. if note_column: with Session() as session: plr_id = self._get_playlistrow_id(row) plr_item = session.get(PlaylistRows, plr_id) item = self.item(row, note_column) if not item: return False if not plr_item: return False if not plr_item.note: plr_item.note = '' item.setText(plr_item.note) # Connect signal so we know when cell has changed. self.cellChanged.connect(self._cell_changed) return result # # ########## Externally called functions ########## def clear_selection(self) -> None: """Unselect all tracks and reset drag mode""" self.clearSelection() self.setDragEnabled(False) def get_new_row_number(self) -> int: """ Return the selected row or the row count if no row selected (ie, new row will be appended) """ if self.selectionModel().hasSelection(): return self.currentRow() else: return self.rowCount() def get_selected_playlistrow_ids(self) -> Optional[List]: """ Return a list of PlaylistRow ids of the selected rows """ return [self._get_playlistrow_id(a) for a in self._get_selected_rows()] def get_selected_playlistrows( self, session: scoped_session) -> List[PlaylistRows]: """ Return a list of PlaylistRows of the selected rows """ plr_ids = self.get_selected_playlistrow_ids() if not plr_ids: return [] plrs = [session.get(PlaylistRows, a) for a in plr_ids] return [plr for plr in plrs if plr is not None] def hide_played_tracks(self, hide: bool) -> None: """Hide played tracks if hide is True else show them""" with Session() as session: played = [ p.row_number for p in PlaylistRows.get_played_rows( session, self.playlist_id) ] for row in range(self.rowCount()): if row in played: if hide: self.hideRow(row) else: self.showRow(row) def insert_header(self, session: scoped_session, note: str) -> None: """ Insert section header into playlist tab. If a row is selected, add header above. Otherwise, add to end of playlist. We simply build a PlaylistRows object and pass it to insert_row() to do the heavy lifing. """ row_number = self.get_new_row_number() plr = PlaylistRows(session, self.playlist_id, None, row_number, note) self.insert_row(session, plr) self.save_playlist(session) def insert_row(self, session: scoped_session, plr: PlaylistRows, update_track_times: bool = True, played=False) -> None: """ Insert passed playlist row (plr) into playlist tab. """ if plr.row_number is None: return row = plr.row_number self.insertRow(row) # Add row metadata to userdata column self._set_row_userdata(row, self.PLAYLISTROW_ID, plr.id) if plr.track_id: _ = 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)) 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 if plr.note is None: log.debug( f"insert_row({plr=}) with no track_id and no note" ) return # In order to colour the row, we need items in every column. # Bug in PyQt5 means that required height of row considers # text to be wrapped in one column and ignores any spanned # columns, hence putting notes in HEADER_NOTES_COLUMN which # is typically reasonably wide and thus minimises # unneccessary row height increases. for i in range(1, len(columns)): if i == HEADER_NOTES_COLUMN: continue 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) note_colour = NoteColours.get_colour(session, plr.note) if not note_colour: note_colour = Config.COLOUR_NOTES_PLAYLIST self._set_row_colour(row, QColor(note_colour)) # Save (or clear) track_id _ = self._set_row_userdata(row, self.ROW_TRACK_ID, 0) if update_track_times: # Queue time updates so playlist updates first QTimer.singleShot(0, lambda: self._update_start_end_times()) def insert_track(self, session: scoped_session, track: Tracks, note: Optional[str] = None, repaint: bool = True) -> None: """ Insert track into playlist tab. If a row is selected, add track above. Otherwise, add to end of playlist. We simply build a PlaylistRows object and pass it to insert_row() to do the heavy lifing. """ if not track: log.debug( f"insert_track({session=}, {note=}, {repaint=}" " called with no track" ) return row_number = self.get_new_row_number() # Check to see whether track is already in playlist existing_plr = PlaylistRows.get_track_plr(session, track.id, self.playlist_id) if existing_plr and ask_yes_no("Duplicate row", "Track already in playlist. " "Move to new location?"): # Yes it is and we should reuse it # If we've been passed a note, we need to add that to the # existing track if note: existing_plr.append_note(note) return self._move_row(session, existing_plr, row_number) # Build playlist_row object plr = PlaylistRows(session, self.playlist_id, track.id, row_number, note) self.insert_row(session, plr) # Let display update, then save playlist QTimer.singleShot(0, lambda: self.save_playlist(session)) def mark_unnext(self) -> None: """ Unmark passed row as next track """ row = self._get_next_track_row_number() if not row: return self.musicmuster.clear_next() self.clear_selection() self._set_row_colour(row, None) self.musicmuster.update_headers() def play_started(self, session: scoped_session) -> None: """ Notification from musicmuster that track has started playing. Actions required: - Mark current row as played - Set next track - Display track as current - Update start/stop times """ current_row = self._get_current_track_row_number() if current_row is None: send_mail(Config.ERRORS_TO, Config.ERRORS_FROM, "MusicMuster unexpected failure", stackprinter.format() ) return # Mark current row as played self._set_played_row(session, current_row) # Set next track next_row = self._find_next_track_row(session, current_row + 1) if next_row: self._set_next(session, next_row) # 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: """ Populate display from the associated playlist ID """ # Sanity check row numbering before we load PlaylistRows.fixup_rownumbers(session, playlist_id) # 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: send_mail(Config.ERRORS_TO, Config.ERRORS_FROM, "MusicMuster unexpected failure", stackprinter.format() ) return for plr in playlist.rows: 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: row0_item = self.item(0, 0) if row0_item: self.scrollToItem(row0_item, QAbstractItemView.PositionAtTop) # Set widths self._set_column_widths(session) # Needed to wrap notes column correctly - add to event queue so # 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""" # Remove rows from display. Do so in reverse order so that # row numbers remain valid. for row in sorted(row_numbers, reverse=True): self.removeRow(row) def reset_plr_row_colour(self, plr_id: int) -> None: """Reset background of row pointed to by plr_id""" row = self._plrid_to_row_number(plr_id) if not row: return self._set_row_colour(row, None) def save_playlist(self, session: scoped_session) -> None: """ Get the PlaylistRow objects for each row in the display. Correct the row_number and playlist_id if necessary. Remove any row numbers in the database that are higher than the last row in the display. """ # Ensure all row plrs have correct row number and playlist_id for row in range(self.rowCount()): plr = self._get_playlistrow_object(session, row) if not plr: continue plr.row_number = row plr.playlist_id = self.playlist_id # Any rows in the database for this playlist that has a row # number equal to or greater than the row count needs to be # removed. session.flush() PlaylistRows.delete_higher_rows( session, self.playlist_id, self.rowCount() - 1) def scroll_current_to_top(self) -> None: """Scroll currently-playing row to top""" current_row = self._get_current_track_row_number() if current_row is not None: self._scroll_to_top(current_row) def scroll_next_to_top(self) -> None: """Scroll nextly-playing row to top""" next_row = self._get_next_track_row_number() if next_row is not None: self._scroll_to_top(next_row) def set_search(self, text: str) -> None: """Set search text and find first match""" self.search_text = text if not text: # Search string has been reset return self._search(next=True) def search_next(self) -> None: """ Select next row containg self.search_string. """ self._search(next=True) def search_previous(self) -> None: """ Select previous row containg self.search_string. """ self._search(next=False) def select_next_row(self) -> None: """ Select next or first row. Don't select section headers. Wrap at last row. """ selected_rows = self._get_selected_rows() # we will only handle zero or one selected rows if len(selected_rows) > 1: return # select first row if none selected if len(selected_rows) == 0: row = 0 else: row = selected_rows[0] + 1 if row >= self.rowCount(): row = 0 # Don't select section headers wrapped = False track_id = self._get_row_track_id(row) while not track_id: row += 1 if row >= self.rowCount(): if wrapped: # we're already wrapped once, so there are no # non-headers return row = 0 wrapped = True track_id = self._get_row_track_id(row) self.selectRow(row) def select_previous_row(self) -> None: """ Select previous or last track. Don't select section headers. Wrap at first row. """ selected_rows = self._get_selected_rows() # we will only handle zero or one selected rows if len(selected_rows) > 1: return # select last row if none selected last_row = self.rowCount() - 1 if len(selected_rows) == 0: row = last_row else: row = selected_rows[0] - 1 if row < 0: row = last_row # Don't select section headers wrapped = False track_id = self._get_row_track_id(row) while not track_id: row -= 1 if row < 0: if wrapped: # we're already wrapped once, so there are no # non-notes return row = last_row wrapped = True track_id = self._get_row_track_id(row) self.selectRow(row) def set_selected_as_next(self) -> None: """Sets the select track as next to play""" row = self._get_selected_row() if row is not None: with Session() as session: self._set_next(session, row) def tab_visible(self) -> None: """Called when tab becomes visible""" # Set row heights self.resizeRowsToContents() self.setColumnWidth(len(columns) - 1, 0) # 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 # """ # 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 # # 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()): # # 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) # 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 # # 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)) # # 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 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) # 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() # # 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 ########## def _add_track(self, row: int) -> None: """Add a track to a section header making it a normal track row""" with Session() as session: track = self.musicmuster.get_one_track(session) if not track: return # Add track to playlist row plr = session.get(PlaylistRows, self._get_playlistrow_id(row)) if not plr: return # Don't add track if there's already a track there if plr.track_id is not None: return plr.track_id = track.id session.flush() # Reset row span for column in range(len(columns)): self.setSpan(row, column, 1, 1) # Update attributes of row _ = 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) def _calculate_end_time(self, start: Optional[datetime], duration: int) -> Optional[datetime]: """Return datetime 'duration' ms after 'start'""" if start is None: return None return start + timedelta(milliseconds=duration) def _column_resize(self, idx: int, _old: int, _new: int) -> None: """ Called when column widths are changed. Save column sizes to database """ with Session() as session: for column_name, data in columns.items(): idx = data.idx if idx == len(columns) - 1: # Don't set width of last column as it's set to # stretch continue width = self.columnWidth(idx) attribute_name = f"playlist_{column_name}_col_width" record = Settings.get_int_settings(session, attribute_name) if record.f_int != self.columnWidth(idx): record.update(session, {'f_int': width}) def _context_menu(self, pos): """Display right-click menu""" assert self.menu self.menu.exec_(self.mapToGlobal(pos)) def _copy_path(self, row: int) -> None: """ If passed row has a track, copy the track path, single-quoted, to the clipboard. Otherwise, return None. """ track_id = self._get_row_track_id(row) if track_id is None: return with Session() as session: track = session.get(Tracks, track_id) if track and track.path: # Escape single quotes and spaces in name path = track.path pathq = path.replace("'", "\\'") pathqs = pathq.replace(" ", "\\ ") cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(pathqs, mode=cb.Clipboard) def _deferred_save(self) -> None: """ Create session and save playlist """ print("_deferred_save() called") with Session() as session: self.save_playlist(session) def _delete_rows(self) -> None: """ Delete mutliple rows Actions required: - Remove the rows from the display - Save the playlist """ with Session() as session: plrs = self.get_selected_playlistrows(session) row_count = len(plrs) if not row_count: return # Get confirmation plural = 's' if row_count > 1 else '' if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): return rows_to_delete = [a.row_number for a in plrs] # Delete rows from database. Would be more efficient to # query then have a single delete. for plr in plrs: session.delete(plr) # Remove from display self.remove_rows(rows_to_delete) # Reset drag mode self.setDragEnabled(False) # QTimer.singleShot(0, lambda: self._deferred_save()) self.signals.save_playlist_signal.emit() def _drop_on(self, event): """ https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget """ index = self.indexAt(event.pos()) if not index.isValid(): return self.rowCount() return (index.row() + 1 if self._is_below(event.pos(), index) else index.row()) def _find_next_track_row(self, session: scoped_session, starting_row: Optional[int] = None) \ -> Optional[int]: """ Find next track to play. If a starting row is given, start there; otherwise, start from top. Skip rows already played. If not found, return None. If found, return row number. """ if starting_row is None: starting_row = 0 track_rows = [ p.row_number for p in PlaylistRows.get_rows_with_tracks( session, self.playlist_id) ] played_rows = [ p.row_number for p in PlaylistRows.get_played_rows( session, self.playlist_id) ] for row in range(starting_row, self.rowCount()): if row not in track_rows or row in played_rows: continue plr = self._get_playlistrow_object(session, row) if not plr: continue if not file_is_readable(plr.track.path): continue else: return row 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_start_time(self) -> Optional[datetime]: """ Return current track start 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.start_time def _get_current_track_row_number(self) -> Optional[int]: """Return current track row or None""" current_track = self.musicmuster.current_track if not current_track or not current_track.plr_id: return None return self._plrid_to_row_number(current_track.plr_id) def _get_next_track_row_number(self) -> Optional[int]: """Return next track row or None""" next_track = self.musicmuster.next_track if not next_track or not next_track.plr_id: return None return self._plrid_to_row_number(next_track.plr_id) @staticmethod def _get_note_text_time(text: str) -> Optional[datetime]: """Return time specified as @hh:mm:ss in text""" try: match = start_time_re.search(text) except TypeError: return None if not match: return None try: return datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT) 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""" plrid = self._get_row_userdata(row, self.PLAYLISTROW_ID) if plrid is None: return None return int(plrid) def _get_playlistrow_object(self, session: scoped_session, row: int) -> Optional[PlaylistRows]: """Return the playlistrow object associated with this row""" playlistrow_id = self._get_playlistrow_id(row) if not playlistrow_id: return None return session.get(PlaylistRows, playlistrow_id) def _get_row_artist(self, row: int) -> Optional[str]: """Return artist on this row or None if none""" item_artist = self.item(row, ARTIST) if not item_artist: return None return item_artist.text() def _get_row_duration(self, row: int) -> int: """Return duration associated with this row""" duration_userdata = self._get_row_userdata(row, self.ROW_DURATION) if not duration_userdata: return 0 else: return int(duration_userdata) def _get_row_note(self, row: int) -> Optional[str]: """Return note on this row or None if none""" track_id = self._get_row_track_id(row) if track_id: item_note = self.item(row, ROW_NOTES) else: item_note = self.item(row, HEADER_NOTES_COLUMN) if not item_note: return None 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""" start_time_item = self.item(row, START_TIME) if not start_time_item: return None try: return datetime.strptime(start_time_item.text(), Config.NOTE_TIME_FORMAT) except ValueError: return None def _get_row_title(self, row: int) -> Optional[str]: """Return title on this row or None if none""" # Header rows may have note in TITLE row so check for track_id if not self._get_row_track_id(row): 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 None return userdata_item.data(role) def _get_selected_row(self) -> Optional[int]: """Return row number of first selected row, or None if none selected""" if not self.selectionModel().hasSelection(): return None else: return self.selectionModel().selectedRows()[0].row() def _get_selected_rows(self) -> List[int]: """Return a list of selected row numbers sorted by row""" # Use a set to deduplicate result (a selected row will have all # items in that row selected) return sorted( [row for row in set([a.row() for a in self.selectedItems()])] ) def _info_row(self, track_id: int) -> None: """Display popup with info re row""" with Session() as session: track = session.get(Tracks, track_id) if track: txt = ( f"Title: {track.title}\n" f"Artist: {track.artist}\n" f"Track ID: {track.id}\n" f"Track duration: {ms_to_mmss(track.duration)}\n" f"Track bitrate: {track.bitrate}\n" f"Track fade at: {ms_to_mmss(track.fade_at)}\n" f"Track silence at: {ms_to_mmss(track.silence_at)}" "\n\n" f"Path: {track.path}\n" ) else: txt = f"Can't find {track_id=}" info: QMessageBox = QMessageBox(self) info.setIcon(QMessageBox.Information) info.setText(txt) info.setStandardButtons(QMessageBox.Ok) info.setDefaultButton(QMessageBox.Cancel) info.exec() def _is_below(self, pos, index): # review """ https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget """ rect = self.visualRect(index) margin = 2 if pos.y() - rect.top() < margin: return False elif rect.bottom() - pos.y() < margin: return True return ( rect.contains(pos, True) and not (int(self.model().flags(index)) & Qt.ItemIsDropEnabled) and pos.y() >= rect.center().y() # noqa W503 ) def _mark_unplayed(self, plr: PlaylistRows) -> None: """ Mark passed row as unplayed in this playlist """ if not plr.row_number: return with Session() as session: session.add(plr) plr.played = False self._set_row_colour(plr.row_number, None) def _move_row(self, session: scoped_session, plr: PlaylistRows, new_row_number: int) -> None: """Move playlist row to new_row_number using parent copy/paste""" if plr.row_number is None: return # Remove source row self.removeRow(plr.row_number) # Fixup plr row number if plr.row_number < new_row_number: plr.row_number = new_row_number - 1 else: plr.row_number = new_row_number self.insert_row(session, plr) self.save_playlist(session) def _mplayer_play(self, track_id: int) -> None: """Play track with mplayer""" with Session() as session: track = session.get(Tracks, track_id) if not track: log.error( f"playlists._mplayer_play({track_id=}): " "Track not found" ) return cmd_list = ['gmplayer', '-vc', 'null', '-vo', 'null', track.path] thread = threading.Thread( target=self._run_subprocess, args=(cmd_list,)) thread.start() def _open_in_audacity(self, track_id: int) -> None: """Open track in Audacity. Audacity must be already running""" with Session() as session: track = session.get(Tracks, track_id) if not track: log.error( f"playlists._open_in_audacity({track_id=}): " "Track not found" ) return if track.path is None: log.error( f"playlists._open_in_audacity({track_id=}): " "Track has no path" ) else: open_in_audacity(track.path) def _plrid_to_row_number(self, plrid: int) -> Optional[int]: """ Return row number of passed plrid, or None if not found """ for row_number in range(self.rowCount()): if self._get_playlistrow_id(row_number) == plrid: return row_number return None def _remove_track(self, row: int) -> None: """Remove track from row, making it a section header""" # Get confirmation if not ask_yes_no("Remove music", "Really remove the music track from this row?"): return # Update playlist_rows record with Session() as session: plr = session.get(PlaylistRows, self._get_playlistrow_id(row)) if not plr: return plr.track_id = None # We can't have null text if not plr.note: plr.note = Config.TEXT_NO_TRACK_NO_NOTE session.flush() # Clear track text items for i in range(2, len(columns)): _ = self._set_item_text(row, i, "") # Remove row duration self._set_row_duration(row, 0) # Remote track_id from row _ = 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) 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""" with Session() as session: track = session.get(Tracks, track_id) if not track: log.error( f"playlists._rescan({track_id=}): " "Track not found" ) return set_track_metadata(session, track) # TODO: set readable/unreadable self._update_row(session, row, track) def _run_subprocess(self, args): """Run args in subprocess""" subprocess.call(args) def _scroll_to_top(self, row: int) -> None: """ Scroll to put passed row Config.SCROLL_TOP_MARGIN from the top. """ if row is None: return padding_required = Config.SCROLL_TOP_MARGIN top_row = row if row > Config.SCROLL_TOP_MARGIN: # We can't scroll to a hidden row. Calculate target_row as the # one that is ideal to be at the top. Then count upwards from # passed row until we either reach the target, pass it or reach # row 0. # target_row = max(0, row - Config.SCROLL_TOP_MARGIN + 1) for i in range(row - 1, -1, -1): if self.isRowHidden(i): continue if padding_required == 0: break top_row = i padding_required -= 1 scroll_item = self.item(top_row, 0) self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop) def _search(self, next: bool = True) -> None: """ Select next/previous row containg self.search_string. Start from top selected row if there is one, else from top. Wrap at last/first row. """ if not self.search_text: return selected_row = self._get_selected_row() if next: if selected_row is not None and selected_row < self.rowCount() - 1: starting_row = selected_row + 1 else: starting_row = 0 else: if selected_row is not None and selected_row > 0: starting_row = selected_row - 1 else: starting_row = self.rowCount() - 1 wrapped = False match_row = None row = starting_row needle = self.search_text.lower() while True: # Check for match in title, artist or notes title = self._get_row_title(row) if title and needle in title.lower(): match_row = row break artist = self._get_row_artist(row) if artist and needle in artist.lower(): match_row = row break note = self._get_row_note(row) if note and needle in note.lower(): match_row = row break if next: row += 1 if wrapped and row >= starting_row: break if row >= self.rowCount(): row = 0 wrapped = True else: row -= 1 if wrapped and row <= starting_row: break if row < 0: row = self.rowCount() - 1 wrapped = True if match_row is not None: self.selectRow(row) def _select_event(self) -> None: """ Called when item selection changes. 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() # If no rows are selected, we have nothing to do if len(selected_rows) == 0: self.musicmuster.lblSumPlaytime.setText("") return # If only one row is selected and it's a track row, show # Wikipedia page for that track if len(selected_rows) == 1: QTimer.singleShot(0, lambda: self._wikipedia(selected_rows[0])) ms = 0 for row in selected_rows: ms += self._get_row_duration(row) if ms > 0: self.musicmuster.lblSumPlaytime.setText( f"Selected duration: {ms_to_mmss(ms)}") else: self.musicmuster.lblSumPlaytime.setText("") def _set_column_widths(self, session: scoped_session) -> None: """Column widths from settings""" for column_name, data in columns.items(): idx = data.idx if idx == len(columns) - 1: # Set width of last column to zero as it's set to stretch self.setColumnWidth(idx, 0) continue attr_name = f"playlist_{column_name}_col_width" record: Settings = Settings.get_int_settings(session, attr_name) if record and record.f_int >= 0: self.setColumnWidth(idx, record.f_int) else: self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH) 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: """ Set passed row as next playlist row to play. Actions required: - Check row has a track - Check track is readable - Notify musicmuster - Display row as next track - Update start/stop times """ # Check row has a track track_id = self._get_row_track_id(row_number) if not track_id: log.error( f"playlists._set_next({row_number=}) has no track associated" ) return track = session.get(Tracks, track_id) if not track: log.error(f"playlists._set_next({row_number=}): Track not found") return # Check track is readable 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: log.debug(f"playists._set_next({row_number=}) can't retrieve plr") else: self.musicmuster.this_is_the_next_playlist_row(session, plr, self) # 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_start_end_times() def _set_played_row(self, session: scoped_session, row: int) -> None: """Mark this row as played""" plr = session.get(PlaylistRows, self._get_playlistrow_id(row)) if not plr: return 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. Don't make notes column bold. """ boldfont = QFont() boldfont.setBold(bold) for column in range(self.columnCount()): if column == ROW_NOTES: continue item = self.item(row, column) if item: item.setFont(boldfont) def _set_row_colour(self, row: int, colour: Optional[QColor] = None) -> None: """ Set or reset row background colour """ if colour: brush = QBrush(colour) else: brush = QBrush() for column in range(1, self.columnCount()): # Don't change colour on start gap columns if column == START_GAP: continue item = self.item(row, column) if item: item.setBackground(brush) def _set_row_duration(self, row: int, ms: Optional[int]) -> QTableWidgetItem: """Set duration of this row. Also set in row metadata""" duration_item = self._set_item_text(row, DURATION, ms_to_mmss(ms)) self._set_row_userdata(row, self.ROW_DURATION, ms) return duration_item 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 = "" 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_note(self, session: scoped_session, row: int, note_text: Optional[str]) -> QTableWidgetItem: """Set row note""" 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 = "" 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_times(self, row: int, start: datetime, duration: int) -> datetime: """ Set row start and end times, return end time """ self._set_row_start_time(row, start) end_time = self._calculate_end_time(start, duration) self._set_row_end_time(row, end_time) return end_time 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: """Return string describing section duration""" duration = ms_to_mmss(ms) caveat = "" if no_end: caveat = " (to end of playlist)" return ' [' + duration + caveat + ']' def _songfacts(self, row_number: int) -> None: """Look up passed row title in songfacts and display info tab""" title = self._get_row_title(row_number) self.musicmuster.tabInfolist.open_in_songfacts(title) def _update_note_text(self, playlist_row: PlaylistRows, new_text: str) -> None: """Update note text""" # 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: column = ROW_NOTES else: column = HEADER_NOTES_COLUMN _ = self._set_item_text(playlist_row.row_number, column, new_text) def _update_row(self, session, row: int, track: Tracks) -> None: """ Update the passed row with info from the passed track. """ _ = 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_section_headers(self, session: scoped_session) -> None: """ Update section headers with run time of section """ header_rows = [] # Get section header PlaylistRows plrs = PlaylistRows.get_section_header_rows(session, self.playlist_id) for plr in plrs: if plr.note.endswith("+"): header_rows.append(plr) else: try: from_plr = header_rows.pop() except IndexError: pass # section runs from from_plr to plr from_row = from_plr.row_number plr_tracks = PlaylistRows.get_rows_with_tracks( session, self.playlist_id, from_row, plr.row_number) total_time = 0 total_time = sum([a.track.duration for a in plr_tracks]) time_str = self._get_section_timing_string(total_time) self._update_note_text(from_plr, from_plr.note + time_str) # Update section end if plr.note.strip() == "-": new_text = ( "[End " + from_plr.note.strip()[:-1].strip() + "]" ) self._update_note_text(plr, new_text) def _update_start_end_times(self) -> None: """ Update track start and end times """ with Session() as session: section_start_rows = [] current_track_end_time = self._get_current_track_end_time() current_track_row = self._get_current_track_row_number() current_track_start_time = self._get_current_track_start_time() next_start_time = None 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 # Get any timing from header row (that's all we need) if self._get_row_track_id(row) == 0: note_time = self._get_note_text_time( self._get_row_note(row)) if note_time: next_start_time = note_time continue # We have a track. Skip if it is unreadable if not file_is_readable(self._get_row_path(row)): continue # Set next track start from end of current track if row == next_track_row: if current_track_end_time: next_start_time = self._set_row_times( row, current_track_end_time, self._get_row_duration(row)) continue # Else set track times below if row == current_track_row: if not current_track_start_time: continue self._set_row_start_time(row, current_track_start_time) self._set_row_end_time(row, current_track_end_time) # Next track may be above us so only reset # next_start_time if it's not set if not next_start_time: next_start_time = current_track_end_time continue if not next_start_time: # Clear any existing times self._set_row_start_time(row, None) self._set_row_end_time(row, None) 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: next_start_time = self._set_row_times( row, next_start_time, self._get_row_duration(row)) self._update_section_headers(session) def _wikipedia(self, row_number: int) -> None: """Look up passed row title in Wikipedia and display info tab""" title = self._get_row_title(row_number) if not title: return self.musicmuster.tabInfolist.open_in_wikipedia(title)