WIP: query tabs

This commit is contained in:
Keith Edmunds 2025-02-07 17:03:48 +00:00
parent 7a98fe3920
commit f4a5ecf79e
11 changed files with 1808 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

694
app/querylistmodel.py Normal file
View File

@ -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"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
)
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"<PlaylistProxyModel: sourceModel={self.sourceModel()}>"
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())

892
app/querylists.py Normal file
View File

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

View File

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

View File

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

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

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