musicmuster/app/playlists.py
2022-03-02 09:23:56 +00:00

1303 lines
44 KiB
Python

from typing import Optional
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from PyQt5.Qt import QFont
from PyQt5.QtGui import QColor, QDropEvent
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import (
QAbstractItemView,
QApplication,
QMenu,
QMessageBox,
QTableWidget,
QTableWidgetItem,
)
from sqlalchemy import inspect
import helpers
import os
from config import Config
from datetime import datetime, timedelta
from helpers import get_relative_date, open_in_audacity
from log import DEBUG, ERROR
from models import (
Notes,
Playdates,
Session,
Settings,
Tracks,
NoteColours
)
class RowMeta:
CLEAR = 0
NOTE = 1
UNREADABLE = 2
NEXT = 4
CURRENT = 8
PLAYED = 16
class PlaylistTab(QTableWidget):
cellEditingStarted = QtCore.pyqtSignal(int, int)
cellEditingEnded = QtCore.pyqtSignal()
# Column names
COL_AUTOPLAY = COL_USERDATA = 0
COL_MSS = 1
COL_NOTE = 2
COL_TITLE = 2
COL_ARTIST = 3
COL_DURATION = 4
COL_START_TIME = 5
COL_END_TIME = 6
COL_LAST_PLAYED = COL_LAST = 7
NOTE_COL_SPAN = COL_LAST - COL_NOTE + 1
NOTE_ROW_SPAN = 1
# Qt.UserRoles
ROW_METADATA = Qt.UserRole
CONTENT_OBJECT = Qt.UserRole + 1
def __init__(self, parent, session, playlist, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent # The MusicMuster process
self.playlist = playlist
self.playlist.mark_open(session)
self.menu = None
# Set up widget
self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.setAlternatingRowColors(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setRowCount(0)
self.setColumnCount(8)
# Add header row
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(0, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(1, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(2, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(3, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(4, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(5, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(6, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(7, item)
self.horizontalHeader().setMinimumSectionSize(0)
self._set_column_widths()
self.setHorizontalHeaderLabels([
Config.COLUMN_NAME_AUTOPLAY,
Config.COLUMN_NAME_LEADING_SILENCE,
Config.COLUMN_NAME_TITLE,
Config.COLUMN_NAME_ARTIST,
Config.COLUMN_NAME_LENGTH,
Config.COLUMN_NAME_START_TIME,
Config.COLUMN_NAME_END_TIME,
Config.COLUMN_NAME_LAST_PLAYED,
])
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True)
self.setDragDropOverwriteMode(False)
self.setDropIndicatorShown(True)
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setDragDropMode(QAbstractItemView.InternalMove)
# This property defines how the widget shows a context menu
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
# This signal is emitted when the widget's contextMenuPolicy is
# Qt::CustomContextMenu, and the user has requested a context
# menu on the widget.
self.customContextMenuRequested.connect(self._context_menu)
self.viewport().installEventFilter(self)
self.itemSelectionChanged.connect(self._select_event)
self.editing_cell = False
self.cellChanged.connect(self._cell_changed)
self.doubleClicked.connect(self._edit_cell)
self.cellEditingStarted.connect(self._cell_edit_started)
self.cellEditingEnded.connect(self._cell_edit_ended)
# Now load our tracks and notes
self.populate(session)
self.current_track_start_time = None
def __repr__(self):
return f"<PlaylistTab(id={self.id}, name={self.name}>"
# ########## Events ##########
def dropEvent(self, event: QDropEvent):
# if not event.isAccepted() and event.source() == self:
if not event.source() == self:
return # We don't accept external drops
drop_row = self._drop_on(event)
rows = sorted(set(item.row() for item in self.selectedItems()))
rows_to_move = [
[QTableWidgetItem(self.item(row_index, column_index)) for
column_index in range(self.columnCount())]
for row_index in rows
]
for row_index in reversed(rows):
self.removeRow(row_index)
if row_index < drop_row:
drop_row -= 1
for row_index, data in enumerate(rows_to_move):
row_index += drop_row
self.insertRow(row_index)
for column_index, column_data in enumerate(data):
self.setItem(row_index, column_index, column_data)
event.accept()
# The above doesn't handle column spans, which we use in note
# rows. Check and fix:
row = 0 # So row is defined even if there are no rows in range
for row in range(drop_row, drop_row + len(rows_to_move)):
if row in self._meta_get_notes():
self.setSpan(
row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN)
# Scroll to drop zone
self.scrollToItem(self.item(row, 1))
super().dropEvent(event)
DEBUG(
"playlist.dropEvent(): "
f"Moved row(s) {rows} to become row {drop_row}"
)
with Session() as session:
self.save_playlist(session)
self.update_display()
def edit(self, index):
result = super(PlaylistTab, self).edit(index)
if result:
self.cellEditingStarted.emit(index.row(), index.column())
return result
def closeEditor(self, editor, hint):
super(PlaylistTab, self).closeEditor(editor, hint)
self.cellEditingEnded.emit()
def eventFilter(self, source, event):
"""Used to process context (right-click) menu, which is defined here"""
if (event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504
event.buttons() == QtCore.Qt.RightButton and # noqa W504
source is self.viewport()):
item = self.itemAt(event.pos())
if item is not None:
row = item.row()
DEBUG(f"playlist.eventFilter(): Right-click on row {row}")
current = row == self._meta_get_current()
next_row = row == self._meta_get_next()
self.menu = QMenu(self)
act_info = self.menu.addAction('Info')
act_info.triggered.connect(lambda: self._info_row(row))
self.menu.addSeparator()
if row not in self._meta_get_notes():
if not current and not next_row:
act_setnext = self.menu.addAction("Set next")
act_setnext.triggered.connect(
lambda: self._set_next(row))
act_copypath = self.menu.addAction("Copy track path")
act_copypath.triggered.connect(
lambda: self._copy_path(row))
if not current:
act_rescan = self.menu.addAction("Rescan track")
act_rescan.triggered.connect(lambda: self._rescan(row))
act_audacity = self.menu.addAction(
"Open track in Audacity")
act_audacity.triggered.connect(
lambda: self._audacity(row))
if not current and not next_row:
self.menu.addSeparator()
act_delete = self.menu.addAction('Delete')
act_delete.triggered.connect(self._delete_rows)
return super(PlaylistTab, self).eventFilter(source, event)
# ########## Externally called functions ##########
def closeEvent(self, event):
"""Save column widths"""
with Session() as session:
for column in range(self.columnCount()):
width = self.columnWidth(column)
name = f"playlist_col_{str(column)}_width"
record = Settings.get_int(session, name)
if record.f_int != self.columnWidth(column):
record.update(session, {'f_int': width})
event.accept()
def clear_current(self):
"""Clear current track"""
self._meta_clear_current()
self.update_display()
def clear_next(self):
"""Clear next track"""
self._meta_clear_next()
self.update_display()
def get_selected_row(self):
"""Return row number of first selected row, or None if none selected"""
if not self.selectionModel().hasSelection():
return None
else:
return self.selectionModel().selectedRows()[0].row()
def get_selected_rows_and_tracks(self):
"""Return a list of selected (rows, track) tuples"""
if not self.selectionModel().hasSelection():
return None
result = []
for row in [r.row() for r in self.selectionModel().selectedRows()]:
result.append((row, self._get_row_object(row)))
return result
def get_selected_title(self):
"""Return title of selected row or None"""
if self.selectionModel().hasSelection():
row = self.currentRow()
return self.item(row, self.COL_TITLE).text()
else:
return None
def remove_rows(self, rows):
"""Remove rows passed in rows list"""
# Row number will change as we delete rows. We could use
# QPersistentModelIndex, but easier just to remove them in
# reverse order.
for row in sorted(rows, reverse=True):
self.removeRow(row)
with Session() as session:
self.save_playlist(session)
self.update_display()
def play_started(self):
"""
Update current track to be what was next, and determine next track.
Return None
"""
self.current_track_start_time = datetime.now()
current_row = self._meta_get_next()
self._meta_set_current(current_row)
self._meta_set_played(current_row)
# Scroll to put current track in centre
scroll_to = self.item(current_row, self.COL_INDEX)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtCenter)
# Set next track
search_from = current_row + 1
next_row = self._find_next_track_row(search_from)
self._set_next(next_row)
self.update_display()
def play_stopped(self):
self._meta_clear_current()
self.current_track_start_time = None
self.update_display()
def populate(self, session):
"""
Populate from the associated playlist object
We don't mandate that an item will be on its specified row, only
that it will be above larger-numbered row items, and below
lower-numbered ones.
"""
data = []
# Make sure the database object is usable
insp = inspect(self.playlist)
if insp.detached:
session.add(self.playlist)
assert insp.persistent
for row, track in self.playlist.tracks.items():
data.append(([row], track))
for note in self.playlist.notes:
data.append(([note.row], note))
# Clear playlist
self.setRowCount(0)
# Now add data in row order
for i in sorted(data, key=lambda x: x[0]):
item = i[1]
if isinstance(item, Tracks):
self._insert_track(session, item, repaint=False)
elif isinstance(item, Notes):
self._insert_note(session, item, repaint=False)
# Scroll to top
scroll_to = self.item(0, self.COL_TITLE)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop)
# We possibly don't need to save the playlist here, but row
# numbers may have changed during population, and it's cheap to do
self.save_playlist(session)
self.update_display()
def save_playlist(self, session):
"""
Save playlist to database.
For notes: check the database entry is correct and update it if
necessary. Playlists:Note is one:many, so each note may only appear
in one playlist.
For tracks: erase the playlist tracks and recreate. This is much
simpler than trying to implement any Playlists:Tracks many:many
changes.
"""
# Ensure we have a valid database class
session.add(self.playlist)
# Notes first
# Create dictionaries indexed by note_id
playlist_notes = {}
database_notes = {}
notes_rows = self._meta_get_notes()
# PlaylistTab
for row in notes_rows:
note = self._get_row_object(row)
session.add(note)
playlist_notes[note.id] = note
# Database
for note in self.playlist.notes:
database_notes[note.id] = note
# We don't need to check for notes to add to the database as
# they can't exist in the playlist without being in the database
# and pointing at this playlist.
# Notes to remove from database
for note_id in set(database_notes.keys()) - set(playlist_notes.keys()):
DEBUG(
"_save_playlist(): "
f"Delete {note_id=} from {self=} in database"
)
database_notes[note_id].delete_note(session)
# Note rows to update in playlist database
for note_id in set(playlist_notes.keys()) & set(database_notes.keys()):
if playlist_notes[note_id].row != database_notes[note_id].row:
DEBUG(
f"_save_playlist(): Update notes row in database "
f"from {database_notes[note_id]=} "
f"to {playlist_notes[note_id]=}"
)
database_notes[note_id].update_note(
session, row=playlist_notes[note_id].row)
# Tracks
# Remove all tracks from this playlist
self.playlist.remove_all_tracks(session)
# Iterate on-screen playlist and add tracks back in
for row in range(self.rowCount()):
if row in notes_rows:
continue
track = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT)
self.playlist.add_track(session, track, row)
def select_next_row(self):
"""
Select next or first row. Don't select notes. Wrap at last row.
"""
selected_rows = [row for row in
set([a.row() for a in self.selectedItems()])]
# we will only handle zero or one selected rows
if len(selected_rows) > 1:
return
# select first row if none selected
if len(selected_rows) == 0:
row = 0
else:
row = selected_rows[0] + 1
if row >= self.rowCount():
row = 0
# Don't select notes
wrapped = False
while row in self._meta_get_notes():
row += 1
if row >= self.rowCount():
if wrapped:
# we're already wrapped once, so there are no
# non-notes
return
row = 0
wrapped = True
self.selectRow(row)
def select_played_tracks(self):
"""Select all played tracks in playlist"""
self._select_tracks(played=True)
def select_previous_row(self):
"""
Select previous or last track. Don't select notes. Wrap at first row.
"""
selected_rows = [row for row in
set([a.row() for a in self.selectedItems()])]
# we will only handle zero or one selected rows
if len(selected_rows) > 1:
return
# select last row if none selected
last_row = self.rowCount() - 1
if len(selected_rows) == 0:
row = last_row
else:
row = selected_rows[0] - 1
if row < 0:
row = last_row
# Don't select notes
wrapped = False
while row in self._meta_get_notes():
row -= 1
if row < 0:
if wrapped:
# we're already wrapped once, so there are no
# non-notes
return
row = last_row
wrapped = True
self.selectRow(row)
def select_unplayed_tracks(self):
"""Select all unplayed tracks in playlist"""
self._select_tracks(played=False)
def set_selected_as_next(self):
"""
Sets the selected track as the next track.
"""
if len(self.selectedItems()) != 1:
return
self._set_next(self.currentRow())
self.update_display()
def update_display(self, clear_selection=True):
"""Set row colours, fonts, etc"""
DEBUG(f"playlist.update_display [{self.playlist=}]")
with Session() as session:
if clear_selection:
self.clearSelection()
current_row = self._meta_get_current()
next_row = self._meta_get_next()
notes = self._meta_get_notes()
played = self._meta_get_played()
unreadable = self._meta_get_unreadable()
# Set colours and start times
next_start_time = None
# Don't change start times for tracks that have been played.
# For unplayed tracks, if there's a 'current' or 'next'
# track marked, populate start times from then onwards. A note
# with a start time will reset the next track start time.
if current_row and next_row:
start_times_row = min(current_row, next_row)
else:
start_times_row = current_row or next_row
if not start_times_row:
start_times_row = 0
# Cycle through all rows
for row in range(self.rowCount()):
# We can't calculate start times until next_start_time is
# set.
if row in notes:
# Extract note text
note_text = self.item(row, self.COL_TITLE).text()
# Does the note end with a time?
row_time = self._get_note_text_time(note_text)
if row_time:
next_start_time = row_time
# Set colour
note_colour = NoteColours.get_colour(session, note_text)
if not note_colour:
note_colour = Config.COLOUR_NOTES_PLAYLIST
self._set_row_colour(
row, QColor(note_colour)
)
# Notes are always bold
self._set_row_bold(row)
elif row in unreadable:
# Set colour
self._set_row_colour(
row, QColor(Config.COLOUR_UNREADABLE)
)
self._set_row_bold(row)
elif row == current_row:
# Extract track object
track = self._get_row_object(row)
# Set start time
self._set_row_start_time(
row, self.current_track_start_time)
# Set last played time
last_played_str = get_relative_date(
self.current_track_start_time)
self.item(row, self.COL_LAST_PLAYED).setText(
last_played_str)
# Calculate next_start_time
next_start_time = self._calculate_track_end_time(
track, self.current_track_start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
# Set colour
self._set_row_colour(row, QColor(
Config.COLOUR_CURRENT_PLAYLIST))
# Make bold
self._set_row_bold(row)
elif row == next_row:
# Extract track object
track = self._get_row_object(row)
# if there's a track playing, set start time from that
if self.current_track_start_time:
start_time = self._calculate_track_end_time(
track, self.current_track_start_time)
else:
# No current track to base from, but don't change
# time if it's already set
start_time = self._get_row_time(row)
if not start_time:
start_time = next_start_time
# Now set it
self._set_row_start_time(row, start_time)
next_start_time = self._calculate_track_end_time(
track, start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
# Set colour
self._set_row_colour(
row, QColor(Config.COLOUR_NEXT_PLAYLIST))
# Make bold
self._set_row_bold(row)
else:
# Extract track object
track = self._get_row_object(row)
if row in played:
# Played today, so update last played column
last_playtime = Playdates.last_played(
session, track.id)
last_played_str = get_relative_date(last_playtime)
self.item(row, self.COL_LAST_PLAYED).setText(
last_played_str)
self._set_row_not_bold(row)
else:
# Set start/end times as we haven't played it yet
if next_start_time and row >= start_times_row:
self._set_row_start_time(row, next_start_time)
next_start_time = self._calculate_track_end_time(
track, next_start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
else:
# Clear start and end time
self._set_row_start_time(row, None)
self._set_row_end_time(row, None)
# Don't dim unplayed tracks
self._set_row_bold(row)
# Stripe rows
if row % 2:
self._set_row_colour(
row, QColor(Config.COLOUR_ODD_PLAYLIST))
else:
self._set_row_colour(
row, QColor(Config.COLOUR_EVEN_PLAYLIST))
# ########## Internally called functions ##########
def _audacity(self, row):
"""Open track in Audacity. Audacity must be already running"""
DEBUG(f"_audacity({row})")
if row in self._meta_get_notes():
return None
track = self._get_row_object(row)
open_in_audacity(track.path)
@staticmethod
def _calculate_track_end_time(track, start: datetime) -> Optional[datetime]:
"""Return this track's end time given its start time"""
if start is None:
return None
if track is None:
DEBUG("_calculate_next_start_time() called with track=None")
return None
duration = Tracks.get_duration(session, self._get_row_id(row))
return start + timedelta(milliseconds=duration)
def _context_menu(self, pos):
self.menu.exec_(self.mapToGlobal(pos))
def _copy_path(self, row):
"""
If passed row is track row, copy the track path to the clipboard.
Otherwise, return None.
"""
DEBUG(f"_copy_path({row})")
if row in self._meta_get_notes():
return None
track = self._get_row_object(row)
if track:
cb = QApplication.clipboard()
cb.clear(mode=cb.Clipboard)
cb.setText(track.path, mode=cb.Clipboard)
def _cell_changed(self, row, column):
"""Called when cell content has changed"""
if not self.editing_cell:
return
if column not in [self.COL_TITLE, self.COL_ARTIST]:
return
new_text = self.item(row, column).text()
DEBUG(f"_cell_changed({row=}, {column=}, {new_text=}")
row_object = self._get_row_object(row)
with Session() as session:
if row in self._meta_get_notes():
# Save change to database
DEBUG(f"Notes.update_note: saving new note text '{new_text=}'")
row_object.update_note(session, row, new_text)
# Set/clear row start time accordingly
start_time = self._get_note_text_time(new_text)
if start_time:
self._set_row_start_time(row, start_time)
DEBUG(
f"_cell_changed:Note {new_text} contains valid "
f"time={start_time}"
)
else:
# Reset row start time in case it used to have one
self._set_row_start_time(row, None)
DEBUG(
f"_ct ell_changed:Note {new_text} does not contain "
"start time"
)
else:
if column == self.COL_ARTIST:
row_object.update_artist(session, artist=new_text)
elif column == self.COL_TITLE:
row_object.update_title(session, title=new_text)
else:
ERROR("_cell_changed(): unrecognised column")
def _cell_edit_ended(self):
DEBUG("_cell_edit_ended()")
self.editing_cell = False
# update_display to update start times, such as when a note has
# been edited
self.update_display()
self.parent.enable_play_next_controls()
def _cell_edit_started(self, row, column):
DEBUG(f"_cell_edit_started({row=}, {column=})")
self.editing_cell = True
# Disable play controls so that keyboard input doesn't disturb playing
self.parent.disable_play_next_controls()
def _delete_rows(self):
"""Delete mutliple rows"""
DEBUG("playlist._delete_rows()")
rows = sorted(set(item.row() for item in self.selectedItems()))
rows_to_delete = []
notes = self._meta_get_notes()
with Session() as session:
for row in rows:
title = self.item(row, self.COL_TITLE).text()
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Warning)
msg.setText(f"Delete '{title}'?")
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel)
msg.setDefaultButton(QMessageBox.Cancel)
msg.setWindowTitle("Delete row")
# Store list of rows to delete
if msg.exec() == QMessageBox.Yes:
rows_to_delete.append(row)
# delete in reverse row order so row numbers don't
# change
for row in sorted(rows_to_delete, reverse=True):
row_object = self._get_row_object(row)
if row in notes:
row_object.delete_note(session)
else:
self.remove_track(session, row)
self.removeRow(row)
self.save_playlist(session)
self.update_display()
def _drop_on(self, event):
index = self.indexAt(event.pos())
if not index.isValid():
return self.rowCount()
return (index.row() + 1 if self._is_below(event.pos(), index)
else index.row())
@staticmethod
def _get_note_text_time(text):
"""Return time specified at the end of text"""
try:
return datetime.strptime(
text[-Config.NOTE_TIME_FORMAT:],
Config.NOTE_TIME_FORMAT
)
except ValueError:
return None
def _get_row_object(self, row):
"""Return content associated with this row"""
return self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT)
def _info_row(self, row):
"""Display popup with info re row"""
row_object = self._get_row_object(row)
if row in self._meta_get_notes():
txt = row_object.note
else:
track = row_object
txt = (
f"Title: {track.title}\n"
f"Artist: {track.artist}\n"
f"Track ID: {track.id}\n"
f"Track duration: {helpers.ms_to_mmss(track.duration)}\n"
f"Track fade at: {helpers.ms_to_mmss(track.fade_at)}\n"
f"Track silence at: {helpers.ms_to_mmss(track.silence_at)}"
"\n\n"
f"Path: {track.path}\n"
)
info = QMessageBox(self)
info.setIcon(QMessageBox.Information)
info.setText(txt)
info.setStandardButtons(QMessageBox.Ok)
info.setDefaultButton(QMessageBox.Cancel)
info.exec()
def _insert_note(self, session, note, repaint=True):
"""
Insert a note to playlist tab.
If a row is selected, add note above. Otherwise, add to end of
playlist.
Return the row number that track is now in.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(f"playlist.inset_note(): row={row}")
self.insertRow(row)
# Add empty items to unused columns because
# colour won't be set for columns without items
item = QTableWidgetItem()
self.setItem(row, self.COL_USERDATA, item)
item = QTableWidgetItem()
self.setItem(row, self.COL_MSS, item)
# Add text of note from title column onwards
titleitem = QTableWidgetItem(note.note)
self.setItem(row, self.COL_NOTE, titleitem)
self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN)
# Attach note object to row
self._set_row_content(row, note)
# Mark row as a Note row
self._meta_set_note(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(clear_selection=False)
return row
def _insert_track(self, session, track, repaint=True):
"""
Insert track into playlist tab.
If a row is selected, add track above. Otherwise, add to end of
playlist.
Return the row number that track is now in.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(
f"playlists.insert_track({session=}, {track=}, {repaint=}), "
f"{row=}"
)
self.insertRow(row)
# Put an item in COL_USERDATA for later
item = QTableWidgetItem()
self.setItem(row, self.COL_USERDATA, item)
# Add track details to columns
mss_item = QTableWidgetItem(str(track.start_gap))
if track.start_gap and track.start_gap >= 500:
item.setBackground(QColor(Config.COLOUR_LONG_START))
self.setItem(row, self.COL_MSS, mss_item)
title_item = QTableWidgetItem(track.title)
self.setItem(row, self.COL_TITLE, title_item)
artist_item = QTableWidgetItem(track.artist)
self.setItem(row, self.COL_ARTIST, artist_item)
duration_item = QTableWidgetItem(helpers.ms_to_mmss(track.duration))
self.setItem(row, self.COL_DURATION, duration_item)
last_playtime = Playdates.last_played(session, track.id)
last_played_str = get_relative_date(last_playtime)
last_played_item = QTableWidgetItem(last_played_str)
self.setItem(row, self.COL_LAST_PLAYED, last_played_item)
# Add empty start and stop time because background
# colour won't be set for columns without items
start_item = QTableWidgetItem()
self.setItem(row, self.COL_START_TIME, start_item)
stop_item = QTableWidgetItem()
self.setItem(row, self.COL_END_TIME, stop_item)
# Attach track object to row
self._set_row_content(row, track)
# Mart track if file is unreadable
if not os.access(track.path, os.R_OK):
self._meta_set_unreadable(row)
# Scroll to new row
self.scrollToItem(title_item, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(clear_selection=False)
return row
def _is_below(self, pos, index):
rect = self.visualRect(index)
margin = 2
if pos.y() - rect.top() < margin:
return False
elif rect.bottom() - pos.y() < margin:
return True
return (
rect.contains(pos, True) and not
(int(self.model().flags(index)) & Qt.ItemIsDropEnabled)
and pos.y() >= rect.center().y() # noqa W503
)
def _edit_cell(self, mi):
"""Called when table is double-clicked"""
row = mi.row()
column = mi.column()
item = self.item(row, column)
if column in [self.COL_TITLE, self.COL_ARTIST]:
self.editItem(item)
def _find_next_track_row(self, starting_row=None):
"""
Find next track to play. If a starting row is given, start there;
else if there's a track selected, start looking from next track;
otherwise, start from top. Skip rows already played.
If not found, return None.
If found, return row number.
"""
if starting_row is None:
current_row = self._meta_get_current()
if current_row is not None:
starting_row = current_row + 1
else:
starting_row = 0
notes_rows = self._meta_get_notes()
played_rows = self._meta_get_played()
for row in range(starting_row, self.rowCount()):
if row in notes_rows or row in played_rows:
continue
else:
return row
return None
def _meta_clear_attribute(self, row, attribute):
"""Clear given metadata for row"""
if row is None:
raise ValueError(f"_meta_clear_attribute({row=}, {attribute=})")
new_metadata = self._meta_get(row) ^ attribute
self.item(row, self.COL_USERDATA).setData(
self.ROW_METADATA, new_metadata)
def _meta_clear_current(self):
"""
Clear current row if there is one. There may not be if
we've changed playlists
"""
current_row = self._meta_get_current()
if current_row is not None:
self._meta_clear_attribute(current_row, RowMeta.CURRENT)
def _meta_clear_next(self):
"""
Clear next row if there is one. There may not be if
we've changed playlists
"""
next_row = self._meta_get_next()
if next_row is not None:
self._meta_clear_attribute(next_row, RowMeta.NEXT)
def _meta_clear_played(self, row):
"""Clear played status on row"""
self._meta_clear_attribute(row, RowMeta.PLAYED)
def _meta_get(self, row):
"""Return row metadata"""
return self.item(row, self.COL_USERDATA).data(self.ROW_METADATA)
def _meta_get_current(self):
"""Return row marked as current, or None"""
return self._meta_search(RowMeta.CURRENT)
def _meta_get_next(self):
"""Return row marked as next, or None"""
return self._meta_search(RowMeta.NEXT)
def _meta_get_notes(self):
"""Return rows marked as notes, or None"""
return self._meta_search(RowMeta.NOTE, one=False)
def _meta_get_played(self):
"""Return rows marked as played, or None"""
return self._meta_search(RowMeta.PLAYED, one=False)
def _meta_get_unreadable(self):
"""Return rows marked as unreadable, or None"""
return self._meta_search(RowMeta.UNREADABLE, one=False)
def _meta_search(self, metadata, one=True):
"""
Search rows for metadata.
If one is True, check that only one row matches and return
the row number.
If one is False, return a list of matching row numbers.
"""
matches = []
for row in range(self.rowCount()):
if self._meta_get(row) & metadata:
matches.append(row)
if not one:
return matches
if len(matches) == 0:
return None
elif len(matches) == 1:
return matches[0]
else:
ERROR(
f"Multiple matches for metadata '{metadata}' found "
f"in rows: {', '.join([str(x) for x in matches])}"
)
raise AttributeError(f"Multiple '{metadata}' metadata {matches}")
def _meta_set_attribute(self, row, attribute):
"""Set row metadata"""
if row is None:
raise ValueError(f"_meta_set_attribute({row=}, {attribute=})")
new_metadata = self._meta_get(row) | attribute
self.item(row, self.COL_USERDATA).setData(
self.ROW_METADATA, new_metadata)
def _meta_set_current(self, row):
"""Mark this row as current track"""
self._meta_clear_current()
self._meta_set_attribute(row, RowMeta.CURRENT)
def _meta_set_next(self, row):
"""Mark this row as next track"""
self._meta_clear_next()
self._meta_set_attribute(row, RowMeta.NEXT)
def _meta_set_note(self, row):
"""Mark this row as a note"""
self._meta_set_attribute(row, RowMeta.NOTE)
def _meta_set_played(self, row):
"""Mark this row as played"""
self._meta_set_attribute(row, RowMeta.PLAYED)
def _meta_set_unreadable(self, row):
"""Mark this row as unreadable"""
self._meta_set_attribute(row, RowMeta.UNREADABLE)
def _set_next(self, row):
"""
If passed row is track row, check track is readable and, if it is:
- mark that track as the next track to be played
- notify musicmuster
- return track
Otherwise, return None.
"""
DEBUG(f"_set_next({row=})")
if row in self._meta_get_notes():
return None
track = self._get_row_object(row)
if not track:
return None
if self._track_is_readable(track):
self._meta_set_next(row)
self.parent.set_next_track(track)
else:
self._meta_set_unreadable(row)
track = None
self.update_display()
return track
def _rescan(self, row):
"""
If passed row is track row, rescan it.
Otherwise, return None.
"""
DEBUG(f"_rescan({row=})")
if row in self._meta_get_notes():
return None
track = self._get_row_object(row)
if track:
with Session() as session:
track.rescan(session)
self._update_row(row, track)
def _select_event(self):
"""
Called when item selection changes.
If multiple rows are selected, display sum of durations in status bar.
"""
rows = set([item.row() for item in self.selectedItems()])
note_rows = self._meta_get_notes()
ms = sum([self._get_row_object(row).duration
for row in rows if row not in note_rows])
# Only paint message if there are selected track rows
if ms > 0:
self.parent.lblSumPlaytime.setText(
f"Selected duration: {helpers.ms_to_mmss(ms)}")
else:
self.parent.lblSumPlaytime.setText("")
def _set_column_widths(self):
# Column widths from settings
with Session() as session:
for column in range(self.columnCount()):
name = f"playlist_col_{str(column)}_width"
record = Settings.get_int(session, name)
if record and record.f_int is not None:
self.setColumnWidth(column, record.f_int)
else:
self.setColumnWidth(column, Config.DEFAULT_COLUMN_WIDTH)
def _set_row_bold(self, row, bold=True):
boldfont = QFont()
boldfont.setBold(bold)
for j in range(self.columnCount()):
if self.item(row, j):
self.item(row, j).setFont(boldfont)
def _set_row_colour(self, row, colour):
for j in range(2, self.columnCount()):
if self.item(row, j):
self.item(row, j).setBackground(colour)
def _set_row_content(self, row, content):
"""Set content associated with this row"""
assert self.item(row, self.COL_USERDATA)
self.item(row, self.COL_USERDATA).setData(self.CONTENT_OBJECT, content)
def _set_row_end_time(self, row, time: Optional[datetime]):
"""Set passed row end time to passed time"""
try:
time_str = time.strftime(Config.NOTE_TIME_FORMAT)
except AttributeError:
time_str = ""
item = QTableWidgetItem(time_str)
self.setItem(row, self.COL_END_TIME, item)
def _set_row_not_bold(self, row):
self._set_row_bold(row, False)
def _set_row_start_time(self, row, time: Optional[datetime]):
"""Set passed row start time to passed time"""
try:
time_str = time.strftime(Config.NOTE_TIME_FORMAT)
except AttributeError:
time_str = ""
item = QTableWidgetItem(time_str)
self.setItem(row, self.COL_START_TIME, item)
def _track_path_is_readable(self, track_id):
"""
Returns True if track path is readable, else False
vlc cannot read files with a colon in the path
"""
with Session() as session:
path = Tracks.get_path(session, track_id)
if os.access(path, os.R_OK):
if ':' not in path:
return True
return False
def _update_row(self, row, track):
"""
Update the passed row with info from the passed track.
"""
DEBUG(f"_update_row({row=}, {track=}")
item_startgap = self.item(row, self.COL_MSS)
item_startgap.setText(str(track.start_gap))
if track.start_gap >= 500:
item_startgap.setBackground(QColor(Config.COLOUR_LONG_START))
else:
item_startgap.setBackground(QColor("white"))
item_title = self.item(row, self.COL_TITLE)
item_title.setText(track.title)
item_artist = self.item(row, self.COL_ARTIST)
item_artist.setText(track.artist)
item_duration = self.item(row, self.COL_DURATION)
item_duration.setText(helpers.ms_to_mmss(track.duration))
self.update_display()