Compare commits

...

5 Commits

Author SHA1 Message Date
Keith Edmunds
c626d91f26 WIP V3: copy and paste rows to same or other playlist works 2023-11-23 10:59:03 +00:00
Keith Edmunds
551a574eac WIP V3: move unplayed rows 2023-11-23 04:44:36 +00:00
Keith Edmunds
80c363c316 WIP V3: better handle row order changing 2023-11-23 04:44:17 +00:00
Keith Edmunds
48b180e280 WIP V3: move selected tracks works 2023-11-22 19:57:14 +00:00
Keith Edmunds
223fb3bdec WIP V3: tests for moving rows between playlists pass 2023-11-22 16:57:16 +00:00
7 changed files with 313 additions and 165 deletions

View File

@ -81,8 +81,11 @@ class MusicMusterSignals(QObject):
add_track_to_header_signal = pyqtSignal(int, int, int)
add_track_to_playlist_signal = pyqtSignal(int, int, int, str)
begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
row_order_changed_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
@ -136,10 +139,10 @@ class PlaylistTrack:
Update with new plr information
"""
session.add(plr)
self.plr_rownum = plr.plr_rownum
if not plr.track:
return
session.add(plr)
track = plr.track
self.artist = track.artist
@ -149,7 +152,6 @@ class PlaylistTrack:
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
self.plr_rownum = plr.plr_rownum
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.start_time = None

View File

@ -6,6 +6,7 @@ from config import Config
from dbconfig import scoped_session
from datetime import datetime
from pprint import pprint
from typing import List, Optional, Sequence
from sqlalchemy.ext.associationproxy import association_proxy
@ -314,6 +315,7 @@ class Playlists(Base):
"""Mark playlist as loaded and used now"""
self.open = True
self.last_used = datetime.now()
@staticmethod
def name_is_available(session: scoped_session, name: str) -> bool:

View File

@ -221,6 +221,8 @@ class Window(QMainWindow, Ui_MainWindow):
self.active_tab = lambda: self.tabPlaylist.currentWidget()
self.active_model = lambda: self.tabPlaylist.currentWidget().model()
self.move_source_rows: Optional[List[int]] = None
self.move_source_model: Optional[PlaylistModel] = None
self.load_last_playlists()
if Config.CARTS_HIDE:
@ -610,10 +612,10 @@ class Window(QMainWindow, Ui_MainWindow):
Cut rows ready for pasting.
"""
with Session() as session:
# Save the selected PlaylistRows items ready for a later
# paste
self.selected_plrs = self.active_tab().get_selected_playlistrows(session)
# Save the selected PlaylistRows items ready for a later
# paste
self.move_source_rows = self.active_tab().get_selected_rows()
self.move_source_model = self.active_model()
def debug(self):
"""Invoke debugger"""
@ -925,106 +927,61 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals.search_wikipedia_signal.emit(track_info.title)
def move_playlist_rows(
self, session: scoped_session, playlistrows: Sequence[PlaylistRows]
) -> None:
def move_playlist_rows(self, row_numbers: List[int]) -> None:
"""
Move passed playlist rows to another playlist
Actions required:
- exclude current/next tracks from being moved
- identify destination playlist
- update playlist for the rows in the database
- remove them from the display
- update destination playlist display if loaded
"""
# Remove current/next rows from list
plrs_to_move = [
plr
for plr in playlistrows
if plr.id not in [track_sequence.now.plr_id, track_sequence.next.plr_id]
]
rows_to_delete = [
plr.plr_rownum for plr in plrs_to_move if plr.plr_rownum is not None
]
if not rows_to_delete:
return
# Identify destination playlist
playlists = []
visible_tab = self.active_tab()
source_playlist_id = visible_tab.playlist_id
for playlist in Playlists.get_all(session):
if playlist.id == source_playlist_id:
continue
with Session() as session:
for playlist in Playlists.get_all(session):
if playlist.id == source_playlist_id:
continue
else:
playlists.append(playlist)
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec()
if not dlg.playlist:
return
to_playlist_id = dlg.playlist.id
# Get row number in destination playlist
last_row = PlaylistRows.get_last_used_row(session, to_playlist_id)
if last_row is not None:
to_row = last_row + 1
else:
playlists.append(playlist)
to_row = 0
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec()
if not dlg.playlist:
return
destination_playlist_id = dlg.playlist.id
# Update destination playlist in the database
last_row = PlaylistRows.get_last_used_row(session, destination_playlist_id)
if last_row is not None:
next_row = last_row + 1
else:
next_row = 0
for plr in plrs_to_move:
plr.plr_rownum = next_row
next_row += 1
plr.playlist_id = destination_playlist_id
# Reset played as it's not been played on this playlist
plr.played = False
session.commit()
# Remove moved rows from display and save visible playlist
visible_tab.remove_rows(rows_to_delete)
visible_tab.save_playlist(session)
# Disable sort undo
self.sort_undo: List[int] = []
# Update destination playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
destination_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == dlg.playlist.id:
destination_playlist_tab = self.tabPlaylist.widget(tab)
break
if destination_playlist_tab:
destination_playlist_tab.populate_display(session, dlg.playlist.id)
# Move rows
self.active_model().move_rows_between_playlists(
row_numbers, to_row, to_playlist_id
)
def move_selected(self) -> None:
"""
Move selected rows to another playlist
"""
with Session() as session:
selected_plrs = self.active_tab().get_selected_playlistrows(session)
if not selected_plrs:
return
selected_rows = self.active_tab().get_selected_rows()
if not selected_rows:
return
self.move_playlist_rows(session, selected_plrs)
self.move_playlist_rows(selected_rows)
def move_unplayed(self) -> None:
"""
Move unplayed rows to another playlist
"""
playlist_id = self.active_tab().playlist_id
with Session() as session:
unplayed_plrs = PlaylistRows.get_unplayed_rows(session, playlist_id)
if helpers.ask_yes_no(
"Move tracks", f"Move {len(unplayed_plrs)} tracks:" " Are you sure?"
):
self.move_playlist_rows(session, unplayed_plrs)
unplayed_rows = self.active_model().get_unplayed_rows()
if not unplayed_rows:
return
self.move_playlist_rows(unplayed_rows)
def new_from_template(self) -> None:
"""Create new playlist from template"""
@ -1065,69 +1022,25 @@ class Window(QMainWindow, Ui_MainWindow):
def paste_rows(self) -> None:
"""
Paste earlier cut rows.
Process:
- ensure we have some cut rows
- if not pasting at end of playlist, move later rows down
- update plrs with correct playlist and row
- if moving between playlists: renumber source playlist rows
- else: check integrity of playlist rows
"""
if not self.selected_plrs:
if self.move_source_rows is None or self.move_source_model is None:
return
playlist_tab = self.active_tab()
dst_playlist_id = playlist_tab.playlist_id
dst_row = self.active_tab().get_new_row_number()
to_playlist_id = self.active_tab().playlist_id
selected_rows = self.active_tab().get_selected_rows()
if selected_rows:
destination_row = selected_rows[0]
else:
destination_row = self.active_model().rowCount()
with Session() as session:
# Create space in destination playlist
PlaylistRows.move_rows_down(
session, dst_playlist_id, dst_row, len(self.selected_plrs)
if to_playlist_id == self.move_source_model.playlist_id:
self.move_source_model.move_rows(self.move_source_rows, destination_row)
else:
self.move_source_model.move_rows_between_playlists(
self.move_source_rows, destination_row, to_playlist_id
)
session.commit()
# Update plrs
row = dst_row
src_playlist_id = None
for plr in self.selected_plrs:
# Update moved rows
session.add(plr)
if not src_playlist_id:
src_playlist_id = plr.playlist_id
plr.playlist_id = dst_playlist_id
plr.plr_rownum = row
row += 1
if not src_playlist_id:
return
session.flush()
# Update display
self.active_tab().populate_display(
session, dst_playlist_id, scroll_to_top=False
)
# If source playlist is not destination playlist, fixup row
# numbers and update display
if src_playlist_id != dst_playlist_id:
PlaylistRows.fixup_rownumbers(session, src_playlist_id)
# Update source playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
source_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == src_playlist_id:
source_playlist_tab = self.tabPlaylist.widget(tab)
break
if source_playlist_tab:
source_playlist_tab.populate_display(
session, src_playlist_id, scroll_to_top=False
)
# Reset so rows can't be repasted
self.selected_plrs = None
self.move_source_rows = self.move_source_model = None
def play_next(self, position: Optional[float] = None) -> None:
"""

View File

@ -2,6 +2,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import auto, Enum
from operator import attrgetter
from pprint import pprint
from typing import List, Optional
from PyQt6.QtCore import (
@ -124,6 +125,9 @@ class PlaylistModel(QAbstractTableModel):
self.signals.add_track_to_header_signal.connect(self.add_track_to_header)
self.signals.add_track_to_playlist_signal.connect(self.add_track)
self.signals.begin_reset_model_signal.connect(self.begin_reset_model)
self.signals.end_reset_model_signal.connect(self.end_reset_model)
self.signals.row_order_changed_signal.connect(self.row_order_changed)
with Session() as session:
# Ensure row numbers in playlist are contiguous
@ -230,6 +234,15 @@ class PlaylistModel(QAbstractTableModel):
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"""
@ -284,11 +297,7 @@ class PlaylistModel(QAbstractTableModel):
# Find next track
# Get all unplayed track rows
next_row = None
unplayed_rows = [
a.plr_rownum
for a in PlaylistRows.get_unplayed_rows(session, self.playlist_id)
]
unplayed_rows = self.get_unplayed_rows()
if unplayed_rows:
try:
# Find next row after current track
@ -357,7 +366,7 @@ class PlaylistModel(QAbstractTableModel):
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
self.refresh_data(session)
self.update_track_times()
self.row_order_changed(self.playlist_id)
def display_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
"""
@ -403,6 +412,18 @@ class PlaylistModel(QAbstractTableModel):
return QVariant()
def end_reset_model(self, playlist_id: int) -> None:
"""
End model reset if this is our playlist
"""
if playlist_id != self.playlist_id:
return
with Session() as session:
self.refresh_data(session)
super().endResetModel()
self.row_order_changed(self.playlist_id)
def get_duplicate_rows(self) -> List[int]:
"""
Return a list of duplicate rows. If track appears in rows 2, 3 and 4, return [3, 4]
@ -518,6 +539,13 @@ class PlaylistModel(QAbstractTableModel):
return self.playlist_rows[row_number]
def get_unplayed_rows(self) -> List[int]:
"""
Return a list of unplayed row numbers
"""
return [a.plr_rownum for a in self.playlist_rows.values() if not a.played]
def headerData(
self,
section: int,
@ -675,7 +703,8 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session)
super().endInsertRows()
self.invalidate_rows(list(range(plr.plr_rownum, len(self.playlist_rows))))
self.row_order_changed(self.playlist_id)
self.invalidate_rows(list(range(plr.plr_rownum, len(self.playlist_rows))))
return plr
@ -773,9 +802,64 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session)
# Update display
self.update_track_times()
self.signals.row_order_changed_signal.emit(self.playlist_id)
self.invalidate_rows(list(row_map.keys()))
def move_rows_between_playlists(
self, from_rows: List[int], to_row_number: int, to_playlist_id: int
) -> None:
"""
Move the playlist rows given to to_row and below of to_playlist.
"""
# 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)
next_to_row = to_row_number
# Prepare destination playlist for a reset
self.signals.begin_reset_model_signal.emit(to_playlist_id)
with Session() as session:
# 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
):
PlaylistRows.move_rows_down(
session, to_playlist_id, to_row_number, len(from_rows)
)
for row_group in row_groups:
super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group))
for plr in PlaylistRows.plrids_to_plrs(
session,
self.playlist_id,
[self.playlist_rows[a].plrid for a in row_group],
):
if plr.id == track_sequence.now.plr_id:
# Don't move current track
continue
plr.playlist_id = to_playlist_id
plr.plr_rownum = 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)
# Reset of model must come after session has been closed
self.signals.row_order_changed_signal.emit(self.playlist_id)
self.signals.row_order_changed_signal.emit(to_playlist_id)
self.signals.end_reset_model_signal.emit(to_playlist_id)
self.update_track_times()
def open_in_audacity(self, row_number: int) -> None:
"""
Open track at passed row number in Audacity
@ -851,11 +935,63 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_row(session, row_number)
self.invalidate_row(row_number)
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]]
"""
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()
return result
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
return len(self.playlist_rows)
def row_order_changed(self, playlist_id: int) -> None:
"""
Signal handler for when row ordering has changed
"""
if playlist_id != self.playlist_id:
return
with Session() as session:
if track_sequence.next.plr_rownum:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_rownum)
if next_plr:
track_sequence.next.plr_rownum = next_plr.plr_rownum
if track_sequence.now.plr_rownum:
now_plr = session.get(PlaylistRows, track_sequence.now.plr_rownum)
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)
if previous_plr:
track_sequence.previous.plr_rownum = previous_plr.plr_rownum
self.update_track_times()
def selection_is_sortable(self, row_numbers: List[int]) -> bool:
"""
Return True if the selection is sortable. That means:

View File

@ -7,6 +7,7 @@ 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 PyQt6.QtCore import (
@ -541,7 +542,7 @@ class PlaylistTab(QTableView):
# Mark unplayed
if track_row and model.is_unplayed_row(row_number):
self._add_context_menu(
"Mark unplayed", lambda: self._mark_as_unplayed(self._get_selected_rows())
"Mark unplayed", lambda: self._mark_as_unplayed(self.get_selected_rows())
)
# Unmark as next
@ -557,22 +558,22 @@ class PlaylistTab(QTableView):
sort_menu = self.menu.addMenu("Sort")
self._add_context_menu(
"by title",
lambda: model.sort_by_title(self._get_selected_rows()),
lambda: model.sort_by_title(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by artist",
lambda: model.sort_by_artist(self._get_selected_rows()),
lambda: model.sort_by_artist(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by duration",
lambda: model.sort_by_duration(self._get_selected_rows()),
lambda: model.sort_by_duration(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by last played",
lambda: model.sort_by_lastplayed(self._get_selected_rows()),
lambda: model.sort_by_lastplayed(self.get_selected_rows()),
parent_menu=sort_menu,
)
@ -652,7 +653,7 @@ class PlaylistTab(QTableView):
- Pass to model to do the deed
"""
rows_to_delete = self._get_selected_rows()
rows_to_delete = self.get_selected_rows()
row_count = len(rows_to_delete)
if row_count < 1:
return
@ -663,9 +664,9 @@ class PlaylistTab(QTableView):
return
model = cast(PlaylistModel, self.model())
model.delete_rows(self._get_selected_rows())
model.delete_rows(self.get_selected_rows())
def _get_selected_rows(self) -> List[int]:
def get_selected_rows(self) -> List[int]:
"""Return a list of selected row numbers sorted by row"""
# Use a set to deduplicate result (a selected row will have all
@ -939,13 +940,13 @@ class PlaylistTab(QTableView):
Toggle drag behaviour according to whether rows are selected
"""
selected_rows = self._get_selected_rows()
selected_rows = self.get_selected_rows()
# If no rows are selected, we have nothing to do
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 = model.get_rows_duration(self.get_selected_rows())
if selected_duration > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"

View File

@ -131,12 +131,12 @@ def test_playlist_open_and_close(session):
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open(session, tab_index=0)
playlist.mark_open()
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close(session)
playlist.close()
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1

View File

@ -1,8 +1,11 @@
from pprint import pprint
from typing import Optional
from app.models import (
Playlists,
Tracks,
)
from PyQt6.QtCore import Qt
from PyQt6.QtCore import Qt, QModelIndex
from app.helpers import get_file_metadata
from app import playlistmodel
@ -19,8 +22,8 @@ test_tracks = [
]
def create_model_with_tracks(session: scoped_session) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, "test playlist")
def create_model_with_tracks(session: scoped_session, name: Optional[str] = None) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, name or "test playlist")
model = playlistmodel.PlaylistModel(playlist.id)
for row in range(len(test_tracks)):
@ -34,9 +37,9 @@ def create_model_with_tracks(session: scoped_session) -> "playlistmodel.Playlist
def create_model_with_playlist_rows(
session: scoped_session, rows: int
session: scoped_session, rows: int, name: Optional[str] = None
) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, "test playlist")
playlist = Playlists(session, name or "test playlist")
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
for row in range(rows):
@ -268,6 +271,97 @@ def test_insert_track_new_playlist(monkeypatch, session):
)
def test_reverse_row_groups_one_row(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
rows_to_move = [3]
model_src = create_model_with_playlist_rows(session, 5, name="source")
result = model_src._reversed_contiguous_row_groups(rows_to_move)
assert len(result) == 1
assert result[0] == [3]
def test_reverse_row_groups_multiple_row(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
rows_to_move = [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
model_src = create_model_with_playlist_rows(session, 5, name="source")
result = model_src._reversed_contiguous_row_groups(rows_to_move)
assert result == [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
def test_move_one_row_between_playlists_to_end(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [3]
to_row = create_rowcount
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
model_dst.refresh_data(session)
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows))
)
def test_move_one_row_between_playlists_to_middle(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [3]
to_row = 2
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(row_number, playlistmodel.Col.TITLE.value, QModelIndex())
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert [int(a) for a in row_notes] == [0, 1, 3, 2, 3, 4]
def test_move_multiple_rows_between_playlists_to_end(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [1, 3, 4]
to_row = 2
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(row_number, playlistmodel.Col.TITLE.value, QModelIndex())
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert [int(a) for a in row_notes] == [0, 1, 3, 4, 1, 2, 3, 4]
# def test_edit_header(monkeypatch, session): # edit header row in middle of playlist
# monkeypatch.setattr(playlistmodel, "Session", session)