diff --git a/app/musicmuster.py b/app/musicmuster.py
index 89cd847..c7296f6 100755
--- a/app/musicmuster.py
+++ b/app/musicmuster.py
@@ -6,7 +6,6 @@ from typing import (
cast,
List,
Optional,
- Sequence,
)
from os.path import basename
@@ -49,7 +48,6 @@ from PyQt6.QtWidgets import (
QProgressBar,
QPushButton,
)
-from sqlalchemy import text
import stackprinter # type: ignore
from classes import (
@@ -511,12 +509,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionEnable_controls.triggered.connect(self.enable_play_next_controls)
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade)
- self.actionFind_next.triggered.connect(
- lambda: self.tabPlaylist.currentWidget().search_next()
- )
- self.actionFind_previous.triggered.connect(
- lambda: self.tabPlaylist.currentWidget().search_previous()
- )
self.actionImport.triggered.connect(self.import_track)
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track)
@@ -555,7 +547,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.hdrNextTrack.clicked.connect(self.show_next)
self.tabPlaylist.tabCloseRequested.connect(self.close_tab)
self.tabBar = self.tabPlaylist.tabBar()
- self.txtSearch.returnPressed.connect(self.search_playlist_return)
+ self.txtSearch.textChanged.connect(self.search_playlist_text_changed)
self.signals.enable_escape_signal.connect(self.enable_escape)
self.signals.next_track_changed_signal.connect(self.update_headers)
@@ -870,7 +862,7 @@ class Window(QMainWindow, Ui_MainWindow):
ok = dlg.exec()
if ok:
model.insert_row(
- proposed_row_number=self.active_tab().get_selected_row_number(),
+ proposed_row_number=self.active_tab().selected_model_row_number(),
note=dlg.textValue(),
)
@@ -1249,6 +1241,13 @@ class Window(QMainWindow, Ui_MainWindow):
self.active_tab().set_search(self.txtSearch.text())
self.enable_play_next_controls()
+ def search_playlist_text_changed(self) -> None:
+ """
+ Incremental search of playlist
+ """
+
+ self.active_model().set_incremental_search(self.txtSearch.text())
+
def select_next_row(self) -> None:
"""Select next or first row in playlist"""
diff --git a/app/playlistmodel.py b/app/playlistmodel.py
index c38aaa5..7fc535f 100644
--- a/app/playlistmodel.py
+++ b/app/playlistmodel.py
@@ -3,11 +3,12 @@ from datetime import datetime, timedelta
from enum import auto, Enum
from operator import attrgetter
from pprint import pprint
-from typing import List, Optional
+from typing import cast, List, Optional
from PyQt6.QtCore import (
QAbstractTableModel,
QModelIndex,
+ QRegularExpression,
QSortFilterProxyModel,
Qt,
QVariant,
@@ -24,10 +25,8 @@ from dbconfig import scoped_session, Session
from helpers import (
file_is_unreadable,
get_embedded_time,
- get_file_metadata,
get_relative_date,
open_in_audacity,
- normalise_track,
ms_to_mmss,
set_track_metadata,
)
@@ -95,23 +94,6 @@ class StartEndTimes:
end_time: Optional[datetime] = None
-class PlaylistProxyModel(QSortFilterProxyModel):
- """
- For searching and filtering
- """
-
- def __init__(
- self,
- playlist_id: int,
- *args,
- **kwargs,
- ):
- self.playlist_id = playlist_id
- super().__init__(*args, **kwargs)
-
- self.setSourceModel(PlaylistModel(playlist_id))
-
-
class PlaylistModel(QAbstractTableModel):
"""
The Playlist Model
@@ -1002,7 +984,9 @@ class PlaylistModel(QAbstractTableModel):
if now_plr:
track_sequence.now.plr_rownum = now_plr.plr_rownum
if track_sequence.previous.plr_rownum:
- previous_plr = session.get(PlaylistRows, track_sequence.previous.plr_rownum)
+ previous_plr = session.get(
+ PlaylistRows, track_sequence.previous.plr_rownum
+ )
if previous_plr:
track_sequence.previous.plr_rownum = previous_plr.plr_rownum
@@ -1243,3 +1227,126 @@ class PlaylistModel(QAbstractTableModel):
self.index(updated_row, Col.START_TIME.value),
self.index(updated_row, Col.END_TIME.value),
)
+
+
+class PlaylistProxyModel(QSortFilterProxyModel):
+ """
+ For searching and filtering
+ """
+
+ def __init__(
+ self,
+ playlist_model: PlaylistModel,
+ *args,
+ **kwargs,
+ ):
+ self.playlist_model = playlist_model
+ super().__init__(*args, **kwargs)
+
+ self.setSourceModel(playlist_model)
+ # Search all columns
+ self.setFilterKeyColumn(-1)
+
+ def set_incremental_search(self, search_string: str) -> None:
+ """
+ Update search pattern
+ """
+
+ self.setFilterRegularExpression(
+ QRegularExpression(
+ search_string, QRegularExpression.PatternOption.CaseInsensitiveOption
+ )
+ )
+
+ # ######################################
+ # Forward functions not handled in proxy
+ # ######################################
+
+ def delete_rows(self, row_numbers: List[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.delete_rows(row_numbers)
+
+ def get_duplicate_rows(self) -> List[int]:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.get_duplicate_rows()
+
+ def get_rows_duration(self, row_numbers: List[int]) -> int:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.get_rows_duration(row_numbers)
+
+ def get_row_info(self, row_number: int) -> PlaylistRowData:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.get_row_info(row_number)
+
+ def get_row_track_path(self, row_number: int) -> str:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.get_row_track_path(row_number)
+
+ def insert_row(
+ self,
+ proposed_row_number: Optional[int],
+ track_id: Optional[int] = None,
+ note: Optional[str] = None,
+ ) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.insert_row(proposed_row_number, track_id, note)
+
+ def is_header_row(self, row_number: int) -> bool:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.is_header_row(row_number)
+
+ def is_unplayed_row(self, row_number: int) -> bool:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.is_unplayed_row(row_number)
+
+ def mark_unplayed(self, row_numbers: List[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.mark_unplayed(row_numbers)
+
+ def move_rows(self, from_rows: List[int], to_row_number: int) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.move_rows(from_rows, to_row_number)
+
+ def move_rows_between_playlists(
+ self, from_rows: List[int], to_row_number: int, to_playlist_id: int
+ ) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.move_rows_between_playlists(
+ from_rows, to_row_number, to_playlist_id
+ )
+
+ def open_in_audacity(self, row_number: int) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.open_in_audacity(row_number)
+
+ def remove_track(self, row_number: int) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.remove_track(row_number)
+
+ def rescan_track(self, row_number: int) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.rescan_track(row_number)
+
+ def set_next_row(self, row_number: Optional[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.set_next_row(row_number)
+
+ def sort_by_artist(self, row_numbers: List[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.sort_by_artist(row_numbers)
+
+ def sort_by_duration(self, row_numbers: List[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.sort_by_duration(row_numbers)
+
+ def sort_by_lastplayed(self, row_numbers: List[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.sort_by_lastplayed(row_numbers)
+
+ def sort_by_title(self, row_numbers: List[int]) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.sort_by_title(row_numbers)
+
+ def update_track_times(self) -> None:
+ model = cast(PlaylistModel, self.sourceModel())
+ return model.update_track_times()
diff --git a/app/playlists.py b/app/playlists.py
index cccc1c5..4aa11f9 100644
--- a/app/playlists.py
+++ b/app/playlists.py
@@ -1,24 +1,21 @@
-import os
-import re
-import stackprinter # type: ignore
import subprocess
-import threading
import obsws_python as obs # type: ignore
from datetime import datetime, timedelta
from pprint import pprint
-from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING
+from typing import Callable, cast, List, Optional, TYPE_CHECKING
from PyQt6.QtCore import (
QEvent,
QModelIndex,
QObject,
QItemSelection,
+ QItemSelectionModel,
Qt,
# QTimer,
)
-from PyQt6.QtGui import QAction, QBrush, QColor, QFont, QDropEvent, QKeyEvent
+from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent
from PyQt6.QtWidgets import (
QAbstractItemDelegate,
QAbstractItemView,
@@ -49,6 +46,7 @@ from helpers import (
open_in_audacity,
send_mail,
set_track_metadata,
+ show_warning,
)
from log import log
from models import PlaylistRows, Settings, Tracks, NoteColours
@@ -67,8 +65,9 @@ class EscapeDelegate(QStyledItemDelegate):
- checks with user before abandoning edit on Escape
"""
- def __init__(self, parent) -> None:
+ def __init__(self, parent, playlist_model: PlaylistModel) -> None:
super().__init__(parent)
+ self.playlist_model = playlist_model
self.signals = MusicMusterSignals()
def createEditor(
@@ -123,12 +122,24 @@ class EscapeDelegate(QStyledItemDelegate):
return False
def setEditorData(self, editor, index):
- value = index.model().data(index, Qt.ItemDataRole.EditRole)
+ model = index.model()
+ if hasattr(model, "mapToSource"):
+ edit_index = model.mapToSource(index)
+ else:
+ edit_index = index
+
+ value = self.playlist_model.data(edit_index, Qt.ItemDataRole.EditRole)
editor.setPlainText(value.value())
def setModelData(self, editor, model, index):
+ model = index.model()
+ if hasattr(model, "mapToSource"):
+ edit_index = model.mapToSource(index)
+ else:
+ edit_index = index
+
value = editor.toPlainText()
- model.setData(index, value, Qt.ItemDataRole.EditRole)
+ self.playlist_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
@@ -165,10 +176,10 @@ class PlaylistTab(QTableView):
self.playlist_id = playlist_id
# Set up widget
- self.setItemDelegate(EscapeDelegate(self))
+ self.playlist_model = PlaylistModel(playlist_id)
+ self.proxy_model = PlaylistProxyModel(self.playlist_model)
+ self.setItemDelegate(EscapeDelegate(self, self.playlist_model))
self.setAlternatingRowColors(True)
- self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
- self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
@@ -202,8 +213,12 @@ class PlaylistTab(QTableView):
self.sort_undo: List[int] = []
# self.edit_cell_type: Optional[int]
+ # Selection model
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+
# Load playlist rows
- self.setModel(PlaylistProxyModel(playlist_id))
+ self.setModel(self.proxy_model)
self._set_column_widths()
def closeEditor(
@@ -224,8 +239,7 @@ class PlaylistTab(QTableView):
# Update start times in case a start time in a note has been
# edited
- model = cast(PlaylistModel, self.model())
- model.update_track_times()
+ self.playlist_model.update_track_times()
def dropEvent(self, event):
if event.source() is not self or (
@@ -234,7 +248,7 @@ class PlaylistTab(QTableView):
):
super().dropEvent(event)
- from_rows = list(set([a.row() for a in self.selectedIndexes()]))
+ from_rows = self.selected_model_row_numbers()
to_row = self.indexAt(event.position().toPoint()).row()
if (
0 <= min(from_rows) <= self.model().rowCount()
@@ -311,17 +325,76 @@ class PlaylistTab(QTableView):
self.clearSelection()
self.setDragEnabled(False)
- def get_selected_row_number(self) -> Optional[int]:
+ def selected_display_row_number(self):
"""
Return the selected row number or None if none selected.
"""
+ row_index = self._selected_row_index()
+ if row_index:
+ return row_index.row()
+ else:
+ return None
+ return row_index.row()
+
+ def selected_display_row_numbers(self):
+ """
+ Return a list of the selected row numbers
+ """
+
+ indexes = self._selected_row_indexes()
+
+ return [a.row() for a in indexes]
+
+ def selected_model_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
+ if hasattr(self.proxy_model, "mapToSource"):
+ return self.proxy_model.mapToSource(selected_index).row()
+ return selected_index.row()
+
+ def selected_model_row_numbers(self) -> Optional[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 None
+ if hasattr(self.proxy_model, "mapToSource"):
+ return [self.proxy_model.mapToSource(a).row() for a in selected_indexes]
+ return [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, "No or multiple rows selected", "Select only one row"
+ )
+ return None
+
+ return row_indexes[0]
+
+ def _selected_row_indexes(self) -> List[QModelIndex]:
+ """
+ Return a list of indexes of column 1 of selected rows
+ """
+
sm = self.selectionModel()
if sm and sm.hasSelection():
- index = sm.currentIndex()
- if index.isValid():
- return index.row()
- return None
+ return sm.selectedRows()
+ return []
def get_selected_row_track_path(self) -> str:
"""
@@ -329,13 +402,10 @@ class PlaylistTab(QTableView):
row does not have a track, return empty string.
"""
- sm = self.selectionModel()
- if sm and sm.hasSelection():
- index = sm.currentIndex()
- if index.isValid():
- model = cast(PlaylistModel, self.model())
- return model.get_row_track_path(index.row())
- return ""
+ model_row_number = self.selected_model_row_number()
+ if model_row_number is None:
+ return ""
+ return self.playlist_model.get_row_track_path(model_row_number)
# def lookup_row_in_songfacts(self) -> None:
# """
@@ -473,11 +543,10 @@ class PlaylistTab(QTableView):
Set selected row as next track
"""
- selected_row = self.get_selected_row_number()
- if selected_row is None:
+ model_row_number = self.selected_model_row_number()
+ if model_row_number is None:
return
- model = cast(PlaylistModel, self.model())
- model.set_next_row(selected_row)
+ self.playlist_model.set_next_row(model_row_number)
self.clearSelection()
# # # ########## Internally called functions ##########
@@ -485,14 +554,14 @@ class PlaylistTab(QTableView):
def _add_track(self) -> None:
"""Add a track to a section header making it a normal track row"""
- row_number = self.get_selected_row_number()
- if not row_number:
+ model_row_number = self.selected_model_row_number()
+ if model_row_number is None:
return
with Session() as session:
dlg = TrackSelectDialog(
session=session,
- new_row_number=row_number,
+ new_row_number=model_row_number,
playlist_id=self.playlist_id,
add_to_header=True,
)
@@ -502,25 +571,31 @@ class PlaylistTab(QTableView):
"""Used to process context (right-click) menu, which is defined here"""
self.menu.clear()
- model = cast(PlaylistModel, self.model())
- if not model:
- return
+ model = self.proxy_model
- row_number = item.row()
- header_row = model.is_header_row(row_number)
+ display_row_number = item.row()
+ if hasattr(model, "mapToSource"):
+ index = model.index(item.row(), item.column())
+ model_row_number = model.mapToSource(index).row()
+ else:
+ model_row_number = display_row_number
+
+ header_row = model.is_header_row(model_row_number)
track_row = not header_row
- current_row = row_number == track_sequence.now.plr_rownum
- next_row = row_number == track_sequence.next.plr_rownum
+ current_row = model_row_number == track_sequence.now.plr_rownum
+ next_row = model_row_number == track_sequence.next.plr_rownum
# Open in Audacity
if track_row and not current_row:
self._add_context_menu(
- "Open in Audacity", lambda: model.open_in_audacity(row_number)
+ "Open in Audacity", lambda: model.open_in_audacity(model_row_number)
)
# Rescan
if track_row and not current_row:
- self._add_context_menu("Rescan track", lambda: self._rescan(row_number))
+ self._add_context_menu(
+ "Rescan track", lambda: self._rescan(model_row_number)
+ )
# ----------------------
self.menu.addSeparator()
@@ -532,7 +607,7 @@ class PlaylistTab(QTableView):
# Remove track from row
if track_row and not current_row and not next_row:
self._add_context_menu(
- "Remove track from row", lambda: model.remove_track(row_number)
+ "Remove track from row", lambda: model.remove_track(model_row_number)
)
# Add track to section header (ie, make this a track row)
@@ -543,7 +618,7 @@ class PlaylistTab(QTableView):
self.menu.addSeparator()
# Mark unplayed
- if track_row and model.is_unplayed_row(row_number):
+ if track_row and model.is_unplayed_row(model_row_number):
self._add_context_menu(
"Mark unplayed",
lambda: self._mark_as_unplayed(self.get_selected_rows()),
@@ -583,12 +658,12 @@ class PlaylistTab(QTableView):
# Info
if track_row:
- self._add_context_menu("Info", lambda: self._info_row(row_number))
+ self._add_context_menu("Info", lambda: self._info_row(model_row_number))
# Track path TODO
if track_row:
self._add_context_menu(
- "Copy track path", lambda: self._copy_path(row_number)
+ "Copy track path", lambda: self._copy_path(model_row_number)
)
def _calculate_end_time(
@@ -631,8 +706,7 @@ class PlaylistTab(QTableView):
to the clipboard. Otherwise, return None.
"""
- model = cast(PlaylistModel, self.model())
- track_path = model.get_row_info(row_number).path
+ track_path = self.playlist_model.get_row_info(row_number).path
if not track_path:
return
@@ -669,8 +743,7 @@ class PlaylistTab(QTableView):
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
return
- model = cast(PlaylistModel, self.model())
- model.delete_rows(self.get_selected_rows())
+ self.playlist_model.delete_rows(self.selected_model_row_numbers())
def get_selected_rows(self) -> List[int]:
"""Return a list of selected row numbers sorted by row"""
@@ -682,8 +755,7 @@ class PlaylistTab(QTableView):
def _info_row(self, row_number: int) -> None:
"""Display popup with info re row"""
- model = cast(PlaylistModel, self.model())
- prd = model.get_row_info(row_number)
+ prd = self.playlist_model.get_row_info(row_number)
if prd:
txt = (
f"Title: {prd.title}\n"
@@ -739,8 +811,7 @@ class PlaylistTab(QTableView):
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Rescan track"""
- model = cast(PlaylistModel, self.model())
- model.mark_unplayed(row_numbers)
+ self.playlist_model.mark_unplayed(row_numbers)
self.clear_selection()
def _obs_change_scene(self, current_row: int) -> None:
@@ -786,8 +857,7 @@ class PlaylistTab(QTableView):
def _rescan(self, row_number: int) -> None:
"""Rescan track"""
- model = cast(PlaylistModel, self.model())
- model.rescan_track(row_number)
+ self.playlist_model.rescan_track(row_number)
self.clear_selection()
# def _reset_next(self, old_plrid: int, new_plrid: int) -> None:
@@ -931,8 +1001,7 @@ class PlaylistTab(QTableView):
# We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows
- model = cast(PlaylistModel, self.model())
- duplicate_rows = model.get_duplicate_rows()
+ duplicate_rows = self.playlist_model.get_duplicate_rows()
# Select the rows
for duplicate_row in duplicate_rows:
self.selectRow(duplicate_row)
@@ -951,8 +1020,9 @@ class PlaylistTab(QTableView):
if len(selected_rows) == 0:
self.musicmuster.lblSumPlaytime.setText("")
else:
- model = cast(PlaylistModel, self.model())
- selected_duration = model.get_rows_duration(self.get_selected_rows())
+ selected_duration = self.playlist_model.get_rows_duration(
+ self.get_selected_rows()
+ )
if selected_duration > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"
@@ -1011,6 +1081,14 @@ class PlaylistTab(QTableView):
Implement spanning of cells, initiated by signal
"""
+ model = self.proxy_model
+ if hasattr(model, "mapToSource"):
+ edit_index = model.mapFromSource(
+ self.playlist_model.createIndex(row, column)
+ )
+ row = edit_index.row()
+ column = edit_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.
@@ -1025,6 +1103,5 @@ class PlaylistTab(QTableView):
def _unmark_as_next(self) -> None:
"""Rescan track"""
- model = cast(PlaylistModel, self.model())
- model.set_next_row(None)
+ self.playlist_model.set_next_row(None)
self.clear_selection()
diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui
index 14a028a..a19d04a 100644
--- a/app/ui/main_window.ui
+++ b/app/ui/main_window.ui
@@ -785,8 +785,6 @@ padding-left: 8px;
&Search
-
-
diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py
index 4488a3e..6198d70 100644
--- a/app/ui/main_window_ui.py
+++ b/app/ui/main_window_ui.py
@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
-# Created by: PyQt6 UI code generator 6.5.3
+# Created by: PyQt6 UI code generator 6.6.0
#
# 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.
@@ -492,8 +492,6 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionMark_for_moving)
self.menuPlaylist.addAction(self.actionPaste)
self.menuSearc_h.addAction(self.actionSearch)
- self.menuSearc_h.addAction(self.actionFind_next)
- self.menuSearc_h.addAction(self.actionFind_previous)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSelect_next_track)
self.menuSearc_h.addAction(self.actionSelect_previous_track)