Compare commits

..

6 Commits

Author SHA1 Message Date
Keith Edmunds
f2a27366d3 Fix deleting rows from playlist 2022-12-23 21:27:06 +00:00
Keith Edmunds
46f2b662f3 Copy/paste, insert track/header works 2022-12-23 20:52:18 +00:00
Keith Edmunds
647e7d478a Move rows works. 2022-12-23 20:37:21 +00:00
Keith Edmunds
444c3e4fb4 Remove rows from playlist works and db updates 2022-12-23 20:15:07 +00:00
Keith Edmunds
35b101a538 Tidy up saving database 2022-12-23 17:23:43 +00:00
Keith Edmunds
d3958db8a3 Fix crash if create new playlist is cancelled 2022-12-23 09:27:14 +00:00
3 changed files with 256 additions and 203 deletions

View File

@ -2,6 +2,7 @@
#
import os.path
import re
import stackprinter
#
from dbconfig import Session
#
@ -417,7 +418,7 @@ class PlaylistRows(Base):
return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
f"note={self.note} row_number={self.row_number}>"
f"note={self.note}, row_number={self.row_number}>"
)
def __init__(self,
@ -452,31 +453,22 @@ class PlaylistRows(Base):
plr.note)
@staticmethod
def delete_higher_rows(session: Session, playlist_id: int, row: int) \
-> None:
def delete_plrids_not_in_list(session: Session, playlist_id: int,
plrids: List["PlaylistRows"]) -> None:
"""
Delete rows in given playlist that have a higher row number
than 'row'
than 'maxrow'
"""
# Log the rows to be deleted
rows_to_go = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > row)
).scalars().all()
if not rows_to_go:
return
for row in rows_to_go:
log.debug(f"Should delete: {row}")
# If needed later:
# session.delete(row)
rows_to_go = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > row)
).scalars().all()
session.execute(
delete(PlaylistRows)
.where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id.not_in(plrids)
)
)
# Delete won't take effect until commit()
session.commit()
@staticmethod
def delete_rows(session: Session, ids: List[int]) -> None:
@ -509,6 +501,42 @@ class PlaylistRows(Base):
# Ensure new row numbers are available to the caller
session.commit()
@classmethod
def get_plr(session: Session, row_number: int,
playlist_id: int) -> "PlaylistRows":
"""Return playlistrows object matching passed parameters"""
return (
select(PlaylistRows)
.where(
PlaylistRows.row_number == row_number,
PlaylistRows.playlist_id == playlist_id)
.limit(1)
).first()
@staticmethod
def get_track_plr(session: Session, track_id: int,
playlist_id: int) -> Optional["PlaylistRows"]:
"""Return first matching PlaylistRows object or None"""
return session.scalars(
select(PlaylistRows)
.where(
PlaylistRows.track_id == track_id,
PlaylistRows.playlist_id == playlist_id
)
.limit(1)
).first()
@staticmethod
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""Return the last used row for playlist, or None if no rows"""
return session.execute(
select(func.max(PlaylistRows.row_number))
.where(PlaylistRows.playlist_id == playlist_id)
).scalar_one()
@classmethod
def get_played_rows(cls, session: Session,
playlist_id: int) -> List[int]:
@ -547,15 +575,6 @@ class PlaylistRows(Base):
return plrs
@staticmethod
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""Return the last used row for playlist, or None if no rows"""
return session.execute(
select(func.max(PlaylistRows.row_number))
.where(PlaylistRows.playlist_id == playlist_id)
).scalar_one()
@classmethod
def get_unplayed_rows(cls, session: Session,
playlist_id: int) -> List[int]:
@ -594,23 +613,22 @@ class PlaylistRows(Base):
)
@staticmethod
def indexed_by_row(session: Session, playlist_id: int) -> dict:
def indexed_by_id(session: Session, plr_ids: List[int]) -> dict:
"""
Return a dictionary of playlist_rows indexed by row number for
the passed playlist_id.
Return a dictionary of playlist_rows indexed by their plr id from
the passed plr_id list.
"""
plrs = session.execute(
select(PlaylistRows)
.where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id.in_(plr_ids)
)
.order_by(PlaylistRows.row_number)
).scalars().all()
result = {}
for plr in plrs:
result[plr.row_number] = plr
result[plr.id] = plr
return result

View File

@ -2,7 +2,7 @@
from log import log
import argparse
import stackprinter
import stackprinter # type: ignore
import subprocess
import sys
import threading
@ -437,6 +437,8 @@ class Window(QMainWindow, Ui_MainWindow):
if not playlist_name:
playlist_name = self.solicit_playlist_name()
if not playlist_name:
return
playlist = Playlists(session, playlist_name)
return playlist
@ -446,7 +448,8 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session:
playlist = self.create_playlist(session)
self.create_playlist_tab(session, playlist)
if playlist:
self.create_playlist_tab(session, playlist)
def create_playlist_tab(self, session: Session,
playlist: Playlists) -> int:
@ -736,6 +739,7 @@ class Window(QMainWindow, Ui_MainWindow):
helpers.set_track_metadata(session, track)
helpers.normalise_track(track.path)
self.visible_playlist_tab().insert_track(session, track)
self.visible_playlist_tab().save_playlist(session)
def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist"""
@ -755,6 +759,7 @@ class Window(QMainWindow, Ui_MainWindow):
if ok:
with Session() as session:
playlist_tab.insert_header(session, dlg.textValue())
playlist_tab.save_playlist(session)
def insert_track(self) -> None:
"""Show dialog box to select and add track from database"""
@ -792,6 +797,8 @@ class Window(QMainWindow, Ui_MainWindow):
# Identify destination playlist
visible_tab = self.visible_playlist_tab()
source_playlist = visible_tab.playlist_id
# Get destination playlist id
playlists = []
for playlist in Playlists.get_all(session):
if playlist.id == source_playlist:
@ -799,17 +806,17 @@ class Window(QMainWindow, Ui_MainWindow):
else:
playlists.append(playlist)
# Get destination playlist id
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec()
if not dlg.playlist:
return
destination_playlist_id = dlg.playlist.id
# Remove moved rows from display
# Remove moved rows from display and save
visible_tab.remove_rows([plr.row_number for plr in playlistrows])
visible_tab.save_playlist(session)
# Update playlist for the rows in the database
# Update destination playlist in the database
last_row = PlaylistRows.get_last_used_row(session,
destination_playlist_id)
if last_row is not None:
@ -825,13 +832,13 @@ class Window(QMainWindow, Ui_MainWindow):
# Update destination playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
destionation_playlist_tab = None
destination_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == dlg.playlist.id:
destionation_playlist_tab = self.tabPlaylist.widget(tab)
destination_playlist_tab = self.tabPlaylist.widget(tab)
break
if destionation_playlist_tab:
destionation_playlist_tab.populate(session, dlg.playlist.id)
if destination_playlist_tab:
destination_playlist_tab.populate_display(session, dlg.playlist.id)
def move_selected(self) -> None:
"""
@ -913,17 +920,17 @@ class Window(QMainWindow, Ui_MainWindow):
playlist_tab = self.visible_playlist_tab()
dst_playlist_id = playlist_tab.playlist_id
dst_row = self.visible_playlist_tab().get_new_row_number()
with Session() as session:
# Create space in destination playlist
if playlist_tab.selectionModel().hasSelection():
row = playlist_tab.currentRow()
PlaylistRows.move_rows_down(session, dst_playlist_id,
row, len(self.selected_plrs))
session.commit()
PlaylistRows.move_rows_down(session, dst_playlist_id,
dst_row, len(self.selected_plrs))
session.commit()
# Update plrs
row = dst_row
src_playlist_id = None
dst_row = row
for plr in self.selected_plrs:
# Update moved rows
session.add(plr)
@ -936,8 +943,8 @@ class Window(QMainWindow, Ui_MainWindow):
session.commit()
# Update display
self.visible_playlist_tab().populate(session, dst_playlist_id,
scroll_to_top=False)
self.visible_playlist_tab().populate_display(
session, dst_playlist_id, scroll_to_top=False)
# If source playlist is not destination playlist, fixup row
# numbers and update display
@ -952,8 +959,8 @@ class Window(QMainWindow, Ui_MainWindow):
source_playlist_tab = self.tabPlaylist.widget(tab)
break
if source_playlist_tab:
source_playlist_tab.populate(session, src_playlist_id,
scroll_to_top=False)
source_playlist_tab.populate_display(
session, src_playlist_id, scroll_to_top=False)
# Reset so rows can't be repasted
self.selected_plrs = None
@ -1497,8 +1504,8 @@ class DbDialog(QDialog):
self.parent().visible_playlist_tab().insert_track(
self.session, track, note=self.ui.txtNote.text())
# Commit session to get correct row numbers if more tracks added
self.session.commit()
# Save to database (which will also commit changes)
self.parent().visible_playlist_tab().save_playlist(self.session)
# Clear note field and select search text to make it easier for
# next search
self.ui.txtNote.clear()

View File

@ -1,5 +1,5 @@
import re
import stackprinter
import stackprinter # type: ignore
import subprocess
import threading
@ -199,7 +199,7 @@ class PlaylistTab(QTableWidget):
# self.setSortingEnabled(True)
# Now load our tracks and notes
self.populate(session, self.playlist_id)
self.populate_display(session, self.playlist_id)
def __repr__(self) -> str:
return f"<PlaylistTab(id={self.playlist_id}>"
@ -327,8 +327,8 @@ class PlaylistTab(QTableWidget):
act_setnext.triggered.connect(
lambda: self._set_next(session, row_number))
# Open in Audacity
if not current:
# Open in Audacity
act_audacity = self.menu.addAction(
"Open in Audacity")
act_audacity.triggered.connect(
@ -557,6 +557,17 @@ class PlaylistTab(QTableWidget):
self.clearSelection()
self.setDragEnabled(False)
def get_new_row_number(self) -> int:
"""
Return the selected row or the row count if no row selected
(ie, new row will be appended)
"""
if self.selectionModel().hasSelection():
return self.currentRow()
else:
return self.rowCount()
def get_selected_playlistrow_ids(self) -> Optional[List]:
"""
Return a list of PlaylistRow ids of the selected rows
@ -584,63 +595,49 @@ class PlaylistTab(QTableWidget):
to do the heavy lifing.
"""
# PlaylistRows object requires a row number, but that number
# can be reset by calling PlaylistRows.fixup_rownumbers() later,
# so just fudge a row number for now.
row_number = 0
row_number = self.get_new_row_number()
plr = PlaylistRows(session, self.playlist_id, None, row_number, note)
self.insert_row(session, plr)
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
if repaint:
self.update_display(session, clear_selection=False)
self.insert_row(session, plr, repaint)
self.save_playlist(session)
def insert_row(self, session: Session, row_data: PlaylistRows,
def insert_row(self, session: Session, plr: PlaylistRows,
repaint: bool = True) -> None:
"""
Insert a row into playlist tab.
If playlist has a row selected, add new row above. Otherwise,
add to end of playlist.
Note: we ignore the row number in the PlaylistRows record. That is
used only to order the query that generates the records.
Insert passed playlist row (plr) into playlist tab.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
row = plr.row_number
self.insertRow(row)
# Add row metadata to userdata column
userdata_item = QTableWidgetItem()
userdata_item.setData(self.ROW_FLAGS, 0)
userdata_item.setData(self.PLAYLISTROW_ID, row_data.id)
userdata_item.setData(self.ROW_TRACK_ID, row_data.track_id)
userdata_item.setData(self.PLAYLISTROW_ID, plr.id)
userdata_item.setData(self.ROW_TRACK_ID, plr.track_id)
self.setItem(row, USERDATA, userdata_item)
if row_data.track_id:
if plr.track_id:
# Add track details to items
try:
start_gap = row_data.track.start_gap
except:
start_gap = plr.track.start_gap
except AttributeError:
return
start_gap_item = QTableWidgetItem(str(start_gap))
if start_gap and start_gap >= 500:
start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START))
self.setItem(row, START_GAP, start_gap_item)
title_item = QTableWidgetItem(row_data.track.title)
title_item = QTableWidgetItem(plr.track.title)
log.debug(f"KAE: insert_row:619, {title_item.text()=}")
self.setItem(row, TITLE, title_item)
artist_item = QTableWidgetItem(row_data.track.artist)
artist_item = QTableWidgetItem(plr.track.artist)
self.setItem(row, ARTIST, artist_item)
duration_item = QTableWidgetItem(
ms_to_mmss(row_data.track.duration))
ms_to_mmss(plr.track.duration))
self.setItem(row, DURATION, duration_item)
self._set_row_duration(row, row_data.track.duration)
self._set_row_duration(row, plr.track.duration)
start_item = QTableWidgetItem()
self.setItem(row, START_TIME, start_item)
@ -648,8 +645,8 @@ class PlaylistTab(QTableWidget):
end_item = QTableWidgetItem()
self.setItem(row, END_TIME, end_item)
if row_data.track.bitrate:
bitrate = str(row_data.track.bitrate)
if plr.track.bitrate:
bitrate = str(plr.track.bitrate)
else:
bitrate = ""
bitrate_item = QTableWidgetItem(bitrate)
@ -657,23 +654,23 @@ class PlaylistTab(QTableWidget):
# As we have track info, any notes should be contained in
# the notes column
notes_item = QTableWidgetItem(row_data.note)
notes_item = QTableWidgetItem(plr.note)
self.setItem(row, ROW_NOTES, notes_item)
last_playtime = Playdates.last_played(session, row_data.track.id)
last_playtime = Playdates.last_played(session, plr.track.id)
last_played_str = get_relative_date(last_playtime)
last_played_item = QTableWidgetItem(last_played_str)
self.setItem(row, LASTPLAYED, last_played_item)
# Mark track if file is unreadable
if not file_is_readable(row_data.track.path):
if not file_is_readable(plr.track.path):
self._set_unreadable_row(row)
else:
# This is a section header so it must have note text
if row_data.note is None:
if plr.note is None:
log.debug(
f"insert_row({row_data=}) with no track_id and no note"
f"insert_row({plr=}) with no track_id and no note"
)
return
@ -687,17 +684,16 @@ class PlaylistTab(QTableWidget):
continue
self.setItem(row, i, QTableWidgetItem())
self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1)
notes_item = QTableWidgetItem(row_data.note)
notes_item = QTableWidgetItem(plr.note)
self.setItem(row, HEADER_NOTES_COLUMN, notes_item)
# Save (no) track_id
userdata_item.setData(self.ROW_TRACK_ID, 0)
if repaint:
self.save_playlist(session)
self.update_display(session, clear_selection=False)
def insert_track(self, session: Session, track: Optional[Tracks],
def insert_track(self, session: Session, track: Tracks,
note: str = None, repaint: bool = True) -> None:
"""
Insert track into playlist tab.
@ -709,20 +705,30 @@ class PlaylistTab(QTableWidget):
to do the heavy lifing.
"""
# PlaylistRows object requires a row number, but that number
# can be reset by calling PlaylistRows.fixup_rownumbers() later,
# so just fudge a row number for now.
row_number = 0
if track:
track_id = track.id
else:
track_id = None
plr = PlaylistRows(session, self.playlist_id,
track_id, row_number, note)
self.insert_row(session, plr)
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
if repaint:
self.update_display(session, clear_selection=False)
if not track and track.id:
log.debug(
f"insert_track({session=}, {track=}, {note=}, {repaint=}"
" called with either no track or no track.id"
)
return
row_number = self.get_new_row_number()
# Check to see whether track is already in playlist
existing_plr = PlaylistRows.get_track_plr(session, track.id,
self.playlist_id)
if existing_plr and ask_yes_no("Duplicate row",
"Track already in playlist. "
"Move to new location?"):
# Yes it is and we shoudl reuse it
return self._move_row(session, existing_plr, row_number)
# Build playlist_row object
plr = PlaylistRows(session, self.playlist_id, track.id,
row_number, note)
self.insert_row(session, plr, repaint)
# Let display update, then save playlist
QTimer.singleShot(0, lambda: self.save_playlist(session))
def play_started(self, session: Session) -> None:
"""
@ -771,10 +777,10 @@ class PlaylistTab(QTableWidget):
self._clear_current_track_row()
self.current_track_start_time = None
def populate(self, session: Session, playlist_id: int,
scroll_to_top: bool = True) -> None:
def populate_display(self, session: Session, playlist_id: int,
scroll_to_top: bool = True) -> None:
"""
Populate from the associated playlist ID
Populate display from the associated playlist ID
"""
# Sanity check row numbering before we load
@ -785,8 +791,8 @@ class PlaylistTab(QTableWidget):
# Add the rows
playlist = session.get(Playlists, playlist_id)
for row in playlist.rows:
self.insert_row(session, row, repaint=False)
for plr in playlist.rows:
self.insert_row(session, plr, repaint=False)
# Scroll to top
if scroll_to_top:
@ -822,22 +828,39 @@ class PlaylistTab(QTableWidget):
def save_playlist(self, session: Session) -> None:
"""
All playlist rows have a PlaylistRows id. Check that that id points
to this playlist (in case track has been moved from other) and that
the row number is correct (in case tracks have been reordered).
Get the PlaylistRow objects for each row in the display. Correct
the row_number and playlist_id if necessary. Remove any row
numbers in the database that are higher than the last row in
the display.
"""
plr_dict = PlaylistRows.indexed_by_row(session, self.playlist_id)
for row in range(self.rowCount()):
# Set the row number and playlist id (even if correct)
plr_dict[row].row_number = row
plr_dict[row].playlist_id = self.playlist_id
# Build a dictionary of
# {display_row_number: display_row_plr_id}
display_plr_ids = {row_number: self._get_playlistrow_id(row_number)
for row_number in range(self.rowCount())}
# Any rows in the database with a row_number higher that the
# current value of 'row' should not be there. Commit session
# first to ensure any changes made above are committed.
# Now build a dictionary of
# {display_row_number: display_row_plr}
plr_dict_by_id = PlaylistRows.indexed_by_id(session,
display_plr_ids.values())
# Finally a dictionary of
# {display_row_number: plr}
row_plr = {row_number: plr_dict_by_id[display_plr_ids[row_number]]
for row_number in range(self.rowCount())}
# Ensure all row plrs have correct row number and playlist_id
for row in range(self.rowCount()):
row_plr[row].row_number = row
row_plr[row].playlist_id = self.playlist_id
# Any rows in the database for this playlist that have a plr id
# that's not in the displayed playlist need to be deleted.
# Ensure changes flushed
session.commit()
PlaylistRows.delete_higher_rows(session, self.playlist_id, row)
PlaylistRows.delete_plrids_not_in_list(session, self.playlist_id,
display_plr_ids.values())
def scroll_current_to_top(self) -> None:
"""Scroll currently-playing row to top"""
@ -860,65 +883,6 @@ class PlaylistTab(QTableWidget):
return
self._search(next=True)
def _search(self, next: bool = True) -> None:
"""
Select next/previous row containg self.search_string. Start from
top selected row if there is one, else from top.
Wrap at last/first row.
"""
if not self.search_text:
return
selected_row = self._get_selected_row()
if next:
if selected_row is not None and selected_row < self.rowCount() - 1:
starting_row = selected_row + 1
else:
starting_row = 0
else:
if selected_row is not None and selected_row > 0:
starting_row = selected_row - 1
else:
starting_row = self.rowCount() - 1
wrapped = False
match_row = None
row = starting_row
needle = self.search_text.lower()
while True:
# Check for match in title, artist or notes
title = self._get_row_title(row)
if title and needle in title.lower():
match_row = row
break
artist = self._get_row_artist(row)
if artist and needle in artist.lower():
match_row = row
break
note = self._get_row_note(row)
if note and needle in note.lower():
match_row = row
break
if next:
row += 1
if wrapped and row >= starting_row:
break
if row >= self.rowCount():
row = 0
wrapped = True
else:
row -= 1
if wrapped and row <= starting_row:
break
if row < 0:
row = self.rowCount() - 1
wrapped = True
if match_row is not None:
self.selectRow(row)
def search_next(self) -> None:
"""
Select next row containg self.search_string.
@ -1344,9 +1308,8 @@ class PlaylistTab(QTableWidget):
Delete mutliple rows
Actions required:
- Delete the rows from the PlaylistRows table
- Correct the row numbers in the PlaylistRows table
- Remove the rows from the display
- Save the playlist
"""
# Delete rows from database
@ -1359,13 +1322,10 @@ class PlaylistTab(QTableWidget):
f"Really delete {row_count} row{plural}?"):
return
with Session() as session:
PlaylistRows.delete_rows(session, plr_ids)
self.remove_selected_rows()
# Fix up row numbers left in this playlist
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
# Remove selected rows from display
self.remove_selected_rows()
with Session() as session:
QTimer.singleShot(0, lambda: self.save_playlist(session))
def _drop_on(self, event):
"""
@ -1545,11 +1505,6 @@ class PlaylistTab(QTableWidget):
return self._meta_search(RowMeta.UNREADABLE, one=False)
# def _header_click(self, index: int) -> None:
# """Handle playlist header click"""
# print(f"_header_click({index=})")
def _info_row(self, track_id: int) -> None:
"""Display popup with info re row"""
@ -1658,6 +1613,20 @@ class PlaylistTab(QTableWidget):
new_metadata = self._meta_get(row) | (1 << attribute)
self.item(row, USERDATA).setData(self.ROW_FLAGS, new_metadata)
def _move_row(self, session: Session, plr: PlaylistRows,
new_row_number: int) -> None:
"""Move playlist row to new_row_number using parent copy/paste"""
# Remove source row
self.removeRow(plr.row_number)
# Fixup plr row number
if plr.row_number < new_row_number:
plr.row_number = new_row_number - 1
else:
plr.row_number = new_row_number
self.insert_row(session, plr)
self.save_playlist(session)
def _mplayer_play(self, track_id: int) -> None:
"""Play track with mplayer"""
@ -1769,6 +1738,65 @@ class PlaylistTab(QTableWidget):
scroll_item = self.item(top_row, 0)
self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop)
def _search(self, next: bool = True) -> None:
"""
Select next/previous row containg self.search_string. Start from
top selected row if there is one, else from top.
Wrap at last/first row.
"""
if not self.search_text:
return
selected_row = self._get_selected_row()
if next:
if selected_row is not None and selected_row < self.rowCount() - 1:
starting_row = selected_row + 1
else:
starting_row = 0
else:
if selected_row is not None and selected_row > 0:
starting_row = selected_row - 1
else:
starting_row = self.rowCount() - 1
wrapped = False
match_row = None
row = starting_row
needle = self.search_text.lower()
while True:
# Check for match in title, artist or notes
title = self._get_row_title(row)
if title and needle in title.lower():
match_row = row
break
artist = self._get_row_artist(row)
if artist and needle in artist.lower():
match_row = row
break
note = self._get_row_note(row)
if note and needle in note.lower():
match_row = row
break
if next:
row += 1
if wrapped and row >= starting_row:
break
if row >= self.rowCount():
row = 0
wrapped = True
else:
row -= 1
if wrapped and row <= starting_row:
break
if row < 0:
row = self.rowCount() - 1
wrapped = True
if match_row is not None:
self.selectRow(row)
def _select_event(self) -> None:
"""
Called when item selection changes.