Query tabs WIP

This commit is contained in:
Keith Edmunds 2025-02-11 21:11:56 +00:00
parent 5ed7b822e1
commit 955bea2037
17 changed files with 817 additions and 65 deletions

View File

@ -14,6 +14,11 @@ from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
) )
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports # App imports
@ -31,6 +36,14 @@ class Col(Enum):
NOTE = auto() NOTE = auto()
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
def singleton(cls): def singleton(cls):
""" """
Make a class a Singleton class (see Make a class a Singleton class (see
@ -100,6 +113,24 @@ class MusicMusterSignals(QObject):
super().__init__() 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): class Tags(NamedTuple):
artist: str = "" artist: str = ""
title: str = "" title: str = ""

View File

@ -31,6 +31,7 @@ class Config(object):
COLOUR_NORMAL_TAB = "#000000" COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2" COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50

View File

@ -18,7 +18,6 @@ class DatabaseManager:
def __init__(self, database_url: str, **kwargs: dict) -> None: def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs) self.db = Alchemical(database_url, **kwargs)
self.db.create_all()
DatabaseManager.__instance = self DatabaseManager.__instance = self
else: else:
raise Exception("Attempted to create a second DatabaseManager instance") raise Exception("Attempted to create a second DatabaseManager instance")

View File

@ -50,7 +50,8 @@ class PlaydatesTable(Model):
lastplayed: Mapped[dt.datetime] = mapped_column(index=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"))
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", back_populates="playdates" "TracksTable",
back_populates="playdates",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -103,7 +104,7 @@ class PlaylistRowsTable(Model):
) )
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") 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( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playlistrows", back_populates="playlistrows",
@ -127,7 +128,9 @@ class QueriesTable(Model):
query: Mapped[str] = mapped_column( query: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False 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") playlist: Mapped[PlaylistsTable] = relationship(back_populates="query")
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@ -19,7 +19,7 @@ from sqlalchemy import (
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
@ -36,7 +36,6 @@ if DATABASE_URL is None:
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
raise ValueError("Unit tests running on non-Sqlite database") raise ValueError("Unit tests running on non-Sqlite database")
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
db.create_all()
# Database classes # Database classes
@ -236,10 +235,23 @@ class Playlists(dbtables.PlaylistsTable):
return session.scalars( return session.scalars(
select(cls) select(cls)
.filter(cls.is_template.is_(False)) .filter(
cls.is_template.is_(False),
~cls.query.has()
)
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).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 @classmethod
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]: def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name""" """Returns a list of all templates ordered by name"""
@ -257,6 +269,7 @@ class Playlists(dbtables.PlaylistsTable):
.filter( .filter(
cls.open.is_(False), cls.open.is_(False),
cls.is_template.is_(False), cls.is_template.is_(False),
~cls.query.has()
) )
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).all()
@ -268,7 +281,13 @@ class Playlists(dbtables.PlaylistsTable):
""" """
return session.scalars( 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() ).all()
def mark_open(self) -> None: def mark_open(self) -> None:
@ -312,6 +331,26 @@ class Playlists(dbtables.PlaylistsTable):
PlaylistRows.copy_playlist(session, playlist_id, template.id) 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): class PlaylistRows(dbtables.PlaylistRowsTable):
def __init__( def __init__(
self, self,

View File

@ -63,7 +63,9 @@ from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from music_manager import RowAndTrack, track_sequence from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from querylistmodel import QuerylistModel
from playlists import PlaylistTab from playlists import PlaylistTab
from querylists import QuerylistTab
from ui import icons_rc # noqa F401 from ui import icons_rc # noqa F401
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # 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: 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. add tab to display. Return index number of tab.
""" """
@ -787,6 +789,25 @@ class Window(QMainWindow):
return idx 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: def current_row_or_end(self) -> int:
""" """
If a row or rows are selected, return the row number of the first 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) 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: def open_songfacts_browser(self, title: str) -> None:
"""Search Songfacts for title""" """Search Songfacts for title"""

View File

@ -497,7 +497,7 @@ class PlaylistModel(QAbstractTableModel):
""" """
if not index.isValid(): if not index.isValid():
return Qt.ItemFlag.ItemIsDropEnabled return Qt.ItemFlag.NoItemFlags
default = ( default = (
Qt.ItemFlag.ItemIsEnabled Qt.ItemFlag.ItemIsEnabled

View File

@ -22,10 +22,7 @@ from PyQt6.QtWidgets import (
QFrame, QFrame,
QMenu, QMenu,
QMessageBox, QMessageBox,
QProxyStyle,
QStyle,
QStyledItemDelegate, QStyledItemDelegate,
QStyleOption,
QStyleOptionViewItem, QStyleOptionViewItem,
QTableView, QTableView,
QTableWidgetItem, QTableWidgetItem,
@ -37,7 +34,7 @@ from PyQt6.QtWidgets import (
# App imports # App imports
from audacity_controller import AudacityController 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 config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -112,7 +109,7 @@ class PlaylistDelegate(QStyledItemDelegate):
if self.current_editor: if self.current_editor:
editor = self.current_editor editor = self.current_editor
else: else:
if index.column() == Col.INTRO.value: if index.column() == QueryCol.INTRO.value:
editor = QDoubleSpinBox(parent) editor = QDoubleSpinBox(parent)
editor.setDecimals(1) editor.setDecimals(1)
editor.setSingleStep(0.1) editor.setSingleStep(0.1)
@ -248,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate):
self.original_model_data = self.base_model.data( self.original_model_data = self.base_model.data(
edit_index, Qt.ItemDataRole.EditRole edit_index, Qt.ItemDataRole.EditRole
) )
if index.column() == Col.INTRO.value: if index.column() == QueryCol.INTRO.value:
if self.original_model_data.value(): if self.original_model_data.value():
editor.setValue(self.original_model_data.value() / 1000) editor.setValue(self.original_model_data.value() / 1000)
else: else:
@ -268,24 +265,6 @@ class PlaylistDelegate(QStyledItemDelegate):
editor.setGeometry(option.rect) 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): class PlaylistTab(QTableView):
""" """
The playlist view The playlist view

304
app/querylistmodel.py Normal file
View File

@ -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"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
)
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(
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
]
)
)

193
app/querylists.py Normal file
View File

@ -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

View File

@ -997,6 +997,9 @@ padding-left: 8px;</string>
<addaction name="actionRenamePlaylist"/> <addaction name="actionRenamePlaylist"/>
<addaction name="actionDeletePlaylist"/> <addaction name="actionDeletePlaylist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionOpenQuerylist"/>
<addaction name="actionManage_querylists"/>
<addaction name="separator"/>
<addaction name="actionSave_as_template"/> <addaction name="actionSave_as_template"/>
<addaction name="actionManage_templates"/> <addaction name="actionManage_templates"/>
<addaction name="separator"/> <addaction name="separator"/>
@ -1140,7 +1143,7 @@ padding-left: 8px;</string>
</action> </action>
<action name="actionOpenPlaylist"> <action name="actionOpenPlaylist">
<property name="text"> <property name="text">
<string>O&amp;pen...</string> <string>Open &amp;playlist...</string>
</property> </property>
</action> </action>
<action name="actionNewPlaylist"> <action name="actionNewPlaylist">
@ -1369,6 +1372,16 @@ padding-left: 8px;</string>
<string>Import files...</string> <string>Import files...</string>
</property> </property>
</action> </action>
<action name="actionOpenQuerylist">
<property name="text">
<string>Open &amp;querylist...</string>
</property>
</action>
<action name="actionManage_querylists">
<property name="text">
<string>Manage querylists...</string>
</property>
</action>
</widget> </widget>
<customwidgets> <customwidgets>
<customwidget> <customwidget>

View File

@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui' # 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 # 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. # 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.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionImport_files = QtGui.QAction(parent=MainWindow) self.actionImport_files = QtGui.QAction(parent=MainWindow)
self.actionImport_files.setObjectName("actionImport_files") 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.addSeparator()
self.menuFile.addAction(self.actionInsertTrack) self.menuFile.addAction(self.actionInsertTrack)
self.menuFile.addAction(self.actionRemove) self.menuFile.addAction(self.actionRemove)
@ -680,6 +684,9 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionRenamePlaylist) self.menuPlaylist.addAction(self.actionRenamePlaylist)
self.menuPlaylist.addAction(self.actionDeletePlaylist) self.menuPlaylist.addAction(self.actionDeletePlaylist)
self.menuPlaylist.addSeparator() 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.actionSave_as_template)
self.menuPlaylist.addAction(self.actionManage_templates) self.menuPlaylist.addAction(self.actionManage_templates)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
@ -757,7 +764,7 @@ class Ui_MainWindow(object):
) )
self.actionE_xit.setText(_translate("MainWindow", "E&xit")) self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test")) 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.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function")) self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText( self.actionSkipToFade.setText(
@ -840,4 +847,4 @@ class Ui_MainWindow(object):
from infotabs import InfoTabs from infotabs import InfoTabs
from pyqtgraph import PlotWidget # type: ignore from pyqtgraph import PlotWidget

View File

@ -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,
})

1
migrations/env.py Symbolic link
View File

@ -0,0 +1 @@
env.py.DEBUG

28
migrations/env.py.DEBUG Normal file
View File

@ -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,
})

27
migrations/env.py.NODEBUG Normal file
View File

@ -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,
})

View File

@ -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 ###

View File

@ -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 ###