Compare commits

..

No commits in common. "87a0b0149cba99e04a96526ca66ec1513ad34d38" and "71fad71ed0315a263fbdbe81ad011d2cbe1571d2" have entirely different histories.

7 changed files with 1206 additions and 113 deletions

View File

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

View File

@ -62,7 +62,7 @@ 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
from querylistmodel import QuerylistModel, QuerylistProxyModel
from playlists import PlaylistTab
from querylists import QuerylistTab
from ui import icons_rc # noqa F401
@ -680,9 +680,11 @@ class Window(QMainWindow, Ui_MainWindow):
# 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=base_model)
querylist_tab = QuerylistTab(musicmuster=self, model=proxy_model)
idx = self.tabPlaylist.addTab(querylist_tab, querylist.name)
log.debug(f"create_querylist_tab() returned: {idx=}")

View File

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

View File

@ -22,7 +22,10 @@ from PyQt6.QtWidgets import (
QFrame,
QMenu,
QMessageBox,
QProxyStyle,
QStyle,
QStyledItemDelegate,
QStyleOption,
QStyleOptionViewItem,
QTableView,
QTableWidgetItem,
@ -34,7 +37,7 @@ from PyQt6.QtWidgets import (
# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
from classes import ApplicationError, QueryCol, MusicMusterSignals, PlaylistStyle, TrackInfo
from config import Config
from dialogs import TrackSelectDialog
from helpers import (
@ -109,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)
@ -245,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:

View File

@ -2,18 +2,21 @@
# Allow forward reference to PlaylistModel
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
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 (
@ -23,22 +26,29 @@ from PyQt6.QtGui import (
)
# 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, Playdates
from music_manager import RowAndTrack
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from music_manager import RowAndTrack, track_sequence
@dataclass
@ -72,7 +82,10 @@ class QuerylistModel(QAbstractTableModel):
super().__init__()
self.querylist_rows: dict[int, QueryRow] = {}
self._selected_rows: set[int] = set()
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
@ -83,27 +96,32 @@ class QuerylistModel(QAbstractTableModel):
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) -> QBrush:
"""Return background setting"""
# Unreadable track file
if file_is_unreadable(qrow.path):
return QVariant(QColor(Config.COLOUR_UNREADABLE))
# Selected row
if row in self._selected_rows:
return QVariant(QColor(Config.COLOUR_QUERYLIST_SELECTED))
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 QVariant(QColor(Config.COLOUR_BITRATE_LOW))
return QBrush(QColor(Config.COLOUR_BITRATE_LOW))
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM))
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
else:
return QVariant(QColor(Config.COLOUR_BITRATE_OK))
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
return QVariant()
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"""
@ -124,10 +142,10 @@ class QuerylistModel(QAbstractTableModel):
qrow = self.querylist_rows[row]
# Dispatch to role-specific functions
dispatch_table: dict[int, Callable] = {
dispatch_table = {
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
int(Qt.ItemDataRole.DisplayRole): self.display_role,
int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role,
int(Qt.ItemDataRole.EditRole): self.edit_role,
}
if role in dispatch_table:
@ -135,8 +153,8 @@ class QuerylistModel(QAbstractTableModel):
# Document other roles but don't use them
if role in [
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
Qt.ItemDataRole.InitialSortOrderRole,
@ -168,60 +186,102 @@ class QuerylistModel(QAbstractTableModel):
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
"""
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
default = (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
)
if index.column() in [
QueryCol.TITLE.value,
QueryCol.ARTIST.value,
]:
return default | Qt.ItemFlag.ItemIsEditable
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
return default
def get_selected_track_ids(self) -> list[int]:
def get_row_info(self, row_number: int) -> RowAndTrack:
"""
Return a list of track_ids from selected tracks
Return info about passed row
"""
return [self.querylist_rows[row].track_id for row in self._selected_rows]
return self.querylist_rows[row_number]
def headerData(
self,
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole,
) -> QVariant:
def get_row_track_id(self, row_number: int) -> Optional[int]:
"""
Return text for headers
Return id of track associated with row or None if no track associated
"""
display_dispatch_table = {
QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE),
QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST),
QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION),
QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED),
QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE),
}
return self.querylist_rows[row_number].track_id
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal:
return display_dispatch_table[section]
else:
if Config.ROWS_FROM_ZERO:
return QVariant(str(section))
else:
return QVariant(str(section + 1))
def get_row_track_path(self, row_number: int) -> str:
"""
Return path of track associated with row or empty string if no track associated
"""
elif role == Qt.ItemDataRole.FontRole:
boldfont = QFont()
boldfont.setBold(True)
return QVariant(boldfont)
return self.querylist_rows[row_number].path
return QVariant()
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,
self, session: db.session,
sql: str = """
SELECT
tracks.*,playdates.lastplayed
@ -236,7 +296,7 @@ class QuerylistModel(QAbstractTableModel):
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
@ -255,50 +315,380 @@ class QuerylistModel(QAbstractTableModel):
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"],
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 toggle_row_selection(self, row: int) -> None:
if row in self._selected_rows:
self._selected_rows.discard(row)
else:
self._selected_rows.add(row)
# Emit dataChanged for the entire row
top_left = self.index(row, 0)
bottom_right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
def selection_is_sortable(self, row_numbers: list[int]) -> bool:
"""
Return tooltip. Currently only used for last_played column.
Return True if the selection is sortable. That means:
- at least two rows selected
- selected rows are contiguous
"""
if column != QueryCol.LAST_PLAYED.value:
return QVariant()
# 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:
track_id = self.querylist_rows[row].track_id
if not track_id:
return QVariant()
playdates = Playdates.last_playdates(session, track_id)
return QVariant(
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
]
)
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())

View File

@ -1,28 +1,55 @@
# Standard library imports
from typing import cast, Optional, TYPE_CHECKING
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
import line_profiler
# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, MusicMusterSignals, PlaylistStyle
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 querylistmodel import QuerylistModel
from music_manager import track_sequence
from querylistmodel import QuerylistModel, QuerylistProxyModel
if TYPE_CHECKING:
from musicmuster import Window
@ -33,13 +60,13 @@ class QuerylistTab(QTableView):
The querylist view
"""
def __init__(self, musicmuster: "Window", model: QuerylistModel) -> None:
def __init__(self, musicmuster: "Window", model: QuerylistProxyModel) -> None:
super().__init__()
# Save passed settings
self.musicmuster = musicmuster
self.playlist_id = model.playlist_id
self.playlist_id = model.sourceModel().playlist_id
# Set up widget
self.setAlternatingRowColors(True)
@ -52,17 +79,20 @@ class QuerylistTab(QTableView):
# 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)
# Enable item editing for checkboxes
self.clicked.connect(self.handle_row_click)
# Set up for Audacity
try:
self.ac: Optional[AudacityController] = AudacityController()
@ -92,6 +122,114 @@ class QuerylistTab(QTableView):
# ########## 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))
@ -102,7 +240,214 @@ class QuerylistTab(QTableView):
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"""
@ -134,16 +479,234 @@ class QuerylistTab(QTableView):
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 _context_menu(self, pos):
"""Display right-click menu"""
def model(self) -> QuerylistModel:
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(QuerylistModel, super().model())
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:
"""
@ -168,6 +731,85 @@ class QuerylistTab(QTableView):
# 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"""
@ -187,7 +829,64 @@ class QuerylistTab(QTableView):
else:
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
def tab_live(self) -> None:
"""Noop for query tabs"""
def set_row_as_next_track(self) -> None:
"""
Set selected row as next track
"""
return
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

@ -686,5 +686,5 @@ class Ui_MainWindow(object):
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 # type: ignore
from pyqtgraph import PlotWidget # type: ignore
from infotabs import InfoTabs
from pyqtgraph import PlotWidget