from typing import Optional from PyQt5 import QtCore from PyQt5.QtCore import Qt from PyQt5.Qt import QFont from PyQt5.QtGui import QColor, QDropEvent from PyQt5 import QtWidgets from PyQt5.QtWidgets import ( QAbstractItemView, QApplication, QMenu, QMessageBox, QTableWidget, QTableWidgetItem, ) from sqlalchemy import inspect import helpers import os from config import Config from datetime import datetime, timedelta from helpers import get_relative_date, open_in_audacity from log import DEBUG, ERROR from models import ( Notes, Playdates, Session, Settings, Tracks, NoteColours ) class RowMeta: CLEAR = 0 NOTE = 1 UNREADABLE = 2 NEXT = 4 CURRENT = 8 PLAYED = 16 class PlaylistTab(QTableWidget): cellEditingStarted = QtCore.pyqtSignal(int, int) cellEditingEnded = QtCore.pyqtSignal() # Column names COL_AUTOPLAY = COL_USERDATA = 0 COL_MSS = 1 COL_NOTE = 2 COL_TITLE = 2 COL_ARTIST = 3 COL_DURATION = 4 COL_START_TIME = 5 COL_END_TIME = 6 COL_LAST_PLAYED = COL_LAST = 7 NOTE_COL_SPAN = COL_LAST - COL_NOTE + 1 NOTE_ROW_SPAN = 1 # Qt.UserRoles ROW_METADATA = Qt.UserRole CONTENT_OBJECT = Qt.UserRole + 1 def __init__(self, parent, session, playlist, *args, **kwargs): super().__init__(*args, **kwargs) self.parent = parent # The MusicMuster process self.playlist = playlist self.playlist.mark_open(session) self.menu = None # Set up widget self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.setAlternatingRowColors(True) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) self.setRowCount(0) self.setColumnCount(8) # Add header row item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(0, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(1, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(2, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(3, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(4, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(5, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(6, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(7, item) self.horizontalHeader().setMinimumSectionSize(0) self._set_column_widths() self.setHorizontalHeaderLabels([ Config.COLUMN_NAME_AUTOPLAY, Config.COLUMN_NAME_LEADING_SILENCE, Config.COLUMN_NAME_TITLE, Config.COLUMN_NAME_ARTIST, Config.COLUMN_NAME_LENGTH, Config.COLUMN_NAME_START_TIME, Config.COLUMN_NAME_END_TIME, Config.COLUMN_NAME_LAST_PLAYED, ]) self.setDragEnabled(True) self.setAcceptDrops(True) self.viewport().setAcceptDrops(True) self.setDragDropOverwriteMode(False) self.setDropIndicatorShown(True) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setDragDropMode(QAbstractItemView.InternalMove) # This property defines how the widget shows a context menu self.setContextMenuPolicy(QtCore.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.editing_cell = False self.cellChanged.connect(self._cell_changed) self.doubleClicked.connect(self._edit_cell) self.cellEditingStarted.connect(self._cell_edit_started) self.cellEditingEnded.connect(self._cell_edit_ended) # Now load our tracks and notes self.populate(session) self.current_track_start_time = None def __repr__(self): return f"" # ########## Events ########## def dropEvent(self, event: QDropEvent): # if not event.isAccepted() and event.source() == self: if not event.source() == self: return # We don't accept external drops drop_row = self._drop_on(event) rows = 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: row = 0 # So row is defined even if there are no rows in range for row in range(drop_row, drop_row + len(rows_to_move)): if row in self._meta_get_notes(): self.setSpan( row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) # Scroll to drop zone self.scrollToItem(self.item(row, 1)) super().dropEvent(event) DEBUG( "playlist.dropEvent(): " f"Moved row(s) {rows} to become row {drop_row}" ) with Session() as session: self.save_playlist(session) self.update_display() def edit(self, index): result = super(PlaylistTab, self).edit(index) if result: self.cellEditingStarted.emit(index.row(), index.column()) return result def closeEditor(self, editor, hint): super(PlaylistTab, self).closeEditor(editor, hint) self.cellEditingEnded.emit() def eventFilter(self, source, event): """Used to process context (right-click) menu, which is defined here""" if (event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504 event.buttons() == QtCore.Qt.RightButton and # noqa W504 source is self.viewport()): item = self.itemAt(event.pos()) if item is not None: row = item.row() DEBUG(f"playlist.eventFilter(): Right-click on row {row}") current = row == self._meta_get_current() next_row = row == self._meta_get_next() self.menu = QMenu(self) act_info = self.menu.addAction('Info') act_info.triggered.connect(lambda: self._info_row(row)) self.menu.addSeparator() if row not in self._meta_get_notes(): if not current and not next_row: act_setnext = self.menu.addAction("Set next") act_setnext.triggered.connect( lambda: self._set_next(row)) act_copypath = self.menu.addAction("Copy track path") act_copypath.triggered.connect( lambda: self._copy_path(row)) if not current: act_rescan = self.menu.addAction("Rescan track") act_rescan.triggered.connect(lambda: self._rescan(row)) act_audacity = self.menu.addAction( "Open track in Audacity") act_audacity.triggered.connect( lambda: self._audacity(row)) if not current and not next_row: self.menu.addSeparator() act_delete = self.menu.addAction('Delete') act_delete.triggered.connect(self._delete_rows) return super(PlaylistTab, self).eventFilter(source, event) # ########## Externally called functions ########## def closeEvent(self, event): """Save column widths""" with Session() as session: for column in range(self.columnCount()): width = self.columnWidth(column) name = f"playlist_col_{str(column)}_width" record = Settings.get_int(session, name) if record.f_int != self.columnWidth(column): record.update(session, {'f_int': width}) event.accept() def clear_current(self): """Clear current track""" self._meta_clear_current() self.update_display() def clear_next(self): """Clear next track""" self._meta_clear_next() self.update_display() def get_selected_row(self): """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_and_tracks(self): """Return a list of selected (rows, track) tuples""" if not self.selectionModel().hasSelection(): return None result = [] for row in [r.row() for r in self.selectionModel().selectedRows()]: result.append((row, self._get_row_object(row))) return result def get_selected_title(self): """Return title of selected row or None""" if self.selectionModel().hasSelection(): row = self.currentRow() return self.item(row, self.COL_TITLE).text() else: return None def remove_rows(self, rows): """Remove rows passed in rows list""" # Row number will change as we delete rows. We could use # QPersistentModelIndex, but easier just to remove them in # reverse order. for row in sorted(rows, reverse=True): self.removeRow(row) with Session() as session: self.save_playlist(session) self.update_display() def play_started(self): """ Update current track to be what was next, and determine next track. Return None """ self.current_track_start_time = datetime.now() current_row = self._meta_get_next() self._meta_set_current(current_row) self._meta_set_played(current_row) # Scroll to put current track in centre scroll_to = self.item(current_row, self.COL_INDEX) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtCenter) # Set next track search_from = current_row + 1 next_row = self._find_next_track_row(search_from) self._set_next(next_row) self.update_display() def play_stopped(self): self._meta_clear_current() self.current_track_start_time = None self.update_display() def populate(self, session): """ Populate from the associated playlist object We don't mandate that an item will be on its specified row, only that it will be above larger-numbered row items, and below lower-numbered ones. """ data = [] # Make sure the database object is usable insp = inspect(self.playlist) if insp.detached: session.add(self.playlist) assert insp.persistent for row, track in self.playlist.tracks.items(): data.append(([row], track)) for note in self.playlist.notes: data.append(([note.row], note)) # Clear playlist self.setRowCount(0) # Now add data in row order for i in sorted(data, key=lambda x: x[0]): item = i[1] if isinstance(item, Tracks): self._insert_track(session, item, repaint=False) elif isinstance(item, Notes): self._insert_note(session, item, repaint=False) # Scroll to top scroll_to = self.item(0, self.COL_TITLE) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) # 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() def save_playlist(self, session): """ Save playlist to database. For notes: check the database entry is correct and update it if necessary. Playlists:Note is one:many, so each note may only appear in one playlist. For tracks: erase the playlist tracks and recreate. This is much simpler than trying to implement any Playlists:Tracks many:many changes. """ # Ensure we have a valid database class session.add(self.playlist) # Notes first # Create dictionaries indexed by note_id playlist_notes = {} database_notes = {} notes_rows = self._meta_get_notes() # PlaylistTab for row in notes_rows: note = self._get_row_object(row) session.add(note) playlist_notes[note.id] = note # Database for note in self.playlist.notes: database_notes[note.id] = note # We don't need to check for notes to add to the database as # they can't exist in the playlist without being in the database # and pointing at this playlist. # Notes to remove from database for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): DEBUG( "_save_playlist(): " f"Delete {note_id=} from {self=} in database" ) database_notes[note_id].delete_note(session) # Note rows to update in playlist database for note_id in set(playlist_notes.keys()) & set(database_notes.keys()): if playlist_notes[note_id].row != database_notes[note_id].row: DEBUG( f"_save_playlist(): Update notes row in database " f"from {database_notes[note_id]=} " f"to {playlist_notes[note_id]=}" ) database_notes[note_id].update_note( session, row=playlist_notes[note_id].row) # Tracks # Remove all tracks from this playlist self.playlist.remove_all_tracks(session) # Iterate on-screen playlist and add tracks back in for row in range(self.rowCount()): if row in notes_rows: continue track = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT) self.playlist.add_track(session, track, row) def select_next_row(self): """ Select next or first row. Don't select notes. Wrap at last row. """ selected_rows = [row for row in set([a.row() for a in self.selectedItems()])] # 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 notes wrapped = False while row in self._meta_get_notes(): row += 1 if row >= self.rowCount(): if wrapped: # we're already wrapped once, so there are no # non-notes return row = 0 wrapped = True self.selectRow(row) def select_played_tracks(self): """Select all played tracks in playlist""" self._select_tracks(played=True) def select_previous_row(self): """ Select previous or last track. Don't select notes. Wrap at first row. """ selected_rows = [row for row in set([a.row() for a in self.selectedItems()])] # 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 notes wrapped = False while row in self._meta_get_notes(): row -= 1 if row < 0: if wrapped: # we're already wrapped once, so there are no # non-notes return row = last_row wrapped = True self.selectRow(row) def select_unplayed_tracks(self): """Select all unplayed tracks in playlist""" self._select_tracks(played=False) def set_selected_as_next(self): """ Sets the selected track as the next track. """ if len(self.selectedItems()) != 1: return self._set_next(self.currentRow()) self.update_display() def update_display(self, clear_selection=True): """Set row colours, fonts, etc""" DEBUG(f"playlist.update_display [{self.playlist=}]") with Session() as session: if clear_selection: self.clearSelection() current_row = self._meta_get_current() next_row = self._meta_get_next() notes = self._meta_get_notes() played = self._meta_get_played() unreadable = self._meta_get_unreadable() # Set colours and start times next_start_time = None # 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. if current_row and next_row: start_times_row = min(current_row, next_row) else: start_times_row = current_row or next_row if not start_times_row: start_times_row = 0 # Cycle through all rows for row in range(self.rowCount()): # We can't calculate start times until next_start_time is # set. if row in notes: # Extract note text note_text = self.item(row, self.COL_TITLE).text() # Does the note end with a time? row_time = self._get_note_text_time(note_text) if row_time: next_start_time = row_time # Set colour note_colour = NoteColours.get_colour(session, note_text) if not note_colour: note_colour = Config.COLOUR_NOTES_PLAYLIST self._set_row_colour( row, QColor(note_colour) ) # Notes are always bold self._set_row_bold(row) elif row in unreadable: # Set colour self._set_row_colour( row, QColor(Config.COLOUR_UNREADABLE) ) self._set_row_bold(row) elif row == current_row: # Extract track object track = self._get_row_object(row) # Set start time self._set_row_start_time( row, self.current_track_start_time) # Set last played time last_played_str = get_relative_date( self.current_track_start_time) self.item(row, self.COL_LAST_PLAYED).setText( last_played_str) # Calculate next_start_time next_start_time = self._calculate_track_end_time( track, self.current_track_start_time) # 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) elif row == next_row: # Extract track object track = self._get_row_object(row) # if there's a track playing, set start time from that if self.current_track_start_time: start_time = self._calculate_track_end_time( track, self.current_track_start_time) else: # No current track to base from, but don't change # time if it's already set start_time = self._get_row_time(row) if not start_time: start_time = next_start_time # Now set it self._set_row_start_time(row, start_time) next_start_time = self._calculate_track_end_time( track, start_time) # 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) else: # Extract track object track = self._get_row_object(row) if row in played: # Played today, so update last played column last_playtime = Playdates.last_played( session, track.id) last_played_str = get_relative_date(last_playtime) self.item(row, self.COL_LAST_PLAYED).setText( last_played_str) self._set_row_not_bold(row) else: # Set start/end times as we haven't played it yet if next_start_time and row >= start_times_row: self._set_row_start_time(row, next_start_time) next_start_time = self._calculate_track_end_time( track, next_start_time) # 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) # Stripe rows if row % 2: self._set_row_colour( row, QColor(Config.COLOUR_ODD_PLAYLIST)) else: self._set_row_colour( row, QColor(Config.COLOUR_EVEN_PLAYLIST)) # ########## Internally called functions ########## def _audacity(self, row): """Open track in Audacity. Audacity must be already running""" DEBUG(f"_audacity({row})") if row in self._meta_get_notes(): return None track = self._get_row_object(row) open_in_audacity(track.path) @staticmethod def _calculate_track_end_time(track, start: datetime) -> Optional[datetime]: """Return this track's end time given its start time""" if start is None: return None if track is None: DEBUG("_calculate_next_start_time() called with track=None") return None duration = Tracks.get_duration(session, self._get_row_id(row)) return start + timedelta(milliseconds=duration) def _context_menu(self, pos): self.menu.exec_(self.mapToGlobal(pos)) def _copy_path(self, row): """ If passed row is track row, copy the track path to the clipboard. Otherwise, return None. """ DEBUG(f"_copy_path({row})") if row in self._meta_get_notes(): return None track = self._get_row_object(row) if track: cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(track.path, mode=cb.Clipboard) def _cell_changed(self, row, column): """Called when cell content has changed""" if not self.editing_cell: return if column not in [self.COL_TITLE, self.COL_ARTIST]: return new_text = self.item(row, column).text() DEBUG(f"_cell_changed({row=}, {column=}, {new_text=}") row_object = self._get_row_object(row) with Session() as session: if row in self._meta_get_notes(): # Save change to database DEBUG(f"Notes.update_note: saving new note text '{new_text=}'") row_object.update_note(session, row, 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) DEBUG( f"_cell_changed:Note {new_text} contains valid " f"time={start_time}" ) else: # Reset row start time in case it used to have one self._set_row_start_time(row, None) DEBUG( f"_ct ell_changed:Note {new_text} does not contain " "start time" ) else: if column == self.COL_ARTIST: row_object.update_artist(session, artist=new_text) elif column == self.COL_TITLE: row_object.update_title(session, title=new_text) else: ERROR("_cell_changed(): unrecognised column") def _cell_edit_ended(self): DEBUG("_cell_edit_ended()") self.editing_cell = False # update_display to update start times, such as when a note has # been edited self.update_display() self.parent.enable_play_next_controls() def _cell_edit_started(self, row, column): DEBUG(f"_cell_edit_started({row=}, {column=})") self.editing_cell = True # Disable play controls so that keyboard input doesn't disturb playing self.parent.disable_play_next_controls() def _delete_rows(self): """Delete mutliple rows""" DEBUG("playlist._delete_rows()") rows = sorted(set(item.row() for item in self.selectedItems())) rows_to_delete = [] notes = self._meta_get_notes() with Session() as session: for row in rows: title = self.item(row, self.COL_TITLE).text() msg = QMessageBox(self) msg.setIcon(QMessageBox.Warning) msg.setText(f"Delete '{title}'?") msg.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) msg.setDefaultButton(QMessageBox.Cancel) msg.setWindowTitle("Delete row") # Store list of rows to delete if msg.exec() == QMessageBox.Yes: rows_to_delete.append(row) # delete in reverse row order so row numbers don't # change for row in sorted(rows_to_delete, reverse=True): row_object = self._get_row_object(row) if row in notes: row_object.delete_note(session) else: self.remove_track(session, row) self.removeRow(row) self.save_playlist(session) self.update_display() def _drop_on(self, event): 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()) @staticmethod def _get_note_text_time(text): """Return time specified at the end of text""" try: return datetime.strptime( text[-Config.NOTE_TIME_FORMAT:], Config.NOTE_TIME_FORMAT ) except ValueError: return None def _get_row_object(self, row): """Return content associated with this row""" return self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT) def _info_row(self, row): """Display popup with info re row""" row_object = self._get_row_object(row) if row in self._meta_get_notes(): txt = row_object.note else: track = row_object txt = ( f"Title: {track.title}\n" f"Artist: {track.artist}\n" f"Track ID: {track.id}\n" f"Track duration: {helpers.ms_to_mmss(track.duration)}\n" f"Track fade at: {helpers.ms_to_mmss(track.fade_at)}\n" f"Track silence at: {helpers.ms_to_mmss(track.silence_at)}" "\n\n" f"Path: {track.path}\n" ) info = QMessageBox(self) info.setIcon(QMessageBox.Information) info.setText(txt) info.setStandardButtons(QMessageBox.Ok) info.setDefaultButton(QMessageBox.Cancel) info.exec() def _insert_note(self, session, note, repaint=True): """ Insert a note to playlist tab. If a row is selected, add note above. Otherwise, add to end of playlist. Return the row number that track is now in. """ if self.selectionModel().hasSelection(): row = self.currentRow() else: row = self.rowCount() DEBUG(f"playlist.inset_note(): row={row}") self.insertRow(row) # Add empty items to unused columns because # colour won't be set for columns without items item = QTableWidgetItem() self.setItem(row, self.COL_USERDATA, item) item = QTableWidgetItem() self.setItem(row, self.COL_MSS, item) # Add text of note from title column onwards titleitem = QTableWidgetItem(note.note) self.setItem(row, self.COL_NOTE, titleitem) self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) # Attach note object to row self._set_row_content(row, note) # Mark row as a Note row self._meta_set_note(row) # Scroll to new row self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter) if repaint: self.save_playlist(session) self.update_display(clear_selection=False) return row def _insert_track(self, session, track, repaint=True): """ Insert track into playlist tab. If a row is selected, add track above. Otherwise, add to end of playlist. Return the row number that track is now in. """ if self.selectionModel().hasSelection(): row = self.currentRow() else: row = self.rowCount() DEBUG( f"playlists.insert_track({session=}, {track=}, {repaint=}), " f"{row=}" ) self.insertRow(row) # Put an item in COL_USERDATA for later item = QTableWidgetItem() self.setItem(row, self.COL_USERDATA, item) # Add track details to columns mss_item = QTableWidgetItem(str(track.start_gap)) if track.start_gap and track.start_gap >= 500: item.setBackground(QColor(Config.COLOUR_LONG_START)) self.setItem(row, self.COL_MSS, mss_item) title_item = QTableWidgetItem(track.title) self.setItem(row, self.COL_TITLE, title_item) artist_item = QTableWidgetItem(track.artist) self.setItem(row, self.COL_ARTIST, artist_item) duration_item = QTableWidgetItem(helpers.ms_to_mmss(track.duration)) self.setItem(row, self.COL_DURATION, duration_item) last_playtime = Playdates.last_played(session, track.id) last_played_str = get_relative_date(last_playtime) last_played_item = QTableWidgetItem(last_played_str) self.setItem(row, self.COL_LAST_PLAYED, last_played_item) # Add empty start and stop time because background # colour won't be set for columns without items start_item = QTableWidgetItem() self.setItem(row, self.COL_START_TIME, start_item) stop_item = QTableWidgetItem() self.setItem(row, self.COL_END_TIME, stop_item) # Attach track object to row self._set_row_content(row, track) # Mart track if file is unreadable if not os.access(track.path, os.R_OK): self._meta_set_unreadable(row) # Scroll to new row self.scrollToItem(title_item, QAbstractItemView.PositionAtCenter) if repaint: self.save_playlist(session) self.update_display(clear_selection=False) return row def _is_below(self, pos, index): 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 _edit_cell(self, mi): """Called when table is double-clicked""" row = mi.row() column = mi.column() item = self.item(row, column) if column in [self.COL_TITLE, self.COL_ARTIST]: self.editItem(item) def _find_next_track_row(self, starting_row=None): """ Find next track to play. If a starting row is given, start there; else if there's a track selected, start looking from next track; otherwise, start from top. Skip rows already played. If not found, return None. If found, return row number. """ if starting_row is None: current_row = self._meta_get_current() if current_row is not None: starting_row = current_row + 1 else: starting_row = 0 notes_rows = self._meta_get_notes() played_rows = self._meta_get_played() for row in range(starting_row, self.rowCount()): if row in notes_rows or row in played_rows: continue else: return row return None def _meta_clear_attribute(self, row, attribute): """Clear given metadata for row""" if row is None: raise ValueError(f"_meta_clear_attribute({row=}, {attribute=})") new_metadata = self._meta_get(row) ^ attribute self.item(row, self.COL_USERDATA).setData( self.ROW_METADATA, new_metadata) def _meta_clear_current(self): """ Clear current row if there is one. There may not be if we've changed playlists """ current_row = self._meta_get_current() if current_row is not None: self._meta_clear_attribute(current_row, RowMeta.CURRENT) def _meta_clear_next(self): """ Clear next row if there is one. There may not be if we've changed playlists """ next_row = self._meta_get_next() if next_row is not None: self._meta_clear_attribute(next_row, RowMeta.NEXT) def _meta_clear_played(self, row): """Clear played status on row""" self._meta_clear_attribute(row, RowMeta.PLAYED) def _meta_get(self, row): """Return row metadata""" return self.item(row, self.COL_USERDATA).data(self.ROW_METADATA) def _meta_get_current(self): """Return row marked as current, or None""" return self._meta_search(RowMeta.CURRENT) def _meta_get_next(self): """Return row marked as next, or None""" return self._meta_search(RowMeta.NEXT) def _meta_get_notes(self): """Return rows marked as notes, or None""" return self._meta_search(RowMeta.NOTE, one=False) def _meta_get_played(self): """Return rows marked as played, or None""" return self._meta_search(RowMeta.PLAYED, one=False) def _meta_get_unreadable(self): """Return rows marked as unreadable, or None""" return self._meta_search(RowMeta.UNREADABLE, one=False) def _meta_search(self, metadata, one=True): """ Search rows for metadata. If one is True, check that only one row matches and return the row number. If one is False, return a list of matching row numbers. """ matches = [] for row in range(self.rowCount()): if self._meta_get(row) & metadata: matches.append(row) if not one: return matches if len(matches) == 0: return None elif len(matches) == 1: return matches[0] else: ERROR( f"Multiple matches for metadata '{metadata}' found " f"in rows: {', '.join([str(x) for x in matches])}" ) raise AttributeError(f"Multiple '{metadata}' metadata {matches}") def _meta_set_attribute(self, row, attribute): """Set row metadata""" if row is None: raise ValueError(f"_meta_set_attribute({row=}, {attribute=})") new_metadata = self._meta_get(row) | attribute self.item(row, self.COL_USERDATA).setData( self.ROW_METADATA, new_metadata) def _meta_set_current(self, row): """Mark this row as current track""" self._meta_clear_current() self._meta_set_attribute(row, RowMeta.CURRENT) def _meta_set_next(self, row): """Mark this row as next track""" self._meta_clear_next() self._meta_set_attribute(row, RowMeta.NEXT) def _meta_set_note(self, row): """Mark this row as a note""" self._meta_set_attribute(row, RowMeta.NOTE) def _meta_set_played(self, row): """Mark this row as played""" self._meta_set_attribute(row, RowMeta.PLAYED) def _meta_set_unreadable(self, row): """Mark this row as unreadable""" self._meta_set_attribute(row, RowMeta.UNREADABLE) def _set_next(self, row): """ If passed row is track row, check track is readable and, if it is: - mark that track as the next track to be played - notify musicmuster - return track Otherwise, return None. """ DEBUG(f"_set_next({row=})") if row in self._meta_get_notes(): return None track = self._get_row_object(row) if not track: return None if self._track_is_readable(track): self._meta_set_next(row) self.parent.set_next_track(track) else: self._meta_set_unreadable(row) track = None self.update_display() return track def _rescan(self, row): """ If passed row is track row, rescan it. Otherwise, return None. """ DEBUG(f"_rescan({row=})") if row in self._meta_get_notes(): return None track = self._get_row_object(row) if track: with Session() as session: track.rescan(session) self._update_row(row, track) def _select_event(self): """ Called when item selection changes. If multiple rows are selected, display sum of durations in status bar. """ rows = set([item.row() for item in self.selectedItems()]) note_rows = self._meta_get_notes() ms = sum([self._get_row_object(row).duration for row in rows if row not in note_rows]) # Only paint message if there are selected track rows if ms > 0: self.parent.lblSumPlaytime.setText( f"Selected duration: {helpers.ms_to_mmss(ms)}") else: self.parent.lblSumPlaytime.setText("") def _set_column_widths(self): # Column widths from settings with Session() as session: for column in range(self.columnCount()): name = f"playlist_col_{str(column)}_width" record = Settings.get_int(session, name) if record and record.f_int is not None: self.setColumnWidth(column, record.f_int) else: self.setColumnWidth(column, Config.DEFAULT_COLUMN_WIDTH) def _set_row_bold(self, row, bold=True): boldfont = QFont() boldfont.setBold(bold) for j in range(self.columnCount()): if self.item(row, j): self.item(row, j).setFont(boldfont) def _set_row_colour(self, row, colour): for j in range(2, self.columnCount()): if self.item(row, j): self.item(row, j).setBackground(colour) def _set_row_content(self, row, content): """Set content associated with this row""" assert self.item(row, self.COL_USERDATA) self.item(row, self.COL_USERDATA).setData(self.CONTENT_OBJECT, content) def _set_row_end_time(self, row, time: Optional[datetime]): """Set passed row end time to passed time""" try: time_str = time.strftime(Config.NOTE_TIME_FORMAT) except AttributeError: time_str = "" item = QTableWidgetItem(time_str) self.setItem(row, self.COL_END_TIME, item) def _set_row_not_bold(self, row): self._set_row_bold(row, False) def _set_row_start_time(self, row, time: Optional[datetime]): """Set passed row start time to passed time""" try: time_str = time.strftime(Config.NOTE_TIME_FORMAT) except AttributeError: time_str = "" item = QTableWidgetItem(time_str) self.setItem(row, self.COL_START_TIME, item) def _track_path_is_readable(self, track_id): """ Returns True if track path is readable, else False vlc cannot read files with a colon in the path """ with Session() as session: path = Tracks.get_path(session, track_id) if os.access(path, os.R_OK): if ':' not in path: return True return False def _update_row(self, row, track): """ Update the passed row with info from the passed track. """ DEBUG(f"_update_row({row=}, {track=}") item_startgap = self.item(row, self.COL_MSS) 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, self.COL_TITLE) item_title.setText(track.title) item_artist = self.item(row, self.COL_ARTIST) item_artist.setText(track.artist) item_duration = self.item(row, self.COL_DURATION) item_duration.setText(helpers.ms_to_mmss(track.duration)) self.update_display()