WIP: queries management

Menus and management working. Wrong tracks showing up in queries.
This commit is contained in:
Keith Edmunds 2025-03-02 19:14:53 +00:00
parent aa6ab03555
commit 8e48d63ebb
4 changed files with 364 additions and 23 deletions

View File

@ -49,6 +49,18 @@ class Config(object):
FADEOUT_DB = -10
FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5
FILTER_DURATION_LONGER = "longer than"
FILTER_DURATION_MINUTES = "minutes"
FILTER_DURATION_SECONDS = "seconds"
FILTER_DURATION_SHORTER = "shorter than"
FILTER_PATH_CONTAINS = "contains"
FILTER_PATH_EXCLUDING = "excluding"
FILTER_PLAYED_BEFORE = "before"
FILTER_PLAYED_DAYS = "days"
FILTER_PLAYED_MONTHS = "months"
FILTER_PLAYED_NEVER = "never"
FILTER_PLAYED_WEEKS = "weeks"
FILTER_PLAYED_YEARS = "years"
FUZZYMATCH_MINIMUM_LIST = 60.0
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0

View File

@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session
from sqlalchemy.engine.row import RowMapping
# App imports
from classes import ApplicationError
from classes import ApplicationError, Filter
from config import Config
from dbmanager import DatabaseManager
import dbtables
@ -610,11 +610,21 @@ class Queries(dbtables.QueriesTable):
session.commit()
@classmethod
def get_all_queries(cls, session: Session) -> Sequence["Queries"]:
def get_all(cls, session: Session) -> Sequence["Queries"]:
"""Returns a list of all queries ordered by name"""
return session.scalars(select(cls).order_by(cls.name)).all()
@classmethod
def get_favourites(cls, session: Session) -> Sequence["Queries"]:
"""Returns a list of favourite queries ordered by name"""
return session.scalars(
select(cls)
.where(cls.favourite.is_(True))
.order_by(cls.name)
).all()
class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str) -> None:
@ -700,6 +710,40 @@ class Tracks(dbtables.TracksTable):
.all()
)
@classmethod
def get_filtered_tracks(cls, session: Session, filter: Filter) -> Sequence["Tracks"]:
"""
Return tracks matching filter
"""
query = select(cls)
if filter.path:
if filter.path_type == "contains":
query = query.where(cls.path.ilike(f"%{filter.path}%"))
elif filter.path_type == "excluding":
query = query.where(cls.path.notilike(f"%{filter.path}%"))
else:
raise ApplicationError(f"Can't process filter path ({filter=})")
# TODO
# if last_played_number:
# need group_by track_id and having max/min lastplayed gt/lt, etc
seconds_duration = filter.duration_number
if filter.duration_unit == Config.FILTER_DURATION_MINUTES:
seconds_duration *= 60
elif filter.duration_unit != Config.FILTER_DURATION_SECONDS:
raise ApplicationError(f"Can't process filter duration ({filter=})")
if filter.duration_type == Config.FILTER_DURATION_LONGER:
query = query.where(cls.duration >= seconds_duration)
elif filter.duration_unit == Config.FILTER_DURATION_SHORTER:
query = query.where(cls.duration <= seconds_duration)
else:
raise ApplicationError(f"Can't process filter duration type ({filter=})")
records = session.scalars(
query).unique().all()
return records
@classmethod
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
"""

View File

@ -20,6 +20,7 @@ from PyQt6.QtCore import (
Qt,
QTime,
QTimer,
QVariant,
)
from PyQt6.QtGui import (
QAction,
@ -32,6 +33,7 @@ from PyQt6.QtGui import (
QShortcut,
)
from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
@ -46,7 +48,9 @@ from PyQt6.QtWidgets import (
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QTableView,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@ -74,6 +78,7 @@ from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tr
from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab
from querylistmodel import QuerylistModel
from ui import icons_rc # noqa F401
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
@ -190,7 +195,9 @@ class FilterDialog(QDialog):
path_layout = QHBoxLayout()
path_label = QLabel("Path")
self.path_combo = QComboBox()
self.path_combo.addItems(["contains", "excluding"])
self.path_combo.addItems(
[Config.FILTER_PATH_CONTAINS, Config.FILTER_PATH_EXCLUDING]
)
for idx in range(self.path_combo.count()):
if self.path_combo.itemText(idx) == filter.path_type:
self.path_combo.setCurrentIndex(idx)
@ -207,7 +214,9 @@ class FilterDialog(QDialog):
last_played_layout = QHBoxLayout()
last_played_label = QLabel("Last played")
self.last_played_combo = QComboBox()
self.last_played_combo.addItems(["before", "never"])
self.last_played_combo.addItems(
[Config.FILTER_PLAYED_BEFORE, Config.FILTER_PLAYED_NEVER]
)
for idx in range(self.last_played_combo.count()):
if self.last_played_combo.itemText(idx) == filter.last_played_type:
self.last_played_combo.setCurrentIndex(idx)
@ -219,7 +228,14 @@ class FilterDialog(QDialog):
self.last_played_spinbox.setValue(filter.last_played_number or 0)
self.last_played_unit = QComboBox()
self.last_played_unit.addItems(["years", "months", "weeks", "days"])
self.last_played_unit.addItems(
[
Config.FILTER_PLAYED_YEARS,
Config.FILTER_PLAYED_MONTHS,
Config.FILTER_PLAYED_WEEKS,
Config.FILTER_PLAYED_DAYS,
]
)
for idx in range(self.last_played_unit.count()):
if self.last_played_unit.itemText(idx) == filter.last_played_unit:
self.last_played_unit.setCurrentIndex(idx)
@ -239,7 +255,9 @@ class FilterDialog(QDialog):
duration_layout = QHBoxLayout()
duration_label = QLabel("Duration")
self.duration_combo = QComboBox()
self.duration_combo.addItems(["longer than", "shorter than"])
self.duration_combo.addItems(
[Config.FILTER_DURATION_LONGER, Config.FILTER_DURATION_SHORTER]
)
for idx in range(self.duration_combo.count()):
if self.duration_combo.itemText(idx) == filter.duration_type:
self.duration_combo.setCurrentIndex(idx)
@ -251,8 +269,10 @@ class FilterDialog(QDialog):
self.duration_spinbox.setValue(filter.duration_number)
self.duration_unit = QComboBox()
self.duration_unit.addItems(["minutes", "seconds"])
self.duration_unit.setCurrentText("minutes")
self.duration_unit.addItems(
[Config.FILTER_DURATION_MINUTES, Config.FILTER_DURATION_SECONDS]
)
self.duration_unit.setCurrentText(Config.FILTER_DURATION_MINUTES)
for idx in range(self.duration_unit.count()):
if self.duration_unit.itemText(idx) == filter.duration_unit:
self.duration_unit.setCurrentIndex(idx)
@ -447,7 +467,7 @@ class ManageQueries(ItemlistManager):
# Build a list of queries
query_list: list[ItemlistItem] = []
for query in Queries.get_all_queries(self.session):
for query in Queries.get_all(self.session):
query_list.append(
ItemlistItem(name=query.name, id=query.id, favourite=query.favourite)
)
@ -478,6 +498,7 @@ class ManageQueries(ItemlistManager):
dlg = FilterDialog(query.name, query.filter)
if dlg.exec():
query.filter = dlg.filter
query.name = dlg.name_text.text()
self.session.commit()
def edit_item(self, query_id: int) -> None:
@ -771,6 +792,230 @@ class PreviewManager:
self.start_time = None
class QueryDialog(QDialog):
"""Dialog box to handle selecting track from a query"""
def __init__(self, session: Session, default: int = 0) -> None:
super().__init__()
self.session = session
self.default = default
# Build a list of (query-name, playlist-id) tuples
self.selected_tracks: list[int] = []
self.query_list: list[tuple[str, int]] = []
self.query_list.append((Config.NO_QUERY_NAME, 0))
for query in Queries.get_all(self.session):
self.query_list.append((query.name, query.id))
self.setWindowTitle("Query Selector")
# Create label
query_label = QLabel("Query:")
# Top layout (Query label, combo box, and info label)
top_layout = QHBoxLayout()
# Query label
query_label = QLabel("Query:")
top_layout.addWidget(query_label)
# Combo Box with fixed width
self.combo_box = QComboBox()
# self.combo_box.setFixedWidth(150) # Adjust as necessary for 20 characters
for text, id_ in self.query_list:
self.combo_box.addItem(text, id_)
top_layout.addWidget(self.combo_box)
# Table (middle part)
self.table_view = QTableView()
self.table_view.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection
)
self.table_view.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table_view.setAlternatingRowColors(True)
self.table_view.setVerticalScrollMode(
QAbstractItemView.ScrollMode.ScrollPerPixel
)
self.table_view.clicked.connect(self.handle_row_click)
# Bottom layout (buttons)
bottom_layout = QHBoxLayout()
bottom_layout.addStretch() # Push buttons to the right
self.add_tracks_button = QPushButton("Add tracks")
self.add_tracks_button.setEnabled(False) # Disabled by default
self.add_tracks_button.clicked.connect(self.add_tracks_clicked)
bottom_layout.addWidget(self.add_tracks_button)
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.cancel_clicked)
bottom_layout.addWidget(self.cancel_button)
# Main layout
main_layout = QVBoxLayout()
main_layout.addLayout(top_layout)
main_layout.addWidget(self.table_view)
main_layout.addLayout(bottom_layout)
self.combo_box.currentIndexChanged.connect(self.query_changed)
if self.default:
default_idx = self.combo_box.findData(QVariant(self.default))
self.combo_box.setCurrentIndex(default_idx)
self.path_text = QLineEdit()
self.setLayout(main_layout)
# Stretch last column *after* setting column widths which is
# *much* faster
h_header = self.table_view.horizontalHeader()
if h_header:
h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True)
# Resize on vertical header click
v_header = self.table_view.verticalHeader()
if v_header:
v_header.setMinimumSectionSize(5)
v_header.sectionHandleDoubleClicked.disconnect()
v_header.sectionHandleDoubleClicked.connect(
self.table_view.resizeRowToContents
)
self.set_window_size()
self.resizeRowsToContents()
def add_tracks_clicked(self):
self.selected_tracks = self.table_view.model().get_selected_track_ids()
self.accept()
def cancel_clicked(self):
self.selected_tracks = []
self.reject()
def closeEvent(self, event: QCloseEvent | None) -> None:
"""
Record size and columns
"""
self.save_sizes()
super().closeEvent(event)
def accept(self) -> None:
self.save_sizes()
super().accept()
def reject(self) -> None:
self.save_sizes()
super().reject()
def save_sizes(self) -> None:
"""
Save window size
"""
# Save dialog box attributes
attributes_to_save = dict(
querylist_height=self.height(),
querylist_width=self.width(),
querylist_x=self.x(),
querylist_y=self.y(),
)
for name, value in attributes_to_save.items():
record = Settings.get_setting(self.session, name)
record.f_int = value
header = self.table_view.horizontalHeader()
if header is None:
return
column_count = header.count()
if column_count < 2:
return
for column_number in range(column_count - 1):
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(self.session, attr_name)
record.f_int = self.table_view.columnWidth(column_number)
self.session.commit()
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
"""
Called when column width changes.
"""
header = self.table_view.horizontalHeader()
if not header:
return
# Resize rows if necessary
self.resizeRowsToContents()
def resizeRowsToContents(self):
header = self.table_view.verticalHeader()
model = self.table_view.model()
if model:
for row in range(model.rowCount()):
hint = self.table_view.sizeHintForRow(row)
header.resizeSection(row, hint)
def query_changed(self, idx: int) -> None:
"""
Called when user selects query
"""
# Get query id
query_id = self.combo_box.currentData()
query = self.session.get(Queries, query_id)
if not query:
return
# Create model
base_model = QuerylistModel(self.session, query.filter)
# Create table
self.table_view.setModel(base_model)
self.set_column_sizes()
def handle_row_click(self, index):
self.table_view.model().toggle_row_selection(index.row())
self.table_view.clearSelection()
# Enable 'Add tracks' button only when a row is selected
selected = self.table_view.model().get_selected_track_ids()
self.add_tracks_button.setEnabled(selected != [])
def set_window_size(self) -> None:
"""Set window sizes"""
x = Settings.get_setting(self.session, "querylist_x").f_int or 100
y = Settings.get_setting(self.session, "querylist_y").f_int or 100
width = Settings.get_setting(self.session, "querylist_width").f_int or 100
height = Settings.get_setting(self.session, "querylist_height").f_int or 100
self.setGeometry(x, y, width, height)
def set_column_sizes(self) -> None:
"""Set column sizes"""
header = self.table_view.horizontalHeader()
if header is None:
return
column_count = header.count()
if column_count < 2:
return
# Last column is set to stretch so ignore it here
for column_number in range(column_count - 1):
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(self.session, attr_name)
if record.f_int is not None:
self.table_view.setColumnWidth(column_number, record.f_int)
else:
self.table_view.setColumnWidth(
column_number, Config.DEFAULT_COLUMN_WIDTH
)
class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlists=None, session=None):
super().__init__()
@ -1146,12 +1391,52 @@ class Window(QMainWindow):
return submenu_items
def get_query_dynamic_submenu_items(self):
"""Returns dynamically generated menu items for Submenu 2."""
return [
{"text": "Action Xargs", "handler": "kae", "args": (21,)},
{"text": "Action Y", "handler": "action_y_handler"},
]
def get_query_dynamic_submenu_items(
self,
) -> list[dict[str, str | tuple[Session, int] | bool]]:
"""
Return dynamically generated menu items, in this case
templates marked as favourite from which to generate a
new playlist.
The handler is to call show_query with a session
and query_id.
"""
with db.Session() as session:
submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [
{
"text": "Show all",
"handler": "show_query",
"args": (session, 0),
},
{
"separator": True,
},
]
queries = Queries.get_favourites(session)
for query in queries:
submenu_items.append(
{
"text": query.name,
"handler": "show_query",
"args": (
session,
query.id,
),
}
)
return submenu_items
def show_query(self, session: Session, query_id: int) -> None:
"""
Show query dialog with query_id selected
"""
# Keep a reference else it will be gc'd
self.query_dialog = QueryDialog(session, query_id)
self.query_dialog.exec()
# # # # # # # # # # Playlist management functions # # # # # # # # # #

View File

@ -38,7 +38,7 @@ from helpers import (
show_warning,
)
from log import log
from models import db, Playdates
from models import db, Playdates, Tracks
from music_manager import RowAndTrack
@ -228,20 +228,20 @@ class QuerylistModel(QAbstractTableModel):
row = 0
try:
results = Tracks.get_filtered(self.session, self.filter)
results = Tracks.get_filtered_tracks(self.session, self.filter)
for result in results:
if hasattr(result, "lastplayed"):
lastplayed = result["lastplayed"]
else:
lastplayed = None
queryrow = QueryRow(
artist=result["artist"],
bitrate=result["bitrate"],
duration=result["duration"],
artist=result.artist,
bitrate=result.bitrate or 0,
duration=result.duration,
lastplayed=lastplayed,
path=result["path"],
title=result["title"],
track_id=result["id"],
path=result.path,
title=result.title,
track_id=result.id,
)
self.querylist_rows[row] = queryrow