from typing import Dict, List, Optional, Set, Tuple, Union 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, QInputDialog, QMainWindow, QMenu, QMessageBox, QTableWidget, QTableWidgetItem, ) import helpers import os import re 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, Playlists, Settings, Tracks, NoteColours ) from dbconfig import Session start_time_re = re.compile(r"@\d\d:\d\d:\d\d") class RowMeta: CLEAR = 0 NOTE = 1 UNREADABLE = 2 NEXT = 3 CURRENT = 4 PLAYED = 5 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, musicmuster: QMainWindow, session: Session, playlist_id: int, *args, **kwargs): super().__init__(*args, **kwargs) self.musicmuster: QMainWindow = musicmuster self.playlist_id: int = playlist_id self.menu: Optional[QMenu] = None self.current_track_start_time: Optional[datetime] = 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: QTableWidgetItem = 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(session) 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: bool = False self.selecting_in_progress = 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.playlist_id) def __repr__(self) -> str: return (f" None: # if not event.isAccepted() and event.source() == self: 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: 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._get_notes_rows(): 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: # checked self.save_playlist(session) self.update_display(session) def edit(self, index, trigger, event): # review result = super(PlaylistTab, self).edit(index, trigger, event) if result: self.cellEditingStarted.emit(index.row(), index.column()) return result def closeEditor(self, editor, hint): # review super(PlaylistTab, self).closeEditor(editor, hint) self.cellEditingEnded.emit() def eventFilter(self, source, event): # review """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._get_current_track_row() next_row = row == self._get_next_track_row() 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._get_notes_rows(): 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(row, session)) 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) -> None: """Save column widths""" DEBUG(f"playlists.closeEvent()") 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_settings(session, name) if record.f_int != self.columnWidth(column): record.update(session, {'f_int': width}) # Record playlist as closed playlist = Playlists.get_by_id(session, self.playlist_id) playlist.close(session) event.accept() def clear_next(self, session) -> None: """Clear next track""" self._meta_clear_next() self.update_display(session) def create_note(self) -> None: """ Create note If a row is selected, set note row to be row above. Otherwise, set note row to be end of playlist. """ row: Optional[int] = self.get_selected_row() if not row: row = self.rowCount() # Get note text dlg: QInputDialog = QInputDialog(self) dlg.setInputMode(QInputDialog.TextInput) dlg.setLabelText("Note:") dlg.resize(500, 100) ok: int = dlg.exec() if ok: with Session() as session: note: Notes = Notes( session, self.playlist_id, row, dlg.textValue()) self._insert_note(session, note, row, True) # checked 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""" rows = self.selectionModel().selectedRows() return [row.row() for row in rows] def get_selected_title(self) -> Optional[str]: """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 insert_track(self, session: Session, track: Tracks, repaint: bool = True) -> None: """ Insert track into playlist tab. If a row is selected, add track above. Otherwise, add to end of playlist. """ 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 = QTableWidgetItem() # Add row metadata item.setData(self.ROW_METADATA, 0) self.setItem(row, self.COL_USERDATA, item) # Add track details to columns mss_item: QTableWidgetItem = QTableWidgetItem(str(track.start_gap)) if track.start_gap and track.start_gap >= 500: mss_item.setBackground(QColor(Config.COLOUR_LONG_START)) self.setItem(row, self.COL_MSS, mss_item) title_item: QTableWidgetItem = QTableWidgetItem(track.title) self.setItem(row, self.COL_TITLE, title_item) artist_item: QTableWidgetItem = QTableWidgetItem(track.artist) self.setItem(row, self.COL_ARTIST, artist_item) duration_item: QTableWidgetItem = QTableWidgetItem( helpers.ms_to_mmss(track.duration) ) self.setItem(row, self.COL_DURATION, duration_item) last_playtime: Optional[datetime] = Playdates.last_played( session, track.id) last_played_str: str = get_relative_date(last_playtime) last_played_item: QTableWidgetItem = 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 = QTableWidgetItem() self.setItem(row, self.COL_START_TIME, start_item) stop_item: QTableWidgetItem = QTableWidgetItem() self.setItem(row, self.COL_END_TIME, stop_item) # Attach track.id object to row self._set_row_content(row, track.id) # Mark track if file is unreadable if not self._file_is_readable(track.path): self._set_unreadable_row(row) # Scroll to new row self.scrollToItem(title_item, QAbstractItemView.PositionAtCenter) if repaint: self.save_playlist(session) self.update_display(session, clear_selection=False) def remove_rows(self, rows) -> None: """Remove rows passed in rows list""" # Row number will change as we delete rows so remove them in # reverse order. try: self.selecting_in_progress = True for row in sorted(rows, reverse=True): self.removeRow(row) finally: self.selecting_in_progress = False self._select_event() with Session() as session: self.save_playlist(session) self.update_display(session) def play_started(self, session: 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 current track as required - Set next track - Update display """ # Note start time self.current_track_start_time = datetime.now() # Mark next-track row as current current_row = self._get_next_track_row() if not current_row: return self._set_current_track_row(current_row) # Mark current row as played self._set_played_row(current_row) # Scroll to put current track as requiredin middle We want this # row to be Config.SCROLL_TOP_MARGIN from the top. Rows number # from zero, so set (current_row - Config.SCROLL_TOP_MARGIN + 1) # row to be top row top_row = max(0, current_row - Config.SCROLL_TOP_MARGIN + 1) scroll_item = self.item(top_row, self.COL_MSS) self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop) # Set next track search_from = current_row + 1 next_row = self._find_next_track_row(search_from) if next_row: self._set_next(next_row, session) # Update display self.update_display(session) def play_stopped(self) -> None: """ Notification from musicmuster that track has ended. Actions required: - Remove current track marker - Reset current track start time - Update display """ self._clear_current_track_row() self.current_track_start_time = None def populate(self, session: Session, playlist_id: int) -> None: """ Populate from the associated playlist ID 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: List[Union[Tuple[List[int], Tracks], Tuple[List[int], Notes]]] \ = [] item: Union[Notes, Tracks] note: Notes row: int track: Tracks playlist = Playlists.get_by_id(session, playlist_id) for row, track in playlist.tracks.items(): data.append(([row], track)) for note in 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: QTableWidgetItem = self.item(0, 0) 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(session) def save_playlist(self, session) -> None: """ 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. """ playlist = Playlists.get_by_id(session, self.playlist_id) # Notes first # Create dictionaries indexed by note_id playlist_notes: Dict[int, Notes] = {} database_notes: Dict[int, Notes] = {} notes_rows: List[int] = self._get_notes_rows() # PlaylistTab for row in notes_rows: note: Notes = self._get_row_notes_object(row, session) session.add(note) playlist_notes[note.id] = note # Database for note in 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 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_id: int = self.item( row, self.COL_USERDATA).data(self.CONTENT_OBJECT) playlist.add_track(session, track_id, row) def select_next_row(self) -> None: """ Select next or first row. Don't select notes. Wrap at last row. """ row: int selected_rows: List[int] 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: bool = False while row in self._get_notes_rows(): 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) -> None: """Select all played tracks in playlist""" try: self.selecting_in_progress = True self._select_tracks(played=True) finally: self.selecting_in_progress = False self._select_event() def select_previous_row(self) -> None: """ Select previous or last track. Don't select notes. Wrap at first row. """ row: int selected_rows: List[int] 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: int = 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: bool = False while row in self._get_notes_rows(): 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) -> None: """Select all unplayed tracks in playlist""" try: self.selecting_in_progress = True self._select_tracks(played=False) finally: self.selecting_in_progress = False self._select_event() 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(row, session) def update_display(self, session, clear_selection: bool = True) -> None: """ Set row colours, fonts, etc Actions required: - Clear selection if 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 """ # Clear selection if required if clear_selection: self.clearSelection() current_row: Optional[int] = self._get_current_track_row() next_row: Optional[int] = self._get_next_track_row() notes: List[int] = self._get_notes_rows() played: Optional[List[int]] = self._get_played_track_rows() unreadable: List[int] = self._get_unreadable_track_rows() last_played_str: str last_playedtime: Optional[datetime] next_start_time: Optional[datetime] = None note_colour: str note_start_time: Optional[str] note_text: str row: int row_time: Optional[datetime] section_start_row: Optional[int] = None section_time: int = 0 start_time: Optional[datetime] start_times_row: Optional[int] track: Optional[Tracks] # 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. 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()): # Render notes in correct colour if row in notes: # Extract note text from database to ignore section timings note_text = self._get_row_notes_object(row, session).note # 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_row is not None: if note_text.endswith("-"): self._set_timed_section(session, section_start_row, section_time) section_start_row = None section_time = 0 elif note_text.endswith("+"): section_start_row = row section_time = 0 # 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) continue # Render unplayable tracks in correct colour if row in unreadable: self._set_row_colour( row, QColor(Config.COLOUR_UNREADABLE) ) self._set_row_bold(row) continue # Current row is a track row track = self._get_row_track_object(row, session) # Add track time to section time if in timed section if section_start_row is not None: section_time += track.duration # Render current track if row == current_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) continue # Render next track if row == next_row: # if there's a track playing, set start time from that if current_row: start_time = 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_start_time(row) if not start_time: start_time = next_start_time self._set_row_start_time(row, start_time) # Set end time next_start_time = self._calculate_track_end_time( track, start_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: # This is a track row other than next or current if row in played: # Played today, so update last played column last_playedtime = Playdates.last_played( session, track.id) last_played_str = get_relative_date(last_playedtime) 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)) # Have we had a section start but not end? if section_start_row is not None: self._set_timed_section( session, section_start_row, section_time, no_end=True) # ########## Internally called functions ########## def _audacity(self, row: int) -> None: """Open track in Audacity. Audacity must be already running""" DEBUG(f"_audacity({row})") if row in self._get_notes_rows(): return None with Session() as session: track: Tracks = self._get_row_track_object(row, session) open_in_audacity(track.path) @staticmethod def _calculate_track_end_time( track: Tracks, start: Optional[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 = track.duration return start + timedelta(milliseconds=duration) def _context_menu(self, pos): # review self.menu.exec_(self.mapToGlobal(pos)) def _copy_path(self, row: int) -> None: """ 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._get_notes_rows(): return None with Session() as session: track: Optional[Tracks] = self._get_row_track_object(row, session) if track: cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(track.path, mode=cb.Clipboard) def _cell_changed(self, row: int, column: int) -> None: """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: str = self.item(row, column).text() DEBUG(f"_cell_changed({row=}, {column=}, {new_text=}") with Session() as session: if row in self._get_notes_rows(): # Save change to database DEBUG(f"Notes.update_note: saving new note text '{new_text=}'") note: Notes = self._get_row_notes_object(row, session) note.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"{start_time=}" ) else: # Reset row start time in case it used to have one self._set_row_start_time(row, None) DEBUG( f"_cell_changed:Note {new_text} does not contain " "start time" ) else: track: Tracks = self._get_row_track_object(row, session) if column == self.COL_ARTIST: track.update_artist(session, artist=new_text) elif column == self.COL_TITLE: track.update_title(session, title=new_text) else: ERROR("_cell_changed(): unrecognised column") def _cell_edit_ended(self) -> None: """Called when cell edit ends""" DEBUG("_cell_edit_ended()") self.editing_cell = False # update_display to update start times, such as when a note has # been edited with Session() as session: self.update_display(session) self.musicmuster.enable_play_next_controls() def _cell_edit_started(self, row: int, column: int) -> None: """ Called when cell editing started. Disable play controls so that keys work during edit. """ DEBUG(f"_cell_edit_started({row=}, {column=})") self.editing_cell = True # Disable play controls so that keyboard input doesn't disturb playing self.musicmuster.disable_play_next_controls() # If this is a note cell and it's a section start, we need to # remove any existing section timing so user can't edit that. # Section timing is only in display of item, not in note text in # database. Keep it simple: if this is a note, pull text from # database. if self._is_note_row(row): item = self.item(row, self.COL_TITLE) with Session() as session: note_object = self._get_row_notes_object(row, session) if note_object: item.setText(note_object.note) return def _clear_current_track_row(self) -> None: """ Clear current row if there is one. """ current_row: Optional[int] = self._get_current_track_row() if current_row is not None: self._meta_clear_attribute(current_row, RowMeta.CURRENT) # Reset row colour if current_row % 2: self._set_row_colour( current_row, QColor(Config.COLOUR_ODD_PLAYLIST)) else: self._set_row_colour( current_row, QColor(Config.COLOUR_EVEN_PLAYLIST)) def _clear_played_row_status(self, row: int) -> None: """Clear played status on row""" self._meta_clear_attribute(row, RowMeta.PLAYED) def _delete_rows(self) -> None: """Delete mutliple rows""" DEBUG("playlist._delete_rows()") rows: List[int] = sorted( set(item.row() for item in self.selectedItems()) ) rows_to_delete: List[int] = [] note_rows: Optional[List[int]] = self._get_notes_rows() row: int row_object: Union[Tracks, 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 playlist = Playlists.get_by_id(session, self.playlist_id) for row in sorted(rows_to_delete, reverse=True): if row in note_rows: note: Notes = self._get_row_notes_object(row, session) note.delete_note(session) else: playlist.remove_track(session, row) self.removeRow(row) self.save_playlist(session) self.update_display(session) def _drop_on(self, event): # review 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 _edit_cell(self, mi): # review """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) @staticmethod def _file_is_readable(path: str) -> bool: """ Returns True if track path is readable, else False vlc cannot read files with a colon in the path """ if os.access(path, os.R_OK): if ':' not in path: return True return False def _find_next_track_row(self, starting_row: int = None) -> Optional[int]: """ 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._get_current_track_row() if current_row is not None: starting_row = current_row + 1 else: starting_row = 0 notes_rows = self._get_notes_rows() played_rows = self._get_played_track_rows() 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 _get_current_track_row(self) -> Optional[int]: """Return row marked as current, or None""" row = self._meta_search(RowMeta.CURRENT) if len(row) > 0: return row[0] else: return None def _get_next_track_row(self) -> Optional[int]: """Return row marked as next, or None""" row = self._meta_search(RowMeta.NEXT) if len(row) > 0: return row[0] else: return None @staticmethod def _get_note_text_time(text: str) -> Optional[datetime]: """Return time specified as @hh:mm:ss in text""" match = start_time_re.search(text) if not match: return None try: return datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT) except ValueError: return None def _get_notes_rows(self) -> List[int]: """Return rows marked as notes, or None""" return self._meta_search(RowMeta.NOTE, one=False) def _get_row_end_time(self, row) -> Optional[datetime]: """ Return row end time as string """ try: if self.item(row, self.COL_END_TIME): return datetime.strptime(self.item( row, self.COL_END_TIME).text(), Config.NOTE_TIME_FORMAT ) else: return None except ValueError: return None def _get_row_notes_object(self, row: int, session: Session) \ -> Optional[Notes]: """Return note associated with this row""" note_id = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT) note = Notes.get_by_id(session, note_id) return note def _get_played_track_rows(self) -> List[int]: """Return rows marked as played, or None""" return self._meta_search(RowMeta.PLAYED, one=False) def _get_unplayed_track_rows(self) -> Optional[List[int]]: """Return rows marked as unplayed, or None""" unplayed_rows: Set[int] = set(self._meta_notset(RowMeta.PLAYED)) notes_rows: Set[int] = set(self._get_notes_rows()) return list(unplayed_rows - notes_rows) def _get_row_start_time(self, row: int) -> Optional[datetime]: try: if self.item(row, self.COL_START_TIME): return datetime.strptime(self.item( row, self.COL_START_TIME).text(), Config.NOTE_TIME_FORMAT ) else: return None except ValueError: return None def _get_row_track_object(self, row: int, session: Session) \ -> Optional[Tracks]: """Return track associated with this row""" track_id = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT) track = Tracks.get_by_id(session, track_id) return track def _get_track_rows(self) -> List[int]: """Return rows marked as tracks, or None""" return self._meta_notset(RowMeta.NOTE) def _get_unreadable_track_rows(self) -> List[int]: """Return rows marked as unreadable, or None""" return self._meta_search(RowMeta.UNREADABLE, one=False) def _info_row(self, row: int) -> None: """Display popup with info re row""" txt: str with Session() as session: if row in self._get_notes_rows(): note: Notes = self._get_row_notes_object(row, session) txt = note.note else: track: Tracks = self._get_row_track_object(row, session) 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 = QMessageBox(self) info.setIcon(QMessageBox.Information) info.setText(txt) info.setStandardButtons(QMessageBox.Ok) info.setDefaultButton(QMessageBox.Cancel) info.exec() def _insert_note(self, session: Session, note: Notes, row: Optional[int] = None, repaint: bool = True) -> None: """ Insert a note to playlist tab. If a row is given, add note above. Otherwise, add to end of playlist. """ if row is None: 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 = 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 = 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 id to row self._set_row_content(row, note.id) # Mark row as a Note row self._set_note_row(row) # Scroll to new row self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter) if repaint: self.save_playlist(session) self.update_display(session, clear_selection=False) def _is_below(self, pos, index): # review 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 _is_note_row(self, row: int) -> bool: """ Return True if passed row is a note row, else False """ if self._meta_get(row): if self._meta_get(row) & (1 << RowMeta.NOTE): return True return False def _meta_clear_attribute(self, row: int, attribute: int) -> None: """Clear given metadata for row""" if row is None: raise ValueError(f"_meta_clear_attribute({row=}, {attribute=})") new_metadata: int = self._meta_get(row) & ~(1 << attribute) self.item(row, self.COL_USERDATA).setData( self.ROW_METADATA, new_metadata) def _meta_clear_next(self) -> None: """ Clear next row if there is one. """ next_row: Optional[int] = self._get_next_track_row() if next_row is not None: self._meta_clear_attribute(next_row, RowMeta.NEXT) def _meta_get(self, row: int) -> int: """Return row metadata""" return self.item(row, self.COL_USERDATA).data(self.ROW_METADATA) def _meta_notset(self, metadata: int) -> List[int]: """ Search rows for metadata not set. Return a list of matching row numbers. """ matches = [] for row in range(self.rowCount()): row_meta = self._meta_get(row) if row_meta is not None: if not self._meta_get(row) & (1 << metadata): matches.append(row) return matches def _meta_search(self, metadata: int, one: bool = True) -> List[int]: """ 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): if self._meta_get(row) & (1 << metadata): matches.append(row) if not one: return matches if len(matches) <= 1: return matches 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: int, attribute: int) -> None: """Set row metadata""" if row is None: raise ValueError(f"_meta_set_attribute({row=}, {attribute=})") current_metadata: int = self._meta_get(row) if not current_metadata: new_metadata: int = (1 << attribute) else: new_metadata = self._meta_get(row) | (1 << attribute) self.item(row, self.COL_USERDATA).setData( self.ROW_METADATA, new_metadata) def _rescan(self, row: int) -> None: """ If passed row is track row, rescan it. Otherwise, return None. """ DEBUG(f"_rescan({row=})") with Session() as session: if row in self._get_track_rows(): track: Tracks = self._get_row_track_object(row, session) if track: track.rescan(session) self._update_row(session, row, track) def _set_current_track_row(self, row: int) -> None: """Mark this row as current track""" self._clear_current_track_row() self._meta_set_attribute(row, RowMeta.CURRENT) def _set_next_track_row(self, row: int) -> None: """Mark this row as next track""" self._meta_clear_next() self._meta_set_attribute(row, RowMeta.NEXT) def _set_note_row(self, row: int) -> None: """Mark this row as a note""" self._meta_set_attribute(row, RowMeta.NOTE) def _set_played_row(self, row: int) -> None: """Mark this row as played""" self._meta_set_attribute(row, RowMeta.PLAYED) def _set_unreadable_row(self, row: int) -> None: """Mark this row as unreadable""" self._meta_set_attribute(row, RowMeta.UNREADABLE) 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 # Get the row number of all selected items and put into a set # to deduplicate sel_rows: Set[int] = set([item.row() for item in self.selectedItems()]) # If no rows are selected, we have nothing to do if len(sel_rows) == 0: self.musicmuster.lblSumPlaytime.setText("") return notes_rows: Set[int] = set(self._get_notes_rows()) ms: int = 0 with Session() as session: for row in (sel_rows - notes_rows): ms += self._get_row_track_object(row, session).duration or 0 # Only paint message if there are selected track rows if ms > 0: self.musicmuster.lblSumPlaytime.setText( f"Selected duration: {helpers.ms_to_mmss(ms)}") else: self.musicmuster.lblSumPlaytime.setText("") def _select_tracks(self, played: bool) -> None: """ Select all played (played=True) or unplayed (played=False) tracks in playlist """ # Need to allow multiple rows to be selected self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) self.clearSelection() if played: rows = self._get_played_track_rows() else: rows = self._get_unplayed_track_rows() for row in rows: self.selectRow(row) # Reset extended selection self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) def _set_column_widths(self, session: Session) -> None: """Column widths from settings""" for column in range(self.columnCount()): name: str = f"playlist_col_{str(column)}_width" record: Settings = Settings.get_int_settings(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_next(self, row: int, session: Session) -> None: """ Set passed row as next track to play. Actions required: - Check row is a track row - Check track is readable - Mark as next track - Update display - Notify musicmuster """ DEBUG(f"_set_next({row=})") # Check row is a track row if row in self._get_notes_rows(): return None track: Tracks = self._get_row_track_object(row, session) if not track: return None # Check track is readable if not self._file_is_readable(track.path): self._set_unreadable_row(row) return None # Mark as next track self._set_next_track_row(row) # Update display self.update_display(session) # Notify musicmuster self.musicmuster.this_is_the_next_track(self, track, session) def _set_row_bold(self, row: int, bold: bool = True) -> None: """Make row bold (bold=True) or not bold""" i: int j: int boldfont: QFont = 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: int, colour: QColor) -> None: """Set row background colour""" j: int for j in range(2, self.columnCount()): if self.item(row, j): self.item(row, j).setBackground(colour) def _set_row_content(self, row: int, object_id: int) -> None: """Set content associated with this row""" assert self.item(row, self.COL_USERDATA) self.item(row, self.COL_USERDATA).setData( self.CONTENT_OBJECT, object_id) def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None: """Set passed row end time to passed time""" try: time_str: str = time.strftime(Config.TRACK_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: 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: str = time.strftime(Config.TRACK_TIME_FORMAT) except AttributeError: time_str = "" item: QTableWidgetItem = QTableWidgetItem(time_str) self.setItem(row, self.COL_START_TIME, item) def _set_timed_section(self, session, start_row, ms, no_end=False): """Add duration to a marked section""" duration = helpers.ms_to_mmss(ms) note_object = self._get_row_notes_object(start_row, session) if not note_object: ERROR("Can't get note_object in playlists._set_timed_section") note_text = note_object.note caveat = "" if no_end: caveat = " (to end of playlist)" display_text = note_text + ' [' + duration + caveat + ']' item = self.item(start_row, self.COL_TITLE) item.setText(display_text) def _update_row(self, session, row: int, track: Tracks) -> None: """ Update the passed row with info from the passed track. """ DEBUG(f"_update_row({row=}, {track=}") item_startgap: QTableWidgetItem = 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: QTableWidgetItem = self.item(row, self.COL_TITLE) item_title.setText(track.title) item_artist: QTableWidgetItem = self.item(row, self.COL_ARTIST) item_artist.setText(track.artist) item_duration: QTableWidgetItem = self.item(row, self.COL_DURATION) item_duration.setText(helpers.ms_to_mmss(track.duration)) self.update_display(session)