From 955bea20378e3cdc8b4be594d70fabfb682fbe6f Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 11 Feb 2025 21:11:56 +0000 Subject: [PATCH] Query tabs WIP --- app/classes.py | 31 ++ app/config.py | 1 + app/dbmanager.py | 1 - app/dbtables.py | 9 +- app/models.py | 47 ++- app/musicmuster.py | 36 ++- app/playlistmodel.py | 2 +- app/playlists.py | 27 +- app/querylistmodel.py | 304 ++++++++++++++++++ app/querylists.py | 193 +++++++++++ app/ui/main_window.ui | 15 +- app/ui/main_window_ui.py | 13 +- migrations/env.py | 28 +- migrations/env.py.DEBUG | 28 ++ migrations/env.py.NODEBUG | 27 ++ ...4f2d4c88a5_add_data_for_query_playlists.py | 52 +++ ...65ccb85_index_for_notesolours_substring.py | 68 ++++ 17 files changed, 817 insertions(+), 65 deletions(-) create mode 100644 app/querylistmodel.py create mode 100644 app/querylists.py mode change 100644 => 120000 migrations/env.py create mode 100644 migrations/env.py.DEBUG create mode 100644 migrations/env.py.NODEBUG create mode 100644 migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py create mode 100644 migrations/versions/c76e865ccb85_index_for_notesolours_substring.py diff --git a/app/classes.py b/app/classes.py index c625165..d901d24 100644 --- a/app/classes.py +++ b/app/classes.py @@ -14,6 +14,11 @@ from PyQt6.QtCore import ( pyqtSignal, QObject, ) +from PyQt6.QtWidgets import ( + QProxyStyle, + QStyle, + QStyleOption, +) # App imports @@ -31,6 +36,14 @@ class Col(Enum): NOTE = auto() +class QueryCol(Enum): + TITLE = 0 + ARTIST = auto() + DURATION = auto() + LAST_PLAYED = auto() + BITRATE = auto() + + def singleton(cls): """ Make a class a Singleton class (see @@ -100,6 +113,24 @@ class MusicMusterSignals(QObject): super().__init__() +class PlaylistStyle(QProxyStyle): + def drawPrimitive(self, element, option, painter, widget=None): + """ + Draw a line across the entire row rather than just the column + we're hovering over. + """ + if ( + element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop + and not option.rect.isNull() + ): + option_new = QStyleOption(option) + option_new.rect.setLeft(0) + if widget: + option_new.rect.setRight(widget.width()) + option = option_new + super().drawPrimitive(element, option, painter, widget) + + class Tags(NamedTuple): artist: str = "" title: str = "" diff --git a/app/config.py b/app/config.py index 3b4fd65..08c3881 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,7 @@ class Config(object): COLOUR_NORMAL_TAB = "#000000" COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_ODD_PLAYLIST = "#f2f2f2" + COLOUR_QUERYLIST_SELECTED = "#d3ffd3" COLOUR_UNREADABLE = "#dc3545" COLOUR_WARNING_TIMER = "#ffc107" DBFS_SILENCE = -50 diff --git a/app/dbmanager.py b/app/dbmanager.py index 9f8c2ca..52ec902 100644 --- a/app/dbmanager.py +++ b/app/dbmanager.py @@ -18,7 +18,6 @@ class DatabaseManager: def __init__(self, database_url: str, **kwargs: dict) -> None: if DatabaseManager.__instance is None: self.db = Alchemical(database_url, **kwargs) - self.db.create_all() DatabaseManager.__instance = self else: raise Exception("Attempted to create a second DatabaseManager instance") diff --git a/app/dbtables.py b/app/dbtables.py index c12b8cc..260d15c 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -50,7 +50,8 @@ class PlaydatesTable(Model): lastplayed: Mapped[dt.datetime] = mapped_column(index=True) track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id")) track: Mapped["TracksTable"] = relationship( - "TracksTable", back_populates="playdates" + "TracksTable", + back_populates="playdates", ) def __repr__(self) -> str: @@ -103,7 +104,7 @@ class PlaylistRowsTable(Model): ) playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") - track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id")) + track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) track: Mapped["TracksTable"] = relationship( "TracksTable", back_populates="playlistrows", @@ -127,7 +128,9 @@ class QueriesTable(Model): query: Mapped[str] = mapped_column( String(2048), index=False, default="", nullable=False ) - playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"), index=True) + playlist_id: Mapped[int] = mapped_column( + ForeignKey("playlists.id", ondelete="CASCADE"), index=True + ) playlist: Mapped[PlaylistsTable] = relationship(back_populates="query") def __repr__(self) -> str: diff --git a/app/models.py b/app/models.py index 4168670..49c79a3 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 +from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.session import Session # App imports @@ -36,7 +36,6 @@ if DATABASE_URL is None: if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: raise ValueError("Unit tests running on non-Sqlite database") db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db -db.create_all() # Database classes @@ -236,10 +235,23 @@ class Playlists(dbtables.PlaylistsTable): return session.scalars( select(cls) - .filter(cls.is_template.is_(False)) + .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""" @@ -257,6 +269,7 @@ class Playlists(dbtables.PlaylistsTable): .filter( cls.open.is_(False), cls.is_template.is_(False), + ~cls.query.has() ) .order_by(cls.last_used.desc()) ).all() @@ -268,7 +281,13 @@ class Playlists(dbtables.PlaylistsTable): """ return session.scalars( - select(cls).where(cls.open.is_(True)).order_by(cls.tab) + select(cls) + .where( + cls.open.is_(True), + ~cls.query.has() + ) + .order_by(cls.tab) + ).all() def mark_open(self) -> None: @@ -312,6 +331,26 @@ class Playlists(dbtables.PlaylistsTable): 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, diff --git a/app/musicmuster.py b/app/musicmuster.py index 4b171d1..e2a6a60 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -63,7 +63,9 @@ from log import log from models import db, Playdates, PlaylistRows, Playlists, 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 @@ -768,7 +770,7 @@ class Window(QMainWindow): def create_playlist_tab(self, playlist: Playlists) -> int: """ - Take the passed proxy model, create a playlist tab and + Take the passed playlist, create a playlist tab and add tab to display. Return index number of tab. """ @@ -787,6 +789,25 @@ 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 @@ -1236,6 +1257,19 @@ class Window(QMainWindow): self.playlist_section.tabPlaylist.setCurrentIndex(idx) + def open_querylist(self) -> None: + """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) + def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 53e174f..a075a74 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -497,7 +497,7 @@ class PlaylistModel(QAbstractTableModel): """ if not index.isValid(): - return Qt.ItemFlag.ItemIsDropEnabled + return Qt.ItemFlag.NoItemFlags default = ( Qt.ItemFlag.ItemIsEnabled diff --git a/app/playlists.py b/app/playlists.py index 6b32213..aedeb7d 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -22,10 +22,7 @@ from PyQt6.QtWidgets import ( QFrame, QMenu, QMessageBox, - QProxyStyle, - QStyle, QStyledItemDelegate, - QStyleOption, QStyleOptionViewItem, QTableView, QTableWidgetItem, @@ -37,7 +34,7 @@ from PyQt6.QtWidgets import ( # App imports from audacity_controller import AudacityController -from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo +from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -112,7 +109,7 @@ class PlaylistDelegate(QStyledItemDelegate): if self.current_editor: editor = self.current_editor else: - if index.column() == Col.INTRO.value: + if index.column() == QueryCol.INTRO.value: editor = QDoubleSpinBox(parent) editor.setDecimals(1) editor.setSingleStep(0.1) @@ -248,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate): self.original_model_data = self.base_model.data( edit_index, Qt.ItemDataRole.EditRole ) - if index.column() == Col.INTRO.value: + if index.column() == QueryCol.INTRO.value: if self.original_model_data.value(): editor.setValue(self.original_model_data.value() / 1000) else: @@ -268,24 +265,6 @@ class PlaylistDelegate(QStyledItemDelegate): editor.setGeometry(option.rect) -class PlaylistStyle(QProxyStyle): - def drawPrimitive(self, element, option, painter, widget=None): - """ - Draw a line across the entire row rather than just the column - we're hovering over. - """ - if ( - element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop - and not option.rect.isNull() - ): - option_new = QStyleOption(option) - option_new.rect.setLeft(0) - if widget: - option_new.rect.setRight(widget.width()) - option = option_new - super().drawPrimitive(element, option, painter, widget) - - class PlaylistTab(QTableView): """ The playlist view diff --git a/app/querylistmodel.py b/app/querylistmodel.py new file mode 100644 index 0000000..d6b3d3b --- /dev/null +++ b/app/querylistmodel.py @@ -0,0 +1,304 @@ +# Standard library imports +# Allow forward reference to PlaylistModel +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast +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 + +# import snoop # type: ignore + +# App imports +from classes import ( + QueryCol, +) +from config import Config +from helpers import ( + file_is_unreadable, + get_relative_date, + ms_to_mmss, +) +from log import log +from models import db, Playdates +from music_manager import RowAndTrack + + +@dataclass +class QueryRow: + artist: str + bitrate: int + duration: int + lastplayed: dt.datetime + path: str + title: str + track_id: int + + +class QuerylistModel(QAbstractTableModel): + """ + The Querylist Model + + Used to support query lists. The underlying database is never + updated. We just present tracks that match a query and allow the user + to copy those to a playlist. + + """ + + def __init__( + self, + playlist_id: int, + ) -> None: + log.debug("QuerylistModel.__init__()") + + self.playlist_id = playlist_id + super().__init__() + + 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) + + def __repr__(self) -> str: + return ( + f"" + ) + + def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: + """Return background setting""" + + # Unreadable track file + if file_is_unreadable(qrow.path): + return QVariant(QColor(Config.COLOUR_UNREADABLE)) + + # Selected row + if row in self._selected_rows: + return QVariant(QColor(Config.COLOUR_QUERYLIST_SELECTED)) + + # Individual cell colouring + if column == QueryCol.BITRATE.value: + if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD: + return QVariant(QColor(Config.COLOUR_BITRATE_LOW)) + elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD: + return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM)) + else: + return QVariant(QColor(Config.COLOUR_BITRATE_OK)) + + return QVariant() + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + """Standard function for view""" + + return len(QueryCol) + + def data( + self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole + ) -> QVariant: + """Return data to view""" + + if not index.isValid() or not (0 <= index.row() < len(self.querylist_rows)): + return QVariant() + + row = index.row() + column = index.column() + # rat for playlist row data as it's used a lot + qrow = self.querylist_rows[row] + + # Dispatch to role-specific functions + dispatch_table: dict[int, Callable] = { + int(Qt.ItemDataRole.BackgroundRole): self.background_role, + int(Qt.ItemDataRole.DisplayRole): self.display_role, + int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role, + } + + if role in dispatch_table: + return QVariant(dispatch_table[role](row, column, qrow)) + + # Document other roles but don't use them + if role in [ + Qt.ItemDataRole.DecorationRole, + Qt.ItemDataRole.EditRole, + Qt.ItemDataRole.FontRole, + Qt.ItemDataRole.ForegroundRole, + Qt.ItemDataRole.InitialSortOrderRole, + Qt.ItemDataRole.SizeHintRole, + Qt.ItemDataRole.StatusTipRole, + Qt.ItemDataRole.TextAlignmentRole, + Qt.ItemDataRole.ToolTipRole, + Qt.ItemDataRole.WhatsThisRole, + ]: + return QVariant() + + # Fall through to no-op + return QVariant() + + def display_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: + """ + Return text for display + """ + + dispatch_table = { + QueryCol.ARTIST.value: QVariant(qrow.artist), + QueryCol.BITRATE.value: QVariant(qrow.bitrate), + QueryCol.DURATION.value: QVariant(ms_to_mmss(qrow.duration)), + QueryCol.LAST_PLAYED.value: QVariant(get_relative_date(qrow.lastplayed)), + QueryCol.TITLE.value: QVariant(qrow.title), + } + if column in dispatch_table: + return dispatch_table[column] + + return QVariant() + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + """ + Standard model flags + """ + + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + + def get_selected_track_ids(self) -> list[int]: + """ + Return a list of track_ids from selected tracks + """ + + return [self.querylist_rows[row].track_id for row in self._selected_rows] + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> QVariant: + """ + Return text for headers + """ + + display_dispatch_table = { + QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE), + QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST), + QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION), + QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED), + QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE), + } + + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return display_dispatch_table[section] + else: + if Config.ROWS_FROM_ZERO: + return QVariant(str(section)) + else: + return QVariant(str(section + 1)) + + elif role == Qt.ItemDataRole.FontRole: + boldfont = QFont() + boldfont.setBold(True) + return QVariant(boldfont) + + 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: + """ + Load data from user-defined query. Can probably hard-code the SELECT part + to ensure the required fields are returned. + """ + + # TODO: Move the SQLAlchemy parts to models later, but for now as proof + # of concept we'll keep it here. + + from sqlalchemy import text + + # Clear any exsiting rows + self.querylist_rows = {} + row = 0 + + results = session.execute(text(sql)).mappings().all() + for result in results: + queryrow = QueryRow( + artist=result["artist"], + bitrate=result["bitrate"], + duration=result["duration"], + lastplayed=result["lastplayed"], + path=result["path"], + title=result["title"], + track_id=result["id"], + ) + self.querylist_rows[row] = queryrow + row += 1 + + def rowCount(self, index: QModelIndex = QModelIndex()) -> int: + """Standard function for view""" + + return len(self.querylist_rows) + + def toggle_row_selection(self, row: int) -> None: + if row in self._selected_rows: + self._selected_rows.discard(row) + else: + self._selected_rows.add(row) + + # Emit dataChanged for the entire row + top_left = self.index(row, 0) + bottom_right = self.index(row, self.columnCount() - 1) + self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole]) + + def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: + """ + Return tooltip. Currently only used for last_played column. + """ + + if column != QueryCol.LAST_PLAYED.value: + return QVariant() + with db.Session() as session: + track_id = self.querylist_rows[row].track_id + if not track_id: + return QVariant() + playdates = Playdates.last_playdates(session, track_id) + return QVariant( + "
".join( + [ + a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) + for a in reversed(playdates) + ] + ) + ) diff --git a/app/querylists.py b/app/querylists.py new file mode 100644 index 0000000..3724611 --- /dev/null +++ b/app/querylists.py @@ -0,0 +1,193 @@ +# 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/main_window.ui b/app/ui/main_window.ui index 425bc93..41ee1ac 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -997,6 +997,9 @@ padding-left: 8px; + + + @@ -1140,7 +1143,7 @@ padding-left: 8px; - O&pen... + Open &playlist... @@ -1369,6 +1372,16 @@ padding-left: 8px; Import files... + + + Open &querylist... + + + + + Manage querylists... + + diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index cad53d2..b711264 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'app/ui/main_window.ui' # -# Created by: PyQt6 UI code generator 6.8.0 +# 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. @@ -657,6 +657,10 @@ class Ui_MainWindow(object): self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionImport_files = QtGui.QAction(parent=MainWindow) self.actionImport_files.setObjectName("actionImport_files") + self.actionOpenQuerylist = QtGui.QAction(parent=MainWindow) + self.actionOpenQuerylist.setObjectName("actionOpenQuerylist") + self.actionManage_querylists = QtGui.QAction(parent=MainWindow) + self.actionManage_querylists.setObjectName("actionManage_querylists") self.menuFile.addSeparator() self.menuFile.addAction(self.actionInsertTrack) self.menuFile.addAction(self.actionRemove) @@ -680,6 +684,9 @@ class Ui_MainWindow(object): self.menuPlaylist.addAction(self.actionRenamePlaylist) self.menuPlaylist.addAction(self.actionDeletePlaylist) self.menuPlaylist.addSeparator() + self.menuPlaylist.addAction(self.actionOpenQuerylist) + self.menuPlaylist.addAction(self.actionManage_querylists) + self.menuPlaylist.addSeparator() self.menuPlaylist.addAction(self.actionSave_as_template) self.menuPlaylist.addAction(self.actionManage_templates) self.menuPlaylist.addSeparator() @@ -757,7 +764,7 @@ class Ui_MainWindow(object): ) self.actionE_xit.setText(_translate("MainWindow", "E&xit")) self.actionTest.setText(_translate("MainWindow", "&Test")) - self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen...")) + self.actionOpenPlaylist.setText(_translate("MainWindow", "Open &playlist...")) self.actionNewPlaylist.setText(_translate("MainWindow", "&New...")) self.actionTestFunction.setText(_translate("MainWindow", "&Test function")) self.actionSkipToFade.setText( @@ -840,4 +847,4 @@ class Ui_MainWindow(object): from infotabs import InfoTabs -from pyqtgraph import PlotWidget # type: ignore +from pyqtgraph import PlotWidget diff --git a/migrations/env.py b/migrations/env.py deleted file mode 100644 index 027fd36..0000000 --- a/migrations/env.py +++ /dev/null @@ -1,27 +0,0 @@ -from importlib import import_module -from alembic import context -from alchemical.alembic.env import run_migrations - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# import the application's Alchemical instance -try: - import_mod, db_name = config.get_main_option('alchemical_db', '').split( - ':') - db = getattr(import_module(import_mod), db_name) -except (ModuleNotFoundError, AttributeError): - raise ValueError( - 'Could not import the Alchemical database instance. ' - 'Ensure that the alchemical_db setting in alembic.ini is correct.' - ) - -# run the migration engine -# The dictionary provided as second argument includes options to pass to the -# Alembic context. For details on what other options are available, see -# https://alembic.sqlalchemy.org/en/latest/autogenerate.html -run_migrations(db, { - 'render_as_batch': True, - 'compare_type': True, -}) diff --git a/migrations/env.py b/migrations/env.py new file mode 120000 index 0000000..200c230 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1 @@ +env.py.DEBUG \ No newline at end of file diff --git a/migrations/env.py.DEBUG b/migrations/env.py.DEBUG new file mode 100644 index 0000000..c6420ae --- /dev/null +++ b/migrations/env.py.DEBUG @@ -0,0 +1,28 @@ +from importlib import import_module +from alembic import context +from alchemical.alembic.env import run_migrations + +# Load Alembic configuration +config = context.config + +try: + # Import the Alchemical database instance as specified in alembic.ini + import_mod, db_name = config.get_main_option('alchemical_db', '').split(':') + db = getattr(import_module(import_mod), db_name) + print(f"Successfully loaded Alchemical database instance: {db}") + + # Use the metadata associated with the Alchemical instance + metadata = db.Model.metadata + print(f"Metadata tables detected: {metadata.tables.keys()}") # Debug output +except (ModuleNotFoundError, AttributeError) as e: + raise ValueError( + 'Could not import the Alchemical database instance or access metadata. ' + 'Ensure that the alchemical_db setting in alembic.ini is correct and ' + 'that the Alchemical instance is correctly configured.' + ) from e + +# Run migrations with metadata +run_migrations(db, { + 'render_as_batch': True, + 'compare_type': True, +}) diff --git a/migrations/env.py.NODEBUG b/migrations/env.py.NODEBUG new file mode 100644 index 0000000..027fd36 --- /dev/null +++ b/migrations/env.py.NODEBUG @@ -0,0 +1,27 @@ +from importlib import import_module +from alembic import context +from alchemical.alembic.env import run_migrations + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# import the application's Alchemical instance +try: + import_mod, db_name = config.get_main_option('alchemical_db', '').split( + ':') + db = getattr(import_module(import_mod), db_name) +except (ModuleNotFoundError, AttributeError): + raise ValueError( + 'Could not import the Alchemical database instance. ' + 'Ensure that the alchemical_db setting in alembic.ini is correct.' + ) + +# run the migration engine +# The dictionary provided as second argument includes options to pass to the +# Alembic context. For details on what other options are available, see +# https://alembic.sqlalchemy.org/en/latest/autogenerate.html +run_migrations(db, { + 'render_as_batch': True, + 'compare_type': True, +}) diff --git a/migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py b/migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py new file mode 100644 index 0000000..7562809 --- /dev/null +++ b/migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py @@ -0,0 +1,52 @@ +"""Add data for query playlists + +Revision ID: 014f2d4c88a5 +Revises: 33c04e3c12c8 +Create Date: 2024-12-30 14:23:36.924478 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '014f2d4c88a5' +down_revision = '33c04e3c12c8' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + + + + +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.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 ### + diff --git a/migrations/versions/c76e865ccb85_index_for_notesolours_substring.py b/migrations/versions/c76e865ccb85_index_for_notesolours_substring.py new file mode 100644 index 0000000..6850c31 --- /dev/null +++ b/migrations/versions/c76e865ccb85_index_for_notesolours_substring.py @@ -0,0 +1,68 @@ +"""Index for notesolours substring + +Revision ID: c76e865ccb85 +Revises: 33c04e3c12c8 +Create Date: 2025-02-07 18:21:01.760057 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c76e865ccb85' +down_revision = '33c04e3c12c8' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + + + + +def upgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('notecolours', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=False) + + with op.batch_alter_table('playdates', schema=None) as batch_op: + batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey') + batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('playlist_rows', schema=None) as batch_op: + batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey') + batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('queries', schema=None) as batch_op: + batch_op.drop_constraint('fk_queries_playlist_id_playlists', type_='foreignkey') + batch_op.create_foreign_key(None, 'playlists', ['playlist_id'], ['id'], ondelete='CASCADE') + + # ### 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_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('fk_queries_playlist_id_playlists', 'playlists', ['playlist_id'], ['id']) + + with op.batch_alter_table('playlist_rows', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id']) + + with op.batch_alter_table('playdates', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id']) + + with op.batch_alter_table('notecolours', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_notecolours_substring')) + + # ### end Alembic commands ### +