From b4f5d92f5dde358a85fa9669e256a7dbd002aef7 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 26 Feb 2025 13:58:13 +0000 Subject: [PATCH] WIP: query management --- app/classes.py | 76 +++---- app/menu.yaml | 2 + app/models.py | 13 +- app/musicmuster.py | 484 +++++++++++++++++++++++++++++++++------------ 4 files changed, 397 insertions(+), 178 deletions(-) diff --git a/app/classes.py b/app/classes.py index a4fdbfc..1120e73 100644 --- a/app/classes.py +++ b/app/classes.py @@ -23,27 +23,7 @@ from PyQt6.QtWidgets import ( # App imports -class Col(Enum): - START_GAP = 0 - TITLE = auto() - ARTIST = auto() - INTRO = auto() - DURATION = auto() - START_TIME = auto() - END_TIME = auto() - LAST_PLAYED = auto() - BITRATE = auto() - NOTE = auto() - - -class QueryCol(Enum): - TITLE = 0 - ARTIST = auto() - DURATION = auto() - LAST_PLAYED = auto() - BITRATE = auto() - - +# Define singleton first as it's needed below def singleton(cls): """ Make a class a Singleton class (see @@ -66,22 +46,6 @@ def singleton(cls): return wrapper_singleton -class FileErrors(NamedTuple): - path: str - error: str - - -@dataclass -class Filter: - path_type: str = "contains" - path: Optional[str] = None - last_played_number: Optional[int] = None - last_played_unit: str = "years" - duration_type: str = "longer than" - duration_number: int = 0 - duration_unit: str = "minutes" - - class ApplicationError(Exception): """ Custom exception @@ -96,6 +60,36 @@ class AudioMetadata(NamedTuple): fade_at: int = 0 +class Col(Enum): + START_GAP = 0 + TITLE = auto() + ARTIST = auto() + INTRO = auto() + DURATION = auto() + START_TIME = auto() + END_TIME = auto() + LAST_PLAYED = auto() + BITRATE = auto() + NOTE = auto() + + +class FileErrors(NamedTuple): + path: str + error: str + + +@dataclass +class Filter: + path_type: str = "contains" + path: Optional[str] = None + last_played_number: Optional[int] = None + last_played_type: str = "before" + last_played_unit: str = "years" + duration_type: str = "longer than" + duration_number: int = 0 + duration_unit: str = "minutes" + + @singleton @dataclass class MusicMusterSignals(QObject): @@ -142,6 +136,14 @@ class PlaylistStyle(QProxyStyle): super().drawPrimitive(element, option, painter, widget) +class QueryCol(Enum): + TITLE = 0 + ARTIST = auto() + DURATION = auto() + LAST_PLAYED = auto() + BITRATE = auto() + + class Tags(NamedTuple): artist: str = "" title: str = "" diff --git a/app/menu.yaml b/app/menu.yaml index 239d5ce..f9787eb 100644 --- a/app/menu.yaml +++ b/app/menu.yaml @@ -6,6 +6,8 @@ menus: - text: "Manage Templates" handler: "manage_templates" - separator: true + - text: "Manage Queries" + handler: "manage_queries" - separator: true - text: "Exit" handler: "close" diff --git a/app/models.py b/app/models.py index eed3241..59c71b4 100644 --- a/app/models.py +++ b/app/models.py @@ -221,14 +221,6 @@ class Playlists(dbtables.PlaylistsTable): self.open = False session.commit() - def delete(self, session: Session) -> None: - """ - Delete playlist - """ - - session.execute(delete(Playlists).where(Playlists.id == self.id)) - session.commit() - @classmethod def get_all(cls, session: Session) -> Sequence["Playlists"]: """Returns a list of all playlists ordered by last use""" @@ -253,10 +245,7 @@ class Playlists(dbtables.PlaylistsTable): return session.scalars( select(cls) - .where( - cls.is_template.is_(True), - cls.favourite.is_(True) - ) + .where(cls.is_template.is_(True), cls.favourite.is_(True)) .order_by(cls.name) ).all() diff --git a/app/musicmuster.py b/app/musicmuster.py index 173da9c..da4d93e 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -46,6 +46,7 @@ from PyQt6.QtWidgets import ( QMenu, QMessageBox, QPushButton, + QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, @@ -60,6 +61,7 @@ import stackprinter # type: ignore # App imports from classes import ( ApplicationError, + Filter, MusicMusterSignals, TrackInfo, ) @@ -68,7 +70,7 @@ from dialogs import TrackSelectDialog from file_importer import FileImporter from helpers import file_is_unreadable from log import log -from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks +from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks from music_manager import RowAndTrack, track_sequence from playlistmodel import PlaylistModel, PlaylistProxyModel from playlists import PlaylistTab @@ -166,6 +168,126 @@ class EditDeleteDialog(QDialog): self.reject() +class FilterDialog(QDialog): + def __init__(self, name: str, filter: Filter) -> None: + super().__init__() + self.filter = filter + + self.setWindowTitle("Filter Settings") + + layout = QVBoxLayout() + + # Name row + name_layout = QHBoxLayout() + name_label = QLabel("Name") + self.name_text = QLineEdit() + self.name_text.setText(name) + name_layout.addWidget(name_label) + name_layout.addWidget(self.name_text) + layout.addLayout(name_layout) + + # Path row + path_layout = QHBoxLayout() + path_label = QLabel("Path") + self.path_combo = QComboBox() + self.path_combo.addItems(["contains", "excluding"]) + for idx in range(self.path_combo.count()): + if self.path_combo.itemText(idx) == filter.path_type: + self.path_combo.setCurrentIndex(idx) + break + self.path_text = QLineEdit() + if filter.path: + self.path_text.setText(filter.path) + path_layout.addWidget(path_label) + path_layout.addWidget(self.path_combo) + path_layout.addWidget(self.path_text) + layout.addLayout(path_layout) + + # Last played row + last_played_layout = QHBoxLayout() + last_played_label = QLabel("Last played") + self.last_played_combo = QComboBox() + self.last_played_combo.addItems(["before", "never"]) + for idx in range(self.last_played_combo.count()): + if self.last_played_combo.itemText(idx) == filter.last_played_type: + self.last_played_combo.setCurrentIndex(idx) + break + + self.last_played_spinbox = QSpinBox() + self.last_played_spinbox.setMinimum(0) + self.last_played_spinbox.setMaximum(100) + self.last_played_spinbox.setValue(filter.last_played_number or 0) + + self.last_played_unit = QComboBox() + self.last_played_unit.addItems(["years", "months", "weeks", "days"]) + for idx in range(self.last_played_unit.count()): + if self.last_played_unit.itemText(idx) == filter.last_played_unit: + self.last_played_unit.setCurrentIndex(idx) + break + + last_played_ago_label = QLabel("ago") + + last_played_layout.addWidget(last_played_label) + last_played_layout.addWidget(self.last_played_combo) + last_played_layout.addWidget(self.last_played_spinbox) + last_played_layout.addWidget(self.last_played_unit) + last_played_layout.addWidget(last_played_ago_label) + + layout.addLayout(last_played_layout) + + # Duration row + duration_layout = QHBoxLayout() + duration_label = QLabel("Duration") + self.duration_combo = QComboBox() + self.duration_combo.addItems(["longer than", "shorter than"]) + for idx in range(self.duration_combo.count()): + if self.duration_combo.itemText(idx) == filter.duration_type: + self.duration_combo.setCurrentIndex(idx) + break + + self.duration_spinbox = QSpinBox() + self.duration_spinbox.setMinimum(0) + self.duration_spinbox.setMaximum(1000) + self.duration_spinbox.setValue(filter.duration_number) + + self.duration_unit = QComboBox() + self.duration_unit.addItems(["minutes", "seconds"]) + self.duration_unit.setCurrentText("minutes") + for idx in range(self.duration_unit.count()): + if self.duration_unit.itemText(idx) == filter.duration_unit: + self.duration_unit.setCurrentIndex(idx) + break + + duration_layout.addWidget(duration_label) + duration_layout.addWidget(self.duration_combo) + duration_layout.addWidget(self.duration_spinbox) + duration_layout.addWidget(self.duration_unit) + + layout.addLayout(duration_layout) + + # Buttons + button_layout = QHBoxLayout() + self.ok_button = QPushButton("OK") + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + self.ok_button.clicked.connect(self.accept) + button_layout.addWidget(self.ok_button) + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + # Connect signals + self.last_played_combo.currentIndexChanged.connect(self.toggle_last_played_controls) + + self.toggle_last_played_controls() + + def toggle_last_played_controls(self): + disabled = self.last_played_combo.currentText() == "never" + self.last_played_spinbox.setDisabled(disabled) + self.last_played_unit.setDisabled(disabled) + + @dataclass class ItemlistItem: id: int @@ -724,6 +846,10 @@ class Window(QMainWindow): # Dynamically call the correct function items = getattr(self, f"get_{key}_items")() for item in items: + # Check for separator + if "separator" in item and item["separator"] == "separator": + submenu.addSeparator() + continue action = QAction(item["text"], self) # Extract handler and arguments @@ -751,10 +877,14 @@ class Window(QMainWindow): with db.Session() as session: submenu_items: list[dict[str, str | tuple[Session, int]]] = [ - {"text": "Show all", - "handler": "create_playlist_from_template", - "args": (session, 0) - } + { + "text": "Show all", + "handler": "create_playlist_from_template", + "args": (session, 0), + }, + { + "separator": "separator", + } ] templates = Playlists.get_favourite_templates(session) for template in templates: @@ -835,7 +965,7 @@ class Window(QMainWindow): else: template_id = selected_template_id - playlist_name = self.solicit_playlist_name(session) + playlist_name = self.solicit_name(session) if not playlist_name: return @@ -857,7 +987,7 @@ class Window(QMainWindow): f"Delete playlist '{playlist.name}': " "Are you sure?", ): if self.close_playlist_tab(): - playlist.delete(session) + session.delete(playlist) session.commit() else: log.error("Failed to retrieve playlist") @@ -900,7 +1030,7 @@ class Window(QMainWindow): session.commit() helpers.show_OK("Template", "Template saved", self) - def solicit_playlist_name( + def solicit_name( self, session: Session, default: str = "", prompt: str = "Playlist name:" ) -> Optional[str]: """Get name of new playlist from user""" @@ -948,6 +1078,216 @@ class Window(QMainWindow): return dlg.selected_id + # # # # # # # # # # Manage templates and queries # # # # # # # # # # + + def manage_queries(self) -> None: + """ + Delete / edit queries + """ + + # Define callbacks to handle management options + def delete(query_id: int) -> None: + """delete query""" + + query = session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.delete({query_id=}) can't load query" + ) + if helpers.ask_yes_no( + "Delete query", + f"Delete query '{query.name}': " "Are you sure?", + ): + log.info(f"manage_queries: delete {query=}") + session.delete(query) + session.commit() + + def edit(query_id: int) -> None: + """Edit query""" + + query = session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.edit({query_id=}) can't load query" + ) + import pdb; pdb.set_trace() + dlg = FilterDialog(query.name, query.filter) + dlg.show() + + def favourite(query_id: int, favourite: bool) -> None: + """Mark query as (not) favourite""" + + query = session.get(Queries, query_id) + query.favourite = favourite + session.commit() + + def new_item() -> None: + """Create new query""" + + # TODO: create query + print("create query") + + def rename(query_id: int) -> Optional[str]: + """rename query""" + + query = session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.delete({query_id=}) can't load query" + ) + new_name = self.solicit_name(session, query.name, prompt="New query name") + if new_name: + query.rename(session, new_name) + idx = self.tabBar.currentIndex() + self.tabBar.setTabText(idx, new_name) + session.commit() + return new_name + + return None + + # Call listitem management dialog to manage queries + callbacks = ItemlistManagerCallbacks( + delete=delete, + edit=edit, + favourite=favourite, + new_item=new_item, + rename=rename, + ) + + # Build a list of queries + query_list: list[ItemlistItem] = [] + + with db.Session() as session: + for query in Queries.get_all_queries(session): + query_list.append( + ItemlistItem( + name=query.name, id=query.id, favourite=query.favourite + ) + ) + # We need to retain a reference to the dialog box to stop it + # going out of scope and being garbage-collected. + self.dlg = ItemlistManager(query_list, callbacks) + self.dlg.show() + + def manage_templates(self) -> None: + """ + Delete / edit templates + """ + + # Define callbacks to handle management options + def delete(template_id: int) -> None: + """delete template""" + + template = session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.delete({template_id=}) can't load template" + ) + if helpers.ask_yes_no( + "Delete template", + f"Delete template '{template.name}': " "Are you sure?", + ): + # If template is currently open, re-check + open_idx = self.get_tab_index_for_playlist(template_id) + if open_idx: + if not helpers.ask_yes_no( + "Delete open template", + f"Template '{template.name}' is currently open. Really delete?", + ): + return + else: + self.playlist_section.tabPlaylist.removeTab(open_idx) + + log.info(f"manage_templates: delete {template=}") + session.delete(template) + session.commit() + + def edit(template_id: int) -> None: + """Edit template""" + + template = session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.edit({template_id=}) can't load template" + ) + # Simply load the template as a playlist. Any changes + # made will persist + self._open_playlist(template, is_template=True) + + def favourite(template_id: int, favourite: bool) -> None: + """Mark template as (not) favourite""" + + template = session.get(Playlists, template_id) + template.favourite = favourite + session.commit() + + def new_item() -> None: + """Create new template""" + + # Get base template + template_id = self.solicit_template_to_use( + session, template_prompt="New template based upon:" + ) + if template_id is None: + return + + # Get new template name + name = self.solicit_name( + session, default="", prompt="New template name:" + ) + if not name: + return + + # Create playlist for template and mark is as a template + template = self._create_playlist(session, name, template_id) + template.is_template = True + session.commit() + + # Open it for editing + self._open_playlist(template, is_template=True) + + def rename(template_id: int) -> Optional[str]: + """rename template""" + + template = session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.delete({template_id=}) can't load template" + ) + new_name = self.solicit_name(session, template.name) + if new_name: + template.rename(session, new_name) + idx = self.tabBar.currentIndex() + self.tabBar.setTabText(idx, new_name) + session.commit() + return new_name + + return None + + # Call listitem management dialog to manage templates + callbacks = ItemlistManagerCallbacks( + delete=delete, + edit=edit, + favourite=favourite, + new_item=new_item, + rename=rename, + ) + + # Build a list of templates + template_list: list[ItemlistItem] = [] + + with db.Session() as session: + for template in Playlists.get_all_templates(session): + template_list.append( + ItemlistItem( + name=template.name, id=template.id, favourite=template.favourite + ) + ) + # We need to retain a reference to the dialog box to stop it + # going out of scope and being garbage-collected. + self.dlg = ItemlistManager(template_list, callbacks) + self.dlg.show() + # # # # # # # # # # Miscellaneous functions # # # # # # # # # # def select_duplicate_rows(self) -> None: @@ -1341,125 +1681,6 @@ class Window(QMainWindow): self.signals.search_wikipedia_signal.emit(track_info.title) - def manage_templates(self) -> None: - """ - Delete / edit templates - """ - - # Define callbacks to handle management options - def delete(template_id: int) -> None: - """delete template""" - - template = session.get(Playlists, template_id) - if not template: - raise ApplicationError( - f"manage_templeate.delete({template_id=}) can't load template" - ) - if helpers.ask_yes_no( - "Delete template", - f"Delete template '{template.name}': " "Are you sure?", - ): - # If template is currently open, re-check - open_idx = self.get_tab_index_for_playlist(template_id) - if open_idx: - if not helpers.ask_yes_no( - "Delete open template", - f"Template '{template.name}' is currently open. Really delete?", - ): - return - else: - self.playlist_section.tabPlaylist.removeTab(open_idx) - - log.info(f"manage_templates: delete {template=}") - template.delete(session) - session.commit() - - def edit(template_id: int) -> None: - """Edit template""" - - template = session.get(Playlists, template_id) - if not template: - raise ApplicationError( - f"manage_templeate.edit({template_id=}) can't load template" - ) - # Simply load the template as a playlist. Any changes - # made will persist - self._open_playlist(template, is_template=True) - - def favourite(template_id: int, favourite: bool) -> None: - """Mark template as (not) favourite""" - - template = session.get(Playlists, template_id) - template.favourite = favourite - session.commit() - - def new_item() -> None: - """Create new template""" - - # Get base template - template_id = self.solicit_template_to_use( - session, template_prompt="New template based upon:" - ) - if template_id is None: - return - - # Get new template name - name = self.solicit_playlist_name( - session, default="", prompt="New template name:" - ) - if not name: - return - - # Create playlist for template and mark is as a template - template = self._create_playlist(session, name, template_id) - template.is_template = True - session.commit() - - # Open it for editing - self._open_playlist(template, is_template=True) - - def rename(template_id: int) -> Optional[str]: - """rename template""" - - template = session.get(Playlists, template_id) - if not template: - raise ApplicationError( - f"manage_templeate.delete({template_id=}) can't load template" - ) - new_name = self.solicit_playlist_name(session, template.name) - if new_name: - template.rename(session, new_name) - idx = self.tabBar.currentIndex() - self.tabBar.setTabText(idx, new_name) - session.commit() - return new_name - - return None - - # Call listitem management dialog to manage templates - callbacks = ItemlistManagerCallbacks( - delete=delete, - edit=edit, - favourite=favourite, - new_item=new_item, - rename=rename, - ) - - # Build a list of templates - template_list: list[ItemlistItem] = [] - - with db.Session() as session: - for template in Playlists.get_all_templates(session): - template_list.append( - ItemlistItem( - name=template.name, id=template.id, favourite=template.favourite - ) - ) - # We need to retain a reference to the dialog box to stop it - # going out of scope and being garbage-collected. - self.dlg = ItemlistManager(template_list, callbacks) - self.dlg.show() - def mark_rows_for_moving(self) -> None: """ Cut rows ready for pasting. @@ -1778,7 +1999,7 @@ class Window(QMainWindow): playlist_id = self.current.playlist_id playlist = session.get(Playlists, playlist_id) if playlist: - new_name = self.solicit_playlist_name(session, playlist.name) + new_name = self.solicit_name(session, playlist.name) if new_name: playlist.rename(session, new_name) idx = self.tabBar.currentIndex() @@ -2199,7 +2420,12 @@ class Window(QMainWindow): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_CURRENT) ) - elif self.playlist_section.tabPlaylist.widget(idx).model().sourceModel().is_template: + elif ( + self.playlist_section.tabPlaylist.widget(idx) + .model() + .sourceModel() + .is_template + ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE) )