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, ) 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 model import ( Notes, Playdates, Playlists, PlaylistTracks, Session, Settings, Tracks ) from songdb import create_track_from_file, update_meta class PlaylistTab(QTableWidget): cellEditingStarted = QtCore.pyqtSignal(int, int) cellEditingEnded = QtCore.pyqtSignal() # Column names COL_INDEX = 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 = 7 NOTE_COL_SPAN = 4 NOTE_ROW_SPAN = 1 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.master_process = self.parent() 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) 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(["ID", "Lead", "Title", "Artist", "Len", "Start", "End", "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 holds 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) self.current_track_start_time = None self.played_tracks = [] # ########## Events ########## def dropEvent(self, event: QDropEvent): if not event.isAccepted() and event.source() == self: 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() # We don't want rows to be selected after move # for row_index in range(len(rows_to_move)): # for column_index in range(self.columnCount()): # self.item(drop_row + row_index, # column_index).setSelected(True) # 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 row in self._meta_get_notes(): self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) 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._repaint() def edit(self, index, trigger, event): result = super(PlaylistTab, self).edit(index, trigger, event) 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" 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 == 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: 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: 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 close(self, session): "Save column widths" 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}) def insert_note(self, session, note, repaint=True): """ Add note to playlist 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}") # Does note end with a time? start_time = None try: start_time = datetime.strptime(note.note[-9:], " %H:%M:%S").time() DEBUG( f"playlist.inset_note(): Note contains valid time={start_time}" ) except ValueError: DEBUG( f"playlist.inset_note(): Note on row {row} ('{note.note}') " "does not contain valid time" ) self.insertRow(row) item = QTableWidgetItem(str(note.id)) self.setItem(row, self.COL_INDEX, item) 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) # Add start/end times or empty items as background # colour won't be set for columns without items self._set_row_start_time(row, start_time) item = QTableWidgetItem() self.setItem(row, self.COL_END_TIME, item) item = QTableWidgetItem() self.setItem(row, self.COL_LAST_PLAYED, item) self._meta_set_note(row) # Scroll to new row self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter) if repaint: self._save_playlist(session) self._repaint(clear_selection=False) return row def insert_track(self, session, track, repaint=True): """ Insert track into on-screen playlist. 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) item = QTableWidgetItem(str(track.id)) self.setItem(row, self.COL_INDEX, item) item = QTableWidgetItem(str(track.start_gap)) if track.start_gap >= 500: item.setBackground(QColor(Config.COLOUR_LONG_START)) self.setItem(row, self.COL_MSS, item) titleitem = QTableWidgetItem(track.title) self.setItem(row, self.COL_TITLE, titleitem) item = QTableWidgetItem(track.artist) self.setItem(row, self.COL_ARTIST, item) item = QTableWidgetItem(helpers.ms_to_mmss(track.duration)) self.setItem(row, self.COL_DURATION, item) last_playtime = Playdates.last_played(session, track.id) last_played_str = get_relative_date(last_playtime) item = QTableWidgetItem(last_played_str) self.setItem(row, self.COL_LAST_PLAYED, item) # Add empty start time for now as background # colour won't be set for columns without items item = QTableWidgetItem() self.setItem(row, self.COL_START_TIME, item) # Scroll to new row self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter) if not self._track_path_is_readable(track.id): self._meta_set_unreadable(row) if repaint: self._save_playlist(session) self._repaint(clear_selection=False) return row def clear_current(self): "Clear current track" self._meta_clear_current() self._repaint() def clear_next(self): "Clear next track" self._meta_clear_next() self._repaint() def get_next_track_id(self): "Return next track id" next_row = self._meta_get_next() return self._get_row_id(next_row) 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_id) tuples" if not self.selectionModel().hasSelection(): return None result = [] for row in [r.row() for r in self.selectionModel().selectedRows()]: track_id = self._get_row_id(row) result.append((row, track_id)) 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 lowest # row first for row in sorted(rows, reverse=True): self.removeRow(row) with Session() as session: self._save_playlist(session) self._repaint() def play_started(self): """ Update current track to be what was next, and determine next track. Return next track_id. """ self.current_track_start_time = datetime.now() current_row = self._meta_get_next() self._meta_set_current(current_row) self.played_tracks.append(self._get_row_id(current_row)) # Scroll to put current track in centre scroll_to = self.item(current_row, self.COL_INDEX) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtCenter) # Get next track next_track_row = self._find_next_track_row() next_track_id = self._set_next(next_track_row) self._repaint() return next_track_id def play_stopped(self): self._meta_clear_current() self.current_track_start_time = None self._repaint() def populate(self, session, playlist_db): """ Populate ourself from the passed playlist_db 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. # That means we need to re-save ourself once loaded to ensure # database is correct. # First, save our id for the future self.id = playlist_db.id self.name = playlist_db.name data = [] for t in playlist_db.tracks: data.append(([t.row], t.tracks)) for n in playlist_db.notes: data.append(([n.row], n)) # 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_INDEX) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) self._save_playlist(session) self._repaint() def repaint(self): # Called when we change tabs self._repaint() 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" # Need to allow multiple rows to be selected self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) self.clearSelection() for row in range(self.rowCount()): if self._get_row_id(row) in self.played_tracks: self.selectRow(row) # Reset extended selection self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) 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" # Need to allow multiple rows to be selected self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) notes_rows = self._meta_get_notes() self.clearSelection() for row in range(self.rowCount()): if row in notes_rows: continue if self._get_row_id(row) in self.played_tracks: continue self.selectRow(row) # Reset extended selection self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) def set_selected_as_next(self): """ Sets the selected track as the next track. """ if not self.selectionModel().hasSelection(): return self._set_next(self.currentRow()) self._repaint() # ########## 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_id = self._get_row_id(row) if track_id: with Session() as session: track = Tracks.get_track(session, track_id) open_in_audacity(track.path) def _calculate_next_start_time(self, session, row, start): "Return this row's end time given its start time" if start is None: return None if row is None: DEBUG("_calculate_next_start_time() called with row=None") return None duration = Tracks.get_duration(session, self._get_row_id(row)) return start + timedelta(milliseconds=duration) def _can_read_track(self, track): "Check track file is readable" return os.access(track.path, os.R_OK) 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_id = self._get_row_id(row) if track_id: with Session() as session: path = Tracks.get_path(session, track_id) cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(path, mode=cb.Clipboard) def _cell_changed(self, row, column): "Called when cell content has changed" if not self.editing_cell: return # If we update start time, _cell_changed will be called if column not in [self.COL_TITLE, self.COL_ARTIST]: return new = self.item(row, column).text() DEBUG(f"_cell_changed({row=}, {column=}, {new=}") row_id = self._get_row_id(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=}'", True ) Notes.update_note(session, row_id, row, new) # Set/clear row start time accordingly try: start_dt = datetime.strptime(new[-9:], " %H:%M:%S") start_time = start_dt.time() self._set_row_start_time(row, start_time) DEBUG( f"_cell_changed:Note {new} contains valid " f"time={start_time}" ) except ValueError: # Reset row start time in case it used to have one self._set_row_start_time(row, None) DEBUG( f"_cell_changed:Note {new} does not contain " "start time" ) else: track = Tracks.get_track(session, row_id) if column == self.COL_ARTIST: update_meta(session, track, artist=new) elif column == self.COL_TITLE: update_meta(session, track, title=new) else: ERROR("_cell_changed(): unrecognised column") def _cell_edit_started(self, row, column): DEBUG(f"_cell_edit_started({row=}, {column=})") self.editing_cell = True self.master_process.disable_play_next_controls() def _cell_edit_ended(self): DEBUG("_cell_edit_ended()") self.editing_cell = False # Call repaint to update start times, such as when a note has # been edited self._repaint() self.master_process.enable_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 del_row in sorted(rows_to_delete, reverse=True): id = self._get_row_id(del_row) if del_row in notes: Notes.delete_note(session, id) else: PlaylistTracks.remove_track(session, self.id, del_row) self.removeRow(del_row) self._save_playlist(session) self._repaint() 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()) def _get_row_id(self, row): "Return item id as integer from passed row" if row is None: return if self.item(row, self.COL_INDEX): try: return int(self.item(row, self.COL_INDEX).text()) except TypeError: ERROR( f"_get_row_id({row}): error retrieving row id " f"({self.item(row, self.COL_INDEX).text()})" ) else: ERROR(f"(_get_row_id({row}): no COL_INDEX data in row") return None def _get_row_time(self, row): try: if self.item(row, self.COL_START_TIME): return datetime.strptime(self.item( row, self.COL_START_TIME).text(), "%H:%M:%S" ) else: return None except ValueError: return None def _info_row(self, row): "Display popup with info re row" id = self._get_row_id(row) if row in self._meta_get_notes(): note_text = self.item(row, self.COL_TITLE).text() txt = f"Note: {note_text}" else: with Session() as session: track = Tracks.get_track(session, id) if not track: txt = f"Track not found (track.id={id})" else: 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 _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): """ Find next track to play. If not found, return None. If found, return row number. """ found_next_track = False current_row = self._meta_get_current() if current_row is not None: start = current_row + 1 else: start = 0 notes_rows = self._meta_get_notes() for row in range(start, self.rowCount()): if row in notes_rows: continue found_next_track = True break if found_next_track: return row else: return None def _meta_clear(self, row): "Clear metadata for row" self._meta_set(row, None) 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(current_row) 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(next_row) def _meta_find(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_get(self, row): "Return row metadata" return self.item(row, self.COL_INDEX).data(Qt.UserRole) def _meta_get_current(self): "Return row marked as current, or None" return self._meta_find("current") def _meta_get_next(self): "Return row marked as next, or None" return self._meta_find("next") def _meta_get_notes(self): "Return rows marked as notes, or None" return self._meta_find("note", one=False) def _meta_get_unreadable(self): "Return rows marked as unreadable, or None" return self._meta_find("unreadable", one=False) def _meta_set_current(self, row): "Mark row as current track" old_current = self._meta_get_current() if old_current is not None: self._meta_clear(old_current) self._meta_set(row, "current") def _meta_set_next(self, row): "Mark row as next track" old_next = self._meta_get_next() if old_next is not None: self._meta_clear(old_next) self._meta_set(row, "next") def _meta_set_note(self, row): "Mark row as note" self._meta_set(row, "note") def _meta_set_unreadable(self, row): "Mark row as unreadable" self._meta_set(row, "unreadable") def _meta_set(self, row, metadata): "Set row metadata" if self.item(row, self.COL_TITLE): title = self.item(row, self.COL_TITLE).text() else: title = "" DEBUG( f"playlist[{self.id}:{self.name}]._meta_set(row={row}, " f"title={title}, metadata={metadata})" ) if row is None: raise ValueError("_meta_set() with row=None") self.item(row, self.COL_INDEX).setData(Qt.UserRole, metadata) def _set_next(self, row): """ If passed row is track row, check track is readable and, if it is, set that track as the next track to be played and return track_id. Otherwise return None. """ DEBUG(f"_set_next({row})") if row in self._meta_get_notes(): return None track_id = self._get_row_id(row) if not track_id: return None if self._track_path_is_readable(track_id): self._meta_set_next(row) self.master_process.set_next_track(track_id) else: self._meta_set_unreadable(row) track_id = None self._repaint() return track_id def _repaint(self, clear_selection=True): "Set row colours, fonts, etc" DEBUG( f"playlist[{self.id}:{self.name}]." f"_repaint(clear_selection={clear_selection}" ) with Session() as session: if clear_selection: self.clearSelection() current = self._meta_get_current() next = self._meta_get_next() notes = self._meta_get_notes() 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. If # neither, populate start times from first note with a start # time. if current and next: start_times_row = min(current, next) else: start_times_row = current or next 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. That can be set by either a note with a time, or the # current track. if row in notes: row_time = self._get_row_time(row) if row_time: next_start_time = row_time # Set colour note_colour = Config.COLOUR_NOTES_PLAYLIST note_text = self.item(row, self.COL_TITLE).text() note_tokens = note_text.split(":", 1) if len(note_tokens) == 2: if note_tokens[0] in Config.NOTE_COLOURS: note_colour = Config.NOTE_COLOURS[note_tokens[0]] self._set_row_colour( row, QColor(note_colour) ) 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: # Set start time self._set_row_start_time( row, self.current_track_start_time) # Calculate next_start_time next_start_time = self._calculate_next_start_time( session, row, 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: # if there's a track playing, set start time from that if self.current_track_start_time: start_time = self._calculate_next_start_time( session, current, 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_next_start_time( session, row, 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: # Stripe remaining rows if row % 2: colour = QColor(Config.COLOUR_ODD_PLAYLIST) else: colour = QColor(Config.COLOUR_EVEN_PLAYLIST) self._set_row_colour(row, colour) track_id = self._get_row_id(row) if track_id in self.played_tracks: # 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 only if 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_next_start_time( session, row, 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) 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_id = self._get_row_id(row) if track_id: with Session() as session: track = Tracks.get_track(session, track_id) create_track_from_file(session, track.path) self._update_row(row, track) 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 there is only one notes appearance in all playlists. For tracks: erase the playlist tracks and recreate. This is much simpler than trying to correct any Playlists:Tracks many:many errors. """ # We need to add ourself to the session playlist_db = session.query(Playlists).filter( Playlists.id == self.id).one() # 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_id = self._get_row_id(row) if not note_id: DEBUG(f"(_save_playlist(): no COL_INDEX data in row {row}") continue playlist_notes[note_id] = row # Database for note in playlist_db.notes: database_notes[note.id] = note.row # Notes to add to database # This should never be needed as notes are added to a specific # playlist upon creation for note_id in set(playlist_notes.keys()) - set(database_notes.keys()): ERROR( f"_save_playlist(): Note.id={note_id} " f"missing from playlist {playlist_db} in database" ) # Notes to remove from database for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): DEBUG( f"_save_playlist(): Delete note note_id={note_id} " f"from playlist {playlist_db} in database" ) Notes.delete_note(session, note_id) # Note rows to update in playlist database for note_id in set(playlist_notes.keys()) & set(database_notes.keys()): if playlist_notes[note_id] != database_notes[note_id]: DEBUG( f"_save_playlist(): Update database note.id {note_id} " f"from row={database_notes[note_id]} to " f"row={playlist_notes[note_id]}" ) Notes.update_note(session, note_id, playlist_notes[note_id]) # Tracks # Remove all tracks for us in datbase PlaylistTracks.remove_all_tracks(session, self.id) # Iterate on-screen playlist and add tracks back in for row in range(self.rowCount()): if row in notes_rows: continue PlaylistTracks.add_track( session, self.id, self._get_row_id(row), row) 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()]) notes = self._meta_get_notes() ms = 0 with Session() as session: for row in rows: if row in notes: continue ms += Tracks.get_duration(session, self._get_row_id(row)) # Only paint message if there are selected track rows if ms > 0: self.master_process.lblSumPlaytime.setText( f"Selected duration: {helpers.ms_to_mmss(ms)}") else: self.master_process.lblSumPlaytime.setText("") def _set_column_widths(self): # Column widths from settings with Session() as session: for column in range(self.columnCount()): # Only show column 0 in test mode if (column == 0 and not Config.TESTMODE): self.setColumnWidth(0, 0) else: name = f"playlist_col_{str(column)}_width" record = Settings.get_int(session, name) if record.f_int is not None: self.setColumnWidth(column, record.f_int) 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_not_bold(self, row): self._set_row_bold(row, False) def _set_row_end_time(self, row, time): "Set passed row end time to passed time" try: time_str = time.strftime("%H:%M:%S") except AttributeError: time_str = "" item = QTableWidgetItem(time_str) self.setItem(row, self.COL_END_TIME, item) def _set_row_start_time(self, row, time): "Set passed row start time to passed time" try: time_str = time.strftime("%H:%M:%S") 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" with Session() as session: return os.access(Tracks.get_path(session, track_id), os.R_OK) 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._repaint()