From fedcfc3eea77982531bbd6fb50c221165b11d690 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 31 Oct 2023 20:09:45 +0000 Subject: [PATCH] WIP V3: Add track to header row implemented --- app/datastructures.py | 3 +- app/dialogs.py | 175 +++++++++++++++++++++++++++++++++ app/musicmuster.py | 160 +----------------------------- app/playlistmodel.py | 52 ++++++++++ app/playlists.py | 223 ++++++++++++++++++++---------------------- 5 files changed, 334 insertions(+), 279 deletions(-) create mode 100644 app/dialogs.py diff --git a/app/datastructures.py b/app/datastructures.py index 6447dc4..70cffba 100644 --- a/app/datastructures.py +++ b/app/datastructures.py @@ -14,7 +14,8 @@ class MusicMusterSignals(QObject): https://refactoring.guru/design-patterns/singleton/python/example#example-0 """ + add_track_to_header_signal = pyqtSignal(int, int, int) + add_track_to_playlist_signal = pyqtSignal(int, int, int, str) enable_escape_signal = pyqtSignal(bool) set_next_track_signal = pyqtSignal(int, int) span_cells_signal = pyqtSignal(int, int, int, int) - add_track_to_playlist_signal = pyqtSignal(int, int, int, str) diff --git a/app/dialogs.py b/app/dialogs.py new file mode 100644 index 0000000..8a515e1 --- /dev/null +++ b/app/dialogs.py @@ -0,0 +1,175 @@ +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtWidgets import QDialog, QListWidgetItem +from typing import Optional + +import helpers + +from datastructures import MusicMusterSignals +from dbconfig import scoped_session +from models import Settings, Tracks +from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore + + +class TrackSelectDialog(QDialog): + """Select track from database""" + + def __init__( + self, + session: scoped_session, + new_row_number: int, + playlist_id: int, + add_to_header: Optional[bool] = False, + *args, + **kwargs, + ) -> None: + """ + Subclassed QDialog to manage track selection + """ + + super().__init__(*args, **kwargs) + self.session = session + self.new_row_number = new_row_number + self.playlist_id = playlist_id + self.add_to_header = add_to_header + self.ui = Ui_Dialog() + self.ui.setupUi(self) + self.ui.btnAdd.clicked.connect(self.add_selected) + self.ui.btnAddClose.clicked.connect(self.add_selected_and_close) + self.ui.btnClose.clicked.connect(self.close) + self.ui.matchList.itemDoubleClicked.connect(self.add_selected) + self.ui.matchList.itemSelectionChanged.connect(self.selection_changed) + self.ui.radioTitle.toggled.connect(self.title_artist_toggle) + self.ui.searchString.textEdited.connect(self.chars_typed) + self.track: Optional[Tracks] = None + self.signals = MusicMusterSignals() + + record = Settings.get_int_settings(self.session, "dbdialog_width") + width = record.f_int or 800 + record = Settings.get_int_settings(self.session, "dbdialog_height") + height = record.f_int or 600 + self.resize(width, height) + + def add_selected(self) -> None: + """Handle Add button""" + + track = None + + if self.ui.matchList.selectedItems(): + item = self.ui.matchList.currentItem() + if item: + track = item.data(Qt.ItemDataRole.UserRole) + + note = self.ui.txtNote.text() + + if not note and not track: + return + + self.ui.txtNote.clear() + self.select_searchtext() + + track_id = None + if track: + track_id = track.id + if self.add_to_header: + self.signals.add_track_to_header_signal.emit( + self.playlist_id, self.new_row_number, track_id + ) + else: + self.signals.add_track_to_playlist_signal.emit( + self.playlist_id, self.new_row_number, track_id, note + ) + + def add_selected_and_close(self) -> None: + """Handle Add and Close button""" + + self.add_selected() + self.accept() + + def chars_typed(self, s: str) -> None: + """Handle text typed in search box""" + + self.ui.matchList.clear() + if len(s) > 0: + if self.ui.radioTitle.isChecked(): + matches = Tracks.search_titles(self.session, "%" + s) + else: + matches = Tracks.search_artists(self.session, "%" + s) + if matches: + for track in matches: + last_played = None + last_playdate = max( + track.playdates, key=lambda p: p.lastplayed, default=None + ) + if last_playdate: + last_played = last_playdate.lastplayed + t = QListWidgetItem() + track_text = ( + f"{track.title} - {track.artist} " + f"[{helpers.ms_to_mmss(track.duration)}] " + f"({helpers.get_relative_date(last_played)})" + ) + t.setText(track_text) + t.setData(Qt.ItemDataRole.UserRole, track) + self.ui.matchList.addItem(t) + + def closeEvent(self, event: Optional[QEvent]) -> None: + """ + Override close and save dialog coordinates + """ + + if not event: + return + + record = Settings.get_int_settings(self.session, "dbdialog_height") + if record.f_int != self.height(): + record.update(self.session, {"f_int": self.height()}) + + record = Settings.get_int_settings(self.session, "dbdialog_width") + if record.f_int != self.width(): + record.update(self.session, {"f_int": self.width()}) + + event.accept() + + def keyPressEvent(self, event): + """ + Clear selection on ESC if there is one + """ + + if event.key() == Qt.Key.Key_Escape: + if self.ui.matchList.selectedItems(): + self.ui.matchList.clearSelection() + return + + super(TrackSelectDialog, self).keyPressEvent(event) + + def select_searchtext(self) -> None: + """Select the searchbox""" + + self.ui.searchString.selectAll() + self.ui.searchString.setFocus() + + def selection_changed(self) -> None: + """Display selected track path in dialog box""" + + if not self.ui.matchList.selectedItems(): + return + + item = self.ui.matchList.currentItem() + track = item.data(Qt.ItemDataRole.UserRole) + last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None) + if last_playdate: + last_played = last_playdate.lastplayed + else: + last_played = None + path_text = f"{track.path} ({helpers.get_relative_date(last_played)})" + + self.ui.dbPath.setText(path_text) + + def title_artist_toggle(self) -> None: + """ + Handle switching between searching for artists and searching for + titles + """ + + # Logic is handled already in chars_typed(), so just call that. + self.chars_typed(self.ui.searchString.text()) diff --git a/app/musicmuster.py b/app/musicmuster.py index aef00d8..0734d62 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -66,12 +66,12 @@ from dbconfig import ( import helpers import icons_rc # noqa F401 import music +from dialogs import TrackSelectDialog from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks from config import Config from datastructures import MusicMusterSignals from playlists import PlaylistTab from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore -from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.main_window_ui import Ui_MainWindow # type: ignore @@ -1901,164 +1901,6 @@ class CartDialog(QDialog): self.ui.lblPath.setText(self.path) -class TrackSelectDialog(QDialog): - """Select track from database""" - - def __init__( - self, - session: scoped_session, - new_row_number: int, - playlist_id: int, - *args, - **kwargs, - ) -> None: - """ - Subclassed QDialog to manage track selection - """ - - super().__init__(*args, **kwargs) - self.session = session - self.new_row_number = new_row_number - self.playlist_id = playlist_id - self.ui = Ui_Dialog() - self.ui.setupUi(self) - self.ui.btnAdd.clicked.connect(self.add_selected) - self.ui.btnAddClose.clicked.connect(self.add_selected_and_close) - self.ui.btnClose.clicked.connect(self.close) - self.ui.matchList.itemDoubleClicked.connect(self.add_selected) - self.ui.matchList.itemSelectionChanged.connect(self.selection_changed) - self.ui.radioTitle.toggled.connect(self.title_artist_toggle) - self.ui.searchString.textEdited.connect(self.chars_typed) - self.track: Optional[Tracks] = None - self.signals = MusicMusterSignals() - - record = Settings.get_int_settings(self.session, "dbdialog_width") - width = record.f_int or 800 - record = Settings.get_int_settings(self.session, "dbdialog_height") - height = record.f_int or 600 - self.resize(width, height) - - def add_selected(self) -> None: - """Handle Add button""" - - track = None - - if self.ui.matchList.selectedItems(): - item = self.ui.matchList.currentItem() - if item: - track = item.data(Qt.ItemDataRole.UserRole) - - note = self.ui.txtNote.text() - - if not note and not track: - return - - self.ui.txtNote.clear() - self.select_searchtext() - - track_id = None - if track: - track_id = track.id - self.signals.add_track_to_playlist_signal.emit( - self.playlist_id, self.new_row_number, track_id, note - ) - - def add_selected_and_close(self) -> None: - """Handle Add and Close button""" - - self.add_selected() - self.accept() - - def chars_typed(self, s: str) -> None: - """Handle text typed in search box""" - - self.ui.matchList.clear() - if len(s) > 0: - if self.ui.radioTitle.isChecked(): - matches = Tracks.search_titles(self.session, "%" + s) - else: - matches = Tracks.search_artists(self.session, "%" + s) - if matches: - for track in matches: - last_played = None - last_playdate = max( - track.playdates, key=lambda p: p.lastplayed, default=None - ) - if last_playdate: - last_played = last_playdate.lastplayed - t = QListWidgetItem() - track_text = ( - f"{track.title} - {track.artist} " - f"[{helpers.ms_to_mmss(track.duration)}] " - f"({helpers.get_relative_date(last_played)})" - ) - t.setText(track_text) - t.setData(Qt.ItemDataRole.UserRole, track) - self.ui.matchList.addItem(t) - - def closeEvent(self, event: Optional[QEvent]) -> None: - """ - Override close and save dialog coordinates - """ - - if not event: - return - - record = Settings.get_int_settings(self.session, "dbdialog_height") - if record.f_int != self.height(): - record.update(self.session, {"f_int": self.height()}) - - record = Settings.get_int_settings(self.session, "dbdialog_width") - if record.f_int != self.width(): - record.update(self.session, {"f_int": self.width()}) - - event.accept() - - def keyPressEvent(self, event): - """ - Clear selection on ESC if there is one - """ - - if event.key() == Qt.Key.Key_Escape: - if self.ui.matchList.selectedItems(): - self.ui.matchList.clearSelection() - return - - super(TrackSelectDialog, self).keyPressEvent(event) - - def select_searchtext(self) -> None: - """Select the searchbox""" - - self.ui.searchString.selectAll() - self.ui.searchString.setFocus() - - def selection_changed(self) -> None: - """Display selected track path in dialog box""" - - if not self.ui.matchList.selectedItems(): - return - - item = self.ui.matchList.currentItem() - track = item.data(Qt.ItemDataRole.UserRole) - last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None) - if last_playdate: - last_played = last_playdate.lastplayed - else: - last_played = None - path_text = f"{track.path} ({helpers.get_relative_date(last_played)})" - - self.ui.dbPath.setText(path_text) - - def title_artist_toggle(self) -> None: - """ - Handle switching between searching for artists and searching for - titles - """ - - # Logic is handled already in chars_typed(), so just call that. - self.chars_typed(self.ui.searchString.text()) - - class DownloadCSV(QDialog): def __init__(self, parent=None): super().__init__() diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 8cb28d9..8bc1996 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -21,6 +21,7 @@ from dbconfig import scoped_session, Session from helpers import ( file_is_unreadable, ) +from log import log from models import PlaylistRows, Tracks @@ -104,6 +105,7 @@ class PlaylistModel(QAbstractTableModel): self.signals = MusicMusterSignals() self.signals.add_track_to_playlist_signal.connect(self.add_track) + self.signals.add_track_to_header_signal.connect(self.add_track_to_header) with Session() as session: self.refresh_data(session) @@ -138,6 +140,49 @@ class PlaylistModel(QAbstractTableModel): # No track, no note, no point return + def add_track_to_header( + self, + playlist_id: int, + row_number: int, + track_id: int, + ) -> None: + """ + Add track to existing header row if it's for our playlist + """ + + # Ignore if it's not for us + if playlist_id != self.playlist_id: + return + + # Get existing row + try: + prd = self.playlist_rows[row_number] + except KeyError: + log.error( + f"KeyError in PlaylistModel:add_track_to_header ({playlist_id=}, " + f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}" + ) + return + if prd.path: + log.error( + f"Error in PlaylistModel:add_track_to_header ({prd=}, " + "Header row already has track associated" + ) + return + with Session() as session: + plr = session.get(PlaylistRows, prd.plrid) + if plr: + # Add track to PlaylistRows + plr.track_id = track_id + # Reset header row spanning + self.signals.span_cells_signal.emit( + row_number, HEADER_NOTES_COLUMN, 1, 1 + ) + # Update local copy + self.refresh_row(session, row_number) + # Repaint row + self.invalidate_row(row_number) + def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush: """Return background setting""" @@ -321,6 +366,13 @@ class PlaylistModel(QAbstractTableModel): return QVariant() + def is_header_row(self, row_number: int) -> bool: + """ + Return True if row is a header row, else False + """ + + return self.playlist_rows[row_number].path == "" + def insert_header_row(self, row_number: Optional[int], text: str) -> None: """ Insert a header row. diff --git a/app/playlists.py b/app/playlists.py index 2443c05..c86e5ee 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -7,7 +7,7 @@ import threading import obsws_python as obs # type: ignore from datetime import datetime, timedelta -from typing import Any, cast, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING from PyQt6.QtCore import ( QEvent, @@ -22,7 +22,7 @@ from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, QHeaderView, - # QMenu, + QMenu, QMessageBox, QPlainTextEdit, QStyledItemDelegate, @@ -37,6 +37,7 @@ from PyQt6.QtWidgets import ( from datastructures import MusicMusterSignals from dbconfig import Session, scoped_session +from dialogs import TrackSelectDialog from config import Config from helpers import ( ask_yes_no, @@ -48,11 +49,11 @@ from helpers import ( set_track_metadata, ) from log import log -from models import Playlists, PlaylistRows, Settings, Tracks, NoteColours -from playlistmodel import PlaylistModel +from models import PlaylistRows, Settings, Tracks, NoteColours if TYPE_CHECKING: - from musicmuster import Window, MusicMusterSignals + from musicmuster import Window +from playlistmodel import PlaylistModel HEADER_NOTES_COLUMN = 2 @@ -78,7 +79,8 @@ class EscapeDelegate(QStyledItemDelegate): Intercept createEditor call and make row just a little bit taller """ - signals.enable_escape_signal.emit(False) + 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): @@ -111,7 +113,7 @@ class EscapeDelegate(QStyledItemDelegate): return True elif key_event.key() == Qt.Key.Key_Escape: discard_edits = QMessageBox.question( - self.parent(), "Abandon edit", "Discard changes?" + cast(QWidget, self), "Abandon edit", "Discard changes?" ) if discard_edits == QMessageBox.StandardButton.Yes: self.closeEditor.emit(editor) @@ -134,8 +136,7 @@ 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. This may not always work depending on global - style - for instance I think it won't work on OSX. + we're hovering over. """ if ( element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop @@ -178,9 +179,9 @@ class PlaylistTab(QTableView): # rows selected self.setDragEnabled(True) # Prepare for context menu - # self.menu = QMenu() - # self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - # self.customContextMenuRequested.connect(self._context_menu) + self.menu = QMenu() + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._context_menu) # Connect signals # This dancing is to satisfy mypy @@ -291,27 +292,27 @@ class PlaylistTab(QTableView): # self.hide_or_show_played_tracks() - # def _add_context_menu( - # self, - # text: str, - # action: Callable, - # disabled: bool = False, - # parent_menu: Optional[QMenu] = None, - # ) -> Optional[QAction]: - # """ - # Add item to self.menu - # """ + 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 + 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) + menu_item = parent_menu.addAction(text) + if not menu_item: + return None + menu_item.setDisabled(disabled) + menu_item.triggered.connect(action) - # return menu_item + return menu_item # def mouseReleaseEvent(self, event): # """ @@ -1033,106 +1034,90 @@ class PlaylistTab(QTableView): """Add a track to a section header making it a normal track row""" with Session() as session: - # Add track to playlist row - plr = self._get_row_plr(session, row_number) - if not plr: - return + dlg = TrackSelectDialog( + session=session, + new_row_number=row_number, + playlist_id=self.playlist_id, + add_to_header=True, + ) + dlg.exec() - # Don't add track if there's already a track there - if plr.track_id is not None: - return + def _build_context_menu(self, item: QTableWidgetItem) -> None: + """Used to process context (right-click) menu, which is defined here""" - # Get track - track = self.musicmuster.get_one_track(session) - if not track: - return - plr.track_id = track.id + self.menu.clear() + row_number = item.row() + # track_id = self._get_row_track_id(row_number) + # track_row = bool(track_id) + header_row = False + model = cast(PlaylistModel, self.model()) + if model: - # Reset row span - self.setSpan(row_number, HEADER_NOTES_COLUMN, 1, 1) + header_row = model.is_header_row(row_number) + # current = row_number == self._get_current_track_row_number() + # next_row = row_number == self._get_next_track_row_number() - # Update attributes of row - self._update_row_track_info(session, row_number, track) - self._set_row_bold(row_number) - self._set_row_colour_default(row_number) - self._set_row_note_text(session, row_number, plr.note) - self.clear_selection() - self.save_playlist(session) - # Update times once display updated - self._update_start_end_times(session) + # # Play with mplayer + # if track_row and not current: + # self._add_context_menu( + # "Play with mplayer", lambda: self._mplayer_play(row_number) + # ) - # def _build_context_menu(self, item: QTableWidgetItem) -> None: - # """Used to process context (right-click) menu, which is defined here""" + # # Paste + # self._add_context_menu( + # "Paste", + # lambda: self.musicmuster.paste_rows(), + # self.musicmuster.selected_plrs is None, + # ) - # self.menu.clear() - # row_number = item.row() - # track_id = self._get_row_track_id(row_number) - # track_row = bool(track_id) - # header_row = not track_row - # current = row_number == self._get_current_track_row_number() - # next_row = row_number == self._get_next_track_row_number() + # # Open in Audacity + # if track_row and not current: + # self._add_context_menu( + # "Open in Audacity", lambda: self._open_in_audacity(row_number) + # ) - # # Play with mplayer - # if track_row and not current: - # self._add_context_menu( - # "Play with mplayer", lambda: self._mplayer_play(row_number) - # ) + # # Rescan + # if track_row and not current: + # self._add_context_menu( + # "Rescan track", lambda: self._rescan(row_number, track_id) + # ) - # # Paste - # self._add_context_menu( - # "Paste", - # lambda: self.musicmuster.paste_rows(), - # self.musicmuster.selected_plrs is None, - # ) + # # ---------------------- + self.menu.addSeparator() - # # Open in Audacity - # if track_row and not current: - # self._add_context_menu( - # "Open in Audacity", lambda: self._open_in_audacity(row_number) - # ) + # # Remove row + # if not current and not next_row: + # self._add_context_menu("Delete row", self._delete_rows) - # # Rescan - # if track_row and not current: - # self._add_context_menu( - # "Rescan track", lambda: self._rescan(row_number, track_id) - # ) + # # Move to playlist + # if not current and not next_row: + # self._add_context_menu( + # "Move to playlist...", self.musicmuster.move_selected + # ) - # # ---------------------- - # self.menu.addSeparator() + # # ---------------------- + # self.menu.addSeparator() - # # Remove row - # if not current and not next_row: - # self._add_context_menu("Delete row", self._delete_rows) + # # Remove track from row + # if track_row and not current and not next_row: + # self._add_context_menu( + # "Remove track from row", lambda: self._remove_track(row_number) + # ) - # # Move to playlist - # if not current and not next_row: - # self._add_context_menu( - # "Move to playlist...", self.musicmuster.move_selected - # ) + # Add track to section header (ie, make this a track row) + if header_row: + self._add_context_menu("Add a track", lambda: self._add_track(row_number)) - # # ---------------------- - # self.menu.addSeparator() + # # Mark unplayed + # if self._get_row_userdata(row_number, self.PLAYED): + # self._add_context_menu("Mark unplayed", self._mark_unplayed) - # # Remove track from row - # if track_row and not current and not next_row: - # self._add_context_menu( - # "Remove track from row", lambda: self._remove_track(row_number) - # ) + # # Unmark as next + # if next_row: + # self._add_context_menu("Unmark as next track", self.clear_next) - # # Add track to section header (ie, make this a track row) - # if header_row: - # self._add_context_menu("Add a track", lambda: self._add_track(row_number)) - - # # Mark unplayed - # if self._get_row_userdata(row_number, self.PLAYED): - # self._add_context_menu("Mark unplayed", self._mark_unplayed) - - # # Unmark as next - # if next_row: - # self._add_context_menu("Unmark as next track", self.clear_next) - - # # ---------------------- - # self.menu.addSeparator() + # # ---------------------- + self.menu.addSeparator() # # Sort # sort_menu = self.menu.addMenu("Sort") @@ -1198,12 +1183,12 @@ class PlaylistTab(QTableView): record = Settings.get_int_settings(session, attr_name) record.f_int = self.columnWidth(column_number) - # def _context_menu(self, pos): - # """Display right-click menu""" + def _context_menu(self, pos): + """Display right-click menu""" - # item = self.itemAt(pos) - # self._build_context_menu(item) - # self.menu.exec(self.mapToGlobal(pos)) + item = self.indexAt(pos) + self._build_context_menu(item) + self.menu.exec(self.mapToGlobal(pos)) def _copy_path(self, row_number: int) -> None: """