import re import stackprinter # type: ignore import subprocess import threading from collections import namedtuple from datetime import datetime, timedelta from typing import List, Optional from PyQt5.QtCore import ( pyqtSignal, QEvent, QModelIndex, QObject, QSize, Qt, QTimer, ) from PyQt5.QtGui import ( QBrush, QColor, QFont, QDropEvent, ) 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 ) start_time_re = re.compile(r"@\d\d:\d\d:\d\d") HEADER_NOTES_COLUMN = 2 MINIMUM_ROW_HEIGHT = 30 # 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 """ 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 + 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 and event.key() == Qt.Key_Return: if event.modifiers() == Qt.ControlModifier: self.commitData.emit(editor) self.closeEditor.emit(editor) return super().eventFilter(editor, event) class PlaylistTab(QTableWidget): # Qt.UserRoles ROW_FLAGS = Qt.UserRole ROW_TRACK_ID = Qt.UserRole + 1 ROW_DURATION = Qt.UserRole + 2 PLAYLISTROW_ID = Qt.UserRole + 3 def __init__(self, musicmuster: QMainWindow, session: scoped_session, playlist_id: int, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.musicmuster = musicmuster self.playlist_id = playlist_id self.menu: Optional[QMenu] = None # Don't select text on edit self.setItemDelegate(NoSelectDelegate(self)) # Set up widget 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(MINIMUM_ROW_HEIGHT) self.horizontalHeader().setStretchLastSection(True) # Header row for idx in [a for a in range(len(columns))]: item: QTableWidgetItem = 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) 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) self.viewport().installEventFilter(self) self.itemSelectionChanged.connect(self._select_event) self.search_text: str = "" self.edit_cell_type = None self.selecting_in_progress = False # Connect signals self.horizontalHeader().sectionResized.connect(self._column_resize) # self.horizontalHeader().sectionClicked.connect(self._header_click) # self.setSortingEnabled(True) # Now load our tracks and notes 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)) 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) log.debug( "playlist.dropEvent(): " f"Moved row(s) {rows} to become row {drop_row}" ) with Session() as session: # checked self.save_playlist(session) self.update_display(session) 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: row_number = item.row() track_id = self._get_row_track_id(row_number) track_row = track_id > 0 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 # 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") with Session() as session: 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) ) 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() # Remove track act_remove_track = self.menu.addAction('Remove track') act_remove_track.triggered.connect( lambda: self._remove_track(row_number) ) 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 # - self.setItemDelegate(NoSelectDelegate(self)) and associated class # ensure that the text is not selected when editing starts # # 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) new_text = self.item(row, column).text().strip() # Update cell with strip()'d text self.item(row, column).setText(new_text) track_id = self._get_row_track_id(row) # Determin cell type changed with Session() as session: # Get playlistrow object plr_id = self._get_playlistrow_id(row) plr_item = session.get(PlaylistRows, plr_id) # 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) 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() def closeEditor(self, editor: QWidget, hint: QAbstractItemDelegate.EndEditHint) -> None: """ Override PySide2.QAbstractItemView.closeEditor to enable 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) 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, 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. self.edit_cell_type = None 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) 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) -> Optional[List]: """ Return a list of PlaylistRows of the selected rows """ plr_ids = self.get_selected_playlistrow_ids() return [session.get(PlaylistRows, a) for a in plr_ids] def insert_header(self, session: scoped_session, note: str, repaint: bool = True) -> 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, repaint) self.save_playlist(session) def insert_row(self, session: scoped_session, plr: PlaylistRows, repaint: bool = True) -> None: """ Insert passed playlist row (plr) into playlist tab. """ row = plr.row_number self.insertRow(row) # Add row metadata to userdata column userdata_item = QTableWidgetItem() userdata_item.setData(self.ROW_FLAGS, 0) userdata_item.setData(self.PLAYLISTROW_ID, plr.id) userdata_item.setData(self.ROW_TRACK_ID, plr.track_id) self.setItem(row, USERDATA, userdata_item) if plr.track_id: # Add track details to items try: start_gap = plr.track.start_gap except AttributeError: return start_gap_item = QTableWidgetItem(str(start_gap)) if start_gap and start_gap >= 500: start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START)) self.setItem(row, START_GAP, start_gap_item) title_item = QTableWidgetItem(plr.track.title) self.setItem(row, TITLE, title_item) artist_item = QTableWidgetItem(plr.track.artist) self.setItem(row, ARTIST, artist_item) duration_item = QTableWidgetItem( ms_to_mmss(plr.track.duration)) self.setItem(row, DURATION, duration_item) self._set_row_duration(row, plr.track.duration) start_item = QTableWidgetItem() self.setItem(row, START_TIME, start_item) end_item = QTableWidgetItem() self.setItem(row, END_TIME, end_item) if plr.track.bitrate: bitrate = str(plr.track.bitrate) else: bitrate = "" bitrate_item = QTableWidgetItem(bitrate) self.setItem(row, BITRATE, bitrate_item) # As we have track info, any notes should be contained in # the notes column notes_item = QTableWidgetItem(plr.note) self.setItem(row, ROW_NOTES, notes_item) last_playtime = Playdates.last_played(session, plr.track.id) last_played_str = get_relative_date(last_playtime) last_played_item = QTableWidgetItem(last_played_str) self.setItem(row, LASTPLAYED, last_played_item) 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 # Make empty items (row background won't be coloured without # items present). Any notes should displayed starting in # column 2 for now - bug in Qt means that when row size is # set, spanned columns are ignored, so put notes in col2 # (typically title). for i in range(1, len(columns)): if i == 2: continue self.setItem(row, i, QTableWidgetItem()) self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1) notes_item = QTableWidgetItem(plr.note) self.setItem(row, HEADER_NOTES_COLUMN, notes_item) # Save (no) track_id userdata_item.setData(self.ROW_TRACK_ID, 0) if repaint: # Schedule so that display can update with new row first QTimer.singleShot(0, lambda: self.update_display(session)) 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, repaint) # Let display update, then save playlist QTimer.singleShot(0, lambda: self.save_playlist(session)) def play_started(self, session: scoped_session) -> None: """ Notification from musicmuster that track has started playing. Actions required: - Note start time - Mark next-track row as current - Mark current row as played - Scroll to put next track as required - Set next track - Update display """ 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 search_from = current_row + 1 # Mark current row as played self._set_played_row(session, current_row) next_row = self._find_next_track_row(session, search_from) if next_row: self._set_next(session, next_row) # Update display self.update_display(session) 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) # 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, repaint=False) # Scroll to top if scroll_to_top: scroll_to: QTableWidgetItem = self.item(0, 0) self.scrollToItem(scroll_to, 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 populated QTimer.singleShot(0, self.tab_visible) # We possibly don't need to save the playlist here, but row # numbers may have changed during population, and it's cheap to do # self.save_playlist(session) self.update_display(session) 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 remove_selected_rows(self) -> None: """Remove selected rows from display""" self.remove_rows(self._get_selected_rows()) # Reset drag mode self.setDragEnabled(False) 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. """ # Build a dictionary of # {display_row_number: display_row_plr_id} display_plr_ids = {row_number: self._get_playlistrow_id(row_number) for row_number in range(self.rowCount())} # Now build a dictionary of # {display_row_number: display_row_plr} plr_dict_by_id = PlaylistRows.indexed_by_id(session, display_plr_ids.values()) # Finally a dictionary of # {display_row_number: plr} row_plr = {row_number: plr_dict_by_id[display_plr_ids[row_number]] for row_number in range(self.rowCount())} # Ensure all row plrs have correct row number and playlist_id for row in range(self.rowCount()): row_plr[row].row_number = row row_plr[row].playlist_id = self.playlist_id # Any rows in the database for this playlist that have a plr id # that's not in the displayed playlist need to be deleted. # Ensure changes flushed session.commit() PlaylistRows.delete_plrids_not_in_list(session, self.playlist_id, display_plr_ids.values()) def scroll_current_to_top(self) -> None: """Scroll currently-playing row to top""" current_row = self._get_current_track_row_number() 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() 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. """ row: int selected_rows: List[int] 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: bool = 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-notes 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. """ row: int selected_rows: List[int] 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: bool = 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 None: return 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) with Session() as session: self.update_display(session) 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)) note_text = playlist_row.note # Get note colour 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.commit() note_item = QTableWidgetItem(note_text) self.setItem(row, HEADER_NOTES_COLUMN, note_item) 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: (self.item(row, ROW_NOTES) .setBackground(QColor(note_colour))) # Highlight low bitrates if track.bitrate: bitrate_str = str(track.bitrate) bitrate_item = self.item(row, BITRATE) if bitrate_item.text() != bitrate_str: bitrate_item.setText(bitrate_str) 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)) self.item(row, BITRATE).setBackground(brush) # Render playing track if row == current_row: # Set last played time to "Today" self.item(row, LASTPLAYED).setText("Today") # Calculate next_start_time 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 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.item(row, LASTPLAYED).setText( 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) 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)) plr.track_id = track.id session.commit() # Reset row span for column in range(len(columns)): self.setSpan(row, column, 1, 1) # Update attributes of row self.item(row, USERDATA).setData(self.ROW_TRACK_ID, track.id) start_gap_item = self.item(row, START_GAP) start_gap_item.setText(str(track.start_gap)) if track.start_gap and track.start_gap >= 500: start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START)) self.item(row, TITLE).setText(str(track.title)) self.item(row, ARTIST).setText(str(track.artist)) self.item(row, DURATION).setText(ms_to_mmss(track.duration)) last_playtime = Playdates.last_played(session, track.id) last_played_str = get_relative_date(last_playtime) self.item(row, LASTPLAYED).setText(last_played_str) self.item(row, ROW_NOTES).setText(plr.note) self.update_display(session) 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: # 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 _delete_rows(self) -> None: """ Delete mutliple rows Actions required: - Remove the rows from the display - Save the playlist """ # Delete rows from database plr_ids = self.get_selected_playlistrow_ids() # Get confirmation row_count = len(plr_ids) plural = 's' if row_count > 1 else '' if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): return self.remove_selected_rows() with Session() as session: QTimer.singleShot(0, lambda: self.save_playlist(session)) 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: 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()): plr = self._get_playlistrow_object(session, row) if ( row not in track_rows or row in played_rows or not file_is_readable(plr.track.path) ): continue else: return row return None def _get_current_track_row_number(self) -> Optional[int]: """Return current track row or None""" current_track = self.musicmuster.current_track 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 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_playlistrow_id(self, row: int) -> int: """Return the playlistrow_id associated with this row""" playlistrow_id = (self.item(row, USERDATA).data(self.PLAYLISTROW_ID)) return playlistrow_id def _get_playlistrow_object(self, session: scoped_session, row: int) -> int: """Return the playlistrow object associated with this row""" playlistrow_id = (self.item(row, USERDATA).data(self.PLAYLISTROW_ID)) return session.get(PlaylistRows, playlistrow_id) 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) return item_artist.text() def _get_row_duration(self, row: int) -> int: """Return duration associated with this row""" duration = (self.item(row, USERDATA).data(self.ROW_DURATION)) if duration: return duration else: return 0 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) return item_note.text() def _get_row_start_time(self, row: int) -> Optional[datetime]: try: if self.item(row, START_TIME): return datetime.strptime(self.item( row, START_TIME).text(), Config.NOTE_TIME_FORMAT ) else: return None except ValueError: return None 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) return item_title.text() def _get_row_track_id(self, row: int) -> int: """Return the track_id associated with this row or None""" try: track_id = (self.item(row, USERDATA) .data(self.ROW_TRACK_ID)) except AttributeError: return None return track_id 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 _move_row(self, session: scoped_session, plr: PlaylistRows, new_row_number: int) -> None: """Move playlist row to new_row_number using parent copy/paste""" # 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 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)) plr.track_id = None # We can't have null text if not plr.note: plr.note = Config.TEXT_NO_TRACK_NO_NOTE session.commit() # Clear track text items for i in range(2, len(columns)): self.item(row, i).setText("") # Remove row duration self._set_row_duration(row, 0) # Remote track_id from row self.item(row, USERDATA).setData(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.item(row, HEADER_NOTES_COLUMN).setText(plr.note) # And refresh display self.update_display(session) 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) 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 padding_required == 0: break if self.isRowHidden(i): continue 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 ms = 0 for row in selected_rows: ms += self._get_row_duration(row) # Only paint message if there are selected track rows 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 is not None: self.setColumnWidth(idx, record.f_int) else: self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH) 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 - Update display """ 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 # Notify musicmuster plr = session.get(PlaylistRows, self._get_playlistrow_id(row_number)) self.musicmuster.this_is_the_next_playlist_row(session, plr, self) # Update display self.clear_selection() self.update_display(session) 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.commit() 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 if self.item(row, column): self.item(row, column).setFont(boldfont) def _set_row_colour(self, row: int, colour: Optional[QColor] = None) -> None: """ Set or reset row background colour """ column: int 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 if self.item(row, column): self.item(row, column).setBackground(brush) def _set_row_duration(self, row: int, ms: int) -> None: """Set duration of this row in row metadata""" self.item(row, USERDATA).setData(self.ROW_DURATION, ms) def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None: """Set passed row end time to passed time""" try: time_str = time.strftime(Config.TRACK_TIME_FORMAT) except AttributeError: time_str = "" item = QTableWidgetItem(time_str) self.setItem(row, END_TIME, item) 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""" try: time_str = time.strftime(Config.TRACK_TIME_FORMAT) except AttributeError: time_str = "" item = QTableWidgetItem(time_str) self.setItem(row, START_TIME, item) def _get_section_timing_string(self, ms: int, no_end: bool = False) -> None: """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, additional_text: str) -> None: """Append additional_text to row display""" # 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 # Update text new_text = playlist_row.note + additional_text # FIXME temporary workaround to issue #147 try: self.item(playlist_row.row_number, column).setText(new_text) except AttributeError as exc: msg = f"Issue 147 occurred. {playlist_row=}, {additional_text=}" msg += "\n\n" msg += stackprinter.format(exc) helpers.send_mail(Config.ERRORS_TO, Confit.ERRORS_FROM, "Issue #147 from musicmuster", msg) def _update_row(self, session, row: int, track: Tracks) -> None: """ Update the passed row with info from the passed track. """ item_startgap = self.item(row, START_GAP) item_startgap.setText(str(track.start_gap)) if track.start_gap >= 500: item_startgap.setBackground(QColor(Config.COLOUR_LONG_START)) else: item_startgap.setBackground(QColor("white")) item_title = self.item(row, TITLE) item_title.setText(track.title) item_artist = self.item(row, ARTIST) item_artist.setText(track.artist) item_duration = self.item(row, DURATION) item_duration.setText(ms_to_mmss(track.duration)) item_bitrate = self.item(row, BITRATE) item_bitrate.setText(str(track.bitrate)) self.update_display(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) self.musicmuster.tabInfolist.open_in_wikipedia(title)