diff --git a/app/config.py b/app/config.py index 08c3881..23dacdf 100644 --- a/app/config.py +++ b/app/config.py @@ -87,6 +87,7 @@ class Config(object): MAX_MISSING_FILES_TO_REPORT = 10 MILLISECOND_SIGFIGS = 0 MINIMUM_ROW_HEIGHT = 30 + NO_QUERY_NAME = "Select query" NO_TEMPLATE_NAME = "None" NOTE_TIME_FORMAT = "%H:%M" OBS_HOST = "localhost" diff --git a/app/dbtables.py b/app/dbtables.py index 260d15c..0507e62 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -80,9 +80,6 @@ class PlaylistsTable(Model): cascade="all, delete-orphan", order_by="PlaylistRowsTable.row_number", ) - query: Mapped["QueriesTable"] = relationship( - back_populates="playlist", cascade="all, delete-orphan" - ) def __repr__(self) -> str: return ( @@ -104,7 +101,9 @@ class PlaylistRowsTable(Model): ) playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") - track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) + track_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("tracks.id", ondelete="CASCADE") + ) track: Mapped["TracksTable"] = relationship( "TracksTable", back_populates="playlistrows", @@ -125,16 +124,16 @@ class QueriesTable(Model): __tablename__ = "queries" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - query: Mapped[str] = mapped_column( + name: Mapped[str] = mapped_column(String(128), nullable=False) + sql: Mapped[str] = mapped_column( String(2048), index=False, default="", nullable=False ) - playlist_id: Mapped[int] = mapped_column( - ForeignKey("playlists.id", ondelete="CASCADE"), index=True + description: Mapped[str] = mapped_column( + String(512), index=False, default="", nullable=False ) - playlist: Mapped[PlaylistsTable] = relationship(back_populates="query") def __repr__(self) -> str: - return f"" + return f"" class SettingsTable(Model): diff --git a/app/models.py b/app/models.py index 63cc2f0..fc240aa 100644 --- a/app/models.py +++ b/app/models.py @@ -19,7 +19,7 @@ from sqlalchemy import ( ) from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.orm import joinedload from sqlalchemy.orm.session import Session # App imports @@ -178,179 +178,6 @@ class Playdates(dbtables.PlaydatesTable): ).all() -class Playlists(dbtables.PlaylistsTable): - def __init__(self, session: Session, name: str): - self.name = name - self.last_used = dt.datetime.now() - session.add(self) - session.commit() - - @staticmethod - def clear_tabs(session: Session, playlist_ids: list[int]) -> None: - """ - Make all tab records NULL - """ - - session.execute( - update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None) - ) - - def close(self, session: Session) -> None: - """Mark playlist as unloaded""" - - self.open = False - session.commit() - - @classmethod - def create_playlist_from_template( - cls, session: Session, template: "Playlists", playlist_name: str - ) -> Optional["Playlists"]: - """Create a new playlist from template""" - - # Sanity check - if not template.id: - return None - - playlist = cls(session, playlist_name) - - # Sanity / mypy checks - if not playlist or not playlist.id: - return None - - PlaylistRows.copy_playlist(session, template.id, playlist.id) - - return playlist - - 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""" - - return session.scalars( - select(cls) - .filter( - cls.is_template.is_(False), - ~cls.query.has() - ) - .order_by(cls.last_used.desc()) - ).all() - - @classmethod - def get_all_queries(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of all query lists ordered by name""" - - return session.scalars( - select(cls) - .where(cls.query.has()) - .order_by(cls.name) - ).all() - - @classmethod - def get_all_templates(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of all templates ordered by name""" - - return session.scalars( - select(cls).where(cls.is_template.is_(True)).order_by(cls.name) - ).all() - - @classmethod - def get_closed(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of all closed playlists ordered by last use""" - - return session.scalars( - select(cls) - .filter( - cls.open.is_(False), - cls.is_template.is_(False), - ~cls.query.has() - ) - .order_by(cls.last_used.desc()) - ).all() - - @classmethod - def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]: - """ - Return a list of loaded playlists ordered by tab. - """ - - return session.scalars( - select(cls) - .where( - cls.open.is_(True), - ~cls.query.has() - ) - .order_by(cls.tab) - - ).all() - - def mark_open(self) -> None: - """Mark playlist as loaded and used now""" - - self.open = True - self.last_used = dt.datetime.now() - - @staticmethod - def name_is_available(session: Session, name: str) -> bool: - """ - Return True if no playlist of this name exists else false. - """ - - return ( - session.execute(select(Playlists).where(Playlists.name == name)).first() - is None - ) - - def rename(self, session: Session, new_name: str) -> None: - """ - Rename playlist - """ - - self.name = new_name - session.commit() - - @staticmethod - def save_as_template( - session: Session, playlist_id: int, template_name: str - ) -> None: - """Save passed playlist as new template""" - - template = Playlists(session, template_name) - if not template or not template.id: - return - - template.is_template = True - session.commit() - - PlaylistRows.copy_playlist(session, playlist_id, template.id) - - -class Queries(dbtables.QueriesTable): - def __init__(self, session: Session, playlist_id: int, query: str = "") -> None: - self.playlist_id = playlist_id - self.query = query - session.add(self) - session.commit() - - @staticmethod - def get_query(session: Session, playlist_id: int) -> str: - """ - Return query associated with playlist or null string if none - """ - - return session.execute( - select(Queries.query).where( - Queries.playlist_id == playlist_id - ) - ).scalar_one() - - class PlaylistRows(dbtables.PlaylistRowsTable): def __init__( self, @@ -635,6 +462,159 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session.connection().execute(stmt, sqla_map) +class Playlists(dbtables.PlaylistsTable): + def __init__(self, session: Session, name: str): + self.name = name + self.last_used = dt.datetime.now() + session.add(self) + session.commit() + + @staticmethod + def clear_tabs(session: Session, playlist_ids: list[int]) -> None: + """ + Make all tab records NULL + """ + + session.execute( + update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None) + ) + + def close(self, session: Session) -> None: + """Mark playlist as unloaded""" + + self.open = False + session.commit() + + @classmethod + def create_playlist_from_template( + cls, session: Session, template: "Playlists", playlist_name: str + ) -> Optional["Playlists"]: + """Create a new playlist from template""" + + # Sanity check + if not template.id: + return None + + playlist = cls(session, playlist_name) + + # Sanity / mypy checks + if not playlist or not playlist.id: + return None + + PlaylistRows.copy_playlist(session, template.id, playlist.id) + + return playlist + + 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""" + + return session.scalars( + select(cls) + .filter(cls.is_template.is_(False)) + .order_by(cls.last_used.desc()) + ).all() + + @classmethod + def get_all_templates(cls, session: Session) -> Sequence["Playlists"]: + """Returns a list of all templates ordered by name""" + + return session.scalars( + select(cls).where(cls.is_template.is_(True)).order_by(cls.name) + ).all() + + @classmethod + def get_closed(cls, session: Session) -> Sequence["Playlists"]: + """Returns a list of all closed playlists ordered by last use""" + + return session.scalars( + select(cls) + .filter(cls.open.is_(False), cls.is_template.is_(False)) + .order_by(cls.last_used.desc()) + ).all() + + @classmethod + def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]: + """ + Return a list of loaded playlists ordered by tab. + """ + + return session.scalars( + select(cls) + .where( + cls.open.is_(True), + ) + .order_by(cls.tab) + ).all() + + def mark_open(self) -> None: + """Mark playlist as loaded and used now""" + + self.open = True + self.last_used = dt.datetime.now() + + @staticmethod + def name_is_available(session: Session, name: str) -> bool: + """ + Return True if no playlist of this name exists else false. + """ + + return ( + session.execute(select(Playlists).where(Playlists.name == name)).first() + is None + ) + + def rename(self, session: Session, new_name: str) -> None: + """ + Rename playlist + """ + + self.name = new_name + session.commit() + + @staticmethod + def save_as_template( + session: Session, playlist_id: int, template_name: str + ) -> None: + """Save passed playlist as new template""" + + template = Playlists(session, template_name) + if not template or not template.id: + return + + template.is_template = True + session.commit() + + PlaylistRows.copy_playlist(session, playlist_id, template.id) + + +class Queries(dbtables.QueriesTable): + def __init__( + self, session: Session, name: str, query: str, description: str = "" + ) -> None: + self.query = query + self.name = name + self.description = description + session.add(self) + session.commit() + + @classmethod + def get_all(cls, session: Session) -> Sequence[Queries]: + """ + Return a list of all queries + """ + + return session.scalars(select(cls)).unique().all() + + class Settings(dbtables.SettingsTable): def __init__(self, session: Session, name: str): self.name = name diff --git a/app/musicmuster.py b/app/musicmuster.py index a8a6af8..8e5acac 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -28,6 +28,7 @@ from PyQt6.QtGui import ( QShortcut, ) from PyQt6.QtWidgets import ( + QAbstractItemView, QApplication, QComboBox, QDialog, @@ -40,6 +41,8 @@ from PyQt6.QtWidgets import ( QMainWindow, QMessageBox, QPushButton, + QSizePolicy, + QTableView, QVBoxLayout, QWidget, ) @@ -60,13 +63,19 @@ 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 querylistmodel import QuerylistModel from playlists import PlaylistTab -from querylists import QuerylistTab -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 from ui.main_window_header_ui import Ui_HeaderSection # type: ignore @@ -271,6 +280,234 @@ class PreviewManager: self.start_time = None +class QueryDialog(QDialog): + """Dialog box to handle selecting track from a SQL query""" + + def __init__(self, session: Session) -> None: + super().__init__() + self.session = session + + # 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) + + # Information label (two-row height, wrapping) + self.description_label = QLabel("") + self.description_label.setWordWrap(True) + self.description_label.setMinimumHeight(40) # Approximate height for two rows + self.description_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + top_layout.addWidget(self.description_label) + + # 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) + 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 model.rowCount(): + hint = self.sizeHintForRow(row) + header.resizeSection(row, hint) + + def query_changed(self, idx: int) -> None: + """ + Called when user selects query + """ + + # Get query + query = self.session.get(Queries, idx) + if not query: + return + + # Create model + base_model = QuerylistModel(self.session, query.sql) + + # Create table + self.table_view.setModel(base_model) + self.set_column_sizes() + self.description_label.setText(query.description) + + 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__() @@ -789,25 +1026,6 @@ class Window(QMainWindow): return idx - def create_querylist_tab(self, querylist: Playlists) -> int: - """ - Take the passed querylist, create a querylist tab and - add tab to display. Return index number of tab. - """ - - log.debug(f"create_querylist_tab({querylist=})") - - # Create model and proxy model - base_model = QuerylistModel(querylist.id) - - # Create tab - querylist_tab = QuerylistTab(musicmuster=self, model=base_model) - idx = self.playlist_section.tabPlaylist.addTab(querylist_tab, querylist.name) - - log.debug(f"create_querylist_tab() returned: {idx=}") - - return idx - def current_row_or_end(self) -> int: """ If a row or rows are selected, return the row number of the first @@ -1261,14 +1479,13 @@ class Window(QMainWindow): """Open existing querylist""" with db.Session() as session: - querylists = Playlists.get_all_queries(session) - dlg = SelectPlaylistDialog(self, playlists=querylists, session=session) - dlg.exec() - querylist = dlg.playlist - if querylist: - idx = self.create_querylist_tab(querylist) - - self.playlist_section.tabPlaylist.setCurrentIndex(idx) + dlg = QueryDialog(session) + if dlg.exec(): + new_row_number = self.current_row_or_end() + for track_id in dlg.selected_tracks: + self.current.base_model.insert_row(new_row_number, track_id) + else: + return # User cancelled def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" diff --git a/app/playlists.py b/app/playlists.py index aedeb7d..192f0ae 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -109,7 +109,7 @@ class PlaylistDelegate(QStyledItemDelegate): if self.current_editor: editor = self.current_editor else: - if index.column() == QueryCol.INTRO.value: + if index.column() == Col.INTRO.value: editor = QDoubleSpinBox(parent) editor.setDecimals(1) editor.setSingleStep(0.1) @@ -245,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate): self.original_model_data = self.base_model.data( edit_index, Qt.ItemDataRole.EditRole ) - if index.column() == QueryCol.INTRO.value: + if index.column() == Col.INTRO.value: if self.original_model_data.value(): editor.setValue(self.original_model_data.value() / 1000) else: diff --git a/app/querylistmodel.py b/app/querylistmodel.py index d6b3d3b..83b19eb 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -4,25 +4,23 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import cast +from typing import Optional import datetime as dt # PyQt imports from PyQt6.QtCore import ( QAbstractTableModel, QModelIndex, - QRegularExpression, - QSortFilterProxyModel, Qt, QVariant, ) from PyQt6.QtGui import ( - QBrush, QColor, QFont, ) # Third party imports +from sqlalchemy.orm.session import Session # import snoop # type: ignore @@ -46,7 +44,7 @@ class QueryRow: artist: str bitrate: int duration: int - lastplayed: dt.datetime + lastplayed: Optional[dt.datetime] path: str title: str track_id: int @@ -62,26 +60,24 @@ class QuerylistModel(QAbstractTableModel): """ - def __init__( - self, - playlist_id: int, - ) -> None: - log.debug("QuerylistModel.__init__()") + def __init__(self, session: Session, sql: str) -> None: + """ + Load query + """ + + log.debug(f"QuerylistModel.__init__({sql=})") - self.playlist_id = playlist_id super().__init__() + self.session = session + self.sql = sql self.querylist_rows: dict[int, QueryRow] = {} self._selected_rows: set[int] = set() - with db.Session() as session: - # Populate self.playlist_rows - self.load_data(session) + self.load_data() def __repr__(self) -> str: - return ( - f"" - ) + return f"" def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: """Return background setting""" @@ -219,28 +215,9 @@ class QuerylistModel(QAbstractTableModel): return QVariant() - def load_data( - self, - session: db.session, - sql: str = """ - SELECT - tracks.*,playdates.lastplayed - FROM - tracks,playdates - WHERE - playdates.track_id=tracks.id - AND tracks.path LIKE '%/Singles/p%' - GROUP BY - tracks.id - HAVING - MAX(playdates.lastplayed) < DATE_SUB(NOW(), INTERVAL 1 YEAR) - ORDER BY tracks.title - ; - """, - ) -> None: + def load_data(self) -> None: """ - Load data from user-defined query. Can probably hard-code the SELECT part - to ensure the required fields are returned. + Populate self.querylist_rows """ # TODO: Move the SQLAlchemy parts to models later, but for now as proof @@ -252,17 +229,22 @@ class QuerylistModel(QAbstractTableModel): self.querylist_rows = {} row = 0 - results = session.execute(text(sql)).mappings().all() + results = self.session.execute(text(self.sql)).mappings().all() 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"], - lastplayed=result["lastplayed"], + lastplayed=lastplayed, path=result["path"], title=result["title"], track_id=result["id"], ) + self.querylist_rows[row] = queryrow row += 1 diff --git a/app/querylists.py b/app/querylists.py deleted file mode 100644 index 3724611..0000000 --- a/app/querylists.py +++ /dev/null @@ -1,193 +0,0 @@ -# Standard library imports -from typing import cast, Optional, TYPE_CHECKING - -# PyQt imports -from PyQt6.QtCore import ( - QTimer, -) -from PyQt6.QtWidgets import ( - QAbstractItemView, - QTableView, -) - -# Third party imports -# import line_profiler - -# App imports -from audacity_controller import AudacityController -from classes import ApplicationError, MusicMusterSignals, PlaylistStyle -from config import Config -from helpers import ( - show_warning, -) -from log import log -from models import db, Settings -from querylistmodel import QuerylistModel - -if TYPE_CHECKING: - from musicmuster import Window - - -class QuerylistTab(QTableView): - """ - The querylist view - """ - - def __init__(self, musicmuster: "Window", model: QuerylistModel) -> None: - super().__init__() - - # Save passed settings - self.musicmuster = musicmuster - - self.playlist_id = model.playlist_id - - # Set up widget - self.setAlternatingRowColors(True) - self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) - - # 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) - - # Connect signals - self.signals = MusicMusterSignals() - self.signals.resize_rows_signal.connect(self.resize_rows) - - # Selection model - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - - # Enable item editing for checkboxes - self.clicked.connect(self.handle_row_click) - - # Set up for Audacity - try: - self.ac: Optional[AudacityController] = AudacityController() - except ApplicationError as e: - self.ac = None - show_warning(self.musicmuster, "Audacity error", str(e)) - - # Load model, set column widths - self.setModel(model) - self._set_column_widths() - - # Stretch last column *after* setting column widths which is - # *much* faster - h_header = self.horizontalHeader() - if h_header: - h_header.sectionResized.connect(self._column_resize) - h_header.setStretchLastSection(True) - # Resize on vertical header click - v_header = self.verticalHeader() - if v_header: - v_header.setMinimumSectionSize(5) - v_header.sectionHandleDoubleClicked.disconnect() - v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents) - - # Setting ResizeToContents causes screen flash on load - self.resize_rows() - - # ########## Overridden class functions ########## - - def resizeRowToContents(self, row): - super().resizeRowToContents(row) - self.verticalHeader().resizeSection(row, self.sizeHintForRow(row)) - - def resizeRowsToContents(self): - header = self.verticalHeader() - for row in range(self.model().rowCount()): - hint = self.sizeHintForRow(row) - header.resizeSection(row, hint) - - # ########## Custom functions ########## - def clear_selection(self) -> None: - """Unselect all tracks and reset drag mode""" - - self.clearSelection() - # We want to remove the focus from any widget otherwise keyboard - # activity may edit a cell. - fw = self.musicmuster.focusWidget() - if fw: - fw.clearFocus() - self.setDragEnabled(False) - - def _column_resize(self, column_number: int, _old: int, _new: int) -> None: - """ - Called when column width changes. Save new width to database. - """ - - log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}") - - header = self.horizontalHeader() - if not header: - return - - # Resize rows if necessary - self.resizeRowsToContents() - - with db.Session() as session: - attr_name = f"querylist_col_{column_number}_width" - record = Settings.get_setting(session, attr_name) - record.f_int = self.columnWidth(column_number) - session.commit() - - def handle_row_click(self, index): - self.model().toggle_row_selection(index.row()) - self.clearSelection() - - def model(self) -> QuerylistModel: - """ - Override return type to keep mypy happy in this module - """ - - return cast(QuerylistModel, super().model()) - - def resize_rows(self, playlist_id: Optional[int] = None) -> None: - """ - If playlist_id is us, resize rows - """ - - log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}") - - if playlist_id and playlist_id != self.playlist_id: - return - - # Suggestion from phind.com - def resize_row(row, count=1): - row_count = self.model().rowCount() - for todo in range(count): - if row < row_count: - self.resizeRowToContents(row) - row += 1 - if row < row_count: - QTimer.singleShot(0, lambda: resize_row(row, count)) - - # Start resizing from row 0, 10 rows at a time - QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE)) - - def _set_column_widths(self) -> None: - """Column widths from settings""" - - log.debug("_set_column_widths()") - - header = self.horizontalHeader() - if not header: - return - - # Last column is set to stretch so ignore it here - with db.Session() as session: - for column_number in range(header.count() - 1): - attr_name = f"querylist_col_{column_number}_width" - record = Settings.get_setting(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 tab_live(self) -> None: - """Noop for query tabs""" - - return diff --git a/app/ui/dlgQuery.ui b/app/ui/dlgQuery.ui new file mode 100644 index 0000000..f410813 --- /dev/null +++ b/app/ui/dlgQuery.ui @@ -0,0 +1,94 @@ + + + queryDialog + + + + 0 + 0 + 762 + 686 + + + + Query + + + + + 10 + 65 + 741 + 561 + + + + + + + 20 + 10 + 61 + 24 + + + + Query: + + + + + + 80 + 10 + 221 + 32 + + + + + + + 530 + 640 + 102 + 36 + + + + Add &tracks + + + + + + 330 + 10 + 401 + 46 + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + 650 + 640 + 102 + 36 + + + + Close + + + + + + diff --git a/app/ui/dlgQuery_ui.py b/app/ui/dlgQuery_ui.py new file mode 100644 index 0000000..6a29211 --- /dev/null +++ b/app/ui/dlgQuery_ui.py @@ -0,0 +1,45 @@ +# Form implementation generated from reading ui file 'app/ui/dlgQuery.ui' +# +# Created by: PyQt6 UI code generator 6.8.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_queryDialog(object): + def setupUi(self, queryDialog): + queryDialog.setObjectName("queryDialog") + queryDialog.resize(762, 686) + self.tableView = QtWidgets.QTableView(parent=queryDialog) + self.tableView.setGeometry(QtCore.QRect(10, 65, 741, 561)) + self.tableView.setObjectName("tableView") + self.label = QtWidgets.QLabel(parent=queryDialog) + self.label.setGeometry(QtCore.QRect(20, 10, 61, 24)) + self.label.setObjectName("label") + self.cboQuery = QtWidgets.QComboBox(parent=queryDialog) + self.cboQuery.setGeometry(QtCore.QRect(80, 10, 221, 32)) + self.cboQuery.setObjectName("cboQuery") + self.btnAddTracks = QtWidgets.QPushButton(parent=queryDialog) + self.btnAddTracks.setGeometry(QtCore.QRect(530, 640, 102, 36)) + self.btnAddTracks.setObjectName("btnAddTracks") + self.lblDescription = QtWidgets.QLabel(parent=queryDialog) + self.lblDescription.setGeometry(QtCore.QRect(330, 10, 401, 46)) + self.lblDescription.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop) + self.lblDescription.setObjectName("lblDescription") + self.pushButton = QtWidgets.QPushButton(parent=queryDialog) + self.pushButton.setGeometry(QtCore.QRect(650, 640, 102, 36)) + self.pushButton.setObjectName("pushButton") + + self.retranslateUi(queryDialog) + QtCore.QMetaObject.connectSlotsByName(queryDialog) + + def retranslateUi(self, queryDialog): + _translate = QtCore.QCoreApplication.translate + queryDialog.setWindowTitle(_translate("queryDialog", "Query")) + self.label.setText(_translate("queryDialog", "Query:")) + self.btnAddTracks.setText(_translate("queryDialog", "Add &tracks")) + self.lblDescription.setText(_translate("queryDialog", "TextLabel")) + self.pushButton.setText(_translate("queryDialog", "Close")) diff --git a/migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py b/migrations/versions/9c1254a8026d_add_queries_table.py similarity index 52% rename from migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py rename to migrations/versions/9c1254a8026d_add_queries_table.py index 7562809..b48f27a 100644 --- a/migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py +++ b/migrations/versions/9c1254a8026d_add_queries_table.py @@ -1,8 +1,8 @@ -"""Add data for query playlists +"""Add queries table -Revision ID: 014f2d4c88a5 -Revises: 33c04e3c12c8 -Create Date: 2024-12-30 14:23:36.924478 +Revision ID: 9c1254a8026d +Revises: c76e865ccb85 +Create Date: 2025-02-14 16:32:37.064567 """ from alembic import op @@ -10,8 +10,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '014f2d4c88a5' -down_revision = '33c04e3c12c8' +revision = '9c1254a8026d' +down_revision = 'c76e865ccb85' branch_labels = None depends_on = None @@ -31,22 +31,16 @@ def upgrade_() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table('queries', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('query', sa.String(length=2048), nullable=False), - sa.Column('playlist_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('sql', sa.String(length=2048), nullable=False), + sa.Column('description', sa.String(length=512), nullable=False), sa.PrimaryKeyConstraint('id') ) - with op.batch_alter_table('queries', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_queries_playlist_id'), ['playlist_id'], unique=False) - # ### end Alembic commands ### def downgrade_() -> None: # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('queries', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_queries_playlist_id')) - op.drop_table('queries') # ### end Alembic commands ###