diff --git a/app/config.py b/app/config.py index c20bdca..41f50b8 100644 --- a/app/config.py +++ b/app/config.py @@ -49,6 +49,18 @@ class Config(object): FADEOUT_DB = -10 FADEOUT_SECONDS = 5 FADEOUT_STEPS_PER_SECOND = 5 + FILTER_DURATION_LONGER = "longer than" + FILTER_DURATION_MINUTES = "minutes" + FILTER_DURATION_SECONDS = "seconds" + FILTER_DURATION_SHORTER = "shorter than" + FILTER_PATH_CONTAINS = "contains" + FILTER_PATH_EXCLUDING = "excluding" + FILTER_PLAYED_BEFORE = "before" + FILTER_PLAYED_DAYS = "days" + FILTER_PLAYED_MONTHS = "months" + FILTER_PLAYED_NEVER = "never" + FILTER_PLAYED_WEEKS = "weeks" + FILTER_PLAYED_YEARS = "years" FUZZYMATCH_MINIMUM_LIST = 60.0 FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0 FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0 diff --git a/app/models.py b/app/models.py index 59c71b4..80a1b98 100644 --- a/app/models.py +++ b/app/models.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from sqlalchemy.engine.row import RowMapping # App imports -from classes import ApplicationError +from classes import ApplicationError, Filter from config import Config from dbmanager import DatabaseManager import dbtables @@ -610,11 +610,21 @@ class Queries(dbtables.QueriesTable): session.commit() @classmethod - def get_all_queries(cls, session: Session) -> Sequence["Queries"]: + def get_all(cls, session: Session) -> Sequence["Queries"]: """Returns a list of all queries ordered by name""" return session.scalars(select(cls).order_by(cls.name)).all() + @classmethod + def get_favourites(cls, session: Session) -> Sequence["Queries"]: + """Returns a list of favourite queries ordered by name""" + + return session.scalars( + select(cls) + .where(cls.favourite.is_(True)) + .order_by(cls.name) + ).all() + class Settings(dbtables.SettingsTable): def __init__(self, session: Session, name: str) -> None: @@ -700,6 +710,40 @@ class Tracks(dbtables.TracksTable): .all() ) + @classmethod + def get_filtered_tracks(cls, session: Session, filter: Filter) -> Sequence["Tracks"]: + """ + Return tracks matching filter + """ + + query = select(cls) + if filter.path: + if filter.path_type == "contains": + query = query.where(cls.path.ilike(f"%{filter.path}%")) + elif filter.path_type == "excluding": + query = query.where(cls.path.notilike(f"%{filter.path}%")) + else: + raise ApplicationError(f"Can't process filter path ({filter=})") + # TODO + # if last_played_number: + # need group_by track_id and having max/min lastplayed gt/lt, etc + seconds_duration = filter.duration_number + if filter.duration_unit == Config.FILTER_DURATION_MINUTES: + seconds_duration *= 60 + elif filter.duration_unit != Config.FILTER_DURATION_SECONDS: + raise ApplicationError(f"Can't process filter duration ({filter=})") + if filter.duration_type == Config.FILTER_DURATION_LONGER: + query = query.where(cls.duration >= seconds_duration) + elif filter.duration_unit == Config.FILTER_DURATION_SHORTER: + query = query.where(cls.duration <= seconds_duration) + else: + raise ApplicationError(f"Can't process filter duration type ({filter=})") + + records = session.scalars( + query).unique().all() + + return records + @classmethod def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: """ diff --git a/app/musicmuster.py b/app/musicmuster.py index bde7fbe..fb46c5b 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -20,6 +20,7 @@ from PyQt6.QtCore import ( Qt, QTime, QTimer, + QVariant, ) from PyQt6.QtGui import ( QAction, @@ -32,6 +33,7 @@ from PyQt6.QtGui import ( QShortcut, ) from PyQt6.QtWidgets import ( + QAbstractItemView, QApplication, QCheckBox, QComboBox, @@ -46,7 +48,9 @@ from PyQt6.QtWidgets import ( QMenu, QMessageBox, QPushButton, + QSizePolicy, QSpinBox, + QTableView, QTableWidget, QTableWidgetItem, QVBoxLayout, @@ -74,6 +78,7 @@ from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tr from music_manager import RowAndTrack, track_sequence from playlistmodel import PlaylistModel, PlaylistProxyModel from playlists import PlaylistTab +from querylistmodel import QuerylistModel from ui import icons_rc # noqa F401 from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore @@ -190,7 +195,9 @@ class FilterDialog(QDialog): path_layout = QHBoxLayout() path_label = QLabel("Path") self.path_combo = QComboBox() - self.path_combo.addItems(["contains", "excluding"]) + self.path_combo.addItems( + [Config.FILTER_PATH_CONTAINS, Config.FILTER_PATH_EXCLUDING] + ) for idx in range(self.path_combo.count()): if self.path_combo.itemText(idx) == filter.path_type: self.path_combo.setCurrentIndex(idx) @@ -207,7 +214,9 @@ class FilterDialog(QDialog): last_played_layout = QHBoxLayout() last_played_label = QLabel("Last played") self.last_played_combo = QComboBox() - self.last_played_combo.addItems(["before", "never"]) + self.last_played_combo.addItems( + [Config.FILTER_PLAYED_BEFORE, Config.FILTER_PLAYED_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) @@ -219,7 +228,14 @@ class FilterDialog(QDialog): 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"]) + self.last_played_unit.addItems( + [ + Config.FILTER_PLAYED_YEARS, + Config.FILTER_PLAYED_MONTHS, + Config.FILTER_PLAYED_WEEKS, + Config.FILTER_PLAYED_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) @@ -239,7 +255,9 @@ class FilterDialog(QDialog): duration_layout = QHBoxLayout() duration_label = QLabel("Duration") self.duration_combo = QComboBox() - self.duration_combo.addItems(["longer than", "shorter than"]) + self.duration_combo.addItems( + [Config.FILTER_DURATION_LONGER, Config.FILTER_DURATION_SHORTER] + ) for idx in range(self.duration_combo.count()): if self.duration_combo.itemText(idx) == filter.duration_type: self.duration_combo.setCurrentIndex(idx) @@ -251,8 +269,10 @@ class FilterDialog(QDialog): self.duration_spinbox.setValue(filter.duration_number) self.duration_unit = QComboBox() - self.duration_unit.addItems(["minutes", "seconds"]) - self.duration_unit.setCurrentText("minutes") + self.duration_unit.addItems( + [Config.FILTER_DURATION_MINUTES, Config.FILTER_DURATION_SECONDS] + ) + self.duration_unit.setCurrentText(Config.FILTER_DURATION_MINUTES) for idx in range(self.duration_unit.count()): if self.duration_unit.itemText(idx) == filter.duration_unit: self.duration_unit.setCurrentIndex(idx) @@ -447,7 +467,7 @@ class ManageQueries(ItemlistManager): # Build a list of queries query_list: list[ItemlistItem] = [] - for query in Queries.get_all_queries(self.session): + for query in Queries.get_all(self.session): query_list.append( ItemlistItem(name=query.name, id=query.id, favourite=query.favourite) ) @@ -478,6 +498,7 @@ class ManageQueries(ItemlistManager): dlg = FilterDialog(query.name, query.filter) if dlg.exec(): query.filter = dlg.filter + query.name = dlg.name_text.text() self.session.commit() def edit_item(self, query_id: int) -> None: @@ -771,6 +792,230 @@ class PreviewManager: self.start_time = None +class QueryDialog(QDialog): + """Dialog box to handle selecting track from a query""" + + def __init__(self, session: Session, default: int = 0) -> None: + super().__init__() + self.session = session + self.default = default + + # Build a list of (query-name, playlist-id) tuples + self.selected_tracks: list[int] = [] + + self.query_list: list[tuple[str, int]] = [] + self.query_list.append((Config.NO_QUERY_NAME, 0)) + for query in Queries.get_all(self.session): + self.query_list.append((query.name, query.id)) + + self.setWindowTitle("Query Selector") + + # Create label + query_label = QLabel("Query:") + + # Top layout (Query label, combo box, and info label) + top_layout = QHBoxLayout() + + # Query label + query_label = QLabel("Query:") + top_layout.addWidget(query_label) + + # Combo Box with fixed width + self.combo_box = QComboBox() + # self.combo_box.setFixedWidth(150) # Adjust as necessary for 20 characters + for text, id_ in self.query_list: + self.combo_box.addItem(text, id_) + top_layout.addWidget(self.combo_box) + + # Table (middle part) + self.table_view = QTableView() + self.table_view.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.table_view.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) + self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table_view.setAlternatingRowColors(True) + self.table_view.setVerticalScrollMode( + QAbstractItemView.ScrollMode.ScrollPerPixel + ) + self.table_view.clicked.connect(self.handle_row_click) + + # Bottom layout (buttons) + bottom_layout = QHBoxLayout() + bottom_layout.addStretch() # Push buttons to the right + + self.add_tracks_button = QPushButton("Add tracks") + self.add_tracks_button.setEnabled(False) # Disabled by default + self.add_tracks_button.clicked.connect(self.add_tracks_clicked) + bottom_layout.addWidget(self.add_tracks_button) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.cancel_clicked) + bottom_layout.addWidget(self.cancel_button) + + # Main layout + main_layout = QVBoxLayout() + main_layout.addLayout(top_layout) + main_layout.addWidget(self.table_view) + main_layout.addLayout(bottom_layout) + + self.combo_box.currentIndexChanged.connect(self.query_changed) + if self.default: + default_idx = self.combo_box.findData(QVariant(self.default)) + self.combo_box.setCurrentIndex(default_idx) + self.path_text = QLineEdit() + self.setLayout(main_layout) + + # Stretch last column *after* setting column widths which is + # *much* faster + h_header = self.table_view.horizontalHeader() + if h_header: + h_header.sectionResized.connect(self._column_resize) + h_header.setStretchLastSection(True) + # Resize on vertical header click + v_header = self.table_view.verticalHeader() + if v_header: + v_header.setMinimumSectionSize(5) + v_header.sectionHandleDoubleClicked.disconnect() + v_header.sectionHandleDoubleClicked.connect( + self.table_view.resizeRowToContents + ) + + self.set_window_size() + self.resizeRowsToContents() + + def add_tracks_clicked(self): + self.selected_tracks = self.table_view.model().get_selected_track_ids() + self.accept() + + def cancel_clicked(self): + self.selected_tracks = [] + self.reject() + + def closeEvent(self, event: QCloseEvent | None) -> None: + """ + Record size and columns + """ + + self.save_sizes() + super().closeEvent(event) + + def accept(self) -> None: + self.save_sizes() + super().accept() + + def reject(self) -> None: + self.save_sizes() + super().reject() + + def save_sizes(self) -> None: + """ + Save window size + """ + + # Save dialog box attributes + attributes_to_save = dict( + querylist_height=self.height(), + querylist_width=self.width(), + querylist_x=self.x(), + querylist_y=self.y(), + ) + for name, value in attributes_to_save.items(): + record = Settings.get_setting(self.session, name) + record.f_int = value + + header = self.table_view.horizontalHeader() + if header is None: + return + column_count = header.count() + if column_count < 2: + return + for column_number in range(column_count - 1): + attr_name = f"querylist_col_{column_number}_width" + record = Settings.get_setting(self.session, attr_name) + record.f_int = self.table_view.columnWidth(column_number) + + self.session.commit() + + def _column_resize(self, column_number: int, _old: int, _new: int) -> None: + """ + Called when column width changes. + """ + + header = self.table_view.horizontalHeader() + if not header: + return + + # Resize rows if necessary + self.resizeRowsToContents() + + def resizeRowsToContents(self): + header = self.table_view.verticalHeader() + model = self.table_view.model() + if model: + for row in range(model.rowCount()): + hint = self.table_view.sizeHintForRow(row) + header.resizeSection(row, hint) + + def query_changed(self, idx: int) -> None: + """ + Called when user selects query + """ + + # Get query id + query_id = self.combo_box.currentData() + query = self.session.get(Queries, query_id) + if not query: + return + + # Create model + base_model = QuerylistModel(self.session, query.filter) + + # Create table + self.table_view.setModel(base_model) + self.set_column_sizes() + + def handle_row_click(self, index): + self.table_view.model().toggle_row_selection(index.row()) + self.table_view.clearSelection() + + # Enable 'Add tracks' button only when a row is selected + selected = self.table_view.model().get_selected_track_ids() + self.add_tracks_button.setEnabled(selected != []) + + def set_window_size(self) -> None: + """Set window sizes""" + + x = Settings.get_setting(self.session, "querylist_x").f_int or 100 + y = Settings.get_setting(self.session, "querylist_y").f_int or 100 + width = Settings.get_setting(self.session, "querylist_width").f_int or 100 + height = Settings.get_setting(self.session, "querylist_height").f_int or 100 + self.setGeometry(x, y, width, height) + + def set_column_sizes(self) -> None: + """Set column sizes""" + + header = self.table_view.horizontalHeader() + if header is None: + return + column_count = header.count() + if column_count < 2: + return + + # Last column is set to stretch so ignore it here + for column_number in range(column_count - 1): + attr_name = f"querylist_col_{column_number}_width" + record = Settings.get_setting(self.session, attr_name) + if record.f_int is not None: + self.table_view.setColumnWidth(column_number, record.f_int) + else: + self.table_view.setColumnWidth( + column_number, Config.DEFAULT_COLUMN_WIDTH + ) + + class SelectPlaylistDialog(QDialog): def __init__(self, parent=None, playlists=None, session=None): super().__init__() @@ -1146,12 +1391,52 @@ class Window(QMainWindow): return submenu_items - def get_query_dynamic_submenu_items(self): - """Returns dynamically generated menu items for Submenu 2.""" - return [ - {"text": "Action Xargs", "handler": "kae", "args": (21,)}, - {"text": "Action Y", "handler": "action_y_handler"}, - ] + def get_query_dynamic_submenu_items( + self, + ) -> list[dict[str, str | tuple[Session, int] | bool]]: + """ + Return dynamically generated menu items, in this case + templates marked as favourite from which to generate a + new playlist. + + The handler is to call show_query with a session + and query_id. + """ + + with db.Session() as session: + submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ + { + "text": "Show all", + "handler": "show_query", + "args": (session, 0), + }, + { + "separator": True, + }, + ] + queries = Queries.get_favourites(session) + for query in queries: + submenu_items.append( + { + "text": query.name, + "handler": "show_query", + "args": ( + session, + query.id, + ), + } + ) + + return submenu_items + + def show_query(self, session: Session, query_id: int) -> None: + """ + Show query dialog with query_id selected + """ + + # Keep a reference else it will be gc'd + self.query_dialog = QueryDialog(session, query_id) + self.query_dialog.exec() # # # # # # # # # # Playlist management functions # # # # # # # # # # diff --git a/app/querylistmodel.py b/app/querylistmodel.py index ef043a8..a56f3d3 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -38,7 +38,7 @@ from helpers import ( show_warning, ) from log import log -from models import db, Playdates +from models import db, Playdates, Tracks from music_manager import RowAndTrack @@ -228,20 +228,20 @@ class QuerylistModel(QAbstractTableModel): row = 0 try: - results = Tracks.get_filtered(self.session, self.filter) + results = Tracks.get_filtered_tracks(self.session, self.filter) for result in results: if hasattr(result, "lastplayed"): lastplayed = result["lastplayed"] else: lastplayed = None queryrow = QueryRow( - artist=result["artist"], - bitrate=result["bitrate"], - duration=result["duration"], + artist=result.artist, + bitrate=result.bitrate or 0, + duration=result.duration, lastplayed=lastplayed, - path=result["path"], - title=result["title"], - track_id=result["id"], + path=result.path, + title=result.title, + track_id=result.id, ) self.querylist_rows[row] = queryrow