import os import re import stackprinter # type: ignore import subprocess import threading import obsws_python as obs # type: ignore from datetime import datetime, timedelta from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING from PyQt6.QtCore import ( QEvent, QModelIndex, QObject, QItemSelection, Qt, # QTimer, ) from PyQt6.QtGui import QAction, QBrush, QColor, QFont, QDropEvent, QKeyEvent from PyQt6.QtWidgets import ( QAbstractItemDelegate, QAbstractItemView, QApplication, QHeaderView, QMenu, QMessageBox, QPlainTextEdit, QStyledItemDelegate, QStyleOptionViewItem, QTableView, QTableWidgetItem, QWidget, QProxyStyle, QStyle, QStyleOption, ) from dbconfig import Session, scoped_session from dialogs import TrackSelectDialog from classes import MusicMusterSignals, track_sequence from config import Config from helpers import ( ask_yes_no, file_is_unreadable, get_relative_date, ms_to_mmss, open_in_audacity, send_mail, set_track_metadata, ) from log import log from models import PlaylistRows, Settings, Tracks, NoteColours if TYPE_CHECKING: from musicmuster import Window from playlistmodel import PlaylistModel # HEADER_NOTES_COLUMN = 2 class EscapeDelegate(QStyledItemDelegate): """ - increases the height of a row when editing to make editing easier - closes the edit on control-return - checks with user before abandoning edit on Escape """ def __init__(self, parent) -> None: super().__init__(parent) self.signals = MusicMusterSignals() def createEditor( self, parent: Optional[QWidget], option: QStyleOptionViewItem, index: QModelIndex, ): """ Intercept createEditor call and make row just a little bit taller """ self.signals = MusicMusterSignals() self.signals.enable_escape_signal.emit(False) if isinstance(self.parent(), PlaylistTab): p = cast(PlaylistTab, self.parent()) if isinstance(index.data(), str): row = index.row() row_height = p.rowHeight(row) p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT) return QPlainTextEdit(parent) return super().createEditor(parent, option, index) def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None: """ Intercept editor destroyment """ self.signals.enable_escape_signal.emit(True) return super().destroyEditor(editor, index) def eventFilter(self, editor: Optional[QObject], event: Optional[QEvent]) -> bool: """By default, QPlainTextEdit doesn't handle enter or return""" if editor is None or event is None: return False if event.type() == QEvent.Type.KeyPress: key_event = cast(QKeyEvent, event) if key_event.key() == Qt.Key.Key_Return: if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier): self.commitData.emit(editor) self.closeEditor.emit(editor) return True elif key_event.key() == Qt.Key.Key_Escape: discard_edits = QMessageBox.question( cast(QWidget, self.parent()), "Abandon edit", "Discard changes?" ) if discard_edits == QMessageBox.StandardButton.Yes: self.closeEditor.emit(editor) return True return False def setEditorData(self, editor, index): value = index.model().data(index, Qt.ItemDataRole.EditRole) editor.setPlainText(value.value()) def setModelData(self, editor, model, index): value = editor.toPlainText() model.setData(index, value, Qt.ItemDataRole.EditRole) def updateEditorGeometry(self, editor, option, index): editor.setGeometry(option.rect) class PlaylistStyle(QProxyStyle): def drawPrimitive(self, element, option, painter, widget=None): """ Draw a line across the entire row rather than just the column we're hovering over. """ if ( element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop and not option.rect.isNull() ): option_new = QStyleOption(option) option_new.rect.setLeft(0) if widget: option_new.rect.setRight(widget.width()) option = option_new super().drawPrimitive(element, option, painter, widget) class PlaylistTab(QTableView): def __init__( self, musicmuster: "Window", playlist_id: int, ) -> None: super().__init__() # Save passed settings self.musicmuster = musicmuster self.playlist_id = playlist_id # Set up widget self.setItemDelegate(EscapeDelegate(self)) self.setAlternatingRowColors(True) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) # self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDragDropOverwriteMode(False) self.setAcceptDrops(True) # Set our custom style - this draws the drop indicator across the whole row self.setStyle(PlaylistStyle()) # We will enable dragging when rows are selected. Disabling it # here means we can click and drag to select rows. self.setDragEnabled(False) # Prepare for context menu self.menu = QMenu() self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._context_menu) # Connect signals # This dancing is to satisfy mypy h_header = self.horizontalHeader() if isinstance(h_header, QHeaderView): h_header.sectionResized.connect(self._column_resize) h_header.setStretchLastSection(True) # self.signals.set_next_track_signal.connect(self._reset_next) self.signals = MusicMusterSignals() self.signals.span_cells_signal.connect(self._span_cells) # Call self.eventFilter() for events # self.installEventFilter(self) # Initialise miscellaneous instance variables self.search_text: str = "" self.sort_undo: List[int] = [] # self.edit_cell_type: Optional[int] # Load playlist rows self.setModel(PlaylistModel(playlist_id)) self._set_column_widths() def closeEditor( self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint ) -> None: """ Override closeEditor to enable play controls and update display. """ self.musicmuster.enable_play_next_controls() self.musicmuster.actionSetNext.setEnabled(True) self.musicmuster.action_Clear_selection.setEnabled(True) super(PlaylistTab, self).closeEditor(editor, hint) # Optimise row heights after increasing row height for editing self.resizeRowsToContents() # Update start times in case a start time in a note has been # edited model = cast(PlaylistModel, self.model()) model.update_track_times() def dropEvent(self, event): if event.source() is not self or ( event.dropAction() != Qt.DropAction.MoveAction and self.dragDropMode() != QAbstractItemView.InternalMove ): super().dropEvent(event) from_rows = list(set([a.row() for a in self.selectedIndexes()])) to_row = self.indexAt(event.position().toPoint()).row() if ( 0 <= min(from_rows) <= self.model().rowCount() and 0 <= max(from_rows) <= self.model().rowCount() and 0 <= to_row <= self.model().rowCount() ): self.model().move_rows(from_rows, to_row) # Reset drag mode to allow row selection by dragging self.setDragEnabled(False) # Deselect rows self.clear_selection() event.accept() def edit( self, index: QModelIndex, trigger: QAbstractItemView.EditTrigger, event: Optional[QEvent], ) -> bool: """ Override PySide2.QAbstractItemView.edit to catch when editing starts Editing only ever starts with a double click on a cell """ # 'result' will only be true on double-click result = super().edit(index, trigger, event) if result: self.musicmuster.disable_play_next_controls() return result def _add_context_menu( self, text: str, action: Callable, disabled: bool = False, parent_menu: Optional[QMenu] = None, ) -> Optional[QAction]: """ Add item to self.menu """ if parent_menu is None: parent_menu = self.menu menu_item = parent_menu.addAction(text) if not menu_item: return None menu_item.setDisabled(disabled) menu_item.triggered.connect(action) return menu_item def mouseReleaseEvent(self, event): """ Enable dragging if rows are selected """ if self.selectedIndexes(): self.setDragEnabled(True) else: self.setDragEnabled(False) self.reset() super().mouseReleaseEvent(event) # # ########## Externally called functions ########## def clear_selection(self) -> None: """Unselect all tracks and reset drag mode""" self.clearSelection() self.setDragEnabled(False) def get_selected_row_number(self) -> Optional[int]: """ Return the selected row number or None if none selected. """ sm = self.selectionModel() if sm and sm.hasSelection(): index = sm.currentIndex() if index.isValid(): return index.row() return None def get_selected_row_track_path(self) -> str: """ Return the path of the selected row. If no row selected or selected row does not have a track, return empty string. """ sm = self.selectionModel() if sm and sm.hasSelection(): index = sm.currentIndex() if index.isValid(): model = cast(PlaylistModel, self.model()) return model.get_row_track_path(index.row()) return "" # def lookup_row_in_songfacts(self) -> None: # """ # If there is a selected row and it is a track row, # look up its title in songfacts. # If multiple rows are selected, only consider the first one. # Otherwise return. # """ # self._look_up_row(website="songfacts") # def lookup_row_in_wikipedia(self) -> None: # """ # If there is a selected row and it is a track row, # look up its title in wikipedia. # If multiple rows are selected, only consider the first one. # Otherwise return. # """ # self._look_up_row(website="wikipedia") # def scroll_current_to_top(self) -> None: # """Scroll currently-playing row to top""" # current_row = self._get_current_track_row_number() # if current_row is not None: # self._scroll_to_top(current_row) # def scroll_next_to_top(self) -> None: # """Scroll nextly-playing row to top""" # next_row = self._get_next_track_row_number() # if next_row is not None: # self._scroll_to_top(next_row) def set_search(self, text: str) -> None: """Set search text and find first match""" self.search_text = text if not text: # Search string has been reset return self._search(next=True) # def search_next(self) -> None: # """ # Select next row containg self.search_string. # """ # self._search(next=True) # def search_previous(self) -> None: # """ # Select previous row containg self.search_string. # """ # self._search(next=False) # def select_next_row(self) -> None: # """ # Select next or first row. Don't select section headers. # Wrap at last row. # """ # selected_rows = self._get_selected_rows() # # we will only handle zero or one selected rows # if len(selected_rows) > 1: # return # # select first row if none selected # if len(selected_rows) == 0: # row_number = 0 # else: # row_number = selected_rows[0] + 1 # if row_number >= self.rowCount(): # row_number = 0 # # Don't select section headers # wrapped = False # track_id = self._get_row_track_id(row_number) # while not track_id: # row_number += 1 # if row_number >= self.rowCount(): # if wrapped: # # we're already wrapped once, so there are no # # non-headers # return # row_number = 0 # wrapped = True # track_id = self._get_row_track_id(row_number) # self.selectRow(row_number) # def select_previous_row(self) -> None: # """ # Select previous or last track. Don't select section headers. # Wrap at first row. # """ # selected_rows = self._get_selected_rows() # # we will only handle zero or one selected rows # if len(selected_rows) > 1: # return # # select last row if none selected # last_row = self.rowCount() - 1 # if len(selected_rows) == 0: # row_number = last_row # else: # row_number = selected_rows[0] - 1 # if row_number < 0: # row_number = last_row # # Don't select section headers # wrapped = False # track_id = self._get_row_track_id(row_number) # while not track_id: # row_number -= 1 # if row_number < 0: # if wrapped: # # we're already wrapped once, so there are no # # non-notes # return # row_number = last_row # wrapped = True # track_id = self._get_row_track_id(row_number) # self.selectRow(row_number) def set_row_as_next_track(self) -> None: """ Set selected row as next track """ selected_row = self._get_selected_row() if selected_row is None: return model = cast(PlaylistModel, self.model()) model.set_next_row(selected_row) self.clearSelection() # # # ########## Internally called functions ########## def _add_track(self, row_number: int) -> None: """Add a track to a section header making it a normal track row""" with Session() as session: dlg = TrackSelectDialog( session=session, new_row_number=row_number, playlist_id=self.playlist_id, add_to_header=True, ) dlg.exec() def _build_context_menu(self, item: QTableWidgetItem) -> None: """Used to process context (right-click) menu, which is defined here""" self.menu.clear() model = cast(PlaylistModel, self.model()) if not model: return row_number = item.row() header_row = model.is_header_row(row_number) track_row = not header_row current_row = row_number == track_sequence.now.plr_rownum next_row = row_number == track_sequence.next.plr_rownum # Open in Audacity if track_row and not current_row: self._add_context_menu( "Open in Audacity", lambda: model.open_in_audacity(row_number) ) # Rescan if track_row and not current_row: self._add_context_menu("Rescan track", lambda: self._rescan(row_number)) # ---------------------- self.menu.addSeparator() # Delete row if not current_row and not next_row: self._add_context_menu("Delete row", lambda: self._delete_rows()) # Remove track from row if track_row and not current_row and not next_row: self._add_context_menu( "Remove track from row", lambda: model.remove_track(row_number) ) # Add track to section header (ie, make this a track row) # TODO if header_row: self._add_context_menu("Add a track", lambda: print("Add a track")) # # ---------------------- self.menu.addSeparator() # Mark unplayed if track_row and model.is_unplayed_row(row_number): self._add_context_menu( "Mark unplayed", lambda: model.mark_unplayed(self._get_selected_rows()) ) # Unmark as next if next_row: self._add_context_menu( "Unmark as next track", lambda: model.set_next_row(None) ) # ---------------------- self.menu.addSeparator() # Sort sort_menu = self.menu.addMenu("Sort") self._add_context_menu( "by title", lambda: model.sort_by_title(self._get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "by artist", lambda: model.sort_by_artist(self._get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "by duration", lambda: model.sort_by_duration(self._get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "by last played", lambda: model.sort_by_lastplayed(self._get_selected_rows()), parent_menu=sort_menu, ) # Info TODO if track_row: self._add_context_menu("Info", lambda: print("Track info")) # Track path TODO if track_row: self._add_context_menu("Copy track path", lambda: print("Track path")) def _calculate_end_time( self, start: Optional[datetime], duration: int ) -> Optional[datetime]: """Return datetime 'duration' ms after 'start'""" if start is None: return None return start + timedelta(milliseconds=duration) def _column_resize(self, column_number: int, _old: int, _new: int) -> None: """ Called when column width changes. Save new width to database. """ header = self.horizontalHeader() if not header: return # Resize rows if necessary self.resizeRowsToContents() with Session() as session: attr_name = f"playlist_col_{column_number}_width" record = Settings.get_int_settings(session, attr_name) record.f_int = self.columnWidth(column_number) def _context_menu(self, pos): """Display right-click menu""" item = self.indexAt(pos) self._build_context_menu(item) self.menu.exec(self.mapToGlobal(pos)) def _copy_path(self, row_number: int) -> None: """ If passed row_number has a track, copy the track path, single-quoted, to the clipboard. Otherwise, return None. """ track_path = self._get_row_track_path(row_number) if not track_path: return replacements = [ ("'", "\\'"), (" ", "\\ "), ("(", "\\("), (")", "\\)"), ] for old, new in replacements: track_path = track_path.replace(old, new) cb = QApplication.clipboard() cb.clear(mode=cb.Mode.Clipboard) cb.setText(track_path, mode=cb.Mode.Clipboard) def _delete_rows(self) -> None: """ Delete mutliple rows Actions required: - Confirm deletion should go ahead - Pass to model to do the deed """ rows_to_delete = self._get_selected_rows() row_count = len(rows_to_delete) if row_count < 1: return # Get confirmation plural = "s" if row_count > 1 else "" if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): return model = cast(PlaylistModel, self.model()) model.delete_rows(self._get_selected_rows()) def _get_selected_row(self) -> Optional[int]: """ Return row_number number of first selected row, or None if none selected """ sm = self.selectionModel() if sm: if sm.hasSelection(): return sm.selectedIndexes()[0].row() return None def _get_selected_rows(self) -> List[int]: """Return a list of selected row numbers sorted by row""" # Use a set to deduplicate result (a selected row will have all # items in that row selected) return sorted(list(set([a.row() for a in self.selectedIndexes()]))) def _info_row(self, track_id: int) -> None: """Display popup with info re row""" with Session() as session: track = session.get(Tracks, track_id) if track: txt = ( f"Title: {track.title}\n" f"Artist: {track.artist}\n" f"Track ID: {track.id}\n" f"Track duration: {ms_to_mmss(track.duration)}\n" f"Track bitrate: {track.bitrate}\n" f"Track fade at: {ms_to_mmss(track.fade_at)}\n" f"Track silence at: {ms_to_mmss(track.silence_at)}" "\n\n" f"Path: {track.path}\n" ) else: txt = f"Can't find {track_id=}" info: QMessageBox = QMessageBox(self) info.setIcon(QMessageBox.Icon.Information) info.setText(txt) info.setStandardButtons(QMessageBox.StandardButton.Ok) info.setDefaultButton(QMessageBox.StandardButton.Cancel) info.exec() def _look_up_row(self, website: str) -> None: """ If there is a selected row and it is a track row, look up its title in the passed website If multiple rows are selected, only consider the first one. Otherwise return. """ print("playlists_v3:_look_up_row()") return # selected_row = self._get_selected_row() # if not selected_row: # return # if not self._get_row_track_id(selected_row): # return # title = self._get_row_title(selected_row) # if website == "wikipedia": # QTimer.singleShot( # 0, lambda: self.musicmuster.tabInfolist.open_in_wikipedia(title) # ) # elif website == "songfacts": # QTimer.singleShot( # 0, lambda: self.musicmuster.tabInfolist.open_in_songfacts(title) # ) # else: # return def _obs_change_scene(self, current_row: int) -> None: """ Try to change OBS scene to the name passed """ check_row = current_row while True: # If we have a note and it has a scene change command, # execute it note_text = self._get_row_note(check_row) if note_text: match_obj = scene_change_re.search(note_text) if match_obj: scene_name = match_obj.group(1) if scene_name: try: cl = obs.ReqClient( host=Config.OBS_HOST, port=Config.OBS_PORT, password=Config.OBS_PASSWORD, ) except ConnectionRefusedError: log.error("OBS connection refused") return try: cl.set_current_program_scene(scene_name) log.info(f"OBS scene changed to '{scene_name}'") return except obs.error.OBSSDKError as e: log.error(f"OBS SDK error ({e})") return # After current track row, only check header rows and stop # at first non-header row check_row -= 1 if check_row < 0: break if self._get_row_track_id(check_row): break def _rescan(self, row_number: int) -> None: """Rescan track""" model = cast(PlaylistModel, self.model()) model.rescan_track(row_number) self.clear_selection() # def _reset_next(self, old_plrid: int, new_plrid: int) -> None: # """ # Called when set_next_track_signal signal received. # Actions required: # - If old_plrid points to this playlist: # - Remove existing next track # - If new_plrid points to this playlist: # - Set track as next # - Display row as next track # - Update start/stop times # """ # with Session() as session: # # Get plrs # old_plr = new_plr = None # if old_plrid: # old_plr = session.get(PlaylistRows, old_plrid) # # Unmark next track # if old_plr and old_plr.playlist_id == self.playlist_id: # self._set_row_colour_default(old_plr.plr_rownum) # # Mark next track # if new_plrid: # new_plr = session.get(PlaylistRows, new_plrid) # if not new_plr: # log.error(f"_reset_next({new_plrid=}): plr not found") # return # if new_plr.playlist_id == self.playlist_id: # self._set_row_colour_next(new_plr.plr_rownum) # # Update start/stop times # self._update_start_end_times(session) # self.clear_selection() def _run_subprocess(self, args): """Run args in subprocess""" subprocess.call(args) def _scroll_to_top(self, row_number: int) -> None: """ Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the top. """ if row_number is None: return padding_required = Config.SCROLL_TOP_MARGIN top_row = row_number if row_number > Config.SCROLL_TOP_MARGIN: # We can't scroll to a hidden row. Calculate target_row as # the one that is ideal to be at the top. Then count upwards # from passed row_number until we either reach the target, # pass it or reach row_number 0. for i in range(row_number - 1, -1, -1): if self.isRowHidden(i): continue if padding_required == 0: break top_row = i padding_required -= 1 scroll_item = self.item(top_row, 0) self.scrollToItem(scroll_item, QAbstractItemView.ScrollHint.PositionAtTop) # def _search(self, next: bool = True) -> None: # """ # Select next/previous row containg self.search_string. Start from # top selected row if there is one, else from top. # Wrap at last/first row. # """ # if not self.search_text: # return # selected_row = self._get_selected_row() # if next: # if selected_row is not None and selected_row < self.rowCount() - 1: # starting_row = selected_row + 1 # else: # starting_row = 0 # else: # if selected_row is not None and selected_row > 0: # starting_row = selected_row - 1 # else: # starting_row = self.rowCount() - 1 # wrapped = False # match_row = None # row_number = starting_row # needle = self.search_text.lower() # while True: # # Check for match in title, artist or notes # title = self._get_row_title(row_number) # if title and needle in title.lower(): # match_row = row_number # break # artist = self._get_row_artist(row_number) # if artist and needle in artist.lower(): # match_row = row_number # break # note = self._get_row_note(row_number) # if note and needle in note.lower(): # match_row = row_number # break # if next: # row_number += 1 # if wrapped and row_number >= starting_row: # break # if row_number >= self.rowCount(): # row_number = 0 # wrapped = True # else: # row_number -= 1 # if wrapped and row_number <= starting_row: # break # if row_number < 0: # row_number = self.rowCount() - 1 # wrapped = True # if match_row is not None: # self.selectRow(row_number) def selectionChanged( self, selected: QItemSelection, deselected: QItemSelection ) -> None: """ Toggle drag behaviour according to whether rows are selected """ selected_rows = self._get_selected_rows() # If no rows are selected, we have nothing to do if len(selected_rows) == 0: self.musicmuster.lblSumPlaytime.setText("") else: model = cast(PlaylistModel, self.model()) selected_duration = model.get_rows_duration(self._get_selected_rows()) if selected_duration > 0: self.musicmuster.lblSumPlaytime.setText( f"Selected duration: {ms_to_mmss(selected_duration)}" ) else: self.musicmuster.lblSumPlaytime.setText("") super().selectionChanged(selected, deselected) def _set_column_widths(self) -> None: """Column widths from settings""" header = self.horizontalHeader() if not header: return # Set width of last column to zero as it's set to stretch self.setColumnWidth(header.count() - 1, 0) # Set remaining column widths from settings with Session() as session: for column_number in range(header.count() - 1): attr_name = f"playlist_col_{column_number}_width" record = Settings.get_int_settings(session, attr_name) if record.f_int is not None: self.setColumnWidth(column_number, record.f_int) else: self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH) # def _set_row_note_colour(self, session: scoped_session, row_number: int) -> None: # """ # Set row note colour # """ # # Sanity check: this should be a track row and thus have a # # track associated # if not self._get_row_track_id(row_number): # if os.environ["MM_ENV"] == "PRODUCTION": # send_mail( # Config.ERRORS_TO, # Config.ERRORS_FROM, # "playlists:_set_row_note_colour() on header row", # stackprinter.format(), # ) # # stackprinter.show(add_summary=True, style="darkbg") # print(f"playists:_set_row_note_colour() called on track row ({row_number=}") # return # # Set colour # note_text = self._get_row_note(row_number) # note_colour = NoteColours.get_colour(session, note_text) # self._set_cell_colour(row_number, ROW_NOTES, note_colour) def _span_cells(self, row: int, column: int, rowSpan: int, columnSpan: int) -> None: """ Implement spanning of cells, initiated by signal """ # Don't set spanning if already in place because that is seen as # a change to the view and thus it refreshes the data which # again calls us here. if ( self.rowSpan(row, column) == rowSpan and self.columnSpan(row, column) == columnSpan ): return self.setSpan(row, column, rowSpan, columnSpan)