Query searches working

More UI needed
This commit is contained in:
Keith Edmunds 2025-02-14 23:16:56 +00:00
parent 7c0db00b75
commit e6404d075e
10 changed files with 582 additions and 463 deletions

View File

@ -87,6 +87,7 @@ class Config(object):
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MINIMUM_ROW_HEIGHT = 30 MINIMUM_ROW_HEIGHT = 30
NO_QUERY_NAME = "Select query"
NO_TEMPLATE_NAME = "None" NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost" OBS_HOST = "localhost"

View File

@ -80,9 +80,6 @@ class PlaylistsTable(Model):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number", order_by="PlaylistRowsTable.row_number",
) )
query: Mapped["QueriesTable"] = relationship(
back_populates="playlist", cascade="all, delete-orphan"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -104,7 +101,9 @@ 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", ondelete="CASCADE")) 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",
@ -125,16 +124,16 @@ class QueriesTable(Model):
__tablename__ = "queries" __tablename__ = "queries"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column( name: Mapped[str] = mapped_column(String(128), nullable=False)
sql: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False String(2048), index=False, default="", nullable=False
) )
playlist_id: Mapped[int] = mapped_column( description: Mapped[str] = mapped_column(
ForeignKey("playlists.id", ondelete="CASCADE"), index=True String(512), index=False, default="", nullable=False
) )
playlist: Mapped[PlaylistsTable] = relationship(back_populates="query")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Queries(id={self.id}, playlist={self.playlist}, query={self.query}>" return f"<Queries(id={self.id}, name={self.name}, sql={self.sql}>"
class SettingsTable(Model): class SettingsTable(Model):

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, selectinload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
@ -178,179 +178,6 @@ class Playdates(dbtables.PlaydatesTable):
).all() ).all()
class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str):
self.name = name
self.last_used = dt.datetime.now()
session.add(self)
session.commit()
@staticmethod
def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
"""
Make all tab records NULL
"""
session.execute(
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
)
def close(self, session: Session) -> None:
"""Mark playlist as unloaded"""
self.open = False
session.commit()
@classmethod
def create_playlist_from_template(
cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
# Sanity check
if not template.id:
return None
playlist = cls(session, playlist_name)
# Sanity / mypy checks
if not playlist or not playlist.id:
return None
PlaylistRows.copy_playlist(session, template.id, playlist.id)
return playlist
def delete(self, session: Session) -> None:
"""
Delete playlist
"""
session.execute(delete(Playlists).where(Playlists.id == self.id))
session.commit()
@classmethod
def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return session.scalars(
select(cls)
.filter(
cls.is_template.is_(False),
~cls.query.has()
)
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_all_queries(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all query lists ordered by name"""
return session.scalars(
select(cls)
.where(cls.query.has())
.order_by(cls.name)
).all()
@classmethod
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name"""
return session.scalars(
select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
).all()
@classmethod
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return session.scalars(
select(cls)
.filter(
cls.open.is_(False),
cls.is_template.is_(False),
~cls.query.has()
)
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
"""
Return a list of loaded playlists ordered by tab.
"""
return session.scalars(
select(cls)
.where(
cls.open.is_(True),
~cls.query.has()
)
.order_by(cls.tab)
).all()
def mark_open(self) -> None:
"""Mark playlist as loaded and used now"""
self.open = True
self.last_used = dt.datetime.now()
@staticmethod
def name_is_available(session: Session, name: str) -> bool:
"""
Return True if no playlist of this name exists else false.
"""
return (
session.execute(select(Playlists).where(Playlists.name == name)).first()
is None
)
def rename(self, session: Session, new_name: str) -> None:
"""
Rename playlist
"""
self.name = new_name
session.commit()
@staticmethod
def save_as_template(
session: Session, playlist_id: int, template_name: str
) -> None:
"""Save passed playlist as new template"""
template = Playlists(session, template_name)
if not template or not template.id:
return
template.is_template = True
session.commit()
PlaylistRows.copy_playlist(session, playlist_id, template.id)
class Queries(dbtables.QueriesTable):
def __init__(self, session: Session, playlist_id: int, query: str = "") -> None:
self.playlist_id = playlist_id
self.query = query
session.add(self)
session.commit()
@staticmethod
def get_query(session: Session, playlist_id: int) -> str:
"""
Return query associated with playlist or null string if none
"""
return session.execute(
select(Queries.query).where(
Queries.playlist_id == playlist_id
)
).scalar_one()
class PlaylistRows(dbtables.PlaylistRowsTable): class PlaylistRows(dbtables.PlaylistRowsTable):
def __init__( def __init__(
self, self,
@ -635,6 +462,159 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.connection().execute(stmt, sqla_map) session.connection().execute(stmt, sqla_map)
class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str):
self.name = name
self.last_used = dt.datetime.now()
session.add(self)
session.commit()
@staticmethod
def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
"""
Make all tab records NULL
"""
session.execute(
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
)
def close(self, session: Session) -> None:
"""Mark playlist as unloaded"""
self.open = False
session.commit()
@classmethod
def create_playlist_from_template(
cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
# Sanity check
if not template.id:
return None
playlist = cls(session, playlist_name)
# Sanity / mypy checks
if not playlist or not playlist.id:
return None
PlaylistRows.copy_playlist(session, template.id, playlist.id)
return playlist
def delete(self, session: Session) -> None:
"""
Delete playlist
"""
session.execute(delete(Playlists).where(Playlists.id == self.id))
session.commit()
@classmethod
def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return session.scalars(
select(cls)
.filter(cls.is_template.is_(False))
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name"""
return session.scalars(
select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
).all()
@classmethod
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return session.scalars(
select(cls)
.filter(cls.open.is_(False), cls.is_template.is_(False))
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
"""
Return a list of loaded playlists ordered by tab.
"""
return session.scalars(
select(cls)
.where(
cls.open.is_(True),
)
.order_by(cls.tab)
).all()
def mark_open(self) -> None:
"""Mark playlist as loaded and used now"""
self.open = True
self.last_used = dt.datetime.now()
@staticmethod
def name_is_available(session: Session, name: str) -> bool:
"""
Return True if no playlist of this name exists else false.
"""
return (
session.execute(select(Playlists).where(Playlists.name == name)).first()
is None
)
def rename(self, session: Session, new_name: str) -> None:
"""
Rename playlist
"""
self.name = new_name
session.commit()
@staticmethod
def save_as_template(
session: Session, playlist_id: int, template_name: str
) -> None:
"""Save passed playlist as new template"""
template = Playlists(session, template_name)
if not template or not template.id:
return
template.is_template = True
session.commit()
PlaylistRows.copy_playlist(session, playlist_id, template.id)
class Queries(dbtables.QueriesTable):
def __init__(
self, session: Session, name: str, query: str, description: str = ""
) -> None:
self.query = query
self.name = name
self.description = description
session.add(self)
session.commit()
@classmethod
def get_all(cls, session: Session) -> Sequence[Queries]:
"""
Return a list of all queries
"""
return session.scalars(select(cls)).unique().all()
class Settings(dbtables.SettingsTable): class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str): def __init__(self, session: Session, name: str):
self.name = name self.name = name

View File

@ -28,6 +28,7 @@ from PyQt6.QtGui import (
QShortcut, QShortcut,
) )
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication, QApplication,
QComboBox, QComboBox,
QDialog, QDialog,
@ -40,6 +41,8 @@ from PyQt6.QtWidgets import (
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSizePolicy,
QTableView,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@ -60,13 +63,19 @@ from dialogs import TrackSelectDialog
from file_importer import FileImporter from file_importer import FileImporter
from helpers import file_is_unreadable from helpers import file_is_unreadable
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import (
db,
Playdates,
PlaylistRows,
Playlists,
Queries,
Settings,
Tracks,
)
from music_manager import RowAndTrack, track_sequence from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from querylistmodel import QuerylistModel 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.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
from ui.main_window_header_ui import Ui_HeaderSection # type: ignore from ui.main_window_header_ui import Ui_HeaderSection # type: ignore
@ -271,6 +280,234 @@ class PreviewManager:
self.start_time = None self.start_time = None
class QueryDialog(QDialog):
"""Dialog box to handle selecting track from a SQL query"""
def __init__(self, session: Session) -> None:
super().__init__()
self.session = session
# Build a list of (query-name, playlist-id) tuples
self.selected_tracks: list[int] = []
self.query_list: list[tuple[str, int]] = []
self.query_list.append((Config.NO_QUERY_NAME, 0))
for query in Queries.get_all(self.session):
self.query_list.append((query.name, query.id))
self.setWindowTitle("Query Selector")
# Create label
query_label = QLabel("Query:")
# Top layout (Query label, combo box, and info label)
top_layout = QHBoxLayout()
# Query label
query_label = QLabel("Query:")
top_layout.addWidget(query_label)
# Combo Box with fixed width
self.combo_box = QComboBox()
self.combo_box.setFixedWidth(150) # Adjust as necessary for 20 characters
for text, id_ in self.query_list:
self.combo_box.addItem(text, id_)
top_layout.addWidget(self.combo_box)
# Information label (two-row height, wrapping)
self.description_label = QLabel("")
self.description_label.setWordWrap(True)
self.description_label.setMinimumHeight(40) # Approximate height for two rows
self.description_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
top_layout.addWidget(self.description_label)
# Table (middle part)
self.table_view = QTableView()
self.table_view.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection
)
self.table_view.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table_view.setAlternatingRowColors(True)
self.table_view.setVerticalScrollMode(
QAbstractItemView.ScrollMode.ScrollPerPixel
)
self.table_view.clicked.connect(self.handle_row_click)
# Bottom layout (buttons)
bottom_layout = QHBoxLayout()
bottom_layout.addStretch() # Push buttons to the right
self.add_tracks_button = QPushButton("Add tracks")
self.add_tracks_button.setEnabled(False) # Disabled by default
self.add_tracks_button.clicked.connect(self.add_tracks_clicked)
bottom_layout.addWidget(self.add_tracks_button)
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.cancel_clicked)
bottom_layout.addWidget(self.cancel_button)
# Main layout
main_layout = QVBoxLayout()
main_layout.addLayout(top_layout)
main_layout.addWidget(self.table_view)
main_layout.addLayout(bottom_layout)
self.combo_box.currentIndexChanged.connect(self.query_changed)
self.setLayout(main_layout)
# Stretch last column *after* setting column widths which is
# *much* faster
h_header = self.table_view.horizontalHeader()
if h_header:
h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True)
# Resize on vertical header click
v_header = self.table_view.verticalHeader()
if v_header:
v_header.setMinimumSectionSize(5)
v_header.sectionHandleDoubleClicked.disconnect()
v_header.sectionHandleDoubleClicked.connect(
self.table_view.resizeRowToContents
)
self.set_window_size()
self.resizeRowsToContents()
def add_tracks_clicked(self):
self.selected_tracks = self.table_view.model().get_selected_track_ids()
self.accept()
def cancel_clicked(self):
self.selected_tracks = []
self.reject()
def closeEvent(self, event: QCloseEvent | None) -> None:
"""
Record size and columns
"""
self.save_sizes()
super().closeEvent(event)
def accept(self) -> None:
self.save_sizes()
super().accept()
def reject(self) -> None:
self.save_sizes()
super().reject()
def save_sizes(self) -> None:
"""
Save window size
"""
# Save dialog box attributes
attributes_to_save = dict(
querylist_height=self.height(),
querylist_width=self.width(),
querylist_x=self.x(),
querylist_y=self.y(),
)
for name, value in attributes_to_save.items():
record = Settings.get_setting(self.session, name)
record.f_int = value
header = self.table_view.horizontalHeader()
if header is None:
return
column_count = header.count()
if column_count < 2:
return
for column_number in range(column_count - 1):
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(self.session, attr_name)
record.f_int = self.table_view.columnWidth(column_number)
self.session.commit()
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
"""
Called when column width changes.
"""
header = self.table_view.horizontalHeader()
if not header:
return
# Resize rows if necessary
self.resizeRowsToContents()
def resizeRowsToContents(self):
header = self.table_view.verticalHeader()
model = self.table_view.model()
if model:
for row in model.rowCount():
hint = self.sizeHintForRow(row)
header.resizeSection(row, hint)
def query_changed(self, idx: int) -> None:
"""
Called when user selects query
"""
# Get query
query = self.session.get(Queries, idx)
if not query:
return
# Create model
base_model = QuerylistModel(self.session, query.sql)
# Create table
self.table_view.setModel(base_model)
self.set_column_sizes()
self.description_label.setText(query.description)
def handle_row_click(self, index):
self.table_view.model().toggle_row_selection(index.row())
self.table_view.clearSelection()
# Enable 'Add tracks' button only when a row is selected
selected = self.table_view.model().get_selected_track_ids()
self.add_tracks_button.setEnabled(selected != [])
def set_window_size(self) -> None:
"""Set window sizes"""
x = Settings.get_setting(self.session, "querylist_x").f_int or 100
y = Settings.get_setting(self.session, "querylist_y").f_int or 100
width = Settings.get_setting(self.session, "querylist_width").f_int or 100
height = Settings.get_setting(self.session, "querylist_height").f_int or 100
self.setGeometry(x, y, width, height)
def set_column_sizes(self) -> None:
"""Set column sizes"""
header = self.table_view.horizontalHeader()
if header is None:
return
column_count = header.count()
if column_count < 2:
return
# Last column is set to stretch so ignore it here
for column_number in range(column_count - 1):
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(self.session, attr_name)
if record.f_int is not None:
self.table_view.setColumnWidth(column_number, record.f_int)
else:
self.table_view.setColumnWidth(
column_number, Config.DEFAULT_COLUMN_WIDTH
)
class SelectPlaylistDialog(QDialog): class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlists=None, session=None): def __init__(self, parent=None, playlists=None, session=None):
super().__init__() super().__init__()
@ -789,25 +1026,6 @@ 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
@ -1261,14 +1479,13 @@ class Window(QMainWindow):
"""Open existing querylist""" """Open existing querylist"""
with db.Session() as session: with db.Session() as session:
querylists = Playlists.get_all_queries(session) dlg = QueryDialog(session)
dlg = SelectPlaylistDialog(self, playlists=querylists, session=session) if dlg.exec():
dlg.exec() new_row_number = self.current_row_or_end()
querylist = dlg.playlist for track_id in dlg.selected_tracks:
if querylist: self.current.base_model.insert_row(new_row_number, track_id)
idx = self.create_querylist_tab(querylist) else:
return # User cancelled
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

@ -109,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() == QueryCol.INTRO.value: if index.column() == Col.INTRO.value:
editor = QDoubleSpinBox(parent) editor = QDoubleSpinBox(parent)
editor.setDecimals(1) editor.setDecimals(1)
editor.setSingleStep(0.1) editor.setSingleStep(0.1)
@ -245,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() == QueryCol.INTRO.value: if index.column() == Col.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:

View File

@ -4,25 +4,23 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import Optional
import datetime as dt import datetime as dt
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
QAbstractTableModel, QAbstractTableModel,
QModelIndex, QModelIndex,
QRegularExpression,
QSortFilterProxyModel,
Qt, Qt,
QVariant, QVariant,
) )
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QBrush,
QColor, QColor,
QFont, QFont,
) )
# Third party imports # Third party imports
from sqlalchemy.orm.session import Session
# import snoop # type: ignore # import snoop # type: ignore
@ -46,7 +44,7 @@ class QueryRow:
artist: str artist: str
bitrate: int bitrate: int
duration: int duration: int
lastplayed: dt.datetime lastplayed: Optional[dt.datetime]
path: str path: str
title: str title: str
track_id: int track_id: int
@ -62,26 +60,24 @@ class QuerylistModel(QAbstractTableModel):
""" """
def __init__( def __init__(self, session: Session, sql: str) -> None:
self, """
playlist_id: int, Load query
) -> None: """
log.debug("QuerylistModel.__init__()")
log.debug(f"QuerylistModel.__init__({sql=})")
self.playlist_id = playlist_id
super().__init__() super().__init__()
self.session = session
self.sql = sql
self.querylist_rows: dict[int, QueryRow] = {} self.querylist_rows: dict[int, QueryRow] = {}
self._selected_rows: set[int] = set() self._selected_rows: set[int] = set()
with db.Session() as session: self.load_data()
# Populate self.playlist_rows
self.load_data(session)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return f"<QuerylistModel: sql={self.sql}, {self.rowCount()} rows>"
f"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
)
def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""Return background setting""" """Return background setting"""
@ -219,28 +215,9 @@ class QuerylistModel(QAbstractTableModel):
return QVariant() return QVariant()
def load_data( def load_data(self) -> None:
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 Populate self.querylist_rows
to ensure the required fields are returned.
""" """
# TODO: Move the SQLAlchemy parts to models later, but for now as proof # TODO: Move the SQLAlchemy parts to models later, but for now as proof
@ -252,17 +229,22 @@ class QuerylistModel(QAbstractTableModel):
self.querylist_rows = {} self.querylist_rows = {}
row = 0 row = 0
results = session.execute(text(sql)).mappings().all() results = self.session.execute(text(self.sql)).mappings().all()
for result in results: for result in results:
if hasattr(result, "lastplayed"):
lastplayed = result["lastplayed"]
else:
lastplayed = None
queryrow = QueryRow( queryrow = QueryRow(
artist=result["artist"], artist=result["artist"],
bitrate=result["bitrate"], bitrate=result["bitrate"],
duration=result["duration"], duration=result["duration"],
lastplayed=result["lastplayed"], lastplayed=lastplayed,
path=result["path"], path=result["path"],
title=result["title"], title=result["title"],
track_id=result["id"], track_id=result["id"],
) )
self.querylist_rows[row] = queryrow self.querylist_rows[row] = queryrow
row += 1 row += 1

View File

@ -1,193 +0,0 @@
# Standard library imports
from typing import cast, Optional, TYPE_CHECKING
# PyQt imports
from PyQt6.QtCore import (
QTimer,
)
from PyQt6.QtWidgets import (
QAbstractItemView,
QTableView,
)
# Third party imports
# import line_profiler
# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, MusicMusterSignals, PlaylistStyle
from config import Config
from helpers import (
show_warning,
)
from log import log
from models import db, Settings
from querylistmodel import QuerylistModel
if TYPE_CHECKING:
from musicmuster import Window
class QuerylistTab(QTableView):
"""
The querylist view
"""
def __init__(self, musicmuster: "Window", model: QuerylistModel) -> None:
super().__init__()
# Save passed settings
self.musicmuster = musicmuster
self.playlist_id = model.playlist_id
# Set up widget
self.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(PlaylistStyle())
# We will enable dragging when rows are selected. Disabling it
# here means we can click and drag to select rows.
self.setDragEnabled(False)
# Connect signals
self.signals = MusicMusterSignals()
self.signals.resize_rows_signal.connect(self.resize_rows)
# Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Enable item editing for checkboxes
self.clicked.connect(self.handle_row_click)
# Set up for Audacity
try:
self.ac: Optional[AudacityController] = AudacityController()
except ApplicationError as e:
self.ac = None
show_warning(self.musicmuster, "Audacity error", str(e))
# Load model, set column widths
self.setModel(model)
self._set_column_widths()
# Stretch last column *after* setting column widths which is
# *much* faster
h_header = self.horizontalHeader()
if h_header:
h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True)
# Resize on vertical header click
v_header = self.verticalHeader()
if v_header:
v_header.setMinimumSectionSize(5)
v_header.sectionHandleDoubleClicked.disconnect()
v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
# Setting ResizeToContents causes screen flash on load
self.resize_rows()
# ########## Overridden class functions ##########
def resizeRowToContents(self, row):
super().resizeRowToContents(row)
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
def resizeRowsToContents(self):
header = self.verticalHeader()
for row in range(self.model().rowCount()):
hint = self.sizeHintForRow(row)
header.resizeSection(row, hint)
# ########## Custom functions ##########
def clear_selection(self) -> None:
"""Unselect all tracks and reset drag mode"""
self.clearSelection()
# We want to remove the focus from any widget otherwise keyboard
# activity may edit a cell.
fw = self.musicmuster.focusWidget()
if fw:
fw.clearFocus()
self.setDragEnabled(False)
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
"""
Called when column width changes. Save new width to database.
"""
log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")
header = self.horizontalHeader()
if not header:
return
# Resize rows if necessary
self.resizeRowsToContents()
with db.Session() as session:
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(session, attr_name)
record.f_int = self.columnWidth(column_number)
session.commit()
def handle_row_click(self, index):
self.model().toggle_row_selection(index.row())
self.clearSelection()
def model(self) -> QuerylistModel:
"""
Override return type to keep mypy happy in this module
"""
return cast(QuerylistModel, super().model())
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
"""
If playlist_id is us, resize rows
"""
log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")
if playlist_id and playlist_id != self.playlist_id:
return
# Suggestion from phind.com
def resize_row(row, count=1):
row_count = self.model().rowCount()
for todo in range(count):
if row < row_count:
self.resizeRowToContents(row)
row += 1
if row < row_count:
QTimer.singleShot(0, lambda: resize_row(row, count))
# Start resizing from row 0, 10 rows at a time
QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))
def _set_column_widths(self) -> None:
"""Column widths from settings"""
log.debug("_set_column_widths()")
header = self.horizontalHeader()
if not header:
return
# Last column is set to stretch so ignore it here
with db.Session() as session:
for column_number in range(header.count() - 1):
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(session, attr_name)
if record.f_int is not None:
self.setColumnWidth(column_number, record.f_int)
else:
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
def tab_live(self) -> None:
"""Noop for query tabs"""
return

94
app/ui/dlgQuery.ui Normal file
View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>queryDialog</class>
<widget class="QDialog" name="queryDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>762</width>
<height>686</height>
</rect>
</property>
<property name="windowTitle">
<string>Query</string>
</property>
<widget class="QTableView" name="tableView">
<property name="geometry">
<rect>
<x>10</x>
<y>65</y>
<width>741</width>
<height>561</height>
</rect>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>20</x>
<y>10</y>
<width>61</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Query:</string>
</property>
</widget>
<widget class="QComboBox" name="cboQuery">
<property name="geometry">
<rect>
<x>80</x>
<y>10</y>
<width>221</width>
<height>32</height>
</rect>
</property>
</widget>
<widget class="QPushButton" name="btnAddTracks">
<property name="geometry">
<rect>
<x>530</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Add &amp;tracks</string>
</property>
</widget>
<widget class="QLabel" name="lblDescription">
<property name="geometry">
<rect>
<x>330</x>
<y>10</y>
<width>401</width>
<height>46</height>
</rect>
</property>
<property name="text">
<string>TextLabel</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
<widget class="QPushButton" name="pushButton">
<property name="geometry">
<rect>
<x>650</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Close</string>
</property>
</widget>
</widget>
<resources/>
<connections/>
</ui>

45
app/ui/dlgQuery_ui.py Normal file
View File

@ -0,0 +1,45 @@
# Form implementation generated from reading ui file 'app/ui/dlgQuery.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_queryDialog(object):
def setupUi(self, queryDialog):
queryDialog.setObjectName("queryDialog")
queryDialog.resize(762, 686)
self.tableView = QtWidgets.QTableView(parent=queryDialog)
self.tableView.setGeometry(QtCore.QRect(10, 65, 741, 561))
self.tableView.setObjectName("tableView")
self.label = QtWidgets.QLabel(parent=queryDialog)
self.label.setGeometry(QtCore.QRect(20, 10, 61, 24))
self.label.setObjectName("label")
self.cboQuery = QtWidgets.QComboBox(parent=queryDialog)
self.cboQuery.setGeometry(QtCore.QRect(80, 10, 221, 32))
self.cboQuery.setObjectName("cboQuery")
self.btnAddTracks = QtWidgets.QPushButton(parent=queryDialog)
self.btnAddTracks.setGeometry(QtCore.QRect(530, 640, 102, 36))
self.btnAddTracks.setObjectName("btnAddTracks")
self.lblDescription = QtWidgets.QLabel(parent=queryDialog)
self.lblDescription.setGeometry(QtCore.QRect(330, 10, 401, 46))
self.lblDescription.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblDescription.setObjectName("lblDescription")
self.pushButton = QtWidgets.QPushButton(parent=queryDialog)
self.pushButton.setGeometry(QtCore.QRect(650, 640, 102, 36))
self.pushButton.setObjectName("pushButton")
self.retranslateUi(queryDialog)
QtCore.QMetaObject.connectSlotsByName(queryDialog)
def retranslateUi(self, queryDialog):
_translate = QtCore.QCoreApplication.translate
queryDialog.setWindowTitle(_translate("queryDialog", "Query"))
self.label.setText(_translate("queryDialog", "Query:"))
self.btnAddTracks.setText(_translate("queryDialog", "Add &tracks"))
self.lblDescription.setText(_translate("queryDialog", "TextLabel"))
self.pushButton.setText(_translate("queryDialog", "Close"))

View File

@ -1,8 +1,8 @@
"""Add data for query playlists """Add queries table
Revision ID: 014f2d4c88a5 Revision ID: 9c1254a8026d
Revises: 33c04e3c12c8 Revises: c76e865ccb85
Create Date: 2024-12-30 14:23:36.924478 Create Date: 2025-02-14 16:32:37.064567
""" """
from alembic import op from alembic import op
@ -10,8 +10,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '014f2d4c88a5' revision = '9c1254a8026d'
down_revision = '33c04e3c12c8' down_revision = 'c76e865ccb85'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@ -31,22 +31,16 @@ def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('queries', op.create_table('queries',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('query', sa.String(length=2048), nullable=False), sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=False), sa.Column('sql', sa.String(length=2048), nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ), sa.Column('description', sa.String(length=512), nullable=False),
sa.PrimaryKeyConstraint('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 ### # ### end Alembic commands ###
def downgrade_() -> None: def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### 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') op.drop_table('queries')
# ### end Alembic commands ### # ### end Alembic commands ###