From 955bea20378e3cdc8b4be594d70fabfb682fbe6f Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 11 Feb 2025 21:11:56 +0000 Subject: [PATCH 01/27] 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 ### + From 33e2c4bf316dd65a42c331256747504735bc659e Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 19:27:47 +0000 Subject: [PATCH 02/27] Fix order of playdates on hover Fixes: #275 --- app/models.py | 4 ++-- app/playlistmodel.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models.py b/app/models.py index 49c79a3..63cc2f0 100644 --- a/app/models.py +++ b/app/models.py @@ -128,13 +128,13 @@ class Playdates(dbtables.PlaydatesTable): ) -> Sequence["Playdates"]: """ Return a list of the last limit playdates for this track, sorted - earliest to latest. + latest to earliest. """ return session.scalars( Playdates.select() .where(Playdates.track_id == track_id) - .order_by(Playdates.lastplayed.asc()) + .order_by(Playdates.lastplayed.desc()) .limit(limit) ).all() diff --git a/app/playlistmodel.py b/app/playlistmodel.py index a075a74..034c781 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -1559,7 +1559,7 @@ class PlaylistModel(QAbstractTableModel): "
".join( [ a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) - for a in reversed(playdates) + for a in playdates ] ) ) From 499c0c6b70ab977422625f74955e56500f8f96fc Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 19:38:06 +0000 Subject: [PATCH 03/27] Fix "=" header Fixes: #276 --- app/playlistmodel.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 034c781..577533f 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -32,6 +32,7 @@ import obswebsocket # type: ignore # App imports from classes import ( + ApplicationError, Col, MusicMusterSignals, ) @@ -1281,10 +1282,10 @@ class PlaylistModel(QAbstractTableModel): # Show subtotal for row_number in range(rat.row_number - 1, -1, -1): row_rat = self.playlist_rows[row_number] - if self.is_header_row(row_number): - if row_rat.note.endswith(Config.SECTION_STARTS): + if self.is_header_row(row_number) or row_number == 0: + if row_rat.note.endswith(Config.SECTION_STARTS) or row_number == 0: # If we are playing this section, also - # calculate end time if all tracks are played. + # calculate end time when all tracks are played. end_time_str = "" if ( track_sequence.current @@ -1323,9 +1324,8 @@ class PlaylistModel(QAbstractTableModel): unplayed_count += 1 duration += row_rat.duration - # We should only get here if there were no rows in section (ie, - # this was row zero) - return Config.SUBTOTAL_ON_ROW_ZERO + # We should never get here + raise ApplicationError("Error in section_subtotal_header()") def selection_is_sortable(self, row_numbers: list[int]) -> bool: """ From 95983c73b1b339c0746e8141387f4ffe2d6d76c4 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 19:49:13 +0000 Subject: [PATCH 04/27] Log to stderr timer10 stop/start --- app/logging.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/logging.yaml b/app/logging.yaml index 1d7c1aa..fa07d8e 100644 --- a/app/logging.yaml +++ b/app/logging.yaml @@ -22,6 +22,9 @@ filters: # module-name: # - function-name-1 # - function-name-2 + musicmuster: + - update_clocks + - play_next handlers: stderr: From 3547046cc13abf04608ea5741866d88755e05f72 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 21:44:20 +0000 Subject: [PATCH 05/27] Misc cleanups from query_tabs branch --- app/musicmuster.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index e2a6a60..cf825e1 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -504,9 +504,7 @@ class Window(QMainWindow): ) ) file_menu.addSeparator() - file_menu.addAction( - self.create_action("Exit", self.close) - ) # Default action for closing + file_menu.addAction(self.create_action("Exit", self.close)) # Playlist Menu playlist_menu = menu_bar.addMenu("&Playlist") From a33589a9a1216176d781b01f757c0ed6bf07632b Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 21:45:23 +0000 Subject: [PATCH 06/27] "=" header fixes Fixes: #276 --- app/playlistmodel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 577533f..5db28ae 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -1279,6 +1279,10 @@ class PlaylistModel(QAbstractTableModel): unplayed_count: int = 0 duration: int = 0 + if rat.row_number == 0: + # Meaningless to have a subtotal on row 0 + return Config.SUBTOTAL_ON_ROW_ZERO + # Show subtotal for row_number in range(rat.row_number - 1, -1, -1): row_rat = self.playlist_rows[row_number] From 61021b33b8f3693da0ffc9547cf0a407e88d2a8a Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sat, 15 Feb 2025 10:39:26 +0000 Subject: [PATCH 07/27] Fix hide played button --- app/musicmuster.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index cf825e1..7e0553d 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -978,10 +978,10 @@ class Window(QMainWindow): if self.hide_played_tracks: self.hide_played_tracks = False self.current.base_model.hide_played_tracks(False) - self.btnHidePlayed.setText("Hide played") + self.footer_section.btnHidePlayed.setText("Hide played") else: self.hide_played_tracks = True - self.btnHidePlayed.setText("Show played") + self.footer_section.btnHidePlayed.setText("Show played") if Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS: self.active_tab().hide_played_sections() else: From e4e061cf1cbb46e49fb36606c24a07d295ea23ed Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 12 Feb 2025 03:51:15 +0000 Subject: [PATCH 08/27] Add open querylist menu --- app/musicmuster.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/musicmuster.py b/app/musicmuster.py index 7e0553d..a8a6af8 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -491,6 +491,8 @@ class Window(QMainWindow): file_menu.addAction(self.create_action("Rename Playlist", self.rename_playlist)) file_menu.addAction(self.create_action("Delete Playlist", self.delete_playlist)) file_menu.addSeparator() + file_menu.addAction(self.create_action("Open Querylist", self.open_querylist)) + file_menu.addSeparator() file_menu.addAction( self.create_action("Save as Template", self.save_as_template) ) From 7c0db00b7550d4c69723d0279dcab372ed406f85 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 21:42:19 +0000 Subject: [PATCH 09/27] Create databases in dbmanager --- app/dbmanager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/dbmanager.py b/app/dbmanager.py index 52ec902..9f8c2ca 100644 --- a/app/dbmanager.py +++ b/app/dbmanager.py @@ -18,6 +18,7 @@ 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") From e6404d075e6970e27c06a961d6e01eee2d566df5 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 14 Feb 2025 23:16:56 +0000 Subject: [PATCH 10/27] Query searches working More UI needed --- app/config.py | 1 + app/dbtables.py | 17 +- app/models.py | 328 ++++++++---------- app/musicmuster.py | 277 +++++++++++++-- app/playlists.py | 4 +- app/querylistmodel.py | 62 ++-- app/querylists.py | 193 ----------- app/ui/dlgQuery.ui | 94 +++++ app/ui/dlgQuery_ui.py | 45 +++ ...s.py => 9c1254a8026d_add_queries_table.py} | 24 +- 10 files changed, 582 insertions(+), 463 deletions(-) delete mode 100644 app/querylists.py create mode 100644 app/ui/dlgQuery.ui create mode 100644 app/ui/dlgQuery_ui.py rename migrations/versions/{014f2d4c88a5_add_data_for_query_playlists.py => 9c1254a8026d_add_queries_table.py} (52%) 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 ### From 678515403ccd013db09b6990f325a53ea39bc25f Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sat, 15 Feb 2025 04:28:52 +0000 Subject: [PATCH 11/27] Guard against erroneous SQL statements in queries --- app/models.py | 16 ++++++++++++- app/musicmuster.py | 53 ++++++++++++++++++++++--------------------- app/querylistmodel.py | 46 ++++++++++++++++++------------------- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/app/models.py b/app/models.py index fc240aa..1a12a83 100644 --- a/app/models.py +++ b/app/models.py @@ -15,14 +15,17 @@ from sqlalchemy import ( delete, func, select, + text, update, ) -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm import joinedload from sqlalchemy.orm.session import Session +from sqlalchemy.engine.row import RowMapping # App imports +from classes import ApplicationError from config import Config from dbmanager import DatabaseManager import dbtables @@ -38,6 +41,17 @@ if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db +def run_sql(session: Session, sql: str) -> Sequence[RowMapping]: + """ + Run a sql string and return results + """ + + try: + return session.execute(text(sql)).mappings().all() + except ProgrammingError as e: + raise ApplicationError(e) + + # Database classes class NoteColours(dbtables.NoteColoursTable): def __init__( diff --git a/app/musicmuster.py b/app/musicmuster.py index 8e5acac..2c7ab27 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -61,7 +61,7 @@ from classes import ( from config import Config from dialogs import TrackSelectDialog from file_importer import FileImporter -from helpers import file_is_unreadable +from helpers import ask_yes_no, file_is_unreadable, ms_to_mmss, show_OK from log import log from models import ( db, @@ -83,7 +83,6 @@ from ui.main_window_playlist_ui import Ui_PlaylistSection # type: ignore from ui.main_window_footer_ui import Ui_FooterSection # type: ignore from utilities import check_db, update_bitrates -import helpers class DownloadCSV(QDialog): @@ -869,8 +868,8 @@ class Window(QMainWindow): # Don't allow window to close when a track is playing if track_sequence.current and track_sequence.current.is_playing(): event.ignore() - helpers.show_warning( - self, "Track playing", "Can't close application while track is playing" + self.show_warning( + "Track playing", "Can't close application while track is playing" ) else: with db.Session() as session: @@ -927,7 +926,7 @@ class Window(QMainWindow): current_track_playlist_id = track_sequence.current.playlist_id if current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id: - helpers.show_OK( + show_OK( "Current track", "Can't close current track playlist", self ) return False @@ -937,7 +936,7 @@ class Window(QMainWindow): next_track_playlist_id = track_sequence.next.playlist_id if next_track_playlist_id: if closing_tab_playlist_id == next_track_playlist_id: - helpers.show_OK( + show_OK( "Next track", "Can't close next track playlist", self ) return False @@ -1053,7 +1052,7 @@ class Window(QMainWindow): playlist_id = self.current.playlist_id playlist = session.get(Playlists, playlist_id) if playlist: - if helpers.ask_yes_no( + if ask_yes_no( "Delete playlist", f"Delete playlist '{playlist.name}': " "Are you sure?", ): @@ -1323,7 +1322,7 @@ class Window(QMainWindow): self.playlist_section.tabPlaylist.setCurrentIndex(idx) elif action == "Delete": - if helpers.ask_yes_no( + if ask_yes_no( "Delete template", f"Delete template '{playlist.name}': " "Are you sure?", ): @@ -1478,14 +1477,17 @@ class Window(QMainWindow): def open_querylist(self) -> None: """Open existing querylist""" - with db.Session() as session: - 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 + try: + with db.Session() as session: + 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 + except ApplicationError as e: + self.show_warning("Query error", f"Your query gave an error:\n\n{e}") def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" @@ -1759,7 +1761,7 @@ class Window(QMainWindow): msg = "Hit return to play next track now" else: msg = "Press tab to select Yes and hit return to play next track" - if not helpers.ask_yes_no( + if not ask_yes_no( "Play next track", msg, default_yes=default_yes, @@ -1829,12 +1831,12 @@ class Window(QMainWindow): template_name = dlg.textValue() if template_name not in template_names: break - helpers.show_warning( - self, "Duplicate template", "Template name already in use" + self.show_warning( + "Duplicate template", "Template name already in use" ) Playlists.save_as_template(session, self.current.playlist_id, template_name) session.commit() - helpers.show_OK("Template", "Template saved", self) + show_OK("Template", "Template saved", self) def search_playlist(self) -> None: """Show text box to search playlist""" @@ -1980,8 +1982,7 @@ class Window(QMainWindow): if Playlists.name_is_available(session, proposed_name): return proposed_name else: - helpers.show_warning( - self, + self.show_warning( "Name in use", f"There's already a playlist called '{proposed_name}'", ) @@ -2084,16 +2085,16 @@ class Window(QMainWindow): if track_sequence.current and track_sequence.current.is_playing(): # Elapsed time self.header_section.label_elapsed_timer.setText( - helpers.ms_to_mmss(track_sequence.current.time_playing()) + ms_to_mmss(track_sequence.current.time_playing()) + " / " - + helpers.ms_to_mmss(track_sequence.current.duration) + + ms_to_mmss(track_sequence.current.duration) ) # Time to fade time_to_fade = track_sequence.current.time_to_fade() time_to_silence = track_sequence.current.time_to_silence() self.footer_section.label_fade_timer.setText( - helpers.ms_to_mmss(time_to_fade) + ms_to_mmss(time_to_fade) ) # If silent in the next 5 seconds, put warning colour on @@ -2132,7 +2133,7 @@ class Window(QMainWindow): self.footer_section.frame_fade.setStyleSheet("") self.footer_section.label_silent_timer.setText( - helpers.ms_to_mmss(time_to_silence) + ms_to_mmss(time_to_silence) ) def update_headers(self) -> None: diff --git a/app/querylistmodel.py b/app/querylistmodel.py index 83b19eb..31e14fa 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -26,6 +26,7 @@ from sqlalchemy.orm.session import Session # App imports from classes import ( + ApplicationError, QueryCol, ) from config import Config @@ -33,9 +34,10 @@ from helpers import ( file_is_unreadable, get_relative_date, ms_to_mmss, + show_warning, ) from log import log -from models import db, Playdates +from models import db, Playdates, run_sql from music_manager import RowAndTrack @@ -220,33 +222,31 @@ class QuerylistModel(QAbstractTableModel): Populate self.querylist_rows """ - # 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 = 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=lastplayed, - path=result["path"], - title=result["title"], - track_id=result["id"], - ) + try: + results = run_sql(self.session, self.sql) + 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=lastplayed, + path=result["path"], + title=result["title"], + track_id=result["id"], + ) - self.querylist_rows[row] = queryrow - row += 1 + self.querylist_rows[row] = queryrow + row += 1 + except ApplicationError as e: + show_warning(None, "Query error", f"Error loading query data ({e})") def rowCount(self, index: QModelIndex = QModelIndex()) -> int: """Standard function for view""" From 8b8edba64d3f6e3fb2388cacd30d36c8d37b784c Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 19 Feb 2025 12:49:11 +0000 Subject: [PATCH 12/27] Add Filter class to classes --- app/classes.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/classes.py b/app/classes.py index d901d24..a4fdbfc 100644 --- a/app/classes.py +++ b/app/classes.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from enum import auto, Enum import functools import threading -from typing import NamedTuple +from typing import NamedTuple, Optional # Third party imports @@ -71,6 +71,17 @@ class FileErrors(NamedTuple): 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 From 994d510ed9c75e9abadc7e60d81939f957d08b8e Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 21 Feb 2025 15:26:56 +0000 Subject: [PATCH 13/27] Move querylistmodel from SQL to filter --- app/querylistmodel.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/querylistmodel.py b/app/querylistmodel.py index 31e14fa..ef043a8 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -27,6 +27,7 @@ from sqlalchemy.orm.session import Session # App imports from classes import ( ApplicationError, + Filter, QueryCol, ) from config import Config @@ -37,7 +38,7 @@ from helpers import ( show_warning, ) from log import log -from models import db, Playdates, run_sql +from models import db, Playdates from music_manager import RowAndTrack @@ -62,16 +63,16 @@ class QuerylistModel(QAbstractTableModel): """ - def __init__(self, session: Session, sql: str) -> None: + def __init__(self, session: Session, filter: Filter) -> None: """ Load query """ - log.debug(f"QuerylistModel.__init__({sql=})") + log.debug(f"QuerylistModel.__init__({filter=})") super().__init__() self.session = session - self.sql = sql + self.filter = filter self.querylist_rows: dict[int, QueryRow] = {} self._selected_rows: set[int] = set() @@ -79,7 +80,7 @@ class QuerylistModel(QAbstractTableModel): 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""" @@ -227,7 +228,7 @@ class QuerylistModel(QAbstractTableModel): row = 0 try: - results = run_sql(self.session, self.sql) + results = Tracks.get_filtered(self.session, self.filter) for result in results: if hasattr(result, "lastplayed"): lastplayed = result["lastplayed"] From 306ab103b65e39229bd691d98fc640938d5ef1f4 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sat, 22 Feb 2025 20:18:45 +0000 Subject: [PATCH 14/27] Add favourite to queries table --- app/dbtables.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/dbtables.py b/app/dbtables.py index 0507e62..9781f38 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -128,8 +128,8 @@ class QueriesTable(Model): sql: Mapped[str] = mapped_column( String(2048), index=False, default="", nullable=False ) - description: Mapped[str] = mapped_column( - String(512), index=False, default="", nullable=False + favourite: Mapped[bool] = mapped_column( + Boolean, nullable=False, index=False, default=False ) def __repr__(self) -> str: From 40756469ec99fd2f3c3fbb6db109e4528636dd7c Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 23 Feb 2025 18:19:41 +0000 Subject: [PATCH 15/27] WIP query tabs --- app/musicmuster.py | 689 +++++++++++++++++++-------------------------- 1 file changed, 283 insertions(+), 406 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index 2c7ab27..3d10000 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 # Standard library imports +from __future__ import annotations from slugify import slugify # type: ignore from typing import Callable, Optional import argparse +from dataclasses import dataclass import datetime as dt import os import subprocess import sys import urllib.parse import webbrowser +import yaml # PyQt imports from PyQt6.QtCore import ( @@ -22,14 +25,15 @@ from PyQt6.QtGui import ( QAction, QCloseEvent, QColor, + QFont, QIcon, QKeySequence, QPalette, QShortcut, ) from PyQt6.QtWidgets import ( - QAbstractItemView, QApplication, + QCheckBox, QComboBox, QDialog, QFileDialog, @@ -39,10 +43,11 @@ from PyQt6.QtWidgets import ( QLineEdit, QListWidgetItem, QMainWindow, + QMenu, QMessageBox, QPushButton, - QSizePolicy, - QTableView, + QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -61,21 +66,13 @@ from classes import ( from config import Config from dialogs import TrackSelectDialog from file_importer import FileImporter -from helpers import ask_yes_no, file_is_unreadable, ms_to_mmss, show_OK +from helpers import file_is_unreadable from log import log -from models import ( - db, - Playdates, - PlaylistRows, - Playlists, - Queries, - Settings, - Tracks, -) +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 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 @@ -83,18 +80,7 @@ from ui.main_window_playlist_ui import Ui_PlaylistSection # type: ignore from ui.main_window_footer_ui import Ui_FooterSection # type: ignore from utilities import check_db, update_bitrates - - -class DownloadCSV(QDialog): - def __init__(self, parent=None): - super().__init__() - - self.ui = Ui_DateSelect() - self.ui.setupUi(self) - self.ui.dateTimeEdit.setDate(QDate.currentDate()) - self.ui.dateTimeEdit.setTime(QTime(19, 59, 0)) - self.ui.buttonBox.accepted.connect(self.accept) - self.ui.buttonBox.rejected.connect(self.reject) +import helpers class Current: @@ -110,6 +96,18 @@ class Current: ) +class DownloadCSV(QDialog): + def __init__(self, parent=None): + super().__init__() + + self.ui = Ui_DateSelect() + self.ui.setupUi(self) + self.ui.dateTimeEdit.setDate(QDate.currentDate()) + self.ui.dateTimeEdit.setTime(QTime(19, 59, 0)) + self.ui.buttonBox.accepted.connect(self.accept) + self.ui.buttonBox.rejected.connect(self.reject) + + class EditDeleteDialog(QDialog): def __init__(self, templates: list[tuple[str, int]]) -> None: super().__init__() @@ -168,6 +166,129 @@ class EditDeleteDialog(QDialog): self.reject() +@dataclass +class ItemlistItem: + id: int + name: str + favourite: bool = False + + +class ItemlistManager(QDialog): + def __init__(self, items: list[ItemlistItem], callbacks: ItemlistManagerCallbacks) -> None: + super().__init__() + self.setWindowTitle("Item Manager") + self.setMinimumSize(600, 400) + + self.items = items + self.callbacks = callbacks + + layout = QVBoxLayout(self) + self.table = QTableWidget(len(items), 2, self) + self.table.setHorizontalHeaderLabels(["Item", "Actions"]) + hh = self.table.horizontalHeader() + if not hh: + raise ApplicationError("ItemlistManager failed to create horizontalHeader") + hh.setStretchLastSection(True) + self.table.setColumnWidth(0, 200) + self.table.setColumnWidth(1, 300) + + self.populate_table() + + layout.addWidget(self.table) + + button_layout = QHBoxLayout() + self.new_button = QPushButton("New") + self.new_button.clicked.connect(self.new_item) + button_layout.addWidget(self.new_button) + + self.close_button = QPushButton("Close") + self.close_button.clicked.connect(self.close) + button_layout.addWidget(self.close_button) + + layout.addLayout(button_layout) + + def populate_table(self) -> None: + """Populates the table with items and action buttons.""" + self.table.setRowCount(len(self.items)) + + for row, item in enumerate(self.items): + item_text = QTableWidgetItem(item.name) + if item.favourite: + item_text.setFont(QFont("Arial", weight=QFont.Weight.Bold)) + self.table.setItem(row, 0, item_text) + + # Action Buttons and Checkbox in a widget + widget = QWidget() + h_layout = QHBoxLayout(widget) + h_layout.setContentsMargins(0, 0, 0, 0) + h_layout.setSpacing(5) + + rename_button = QPushButton("Rename") + rename_button.clicked.connect(lambda _, i=item.id: self.rename_item(i)) + h_layout.addWidget(rename_button) + + edit_button = QPushButton("Edit") + edit_button.clicked.connect(lambda _, i=item.id: self.edit_item(i)) + h_layout.addWidget(edit_button) + + delete_button = QPushButton("Delete") + delete_button.clicked.connect(lambda _, i=item.id: self.delete_item(i)) + h_layout.addWidget(delete_button) + + fav_checkbox = QCheckBox() + fav_checkbox.setChecked(item.favourite) + fav_checkbox.stateChanged.connect( + lambda state, cb=fav_checkbox, i=item.id: self.toggle_favourite( + i, cb.isChecked() + ) + ) + h_layout.addWidget(fav_checkbox) + + self.table.setCellWidget(row, 1, widget) + + def rename_item(self, item_id: int) -> None: + print(f"Rename item {item_id}") + + def edit_item(self, item_id: int) -> None: + print(f"Edit item {item_id}") + self.callbacks.edit(item_id) + + def delete_item(self, item_id: int) -> None: + print(f"Delete item {item_id}") + self.callbacks.delete(item_id) + + def toggle_favourite(self, item_id: int, checked: bool) -> None: + print(f"Toggle favourite for item {item_id}: {checked}") + self.callbacks.favourite(item_id, checked) + + for row in range(self.table.rowCount()): + item = self.table.item(row, 0) + if item and self.items[row].id == item_id: + font = QFont( + "Arial", + weight=QFont.Weight.Bold if checked else QFont.Weight.Normal, + ) + item.setFont(font) + self.items[row].favourite = checked + break + + def new_item(self) -> None: + print("New item") + + # test_items = [ + # {"id": 1, "text": "Item 1", "favourite": False}, + # {"id": 2, "text": "Item 2", "favourite": True}, + # {"id": 3, "text": "Item 3", "favourite": False} + # ] + + +@dataclass +class ItemlistManagerCallbacks: + edit: Callable[[int], None] + delete: Callable[[int], None] + favourite: Callable[[int, bool], None] + + class PreviewManager: """ Manage track preview player @@ -279,234 +400,6 @@ 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__() @@ -713,106 +606,85 @@ class Window(QMainWindow): return action def create_menu_bar(self): + """Dynamically creates the menu bar from a YAML file.""" menu_bar = self.menuBar() - # File Menu - file_menu = menu_bar.addMenu("&File") - file_menu.addAction( - self.create_action("Open Playlist", self.open_playlist, "Ctrl+O") - ) - file_menu.addAction(self.create_action("New Playlist", self.new_playlist)) - file_menu.addAction( - self.create_action("Close Playlist", self.close_playlist_tab) - ) - file_menu.addAction(self.create_action("Rename Playlist", self.rename_playlist)) - file_menu.addAction(self.create_action("Delete Playlist", self.delete_playlist)) - file_menu.addSeparator() - file_menu.addAction(self.create_action("Open Querylist", self.open_querylist)) - file_menu.addSeparator() - file_menu.addAction( - self.create_action("Save as Template", self.save_as_template) - ) - file_menu.addAction( - self.create_action("Manage Templates", self.manage_templates) - ) - file_menu.addSeparator() - file_menu.addAction( - self.create_action( - "Import Files", self.import_files_wrapper, "Ctrl+Shift+I" - ) - ) - file_menu.addSeparator() - file_menu.addAction(self.create_action("Exit", self.close)) + # Load menu structure from YAML file + with open("menu.yaml", "r") as file: + menu_data = yaml.safe_load(file) - # Playlist Menu - playlist_menu = menu_bar.addMenu("&Playlist") - playlist_menu.addSeparator() - playlist_menu.addAction( - self.create_action("Insert Track", self.insert_track, "Ctrl+T") - ) - playlist_menu.addAction( - self.create_action("Insert Section Header", self.insert_header, "Ctrl+H") - ) - playlist_menu.addSeparator() - playlist_menu.addAction( - self.create_action("Mark for Moving", self.mark_rows_for_moving, "Ctrl+C") - ) - playlist_menu.addAction(self.create_action("Paste", self.paste_rows, "Ctrl+V")) - playlist_menu.addSeparator() - playlist_menu.addAction( - self.create_action("Export Playlist", self.export_playlist_tab) - ) - playlist_menu.addAction( - self.create_action( - "Download CSV of Played Tracks", self.download_played_tracks - ) - ) - playlist_menu.addSeparator() - playlist_menu.addAction( - self.create_action( - "Select Duplicate Rows", - lambda: self.active_tab().select_duplicate_rows(), - ) - ) - playlist_menu.addAction(self.create_action("Move Selected", self.move_selected)) - playlist_menu.addAction(self.create_action("Move Unplayed", self.move_unplayed)) + self.menu_actions = {} # Store reference for enabling/disabling actions + self.dynamic_submenus = {} # Store submenus for dynamic population - # Clear Selection with Escape key. Save in module so we can - # enable/disable it later - self.action_Clear_selection = self.create_action( - "Clear Selection", self.clear_selection, "Esc" - ) - playlist_menu.addAction(self.action_Clear_selection) + for menu_item in menu_data["menus"]: + menu = menu_bar.addMenu(menu_item["title"]) - # Music Menu - music_menu = menu_bar.addMenu("&Music") - music_menu.addAction( - self.create_action("Set Next", self.set_selected_track_next, "Ctrl+N") - ) - music_menu.addAction(self.create_action("Play Next", self.play_next, "Return")) - music_menu.addAction(self.create_action("Fade", self.fade, "Ctrl+Z")) - music_menu.addAction(self.create_action("Stop", self.stop, "Ctrl+Alt+S")) - music_menu.addAction(self.create_action("Resume", self.resume, "Ctrl+R")) - music_menu.addAction( - self.create_action("Skip to Next", self.play_next, "Ctrl+Alt+Return") - ) - music_menu.addSeparator() - music_menu.addAction(self.create_action("Search", self.search_playlist, "/")) - music_menu.addAction( - self.create_action( - "Search Title in Wikipedia", self.lookup_row_in_wikipedia, "Ctrl+W" - ) - ) - music_menu.addAction( - self.create_action( - "Search Title in Songfacts", self.lookup_row_in_songfacts, "Ctrl+S" - ) - ) + for action_item in menu_item["actions"]: + if "separator" in action_item and action_item["separator"]: + menu.addSeparator() + continue - # Help Menu - help_menu = menu_bar.addMenu("Help") - help_menu.addAction(self.create_action("About", self.about)) - help_menu.addAction(self.create_action("Debug", self.debug)) + # Check whether this is a submenu first + if action_item.get("submenu"): + submenu = QMenu(action_item["text"], self) + menu.addMenu(submenu) + + # Store submenu reference for dynamic population + self.dynamic_submenus[action_item["handler"]] = submenu + submenu.aboutToShow.connect(self.populate_dynamic_submenu) + continue # Skip the rest of the loop (no handler needed) + + # Now check for a normal menu action + handler = getattr(self, action_item["handler"], None) + if handler is None: + print(f"Warning: No handler found for {action_item['text']}") + continue + + action = self.create_action( + action_item["text"], handler, action_item.get("shortcut") + ) + # Store reference to "Clear Selection" so we can enable/disable it + if action_item.get("store_reference"): + self.menu_actions[action_item["handler"]] = action + + menu.addAction(action) + + def populate_dynamic_submenu(self): + """Dynamically populates submenus when they are selected.""" + submenu = self.sender() # Get the submenu that triggered the event + + # Find which submenu it is + for key, stored_submenu in self.dynamic_submenus.items(): + if submenu == stored_submenu: + submenu.clear() + # Dynamically call the correct function + items = getattr(self, f"get_{key}_items")() + for item in items: + action = QAction(item["text"], self) + action.triggered.connect( + lambda _, i=item["handler"]: getattr(self, i)() + ) + submenu.addAction(action) + break + + def get_new_playlist_dynamic_submenu_items(self): + """Returns dynamically generated menu items for Submenu 1.""" + return [ + {"text": "Option A", "handler": "option_a_handler"}, + {"text": "Option B", "handler": "option_b_handler"}, + ] + + def get_query_dynamic_submenu_items(self): + """Returns dynamically generated menu items for Submenu 2.""" + return [ + {"text": "Action X", "handler": "action_x_handler"}, + {"text": "Action Y", "handler": "action_y_handler"}, + ] + + def select_duplicate_rows(self) -> None: + """Call playlist to select duplicate rows""" + + self.active_tab().select_duplicate_rows() def about(self) -> None: """Get git tag and database name""" @@ -868,8 +740,8 @@ class Window(QMainWindow): # Don't allow window to close when a track is playing if track_sequence.current and track_sequence.current.is_playing(): event.ignore() - self.show_warning( - "Track playing", "Can't close application while track is playing" + helpers.show_warning( + self, "Track playing", "Can't close application while track is playing" ) else: with db.Session() as session: @@ -926,7 +798,7 @@ class Window(QMainWindow): current_track_playlist_id = track_sequence.current.playlist_id if current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id: - show_OK( + helpers.show_OK( "Current track", "Can't close current track playlist", self ) return False @@ -936,7 +808,7 @@ class Window(QMainWindow): next_track_playlist_id = track_sequence.next.playlist_id if next_track_playlist_id: if closing_tab_playlist_id == next_track_playlist_id: - show_OK( + helpers.show_OK( "Next track", "Can't close next track playlist", self ) return False @@ -1052,7 +924,7 @@ class Window(QMainWindow): playlist_id = self.current.playlist_id playlist = session.get(Playlists, playlist_id) if playlist: - if ask_yes_no( + if helpers.ask_yes_no( "Delete playlist", f"Delete playlist '{playlist.name}': " "Are you sure?", ): @@ -1103,7 +975,8 @@ class Window(QMainWindow): log.debug(f"enable_escape({enabled=})") - self.action_Clear_selection.setEnabled(enabled) + if "clear_selection" in self.menu_actions: + self.menu_actions["clear_selection"].setEnabled(enabled) def end_of_track_actions(self) -> None: """ @@ -1297,42 +1170,60 @@ class Window(QMainWindow): Delete / edit templates """ - # Build a list of (template-name, playlist-id) tuples - template_list: list[tuple[str, int]] = [] + def edit(template_id: int) -> None: + """Edit template""" + + print(f"manage_templates.edit({template_id=}") + + def delete(template_id: int) -> None: + """delete template""" + + print(f"manage_templates.delete({template_id=}") + + def favourite(template_id: int, favourite: bool) -> None: + """favourite template""" + + print(f"manage_templates.favourite({template_id=}") + + callbacks = ItemlistManagerCallbacks(edit, delete, favourite) + + # 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((template.name, template.id)) + # TODO: need to add in favourites + template_list.append(ItemlistItem(name=template.name, id=template.id)) - # Get user's selection - dlg = EditDeleteDialog(template_list) - if not dlg.exec(): - return # User cancelled + # # Get user's selection + # dlg = EditDeleteDialog(template_list) + # if not dlg.exec(): + # return # User cancelled - action, template_id = dlg.selection + # action, template_id = dlg.selection - playlist = session.get(Playlists, template_id) - if not playlist: - log.error(f"Error opening {template_id=}") + # playlist = session.get(Playlists, template_id) + # if not playlist: + # log.error(f"Error opening {template_id=}") - if action == "Edit": - # Simply load the template as a playlist. Any changes - # made will persist - idx = self.create_playlist_tab(playlist) - self.playlist_section.tabPlaylist.setCurrentIndex(idx) + # if action == "Edit": + # # Simply load the template as a playlist. Any changes + # # made will persist + # idx = self.create_playlist_tab(playlist) + # self.playlist_section.tabPlaylist.setCurrentIndex(idx) - elif action == "Delete": - if ask_yes_no( - "Delete template", - f"Delete template '{playlist.name}': " "Are you sure?", - ): - if self.close_playlist_tab(): - playlist.delete(session) - session.commit() - else: - raise ApplicationError( - f"Unrecognised action from EditDeleteDialog: {action=}" - ) + # elif action == "Delete": + # if helpers.ask_yes_no( + # "Delete template", + # f"Delete template '{playlist.name}': " "Are you sure?", + # ): + # if self.close_playlist_tab(): + # playlist.delete(session) + # session.commit() + # else: + # raise ApplicationError( + # f"Unrecognised action from EditDeleteDialog: {action=}" + # ) def mark_rows_for_moving(self) -> None: """ @@ -1474,21 +1365,6 @@ class Window(QMainWindow): self.playlist_section.tabPlaylist.setCurrentIndex(idx) - def open_querylist(self) -> None: - """Open existing querylist""" - - try: - with db.Session() as session: - 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 - except ApplicationError as e: - self.show_warning("Query error", f"Your query gave an error:\n\n{e}") - def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" @@ -1761,7 +1637,7 @@ class Window(QMainWindow): msg = "Hit return to play next track now" else: msg = "Press tab to select Yes and hit return to play next track" - if not ask_yes_no( + if not helpers.ask_yes_no( "Play next track", msg, default_yes=default_yes, @@ -1831,12 +1707,12 @@ class Window(QMainWindow): template_name = dlg.textValue() if template_name not in template_names: break - self.show_warning( - "Duplicate template", "Template name already in use" + helpers.show_warning( + self, "Duplicate template", "Template name already in use" ) Playlists.save_as_template(session, self.current.playlist_id, template_name) session.commit() - show_OK("Template", "Template saved", self) + helpers.show_OK("Template", "Template saved", self) def search_playlist(self) -> None: """Show text box to search playlist""" @@ -1982,7 +1858,8 @@ class Window(QMainWindow): if Playlists.name_is_available(session, proposed_name): return proposed_name else: - self.show_warning( + helpers.show_warning( + self, "Name in use", f"There's already a playlist called '{proposed_name}'", ) @@ -2085,16 +1962,16 @@ class Window(QMainWindow): if track_sequence.current and track_sequence.current.is_playing(): # Elapsed time self.header_section.label_elapsed_timer.setText( - ms_to_mmss(track_sequence.current.time_playing()) + helpers.ms_to_mmss(track_sequence.current.time_playing()) + " / " - + ms_to_mmss(track_sequence.current.duration) + + helpers.ms_to_mmss(track_sequence.current.duration) ) # Time to fade time_to_fade = track_sequence.current.time_to_fade() time_to_silence = track_sequence.current.time_to_silence() self.footer_section.label_fade_timer.setText( - ms_to_mmss(time_to_fade) + helpers.ms_to_mmss(time_to_fade) ) # If silent in the next 5 seconds, put warning colour on @@ -2133,7 +2010,7 @@ class Window(QMainWindow): self.footer_section.frame_fade.setStyleSheet("") self.footer_section.label_silent_timer.setText( - ms_to_mmss(time_to_silence) + helpers.ms_to_mmss(time_to_silence) ) def update_headers(self) -> None: From 3f248d363f2bb9d2e7e12f44ca9db012af4b3ef8 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 23 Feb 2025 21:05:21 +0000 Subject: [PATCH 16/27] rebase from dev --- app/config.py | 1 + app/dbtables.py | 7 +- app/menu.yaml | 102 ++ app/models.py | 289 ++-- app/musicmuster.py | 721 +++++---- app/playlistmodel.py | 9 +- app/playlists.py | 2 +- app/ui/icons.qrc | 1 + app/ui/icons_rc.py | 1399 ++++++++++++++++- app/ui/main_window.ui | 2 +- app/ui/main_window_playlist.ui | 2 +- app/ui/main_window_ui.py | 4 +- migrations/env.py | 28 +- migrations/env.py.DEBUG | 28 - migrations/env.py.NODEBUG | 27 - ...4df697e40cd_add_favouirit_to_playlists.py} | 32 +- .../9c1254a8026d_add_queries_table.py | 46 - 17 files changed, 2091 insertions(+), 609 deletions(-) create mode 100644 app/menu.yaml mode change 120000 => 100644 migrations/env.py delete mode 100644 migrations/env.py.DEBUG delete mode 100644 migrations/env.py.NODEBUG rename migrations/versions/{c76e865ccb85_index_for_notesolours_substring.py => 04df697e40cd_add_favouirit_to_playlists.py} (50%) delete mode 100644 migrations/versions/9c1254a8026d_add_queries_table.py diff --git a/app/config.py b/app/config.py index 23dacdf..c20bdca 100644 --- a/app/config.py +++ b/app/config.py @@ -97,6 +97,7 @@ class Config(object): PLAY_SETTLE = 500000 PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png" PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png" + PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png" PREVIEW_ADVANCE_MS = 5000 PREVIEW_BACK_MS = 5000 PREVIEW_END_BUFFER_MS = 1000 diff --git a/app/dbtables.py b/app/dbtables.py index 9781f38..6d4919b 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -80,6 +80,9 @@ class PlaylistsTable(Model): cascade="all, delete-orphan", order_by="PlaylistRowsTable.row_number", ) + favourite: Mapped[bool] = mapped_column( + Boolean, nullable=False, index=False, default=False + ) def __repr__(self) -> str: return ( @@ -101,9 +104,7 @@ 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", diff --git a/app/menu.yaml b/app/menu.yaml new file mode 100644 index 0000000..239d5ce --- /dev/null +++ b/app/menu.yaml @@ -0,0 +1,102 @@ +menus: + - title: "&File" + actions: + - text: "Save as Template" + handler: "save_as_template" + - text: "Manage Templates" + handler: "manage_templates" + - separator: true + - separator: true + - text: "Exit" + handler: "close" + + - title: "&Playlist" + actions: + - text: "Open Playlist" + handler: "open_existing_playlist" + shortcut: "Ctrl+O" + - text: "New Playlist" + handler: "new_playlist_dynamic_submenu" + submenu: true + - text: "Close Playlist" + handler: "close_playlist_tab" + - text: "Rename Playlist" + handler: "rename_playlist" + - text: "Delete Playlist" + handler: "delete_playlist" + - separator: true + - text: "Insert Track" + handler: "insert_track" + shortcut: "Ctrl+T" + - text: "Select Track from Query" + handler: "query_dynamic_submenu" + submenu: true + - text: "Insert Section Header" + handler: "insert_header" + shortcut: "Ctrl+H" + - text: "Import Files" + handler: "import_files_wrapper" + shortcut: "Ctrl+Shift+I" + - separator: true + - text: "Mark for Moving" + handler: "mark_rows_for_moving" + shortcut: "Ctrl+C" + - text: "Paste" + handler: "paste_rows" + shortcut: "Ctrl+V" + - separator: true + - text: "Export Playlist" + handler: "export_playlist_tab" + - text: "Download CSV of Played Tracks" + handler: "download_played_tracks" + - separator: true + - text: "Select Duplicate Rows" + handler: "select_duplicate_rows" + - text: "Move Selected" + handler: "move_selected" + - text: "Move Unplayed" + handler: "move_unplayed" + - separator: true + - text: "Clear Selection" + handler: "clear_selection" + shortcut: "Esc" + store_reference: true # So we can enable/disable later + + - title: "&Music" + actions: + - text: "Set Next" + handler: "set_selected_track_next" + shortcut: "Ctrl+N" + - text: "Play Next" + handler: "play_next" + shortcut: "Return" + - text: "Fade" + handler: "fade" + shortcut: "Ctrl+Z" + - text: "Stop" + handler: "stop" + shortcut: "Ctrl+Alt+S" + - text: "Resume" + handler: "resume" + shortcut: "Ctrl+R" + - text: "Skip to Next" + handler: "play_next" + shortcut: "Ctrl+Alt+Return" + - separator: true + - text: "Search" + handler: "search_playlist" + shortcut: "/" + - text: "Search Title in Wikipedia" + handler: "lookup_row_in_wikipedia" + shortcut: "Ctrl+W" + - text: "Search Title in Songfacts" + handler: "lookup_row_in_songfacts" + shortcut: "Ctrl+S" + + - title: "Help" + actions: + - text: "About" + handler: "about" + - text: "Debug" + handler: "debug" + diff --git a/app/models.py b/app/models.py index 1a12a83..535bf53 100644 --- a/app/models.py +++ b/app/models.py @@ -192,6 +192,138 @@ class Playdates(dbtables.PlaydatesTable): ).all() +class Playlists(dbtables.PlaylistsTable): + def __init__(self, session: Session, name: str, template_id: int) -> None: + """Create playlist with passed name""" + + self.name = name + self.last_used = dt.datetime.now() + session.add(self) + session.commit() + + # If a template is specified, copy from it + if template_id: + PlaylistRows.copy_playlist(session, template_id, self.id) + + @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() + + 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_favourite_templates(cls, session: Session) -> Sequence["Playlists"]: + """Returns a list of favourite templates ordered by name""" + + return session.scalars( + select(cls) + .where( + cls.is_template.is_(True), + cls.favourite.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, template_id=0) + if not template or not template.id: + return + + template.is_template = True + session.commit() + + PlaylistRows.copy_playlist(session, playlist_id, template.id) + + class PlaylistRows(dbtables.PlaylistRowsTable): def __init__( self, @@ -476,161 +608,8 @@ 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): + def __init__(self, session: Session, name: str) -> None: self.name = name session.add(self) session.commit() @@ -658,7 +637,7 @@ class Tracks(dbtables.TracksTable): fade_at: int, silence_at: int, bitrate: int, - ): + ) -> None: self.path = path self.title = title self.artist = artist diff --git a/app/musicmuster.py b/app/musicmuster.py index 3d10000..173da9c 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -174,7 +174,9 @@ class ItemlistItem: class ItemlistManager(QDialog): - def __init__(self, items: list[ItemlistItem], callbacks: ItemlistManagerCallbacks) -> None: + def __init__( + self, items: list[ItemlistItem], callbacks: ItemlistManagerCallbacks + ) -> None: super().__init__() self.setWindowTitle("Item Manager") self.setMinimumSize(600, 400) @@ -189,7 +191,7 @@ class ItemlistManager(QDialog): if not hh: raise ApplicationError("ItemlistManager failed to create horizontalHeader") hh.setStretchLastSection(True) - self.table.setColumnWidth(0, 200) + self.table.setColumnWidth(0, 288) self.table.setColumnWidth(1, 300) self.populate_table() @@ -246,19 +248,25 @@ class ItemlistManager(QDialog): self.table.setCellWidget(row, 1, widget) - def rename_item(self, item_id: int) -> None: - print(f"Rename item {item_id}") - - def edit_item(self, item_id: int) -> None: - print(f"Edit item {item_id}") - self.callbacks.edit(item_id) - def delete_item(self, item_id: int) -> None: - print(f"Delete item {item_id}") self.callbacks.delete(item_id) + def edit_item(self, item_id: int) -> None: + self.callbacks.edit(item_id) + + def rename_item(self, item_id: int) -> None: + new_name = self.callbacks.rename(item_id) + if not new_name: + return + # Rename item in list + for row in range(self.table.rowCount()): + item = self.table.item(row, 0) + if item and self.items[row].id == item_id: + item.setText(new_name) + self.items[row].name = new_name + break + def toggle_favourite(self, item_id: int, checked: bool) -> None: - print(f"Toggle favourite for item {item_id}: {checked}") self.callbacks.favourite(item_id, checked) for row in range(self.table.rowCount()): @@ -273,20 +281,16 @@ class ItemlistManager(QDialog): break def new_item(self) -> None: - print("New item") - - # test_items = [ - # {"id": 1, "text": "Item 1", "favourite": False}, - # {"id": 2, "text": "Item 2", "favourite": True}, - # {"id": 3, "text": "Item 3", "favourite": False} - # ] + self.callbacks.new_item() @dataclass class ItemlistManagerCallbacks: - edit: Callable[[int], None] delete: Callable[[int], None] + edit: Callable[[int], None] favourite: Callable[[int, bool], None] + new_item: Callable[[], None] + rename: Callable[[int], Optional[str]] class PreviewManager: @@ -451,16 +455,21 @@ class TemplateSelectorDialog(QDialog): Class to manage user selection of template """ - def __init__(self, templates: list[tuple[str, int]]) -> None: + def __init__( + self, templates: list[tuple[str, int]], template_prompt: Optional[str] + ) -> None: super().__init__() self.templates = templates + self.template_prompt = template_prompt self.selected_id = None self.init_ui() def init_ui(self): # Create label - label = QLabel("Select template:") + if not self.template_prompt: + self.template_prompt = "Select template:" + label = QLabel(self.template_prompt) # Create combo box self.combo_box = QComboBox() @@ -592,6 +601,61 @@ class Window(QMainWindow): self.load_last_playlists() self.stop_autoplay = False + # # # # # # # # # # Overrides # # # # # # # # # # + + def closeEvent(self, event: Optional[QCloseEvent]) -> None: + """Handle attempt to close main window""" + + if not event: + return + + # Don't allow window to close when a track is playing + if track_sequence.current and track_sequence.current.is_playing(): + event.ignore() + helpers.show_warning( + self, "Track playing", "Can't close application while track is playing" + ) + else: + with db.Session() as session: + # Save tab number of open playlists + open_playlist_ids: dict[int, int] = {} + for idx in range(self.playlist_section.tabPlaylist.count()): + open_playlist_ids[ + self.playlist_section.tabPlaylist.widget(idx).playlist_id + ] = idx + Playlists.clear_tabs(session, list(open_playlist_ids.keys())) + for playlist_id, idx in open_playlist_ids.items(): + playlist = session.get(Playlists, playlist_id) + if playlist: + log.debug(f"Set {playlist=} tab to {idx=}") + playlist.tab = idx + + # Save window attributes + attributes_to_save = dict( + mainwindow_height=self.height(), + mainwindow_width=self.width(), + mainwindow_x=self.x(), + mainwindow_y=self.y(), + active_tab=self.playlist_section.tabPlaylist.currentIndex(), + ) + for name, value in attributes_to_save.items(): + record = Settings.get_setting(session, name) + record.f_int = value + + session.commit() + + event.accept() + + # # # # # # # # # # Internal utility functions # # # # # # # # # # + + def active_base_model(self) -> PlaylistModel: + return self.current.base_model + + def active_tab(self) -> PlaylistTab: + return self.playlist_section.tabPlaylist.currentWidget() + + # # # # # # # # # # Menu functions # # # # # # # # # # + def create_action( self, text: str, handler: Callable, shortcut: Optional[str] = None ) -> QAction: @@ -610,7 +674,7 @@ class Window(QMainWindow): menu_bar = self.menuBar() # Load menu structure from YAML file - with open("menu.yaml", "r") as file: + with open("app/menu.yaml", "r") as file: menu_data = yaml.safe_load(file) self.menu_actions = {} # Store reference for enabling/disabling actions @@ -661,26 +725,231 @@ class Window(QMainWindow): items = getattr(self, f"get_{key}_items")() for item in items: action = QAction(item["text"], self) - action.triggered.connect( - lambda _, i=item["handler"]: getattr(self, i)() - ) + + # Extract handler and arguments + handler = getattr(self, item["handler"], None) + args = item.get("args", ()) + + if handler: + # Use a lambda to pass arguments to the function + action.triggered.connect(lambda _, h=handler, a=args: h(*a)) + submenu.addAction(action) break - def get_new_playlist_dynamic_submenu_items(self): - """Returns dynamically generated menu items for Submenu 1.""" - return [ - {"text": "Option A", "handler": "option_a_handler"}, - {"text": "Option B", "handler": "option_b_handler"}, - ] + def get_new_playlist_dynamic_submenu_items( + self, + ) -> list[dict[str, str | tuple[Session, int]]]: + """ + Return dynamically generated menu items, in this case + templates marked as favourite from which to generate a + new playlist. + + The handler is to call create_playlist with a session + and template_id. + """ + + 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) + } + ] + templates = Playlists.get_favourite_templates(session) + for template in templates: + submenu_items.append( + { + "text": template.name, + "handler": "create_playlist_from_template", + "args": ( + session, + template.id, + ), + } + ) + + return submenu_items def get_query_dynamic_submenu_items(self): """Returns dynamically generated menu items for Submenu 2.""" return [ - {"text": "Action X", "handler": "action_x_handler"}, + {"text": "Action Xargs", "handler": "kae", "args": (21,)}, {"text": "Action Y", "handler": "action_y_handler"}, ] + # # # # # # # # # # Playlist management functions # # # # # # # # # # + + def _create_playlist( + self, session: Session, name: str, template_id: int + ) -> Playlists: + """ + Create a playlist in the database, populate it from the template + if template_id > 0, and return the Playlists object. + """ + + log.debug(f" _create_playlist({name=}, {template_id=})") + + return Playlists(session, name, template_id) + + def _open_playlist(self, playlist: Playlists, is_template: bool = False) -> int: + """ + With passed playlist: + - create models + - create tab + - switch to tab + - mark playist as open + return: tab index + """ + + log.debug(f" _open_playlist({playlist=}, {is_template=})") + + # Create base model and proxy model + base_model = PlaylistModel(playlist.id, is_template) + proxy_model = PlaylistProxyModel() + proxy_model.setSourceModel(base_model) + + # Create tab + playlist_tab = PlaylistTab(musicmuster=self, model=proxy_model) + idx = self.playlist_section.tabPlaylist.addTab(playlist_tab, playlist.name) + + # Mark playlist as open + playlist.mark_open() + + # Switch to new tab + self.playlist_section.tabPlaylist.setCurrentIndex(idx) + self.update_playlist_icons() + + return idx + + def create_playlist_from_template(self, session: Session, template_id: int) -> None: + """ + Prompt for new playlist name and create from passed template_id + """ + + if template_id == 0: + # Show all templates + selected_template_id = self.solicit_template_to_use(session) + if selected_template_id is None: + return + else: + template_id = selected_template_id + + playlist_name = self.solicit_playlist_name(session) + if not playlist_name: + return + + playlist = self._create_playlist(session, playlist_name, template_id) + self._open_playlist(playlist) + session.commit() + + def delete_playlist(self) -> None: + """ + Delete current playlist + """ + + with db.Session() as session: + playlist_id = self.current.playlist_id + playlist = session.get(Playlists, playlist_id) + if playlist: + if helpers.ask_yes_no( + "Delete playlist", + f"Delete playlist '{playlist.name}': " "Are you sure?", + ): + if self.close_playlist_tab(): + playlist.delete(session) + session.commit() + else: + log.error("Failed to retrieve playlist") + + def open_existing_playlist(self) -> None: + """Open existing playlist""" + + with db.Session() as session: + playlists = Playlists.get_closed(session) + dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) + dlg.exec() + playlist = dlg.playlist + if playlist: + self._open_playlist(playlist) + session.commit() + + def save_as_template(self) -> None: + """Save current playlist as template""" + + with db.Session() as session: + template_names = [a.name for a in Playlists.get_all_templates(session)] + + while True: + # Get name for new template + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.InputMode.TextInput) + dlg.setLabelText("Template name:") + dlg.resize(500, 100) + ok = dlg.exec() + if not ok: + return + + template_name = dlg.textValue() + if template_name not in template_names: + break + helpers.show_warning( + self, "Duplicate template", "Template name already in use" + ) + Playlists.save_as_template(session, self.current.playlist_id, template_name) + session.commit() + helpers.show_OK("Template", "Template saved", self) + + def solicit_playlist_name( + self, session: Session, default: str = "", prompt: str = "Playlist name:" + ) -> Optional[str]: + """Get name of new playlist from user""" + + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.InputMode.TextInput) + dlg.setLabelText(prompt) + while True: + if default: + dlg.setTextValue(default) + dlg.resize(500, 100) + ok = dlg.exec() + if ok: + proposed_name = dlg.textValue() + if Playlists.name_is_available(session, proposed_name): + return proposed_name + else: + helpers.show_warning( + self, + "Name in use", + f"There's already a playlist called '{proposed_name}'", + ) + continue + else: + return None + + def solicit_template_to_use( + self, session: Session, template_prompt: Optional[str] = None + ) -> Optional[int]: + """ + Have user select a template. Return the template.id, or None if they cancel. + template_id of zero means don't use a template. + """ + + template_name_id_list: list[tuple[str, int]] = [] + template_name_id_list.append((Config.NO_TEMPLATE_NAME, 0)) + + with db.Session() as session: + for template in Playlists.get_all_templates(session): + template_name_id_list.append((template.name, template.id)) + + dlg = TemplateSelectorDialog(template_name_id_list, template_prompt) + if not dlg.exec() or dlg.selected_id is None: + return None # User cancelled + + return dlg.selected_id + + # # # # # # # # # # Miscellaneous functions # # # # # # # # # # + def select_duplicate_rows(self) -> None: """Call playlist to select duplicate rows""" @@ -707,12 +976,6 @@ class Window(QMainWindow): QMessageBox.StandardButton.Ok, ) - def active_base_model(self) -> PlaylistModel: - return self.current.base_model - - def active_tab(self) -> PlaylistTab: - return self.playlist_section.tabPlaylist.currentWidget() - def clear_next(self) -> None: """ Clear next track @@ -731,49 +994,6 @@ class Window(QMainWindow): # Clear the search bar self.search_playlist_clear() - def closeEvent(self, event: Optional[QCloseEvent]) -> None: - """Handle attempt to close main window""" - - if not event: - return - - # Don't allow window to close when a track is playing - if track_sequence.current and track_sequence.current.is_playing(): - event.ignore() - helpers.show_warning( - self, "Track playing", "Can't close application while track is playing" - ) - else: - with db.Session() as session: - # Save tab number of open playlists - open_playlist_ids: dict[int, int] = {} - for idx in range(self.playlist_section.tabPlaylist.count()): - open_playlist_ids[ - self.playlist_section.tabPlaylist.widget(idx).playlist_id - ] = idx - Playlists.clear_tabs(session, list(open_playlist_ids.keys())) - for playlist_id, idx in open_playlist_ids.items(): - playlist = session.get(Playlists, playlist_id) - if playlist: - log.debug(f"Set {playlist=} tab to {idx=}") - playlist.tab = idx - - # Save window attributes - attributes_to_save = dict( - mainwindow_height=self.height(), - mainwindow_width=self.width(), - mainwindow_x=self.x(), - mainwindow_y=self.y(), - active_tab=self.playlist_section.tabPlaylist.currentIndex(), - ) - for name, value in attributes_to_save.items(): - record = Settings.get_setting(session, name) - record.f_int = value - - session.commit() - - event.accept() - def close_playlist_tab(self) -> bool: """ Close active playlist tab, called by menu item @@ -860,43 +1080,6 @@ class Window(QMainWindow): self.signals.search_songfacts_signal.connect(self.open_songfacts_browser) self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser) - def create_playlist( - self, session: Session, playlist_name: str - ) -> Optional[Playlists]: - """Create new playlist""" - - log.debug(f"create_playlist({playlist_name=}") - - playlist = Playlists(session, playlist_name) - - if playlist: - return playlist - else: - log.error(f"Failed to create playlist, {playlist_name=}") - - return None - - def create_playlist_tab(self, playlist: Playlists) -> int: - """ - Take the passed playlist, create a playlist tab and - add tab to display. Return index number of tab. - """ - - log.debug(f"create_playlist_tab({playlist=})") - - # Create model and proxy model - base_model = PlaylistModel(playlist.id) - proxy_model = PlaylistProxyModel() - proxy_model.setSourceModel(base_model) - - # Create tab - playlist_tab = PlaylistTab(musicmuster=self, model=proxy_model) - idx = self.playlist_section.tabPlaylist.addTab(playlist_tab, playlist.name) - - log.debug(f"create_playlist_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 @@ -915,25 +1098,6 @@ class Window(QMainWindow): ipdb.set_trace() - def delete_playlist(self) -> None: - """ - Delete current playlist - """ - - with db.Session() as session: - playlist_id = self.current.playlist_id - playlist = session.get(Playlists, playlist_id) - if playlist: - if helpers.ask_yes_no( - "Delete playlist", - f"Delete playlist '{playlist.name}': " "Are you sure?", - ): - if self.close_playlist_tab(): - playlist.delete(session) - session.commit() - else: - log.error("Failed to retrieve playlist") - def download_played_tracks(self) -> None: """Download a CSV of played tracks""" @@ -1064,6 +1228,18 @@ class Window(QMainWindow): if track_sequence.current: track_sequence.current.fade() + def get_tab_index_for_playlist(self, playlist_id: int) -> Optional[int]: + """ + Return the tab index for the passed playlist_id if it is displayed, + else return None. + """ + + for idx in range(self.playlist_section.tabPlaylist.count()): + if self.playlist_section.tabPlaylist.widget(idx).playlist_id == playlist_id: + return idx + + return None + def hide_played(self): """Toggle hide played tracks""" @@ -1129,7 +1305,7 @@ class Window(QMainWindow): if playlist: log.debug(f"load_last_playlists() loaded {playlist=}") # Create tab - playlist_ids.append(self.create_playlist_tab(playlist)) + playlist_ids.append(self._open_playlist(playlist)) # Set active tab record = Settings.get_setting(session, "active_tab") @@ -1170,60 +1346,119 @@ class Window(QMainWindow): Delete / edit templates """ - def edit(template_id: int) -> None: - """Edit template""" - - print(f"manage_templates.edit({template_id=}") - + # Define callbacks to handle management options def delete(template_id: int) -> None: """delete template""" - print(f"manage_templates.delete({template_id=}") + 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: - """favourite template""" + """Mark template as (not) favourite""" - print(f"manage_templates.favourite({template_id=}") + template = session.get(Playlists, template_id) + template.favourite = favourite + session.commit() - callbacks = ItemlistManagerCallbacks(edit, delete, favourite) + 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): - # TODO: need to add in favourites - template_list.append(ItemlistItem(name=template.name, id=template.id)) - - # # Get user's selection - # dlg = EditDeleteDialog(template_list) - # if not dlg.exec(): - # return # User cancelled - - # action, template_id = dlg.selection - - # playlist = session.get(Playlists, template_id) - # if not playlist: - # log.error(f"Error opening {template_id=}") - - # if action == "Edit": - # # Simply load the template as a playlist. Any changes - # # made will persist - # idx = self.create_playlist_tab(playlist) - # self.playlist_section.tabPlaylist.setCurrentIndex(idx) - - # elif action == "Delete": - # if helpers.ask_yes_no( - # "Delete template", - # f"Delete template '{playlist.name}': " "Are you sure?", - # ): - # if self.close_playlist_tab(): - # playlist.delete(session) - # session.commit() - # else: - # raise ApplicationError( - # f"Unrecognised action from EditDeleteDialog: {action=}" - # ) + 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: """ @@ -1308,63 +1543,6 @@ class Window(QMainWindow): self.move_playlist_rows(unplayed_rows) self.disable_selection_timing = False - def new_playlist(self) -> None: - """ - Create new playlist, optionally from template - """ - - # Build a list of (template-name, playlist-id) tuples starting - # with the "no template" entry - template_list: list[tuple[str, int]] = [] - template_list.append((Config.NO_TEMPLATE_NAME, 0)) - - with db.Session() as session: - for template in Playlists.get_all_templates(session): - template_list.append((template.name, template.id)) - - dlg = TemplateSelectorDialog(template_list) - if not dlg.exec(): - return # User cancelled - template_id = dlg.selected_id - - # Get a name for this new playlist - playlist_name = self.solicit_playlist_name(session) - if not playlist_name: - return - - # If template_id == 0, user doesn't want a template - if template_id == 0: - playlist = self.create_playlist(session, playlist_name) - else: - playlist = Playlists.create_playlist_from_template( - session, template, playlist_name - ) - - if playlist: - playlist.mark_open() - # Need to ensure that the new playlist is committed to - # the database before it is opened by the model. - session.commit() - idx = self.create_playlist_tab(playlist) - self.playlist_section.tabPlaylist.setCurrentIndex(idx) - else: - log.error("Playlist failed to create") - - def open_playlist(self) -> None: - """Open existing playlist""" - - with db.Session() as session: - playlists = Playlists.get_closed(session) - dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) - dlg.exec() - playlist = dlg.playlist - if playlist: - idx = self.create_playlist_tab(playlist) - playlist.mark_open() - session.commit() - - self.playlist_section.tabPlaylist.setCurrentIndex(idx) - def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" @@ -1688,32 +1866,6 @@ class Window(QMainWindow): ) track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms) - def save_as_template(self) -> None: - """Save current playlist as template""" - - with db.Session() as session: - template_names = [a.name for a in Playlists.get_all_templates(session)] - - while True: - # Get name for new template - dlg = QInputDialog(self) - dlg.setInputMode(QInputDialog.InputMode.TextInput) - dlg.setLabelText("Template name:") - dlg.resize(500, 100) - ok = dlg.exec() - if not ok: - return - - template_name = dlg.textValue() - if template_name not in template_names: - break - helpers.show_warning( - self, "Duplicate template", "Template name already in use" - ) - Playlists.save_as_template(session, self.current.playlist_id, template_name) - session.commit() - helpers.show_OK("Template", "Template saved", self) - def search_playlist(self) -> None: """Show text box to search playlist""" @@ -1830,43 +1982,16 @@ class Window(QMainWindow): # Switch to correct tab if playlist_id != self.current.playlist_id: - for idx in range(self.playlist_section.tabPlaylist.count()): - if ( - self.playlist_section.tabPlaylist.widget(idx).playlist_id - == playlist_id - ): - self.playlist_section.tabPlaylist.setCurrentIndex(idx) - break + open_idx = self.get_tab_index_for_playlist(playlist_id) + if open_idx: + self.playlist_section.tabPlaylist.setCurrentIndex(open_idx) + else: + raise ApplicationError( + f"show_track() can't find current playlist tab {playlist_id=}" + ) self.active_tab().scroll_to_top(playlist_track.row_number) - def solicit_playlist_name( - self, session: Session, default: str = "" - ) -> Optional[str]: - """Get name of new playlist from user""" - - dlg = QInputDialog(self) - dlg.setInputMode(QInputDialog.InputMode.TextInput) - dlg.setLabelText("Playlist name:") - while True: - if default: - dlg.setTextValue(default) - dlg.resize(500, 100) - ok = dlg.exec() - if ok: - proposed_name = dlg.textValue() - if Playlists.name_is_available(session, proposed_name): - return proposed_name - else: - helpers.show_warning( - self, - "Name in use", - f"There's already a playlist called '{proposed_name}'", - ) - continue - else: - return None - def stop(self) -> None: """Stop playing immediately""" @@ -2074,6 +2199,10 @@ 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: + self.playlist_section.tabPlaylist.setTabIcon( + idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE) + ) else: self.playlist_section.tabPlaylist.setTabIcon(idx, QIcon()) diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 5db28ae..4762033 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -26,6 +26,7 @@ from PyQt6.QtGui import ( ) # Third party imports +from sqlalchemy.orm.session import Session import obswebsocket # type: ignore # import snoop # type: ignore @@ -74,12 +75,14 @@ class PlaylistModel(QAbstractTableModel): def __init__( self, playlist_id: int, + is_template: bool, *args: Optional[QObject], **kwargs: Optional[QObject], ) -> None: log.debug("PlaylistModel.__init__()") self.playlist_id = playlist_id + self.is_template = is_template super().__init__(*args, **kwargs) self.playlist_rows: dict[int, RowAndTrack] = {} @@ -498,7 +501,7 @@ class PlaylistModel(QAbstractTableModel): """ if not index.isValid(): - return Qt.ItemFlag.NoItemFlags + return Qt.ItemFlag.ItemIsDropEnabled default = ( Qt.ItemFlag.ItemIsEnabled @@ -772,7 +775,7 @@ class PlaylistModel(QAbstractTableModel): return None - def load_data(self, session: db.session) -> None: + def load_data(self, session: Session) -> None: """ Same as refresh data, but only used when creating playslit. Distinguishes profile time between initial load and other @@ -1061,7 +1064,7 @@ class PlaylistModel(QAbstractTableModel): # Update display self.invalidate_row(track_sequence.previous.row_number) - def refresh_data(self, session: db.session) -> None: + def refresh_data(self, session: Session) -> None: """ Populate self.playlist_rows with playlist data diff --git a/app/playlists.py b/app/playlists.py index 192f0ae..c1f63a1 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -343,7 +343,7 @@ class PlaylistTab(QTableView): Override closeEditor to enable play controls and update display. """ - self.musicmuster.action_Clear_selection.setEnabled(True) + self.musicmuster.enable_escape(True) super(PlaylistTab, self).closeEditor(editor, hint) diff --git a/app/ui/icons.qrc b/app/ui/icons.qrc index 6e5d633..9d51913 100644 --- a/app/ui/icons.qrc +++ b/app/ui/icons.qrc @@ -1,6 +1,7 @@ yellow-circle.png + redstar.png green-circle.png star.png star_empty.png diff --git a/app/ui/icons_rc.py b/app/ui/icons_rc.py index d9681af..91978ae 100644 --- a/app/ui/icons_rc.py +++ b/app/ui/icons_rc.py @@ -1645,6 +1645,1351 @@ Ou\xb6\x11\xb8\x8dU\x8d`\x86\xac%p\xfd\xedU\ \xa1A <\xfa\x84\x86\xbd\xf0x\x1b\x1a\xe8CG\x98\ \xb7\xa1\xed\x0e\xf8\xd1\xb9\xb9Ttkv\x00\x00\x00\x00\ IEND\xaeB`\x82\ +\x00\x00S\xe1\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x02\x00\x00\x00\x02\x00\x08\x06\x00\x00\x00\xf4x\xd4\xfa\ +\x00\x00\x00\xc4zTXtRaw prof\ +ile type exif\x00\x00x\ +\xdamP[\x0e\x03!\x08\xfc\xe7\x14=\x02\x02\xbax\ +\x1c\xb7k\x93\xde\xa0\xc7/\x0a6\xbbm'\xe1!\x93\ +\x0c#\xd0_\xcf\x07\xdc\x06(\x09H\xde\xb4\xd4R\xd0\ + U*5k\x14\x1dm\xe6\x842\xf3\x02\xc5\xf42\ +\x87\x0fA6b\xab\xec\x84\x16\xafi\xcd\x97P\xd4\xd4\ +\xac\xcb'!\xbd\x07\xb1_\x89*\xb1^\xbf\x84b\x11\ +\x0fG\xc3\xc2\x11B5\x84\x98\x9cH!\xd0\xfc[X\ +\xaan\xe7/\xec\x1d\xafP\x0f\x18i\x0f\xd5\xe9\x08\x7f\ +\xdf\xb2\xd9\xf5\x8el{\x98\xa8sb\xb4\xcc,n\x80\ +Gd\xe06\x9bfa\xa6\xac\xafs\x82\x96\x99S8\ +\xb1\x83\xfc\xbb\xd3\x02\xbc\x0179Y\xc2clt8\ +\x00\x00\x01\x84iCCPICC prof\ +ile\x00\x00x\x9c}\x91=H\xc3@\x1c\xc5_\ +S\xa5E*\x0e\x16\x14q\xc8P\x9d\xecbE\x04\x97\ +Z\x85\x22T\x08\xb5B\xab\x0e&\xd7Oh\xd2\x90\xa4\ +\xb88\x0a\xae\x05\x07?\x16\xab\x0e.\xce\xba:\xb8\x0a\ +\x82\xe0\x07\x88\xbb\xe0\xa4\xe8\x22%\xfe/)\xb4\x88\xf1\ +\xe0\xb8\x1f\xef\xee=\xee\xde\x01B\xb3\xcaT\xb3'\x0e\ +\xa8\x9ae\xa4\x93\x091\x9b[\x15\x03\xaf\x100\x84 \ +f\x11\x93\x99\xa9\xcfIR\x0a\x9e\xe3\xeb\x1e>\xbe\xde\ +Ey\x96\xf7\xb9?G\x7f\xbe`2\xc0'\x12\xc7\x99\ +nX\xc4\x1b\xc4\xd3\x9b\x96\xcey\x9f8\xcc\xcar\x9e\ +\xf8\x9cx\xc2\xa0\x0b\x12?r]q\xf9\x8ds\xc9a\ +\x81g\x86\x8dLz\x9e8L,\x96\xbaX\xe9bV\ +6T\xe2)\xe2H^\xd5(_\xc8\xba\x9c\xe7\xbc\xc5\ +Y\xad\xd6Y\xfb\x9e\xfc\x85\xa1\x82\xb6\xb2\xccu\x9a\xa3\ +Hb\x11K\x90 BA\x1d\x15Ta!J\xabF\ +\x8a\x894\xed'<\xfc#\x8e_\x22\x97B\xae\x0a\x18\ +9\x16P\x83\x0a\xd9\xf1\x83\xff\xc1\xefn\xcdbl\xd2\ +M\x0a%\x80\xde\x17\xdb\xfe\x18\x03\x02\xbb@\xaba\xdb\ +\xdf\xc7\xb6\xdd:\x01\xfc\xcf\xc0\x95\xd6\xf1\xd7\x9a\xc0\xcc\ +'\xe9\x8d\x8e\x169\x02\x06\xb6\x81\x8b\xeb\x8e\xa6\xec\x01\ +\x97;\xc0\xf0\x93.\x1b\xb2#\xf9i\x0a\xc5\x22\xf0~\ +F\xdf\x94\x03\x06o\x81\xbe5\xb7\xb7\xf6>N\x1f\x80\ +\x0cu\x95\xba\x01\x0e\x0e\x81\xf1\x12e\xaf{\xbc;\xd8\ +\xdd\xdb\xbfg\xda\xfd\xfd\x00\x05Dr\xe1xK\xa9Z\ +\x00\x00\x0dxiTXtXML:com.\ +adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a <\ +rdf:Seq>\x0a \x0a \ +\x0a \x0a \ + \x0a \x0a\ +\x0a \ + \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \ +\x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a\ + \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \x0a \ + \ + \ + \ + \ + \ + \ +\x0a \ + \x0a\ +\xf72JK\x00\x00\x00\x06bKGD\x00\xff\x00\xff\ +\x00\xff\xa0\xbd\xa7\x93\x00\x00\x00\x09pHYs\x00\x00\ +\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\x1b\x00\x00\x00\x07t\ +IME\x07\xe9\x02\x17\x09\x00\x1f\xc8H\xea\xd6\x00\x00\ + \x00IDATx\xda\xed\xddw\x98\x15\xd5\xf9\xc0\ +\xf1\xef\xee\x02\x0bR\x96bA\x10\x94*EDc\xc1\ +\xaeQ\xb0\xc4.\x8aF\xa3\xd8\x15{\x17QL\xd4\xc4\ +^b4\x891\x9a\xa2\xc6\xfc\xa21Q\xa3Fc\xef\ +\xd8\x0bX\x00\x05\x04\xc1\x88\x0a\x0cM\x96\xb6\xbf?\xe6\ +nD@\x04\xb6\x9c3w\xbe\x9f\xe7\xb9O\x12#\xdc\ +\xf7\x9esf\xce;g\xce\xbc\x03\x92$I\x92$I\ +\x92$I\x92$I\x92$I\x92$I\x92$I\x92\ +$I\x92$I\x92$I\x92$\xa9\x0e\x95\xd8\x04R\ +\xd1\x1f\xe3=\x80-\x81\xbe@\x17\xa0\x13\xd0\x14hQ\ +\xf8wf\x02\xb3\x81\x09\xc0\xc7\xc0\xdb\xc0\xab\xc0h\xa0\ +\xca&\x94$)\x1bJ\x81\x01\xc0\xef\x80\xc9\x85I|\ +u>\x93\x81[\x0a\x7fW\xa9\xcd*IR\x9cZ\x01\ +\x17\x16\xae\xe4\xabj\xf93\x1e\x18\x06\xb4\xb4\x99%I\ +\x8aCs\xe02 \xa9\x83\x89\x7f\xe9\xcf\x0c\xe0\x12\xa0\ +\x99\xcd.IR\x18%\xc0\xe1\xc0\x94z\x98\xf8\x97w\ +{\xe00\xbb@\x92\xa4\xfa\xb56\xf0@\x80\x89\x7f\xe9\ +\xcf\xa3@;\xbbC\x92\xa4\xba\xd7\x1f\x98\x1a\xc1\xe4_\ +\xfd\x99Z\x88I\x92$\xd5\x91s\x80\x85\x11M\xfe\xd5\ +\x9f\x05\xc0\xd9v\x8f$I\xb5\xab\x04\xb8>\xc2\x89\x7f\ +\xe9\xcf\xaf\xf1\x91AI\x92jE\x03\xe0\xff20\xf9\ +W\x7f\xee.\xc4,I\x92VS)pW\x86&\xff\ +\xea\xcf\x9d\xae\x04H\x92\xb4\xfan\xcd\xe0\xe4_\xfd\xb9\ +\xc5\xee\x93$i\xd5\x0d\xcd\xf0\xe4_\xfd9\xd7n\x94\ +$i\xe5\xed\x0b,*\x82\x04`\x11\xb0\x8f\xdd)I\ +\xd2\xf7\xebBZr\xb7\xaaH>\xd3\x81\xcev\xab$\ +I\xdf\xad\x1cx\xa3\x88&\xff\xea\xcfk\x85\xdf&I\ +\x92\x96\xe3\xea\x22\x9c\xfc\xab?\x97\xdb\xbd\x92$-\xab\ +\x1fqV\xf9\xab\xcdj\x81[\xd8\xcd\x92$}\xa3!\ +0\xaa\x88'\xff\xea\xcf\xc8\xc2o\x95$I\xa4u\xf4\ +\xabr\xf29\xc3\xee\x96$\x09\xd6\xa1\xb8v\xfd\x7f\xdf\ +'\x01\xd6\xb5\xdb\xa5\xb0\xcal\x02)\xb8\xeb\x81ms\ +\xf4{\xcb\x815\x80\x87\xedzIR^m\x00T\xe6\ +\xe8\xea\xbf\xfa3\x9f\xb4\xde\x81$I\xb9tg\x0e'\ +\xff%_\x18$IR\xeet%}4.\xaf\x09\xc0\ +B`C\x87\x81$)o\xee\xc8\xf1\xe4_\xfd\xf9\xb3\ +\xc3@\x92\x94']r~\xf5\xbf\xe4*@w\x87\x83\ +T\xffJm\x02)\x88\x0b\x81\x066\x03e\xc0\xf96\ +\x83$)\x0f\xd6'\x9f;\xffW\xf4D\x80o\x0b\x94\ +\x5c\x01\x90\x8a\xdeE@#\x9b\xe1\x7f\x1a\x02Cm\x06\ +IR\xb1_\xfd\xcf\x8f\xe1\xca{\x8f\xed\xa9\xda}\xbb\ +\xa8V\x01\xd6wxH\x92\x8a\xd5-1L\xb8\xbbo\ +G\xd5\xa2\x8e\x15U\x8b:VT\xed\xbaM4I\xc0\ +-\x0e\x0f\xa9\xfe\x94\xd8\x04R\xbd\xe9\x00\x8c%-\x85\ +\x1b\xcc\x86\x1b\xc0\xfb\x8b+\xbe\xf5\xcfz\x96&\x8c\x99\ +\x10\xbc}*\x81n\xc0$\x87\x8aT\xf7\xdc\x03 \xd5\ +\x9f\xa1\xa1'\xff\x06e\xf0\xe7\xcb\x97\xfd\xe7w_\x0d\ +\x0d\xc3?\x93P\x8eO\x04H\x92\x8aL{\xe0k\x02\ +/\xb3_p\xdc7K\xffK\x7f\xce;:\x8a\xdb\x00\ +_\x17\xdaJR\x1d\xf3\x16\x80T?n\x04N\x0b\x19\ +\xc0Z\xad\xe0\xbf\xcd+V\xf8\xef\xb4\x9b\x93\xf0\xf9W\ +Q\xb4\xd5\x19\x0e\x19\xa9ny\x0b@\xaa{m\x81\xe3\ +B\x07\xf1\xb3\x93\xbf\xff\xdf\xb9\xe8\x84(\xda\xeb\x04\xa0\ +\x9d\xc3Fr\x05@\xca\xba\xeb\x813C\x06\xd0q]\ +\x18\xdf\xb0b\xa5\xfe\xdd.\x8b\x12&L\x8e\xa2\xcd\xce\ +v\xe8H\xae\x00HY\xb5f\x0cW\xff\xc3V!\x82\ +\xf3\x8f\x89\xa2\xddN\x02\xd6u\xf8H\xae\x00HYu\ +\x0dpN\xc8\x00:\xb4\x85\x09\x8d*V\xe9\xcft^\ +\x98\xf0\xc9\x94(\xda\xee<\x87\x90\xe4\x0a\x80\x945m\ +\x80\x13C\x071\xf4\xd8U\xff3\xe7\x1c\x19\xcd*\xc0\ +\xda\x0e#\xc9\x15\x00)k\xae p\x8d\xfbu\xd7\x82\ +O\x9bT\xac\xd6\x9f\xed8/a\xf2\xd4(\xdap\x98\ +CIr\x05@\xca\xd2\xd5\xff\xc9\xa1\x83\xb8\xe0\xd8\xd5\ +\xff\xb3\xe7\x1e\x1dE;\x9e\x0a\xac\xe5p\x92\x5c\x01\x90\ +\xb2\xe2\xe7\xc0\x85!\x03h\xbb&L^\xa3\xa2F\x7f\ +G\x87y\x09S\xa6F\xd1\x96\xc3\x1dR\x92+\x00R\ +\xec*b\xb8\xfa?\xaf\x16\xae\xe0\xcf\x1e\x1cE{\x9e\ +\x06\xb4rXI\xb5\xab\xcc&\x90j\xdd0`\xb7\x90\ +\x01\xac\xd3\x06\xee\x1dSQ\xe3\xbfg\xab\xf7\x1asK\ +Y%s\xbe\x0e\xda\x9e\xe5\xc0<\xe0\x19\x87\x96\xe4\x0a\ +\x80\x14\xf3\xd5\xff\xa9\xa1\x838\xe7\xa88\xff\xae\x1a8\ +\x03h\xe9\xf0\x92j\x8f{\x00\xa4\xdau1pI\xc8\ +\x00\xda\xb4\x84\xa9-*j\xf5\xef\x5cwv\xc2\xd4i\ +Q\xb4\xede\x0e1\xc9\x15\x00)6-\x80\xd3C\x07\ +qn\x1d\x5c\xb1\x9fqx\x14\xed{\x96\xab\x00\x92+\ +\x00R\x8c.$\xdd\xb1^TW\xff\xd5\xda\xceJ\xf8\ +bz\x14m|\xb9CMr\x05@\x8aE\xd3\x18\xae\ +\xfe\xcf<\xa2\xee\xfe\xee\xd3~\x12\xcd*@s\x87\x9b\ +\xe4\x0a\x80\x14\x8b\xf3\x81+C\x06\xd0\xb29|\xd5\xaa\ +\xa2N\xbfc\xcd\x19\x09\xd3g\x06o\xeb\xa1\xc0U\x0e\ +9\xc9\x15\x00)\x86\xab\xff\xb3\x8a\xf9\xea\xbf\xda\xe9q\ +\xac\x02\x9c\x034s\xd8I\xae\x00H1LH\xd7\x84\ +\x0c\xa0\xa2\x19Lk]Q/\xdf\xd5fz\xc2\x8cY\ +Q\xb4\xf9u\x0e=\xc9\x15\x00)\x94\xc6\xc0\x99\xa1\x83\ +8\xbd\x1ew\xe9\x9fzX\x14\xed~.\xb0\x86\xc3O\ +r\x05@\x0a\xe5L\xe0\xfa\x90\x01\xb4h\x06\xd3\xeb\xe9\ +\xea?\xb2U\x803\x81_:\x04%W\x00\xa4\x10W\ +\xffg\x87\x0e\xe2\xd4C\xeb\xff;O:$\x8a\xf6?\ +\x1fh\xe20\x94\x5c\x01\x90\xea}\xee\x05~\x152\x80\ +\xa6M`\xe6Z\x15A\xbe\xbb\xe5W\x09\xb3\xe6\x04\xef\ +\x83\xd3\x80\x9b\x1c\x8a\x92+\x00R})\x07\xce\x0b\x9e\ +\x81\x04\xbc\x1f?\xe4\xe0(\xfa\xe1\x02\xd2\x95\x18I\xae\ +\x00H\xf5\xe2$\xe0\xd7y\xbd\xfa\xafV\xf1e\xc2\xec\ +\xb9Q\xf4\xc5o\x1d\x92\x92+\x00R]kH\xba\x0b\ +=\xa8\x18\xae\xc0O\x18\x14\xcd*@#\x87\xa5\xe4\x0a\ +\x80T\xe7\xf3\x1epK\xc8\x00\x1a\x97\xc3\x9cu*\xa2\ +h\x8c\xe6S\x13\xe6\xce\x8b\xa2OnuhJ\xae\x00\ +Huy\xf5\x7f\xbeW\xff\xdf8\xfe\xa0(\xc2\xb8\xd0\ +U\x00\xc9\x04@\xaaK\x83\x81N\xa1\xaf\xfe\xaf\x9c\x17\ +\xcf\xfbp\xae{\xb6\x82&\xe5\xc1\xc3\xe8\x08\x1c\xee\xf0\ +\x94L\x00\xa4\xbaPF\x04;\xff\x8f?\x10\x1a\x8c\x88\ +\xeb\xd0=f`\x14a\x0c\x03\x1a8L%\x13\x00\xa9\ +\xb6\x1d\x0et\x0b\x19@y#\xb8j\x8d\xf8\xde\x83s\ +\xe3\x0b\x154\x0e\xbf\x0a\xd0\x19\xf8\x89\xc3T2\x01\x90\ +j\xfb\xea\xff\x82\xe0W\xda\x07@\xa3\xc7\xca\xa2l\xa0\ +\xa3\xf6\x8b\x22\x8c\x8b\x5c\x05\x90L\x00\xa4\xdat(\xd0\ +=d\x00\x0d\x1b\xc0\xe5}\xe3\xadys\xedzMi\ +\xd40x\x18]\x80C\x1c\xae\x92\x09\x80T[W\xff\ +\xc3B\x07q\xf4\x01\xd0\xfc\xb7\xe5\xd16R\xe3{\x1b\ +pd\x1c\xab\x00\x17\x17\xfaL\x92\x09\x80T#\x07\x03\ +=B_\xfd_\xb6s\xfcO\xb9]\xd7c\x8d\x18V\ +\x01\xba\x01\x83\x1c\xb6\x92\x09\x80T\xd3c$\xf8\xbd\xff\ +\xc1\xfbB\x9b\xcb\xe3\x7f\xf1\xdd\x1a\x7fn\xc8\xe1{G\ +\x11\xcap\xcfo\x92\x09\x80T\x13\x07\x02\x1b\x85\x0c\xa0\ +\xac\x14\x86\x0e\xceN\xd1\xce\x9f\xef\xd3\x80\x06\xe1\x17\xe0\ +{\x16\xfaN\xd2w\xb0\x14\xb0\xb4\xe2\xe3\xe3m`\xe3\ +\x90A\x1c\xb9\x1f\xdc\xfefE\xa6\x1a\xee\xe8M\x13\xfe\ +\xfc@\xf00\xde+\xf4\xddb\x87\xb2\xe4\x0a\x80\xb4*\ +\x0e\x08=\xf9\x97\x95\xc2\x05\xc7f\xaf\xe1~~pY\ +\x0c\xab\x00\xbd\x81\xfd\x1c\xc6\x92+\x00\xd2\xaa\x1e\x1b\xaf\ +\x01\x9b\x85\x0c\xe2\xf0\xbd\xe1O\xefTd\xb2\x01\x07o\ +\x9cp\xd7C\xc1\xc3x\x07\xd8\x14\xa8rHK\xae\x00\ +H+c\xdf\xd0\x93\x7fY)\x0c;>\xbb\x0d\xf8\xb3\ +\xe3K(\x0b\x7f\x86\xe9\x0b\xec\xe3p\x96\x5c\x01\x90V\ +\xd6\xab\xc0\x16!\x038tO\xb8sdE\xa6\x1b\xf1\ +'\x1b%\xfc\xf5\x91\xe0a\xbcUH\xe6\x5c\x05\x90\x5c\ +\x01\x90Vh\xaf\xd0\x93\x7fiF\xef\xfd/m\xf8\x89\ +\xe9o\x09lS`O\x87\xb5\xe4\x0a\x80\xf4}^\x01\ +\xb6\x0c\x19\xc0\xa0\xdd\xe0\xaf\x1fT\x14Ec\x1e\xd23\ +\xe1\xde\xc7\x82\x87\xf1F!\xa9s\x15@r\x05@Z\ +\xae=BO\xfe%%p\xe1\x09\xc5\xd3\xa0?\x1d\x12\ +\xc5*\xc0f\xc0n\x0eo\xc9\x15\x00\xe9\xbb<\x0fl\ +\x172\x80\x81\x03\xe0\x9e\xd1\x15E\xd5\xa8\x07uO\xf8\ +\xc7\x13\xc1\xc3\x18\x01l\xed\x10\x97\x5c\x01\x90\x96\xb6k\ +\xe8\xc9\xbf\xa4\x04.8\xae\xf8\x1av\xf8\x89\xe9o\x0b\ +l+`\x80\xc3\x5cr\x05@Z\xdas\xc0\xf6!\x03\ +\xd8o\x17\xb8olEQ6\xee\x01]\x13\x1ex*\ +x\x18/\x03\xdb8\xd4%W\x00\xa4j;\x87\x9e\xfc\ +\x01\x86\x1dW\xbc\x0d\xfc\xd3!Q\xac\x02l]\xe8k\ +\xc9\x15\x00\x9b@\x02\xe0\x19`\xc7\x90\x01\xec\xb5#<\ +0\xbe\xa2\xa8\x1by\x9f\x0d\x12\x1e~.x\x18/\x12\ +\xf8V\x8f\xe4\x0a\x80\x14\x87mCO\xfe\x90\xde'/\ +v\x97\x9c\x12\xc5*@\x14\xfd-\xb9\x02 \x85\xf7$\ +\x81\x97\x85\xf7\xd8\x1e\x1e\xfa\xa4\x22\x17\x8d\xbdg\xc7\x84\ +G_\x88\xa2\xcf\xfb;\xf4\xe5\x0a\x80\x94_Q\xdc\x13\ +.\xe6{\xffK\xfb\xe9\x90(\xc2\xd8\x85\x08\xf6|H\ +\xae\x00H\xe1\xbd\x89`\x99\xf7\xe2\x13\xed\x88\xa5ER\x09\ +\xf1\x00`c{C\xae\x00H\xc5\xe7o\xc0\xa0\x90\x01\ +\xfc\xa0\x17\xbc6\xdb\xab\xff\xe5\xd9\xba\x22\xe1\xd5\x91\xc1\ +\xc3\xb8\x078\xd8\xde\x90+\x00R\xf1\xe8I\x04\xcb\xbb\ +\x97x\xef\xff;E\xb2\x17\xe0@\xd2}\x22\x92+\x00\ +R\x91\xb8\x1b\xf8q\xc8\x006\xed\x09\xaf\xcf\xf1\xea\x7f\ +E\xfa5Ox\xfd\xbd(\xc6\xcaa\xf6\x86\x5c\x01\x90\ +\xb2\xaf\x1b\x81\x97\xfe!\x9a\xb7\xe0E\xed\xc2\xe3\xa3\x08\ +\xe3`\xa0\x87\xbd!W\x00\xa4\xec\xbb\x038|X\xe5\ +\xd5\x7fM\xf4*M\x18=!x\x18\xbf\x03L\xe5\xe4\ +\x0a\x80\x94\x01\x1d\x08\xbc\xf1\xcf\xab\xff\xdaqa\x1c\xd5\ +\x01\x8f\x06\xd6\xb77\xe4\x0a\x80\x14\xbf_\x03A\x1f\xbc\ +\xeb\xd2\x01\xc6\x94x\xf5_\x1bz\x96&\x8c\x09\xbf\x0a\ +\xf0\x1b\xc0B\xcer\x05@\x8a\xd8\xbaD\xf0\xe8V$\ +u\xed\x8b\xc2\xd0c\xa2\x08\xe3\x18`={C\xae\x00\ +H\xf1\xfa\x15pj\xc8\x00:\xaf\x07cK\xbd\xfa\xaf\ +M=J\x12\xc6~\x12\xc5\xd8:\xdd\xde\x90+\x00R\ +|\xda\x02\xc1\xf7\x8eG\xf2\x0c{Q9\xef\xe8(\xc2\ +8\x1ehoo\xc8\x15\x00)>\xd7\x03g\x86\x0c\xa0\ +\xe3\xba0\xbe\xa1W\xffu\xa1\xeb\xa2\x84\xf1\x93\x83\x87\ +q\x03p\x96\xbd!W\x00\xa4x\xacC\xe0\xc7\xfe\x00\ +\x86Y2\xa6\xce\x9c\x1f\xc7^\x80!@;{C\xae\ +\x00H\xf1\xb8\x068'd\x00\x1d\xda\xc2\x84F^\xfd\ +\xd7\xa5\xce\x0b\x13>\x99\x12<\x8ck\x81s\xed\x0d\xb9\ +\x02 \x85\xd7\x86\x08\x0a\xb5D\xf2\x16\xbb\xa2vn\x1c\ +\xaf\xe6\x19\x02\xacmo\xc8\x15\x00)\xbc+\x81\xf3C\ +\x06\xb0\xeeZ\xf0i\x13\xaf\xfe\xeb\xc3\xfa\x95\x09\x9f~\ +\x1e<\x8c\xab\x80\xa1\xf6\x86\x5c\x01\x90\xc2^\xfd\x9f\x14\ +:\x88\x0b\xbc\xfa\xaf\xbfU\x808\x9e\x088\x05X\xcb\ +\xde\x90+\x00R8?\x07.\x0c\x19@\xdb5a\xf2\ +\x1a^\xfd\xd7\xa7\x0e\xf3\x12\xa6L\x0d\x1e\xc6/\x80\x8b\ +\xec\x0d\xb9\x02 \xd5\xbf\x96DP\x9e5\x92g\xd4s\ +\xe5\x9c#\xa3\x08\xe3T\xa0\xb5\xbd\xa1\xac*\xb3\x09\x94\ +a\x17\x00\xbb\x85\x0c`\x9d6p\xef\x18\xaf\xfe\xeb\xdb\ +V\xa3\x1a\xf3\xfbF\x95\xcc\x9e\x1b4\x8cr\xa0\x12x\ +\xc6\x1e\x91+\x00R\xfd\xa9 p\xc9_\x80s\x8e\xb2\ +#B9\xf3\x88(\xc28\x1dheo\xc8\x15\x00\xa9\ +\xfe\x0c\x05\xf6\x08\x19@\x9b\x96\xf0\x8f\x8f\xbc\xfa\x0fe\ +\x9b\xf7\x1bsKY%s\xbe\x0e\x1aFc`.\xf0\ +\xac=\x22W\x00\xa4\xba\xd7\x028-t\x10\xe7z\xf5\ +\xef*@\xea,\xd2\xfd(R\xa6\xf8\x14\x80\xb2\xe8B\ +\xd2\xdd\xffA\xaf\xfe\xa7\xb6\xf0\xea?\x06mg%|\ +1=x\x18\x17\x91>\x15 \xb9\x02 \xd5\x91\xa6D\ +\xf0J\xd6H\xae<\x05\x9c~x\x14a\x9c\x094\xb7\ +7\xe4\x0a\x80Tw\x86\x02W\x84\x0c\xa0es\xf8\xaa\ +\x95W\xff1Y+I\x98\x96\x04\x0f\xe3\x02\xd2\xaa\x94\ +\x92+\x00R\x1d\x5c\xfd\x9f\x19:\x08\xaf\xfe#\x5c\x05\ +\xf8I\x14a\x9c\x034\xb37\xe4\x0a\x80T\xfb\xce\x05\ +\xae\x0e\x19@E3\x98\xd6\xda\xab\xff\x18\xad9#a\ +\xfa\xcc(\xc6\xe8\xb5\xf6\x86\x5c\x01\x90jO\xe3\x18\xae\ +\xfe#\xb9\xdf\xac\xe58\xf5\xb0(\xc28\xdfU\x00\xb9\ +\x02 \xd5\xae3\x81\xebC\x06\xd0\xa2\x19L\xf7\xea?\ +jm\xa6'\xcc\x98\x15<\x8c\xb3\x80\x1b\xec\x0d\xb9\x02\ + \xd5\xce\xd5\xff9\xc1\xaf0\x0f\xb5#bw\xf2\x8f\ +\xa3\x08\xe3<\xa0\x89\xbd!W\x00\xa4\x9a;\x0d\xb81\ +d\x00M\x9b\xc0\xcc\xb5\xbc\xfa\xcf\x82\x96_%\xcc\x9a\ +\x13<\x8c\xd3\x81_\xd9\x1br\x05@Z}\xe5\xa4\x1b\ +\xab\xc2^\xfd\x1ffGd\xc5I\x87D\x11\xc6PW\ +\x01\xe4\x0a\x80T3'\x037{\xf5\xafUQ\xf1e\ +\x12\xfaM\x81\x00\xa7\x00\xbf\xb67\xe4\x0a\x80\xb4\xea\x1a\ +\xc6p\xf5?\xe4`;\x22kN\x1c\x14E\x18\xe7\x93\ +\xae`I\xae\x00H\xab\xe8\x04\xe0\x96\x90\x014.\x87\ +9\xebx\xf5\x9fE\xcd\xa7&\xcc\x9d\x17>\x17\x01~\ +go\xc8\x15\x00i\xd5\xae\xfe\xcf\xf7\xea_\xab\x9d=\ +\xc6\xb1\x0a0\x0chdo\xc8\x04@Zy\x83\x81N\ +\xa1\xaf\xfe\xaf\x9c\xe7\xfb]\xb2\xea\xdag*h\x12~\ +\x01\xbe#`\xf1h\x99\x00H+\xa9\x8c\xf4Y\xea\xa0\ +\x8e?\x10\x1a\x8c\xf0\x10\xc9\xb2c\x0f\x8c\x22\x8c\x8b\x5c\ +\x05\x90\x09\x80\xb4r\x8e\x00\xba\x85\x0c\xa0\xbc\x11\x5c\xb5\ +\x86\x15]\xb3\xee\x97\xcfG\xb1\x0a\xb0>\xe0\x83\xa4\x8a\ +\x8e\x9b\x00\x15r\xec\xadW\x98\xe8\x97\xfct\x07:\x87\ +\xbeb:\xe9\x10\xb8\xe9%7\xff\x15\x83S\xb7I\xf8\ +\xcd\xff\x05\x0fc>0\x0e\x18\x03\x8c]\xea\xf3)P\ +eO\xc9\x04@\xc5\xa6UaB\xef\x0c\xf4\x06z\x15\ +\xfe\xfb\x86D\xfa\xd2\x94\x86\x0d\xe0\x8b\xcb\x1a\xd3\xfc\xb7\ +>\xc1U\x0c\xe6\x1d\xb4\x90\xd67\xcd\xa1r~\xb4!\ +\xce/$\x01\xe3\x80\xf7\x81\xf7\x0a\xff}\x1c0\xde\xe4\ +@&\x00\xca\xca$\xbf\xe4d\xdf\x07h\x91\xb5\x1fs\ +\xc2 \xf8\xcd\x08\xaf\xfe\x8b\xc9I[%\xfc\xee\x9eL\ +\x86^\x09L^NbP\xfd\x91L\x00T\xe7Z\x02\ +]\x963\xd1o\x04\xb4-\x96\x1f\xd9\xb0\x01L\xbe\xb9\ +\x11m.\xb7\x8ak1\x99;x\x01m\xae\x9c\xcb\xfc\ +\x05E\xf5\xb3f\x00\x1f/')\x18\x05\xfc\xd7^\x97\ +\x09\x80VE9\xd0\x9eo/\xd5W\x7f:\xe5a\xbc\ +\x1c;\x10~\xf7\x9aW\xff\xc5\xe8\x84-\x12n\xbb/\ +7?w\xfaRIA\xf5\x0a\xc2X`\xa6\xa3A&\ +\x00\xf9\xd4\x88t\xf3]\xe7\xe5L\xf4\x1b\x90\xe3'C\ +\xcaJa\xf4\xfd%t:\xa5\x85\xa3\xa4\x08\xcd\x1aR\ +\xc9Z\xc3\xe7\xb1`a\xee\x9bb\xfar\x12\x83q\xc0\ +h`\xb6#\xc5\x04@\xd9\xd6\x10\xe8\xc0\xb2\xf7\xe4{\ +\x91>\x92Tf\x13-\xeb\xc8\xfd\xe0\xf67\xbd\xfa/\ +f\xc7\xfc \xe1O\xf7\xdb\x0e\xdf\x93\x1c,o\xbf\xc1\ +\x07\xc0\x5c\x9b\xc7\x04@\xf1h\xc7\xb2K\xf5\xbdI\x1f\ +\xa5k`\xf3\xac\xda\xd5\xff\xfb\x0fB\xd7\x93L\x00\x8a\ +\xd9\x94+g\xd3\xe9\xf0E,\x5cd[\xac\x86\xcf\x96\ +\x93\x18\xbc_X9p]\xc5\x04@u\xa0\x15\xcb\xbf\ +'\xdf\x0b\xdf7^k\x0e\xdf\x1b\xfe\xf4\x8e\x93\x7f\x1e\ +\x1c\xd97\xe1\xce\x7f\xd9\x0e\xb5h\x010\x89\xe5\xef9\ +\xf8\x040\xdd2\x01\xd0\xf7L\xf2\xcb{V\xbe\x07\xd0\ +\xd4\xe6\xa9\xfb\xab\xffQ\x0f@\xf7\x93M\x00\xf2`\xfc\ +\xcd3\xe9\xb1\x7f\x95\xab\x00\xf5c\xc9\x1a\x07K\xef9\ +\x98\x00,\xb6\x89L\x00\xf26\xc9/9\xd1w\x07|\ +\xe3L@\x87\xee\x09w\x8et\xf2\xcf\x93\xc3\xfb$\xdc\ +\xfd\xb0\xed\x10\xd8\x8aj\x1cX\x00\xc9\x04 \xf3\x93|\ +\xf5D\xbf\x11\xe0\x0c\x13\xa1\xd2Rx\xe7>\xe8u\xba\ +\xdd\x93'\xa3oN\xd8h?X\xec\xf5g\xac\xe6\x15\ +\x12\x81\xa5\x13\x83\xf7H\xf7\x22\xc8\x04 \x88r\xa0+\ +\xcb\xde\x93\xaf\xfe(C\x06\xed\x06\x7f\xfd\xc0\xc9?\x8f\ +~\xdc3\xe1\x9e\xc7l\x87\x0cZ\xba\xc6A\xf5\xad\x85\ +w\xb1\xc6\x81\x09@-X\xd1\xb3\xf2\x9dl\xb7\x22\x19\ +\xfc%\xf0\xf6}\xb0\xd1\x19&\x00y\xf4\xe1M\x09}\ +\xf6w\x15\xa0\x88\x93\x03k\x1c\x98\x00|\xa7\xa5\x9f\x95\ +_r\xa2\xdf\x00_\x95\x5c\xf4\x06\x0e\x80{F;\xf9\ +\xe7\xd9\xa0\x0d\x13\xee{\xdcv\xc8Yr\xb0\xf4\x9e\x83\ +\xdc\xd68(\xf6\x04\xa0\x01\xd0\x91\xe5\xdf\x97\xdf\x10\x0b\ +\xe2\xe4\xfa\xea\xff\xb5\xbf\xc1\xa6\xe7\x98\x00\xe4\xd9\xfb7\ +&\xf4\x1d\xe8*\x80\x96[\xe3\xa0z\xcf\xc1<\x13\x80\ +\xb8-\xaf N\xf5D\xdf\xd8\xb1\xad\xa5\xed\xb7\x0b\xdc\ +7\xd6\xc9_0\xb0[\xc2\xfdO\xda\x0eZ\xae\x85\xc0\ +D\x96\xbf\xe7\xe0C2^\xe3 K\x09@+\x96\x7f\ +O\xdeg\xe5\xb5\xca^\xfd?\xd8\xec<\x13\x00\xc1;\ +\xd7%l6\x08\xaa|\xe8L\xabf\xe9\x02H\x99\xab\ +q\x10[\x02\xf0]\x05q|V^\xb5f\xaf\x1d\xe1\ +\x81\xf1N\xfe\xfa\xc6\xbe\x9d\x12\x1ez\xd6vP\xadY\ +\xb2\x00\xd2\xd2{\x0e\xa2\xa9q\x10*\x01h\x09\xf4\x03\ +\xfa\x16&\xf9\xea\xfa\xf5\xbe\x86Mu\xee\x95\xbf\xc2\xe6\ +\xe7\x9b\x00\xe8\x1bo\x5c\x9d\xb0\xe5!\xb6\x83\xea\xc5L\ +`L!)x\x8f\xf4\xf1\xc5W\x80\x19\xc5\x9a\x00T\ +\x00\xbb\x00\xbb\x01\xdb\x01=\xf1Q:\x05\xb0\xf7Np\ +\xff8'\x7f-k\xbf\xce\x09\xffz\xc6vP\x10\x8b\ +I\xf7\x14<\x0f<\x06\xa9\xe0\x94C\ +m\x07E\xab;\xf0w\xe09\xa0O}&\x00\x8d\x80\ +\x9f\x01\xaf\x93\xee\xe8\x97\xa2\xd3y=\xf8\xf7-\xf0\xcb\ +\xe7\x9d\xfc\xb5zn|\xa1\x82G~\x0b\x9d\xda\xdb\x16\ +\x8a\xd6\xf6\x85\xb9\xf8\xa7\xa4\xef\xb7Y%\xabz\x0b`\ +}\xd2{\x0f[\xda\xee\x8a\xd1\xe6\xbda\xc8!p\xe4\ +\xef\x9d\xf8U{\xfexl\xc2-\x7f\x83\xd7\xdf\xb3-\ +\x14\xadW\x80A\xa4\xa5\x8bk=\x01\xd8\x15\xf8+\xe9\ +\xe3}RP\xa5\xa5\xd0\xa1-t[\x1fztJ'\ +\xfe\x1f\xb5iD\x9b\xcb\x9b\xd88\xaa3S/\x9d\xc3\ +c\x93\x17\xf2\xfa{\xf0\xe1x\xf8h\x22L\xfc\xcc\x97\ +\x09)\x1a_\x91>z\xffDm&\x00G\x00\xb7\xad\ +\xce\x12\x83T\x13\xadZ\xa4\xcb\xf9\xbd\xba@\xef\xae\xe9\ +\x7f\xef\xdc\xc1\xb7\xf8)\xbe\xc4`\xd4\x82\x85\x8c\xfb\x14\ +\xde\xfb\x08\xde\x1f\x07\xe3&\xc1\x84)&\x07\xaaw\x0b\ +I\x1f\xc5\xbf\xbd6\x12\x80\x93\x81\x9b\xb0r\x9f\xea\xc8\ +\xda\xad\xd3+\xf9\xee\x1b@\xb7\x8e\xe9\x7f\xef\xda\x116\ +>\xcbI^\xd97\xf2\x86\x84\xb1\x9f\x90~&\xa6\xff\ +9f\x02L\x9df\xdb\xa8\xceT\x01C\x80\xdf\xd5$\ +\x018\x16\xb8\xd5\xc9_5\xd5\xba\x22\x9d\xd8\xbbu,\ +L\xf4\x85I\xfe\x07\xe7:\xc9+\xbf\xde\xbc&\xe1\xa3\ +%\x92\x82\xea\x04aZb\xdb\xa8\xc6\x16\x03\xc7\x00\x7f\ +Z\x9d\x04`\x00\xf0\x08\x96\xf0\xd5J*o\x04]:\ +,\xb1T_\xf8l\xd4\xb0\x01k_\xec\x1b\x9b\xa5U\ +\xf1\xd6\xb5\x09\xe3&\xc1\xb8O\xf9\xdf\xad\x85Qc!\ +\x99m\xdbh\xa5-\x04v'}\xb7\xc0J'\x00]\ +\x81WI_\xcf+\xfd\xcf\x1a\x8d\x0bW\xf2\x85+\xf8\ +\xea\xab\xfam\x87{%/\xd5\x97\x17/K\xfe\xb7Z\ +P\xbd\x820\xf6\x13\x98;\xcf\xb6\xd12\xa6\x91>\xb9\ +\xf7\xf1\xca$\x00\x0d\x80\x17H_\xd7\xab\x1cj\xd8 \ +\xdda\xdf\xb9\xc3\x12\x1b\xf0\xbax%/ee\xe5\xe0\ +\xbd\x8f\xe0\xfd\x8f\x0b\xab\x07\x93\xd2'\x16\xe6|m\xdb\ +\xe4\xd8\xeb\xc06\xc0\x82\xefK\x00~JZ\xe8GE\ +>\xc9o\xd0\xbe\xb0\xf9n\xfdo\xae\xea7\x9bSN\ +\xcb\xeb\x1b\xdb@R\x91\x99q\xd6<\xdehZ\xf9\xbf\ +\xd5\x821\x85\xff\x9c0\xd97 \xe6\xc4E\xc0/V\ +\x94\x00t\x06\xde\x03\x9c\x01\x8a\xc4\xbak-{O\xde\ +\xc7\xe8$-i\xc9\xc7\x18\x97\xdcs0z<,\xf2\ +1\xc6b\xf15\xd0\x0b\x98\xf0]\x09\xc0\xdf\x81\x81\xb6\ +Sv\x94\x94\xc0z\xeb|s\x05\xdf\xadczo~\ +\xc3N\xd0\xfdd'yI53\xe6\xd7I\xfa\x84\xc2\ +\x12\x8f1\x8e\xfd\x04>\xfd\x1c\xaa\xaal\x9f\x8c\xb9\x87\ +\xf4\xcd\xbd\xcb$\x00\x1b\x03o\xe3#\x7fQ\xaa.\x88\ +\xd3\xb9\xc37\xf7\xe4\xbd\x92\x97\x14\xc3\xca\xc1{\x1f\x7f\ +{\xcf\xc1\xf8\xc9&\x07\x91\xaa*\xcc\xf5\xa3\x96N\x00\ +\xfe\x02\xf8\xfe\xab\x80\xd6l\xf5M!\x9c\xee\x1b\x14v\ +\xd9w\x84M\x9c\xe4%e\xcc\xdb\xd7\xa6O*|4\ +\x91o\xad |9\xdd\xb6\x09\xec\x0e`\xf0\x92\x09@\ +k`\x0aPn\xdb\xd4\xad\x96\xcd\xd3g\xe5\xabw\xd8\ +W\x7fv\xbe\xccI^R>,]\xe3`\xdc\xa7i\ +\x8d\x83\xff~i\xdb\xd4\x83\xf9@;\xe0\xab\xea\x04\xe0\ +d\xe0f\xdb\xa5v\x947\x82\xf6k/U\xbf\xde\x82\ +8\x92\xb4J\xc9A\xf5\xe3\x8cc'\xc2L\x0b \xd5\ +\xa6\x93\x80\xdfV'\x00O\x03;\xd9&+\xafq\xf9\ +7K\xf4\xdd\x96x\x8cn\xbb?\xb5\xa0d\x9c\xdb(\ +$\xa96Uu\xae\xe2\x85#g~\xf3^\x85\xea\x22\ +H\x13a^\xa5\xed\xb3\x8a\x9e\x06v.\x01\x9a\x03_\ +\x02\x8dl\x93o[\xba NuQ\x9c-\x9ay%\ +/I1y\xe9\xe7\xc97\x1b\x11\x0b\x9b\x11?\x18g\ +u\xc4\xef0\x1fhS\x02\xfc\x08x8\xaf\xad\xd0\xa0\ +\x0c\xd6o\xf7\xcd\x15|uQ\x9c\xde\xcd\xcbh7\xb4\ +\x99\xc3D\x922l\xea\xa5sx\xa7r\xe1\xb7\x8a\x1f\ +\x8d\xfd\x04>\x99\x02\x0b\x17\xe5\xbaiv/\x01.\x06\ +.)\xf6_\xda\xaa\xc5\xb2\xf7\xe4}\x8cN\x92\xf2\x9d\ +\x1c,Y\x00\xa9z\xcf\xc1'SrQ\x00iX\x09\ +ET\xfc\xa7\xfd\xda\xcb\x7fQM\xaf\xd3\x9d\xe4%I\ ++\xef\xfd\x1b\x97\xff\xc2\xa5\xc9S\x8b\xe6'\xdeSB\ +\xfa\x92\x80\xcd\xb2t%_}\xf5\xfe\xbf\x17\xd5t\xf5\ +J^\x92T\x7f+\x07\xaf\xcd^\xb8\xcc\x9e\x83\x8c\x15\ +@\x1aQ\x02L&}&0\x1a\xcd\x9bB\xcf\xce\xcb\ +\xbe\xa8\xe6\x07\xe7:\xc9K\x92\xe2\xf5\xe65\xc92/\ +\x5c\xfa`\x1c\xcc\x9a\x13]\xa8\x93J\x809\xc0\x1a!\ +\xa3\xe8\xb8.\xec\xb9\x03\xec\xb4\x05l\xd6\xbd\x84N\xa7\ +\xb4p\x14I\x92\x8a\xc6\xf8\x9bg\xf2\xc6\x98*\x9ey\ +\x0d\x1ez\x16&\xfd7xH\xb3JH\x1f\x07hX\ +\xdf\xdf\x5cV\x0a\x07\xef\x01\xa7\x1e\x0a[^\xe0\x95\xbd\ +$)?^\xb9\x22\xe1\xa6\xbf\xc0=\x8f\x06\xdbpX\ +Y\x02,\x00\x1a\xd4\xf77\xff\xedZ8\xf0WN\xfc\ +\x92\xa4\xfc\xba\xf7\xb4\x84C\xce\x09\xf2\xd5\x0bJ\x81\x99\ +!\xbe\xf9?/\xd9\xf1\x92\xa4|{\xf4\x85`_\x9d\ +\x94\x023B|\xf3\x1f\xefOk>K\x92\x94Go\ +\x5c\x9dp\xc7\x83\xc1\xbe~z\x09\xf0\x0c\xb0c\x88o\ +\xdfp\x03x\x7f\xb1\xb7\x01$I\xf9\xd3\xa7aZ\xbe\ +8\x90'K\x81\xb1\xa1\xbe}\xf4\x04\x18\xbc\xb1\xab\x00\ +\x92\xa4|9\xbcO\xd0\xc9\x1f\xe0\xe3R\xe0\xbd\x90\x11\ +\xdc\xf5\x10\x5c\xb2\xa7I\x80$)\x1f.\xde=\xe1\xee\ +\xf0o\xe0y\xaf\x14\x18\x11:\x8aK\x7f\x0b\xd7\x1dd\ +\x12 I*n\xbf>,\xe1\x17\xb7F\x11\xca\xcb%\ +@90\x1dh\x122\x92\x92\x12\xb8i\x18\x0c\xb9\xd3\ +=\x01\x92\xa4\xe2\xf3\xdb\xc3\x13N\xbd<\x8ar\xc1s\ +\x81V\xa5@%\xf0t\xe8h\xaa\xaa\xe0\xd4\xcb\xe1\x97\ +\x07\xbb\x12 I*.\xb7\x1c\x11\xcd\xe4\x0f\xf0\x040\ +\xbf\xac\xf0?Z\x00{\xc5\x10\xd5\x7f^\x82\x06\x87W\ +\xb2\xc3\xe8\xc6\x8e\x18IR\xe6]wP\xc2\x99WE\ +\x15\xd2\xf5\xc0\x1b%\x85\xff\xb16\xf0)\x01J\x02\x7f\ +\x97\xf3\x8f\x81\xcb\x1f\xf7v\x80$)\xbb\xae\x19\x980\ +\xf4\x86\xa8BZ\x00\xb4\x07\xbe\xa8^\x01\x98C\xfaJ\ +\xe0\x1e\xb1D\xf8\xe2[0o`%\xfd\xc7\xb9\x12 \ +I\xca\x9eK\xf6L\x18~Sta=\x00\xfc\x11\xa0\ +l\x89\x7f8\x1b84\xa6(_|\x0b\xe6\xee_\xc9\ +\x80\xf1&\x01\x92\xa4\xec8\x7f\xe7\x84+n\x8b2\xb4\ +\xb3\x81\x8f\x00J\x96\xf8\x87%\xc0(\xa0Wl\xd1\x9e\ +8\x08~=\xc2\xdb\x01\x92\xa4\xf8\x9d\xbdc\xc2/\xef\ +\x8c2\xb4Q\xc0\xc6@\xd5\xd2+\x00\x90>\x11\xb0O\ +l\x11\xbf\xfe\x1eL\xdd\xad\x92=?u%@\x92\x14\ +\xaf\x93\xb7J\xb8\xf9\xaf\xd1\x86w>\xf0\xf6\x92W\xfd\ +Kj\x00\x8c$\xa2\xbd\x00K:l/\xb8\xe3]W\ +\x02$I\xf19n\xb3\x84?\xfc3\xda\xf0\xc6\x00\xbd\ +\x81\x85\xd5\xff`\xe9\x15\x80\xc5\xc0\x97\xc0\x811F?\ +r\x0c\x8c\xd9\xa6\x92\x81_\xb8\x12 I\x8a\xc7\xd1\x9b\ +&\xfc\xe9\xfe\xa8C<\x9e\xa5J\xff\x97,\xe7_*\ +\x01\x9e\x07\xb6\x8d\xf5W\x1c\xbc;\xdc\xfd\xbe+\x01\x92\ +\xa4\xf0\x0e\xed\x95\xf0\xb7G\xa3\x0e\xf1Y\xe0\x87\x14\xee\ +\xfd\xaf(\x01\xa0\xb0L\xf0\x16\x11\xd5\x05X\xda^;\ +\xc2\x03\xe3M\x02$I\xe1\x1c\xd4=\xe1\x1fOD\x1d\ +\xe2|`S\xe0\xfd\xa5\xff\x8f\xb2\xef\xf8\x03_\x00\x8d\ +\x81\xedc\xfdEc>\x8177\xa9\xe4\xc73\xbc\x1d\ + I\xaa\x7f\xfbvJx\xe0\xe9\xe8\xc3\xbc\x02\xb8g\ +y\xffG\xc9\x0a\xfePC\xe0E`\x8b\x98\x7f\xd9n\ +\xdb\xc2#\x93\x5c\x09\x90$\xd5\x9f\xfd:'\xfc\xeb\x99\ +\xe8\xc3|\x13\xd8\xba\xb0\x0a\xb0J\x09\x00@O\xe0u\ +`\x8d\x98\x7f\xe1.[\xc1\x7f\xa6\x98\x04H\x92\xea\xde\ +\xae\xed\x12\x9e\x1c\x11}\x98\xd5\x15~G\x7f\xd7\xbfP\ +\xfa=\x7f\xc1\x07\xc0q\xb1\xff\xca'G\xc0\x8ek\xfa\ +\x16AIR\xdd\x1a\xb0n&&\x7f\x80\x93V4\xf9\ +\xafL\x02\x00p7\xf0\xab\xd8\x7f\xe9\x0boB\xbf\xe6\ +&\x01\x92\xa4\xba\xb1]\xab\x84\xa7^\xc9D\xa8\xd7\x03\ +w|\xdf\xbfT\xb2\x92\x7fYC\xe0!`\xd7\xd8\x7f\ +\xf5\x16\x1b\xc1\x88\x99\xde\x0e\x90$\xd5\x9e\xadZ$\xbc\ +6*\x13\xa1>\x0a\xec\xcd\x12\x05\x7fj\x9a\x00\x004\ +'}\x96p\xd3\xd8\x7f\xfd&=\xe0\x8d\xb9&\x01\x92\ +\xa4\x5cM\xfeo\x02;\x92\xbe\xdc\xef{\x95\xac\xe2_\ +\xde\x0ex\x0e\xe8\x12{+\xf4\xe9\x06oW\x9a\x04H\ +\x92jpAY\x9e0rl&B\xfd\x08\xd8\x01\xf8\ +le\xff@\xe9*~\xc1\x14\xd2jB\xe3co\x89\ +\x91c\xa1Wi\xc2\xc2\xad\x16;\x82%I\xab\xaco\ +v&\xff\x89\xc0\x80U\x99\xfcW'\x01\x00\x98\x04\xec\ +R\xf8\xc2\xa8\x8d\x9e\x00}^\x9d\xc5\xfc\xdd\x169\x92\ +%I+e\xfen\x8b\xe8Y\x9a0*;\x93\xffN\ +\xc0\x84U\xfd\x83\xa5\xab\xf9\x85\xe3I\xab\x04~\x1c{\ +\xcb\x8c\x99\x00=\x1e\x9e\xcd\xd4K\xe78\xaa%I+\ +4w\xf0\x02\xfa<6\x9b1\x132\x11\xee'\xd4`\ +U\xbe\xb4\x06_<\xb1\xf0\xc5\xd1\xe7H\x9fL\x81~\ +\x17,d\xd2\xf5\xb3\x1c\xdd\x92\xa4\xe5\x9aq\xd6<\xfa\ +\xfeq.\x1fM\xccD\xb8\xe3\x0bs\xf0\xb8\xd5\xfd\x0b\ +Jj!\x88u\x80'\x80\x8dbo\xad\xb6k\xc2\x93\ +\xb7C\x8fS\xdd\x1c(I\xfa\xc6G\xbfI\xd8\xe5\x18\ +\xf8\xf4\xf3L\x84;\x9a\xf4V\xfc\xe4\x9a\xfc%\xa5\xb5\ +\x10\xc8\xe7\x85@F\xc6\xdeb\xff\xfd\x12v>\x1a\xde\ +\xbf\xd1\x82A\x92\xa4\xd4\x98_'\xfc\xf0\xe8\xccL\xfe\ +\x1f\x14\xae\xfc'\xd7\xf4/*\xad\xa5\x80\xa6\x92>{\ +\xf8Z\xec-\xf7\xf9W\xb0\xcb10\xea\x97&\x01\x92\ +\x94w\xa3oN\xd8\xf9h\x9825\x13\xe1\xbe]\x98\ +k?\xab\x8d\xbf\xac\xb4\x16\x03\x9bNZ)0\xfaB\ +\x89S\xa7\xc1NG\xc2\x1bW\x9b\x04HR^\xbd{\ +}\xc2\x0e\x83\xe1\xb3/2\x11\xee\x9b@\x7f\xa0\xd6\xa2\ +-\xad\xe5\x00g\x00\xbb\x01/\xc7\xde\x92\xd3g\xc2\xae\ +\xc7\xc1\xabW\x98\x04HR\xde\xbcumB\xffc\xe1\ +\xcb\xe9\x99\x08\xf7u\xd2\xe7\xfc\xbf\xaa\xcd\xbf\xb4\xb4\x0e\ +\x02M\x0a\x81>\x1d{\x8b\xce\x98\x05\xbb\x9f\x00#.\ +7\x09\x90\xa4\xbcx\xf3\x9a\x84\x01\xc7\xc2W32\x11\ +\xee\x0b\xa4\xfb\xec\xa6\xd5\xf6_\x5cZG\x01\xcf\x01\xf6\ +\x22}: j\xc9\xect%\xe0\x99\x9f\x9a\x04HR\ +\xb1{\xe9\xe7\xe9=\xff\xe933\x11\xees\xc0\x8f\x80\ +:\x89\xb6\xb4\x0e\x03\x9f[H\x02\x1e\x8a\xbd\x85\xe7|\ +\x0d{\x9f\x0cO\x0e7\x09\x90\xa4b\xf5\xfc\xa5\x09{\ +\x9c\x08\xb3\xb2Q\x17\xee\x19`O\xa0\xce\x0a\xd8\x94\xd6\ +\xf1\x0f\xa8\x04\x06\x02\x0f\xc4\xde\xd2s\xe7\xa5I\xc0\xc3\ +\xe7\x99\x04HR\xb1y\xfc\xc2\x84=N\x80\xd9s3\ +\x11\xee\xbf\x81=X\xc9\xb7\xfa\xc5\x9a\x00\x00\xcc\x07\x06\ +\x01\xff\x88\xbd\xc5+\xe7\xc3\x81g\xc2\x83g\x9b\x04H\ +R\xb1xth\xc2~\xa7\xc1\xd7\x95\x99\x08\xf7a`\ +\x7f`^]\x7fQi=\xfd\xa0\xea$\xe0\xae\xd8[\ +~\xfe\x02\x18t6\xfc\xf3L\x93\x00I\xca\xbaG\xce\ +O8\xe0\x0c\x98\x97\x8d\xc9\xff\xde\xc2\xe4_/\xd1\x96\ +\xd5\xe3\x0f\xab\x02\x1e\x04\xd6\x076\x89\xb9\x07\x16/\x86\ +\xfb\x1e\x87.C+\xe9\xf3Zc\x8f I\xca\xa0\xbf\ +\x9f\x96p\xf09\xe9\x85]\x06\xfc\x1f\xf0\x13`a}\ +}aY=\xff\xc0\xea$\xa0#\xb0i\xcc=QU\ +\x05\x0f>\x05\x1b\x9c[I\xdf7L\x02$)K\xee\ +95\xe1'Caa6\xde\x06\x7f7pD}N\ +\xfe!\x12\x80%\x93\x806\xc0\x96\xb1'\x01\x0f<\x0d\ +k\x9fZ\xc9\xe6\xef\x98\x04HR\x16\xfc\xf1\xd8\x84\xa3\ +.\x82E\x8b3\x11\xee\xef\x81c\x80zOU\xca\x02\ +\xfe\xe8G\x81\x96\xc0V\xb1\xf7\xce\xbf\x9f\x876'U\ +\xb2\xe5H\x93\x00I\x8a\xd9\xedG'\x9cpIz+\ +7\x03~\x07\x0c\x01\x82D[\x16\xf8\xc7?\x0a4\x02\ +\xb6\x8f\xbd\x97\x1e}\x11Z\x9eXI\xbfQ&\x01\x92\ +\x14\xa3[\x8fL\x18ri\xbaz\x9b\x01\xd7\x03\xa7\x93\ +\xae\x8a\x07Q\x16A#\x9c\xb9}\xc2\x8dwe\x22\xd4\ +*\xe04\xe0\xa6\x18\x83+\x8b\xb8\xe1\xde\x04>\x02\xf6\ +\xa5\xfe\xdeY\xb0Z\xdex\x0f\xa6\xf4\xafd\xef)&\ +\x01\x92T\x97N\xdb6\xe1\xe6\xbb3\x11\xeab\xe08\ +\xd2g\xfd\xa3T\x16y\x03\x8e\x04\xc6\x02\xfb\xc5\x9e\x04\ +\xbc\xf9\x01|\xb4]%\x07L5\x09\x90\xa4\xbap\xec\ +f\x09\xb7\xde\x9b\x89P\x17\x91V\xf7\xfbS\xccA\x96\ +e\xa0!G\x01\x1f\x16\x92\x80\xa8\xe3\x1d9\x16Fo\ +]\xc9\xc0/L\x02$\xa96\x1d\xb5I\xc2\x9f\x1f\xc8\ +\xcc\xe4\x7f$pg\xec\x81\x96d\xa8\xff\xf7\x02\xfe\x0e\ +\x94\xc7\x1e\xe8\x81\xbb\xc2\xdf>\xac\xf0\x88\x95\xa4Zp\ +\xc4\xc6\x09\x7fy(\x13\xa1\xce\x07~\x0c\xfc#\x0b\xc1\ +\x96dl\x1c\xecQh\xd8\xe8/\xb1\xf7\xdc\x01\x1e\x9c\ +`\x12 I5\xba\xa0\xea\x96\xf0\xcf'33\xf9\x1f\ +\x0c\xdc\x9f\x95\xb6-\xc9\xe0x\xd8\x0d\xf8'\xd0$\xf6\ +@w\xdf\x0e\x1e\x9eh\x12 I\xabc\xff.\x09\x0f\ +>\x9d\x89P+\x81\x83\x80\x7fe\xa9}K2:.\ +v\x00\x1e\x02\x9a\xc7\x1e\xe8\x8e\x9b\xc3SSM\x02$\ +i\x95.\xa0\xda'<\xfer&B\x9dK\xfa\xb4\xda\ +\x13Yk\xe3\x92\x0c\x8f\x8f\xed\x80\x87\x81\x16\xb1\x07\xba\ +\xfdf\xf0\xcc\x17&\x01\x92\xb42vm\x97\xf0\xe4\x88\ +L\x84:\x07\xd8\x87\xf4\xa5v\x99S\x92\xf1q\xb29\ +\xf0\x18\xd0:\xf6@\xb7\xdd\x14\x9e\xfb\xca$@\x92V\ +\xa4\x7f\xdb\x84\xa7_\xcdD\xa8\x09\xe9\xbe\xb4\x97\xb3\xda\ +\xd6%E0^~\x00\xfc\x07h\x13{\xa0\x9b\xf5\x82\ +Wg\x9b\x04H\xd2\xf2l\xd7*\xe1\xe5w2\x11\xea\ +\x0c`w\xe0\x95,\xb7wI\x91\x8c\x9bM\x80\xc7\x81\ +5c\x0ft\xd3\x9e\xf0\xfa\x1c\x93\x00IZ\xd2\xd6\x15\ +\x09\xaf\x8e\xccD\xa8\xd3I7\xa3\xbf\x96\xf56/)\ +\xa2\xf1\xd3\x13x\x12X7\xf6@\xfbn\x08o~m\ +\x12 I\x00\xfd\x9a'\xbc\xfe^&B\x9dJ\xfa\xb6\ +\xdaw\x8b\xa1\xddK\x8bh\x0c}\x00\xfc\x10\x98\x1c{\ +\xa0\xef\x8c\x86\xdee\x09U\x9d\xab<\xf2%\xe5\xda&\ +\xe5\x99\x99\xfc?\x07v)\x96\xc9\xbf\xd8\x12\x00\x80\xd1\ +\xc0\xf6\xc0\x84\xd8\x03\xfdp<\xf4\x9e0\x93\x85[-\ +\xf6\x0c )\x976n\x940rl&B\xfd\x0c\xd8\ +\x99\xb44}\xd1(-\xc215\x1e\xd8\x09\x18\x17}\ +\xb62\x016|q\x16_\x0d\xfb\xda3\x81\xa4\xdc\x98\ +w\xd0B6$\xe1\xbd\x8f2\x11\xee\xc4\xc2\x85\xe5\xfb\ +\xc5\xd6\x0f\xa5E:\xbe>!\xbd\x1d\x10\xfd\xf0\x9a0\ +\x19\xb6\xb8t>S\xae\x9c\xedYAR\xd1\x9b5\xa4\ +\x92\x8d\xef\x99\xc3G\x133\x11\xee\x84\xc2\x5c\xf2q1\ +\xf6EI\x91\x8f\xb5\xb6\xa4\x1b\x03{\xc5\x1eh\x87\xb6\ +\xf0\xf4\xad%t:\xa5\x85g\x08IEi\xc6Y\xf3\ +\xd8\xfc\x9aJ\xc6O\xceD\xb8cH\xef\xf9\x7fZ\xac\ +\xfdQ\x92\x831\xb7\x0e\xe9#\x82}\xa2\xcfV\xd6\x84\ +'n\x83\x9e\xa7\xf9\x84\x80\xa4\xe22\xf67\x09\xbb\x1c\ +\x0d\x93\xa7f\x22\xdc\x0f\x0b\x93\xff\x94b\xee\x93\x92\x9c\ +\x8c\xbd\xb5H\xeb4o\x1c{\xa0k\xb7\x86'n\x87\ +\xde\xa7\x9b\x04H*\x0ec~\x9d\xb0\xcb10%\x1b\ +\x93\xff\x07\x85\xc9\xff\xb3b\xef\x97\xd2\x9c\x8c\xbf/H\ +7\x06F_`r\xea4\xd8q0\xbcyM\xe2Y\ +CR\xe6\x8d\xbc!a\x87#23\xf9\xbfE\xfa\xb2\ +\xb9\xcf\xf2\xd07\xa59\x1a\x87\xd5\xd5\x9b\xa2\x7f\xc5\xc4\ +\xf4\x990\xe0Xx\xfd*\x93\x00I\xd9\xf5\xceu\x09\ +\xfd\x8f\x85/\xa6g\x22\xdc7H\x8b\xfc|\x99\x97\xfe\ +)\xcd\xd9x\x9cQ\xe8\xe0g\xa2\x0ft\x16\xecz\x1c\ +\xbcr\x85I\x80\xa4\xecy\xeb\xdat\xf2\xff2\x1b\x93\ +\xffK\xa4\xcf\xf9\x7f\x95\xa7>*\xcd\xe1\xb8\x9c\x0d\xec\ +E\x06^\xdf\x98\xccNW\x02\x9e\xfd\x99I\x80\xa4\xec\ +x\xf9\x17\x09;\x1f\x0d\xd3\xb2q\xeaz\x9e\xf4\xc5>\ +3\xf3\xd6O\xa59\x1d\x9fs\x80\xbdI\x9f\x0e\x88;\ +\xd0\xafa\xaf\x93\xe0\xe9\x8bM\x02$\xc5\xef\xc5\xcb\x12\ +\xf68\x11ff\xa3\xb4\xc9\xb3\xc0\x8f\x80Yy\xec\xab\ +\xd2\x1c\x8f\xd3\xb9\x85$\xe0\xc1\xe8\x03\x9d\x07\xfb\x9c\x02\ +O\x5cd\x12 )^\xcf]\x92\xf0\xa3!0kN\ +&\xc2}\x14\xd8\x83tU8\x97Js>^+\x81\ +\x83\x80\xfb\xb3\x90\x04\xec}2\x03k\x9dR\xc9\x16\xef\x9a\x04H\xaa\x1f\x7f8\ +&\xe1\xe8\xe1\xe9-\xc9\x0c\xb8\x158\x91\xf4V\xafL\ +\x00V\xca\xa3@\x05\xb0U\xf4\xd9\xca\x0b\xd0\xfa\xa4J\ +\xb6\x1ci\x12 \xa9n\xfd\xfe\xc8\x84!\x97\xc1\xe2l\ +L\xa7\xb7\x00CHWwe\x02\xb0J\x1e\x03\x1a\x90\ +\xbe\x1c\x22\xee@_\x84\x8a\x13*\xd9j\x94I\x80\xa4\ +:\x9aM\x8fH8\xf9\xe7\xe9\xeac\x06\x5c\x0b\x9cn\ +\xaf\x99\x00\xd4\xc4\xd3\xa4\xafM\xde)\x0bI@\x83\xc3\ ++\xd9a\xb4I\x80\xa4\xdau\xddA\x09g^\x95\x99\ +p\xaf\x02\xce\xb7\xd7L\x00j\xc33\xa4\xcf\x8c\xf6\x8f\ +>[y\x15*\x0f\xacd\x97q&\x01\x92j\xc75\ +\x03\x13\xce\xbf>S\x93\xffP{\xcd\x04\xa06\xbdH\ +\xfa\x0e\x81]\xa3\x0f\xf4-\x93\x00I\xb5\xe3\xd2\xbd\x12\ +\x86\xdf\x94\x99p/.|d\x02P\xeb^\x02\xa6\x92\ +\xbe<\xa2$\xf6$`\xce\xfe\x95\x0c\x18o\x12 i\ +5g\xd3\xdd\x13.\xbb%\x13\xa1V\x01g\x17\xae\xfe\ +e\x02Pg^\x07>\x03\xf6\x8c=\x09x\xf9m\x98\ +\xba[%{~j\x12 i\xd5\x9c\xbdc\xc25\x7f\ +\xcc\xcc\xe4\x7f\x06p\xa3\xbdf\x02P\x1f\xde\x00&\x03\ +{\xc5\x9e\x04\xbc\xfe\x1e|6\xa0\x92\xbd&\x9b\x04H\ +Z9gl\x9f\xf0\xab\xbb23\xf9\x9f\x0a\xdcl\xaf\ +\x99\x00\xd4\xa7\xb7\x80\xb1\xc0\xbeD\xfeR\xa57\xdf\x87\ +Ow\xaed\x9f\xcfL\x02$\xad\xd8\xa9\xdb$\xfc\xfa\ +\xaf\x99\x08u\x11p\x0c\xf0{{\xcd\x04 \x84Q\xc0\ +h`\xbf\xd8\xdb\xf2\xad\x0fa\xcc6\x95\x0c\xfc\xc2$\ +@\xd2\xf2\x1d\xbdi\xc2m\xf7e\x22\xd4E\xc0\xd1\xc0\ +\x9f\xed\xb5\xd5Wb\x13\xd4\x8a\xbd\x81{\x81\xf2\xd8\x03\ +\x1d\xb4\x1b\xfc\xf5\x83\x0a{L\xd2\xb7\x0c\xde8\xe1\xae\ +\x872\x11\xeaB\xe0H\xe0/\xf6\x9a\x09@,\xf6\x04\ +\xfe\x0eD\x7f\x89\xbd\xd7\x8e\xf0\xc0x\x93\x00I\xa9\x83\ +\xba'\xfc\xe3\x89L\x84:\x1f8\x04\xf8\xa7\xbdf\x02\ +\x10\x9b\xdd\x81\x7f\x00Mb\x0f\xf4G;\xc0\xbf&\x98\ +\x04Hy7\xb0[\xc2\xfdOf\x22\xd4J`\x10\xe9\ +\x1b[e\x02\x10\xa5\x1d\x81\x87\x80f\xb1\x07\xba\xdb\xb6\ +\xf0\xc8$\x93\x00)\xaf\xf6\xeb\x9c\xf0\xafg2\x11\xea\ +\x5c\xd2\xbdV\x8f\xdbk&\x00\xb1\xdb\x1ex\x18h\x1e\ +}\xb6\xb29<5\xd5$@\xca\x9b\xdd\xdb'<\xfe\ +rf&\xff}\x80'\xed5\x13\x80\xac\xd8\x16x\x04\ +h\x11{\xa0\xdb\xfd\x00\x9e\xfd\xd2$@\xca\x8b\x01\xeb\ +&<\xf5J&B\x9dC\xba\xc9\xfai{\xcd\x04 \ +k6\x03\xfe\x03\xb4\x8e=\xd0\xcd{\xc3+\xb3L\x02\ +\xa4b\xb7}\xeb\x84\x97\xde\xceD\xa8\x09\xe9\xbe\xaa\x11\ +\xf6\x9a\x09@VmZH\x02\xd6\x8c>[\xe9\x05\xaf\ +\xce6\x09\x90\x8a\xd5\xb6\xad\x12F\xbc\x93\x89Pg\x00\ +\xbb\x01\xaf\xdak&\x00Y\xd7\x8b\xf4\xfeU\xdb\xd8\x03\ +\xdd\xa4\x07\xbc1\xd7$@*6[\xb5HxmT\ +&B\xfd\x02\x18\x00\xbcc\xaf\x99\x00\x14\x8b\x1e\x85$\ +\xa0]\xec\x81\xf6\xec\x0c\xa3\x16\x9a\x04H\xc5b\xd3\xc6\ +\x09\xef\x8e\xc9D\xa8S\x81\xfe\xc0H{\xad\xee\x95\xda\ +\x04\xf5\xe6C\xe0\x87\xc0\xa7\xb1\x07\xfa\xc18\xe8]\x96\ +P\xd5\xb9\xca^\x932\xaeoyf&\xff\xff\x02;\ +;\xf9\xbb\x02P\xcc\xba\x16V\x02:\xc6\x1eh\xf7\x0d\ +\xe0\x83\xc5\xae\x04HY\xd5\xb34a\xcc\x84L\x84:\ +\xa90\xf9\x7fd\xaf\xb9\x02P\xcc>\x22-\x164>\ +\xf6@\xc7L\x80\xcd\x9b&\xf6\x98\x94A\x9b\xad\x91\x99\ +\xc9\x7fB\xe1\x9c\xe8\xe4o\x02\x90\x0b\xd5\x03~l\xec\ +\x81\xbe\xf5\x01\x1c\xb5\x89I\x80\x94%\x87\xf7Ix\xfb\ +\xc3\xcc\x9c\x0bw\xce\xc2\x05\x91\x09\x80j\xd3$`'\ +\xd2\xbd\x01Q\xbb\xe3Ax\xe4|\x93\x00)\x0b\xfeu\ +N\xc2\xdd\x0fg\x22\xd4\x0fI\x0b\xa69\xf9\x07\xe2\x1e\ +\x80\xf0\xd6\x01\x9e\x006\x8a9\xc8^]`\xe4\x02\xf7\ +\x03H\xb1\xeb]\x96\xf0a\xfcS\xea{\xa4\xbb\xfd\xff\ +k\x8f\xb9\x02\x90g\x9f\x03;\x00\xaf\xc5\x1c\xe4\xfb\x1f\ +\xc3\xbf\x87\xba\x0a \xc5~\xf5\x9f\x81\xc9\xffm\xd2'\ +\xa2\x9c\xfcM\x00\x04L'-y\xf9z\xccA\xfe\xe1\ +\x1fv\x94\x14\xb3?=\x10}\x88o\x00\xbb\x90\x16\xfb\ +Q`\xde\x02\x88K\x05\xf0o`\xeb\x18\x83k\xde\x14\ +f\xb4\xf16\x80\x14\xabV\xd3\x12f\xce\x8ez\xf2\xdf\ +\x15\x98fO\xb9\x02\xa0e%\xa4\xf5\xaf_\x881\xb8\ +Ys\xe0\xdd\xeb\xbd\x0d \xc5\xe8\xcdk\xa2\x9e\xfc_\ + \xdd\xed\xef\xe4o\x02\xa0\x15\xcd\xb3\x85$\xe0\x89\x18\ +\x83\xfbx\x92\x1d$\xc5\xe8\xa3\x89\xd1\x86\xf6<\xf0#\ +`\xa6\xbdd\x02\xa0\xef7\x17\xd8\x97\xf4-\x82Q\xf9\ +\xfc+;G\x8a\xd1\x17q^[?N\xba\xbfi\x96\ +=d\x02\xa0UO\x02\xa2z\xa2w\xd1\x22;F\x8a\ +\xd1\xa2\xc5\xd1\x85\xf4\x08\xb0O\xe1\x5c&\x13\x00\xad\xa2\ +y\xc0\x01@4\xfb\xef+\x9a\xdb)R\x8c*\x9aE\ +\x15\xce\xc3\xc0\xc0\xc29L&\x00ZM\xf3\x81C\x80\ +{c\x08\xa6\xfd\xdav\x88\x14\xa3\x8e\xebF\x13\xca\xdf\ +\x81\xfd\x9d\xfcM\x00T;\x16\x00?\x06\xee\x08\x1d\xc8\ +\xf6\xffq\x09@\x8a\xd1\xb6\xafD\xb1\x04\xf0\xb7\xc2\xb9\ +j\x81=\x12?\xeb\x00dK\x19\xf0{\xe0\xa8\x10_\ +^\xd1\x0c\xa6\xb5\xb6\x0e\x80\x14\xab6\xd3\x13f\x84\xdb\ +nw70\x18XhO\xb8\x02\xa0\xda\xb7\x088\x16\ +x%\xc4\x97\xf7\xe9n\x07H1\xdb\xa8[\xb0\xaf~\ +\x058\xdc\xc9\xdf\x04@uk1\xd02\xc8\xc9\xa5\xab\ +\x8d/\xc5\xacw\xb8c\xb4\xa2pn\x92\x09\x80\xeaP\ +9\xd0%g'\x17I+s\x8cv\x09\xf6\xd5]\x81\ +\xc6\xf6\x80\x09\x80\xeaV/\xa0A\x90\x15\x80n6\xbe\ +\x14\xb3\x80\xc7h\x03`C{ [\xdc\x04\x98=?\ +\x01\xee\x0c\xf1\xc5\x8b:\xba\x01P\x8a]\xd9\xc4`\xef\ +\xeb\xf8\x09\xf0\x17{\xc0\x15\x00\xd5\x9d\xde!\xbe\xb4\xed\ +\x9a6\xbc\x94\x05\xeb\xb4\xc9\xd7\xb9I&\x00y\xb2Q\ +\x90/u\xf9_\xca\xc6\x09\x22\xdc\xb1\xba\x91\xado\x02\ +\xa0bL\x00\xdc\x00(e\xe3\x04\x11\xeeX5\x010\ +\x01P\x1dj\x06\xac\x1f\xe2\x8b}\x02@\xca\x86\x80\xc7\ +\xea\x06\x80\xa5BM\x00T\x87\x19v\x90\x8d\x9b\xae\x00\ +H\x199I\x84\xbb\x05PB\xfa\x94\x92L\x00TG\ +\x09@\xfd\x1f\xd5%\xb0\xe5\x05>\x01 eA\xbf\x0b\ +*(\x09\xf7|\x97\xb7\x01L\x00TG\x82\xec\xb2\x8d\ +\xe8-c\x92\xe2>f}\x12\xc0\x04@\xc5\x94]\xbb\ +\xfc/e\xecJ\xc1\x8d\x802\x010\x01\xa8\x95/\xf5\ +\x11@)['\x0a\x13\x00\x99\x00\x14\x955\x81\xb69\ +\xbb\x9a\x90\x94\xadcv\xdd\xc2\xb9J&\x00\xaa\xcdc\ +:\x87W\x13\x92\xb2w\xcc\xfa$\x80\x09\x80j\xfb\x98\ +\x0e\xf1\xa5e\xa5\xd0\xf7l\x9f\x00\x90\xb2d\x93s*\ +(\x0bwv\xf76\x80\x09\x80\x8aa\x05\xa0kG\x1b\ +^\xca\xa2.\xe1\x8e]\x9f\x040\x01P1d\xd5n\ +\x00\x942z\xc2p#\xa0L\x00\x5c\x010\x01\x90r\ +\x98\x00\x84;v\xfb\xd8\xfa&\x00\xaa=\xed\x81\xd6A\ +\xb2\x8e.6\xbe\x94\xc9+\x86p\xc7n+\xa0\x9d=\ +`\x02\xa0ZJ\xe6\x83\x9dD|\x02@r\x05 C\ +\xe7,\x99\x00\x98\x00\xd4\x82F\x0d\xa1\xc7\xa9>\x01 \ +eQ\x8fS+(od\x02 \x13\x80\xac\x0br\xff\ +\xbfG'\x1b^\xca\xb2\x0d7\xc8\xd79K&\x00\xae\ +\x00\xd4\xd6\x97\xba\x01P\xca\xf6\x95\x83O\x02\xc8\x04 \ +\xf3}\x14\xa4\xb2\x96\xf7\xff%\x13\x80\x1a\xac\x008\xbf\ +\x98\x00\xa8\x866\x00\x9a\x06I\xe1M\x00\xa4L\x0bx\ +\x0c7\x05\xd6\xb7\x07L\x00T\xc3c8\xd4\x17o\xd9\ +\xbc\x81\xad/e\xd8fk\x96\xe5\xf2\xdc%\x13\x00\x13\ +\x80\x9a\xa4\xefM`\xed\x8b\x9b\xda\xfaR\x86\xb5\x1b\xda\ +\x8c\xa6ML\x00d\x02\x90UAv\xd3z\xff_*\ +\x0e\xbd\xc2\x15\x04\xf2I\x00\x13\x00e1\x8b6\x01\x90\ +\x8a\xe4\x04\x12\xeei\x1eW\x00L\x00T\x03\x0d\x80\x0d\ +\x83$\x00\x96\x00\x96\x8aB\xc0c\xb9G\xe1\x1c&\x13\ +\x00\xad\x86\xee@y\xce\xae\x1a$\x15\xc7\xb1\x5c\x0ex\ +&1\x01\xd0\xea\x1e\xbb\xa1\xbe\xf8\x87O6\xb7\xf5\xa5\ +\x22\x10\xf8X\xf66\x80\x09\x80VS\x90M4\xadZ\ +@\x83\x11\x0e\x0d\xa9\x184\x18QJ\xebp\xaf\xf4p\ +#\xa0\x09\x80\xb2\x94=\xbb\xfc/\x15\xd9\x95\x84%\x81\ +e\x02`\x02\xb0R_\xea\x13\x00Rq%\x00\xe16\ +\x02\x9a\x00\x98\x00h54\x06\x82\x1c\xb6>\x02(\xb9\ +\x02PK\xba\x02M\xec\x01\x13\x00\xad\x9a\x9e@\x90:\ +\x9e\xde\x02\x90\x8aK\xc0c\xba\x8c@\x8f2\xeb\xfb\x95\ +\xd8\x04\xd1:\x1c\xb8#\xc4\x17/\xeaXa\xebKE\ +\xa6lb\x12\xf2\x5cv\x97=\xe0\x0a\x80V^\x90\xdd\ +\xb3\xeb\xaee\xc3K\xc5\xa8\xed\x9a\xf9:\x97\xc9\x04 \ +\xcb,\x01,\xa9\xf6N(\x96\x04\x96\x09\x80\x09\xc0\x0a\ +\xbf\xd4\x04@*\xce\x13\x8a\x8f\x02\xca\x04 \x13\x9a\x03\ +\x1d]\x01\x90T\x04\xc7\xf6\xfa@\x0b{\xc0\x04@+\ +\x9f1\x07\xd9\xa0\xe9\x0a\x80\xe4\x0a@-+\x01z\xd9\ +\x03&\x00Z\xf9\x04\xa0\xfe\x8f\xd2\x12\xd8\xf2\x02\x9f\x00\ +\x90\x8a\xd1\x96\x17TP\x12\xee\xb9/o\x03\x98\x00h\ +%\x05\xd95\xbb~;\x1b^*f\x01\x8fq\x9f\x04\ +0\x01P\xcc\x07\x8b\xcb\xffRqs#\xa0L\x002\ +p\x9c\x06\xf9R+\x00J\xc5}ea\x02 \x13\x80\ +\xa8\xad\x09\xb4\xcd\xd9\xc9ARq\x1f\xe3m\x01\xcb\x8c\ +\x99\x00(\xd6L\xd9[\x00R\x91\x9f\x5c\xc2\x1e\xe3>\ +\x09`\x02\xa0\x18\x13\x80\xb2R\xd8\xf8,\x9f\x00\x90\x8a\ +Y\xdf\xb3+hP\x16\xec\xeb\xbd\x0d`\x02\xa0\xef\x11\ +d\x03`\xd7\x8e6\xbc\x94\x07]:\xe4\xeb\xdc&\x13\ +\x00W\x00\xbe\xefK\xdd\x00(\xe5\xe3\x04\xe3;\x01d\ +\x02\x10\xad \xf7\xc9L\x00$\x13\x00\x13\x00\x13\x00\x85\ +\xb3\x1e\xd0:\xc4\x17\xf7\xeeb\xe3Ky\x10\xf0Xo\ +\x05\xb4\xb7\x07L\x00\x14Y\x86\xec\x0a\x80\xe4\x0a\x80\xab\ +\x00&\x00\x0a\x98\x9c\x87\xf8\xd2\xf2F\xb0\xe1)>\x01\ + \xe5\xc1\x86\xa7TP\xde\xc8\x04@&\x00&\x00@\ +\x8fN6\xbc\x94'\x01\x8fy\x9f\x040\x01PL\xd9\ +\xb1\xcb\xffR\xceN4n\x04\x94\x09@t}\x11\xe4\ +\x09\x007\x00J\xf9\x12\xf0\x98\xef\xed\xbcc\x02\xa0e\ +u\x02\x9a\xba\x02 \xa9\xceg\xe1p%\x81\xd7\x006\ +\xb0\x07L\x00\xb4\xd4<\x1c\xea\x8b\xb7h\xd6\xc0\xd6\x97\ +rd\xcb\xe6A\x8fyo\x03\x98\x00(\x86\x83\xa2i\ +\x13X\xfb\xe2\xa6\xb6\xbe\x94#k_\xdc\x94\xe6\xe1\x0e\ +{\x13\x00\x13\x00-%\xc8\xeeX_\x01,\xe5S\xcf\ +\xce\xf9:\xd7\xc9\x04\xc0\x15\x80\xa5\xbf\xd4\x04@\xca\xe7\ +\x09'\xdc\xb1\xef\x0a\x80\x09\x80\x96\xd0\x00\xe8\xee\x0a\x80\ +\xa4\x1c\x1c\xfb=\x80\x86\xf6\x80\x09\x80R\xdd\x81\xf2 \ +\xa9\xb8O\x00H\xf9\x5c\x01\x08w\xec7\x02<\xf3\x98\ +\x00\xa8\xfaX\x0c\xf5\xc5\xbb\xdc\xdd\xc2\xd6\x97r(\xf0\ +\xb1\xefm\x00\x13\x00\x15\x04\xd9\x14\xd3\xaa\x05\x94\x8c+\ +\xb1\xf5\xa5\x1c*\x19WB\x9b\x96\xf9:\xe7\xc9\x04\xc0\ +\x15\x80\x82>\xddmx)\xd7W\x1en\x044\x01P\ +>\x13\x00\x9f\x00\x90r~\xe21\x010\x01PP\x8d\ +\x81 \x95\xb9}\x02@r\x05 \x90.@\x13{\xc0\ +\x04 \xefz\x01e&\x00\x92r\xb4\x02PF\xfa8\ +\xa0\x02r\x07XxG\x00\x7f\x0e\xf1\xc5\x8b:V\xd8\ +\xfaR\xce\x95MLB\x9e\xfb\xee\xb4\x07\xc2\xf1-0\ +\xe1\x05\xd9\x0d\xbb\xeeZ6|\xde\x8c\xb8<\xe1\x91\xe7\ +\xe0\xad\x0fa\xec'\x90\xccJ\xffyEs\xe8\xb6>\ +l\xda\x03~\xb4\x03l5\xcc\xc40O\xda\xad\x0dS\ +\xa6\x06\xf9j\xf7\x01\xb8\x02\x90{\x0f\x01{\xd6\xf7\x97\ +\xf6\xdf\x1a\x1e\x9b\xec\x89>\x0fn>,\xe1\x86;`\ +\xc2\xe4\x95\xfb\xf7;\xb5\x873\x8e\x80S\xfe\xe2\xf8\xc8\ +\x83\xdd\xdb'<\xfer\x90\xaf~$\xc4\xb9O\xdfp\ +\x0f@x}\x82|\xa9u\xb8\x8a\xdemG%t\x98\ +\x97p\xfa\x15+?\xf9\x03\x8c\x9f\x0c\xa7_\x01mg\ +%\x5c30\xb1!\x8b\x5c\xc0\x8a\x80\xd6\x020\x01\xc8\ +\xb5\xe6@\x87 G\x9e\x1b\x00\x8b\xd6}\xa7'lH\ +\xc2\x09\x97\xd4li\xf7\x8b\xe90\xf4\x06\xe8\xb4 \xe1\ +\xb6\xa3L\x04\x8aU\xc0sAG\xc0e&\x13\x80\xfc\ +&\xdf\x04\xba\x0dc\x0d\x80\xe2\xf3\xd4\xf0\x84\xcd\xd6H\ +\x18t6|4\xb1\xf6\xfe\xde\x89\x9f\xc1\x09\x97\xc0F\ +\x0d\x12\xee;\xddD\xa0\xe8NB\xe1\xce\x05%\xa4O\ +A)`\x07(\x9c\xe3\x80[\xeb\xbd\xd3K`a\x07\ +\x13\xefb\xf1\xf2/\x12\x86\xdd\x08\xcf\xbd^?\xdf\xb7\ +e\x1f\xf8\xc5i\xb0\xf3e\x8e\xa1b\xd1`RBU\ +U\x90\xaf>\x1e\xf8\xbd=\xe0\x0a@\x1e\x05\xb9\x07\xb6\ +A;\x1b\xbe\x18\xbcqu\xc2\xbe\x9d\x12\xb6;\xbc\xfe\ +&\x7f\x80WG\xc2\x80\xe3`\x876\x09/^\xe6\x8a\ +@1\x08xNp\x1f\x80\x09@n\x05y\x0c\xc6\xfb\ +\xff\xd96\xfa\xe6\x84Cz&\xf4\xfb1<\xf4l\xb8\ +8^|\x0bv\x18\x0c\xbb\xb5Ox\xf7z\x13\x81L\ +\x9f\x88\xc2m\x04\xf4Q@\x13\x00\x13\x80\x9c\x1c\xec\xaa\ +\x81\xb9\x83\x170\xa4_B\x9f\xfd\xe0\xde\xc7\x08\xb5d\ +\xbb\x8c'^\x86\xcd\x06\xc1!=\x13&]?\xcb\x8e\ +2\x010\x010\x01\xd0\xf7X\x13X\xc7\x15\x00\xad\x8c\ +a\x03\x12\xd6\xbaz.\xb7\xde\x0b\x8b\x16\xc7\x17\xdf\xe2\ +\xc5iR\xd2\xed\x90\xc5\x0c\xe9\x97P\xd5\xb9\xcaN\xcb\ +\x90\xde]\x82}\xf5:\x80e\xc9L\x00r\xa7O\xa8\ +/\xf6\x09\x80\xec\xb8f`B\xcb\xaf\x12\xae\xba\x1d\xe6\ +U\xc6\x1f\xef\x82\x85p\xeb\xbd\xd0b\xc4L\xce\xdc\xde\ +\xdb\x02\xae\x00\xac\x5c\xfea\x0f\x84\xe1S\x00\xe1\x9c\x0a\ +\xfc\xaa\xbe\xbf\xb4A\x19T\xb6w\xf7v\x16&\xfe\xab\ +n\x87\xe93\xb3\xfd;\x9a7\x85\x93\x0e\x81\xcb\x1fw\ +\xcc\xc5\xae|r\xc2\xc2E\xc1\xce\x857\xdb\x03\xae\x00\ +\xe4I\x90\xac\xb7kG\x1b>f\xb7\x1d\x95\xb0\xde\xd7\ +\x09Co\xc8\xfe\xe4\x0f0k\x0e\x5cu;\xaccU\ +\xc1\xe8\x05<7\xb8\x02`\x02\x90;n\x00\xd4\xff\xdc\ +wzB\xf7\xaa\xb4z\xdfg_\x14\xdf\xef\xfb\xb2P\ +Up\x83\xf9V\x15\x8c\xf6\x84\xe4F@\x13\x00\x15w\ +\xd6\xeb\x06\xc0\xb8<5<\xe1\x07M\xd2\xea}\x1fO\ +*\xfe\xdf;\xe9\xbfiU\xc1\xdeeV\x15\x8c\xee\x84\ +\x14\xee\xdc\xd0\x07oG\x9b\x00\xe4\xc8z@\xcb \xa9\ +\xb6\x09@4\x13\xffV-\x12\x06\x1c\x07\xef\x8c\xce\xdf\ +\xef\xffp<\x0c:\x1b6)7\x11\x88f\x05 \xdc\ +\xb9\xa1\x02ho\x0f\x98\x00\xe4\xe6X\x0b\xf6\xc5\xde\x02\ +\x08\xea\xb5+\x13vm\x97N\xfc\xaf\x8d\xb2=F\x8e\ +M\x13\x81\xed['<\x7f\xa9\x89@\xd0\x93R\xd8s\ +\x83\xb7\x01L\x00L\x00\xeaRy#\xe8~\xb2\xbb\xb1\ +\x83\x5c\xf1\xde\x94V\xef\xdb\xfa0xr\x84\xed\xb1\xb4\ +\x97\xde\x86\x9d\x8eL\xab\x0a\xbes\x9d\x89@\x08\xddO\ +\xae\xa0I\xb9\x09\x80\x09\x80\xeaZ\x90\xfb\xff=:\xd9\ +\xf0\xf5m\xd6\x90J\x86\xf4K\xd8x\xff\xb8\xaa\xf7\xc5\ +\xea\x89\x97a\xf3\x83\xd3\xaa\x82\xe3o\x9ei\x83\xd4\xb3\ +\x0d\xc3\x9d#|\x12\xc0\x04\xc0\x15\x80:\xfdR\x97\xff\ +\xeb\xd5\xb0\x01\x09\xeb\xfct^\xb4\xd5\xfbbU]U\ +\xb0\xe7\x01U\x0c\xe9\x97\xb0p+\x1b\xaf\xde\xce\x11\xe1\ +\xf6\x01\xb8\x02`\x02\x90\x9b6\xef\x19$\xc5\xeeb\xe3\ +\xd7\xd7\xc4\xdf\xec\xf3\xb4\x90O\xe5|\xdbcuUW\ +\x15l\xfe\xcfY\x0c\xe9\xe7m\x81z9G\x84K\x00\ +z9\x1f\x99\x00\xe4Ag\xa0\xa9+\x00\xc5\xe7\x9a\x81\ +\x09m\xa6\xa7\x13\xff\xd7\x95\xb6Gm\x99\xbf M\x04\ +*\xbeL\x186\xc0D\xa0H\xcf\x11k\x14\xce\x8d2\ +\x01(\xeec,\xd4\x17o\xd5\xa0\x91\xad_\x07n;\ +*\xa1\xfd\xdc\xb4z\xdf\x0c_\x86Wgf\xcfM\xab\ +\x0a\xae=\xd3\xaa\x82ue\x9b\x05\xe5!\xbf\xde}\x00\ +&\x00&\x00u\xa1\xd9\x1a\xd0\xe6\xf2&\xb6~-\xba\ +\xef\xf4\x84n\x8b\xd3\xea}\xff\xfd\xd2\xf6\xa8/_\xcd\ +H\xab\x0a\xae_iU\xc1\xda\xd6\xf2\xfa\xc64o\x1a\ +\xec\xeb\xdd\x07`\x02P\xf4\xac\x00\x98q\x0f\x9f\x97\xb0\ +IyZ\xbdo\xdc\xa7\xb6G(\x9f~\x9eV\x15\xec\ +U\x9ap\xd7\x89&\x02\xb5\xa5W\xb8\xbdB\xae\x00\x98\ +\x00\x98\x00\xd4Ijm\x02PcO\x0dO\xd8\xb2Y\ +\xc2>\xa7\xa4\x05l\x14\x87\xd1\x13`\xf00\xe8kU\ +\xc1\xac\x9f+\x5c\x010\x01(j\x0d\x81\x0d]\x01\xc8\ +\x96W\xafH\xe8\xdf6\xad\xde\xf7\xc6\xfb\xb6G\xacF\ +\x15\xaa\x0an\xd7*\xe1\xb9KL\x042x\xae\xe8\x01\ +\xb8Q\xc9\x04\xa0hu\x0f5\xc0}\x02`\xd5}\xf0\ +\xabo\xaa\xf7=\xfd\xaa\xed\x91\x15/\xbf\x03?<*\ +\xad*\xf8\xf6\xb5&\x02\x19:W4\x04\xc3\x06$\xb4\xbec6\xbf\xfaK\ +ZxF\xc5\xa5\xba\xbc\xf0z\xa7\xce\xb7\xaa\xe0\xca\xcc\ +\xc2\xe1\x9e\x04p#\xa0\x09\x80\x09@\xad~\xa9\x1b\x00\ +\xbf\xd35\x03\x13ZO\xb3z_^TW\x15l\xf1\ +\x85U\x05Wx\xce\x08w'\xde\x04\xc0\x04\xa0(5\ +!P\xa9K\x9f\x00X\xd6mG%\xb4\x9b\x93V\xef\ +Kf\xe7\xb2\x09F\x02\x83\x80\x01\xc0ky\xfb\xf1s\ +\xbe\xb6\xaa`\xa4\xe7\x8c.\xa4e\x81e\x02PTz\ +\x01e\xae\x00\x84u\xd7\x89\x09]\x17\xa5\xd5\xfb>\xff\ +*\x97M\xf0aa\xe2\xef\x0b\xdc\x0b<\x01lYH\ +\x04\xde\xc9[cTW\x15\xec8/\xe1\xa6CM\x04\ +\x228g\x94\x92>\x0e\xa8z\xe0S\x00\xf5\xe7\x08\xe0\ +\xcf!\xbe\xd8'\x00\xd2\xb2\xbd\x17\xdd\x04c&\xe4\xb6\ +\x09&\x02\xbf\x00\xfe\x00,\x5c\xc1\xc9w pE\xe1\ +J,w6h\x0f\x17\x1c\x0b\xc7\xfe\xd1c&\xe0\x93\ +\x00\x83\x81;\x9c2\x5c\x01(&Av\xb7\xb6[;\ +\xdf\x8d\xfe\xd4\xf0\x84-\x9a\xa5e{s:\xf9\x7f\x09\ +\x0c%-@u\xeb\x0a&\x7f\x80\xc5\x85U\x81\x9e\xc0\ +\x09\xc0gyk\xac\x09\x93\xd3\xf2\xc2\x1b7\xb2\xaa`\ +\xc0s\x87O\x02\x98\x00\x14\x1d7\x00\xd6\xa3W\xaeH\ +\xd8e\x9d\xb4z\xdf\x9b\xf9\xac\xde7\x0b\xb8\xaap%\ +\x7f\x150o\x15\xfe\xec\x82B\xb2\xd0\xb5\x90\xef\x9dC\xd5W\xed\xdd\xea\xe0\xaa\xbd&\xab\x09\ +E\xe1\x89\x97a\xcbC`\xdfN\x09\xa3~\x99\x8fD\ + \xe0\xb9\xa3\x03\xd0\xd2i\xc3\x04\xa0\x98\xae\xfe\x83l\ +\xb8\xec]\xe4[\xb9\xe6\x1d\xb4\x90!\xfd\x126\xda\x17\ +\xee\xfcWZ\xf0%g\xaa\xef\xdb\xf7\x22\xbdo?\xa5\ +\x0e\xbf\xeb\x0b\xbe\xbd\x9f`Q\x9e\x1a\xba\xaa\x0a\x1ez\ +\x166=0\xad*8\xf5\xd29\xc5}\xd2\x0a\x97\x00\ +\x94\x14\xc6\xb3\xea\xa1\xa1U\xf7\x8e\x07~W\xef\x9d[\ +\x02\x0b;\x14\xefn\xe6a\x03\x12n\xbc\x0b\xe6\xe5\xb7\ +\x80\xcf\x13\xc09\x84{|\xaf'p\x09p`\x1e\xcf\ +%\x8d\x1a\xc2\x91\xfb\xc1o_)\xdec\xac\xe1\xa7I\ +\xa8\xa4\xfa\x84B\x92)W\x002/\xc8\xae\xd6\x0d\xda\ +\x15gc^30\xa1U\xa1z_N'\xff\x17\x81\ +\x1d\x08\xff\xec\xfe\x07\xa45\x05\xfa\x01\x0f\xe5\xad\x13\xf2\ +PU0\xe09\xc4'\x01L\x00\x8a\x86\x1b\x00k\xc1\ +mG%\xac;;\xad\xde73\x9f\xd5\xfb^\x05\xfa\ +\x03\xdb\x01\xcfG\x14\xd7k\xc0\xde\x85\xb8\x9e\xcb[\xa7\ +TW\x15\x5c+)\xbe\xaa\x82n\x044\x01\x90\x09@\ +\xf0\x89\xbf\xc3\xbc\xb4z\xdf\xd4i\xb9\x1c?\xd5W\xda\ +[\x01OF\xbe2\xb1cae\xe2\xed\xbcu\xd2\xb4\ +$\xad*\xd8vV\xf1$\x02\x01\xf7\x01l\xec\xb4a\ +\x02P\x0c\xd6\x04\x82\x94\xd4\xc8\xfa\x06\xc0\xfbNO\xe8\ +Q\x92N\xfcS\xa6\xe6r\xecL$\xbd\x17\xda\x87t\ +\xa3_UF\xe2~\x02\xd8\xac\x90\xb4|\x94\xb7N\xfb\ +bz\x9a\x08t^\x98p\xdbQ\xd9N\x04\x02>\x09\ +\x10\xec\xbci\x02\xa0\xda\xd4'X\xf6\x9e\xd1\x15\x80\xa7\ +\x86'l\xde4\xad\xde7\xf6\x93\x5c\x8e\x99\xea\xdd\xf6\ +\xdd\xc9\xeen\xfb\xfa|:!J\x9fLI\xab\x0a\xf6\ +i\x98\xdd\xaa\x82\x81\xcf!\xee\x03\xa8c>\x05P\xf7\ +N\x05~U\xdf_\xda\xa0\x0c*\xdbgkw\xf2\xcb\ +\xbfH\x18v#<\xf7zn\xc7\xca4\xe0&\xe0:\ +\x8a\xaf\xe8\xce\x1a\xc0q\xc0\xb0\xbc^\xd9\xf5\xdb\x18~\ +q\x1a\xfc\xf0\xd2l\x1d\x97\x8d\xa7$,X\x18\xe4\xab\ +O+\x1c\x0fr\x05 \xb3\x82d\xb1\xdd\xd6\xcfN\x03\ +\xbdqu\xc2\xbe\x9d\x12\xb6;<\xb7\x93\xff\x1c\xbe)\ +\xb4\xf33\x8a\xb3\xe2\xde\x5c\xe0\xc6\xc2o\xaci\x85\xc2\ +Lz\xe5]\xe8\x7f,\xec\xd0&\xe1\xa5\x9fggE\ +\xa0kGW\x00L\x00\xb4\xba\xdc\x00\xf8\x1d\xc6\xfc:\ +-\xdb\xdb\xef\xc7i\x81\x95\x1c\x9a\xcf\xb7\xab\xf7\xcd\xc8\ +\xc1o\x9eM\xce\xab\x0a\xbe\xf8\x16l\x7fDZ^x\ +\xe4\x0d\xf1'\x02>\x09`\x02\xa0\x8ce\xb11o\x00\ +\x9c;x\x01C\xfa%\xf4\xd9/-\xdb[U\x95\xbb\ +1\x91\xfb\xb7\xee\xf1\xcd[\x0a\xab\xf79,\xcc[\x03\ +<\xf12\xfc\xe0\xa0\xb4\xaa\xe0\x94+\xe3}\xae5\xe0\ +\xb9$X\x05U\x13\x00\xd5\x86`5\xadc}\x07\xc0\ +\xb0\x01\x09k]=\x97[\xef\x85\x85\x8br9&\x9e\ +\x006%\xdd!?\xceC\x84Id\xf3I\x87\xda\xc9\ +\x04\x17\xa7Ip\xe7#\x161\xa4_\x9c\xab\x01\x01W\ +\x00*\x80\xf5\x9e\x00\xb6 }F\xfe]\x0f\x8de|XH\ +\x8a\xfa\x16\x12\x81\x5cY\xb00\xad*\xd8|j|U\ +\x05\x03\x9fK\xbc\x0d`\x02`\x02\xb0*\xca\x1bA\xf7\ +\x93\xe3\xd8i|\xcd\xc0\x845g\xe4\xbaz\xdf\x08`\ +\x97\xc2\xc4\xff\xba\x87\xc4\xf7\x1aYH\x04\xb6\x05r\xb7\ +3d\xee\xbc\xb4\xaa`\xcb\xaf\xe2I\x04\xba\x9dTA\ +\x93r\x13\x00\x13\x00\xad\xaa \xf7\xff{v\x0e\xff\xc3\ +o;*a\xbd\xaf\xd3\x89\x7f\xfa\xcc\x5c\xf6\xfd{\x85\ +\x89l\x1b\xe0)\x0f\x85U\xf6\x12\xb0S!qz3\ +o?~\xd6\x9c4\x11X'\x92\xaa\x82=\xc2\x9dS\ +|\x12\xc0\x04\xc0\x15\x80U\xfa\xd2\x80\xf7\xff\xef;=\ +\xa1{UZ\xbd\xef\xb3/r\xd9\xe7\x9f\x90\xde\xd3\xae\ +^\xca\xae\xf20\xa8\x91'\x80\xcd\x0b\xc9\xd4\xd8\xbc\xfd\ +\xf8/\x0bU\x05;-\x08[U0\xe09\xc5\x15\x00\ +\x13\x80\xcc\xb6m\x8f )s\x80\x83\xf5\xa9\xe1\x09?\ +h\x92V\xef\xfbxR.\xfb{2p\x06\xb0!\xd9\ +\xad\xde\x17\xab\xaaB2\xd5\x1b\x18\x0cL\xc8[\x03L\ +\xfc,\xad*\xb8Q\x830U\x05\x03n*\xee\x05\x94\ +y\x08\x98\x00dM'\xa0i\x90\x94\xb9\x1e7\xed<\ +5\xcb\x7f#P\xe9\xd0\xaf3\x0b\x80;\x0a\x93\ +\xc2\xb9\x85\xb6\xcf\x95\x0f\xc6\xc1\xa0\xb3a\xab\x16\x09O\ +\x0d\xaf\xbfD\xa0O\xb8\x8d\x80M\x0a\xe7R\x99\x00d\ +J\x8fP_\xbc\xcd\x82\xba\xdf\xb1\xf3\xfaU\x09\xbb\xb6\ +K'\xfeWG\xe6\xb2\x7f\xab\xab\xf7u\x05\xae\x05\xbe\ +v\xc8\xd7\x9b\xaf\x0bm\xbe\x01i-\x81$o\x0d\xf0\ +\xda(\x18p\x5cZU\xf0\x85K\xeb\xfe\xe7o\x9b4\ +\x0e\xf9s\xbb;\xe4M\x00\xb2&\xc8\xa0m\xb6\x06\xb4\ +\xbc\xbe\xee\x0e\xd6\x0foJ\xab\xf7mu(<9\x22\ +\x97\xfdZ]\xbd\xaf+\xf9\xa9\xde\x17\xab\xa5\xab\x0a\xe6\ +.\x09{\xf1-\xd8\xf1\xc8\xb4\xaa\xe0\xbb\xd7\xd7]\x22\ +\xd0\xfc\xb7\xe5\xb4h\x16\xecgvs\xa8\x9b\x00dM\ +\x90\xbbfuu\xafn\xea\xa5s\x18\xbcqB\x9f\xfd\ +s[\xbdo!p[\xa1_O\x00\xfe\xeb\x10\x8f\xc6\ +W\x85dl\xc3B\x1f\xe5\xb2\xaa\xe0f\x83`\xf0\xc6\ +\x09S/\x9dS7\xe7\x96p\x15\x01M\x00L\x002\ +'\xc8+4\xeab\xb7\xee\xb0\x01\x09\x1b\x1c\xbf\x90\xbb\ +\x1eJ+\x97\xe5\xcc\x92\x1b\xd0\x8e#\xad\x5c\xa78M\ +*\xf4Q7\xd2U\x9a\x5c\x8d\xd6\xc5\x8b\xe1\xae\x87\xa0\ +\xe3\xb1\x0b\x19\xd2/\xa1\xaas\xedf\xe9\x017\x02Z\ +\x0d\xd0\x04 s\xd6\xc9\xfa\x0a\xc0\x05\xfd\x13\x9aOM\ +\xab\xf7U\xce\xcfe\x1f>\x02lF\xfa\x08\xda\x18\x87\ +tfL ]\xa5\xd9\xbc\xd0\x87\xb9R]U\xb0\xc5\ +\x88\x99\x5c\xd0\xbf\xf6n\x0b\x04L\x00\xd6uH\x9b\x00\ +dM\xdb\x10_\xda\xb9\x16r\xe5\xcb\xf7Mh=-\ +\xe1\xea?\xa4\x95\xc9r\xe8\x05`{`O\xe0-\x87\ +rf\xbdU\xe8\xc3\xed\x0b}\x9a+s\xe7\xc1\xd5\x7f\ +\x80\xd6\xd3\x12.\xdf\xb7\xe6\x89@\x97\x0e\xf9:\x97\x9a\ +\x00\xa8&\x82\xbc\x04\xa8S\x0d\x12\x80\xdb\x8eJh?\ +7a\xf8M\x90\xe4\xb3l\xef\xa8\xc2\xd5~.'\x8c\ +\x1c$t\x03\x807\xf2\xf6\xe3\x93\xd90\xfc\xa6\x9aW\ +\x15\x5c\xbf]\xb0\x9fP\xe1\x10\xae\x1b\xbej\xb1\xee\xda\ +u\x01\x01\x0aX,\xea\xb8\xea\xc7\xca\x9f\x8fK\xf8\xd9\ +o\xd2b#95\x06\xb8\x18\xb8\x07+\xf7\xe5\xe1\xd8\ +\x1c\x04\x5cJN\x1f/\xeb\xb8.\xfc\xec$\x18\xfc\xfb\ +U?W\x94M\x0c\xf2\xc4\xe5\x02\xa0\x91C\xd7\x15\x80\ +\xac('\x03\xd5\xab\xfeqFB\xef\xb2\x84\xa3\x87\xe7\ +v\xf2\xaf\xde4\xd6\x1b\xf8\x9b\x93\x7f.T\x15\xfa:\ +\xb7\x9b:'~\x06G\x0f\x87\xdee\x09\xff8#\x13\ +%\x14\x1a\x16>2\x01\xc8\xccI\xa6\xfe/mVr\ +=\xe7\xa9\xe1\x09\xfd\x9a'\x1ct\x16|8>\x97\xfd\ +\x93\xfb\xc7\xc6\xe4c\x9d\x1f\x8e\x87\x83\xce\x82\xbe\xe5+\ +_^\xb8\xd4\x19\xa3\xa8x\x0b\xa0n4 ]\xb6\xaa\ +\xf7\x04`a\x87\xef^\xd6{\xfc\xc2\x84\xcb\x7f\x0f\xcf\ +\xe5\xf7\xa5\xb43\x81\xeb\x80\x1b\x80Y\x0eS-\xa19\ +p&p6\xd0\x22\x8f\x0d\xb0\xc3\xe6p\xe1\xf1\xd0\xff\ +\xe7\xdf}\x0ei0)\x09U\x03\xa4\x01\xbe_C\x19\ +J\xac\xe6\x17V\x02\xea\xf53k\xf8\x1aU\x8b:V\ +|\xebs\xd7\x95Tm\xd2\xa3\xfec\x89\xe83\x17\xb8\ +\x06h\xe3\xd0\xd4\xf7hS\x18+s\xf3z\xbcl\xd2\ +\x83\xaa\xbf\x5c\xc52\xe7\x91Y\xc3\xd7\x08\x15\xd3|/\ +V]\x01\xc8\x9aI\x04(`q\xe7\x95\xe9\x8b;\xde\ +|\x1f\x1e\x7f\x19\xfe\xf5\x0c\xcc\x9e\x9b\xdb>X\x00\xfc\ +\x01\xb8\x8c\xf4m}\xd2\xcaj\x0f\x0c\x07\x8e&\xa7\xf7\ +\x9f\x9b\xad\x01{\xef\x04\x03\xb6\x86\x1f\xf4\x82Qc\xe1\ +'C\x83\x84\xf2)\xd0\xc1!\xa9,y=\xc7W\xdc\ +\xa1?\x8bIw\xf4[BT5\xb5>\xf0;\xd2=\ +\x03\x1e[a>\xaf:\x0c\xeb\x86[:\xea\x8e\xb5\xe2\ +\xc3x\x82o\xaa\xf7\x8d\xb59TC\x9f\x90n\x12\xdc\ +\x98\xb4$\xb4O\x8ax.5\x01\xd0\xf7\xfa\xdc&\xa8\ +W/\xecy\xed\x9a\x00\x00\x03rIDAT\x01;\ +\x91\x16{\xb1z\x9fj\xdb\xfb\x85\xa4rk\xe0)\x9b\ +\xc3s\xa9\x09\x80Vd\x9cMP/\xde\x00v\x07\xb6\ +\x05\x9e\xb59T\xc7^\x01v)\x8c\xb97l\x8ez\ +1\xde&P\xd6\xec\x8a\xf7\xee\xea\xf2\xf3\x01p\x10n\ +dUX\xfd\x81w<\x1e\xeb\xf4\xd3\xdfa\xa6\xaci\ +I\xba\x19\xcd\x03\xb8v?\x13\x81\xe3I\x9f\x0b\x96b\ +PZHF?\xf6\xf8\xac\x93\x0d\xbd-\x1db\xca\xa2\ +1\x1e\xc0\xb5\xf6\xf9\x028\x1fh\xec\xb0R\xa4\x1a\x15\ +\x92\xd3)\x1e\xaf\xb5\xf6\x19\xed\xb0RV\xfd\xd9\x03\xb8\ +\xc6\x9f\xe9\xc0\x85@3\x87\x932\xa2Ya\xccN\xf7\ +\xf8\xad\xf1\xe7O\x0e'e\xd5~\x1e\xc0\xab\xfd\x99\x03\ +\xdc\x08\xac\xed0RF\xb5\x06~FZ\x82\xdacz\ +\xf5>\xfb8\x8c\x94U\xe5@\xe2A\xbc\xcae?\x7f\ +\x07\xb4s\xf8\xa8H\xac\x05\x5c\x09\xcc\xf3\xf8^\xa5O\ +\x82\xb7\xfc\x94qwy \xaf\xd4g\x11i\xf5\xbe\xae\ +\x0e\x19\x15\xa9\x8eXUpU>w8d\x94u\xbb\ +{ \x7f\xef\xe7~`#\x87\x8arb\xa3\xc2\x98\xf7\ +\xd8_\xf1gW\x87\x8a\x8a\xc1\x9b\x1e\xcc\xcb\xfd\xbc\x00\ +\xec\xe0\xf0PNmIZ\xba\xdas\xc1\xb2\x9f\xb7\xb1\ +\xc6\x87\x8a\xc4a\x1e\xd0\xcb\xbc\xdcco\x87\x85\x04\xc0\ +v\xc0s\x9e\x17\xbe\xf59\xc4a\xa1b\xd1\x00\xf8\xc8\ +\x83\x9aQ\xc0\xfef\xf6\xd22J\x0a\xc7\xc6(\xcf\x13\ +\x8c\xc5B_*2y\xde\x0b\xf0\x09i\x81\x942\x87\ +\x81\xb4B\xd5U\x05\xc7\xe6\xf8|\xb1\x97\xc3@\xc5\xe8\ +\x9f9;\x90\xa7b\xf5>iu4,$\xcd\x93s\ +v\xce\xb8\xcf\xaeW\xb1\xea\x08L\xcb\xc1A<\x0d\x18\ +\x0a4\xb5\xcb\xa5\x1aiZ8\x96\xf2r\xde\xe8`\x97\ +\xab\x98\xedE\xf1\xbe$h\x0ei\xc1\x93Vv\xb3T\ +\xab\x9a\x93\xae\xa6\x15ka\xb1\xc5\xa4\x95S\xa5\xa2w\ +U\x91\x1d\xbc\x95\xc0\xaf\x80\xb6v\xadT\xa7\xda\x16\x8e\ +\xb5\xca\x22;\x87\x5cm\xd7*/J\x80\xdb\x8b\xe0\xa0\ +\xad\xae\xde\xd7\xc5.\x95\xeaU\x07\xd2\xaa\x82\x0b\x8a\xe0\ +\xaa\ +\x00\x00\x01\x80A\xd4\xb2`\ +\x00\x00\x01<\x00\x00\x00\x00\x00\x01\x00\x01\xae\xb0\ +\x00\x00\x01\x8bRz\xee0\ +\x00\x00\x01\xa2\x00\x00\x00\x00\x00\x01\x00\x02;O\ +\x00\x00\x01x\xe8\xe8Z\xc8\ +\x00\x00\x00\xb4\x00\x00\x00\x00\x00\x01\x00\x01\x13}\ +\x00\x00\x01xr\xe6\xdd\xe0\ +\x00\x00\x00h\x00\x00\x00\x00\x00\x01\x00\x00\xb9\xed\ \x00\x00\x01x\xbb\xe5\x06 \ -\x00\x00\x00\xbe\x00\x00\x00\x00\x00\x01\x00\x01\x0b_\ +\x00\x00\x00\xda\x00\x00\x00\x00\x00\x01\x00\x01_D\ \x00\x00\x01y/9=X\ " diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index 41ee1ac..53b2b13 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -1143,7 +1143,7 @@ padding-left: 8px; - Open &playlist... + O&pen... diff --git a/app/ui/main_window_playlist.ui b/app/ui/main_window_playlist.ui index 6bcee88..4b8fb1a 100644 --- a/app/ui/main_window_playlist.ui +++ b/app/ui/main_window_playlist.ui @@ -7,7 +7,7 @@ 0 0 1249 - 499 + 538 diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index b711264..064376f 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -764,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", "Open &playlist...")) + self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen...")) self.actionNewPlaylist.setText(_translate("MainWindow", "&New...")) self.actionTestFunction.setText(_translate("MainWindow", "&Test function")) self.actionSkipToFade.setText( @@ -847,4 +847,4 @@ class Ui_MainWindow(object): from infotabs import InfoTabs -from pyqtgraph import PlotWidget +from pyqtgraph import PlotWidget # type: ignore diff --git a/migrations/env.py b/migrations/env.py deleted file mode 120000 index 200c230..0000000 --- a/migrations/env.py +++ /dev/null @@ -1 +0,0 @@ -env.py.DEBUG \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..027fd36 --- /dev/null +++ b/migrations/env.py @@ -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/env.py.DEBUG b/migrations/env.py.DEBUG deleted file mode 100644 index c6420ae..0000000 --- a/migrations/env.py.DEBUG +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 027fd36..0000000 --- a/migrations/env.py.NODEBUG +++ /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/versions/c76e865ccb85_index_for_notesolours_substring.py b/migrations/versions/04df697e40cd_add_favouirit_to_playlists.py similarity index 50% rename from migrations/versions/c76e865ccb85_index_for_notesolours_substring.py rename to migrations/versions/04df697e40cd_add_favouirit_to_playlists.py index 6850c31..ea4fd70 100644 --- a/migrations/versions/c76e865ccb85_index_for_notesolours_substring.py +++ b/migrations/versions/04df697e40cd_add_favouirit_to_playlists.py @@ -1,16 +1,16 @@ -"""Index for notesolours substring +"""add favouirit to playlists -Revision ID: c76e865ccb85 +Revision ID: 04df697e40cd Revises: 33c04e3c12c8 -Create Date: 2025-02-07 18:21:01.760057 +Create Date: 2025-02-22 20:20:45.030024 """ from alembic import op import sqlalchemy as sa - +from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. -revision = 'c76e865ccb85' +revision = '04df697e40cd' down_revision = '33c04e3c12c8' branch_labels = None depends_on = None @@ -30,39 +30,29 @@ def downgrade(engine_name: str) -> None: def upgrade_() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table('notecolours', schema=None) as batch_op: + batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False)) 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') + with op.batch_alter_table('playlists', schema=None) as batch_op: + batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=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_constraint(None, type_='foreignkey') - batch_op.create_foreign_key('fk_queries_playlist_id_playlists', 'playlists', ['playlist_id'], ['id']) + with op.batch_alter_table('playlists', schema=None) as batch_op: + batch_op.drop_column('favourite') 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')) + batch_op.drop_column('strip_substring') # ### end Alembic commands ### diff --git a/migrations/versions/9c1254a8026d_add_queries_table.py b/migrations/versions/9c1254a8026d_add_queries_table.py deleted file mode 100644 index b48f27a..0000000 --- a/migrations/versions/9c1254a8026d_add_queries_table.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Add queries table - -Revision ID: 9c1254a8026d -Revises: c76e865ccb85 -Create Date: 2025-02-14 16:32:37.064567 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '9c1254a8026d' -down_revision = 'c76e865ccb85' -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('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') - ) - # ### end Alembic commands ### - - -def downgrade_() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('queries') - # ### end Alembic commands ### - From 64ccb485b519884c6538cdb504ed073a48f5c68a Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 26 Feb 2025 13:29:42 +0000 Subject: [PATCH 17/27] Fix playdates cascade deletes --- app/dbtables.py | 6 ++- .../ab475332d873_fix_playdates_cascades.py | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/ab475332d873_fix_playdates_cascades.py diff --git a/app/dbtables.py b/app/dbtables.py index 6d4919b..14116e0 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -48,7 +48,7 @@ class PlaydatesTable(Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) lastplayed: Mapped[dt.datetime] = mapped_column(index=True) - track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id")) + track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) track: Mapped["TracksTable"] = relationship( "TracksTable", back_populates="playdates", @@ -104,7 +104,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", diff --git a/migrations/versions/ab475332d873_fix_playdates_cascades.py b/migrations/versions/ab475332d873_fix_playdates_cascades.py new file mode 100644 index 0000000..5e5cec8 --- /dev/null +++ b/migrations/versions/ab475332d873_fix_playdates_cascades.py @@ -0,0 +1,46 @@ +"""fix playlist cascades + +Revision ID: ab475332d873 +Revises: 04df697e40cd +Create Date: 2025-02-26 13:11:15.417278 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ab475332d873' +down_revision = '04df697e40cd' +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('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') + + # ### end Alembic commands ### + + +def downgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + 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']) + + # ### end Alembic commands ### + From 985629446a0ccb3bb54740ad0a53ba94528577d8 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 26 Feb 2025 13:34:10 +0000 Subject: [PATCH 18/27] Create queries table --- app/dbmanager.py | 3 +- app/dbtables.py | 49 ++++++++++++++++--- app/models.py | 19 +++++++ .../4fc2a9a82ab0_create_queries_table.py | 47 ++++++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/4fc2a9a82ab0_create_queries_table.py diff --git a/app/dbmanager.py b/app/dbmanager.py index 9f8c2ca..e80d6fd 100644 --- a/app/dbmanager.py +++ b/app/dbmanager.py @@ -18,7 +18,8 @@ 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() + # Database managed by Alembic so no create_all() required + # 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 14116e0..45e927b 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -1,6 +1,8 @@ # Standard library imports from typing import Optional +from dataclasses import asdict import datetime as dt +import json # PyQt imports @@ -13,13 +15,37 @@ from sqlalchemy import ( String, ) from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import ( Mapped, mapped_column, relationship, ) +from sqlalchemy.types import TypeDecorator, TEXT # App imports +from classes import Filter + + +class JSONEncodedDict(TypeDecorator): + """ + Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT) + """ + + impl = TEXT + + def process_bind_param(self, value: dict | None, dialect: Dialect) -> str | None: + """Convert Python dictionary to JSON string before saving.""" + if value is None: + return None + return json.dumps(value, default=lambda o: o.__dict__) + + def process_result_value(self, value: str | None, dialect: Dialect) -> dict | None: + """Convert JSON string back to Python dictionary after retrieval.""" + if value is None: + return None + return json.loads(value) # Database classes @@ -128,15 +154,24 @@ class QueriesTable(Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - sql: Mapped[str] = mapped_column( - String(2048), index=False, default="", nullable=False - ) - favourite: Mapped[bool] = mapped_column( - Boolean, nullable=False, index=False, default=False - ) + _filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=True) + favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False) + + def _get_filter(self) -> Filter: + """Convert stored JSON dictionary to a Filter object.""" + if isinstance(self._filter_data, dict): + return Filter(**self._filter_data) + return Filter() # Default object if None or invalid data + + def _set_filter(self, value: Filter | None) -> None: + """Convert a Filter object to JSON before storing.""" + self._filter_data = asdict(value) if isinstance(value, Filter) else None + + # Single definition of `filter` + filter = property(_get_filter, _set_filter) def __repr__(self) -> str: - return f"" + return f"" class SettingsTable(Model): diff --git a/app/models.py b/app/models.py index 535bf53..eed3241 100644 --- a/app/models.py +++ b/app/models.py @@ -608,6 +608,25 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session.connection().execute(stmt, sqla_map) +class Queries(dbtables.QueriesTable): + def __init__( + self, session: Session, name: str, filter: dbtables.Filter, favourite: bool = False + ) -> None: + """Create new query""" + + self.name = name + self.filter = filter + self.favourite = favourite + session.add(self) + session.commit() + + @classmethod + def get_all_queries(cls, session: Session) -> Sequence["Queries"]: + """Returns a list of all queries ordered by name""" + + return session.scalars(select(cls).order_by(cls.name)).all() + + class Settings(dbtables.SettingsTable): def __init__(self, session: Session, name: str) -> None: self.name = name diff --git a/migrations/versions/4fc2a9a82ab0_create_queries_table.py b/migrations/versions/4fc2a9a82ab0_create_queries_table.py new file mode 100644 index 0000000..dc71142 --- /dev/null +++ b/migrations/versions/4fc2a9a82ab0_create_queries_table.py @@ -0,0 +1,47 @@ +"""create queries table + +Revision ID: 4fc2a9a82ab0 +Revises: ab475332d873 +Create Date: 2025-02-26 13:13:25.118489 + +""" +from alembic import op +import sqlalchemy as sa +import dbtables + + +# revision identifiers, used by Alembic. +revision = '4fc2a9a82ab0' +down_revision = 'ab475332d873' +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('name', sa.String(length=128), nullable=False), + sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=True), + sa.Column('favourite', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('queries') + # ### end Alembic commands ### + From b4f5d92f5dde358a85fa9669e256a7dbd002aef7 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 26 Feb 2025 13:58:13 +0000 Subject: [PATCH 19/27] 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) ) From 82e707a6f67d899676d9615e8f06ec4744e36e0c Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Thu, 27 Feb 2025 08:12:48 +0000 Subject: [PATCH 20/27] Make filter field in queries table non-nullable --- app/dbtables.py | 2 +- migrations/versions/4fc2a9a82ab0_create_queries_table.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/dbtables.py b/app/dbtables.py index 45e927b..82a6331 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -154,7 +154,7 @@ class QueriesTable(Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - _filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=True) + _filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=False) favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False) def _get_filter(self) -> Filter: diff --git a/migrations/versions/4fc2a9a82ab0_create_queries_table.py b/migrations/versions/4fc2a9a82ab0_create_queries_table.py index dc71142..073a374 100644 --- a/migrations/versions/4fc2a9a82ab0_create_queries_table.py +++ b/migrations/versions/4fc2a9a82ab0_create_queries_table.py @@ -33,7 +33,7 @@ def upgrade_() -> None: op.create_table('queries', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=128), nullable=False), - sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=True), + sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=False), sa.Column('favourite', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('id') ) From 90d72464cb775642daab41fbb0f89bb53b38723e Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Thu, 27 Feb 2025 08:13:29 +0000 Subject: [PATCH 21/27] Clean up handling of separators in dynamic menu --- app/musicmuster.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index da4d93e..32ef292 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -847,7 +847,7 @@ class Window(QMainWindow): items = getattr(self, f"get_{key}_items")() for item in items: # Check for separator - if "separator" in item and item["separator"] == "separator": + if "separator" in item and item["separator"]: submenu.addSeparator() continue action = QAction(item["text"], self) @@ -865,7 +865,7 @@ class Window(QMainWindow): def get_new_playlist_dynamic_submenu_items( self, - ) -> list[dict[str, str | tuple[Session, int]]]: + ) -> 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 @@ -876,14 +876,14 @@ class Window(QMainWindow): """ with db.Session() as session: - submenu_items: list[dict[str, str | tuple[Session, int]]] = [ + submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ { "text": "Show all", "handler": "create_playlist_from_template", "args": (session, 0), }, { - "separator": "separator", + "separator": True, } ] templates = Playlists.get_favourite_templates(session) From aa6ab03555aee5db22b5887ad02ff9500865ae4b Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 28 Feb 2025 11:25:29 +0000 Subject: [PATCH 22/27] Make manage queries and manage templates into classes --- app/classes.py | 1 + app/helpers.py | 19 +- app/menu.yaml | 4 +- app/musicmuster.py | 505 +++++++++++++++++++++++++-------------------- 4 files changed, 300 insertions(+), 229 deletions(-) diff --git a/app/classes.py b/app/classes.py index 1120e73..aac3c94 100644 --- a/app/classes.py +++ b/app/classes.py @@ -80,6 +80,7 @@ class FileErrors(NamedTuple): @dataclass class Filter: + version: int = 1 path_type: str = "contains" path: Optional[str] = None last_played_number: Optional[int] = None diff --git a/app/helpers.py b/app/helpers.py index 0747af3..2971c08 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -10,7 +10,7 @@ import ssl import tempfile # PyQt imports -from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget +from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget # Third party imports from mutagen.flac import FLAC # type: ignore @@ -150,6 +150,23 @@ def get_audio_metadata(filepath: str) -> AudioMetadata: ) +def get_name(prompt: str, default: str = "") -> str | None: + """Get a name from the user""" + + dlg = QInputDialog() + dlg.setInputMode(QInputDialog.InputMode.TextInput) + dlg.setLabelText(prompt) + while True: + if default: + dlg.setTextValue(default) + dlg.resize(500, 100) + ok = dlg.exec() + if ok: + return dlg.textValue() + + return None + + def get_relative_date( past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None ) -> str: diff --git a/app/menu.yaml b/app/menu.yaml index f9787eb..4e728a0 100644 --- a/app/menu.yaml +++ b/app/menu.yaml @@ -4,10 +4,10 @@ menus: - text: "Save as Template" handler: "save_as_template" - text: "Manage Templates" - handler: "manage_templates" + handler: "manage_templates_wrapper" - separator: true - text: "Manage Queries" - handler: "manage_queries" + handler: "manage_queries_wrapper" - separator: true - text: "Exit" handler: "close" diff --git a/app/musicmuster.py b/app/musicmuster.py index 32ef292..bde7fbe 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -68,7 +68,7 @@ from classes import ( from config import Config from dialogs import TrackSelectDialog from file_importer import FileImporter -from helpers import file_is_unreadable +from helpers import file_is_unreadable, get_name from log import log from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks from music_manager import RowAndTrack, track_sequence @@ -270,7 +270,7 @@ class FilterDialog(QDialog): self.ok_button = QPushButton("OK") self.cancel_button = QPushButton("Cancel") self.cancel_button.clicked.connect(self.reject) - self.ok_button.clicked.connect(self.accept) + self.ok_button.clicked.connect(self.ok_clicked) button_layout.addWidget(self.ok_button) button_layout.addWidget(self.cancel_button) layout.addLayout(button_layout) @@ -278,7 +278,9 @@ class FilterDialog(QDialog): self.setLayout(layout) # Connect signals - self.last_played_combo.currentIndexChanged.connect(self.toggle_last_played_controls) + self.last_played_combo.currentIndexChanged.connect( + self.toggle_last_played_controls + ) self.toggle_last_played_controls() @@ -287,6 +289,21 @@ class FilterDialog(QDialog): self.last_played_spinbox.setDisabled(disabled) self.last_played_unit.setDisabled(disabled) + def ok_clicked(self) -> None: + """ + Update filter to match selections + """ + self.filter.path_type = self.path_combo.currentText() + self.filter.path = self.path_text.text() + self.filter.last_played_number = self.last_played_spinbox.value() + self.filter.last_played_type = self.last_played_combo.currentText() + self.filter.last_played_unit = self.last_played_unit.currentText() + self.filter.duration_type = self.duration_combo.currentText() + self.filter.duration_number = self.duration_spinbox.value() + self.filter.duration_unit = self.duration_unit.currentText() + + self.accept() + @dataclass class ItemlistItem: @@ -296,18 +313,14 @@ class ItemlistItem: class ItemlistManager(QDialog): - def __init__( - self, items: list[ItemlistItem], callbacks: ItemlistManagerCallbacks - ) -> None: + def __init__(self) -> None: super().__init__() self.setWindowTitle("Item Manager") self.setMinimumSize(600, 400) - self.items = items - self.callbacks = callbacks - layout = QVBoxLayout(self) - self.table = QTableWidget(len(items), 2, self) + self.table = QTableWidget(self) + self.table.setColumnCount(2) self.table.setHorizontalHeaderLabels(["Item", "Actions"]) hh = self.table.horizontalHeader() if not hh: @@ -316,8 +329,6 @@ class ItemlistManager(QDialog): self.table.setColumnWidth(0, 288) self.table.setColumnWidth(1, 300) - self.populate_table() - layout.addWidget(self.table) button_layout = QHBoxLayout() @@ -331,8 +342,10 @@ class ItemlistManager(QDialog): layout.addLayout(button_layout) - def populate_table(self) -> None: + def populate_table(self, items: list[ItemlistItem]) -> None: """Populates the table with items and action buttons.""" + + self.items = items self.table.setRowCount(len(self.items)) for row, item in enumerate(self.items): @@ -371,25 +384,35 @@ class ItemlistManager(QDialog): self.table.setCellWidget(row, 1, widget) def delete_item(self, item_id: int) -> None: - self.callbacks.delete(item_id) + """Subclass must implement this method""" + raise NotImplementedError def edit_item(self, item_id: int) -> None: - self.callbacks.edit(item_id) + """Subclass must implement this method""" + raise NotImplementedError + + def new_item(self) -> None: + """Subclass must implement this method""" + raise NotImplementedError def rename_item(self, item_id: int) -> None: - new_name = self.callbacks.rename(item_id) - if not new_name: - return - # Rename item in list + """Subclass must implement this method""" + raise NotImplementedError + + def change_text(self, item_id: int, new_text: str) -> None: + """ + Update text for one row + """ + for row in range(self.table.rowCount()): item = self.table.item(row, 0) if item and self.items[row].id == item_id: - item.setText(new_name) - self.items[row].name = new_name + item.setText(new_text) + self.items[row].name = new_text break def toggle_favourite(self, item_id: int, checked: bool) -> None: - self.callbacks.favourite(item_id, checked) + """Subclass must udpate database if required""" for row in range(self.table.rowCount()): item = self.table.item(row, 0) @@ -402,8 +425,230 @@ class ItemlistManager(QDialog): self.items[row].favourite = checked break + +class ManageQueries(ItemlistManager): + """ + Delete / edit queries + """ + + def __init__(self, session: Session, musicmuster: Window) -> None: + super().__init__() + + self.session = session + self.musicmuster = musicmuster + self.refresh_table() + self.exec() + + def refresh_table(self) -> None: + """ + Update table in widget + """ + + # Build a list of queries + query_list: list[ItemlistItem] = [] + + for query in Queries.get_all_queries(self.session): + query_list.append( + ItemlistItem(name=query.name, id=query.id, favourite=query.favourite) + ) + + self.populate_table(query_list) + + def delete_item(self, query_id: int) -> None: + """delete query""" + + query = self.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.debug(f"manage_queries: delete {query=}") + self.session.delete(query) + self.session.commit() + + self.refresh_table() + + def _edit_item(self, query: Queries) -> None: + """Edit query""" + + dlg = FilterDialog(query.name, query.filter) + if dlg.exec(): + query.filter = dlg.filter + self.session.commit() + + def edit_item(self, query_id: int) -> None: + """Edit query""" + + query = self.session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.edit_item({query_id=}) can't load query" + ) + return self._edit_item(query) + + def toggle_favourite(self, query_id: int, favourite: bool) -> None: + """Mark query as (not) favourite""" + + query = self.session.get(Queries, query_id) + if not query: + return + query.favourite = favourite + self.session.commit() + def new_item(self) -> None: - self.callbacks.new_item() + """Create new query""" + + query_name = get_name(prompt="New query name:") + if not query_name: + return + + query = Queries(self.session, query_name, Filter()) + self._edit_item(query) + self.refresh_table() + + def rename_item(self, query_id: int) -> None: + """rename query""" + + query = self.session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.delete({query_id=}) can't load query" + ) + new_name = get_name(prompt="New query name", default=query.name) + if not new_name: + return + + query.name = new_name + self.session.commit() + + self.change_text(query_id, new_name) + + +class ManageTemplates(ItemlistManager): + """ + Delete / edit templates + """ + + def __init__(self, session: Session, musicmuster: Window) -> None: + super().__init__() + + self.session = session + self.musicmuster = musicmuster + self.refresh_table() + self.exec() + + def refresh_table(self) -> None: + """ + Update table in widget + """ + + # Build a list of templates + template_list: list[ItemlistItem] = [] + + for template in Playlists.get_all_templates(self.session): + template_list.append( + ItemlistItem( + name=template.name, id=template.id, favourite=template.favourite + ) + ) + + self.populate_table(template_list) + + def delete_item(self, template_id: int) -> None: + """delete template""" + + template = self.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.musicmuster.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.musicmuster.playlist_section.tabPlaylist.removeTab(open_idx) + + log.debug(f"manage_templates: delete {template=}") + self.session.delete(template) + self.session.commit() + + def edit_item(self, template_id: int) -> None: + """Edit template""" + + template = self.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.musicmuster._open_playlist(template, is_template=True) + + def toggle_favourite(self, template_id: int, favourite: bool) -> None: + """Mark template as (not) favourite""" + + template = self.session.get(Playlists, template_id) + if not template: + return + template.favourite = favourite + self.session.commit() + + def new_item( + self, + ) -> None: + """Create new template""" + + # Get base template + template_id = self.musicmuster.solicit_template_to_use( + self.session, template_prompt="New template based upon:" + ) + if template_id is None: + return + + # Get new template name + name = self.musicmuster.get_playlist_name( + self.session, default="", prompt="New template name:" + ) + if not name: + return + + # Create playlist for template and mark is as a template + template = self.musicmuster._create_playlist(self.session, name, template_id) + template.is_template = True + self.session.commit() + + # Open it for editing + self.musicmuster._open_playlist(template, is_template=True) + + def rename_item(self, template_id: int) -> None: + """rename template""" + + template = self.session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.delete({template_id=}) can't load template" + ) + new_name = self.musicmuster.get_playlist_name(self.session, template.name) + if not new_name: + return + + template.name = new_name + self.session.commit() + + self.change_text(template_id, new_name) @dataclass @@ -884,7 +1129,7 @@ class Window(QMainWindow): }, { "separator": True, - } + }, ] templates = Playlists.get_favourite_templates(session) for template in templates: @@ -965,7 +1210,7 @@ class Window(QMainWindow): else: template_id = selected_template_id - playlist_name = self.solicit_name(session) + playlist_name = self.get_playlist_name(session) if not playlist_name: return @@ -1030,10 +1275,10 @@ class Window(QMainWindow): session.commit() helpers.show_OK("Template", "Template saved", self) - def solicit_name( + def get_playlist_name( self, session: Session, default: str = "", prompt: str = "Playlist name:" ) -> Optional[str]: - """Get name of new playlist from user""" + """Get a name from the user""" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) @@ -1080,213 +1325,21 @@ class Window(QMainWindow): # # # # # # # # # # Manage templates and queries # # # # # # # # # # - def manage_queries(self) -> None: + def manage_queries_wrapper(self): """ - Delete / edit queries + Simply instantiate the manage_queries class """ - # 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() + _ = ManageQueries(session, self) - def manage_templates(self) -> None: + def manage_templates_wrapper(self): """ - Delete / edit templates + Simply instantiate the manage_queries class """ - # 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() + _ = ManageTemplates(session, self) # # # # # # # # # # Miscellaneous functions # # # # # # # # # # @@ -1999,7 +2052,7 @@ class Window(QMainWindow): playlist_id = self.current.playlist_id playlist = session.get(Playlists, playlist_id) if playlist: - new_name = self.solicit_name(session, playlist.name) + new_name = self.get_playlist_name(session, playlist.name) if new_name: playlist.rename(session, new_name) idx = self.tabBar.currentIndex() From 8e48d63ebb5d56f00609db49c6ee680d79e78c26 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 2 Mar 2025 19:14:53 +0000 Subject: [PATCH 23/27] WIP: queries management Menus and management working. Wrong tracks showing up in queries. --- app/config.py | 12 ++ app/models.py | 48 ++++++- app/musicmuster.py | 311 ++++++++++++++++++++++++++++++++++++++++-- app/querylistmodel.py | 16 +-- 4 files changed, 364 insertions(+), 23 deletions(-) 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 From 67c48f5022246896d97ca5c09e582c562b4fdbae Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 4 Mar 2025 10:32:11 +0000 Subject: [PATCH 24/27] Select from query working (may need tidying) --- app/classes.py | 2 +- app/config.py | 6 +++-- app/models.py | 59 +++++++++++++++++++++++++++++++++++-------- app/musicmuster.py | 11 +++++--- app/querylistmodel.py | 5 ++-- 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/app/classes.py b/app/classes.py index aac3c94..2563919 100644 --- a/app/classes.py +++ b/app/classes.py @@ -84,7 +84,7 @@ class Filter: path_type: str = "contains" path: Optional[str] = None last_played_number: Optional[int] = None - last_played_type: str = "before" + last_played_comparator: str = "before" last_played_unit: str = "years" duration_type: str = "longer than" duration_number: int = 0 diff --git a/app/config.py b/app/config.py index 41f50b8..5d5ef6c 100644 --- a/app/config.py +++ b/app/config.py @@ -39,6 +39,7 @@ class Config(object): DISPLAY_SQL = False DO_NOT_IMPORT = "Do not import" ENGINE_OPTIONS = dict(pool_pre_ping=True) + # ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True) EPOCH = dt.datetime(1970, 1, 1) ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"] @@ -55,10 +56,11 @@ class Config(object): FILTER_DURATION_SHORTER = "shorter than" FILTER_PATH_CONTAINS = "contains" FILTER_PATH_EXCLUDING = "excluding" - FILTER_PLAYED_BEFORE = "before" + FILTER_PLAYED_COMPARATOR_ANYTIME = "Any time" + FILTER_PLAYED_COMPARATOR_BEFORE = "before" + FILTER_PLAYED_COMPARATOR_NEVER = "never" 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 diff --git a/app/models.py b/app/models.py index 80a1b98..92344ee 100644 --- a/app/models.py +++ b/app/models.py @@ -599,7 +599,11 @@ class PlaylistRows(dbtables.PlaylistRowsTable): class Queries(dbtables.QueriesTable): def __init__( - self, session: Session, name: str, filter: dbtables.Filter, favourite: bool = False + self, + session: Session, + name: str, + filter: dbtables.Filter, + favourite: bool = False, ) -> None: """Create new query""" @@ -620,9 +624,7 @@ class Queries(dbtables.QueriesTable): """Returns a list of favourite queries ordered by name""" return session.scalars( - select(cls) - .where(cls.favourite.is_(True)) - .order_by(cls.name) + select(cls).where(cls.favourite.is_(True)).order_by(cls.name) ).all() @@ -711,12 +713,16 @@ class Tracks(dbtables.TracksTable): ) @classmethod - def get_filtered_tracks(cls, session: Session, filter: Filter) -> Sequence["Tracks"]: + def get_filtered_tracks( + cls, session: Session, filter: Filter + ) -> Sequence["Tracks"]: """ Return tracks matching filter """ query = select(cls) + + # Path specification if filter.path: if filter.path_type == "contains": query = query.where(cls.path.ilike(f"%{filter.path}%")) @@ -724,14 +730,14 @@ class Tracks(dbtables.TracksTable): 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 + + # Duration specification 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: @@ -739,8 +745,41 @@ class Tracks(dbtables.TracksTable): else: raise ApplicationError(f"Can't process filter duration type ({filter=})") - records = session.scalars( - query).unique().all() + # Last played specification + if ( + filter.last_played_number + and filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME + ): + now = dt.datetime.now() + since = now - dt.timedelta(days=365 * filter.last_played_number) + if filter.duration_unit == Config.FILTER_PLAYED_DAYS: + since = now - dt.timedelta(days=filter.last_played_number) + elif filter.duration_unit == Config.FILTER_PLAYED_WEEKS: + since = now - dt.timedelta(days=7 * filter.last_played_number) + if filter.duration_unit == Config.FILTER_PLAYED_MONTHS: + since = now - dt.timedelta(days=30 * filter.last_played_number) + + if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: + # Select tracks that have never been played + query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where( + Playdates.id.is_(None) + ) + elif ( + filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_BEFORE + ): + subquery = ( + select( + Playdates.track_id, + func.max(Playdates.lastplayed).label("max_last_played"), + ) + .group_by(Playdates.track_id) + .subquery() + ) + query = query.join(subquery, Tracks.id == subquery.c.track_id).where( + subquery.c.max_last_played < since + ) + + records = session.scalars(query).unique().all() return records diff --git a/app/musicmuster.py b/app/musicmuster.py index fb46c5b..61fe8bd 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -48,7 +48,6 @@ from PyQt6.QtWidgets import ( QMenu, QMessageBox, QPushButton, - QSizePolicy, QSpinBox, QTableView, QTableWidget, @@ -215,10 +214,14 @@ class FilterDialog(QDialog): last_played_label = QLabel("Last played") self.last_played_combo = QComboBox() self.last_played_combo.addItems( - [Config.FILTER_PLAYED_BEFORE, Config.FILTER_PLAYED_NEVER] + [ + Config.FILTER_PLAYED_COMPARATOR_BEFORE, + Config.FILTER_PLAYED_COMPARATOR_NEVER, + Config.FILTER_PLAYED_COMPARATOR_ANYTIME, + ] ) for idx in range(self.last_played_combo.count()): - if self.last_played_combo.itemText(idx) == filter.last_played_type: + if self.last_played_combo.itemText(idx) == filter.last_played_comparator: self.last_played_combo.setCurrentIndex(idx) break @@ -316,7 +319,7 @@ class FilterDialog(QDialog): self.filter.path_type = self.path_combo.currentText() self.filter.path = self.path_text.text() self.filter.last_played_number = self.last_played_spinbox.value() - self.filter.last_played_type = self.last_played_combo.currentText() + self.filter.last_played_comparator = self.last_played_combo.currentText() self.filter.last_played_unit = self.last_played_unit.currentText() self.filter.duration_type = self.duration_combo.currentText() self.filter.duration_number = self.duration_spinbox.value() diff --git a/app/querylistmodel.py b/app/querylistmodel.py index a56f3d3..e2ed657 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -230,8 +230,9 @@ class QuerylistModel(QAbstractTableModel): try: results = Tracks.get_filtered_tracks(self.session, self.filter) for result in results: - if hasattr(result, "lastplayed"): - lastplayed = result["lastplayed"] + if hasattr(result, "playdates"): + pds = result.playdates + lastplayed = max([a.lastplayed for a in pds]) else: lastplayed = None queryrow = QueryRow( From 096889d6cb8cea8f19c9fb2f4046700a9959321d Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 4 Mar 2025 13:22:29 +0000 Subject: [PATCH 25/27] Fix up tests in light of recent changes --- tests/test_file_importer.py | 4 ++-- tests/test_models.py | 21 ++++++++------------- tests/test_playlistmodel.py | 24 ++++++++++++------------ tests/test_ui.py | 19 ++++++++++--------- 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/tests/test_file_importer.py b/tests/test_file_importer.py index feb9ee5..f49e557 100644 --- a/tests/test_file_importer.py +++ b/tests/test_file_importer.py @@ -57,8 +57,8 @@ class MyTestCase(unittest.TestCase): # Create a playlist for all tests playlist_name = "file importer playlist" with db.Session() as session: - playlist = Playlists(session, playlist_name) - cls.widget.create_playlist_tab(playlist) + playlist = Playlists(session=session, name=playlist_name, template_id=0) + cls.widget._open_playlist(playlist) # Create our musicstore cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp") diff --git a/tests/test_models.py b/tests/test_models.py index b03eef1..20deaee 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -108,7 +108,7 @@ class TestMMModels(unittest.TestCase): TEMPLATE_NAME = "my template" with db.Session() as session: - playlist = Playlists(session, "my playlist") + playlist = Playlists(session, "my playlist", template_id=0) assert playlist # test repr _ = str(playlist) @@ -119,23 +119,18 @@ class TestMMModels(unittest.TestCase): # create template Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME) - # test create template - _ = Playlists.create_playlist_from_template( - session, playlist, "my new name" - ) - # get all templates all_templates = Playlists.get_all_templates(session) assert len(all_templates) == 1 # Save as template creates new playlist assert all_templates[0] != playlist # test delete playlist - playlist.delete(session) + session.delete(playlist) def test_playlist_open_and_close(self): # We need a playlist with db.Session() as session: - playlist = Playlists(session, "my playlist") + playlist = Playlists(session, "my playlist", template_id=0) assert len(Playlists.get_open(session)) == 0 assert len(Playlists.get_closed(session)) == 1 @@ -155,8 +150,8 @@ class TestMMModels(unittest.TestCase): p1_name = "playlist one" p2_name = "playlist two" with db.Session() as session: - playlist1 = Playlists(session, p1_name) - _ = Playlists(session, p2_name) + playlist1 = Playlists(session, p1_name, template_id=0) + _ = Playlists(session, p2_name, template_id=0) all_playlists = Playlists.get_all(session) assert len(all_playlists) == 2 @@ -254,7 +249,7 @@ class TestMMModels(unittest.TestCase): with db.Session() as session: if Playlists.name_is_available(session, PLAYLIST_NAME): - playlist = Playlists(session, PLAYLIST_NAME) + playlist = Playlists(session, PLAYLIST_NAME, template_id=0) assert playlist assert Playlists.name_is_available(session, PLAYLIST_NAME) is False @@ -266,7 +261,7 @@ class TestMMModels(unittest.TestCase): with db.Session() as session: if Playlists.name_is_available(session, PLAYLIST_NAME): - playlist = Playlists(session, PLAYLIST_NAME) + playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0) plr = PlaylistRows(session, playlist.id, 1) assert plr @@ -279,7 +274,7 @@ class TestMMModels(unittest.TestCase): with db.Session() as session: if Playlists.name_is_available(session, PLAYLIST_NAME): - playlist = Playlists(session, PLAYLIST_NAME) + playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0) plr = PlaylistRows(session, playlist.id, 1) assert plr diff --git a/tests/test_playlistmodel.py b/tests/test_playlistmodel.py index 42778c0..4791d2c 100644 --- a/tests/test_playlistmodel.py +++ b/tests/test_playlistmodel.py @@ -34,8 +34,8 @@ class TestMMMiscTracks(unittest.TestCase): # Create a playlist and model with db.Session() as session: - self.playlist = Playlists(session, PLAYLIST_NAME) - self.model = playlistmodel.PlaylistModel(self.playlist.id) + self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0) + self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) for row in range(len(self.test_tracks)): track_path = self.test_tracks[row % len(self.test_tracks)] @@ -93,9 +93,9 @@ class TestMMMiscNoPlaylist(unittest.TestCase): def test_insert_track_new_playlist(self): # insert a track into a new playlist with db.Session() as session: - playlist = Playlists(session, self.PLAYLIST_NAME) + playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0) # Create a model - model = playlistmodel.PlaylistModel(playlist.id) + model = playlistmodel.PlaylistModel(playlist.id, is_template=False) # test repr _ = str(model) @@ -124,8 +124,8 @@ class TestMMMiscRowMove(unittest.TestCase): db.create_all() with db.Session() as session: - self.playlist = Playlists(session, self.PLAYLIST_NAME) - self.model = playlistmodel.PlaylistModel(self.playlist.id) + self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0) + self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) for row in range(self.ROWS_TO_CREATE): self.model.insert_row(proposed_row_number=row, note=str(row)) @@ -318,8 +318,8 @@ class TestMMMiscRowMove(unittest.TestCase): model_src = self.model with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id) + playlist_dst = Playlists(session, destination_playlist, template_id=0) + model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) for row in range(self.ROWS_TO_CREATE): model_dst.insert_row(proposed_row_number=row, note=str(row)) @@ -339,8 +339,8 @@ class TestMMMiscRowMove(unittest.TestCase): model_src = self.model with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id) + playlist_dst = Playlists(session, destination_playlist, template_id=0) + model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) for row in range(self.ROWS_TO_CREATE): model_dst.insert_row(proposed_row_number=row, note=str(row)) @@ -366,8 +366,8 @@ class TestMMMiscRowMove(unittest.TestCase): model_src = self.model with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id) + playlist_dst = Playlists(session, destination_playlist, template_id=0) + model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) for row in range(self.ROWS_TO_CREATE): model_dst.insert_row(proposed_row_number=row, note=str(row)) diff --git a/tests/test_ui.py b/tests/test_ui.py index 2d6f34b..508c700 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -90,8 +90,8 @@ class MyTestCase(unittest.TestCase): playlist_name = "test_init playlist" with db.Session() as session: - playlist = Playlists(session, playlist_name) - self.widget.create_playlist_tab(playlist) + playlist = Playlists(session, playlist_name, template_id=0) + self.widget._open_playlist(playlist, is_template=False) with self.qtbot.waitExposed(self.widget): self.widget.show() @@ -103,8 +103,8 @@ class MyTestCase(unittest.TestCase): playlist_name = "test_save_and_restore playlist" with db.Session() as session: - playlist = Playlists(session, playlist_name) - model = playlistmodel.PlaylistModel(playlist.id) + playlist = Playlists(session, playlist_name, template_id=0) + model = playlistmodel.PlaylistModel(playlist.id, is_template=False) # Add a track with a note model.insert_row( @@ -139,7 +139,7 @@ class MyTestCase(unittest.TestCase): # def test_meta_all_clear(qtbot, session): # # Create playlist -# playlist = models.Playlists(session, "my playlist") +# playlist = models.Playlists(session, "my playlist", template_id=0) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # # Add some tracks @@ -167,7 +167,8 @@ class MyTestCase(unittest.TestCase): # def test_meta(qtbot, session): # # Create playlist -# playlist = playlists.Playlists(session, "my playlist") +# playlist = playlists.Playlists(session, "my playlist", +# template_id=0) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # # Add some tracks @@ -248,7 +249,7 @@ class MyTestCase(unittest.TestCase): # def test_clear_next(qtbot, session): # # Create playlist -# playlist = models.Playlists(session, "my playlist") +# playlist = models.Playlists(session, "my playlist", template_id=0) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # # Add some tracks @@ -274,7 +275,7 @@ class MyTestCase(unittest.TestCase): # # Create playlist and playlist_tab # window = musicmuster.Window() -# playlist = models.Playlists(session, "test playlist") +# playlist = models.Playlists(session, "test playlist", template_id=0) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # # Add some tracks @@ -306,7 +307,7 @@ class MyTestCase(unittest.TestCase): # playlist_name = "test playlist" # # Create testing playlist # window = musicmuster.Window() -# playlist = models.Playlists(session, playlist_name) +# playlist = models.Playlists(session, playlist_name, template_id=0) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # idx = window.tabPlaylist.addTab(playlist_tab, playlist_name) # window.tabPlaylist.setCurrentIndex(idx) From 7fd655f96f8c82adef44e25bc96e4ee3d27342ee Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 5 Mar 2025 09:00:41 +0000 Subject: [PATCH 26/27] WIP: queries working, tests so far good --- app/classes.py | 2 +- app/dbtables.py | 2 +- app/models.py | 69 ++++++++++++++++------------- app/querylistmodel.py | 6 +-- tests/test_queries.py | 101 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 37 deletions(-) create mode 100644 tests/test_queries.py diff --git a/app/classes.py b/app/classes.py index 2563919..e0e54ac 100644 --- a/app/classes.py +++ b/app/classes.py @@ -83,7 +83,7 @@ class Filter: version: int = 1 path_type: str = "contains" path: Optional[str] = None - last_played_number: Optional[int] = None + last_played_number: int = 0 last_played_comparator: str = "before" last_played_unit: str = "years" duration_type: str = "longer than" diff --git a/app/dbtables.py b/app/dbtables.py index 82a6331..6d80d57 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -197,7 +197,7 @@ class TracksTable(Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) artist: Mapped[str] = mapped_column(String(256), index=True) - bitrate: Mapped[Optional[int]] = mapped_column(default=None) + bitrate: Mapped[int] = mapped_column(default=None) duration: Mapped[int] = mapped_column(index=True) fade_at: Mapped[int] = mapped_column(index=False) intro: Mapped[Optional[int]] = mapped_column(default=None) diff --git a/app/models.py b/app/models.py index 92344ee..e3a756b 100644 --- a/app/models.py +++ b/app/models.py @@ -128,10 +128,15 @@ class NoteColours(dbtables.NoteColoursTable): class Playdates(dbtables.PlaydatesTable): - def __init__(self, session: Session, track_id: int) -> None: + def __init__( + self, session: Session, track_id: int, when: Optional[dt.datetime] = None + ) -> None: """Record that track was played""" - self.lastplayed = dt.datetime.now() + if not when: + self.lastplayed = dt.datetime.now() + else: + self.lastplayed = when self.track_id = track_id session.add(self) session.commit() @@ -745,39 +750,39 @@ class Tracks(dbtables.TracksTable): else: raise ApplicationError(f"Can't process filter duration type ({filter=})") - # Last played specification - if ( - filter.last_played_number - and filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME - ): + # Process comparator + if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: + # Select tracks that have never been played + query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where( + Playdates.id.is_(None) + ) + else: + # Last played specification now = dt.datetime.now() - since = now - dt.timedelta(days=365 * filter.last_played_number) - if filter.duration_unit == Config.FILTER_PLAYED_DAYS: - since = now - dt.timedelta(days=filter.last_played_number) - elif filter.duration_unit == Config.FILTER_PLAYED_WEEKS: - since = now - dt.timedelta(days=7 * filter.last_played_number) - if filter.duration_unit == Config.FILTER_PLAYED_MONTHS: - since = now - dt.timedelta(days=30 * filter.last_played_number) + # Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME + before = now + # If not ANYTIME, set 'before' appropriates + if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME: + if filter.last_played_unit == Config.FILTER_PLAYED_DAYS: + before = now - dt.timedelta(days=filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS: + before = now - dt.timedelta(days=7 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS: + before = now - dt.timedelta(days=30 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS: + before = now - dt.timedelta(days=365 * filter.last_played_number) - if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: - # Select tracks that have never been played - query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where( - Playdates.id.is_(None) - ) - elif ( - filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_BEFORE - ): - subquery = ( - select( - Playdates.track_id, - func.max(Playdates.lastplayed).label("max_last_played"), - ) - .group_by(Playdates.track_id) - .subquery() - ) - query = query.join(subquery, Tracks.id == subquery.c.track_id).where( - subquery.c.max_last_played < since + subquery = ( + select( + Playdates.track_id, + func.max(Playdates.lastplayed).label("max_last_played"), ) + .group_by(Playdates.track_id) + .subquery() + ) + query = query.join(subquery, Tracks.id == subquery.c.track_id).where( + subquery.c.max_last_played < before + ) records = session.scalars(query).unique().all() diff --git a/app/querylistmodel.py b/app/querylistmodel.py index e2ed657..995972b 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -230,11 +230,11 @@ class QuerylistModel(QAbstractTableModel): try: results = Tracks.get_filtered_tracks(self.session, self.filter) for result in results: + lastplayed = None if hasattr(result, "playdates"): pds = result.playdates - lastplayed = max([a.lastplayed for a in pds]) - else: - lastplayed = None + if pds: + lastplayed = max([a.lastplayed for a in pds]) queryrow = QueryRow( artist=result.artist, bitrate=result.bitrate or 0, diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 0000000..246fcc2 --- /dev/null +++ b/tests/test_queries.py @@ -0,0 +1,101 @@ +# Standard library imports +import datetime as dt +import unittest + +# PyQt imports + + +# Third party imports + +# App imports +from app.models import ( + db, + Playdates, + Tracks, +) +from classes import ( + Filter, +) + + +class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Runs once before any test in this class""" + + db.create_all() + + with db.Session() as session: + # Create some track entries + _ = Tracks(**dict( + session=session, + artist="a", + bitrate=0, + duration=100, + fade_at=0, + path="/alpha/bravo/charlie", + silence_at=0, + start_gap=0, + title="abc" + )) + track2 = Tracks(**dict( + session=session, + artist="a", + bitrate=0, + duration=100, + fade_at=0, + path="/xray/yankee/zulu", + silence_at=0, + start_gap=0, + title="xyz" + )) + track2_id = track2.id + # Add playdates + # Track 2 played just over a year ago + just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367) + _ = Playdates(session, track2_id, when=just_over_a_year_ago) + + @classmethod + def tearDownClass(cls): + """Runs once after all tests""" + + db.drop_all() + + def setUp(self): + """Runs before each test""" + + pass + + def tearDown(self): + """Runs after each test""" + + pass + + def test_search_path_1(self): + """Search for unplayed track""" + + filter = Filter(path="alpha", last_played_comparator="never") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'alpha' in results[0].path + + def test_search_path_2(self): + """Search for unplayed track that doesn't exist""" + + filter = Filter(path="xray", last_played_comparator="never") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 0 + + def test_played_over_a_year_ago(self): + """Search for tracks played over a year ago""" + + filter = Filter(last_played_unit="years", last_played_number=1) + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'zulu' in results[0].path From 1cf75a5d425b3a170028b6d864fd30186b687dce Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 5 Mar 2025 14:27:19 +0000 Subject: [PATCH 27/27] More query tests and remove Optional from Filter --- app/classes.py | 2 +- tests/test_queries.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/classes.py b/app/classes.py index e0e54ac..6909986 100644 --- a/app/classes.py +++ b/app/classes.py @@ -82,7 +82,7 @@ class FileErrors(NamedTuple): class Filter: version: int = 1 path_type: str = "contains" - path: Optional[str] = None + path: str = "" last_played_number: int = 0 last_played_comparator: str = "before" last_played_unit: str = "years" diff --git a/tests/test_queries.py b/tests/test_queries.py index 246fcc2..1e84a61 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -99,3 +99,32 @@ class MyTestCase(unittest.TestCase): results = Tracks.get_filtered_tracks(session, filter) assert len(results) == 1 assert 'zulu' in results[0].path + + def test_played_over_two_years_ago(self): + """Search for tracks played over 2 years ago""" + + filter = Filter(last_played_unit="years", last_played_number=2) + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 0 + + def test_never_played(self): + """Search for tracks never played""" + + filter = Filter(last_played_comparator="never") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'alpha' in results[0].path + + def test_played_anytime(self): + """Search for tracks played over a year ago""" + + filter = Filter(last_played_comparator="Any time") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'zulu' in results[0].path