From f4a5ecf79e86a1563efd44093252d165d431dfb1 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 7 Feb 2025 17:03:48 +0000 Subject: [PATCH] WIP: query tabs --- app/classes.py | 31 + app/dbmanager.py | 1 - app/models.py | 48 +- app/musicmuster.py | 39 +- app/playlists.py | 24 +- app/querylistmodel.py | 694 ++++++++++++++ app/querylists.py | 892 ++++++++++++++++++ app/ui/main_window.ui | 15 +- app/ui/main_window_ui.py | 15 +- migrations/env.py.DEBUG | 28 + ...4f2d4c88a5_add_data_for_query_playlists.py | 52 + 11 files changed, 1808 insertions(+), 31 deletions(-) create mode 100644 app/querylistmodel.py create mode 100644 app/querylists.py create mode 100644 migrations/env.py.DEBUG create mode 100644 migrations/versions/014f2d4c88a5_add_data_for_query_playlists.py diff --git a/app/classes.py b/app/classes.py index c625165..f10de9f 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 = auto() + 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/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/models.py b/app/models.py index 4168670..a4443e3 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,7 @@ 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() +# db.create_all() # Database classes @@ -236,10 +236,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 +270,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 +282,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 +332,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 1b661e2..1da9181 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -62,7 +62,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, QuerylistProxyModel 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 @@ -579,6 +581,7 @@ class Window(QMainWindow, Ui_MainWindow): self.actionMoveUnplayed.triggered.connect(self.move_unplayed) self.actionNewPlaylist.triggered.connect(self.new_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist) + self.actionOpenQuerylist.triggered.connect(self.open_querylist) self.actionPaste.triggered.connect(self.paste_rows) self.actionPlay_next.triggered.connect(self.play_next) self.actionRenamePlaylist.triggered.connect(self.rename_playlist) @@ -648,7 +651,7 @@ class Window(QMainWindow, Ui_MainWindow): 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. """ @@ -667,6 +670,27 @@ class Window(QMainWindow, Ui_MainWindow): 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) + proxy_model = QuerylistProxyModel() + proxy_model.setSourceModel(base_model) + + # Create tab + querylist_tab = QuerylistTab(musicmuster=self, model=proxy_model) + idx = self.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 @@ -1116,6 +1140,19 @@ class Window(QMainWindow, Ui_MainWindow): self.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.tabPlaylist.setCurrentIndex(idx) + def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" diff --git a/app/playlists.py b/app/playlists.py index 6b32213..80cdd5d 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -37,7 +37,7 @@ from PyQt6.QtWidgets import ( # App imports from audacity_controller import AudacityController -from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo +from classes import ApplicationError, QueryCol, MusicMusterSignals, PlaylistStyle, TrackInfo from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -112,7 +112,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 +248,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 +268,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..a16ac0c --- /dev/null +++ b/app/querylistmodel.py @@ -0,0 +1,694 @@ +# Standard library imports +# Allow forward reference to PlaylistModel +from __future__ import annotations + +from dataclasses import dataclass +from operator import attrgetter +from random import shuffle +from typing import cast, Optional +import datetime as dt + +# PyQt imports +from PyQt6.QtCore import ( + QAbstractTableModel, + QModelIndex, + QObject, + QRegularExpression, + QSortFilterProxyModel, + Qt, + QTimer, + QVariant, +) +from PyQt6.QtGui import ( + QBrush, + QColor, + QFont, +) + +# Third party imports +import line_profiler +import obswebsocket # type: ignore + +# import snoop # type: ignore + +# App imports +from classes import ( + QueryCol, + MusicMusterSignals, +) +from config import Config +from helpers import ( + ask_yes_no, + file_is_unreadable, + get_embedded_time, + get_relative_date, + ms_to_mmss, + remove_substring_case_insensitive, + set_track_metadata, +) +from log import log +from models import db, NoteColours, Playdates, PlaylistRows, Tracks +from music_manager import RowAndTrack, track_sequence + + +@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.signals = MusicMusterSignals() + + self.signals.begin_reset_model_signal.connect(self.begin_reset_model) + self.signals.end_reset_model_signal.connect(self.end_reset_model) + + 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) -> QBrush: + """Return background setting""" + + # Unreadable track file + if file_is_unreadable(qrow.path): + return QBrush(QColor(Config.COLOUR_UNREADABLE)) + + # Individual cell colouring + if column == QueryCol.BITRATE.value: + if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD: + return QBrush(QColor(Config.COLOUR_BITRATE_LOW)) + elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD: + return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM)) + else: + return QBrush(QColor(Config.COLOUR_BITRATE_OK)) + + return QBrush() + + def begin_reset_model(self, playlist_id: int) -> None: + """ + Reset model if playlist_id is ours + """ + + if playlist_id != self.playlist_id: + return + super().beginResetModel() + + 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 = { + int(Qt.ItemDataRole.BackgroundRole): self.background_role, + int(Qt.ItemDataRole.DisplayRole): self.display_role, + int(Qt.ItemDataRole.EditRole): self.edit_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.CheckStateRole, + Qt.ItemDataRole.DecorationRole, + 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 end_reset_model(self, playlist_id: int) -> None: + """ + End model reset if this is our playlist + """ + + log.debug(f"{self}: end_reset_model({playlist_id=})") + + if playlist_id != self.playlist_id: + log.debug(f"{self}: end_reset_model: not us ({self.playlist_id=})") + return + with db.Session() as session: + self.refresh_data(session) + super().endResetModel() + self.reset_track_sequence_row_numbers() + + def edit_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: + """ + Return text for editing + """ + + if column == QueryCol.TITLE.value: + return QVariant(qrow.title) + if column == QueryCol.ARTIST.value: + return QVariant(qrow.artist) + + return QVariant() + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + """ + Standard model flags + """ + + default = ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + ) + if index.column() in [ + QueryCol.TITLE.value, + QueryCol.ARTIST.value, + ]: + return default | Qt.ItemFlag.ItemIsEditable + + return default + + def get_row_info(self, row_number: int) -> RowAndTrack: + """ + Return info about passed row + """ + + return self.querylist_rows[row_number] + + def get_row_track_id(self, row_number: int) -> Optional[int]: + """ + Return id of track associated with row or None if no track associated + """ + + return self.querylist_rows[row_number].track_id + + def get_row_track_path(self, row_number: int) -> str: + """ + Return path of track associated with row or empty string if no track associated + """ + + return self.querylist_rows[row_number].path + + def get_rows_duration(self, row_numbers: list[int]) -> int: + """ + Return the total duration of the passed rows + """ + + duration = 0 + for row_number in row_numbers: + duration += self.querylist_rows[row_number].duration + + return duration + + def invalidate_row(self, modified_row: int) -> None: + """ + Signal to view to refresh invalidated row + """ + + self.dataChanged.emit( + self.index(modified_row, 0), + self.index(modified_row, self.columnCount() - 1), + ) + + def invalidate_rows(self, modified_rows: list[int]) -> None: + """ + Signal to view to refresh invlidated rows + """ + + for modified_row in modified_rows: + self.invalidate_row(modified_row) + + 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 + + + +# # Note where each playlist_id is +# plid_to_row: dict[int, int] = {} +# for oldrow in self.playlist_rows: +# plrdata = self.playlist_rows[oldrow] +# plid_to_row[plrdata.playlistrow_id] = plrdata.row_number + +# # build a new playlist_rows +# new_playlist_rows: dict[int, RowAndTrack] = {} +# for p in PlaylistRows.get_playlist_rows(session, self.playlist_id): +# if p.id not in plid_to_row: +# new_playlist_rows[p.row_number] = RowAndTrack(p) +# else: +# new_playlist_rows[p.row_number] = self.playlist_rows[plid_to_row[p.id]] +# new_playlist_rows[p.row_number].row_number = p.row_number + +# # Copy to self.playlist_rows +# self.playlist_rows = new_playlist_rows + + def move_rows( + self, + from_rows: list[int], + to_row_number: int, + dummy_for_profiling: Optional[int] = None, + ) -> None: + """ + Move the playlist rows given to to_row and below. + """ + + log.debug(f"{self}: move_rows({from_rows=}, {to_row_number=}") + + # Build a {current_row_number: new_row_number} dictionary + row_map: dict[int, int] = {} + + # The destination row number will need to be reduced by the + # number of rows being move from above the destination row + # otherwise rows below the destination row will end up above the + # moved rows. + adjusted_to_row = to_row_number - len( + [a for a in from_rows if a < to_row_number] + ) + + # Put the from_row row numbers into the row_map. Ultimately the + # total number of elements in the playlist doesn't change, so + # check that adding the moved rows starting at to_row won't + # overshoot the end of the playlist. + if adjusted_to_row + len(from_rows) > len(self.querylist_rows): + next_to_row = len(self.querylist_rows) - len(from_rows) + else: + next_to_row = adjusted_to_row + + # zip iterates from_row and to_row simultaneously from the + # respective sequences inside zip() + for from_row, to_row in zip( + from_rows, range(next_to_row, next_to_row + len(from_rows)) + ): + row_map[from_row] = to_row + + # Move the remaining rows to the row_map. We want to fill it + # before (if there are gaps) and after (likewise) the rows that + # are moving. + # zip iterates old_row and new_row simultaneously from the + # respective sequences inside zip() + for old_row, new_row in zip( + [x for x in self.querylist_rows.keys() if x not in from_rows], + [y for y in range(len(self.querylist_rows)) if y not in row_map.values()], + ): + # Optimise: only add to map if there is a change + if old_row != new_row: + row_map[old_row] = new_row + + # For SQLAlchemy, build a list of dictionaries that map playlistrow_id to + # new row number: + sqla_map: list[dict[str, int]] = [] + for oldrow, newrow in row_map.items(): + playlistrow_id = self.querylist_rows[oldrow].playlistrow_id + sqla_map.append({"playlistrow_id": playlistrow_id, "row_number": newrow}) + + with db.Session() as session: + PlaylistRows.update_plr_row_numbers(session, self.playlist_id, sqla_map) + session.commit() + # Update playlist_rows + self.refresh_data(session) + + # Update display + self.reset_track_sequence_row_numbers() + self.invalidate_rows(list(row_map.keys())) + + @line_profiler.profile + def move_rows_between_playlists( + self, + from_rows: list[int], + to_row_number: int, + to_playlist_id: int, + dummy_for_profiling: Optional[int] = None, + ) -> None: + """ + Move the playlist rows given to to_row and below of to_playlist. + """ + + log.debug( + f"{self}: move_rows_between_playlists({from_rows=}, " + f"{to_row_number=}, {to_playlist_id=}" + ) + + # Row removal must be wrapped in beginRemoveRows .. + # endRemoveRows and the row range must be contiguous. Process + # the highest rows first so the lower row numbers are unchanged + row_groups = self._reversed_contiguous_row_groups(from_rows) + + # Prepare destination playlist for a reset + self.signals.begin_reset_model_signal.emit(to_playlist_id) + + with db.Session() as session: + for row_group in row_groups: + # Make room in destination playlist + max_destination_row_number = PlaylistRows.get_last_used_row( + session, to_playlist_id + ) + if ( + max_destination_row_number + and to_row_number <= max_destination_row_number + ): + # Move the destination playlist rows down to make room. + PlaylistRows.move_rows_down( + session, to_playlist_id, to_row_number, len(row_group) + ) + next_to_row = to_row_number + + super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group)) + for playlist_row in PlaylistRows.plrids_to_plrs( + session, + self.playlist_id, + [self.querylist_rows[a].playlistrow_id for a in row_group], + ): + if ( + track_sequence.current + and playlist_row.id == track_sequence.current.playlistrow_id + ): + # Don't move current track + continue + playlist_row.playlist_id = to_playlist_id + playlist_row.row_number = next_to_row + next_to_row += 1 + self.refresh_data(session) + super().endRemoveRows() + # We need to remove gaps in row numbers after tracks have + # moved. + PlaylistRows.fixup_rownumbers(session, self.playlist_id) + self.refresh_data(session) + session.commit() + + # Reset of model must come after session has been closed + self.reset_track_sequence_row_numbers() + self.signals.end_reset_model_signal.emit(to_playlist_id) + + def reset_track_sequence_row_numbers(self) -> None: + """ + Signal handler for when row ordering has changed. + + Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will + be correctly updated with change of row number, but track_sequence.next will still + contain row_number==4. This function fixes up the track_sequence row numbers by + looking up the playlistrow_id and retrieving the row number from the database. + """ + + log.debug(f"{self}: reset_track_sequence_row_numbers()") + + # Check the track_sequence.next, current and previous plrs and + # update the row number + with db.Session() as session: + for ts in [ + track_sequence.next, + track_sequence.current, + track_sequence.previous, + ]: + if ts: + ts.update_playlist_and_row(session) + + def _reversed_contiguous_row_groups( + self, row_numbers: list[int] + ) -> list[list[int]]: + """ + Take the list of row numbers and split into groups of contiguous rows. Return as a list + of lists with the highest row numbers first. + + Example: + input [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21] + return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]] + """ + + log.debug(f"{self}: _reversed_contiguous_row_groups({row_numbers=} called") + + result: list[list[int]] = [] + temp: list[int] = [] + last_value = row_numbers[0] - 1 + + for idx in range(len(row_numbers)): + if row_numbers[idx] != last_value + 1: + result.append(temp) + temp = [] + last_value = row_numbers[idx] + temp.append(last_value) + if temp: + result.append(temp) + result.reverse() + + log.debug(f"{self}: _reversed_contiguous_row_groups() returned: {result=}") + return result + + def rowCount(self, index: QModelIndex = QModelIndex()) -> int: + """Standard function for view""" + + return len(self.querylist_rows) + + def selection_is_sortable(self, row_numbers: list[int]) -> bool: + """ + Return True if the selection is sortable. That means: + - at least two rows selected + - selected rows are contiguous + """ + + # at least two rows selected + if len(row_numbers) < 2: + return False + + # selected rows are contiguous + if sorted(row_numbers) != list(range(min(row_numbers), max(row_numbers) + 1)): + return False + + return True + + def setData( + self, + index: QModelIndex, + value: str | float, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + """ + Update model with edited data + """ + + if not index.isValid() or role != Qt.ItemDataRole.EditRole: + return False + + row_number = index.row() + column = index.column() + + with db.Session() as session: + playlist_row = session.get( + PlaylistRows, self.querylist_rows[row_number].playlistrow_id + ) + if not playlist_row: + log.error( + f"{self}: Error saving data: {row_number=}, {column=}, {value=}" + ) + return False + + if column in [QueryCol.TITLE.value, QueryCol.ARTIST.value]: + track = session.get(Tracks, playlist_row.track_id) + if not track: + log.error(f"{self}: Error retreiving track: {playlist_row=}") + return False + if column == QueryCol.TITLE.value: + track.title = str(value) + elif column == QueryCol.ARTIST.value: + track.artist = str(value) + else: + log.error(f"{self}: Error updating track: {column=}, {value=}") + return False + + # commit changes before refreshing data + session.commit() + self.refresh_row(session, row_number) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role]) + + return True + + def sort_by_artist(self, row_numbers: list[int]) -> None: + """ + Sort selected rows by artist + """ + + self.sort_by_attribute(row_numbers, "artist") + + def sort_by_attribute(self, row_numbers: list[int], attr_name: str) -> None: + """ + Sort selected rows by passed attribute name where 'attribute' is a + key in PlaylistRowData + """ + + # Create a subset of playlist_rows with the rows we are + # interested in + shortlist_rows = {k: self.querylist_rows[k] for k in row_numbers} + sorted_list = [ + playlist_row.row_number + for playlist_row in sorted( + shortlist_rows.values(), key=attrgetter(attr_name) + ) + ] + self.move_rows(sorted_list, min(sorted_list)) + + def sort_by_duration(self, row_numbers: list[int]) -> None: + """ + Sort selected rows by duration + """ + + self.sort_by_attribute(row_numbers, "duration") + + def sort_by_lastplayed(self, row_numbers: list[int]) -> None: + """ + Sort selected rows by lastplayed + """ + + self.sort_by_attribute(row_numbers, "lastplayed") + + def sort_randomly(self, row_numbers: list[int]) -> None: + """ + Sort selected rows randomly + """ + + shuffle(row_numbers) + self.move_rows(row_numbers, min(row_numbers)) + + def sort_by_title(self, row_numbers: list[int]) -> None: + """ + Sort selected rows by title + """ + + self.sort_by_attribute(row_numbers, "title") + + +class QuerylistProxyModel(QSortFilterProxyModel): + """ + For searching and filtering + """ + + def __init__( + self, + ) -> None: + super().__init__() + + # Search all columns + self.setFilterKeyColumn(-1) + + def __repr__(self) -> str: + return f"" + + def set_incremental_search(self, search_string: str) -> None: + """ + Update search pattern + """ + + self.setFilterRegularExpression( + QRegularExpression( + search_string, QRegularExpression.PatternOption.CaseInsensitiveOption + ) + ) + + def sourceModel(self) -> QuerylistModel: + """ + Override sourceModel to return correct type + """ + + return cast(QuerylistModel, super().sourceModel()) diff --git a/app/querylists.py b/app/querylists.py new file mode 100644 index 0000000..1b91892 --- /dev/null +++ b/app/querylists.py @@ -0,0 +1,892 @@ +# Standard library imports +from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING + +# PyQt imports +from PyQt6.QtCore import ( + QAbstractItemModel, + QEvent, + QModelIndex, + QObject, + QItemSelection, + QSize, + Qt, + QTimer, +) +from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument +from PyQt6.QtWidgets import ( + QAbstractItemDelegate, + QAbstractItemView, + QApplication, + QDoubleSpinBox, + QFrame, + QMenu, + QMessageBox, + QProxyStyle, + QStyle, + QStyledItemDelegate, + QStyleOption, + QStyleOptionViewItem, + QTableView, + QTableWidgetItem, + QTextEdit, + QWidget, +) + +# Third party imports +import line_profiler + +# App imports +from audacity_controller import AudacityController +from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo +from config import Config +from dialogs import TrackSelectDialog +from helpers import ( + ask_yes_no, + ms_to_mmss, + show_OK, + show_warning, +) +from log import log +from models import db, Settings +from music_manager import track_sequence +from querylistmodel import QuerylistModel, QuerylistProxyModel + +if TYPE_CHECKING: + from musicmuster import Window + + +class QuerylistTab(QTableView): + """ + The querylist view + """ + + def __init__(self, musicmuster: "Window", model: QuerylistProxyModel) -> None: + super().__init__() + + # Save passed settings + self.musicmuster = musicmuster + + self.playlist_id = model.sourceModel().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) + + # Prepare for context menu + self.menu = QMenu() + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._context_menu) + + # Connect signals + self.signals = MusicMusterSignals() + self.signals.resize_rows_signal.connect(self.resize_rows) + self.signals.span_cells_signal.connect(self._span_cells) + + # Selection model + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + + # 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 closeEditor( + self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint + ) -> None: + """ + Override closeEditor to enable play controls and update display. + """ + + self.musicmuster.action_Clear_selection.setEnabled(True) + + super(PlaylistTab, self).closeEditor(editor, hint) + + # Optimise row heights after increasing row height for editing + self.resize_rows() + + # Update start times in case a start time in a note has been + # edited + self.get_base_model().update_track_times() + + # Deselect edited line + self.clear_selection() + + @line_profiler.profile + def dropEvent( + self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None + ) -> None: + """ + Move dropped rows + """ + + if not event: + return + + if event.source() is not self or ( + event.dropAction() != Qt.DropAction.MoveAction + and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove + ): + return super().dropEvent(event) + + from_rows = self.selected_model_row_numbers() + to_index = self.indexAt(event.position().toPoint()) + + # The drop indicator can either be immediately below a row or + # immediately above a row. There's about a 1 pixel difference, + # but we always want to drop between rows regardless of where + # drop indicator is. + if ( + self.dropIndicatorPosition() + == QAbstractItemView.DropIndicatorPosition.BelowItem + ): + # Drop on the row below + next_row = to_index.row() + 1 + if next_row < self.model().rowCount(): # Ensure the row exists + destination_index = to_index.siblingAtRow(next_row) + else: + # Handle edge case where next_row is beyond the last row + destination_index = to_index + else: + destination_index = to_index + + to_model_row = self.model().mapToSource(destination_index).row() + log.debug( + f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}" + ) + + # Sanity check + base_model_row_count = self.get_base_model().rowCount() + if ( + 0 <= min(from_rows) <= base_model_row_count + and 0 <= to_model_row <= base_model_row_count + ): + # If we move a row to immediately under the current track, make + # that moved row the next track + set_next_row: Optional[int] = None + if ( + track_sequence.current + and to_model_row == track_sequence.current.row_number + 1 + ): + set_next_row = to_model_row + + self.get_base_model().move_rows(from_rows, to_model_row) + + # Reset drag mode to allow row selection by dragging + self.setDragEnabled(False) + + # Deselect rows + self.clear_selection() + + # Resize rows + self.resize_rows() + + # Set next row if we are immediately under current row + if set_next_row: + self.get_base_model().set_next_row(set_next_row) + + event.accept() + + def mouseReleaseEvent(self, event): + """ + Enable dragging if rows are selected + """ + + if self.selectedIndexes(): + self.setDragEnabled(True) + else: + self.setDragEnabled(False) + self.reset() + super().mouseReleaseEvent(event) + + 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) + + def selectionChanged( + self, selected: QItemSelection, deselected: QItemSelection + ) -> None: + """ + Toggle drag behaviour according to whether rows are selected + """ + + selected_rows = self.get_selected_rows() + self.musicmuster.current.selected_rows = selected_rows + + # If no rows are selected, we have nothing to do + if len(selected_rows) == 0: + self.musicmuster.lblSumPlaytime.setText("") + else: + if not self.musicmuster.disable_selection_timing: + selected_duration = self.get_base_model().get_rows_duration( + self.get_selected_rows() + ) + if selected_duration > 0: + self.musicmuster.lblSumPlaytime.setText( + f"Selected duration: {ms_to_mmss(selected_duration)}" + ) + else: + self.musicmuster.lblSumPlaytime.setText("") + else: + log.debug( + f"playlists.py.selectionChanged: {self.musicmuster.disable_selection_timing=}" + ) + + super().selectionChanged(selected, deselected) + + # ########## Custom functions ########## + def _add_context_menu( + self, + text: str, + action: Callable, + disabled: bool = False, + parent_menu: Optional[QMenu] = None, + ) -> Optional[QAction]: + """ + Add item to self.menu + """ + + if parent_menu is None: + parent_menu = self.menu + + menu_item = parent_menu.addAction(text) + if not menu_item: + return None + menu_item.setDisabled(disabled) + menu_item.triggered.connect(action) + + return menu_item + + def _add_track(self) -> None: + """Add a track to a section header making it a normal track row""" + + model_row_number = self.source_model_selected_row_number() + if model_row_number is None: + return + + with db.Session() as session: + dlg = TrackSelectDialog( + parent=self.musicmuster, + session=session, + new_row_number=model_row_number, + base_model=self.get_base_model(), + add_to_header=True, + ) + dlg.exec() + session.commit() + + def _build_context_menu(self, item: QTableWidgetItem) -> None: + """Used to process context (right-click) menu, which is defined here""" + + self.menu.clear() + + index = self.model().index(item.row(), item.column()) + model_row_number = self.model().mapToSource(index).row() + base_model = self.get_base_model() + + header_row = self.get_base_model().is_header_row(model_row_number) + track_row = not header_row + if track_sequence.current: + this_is_current_row = model_row_number == track_sequence.current.row_number + else: + this_is_current_row = False + if track_sequence.next: + this_is_next_row = model_row_number == track_sequence.next.row_number + else: + this_is_next_row = False + track_path = base_model.get_row_info(model_row_number).path + + # Open/import in/from Audacity + if track_row and not this_is_current_row: + if self.ac and track_path == self.ac.path: + # This track was opened in Audacity + self._add_context_menu( + "Update from Audacity", + lambda: self._import_from_audacity(model_row_number), + ) + self._add_context_menu( + "Cancel Audacity", + lambda: self._cancel_audacity(), + ) + else: + self._add_context_menu( + "Open in Audacity", lambda: self._open_in_audacity(model_row_number) + ) + + # Rescan + if track_row and not this_is_current_row: + self._add_context_menu( + "Rescan track", lambda: self._rescan(model_row_number) + ) + self._add_context_menu("Mark for moving", lambda: self._mark_for_moving()) + if self.musicmuster.move_source_rows: + self._add_context_menu( + "Move selected rows here", lambda: self._move_selected_rows() + ) + + # ---------------------- + self.menu.addSeparator() + + # Delete row + if not this_is_current_row and not this_is_next_row: + self._add_context_menu("Delete row", lambda: self._delete_rows()) + + # Remove track from row + if track_row and not this_is_current_row and not this_is_next_row: + self._add_context_menu( + "Remove track from row", + lambda: base_model.remove_track(model_row_number), + ) + + # Remove comments + self._add_context_menu("Remove comments", lambda: self._remove_comments()) + + # Add track to section header (ie, make this a track row) + if header_row: + self._add_context_menu("Add a track", lambda: self._add_track()) + + # # ---------------------- + self.menu.addSeparator() + + # Mark unplayed + if track_row and base_model.is_played_row(model_row_number): + self._add_context_menu( + "Mark unplayed", + lambda: self._mark_as_unplayed(self.get_selected_rows()), + ) + + # Unmark as next + if this_is_next_row: + self._add_context_menu( + "Unmark as next track", lambda: self._unmark_as_next() + ) + + # ---------------------- + self.menu.addSeparator() + + # Sort + sort_menu = self.menu.addMenu("Sort") + self._add_context_menu( + "by title", + lambda: base_model.sort_by_title(self.get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "by artist", + lambda: base_model.sort_by_artist(self.get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "by duration", + lambda: base_model.sort_by_duration(self.get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "by last played", + lambda: base_model.sort_by_lastplayed(self.get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "randomly", + lambda: base_model.sort_randomly(self.get_selected_rows()), + parent_menu=sort_menu, + ) + + # Info + if track_row: + self._add_context_menu("Info", lambda: self._info_row(model_row_number)) + + # Track path + if track_row: + self._add_context_menu( + "Copy track path", lambda: self._copy_path(model_row_number) + ) + + def _cancel_audacity(self) -> None: + """ + Cancel Audacity editing. We don't do anything with Audacity, just "forget" + that we have an edit open. + """ + + if self.ac: + self.ac.path = None + + 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"playlist_col_{column_number}_width" + record = Settings.get_setting(session, attr_name) + record.f_int = self.columnWidth(column_number) + session.commit() + + def _context_menu(self, pos): + """Display right-click menu""" + + item = self.indexAt(pos) + self._build_context_menu(item) + self.menu.exec(self.mapToGlobal(pos)) + + def _copy_path(self, row_number: int) -> None: + """ + If passed row_number has a track, copy the track path, single-quoted, + to the clipboard. Otherwise, return None. + """ + + track_path = self.get_base_model().get_row_info(row_number).path + if not track_path: + return + + replacements = [ + ("'", "\\'"), + (" ", "\\ "), + ("(", "\\("), + (")", "\\)"), + ] + for old, new in replacements: + track_path = track_path.replace(old, new) + + cb = QApplication.clipboard() + if cb: + cb.clear(mode=cb.Mode.Clipboard) + cb.setText(track_path, mode=cb.Mode.Clipboard) + + def current_track_started(self) -> None: + """ + Called when track starts playing + """ + + self.get_base_model().current_track_started() + # Scroll to current section if hide mode is by section + if ( + self.musicmuster.hide_played_tracks + and Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS + ): + # Hide section after delay + QTimer.singleShot( + Config.HIDE_AFTER_PLAYING_OFFSET + 100, + lambda: self.hide_played_sections(), + ) + + def _delete_rows(self) -> None: + """ + Delete mutliple rows + + Actions required: + - Confirm deletion should go ahead + - Pass to model to do the deed + """ + + rows_to_delete = self.get_selected_rows() + log.debug(f"_delete_rows({rows_to_delete=}") + row_count = len(rows_to_delete) + if row_count < 1: + return + + # Get confirmation + plural = "s" if row_count > 1 else "" + if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): + return + + base_model = self.get_base_model() + + base_model.delete_rows(self.selected_model_row_numbers()) + self.clear_selection() + + def get_base_model(self) -> QuerylistModel: + """ + Return the base model for this proxy model + """ + + return cast(QuerylistModel, self.model().sourceModel()) + + def get_selected_row_track_info(self) -> Optional[TrackInfo]: + """ + Return the track_id and row number of the selected + row. If no row selected or selected row does not have a track, + return None. + """ + + selected_row = self.get_selected_row() + if selected_row is None: + return None + + base_model = self.get_base_model() + model_row_number = self.source_model_selected_row_number() + + if model_row_number is None: + return None + else: + track_id = base_model.get_row_track_id(model_row_number) + if not track_id: + return None + else: + return TrackInfo(track_id, selected_row) + + def get_selected_row(self) -> Optional[int]: + """ + Return selected row number. If no rows or multiple rows selected, return None + """ + + selected = self.get_selected_rows() + if len(selected) == 1: + return selected[0] + else: + return None + + def get_selected_rows(self) -> List[int]: + """Return a list of model-selected row numbers sorted by row""" + + # Use a set to deduplicate result (a selected row will have all + # items in that row selected) + result = sorted( + list( + set([self.model().mapToSource(a).row() for a in self.selectedIndexes()]) + ) + ) + + log.debug(f"get_selected_rows() returned: {result=}") + return result + + def hide_played_sections(self) -> None: + """ + Scroll played sections off screen + """ + + self.scroll_to_top(self.get_base_model().active_section_header()) + + def _import_from_audacity(self, row_number: int) -> None: + """ + Import current Audacity track to passed row + """ + + if not self.ac: + return + try: + self.ac.export() + self._rescan(row_number) + except ApplicationError as e: + show_warning(self.musicmuster, "Audacity error", str(e)) + self._cancel_audacity() + + def _info_row(self, row_number: int) -> None: + """Display popup with info re row""" + + prd = self.get_base_model().get_row_info(row_number) + if prd: + txt = ( + f"Title: {prd.title}\n" + f"Artist: {prd.artist}\n" + f"Track ID: {prd.track_id}\n" + f"Track duration: {ms_to_mmss(prd.duration)}\n" + f"Track bitrate: {prd.bitrate}\n" + "\n\n" + f"Path: {prd.path}\n" + ) + else: + txt = f"Can't find info about row{row_number}" + + show_OK(self.musicmuster, "Track info", txt) + + def _mark_as_unplayed(self, row_numbers: List[int]) -> None: + """Mark row as unplayed""" + + self.get_base_model().mark_unplayed(row_numbers) + self.clear_selection() + + def _mark_for_moving(self) -> None: + """ + Mark selected rows for pasting + """ + + self.musicmuster.mark_rows_for_moving() + + def model(self) -> QuerylistProxyModel: + """ + Override return type to keep mypy happy in this module + """ + + return cast(QuerylistProxyModel, super().model()) + + def _move_selected_rows(self) -> None: + """ + Move selected rows here + """ + + self.musicmuster.paste_rows() + + def _open_in_audacity(self, row_number: int) -> None: + """ + Open track in passed row in Audacity + """ + + path = self.get_base_model().get_row_track_path(row_number) + if not path: + log.error(f"_open_in_audacity: can't get path for {row_number=}") + return + + try: + if not self.ac: + self.ac = AudacityController() + self.ac.open(path) + except ApplicationError as e: + show_warning(self.musicmuster, "Audacity error", str(e)) + + def _remove_comments(self) -> None: + """ + Remove comments from selected rows + """ + + row_numbers = self.selected_model_row_numbers() + if not row_numbers: + return + + self.get_base_model().remove_comments(row_numbers) + + def _rescan(self, row_number: int) -> None: + """Rescan track""" + + self.get_base_model().rescan_track(row_number) + self.clear_selection() + + 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 scroll_to_top(self, row_number: int) -> None: + """ + Scroll to put passed row_number at the top of the displayed playlist. + """ + + if row_number is None: + return + + row_index = self.model().index(row_number, 0) + self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop) + + def select_duplicate_rows(self) -> None: + """ + Select the last of any rows with duplicate tracks in current playlist. + This allows the selection to typically come towards the end of the playlist away + from any show specific sections. + """ + + # Clear any selected rows to avoid confustion + self.clear_selection() + # We need to be in MultiSelection mode + self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) + # Get the duplicate rows + duplicate_rows = self.get_base_model().get_duplicate_rows() + # Select the rows + for duplicate_row in duplicate_rows: + self.selectRow(duplicate_row) + # Reset selection mode + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + def source_model_selected_row_number(self) -> Optional[int]: + """ + Return the model row number corresponding to the selected row or None + """ + + selected_index = self._selected_row_index() + if selected_index is None: + return None + return self.model().mapToSource(selected_index).row() + + def selected_model_row_numbers(self) -> List[int]: + """ + Return a list of model row numbers corresponding to the selected rows or + an empty list. + """ + + selected_indexes = self._selected_row_indexes() + if selected_indexes is None: + return [] + + return [self.model().mapToSource(a).row() for a in selected_indexes] + + def _selected_row_index(self) -> Optional[QModelIndex]: + """ + Return the selected row index or None if none selected. + """ + + row_indexes = self._selected_row_indexes() + + if len(row_indexes) > 1: + show_warning( + self.musicmuster, "Multiple rows selected", "Select only one row" + ) + return None + elif not row_indexes: + return None + + return row_indexes[0] + + def _selected_row_indexes(self) -> List[QModelIndex]: + """ + Return a list of indexes of column 0 of selected rows + """ + + sm = self.selectionModel() + if sm and sm.hasSelection(): + return sm.selectedRows() + return [] + + 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"playlist_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 set_row_as_next_track(self) -> None: + """ + Set selected row as next track + """ + + model_row_number = self.source_model_selected_row_number() + log.debug(f"set_row_as_next_track() {model_row_number=}") + if model_row_number is None: + return + self.get_base_model().set_next_row(model_row_number) + self.clearSelection() + + def _span_cells( + self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int + ) -> None: + """ + Implement spanning of cells, initiated by signal + + row and column are from the base model so we need to translate + the row into this display row + """ + + if playlist_id != self.playlist_id: + return + + base_model = self.get_base_model() + + cell_index = self.model().mapFromSource(base_model.createIndex(row, column)) + row = cell_index.row() + column = cell_index.column() + + # Don't set spanning if already in place because that is seen as + # a change to the view and thus it refreshes the data which + # again calls us here. + if ( + self.rowSpan(row, column) == rowSpan + and self.columnSpan(row, column) == columnSpan + ): + return + + self.setSpan(row, column, rowSpan, columnSpan) + + def tab_live(self) -> None: + """ + Called when tab gets focus + """ + + # Update musicmuster + self.musicmuster.current.playlist_id = self.playlist_id + self.musicmuster.current.selected_rows = self.get_selected_rows() + self.musicmuster.current.base_model = self.get_base_model() + self.musicmuster.current.proxy_model = self.model() + + self.resize_rows() + + def _unmark_as_next(self) -> None: + """Rescan track""" + + track_sequence.set_next(None) + self.clear_selection() + self.signals.next_track_changed_signal.emit() 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 114c163..e24cb3e 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. @@ -531,6 +531,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) @@ -554,6 +558,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() @@ -627,7 +634,7 @@ class Ui_MainWindow(object): self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous")) 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(_translate("MainWindow", "&Skip to start of fade")) @@ -677,5 +684,7 @@ class Ui_MainWindow(object): self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S")) self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows...")) self.actionImport_files.setText(_translate("MainWindow", "Import files...")) + self.actionOpenQuerylist.setText(_translate("MainWindow", "Open &querylist...")) + self.actionManage_querylists.setText(_translate("MainWindow", "Manage querylists...")) from infotabs import InfoTabs -from pyqtgraph import PlotWidget # type: ignore +from pyqtgraph import PlotWidget 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/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 ### +