musicmuster/app/playlists.py
2021-05-16 22:56:11 +01:00

866 lines
28 KiB
Python

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,
QMenu,
QMessageBox,
QTableWidget,
QTableWidgetItem,
)
import helpers
import os
from config import Config
from datetime import datetime, timedelta
from log import DEBUG, ERROR
from model import Notes, PlaylistTracks, Settings, Tracks
class Playlist(QTableWidget):
# Column names
COL_INDEX = 0
COL_MSS = 1
COL_NOTE = 2
COL_TITLE = 2
COL_ARTIST = 3
COL_DURATION = 4
COL_START_TIME = 5
COL_PATH = 6
NOTE_COL_SPAN = 3
NOTE_ROW_SPAN = 1
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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(7)
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)
self.horizontalHeader().setMinimumSectionSize(0)
self._set_column_widths()
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 holds 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.current_track_start_time = None
self.played_tracks = []
# ########## Events ##########
def closeEvent(self, event):
"Save column widths"
for column in range(self.columnCount()):
width = self.columnWidth(column)
name = f"playlist_col_{str(column)}_width"
record = Settings.get_int(name)
if record.f_int != self.columnWidth(column):
record.update({'f_int': width})
event.accept()
def dropEvent(self, event: QDropEvent):
if not event.isAccepted() and event.source() == self:
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()
# We don't want rows to be selected after move
# for row_index in range(len(rows_to_move)):
# for column_index in range(self.columnCount()):
# self.item(drop_row + row_index,
# column_index).setSelected(True)
# The above doesn't handle column spans, which we use in note
# rows. Check and fix:
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)
super().dropEvent(event)
DEBUG(
"playlist.dropEvent(): "
f"Moved row(s) {rows} to become row {drop_row}"
)
self._repaint()
def eventFilter(self, source, event):
"Used to process context (right-click) menu"
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}")
self.menu = QMenu(self)
if row not in self._meta_get_notes():
act_setnext = self.menu.addAction("Set next")
act_setnext.triggered.connect(lambda: self._set_next(row))
self.menu.addSeparator()
act_delete = self.menu.addAction('Delete')
act_delete.triggered.connect(lambda: self._delete_row(row))
return super(Playlist, self).eventFilter(source, event)
# ########## Externally called functions ##########
def add_note(self, note, repaint=True):
"""
Add note to playlist
If a row is selected, add note above. Otherwise, add to end of
playlist.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(f"playlist.add_note(): row={row}")
# Does note end with a time?
start_time = None
try:
start_time = datetime.strptime(note.note[-9:], " %H:%M:%S").time()
DEBUG(f"Note contains valid time={start_time}")
except ValueError:
DEBUG(
f"Note on row {row} ('{note.note}') "
"does not contain valid time"
)
self.insertRow(row)
item = QTableWidgetItem(str(note.id))
self.setItem(row, self.COL_INDEX, item)
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)
# Add start times or empty items as background
# colour won't be set for columns without items
self._set_row_time(row, start_time)
item = QTableWidgetItem()
self.setItem(row, self.COL_PATH, item)
self._meta_set_note(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self._repaint(clear_selection=False)
def add_track(self, track, repaint=True):
"""
Add track to playlist
If a row is selected, add track above. Otherwise, add to end of
playlist.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(f"add_track({track=}), {row=}")
self.insertRow(row)
item = QTableWidgetItem(str(track.id))
self.setItem(row, self.COL_INDEX, item)
item = QTableWidgetItem(str(track.start_gap))
self.setItem(row, self.COL_MSS, item)
titleitem = QTableWidgetItem(track.title)
self.setItem(row, self.COL_TITLE, titleitem)
item = QTableWidgetItem(track.artist)
self.setItem(row, self.COL_ARTIST, item)
item = QTableWidgetItem(helpers.ms_to_mmss(track.duration))
self.setItem(row, self.COL_DURATION, item)
item = QTableWidgetItem(track.path)
self.setItem(row, self.COL_PATH, item)
# Add empty start time for now as background
# colour won't be set for columns without items
item = QTableWidgetItem()
self.setItem(row, self.COL_PATH, item)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self._repaint(clear_selection=False)
def clear_current(self):
"Clear current track"
self._meta_clear_current()
self._repaint(save_playlist=False)
def clear_next(self):
"Clear next track"
self._meta_clear_next()
self._repaint(save_playlist=False)
def get_next_track_id(self):
"Return next track id"
next_row = self._meta_get_next()
return self._get_row_id(next_row)
def get_selected_rows_and_tracks(self):
"Return a list of selected (rows, track_id) tuples"
if not self.selectionModel().hasSelection():
return None
result = []
for row in [r.row() for r in self.selectionModel().selectedRows()]:
track_id = self._get_row_id(row)
result.append((row, track_id))
return result
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 lowest
# row first
for row in sorted(rows, reverse=True):
self.removeRow(row)
self._repaint(save_playlist=False)
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 play_started(self):
"""
Update current track to be what was next, and determine next track.
Return next track_id.
"""
self.current_track_start_time = datetime.now()
current_row = self._meta_get_next()
self._meta_set_current(current_row)
self.played_tracks.append(current_row)
# Scroll to put current track in centre
scroll_to = self.item(current_row, self.COL_INDEX)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtCenter)
next_track_id = self._mark_next_track()
self._repaint(save_playlist=False)
return next_track_id
def play_stopped(self):
self._meta_clear_current()
self.current_track_start_time = None
def populate(self):
# add them in row order. 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 = []
for t in self.db.tracks:
data.append(([t.row], t.tracks))
for n in self.db.notes:
data.append(([n.row], n))
# Clear playlist
self.setRowCount(0)
# Now add data in row order
for item in sorted(data, key=lambda x: x[0]):
self._add_to_playlist(item[1], repaint=False)
# Scroll to top
scroll_to = self.item(0, self.COL_INDEX)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop)
self._repaint()
def repaint(self):
# Called when we change tabs
self._repaint(save_playlist=False)
def set_selected_as_next(self):
"""
Sets the selected track as the next track.
"""
if not self.selectionModel().hasSelection():
return
return self._set_next(self.currentRow())
# ########## Internally called functions ##########
def _add_to_playlist(self, data, repaint=True):
"""
Add data to playlist. Data may be either a Tracks object or a
Notes object.
"""
DEBUG(f"_add_to_playlist({data=})")
if isinstance(data, Tracks):
self.add_track(data, repaint=repaint)
elif isinstance(data, Notes):
self.add_note(data, repaint=repaint)
def _calculate_next_start_time(self, row, start):
"Return this row's end time given its start time"
if start is None:
return None
if row is None:
DEBUG("_calculate_next_start_time() called with row=None")
return None
duration = Tracks.get_duration(self._get_row_id(row))
return start + timedelta(milliseconds=duration)
def _can_read_track(self, track):
"Check track file is readable"
return os.access(track.path, os.R_OK)
def _context_menu(self, pos):
self.menu.exec_(self.mapToGlobal(pos))
def _delete_row(self, row):
"Delete row"
DEBUG(f"playlist._delete_row({row})")
if row == self._meta_get_current():
# TODO
DEBUG("playlist._delete_row(): Can't delete playing track")
return
elif row == self._meta_get_next():
# TODO
DEBUG("playlist._delete_row(): Can't delete next track")
return
else:
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")
if msg.exec() == QMessageBox.Yes:
id = self._get_row_id(row)
if row in self._meta_get_notes():
Notes.delete_note(id)
else:
PlaylistTracks.remove_track(self.db.id, row)
self.removeRow(row)
self._repaint()
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())
def _get_row_id(self, row):
"Return item id as integer from passed row"
if row is None:
return
if self.item(row, self.COL_INDEX):
try:
return int(self.item(row, self.COL_INDEX).text())
except TypeError:
ERROR(
f"_get_row_id({row}): error retrieving row id "
f"({self.item(row, self.COL_INDEX).text()})"
)
else:
ERROR(f"(_get_row_id({row}): no COL_INDEX data in row")
return None
def _get_row_time(self, row):
try:
if self.item(row, self.COL_START_TIME):
return datetime.strptime(self.item(
row, self.COL_START_TIME).text(), "%H:%M:%S"
)
else:
return None
except ValueError:
return None
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 _mark_next_track(self):
"""
Find next track to play.
If not found, return None.
If found, mark row with metadata and return track_id.
"""
found_next_track = False
current_row = self._meta_get_current()
if current_row is not None:
start = current_row + 1
else:
start = 0
notes_rows = self._meta_get_notes()
for row in range(start, self.rowCount()):
if row in notes_rows:
continue
self._meta_set_next(row)
found_next_track = True
break
if not found_next_track:
return None
track_id = self._get_row_id(row)
return track_id
def _meta_clear(self, row):
"Clear metadata for row"
self._meta_set(row, None)
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(current_row)
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(next_row)
def _meta_find(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_get(self, row):
"Return row metadata"
return self.item(row, self.COL_INDEX).data(Qt.UserRole)
def _meta_get_current(self):
"Return row marked as current, or None"
return self._meta_find("current")
def _meta_get_next(self):
"Return row marked as next, or None"
return self._meta_find("next")
def _meta_get_notes(self):
"Return rows marked as notes, or None"
return self._meta_find("note", one=False)
def _meta_set_current(self, row):
"Mark row as current track"
old_current = self._meta_get_current()
if old_current is not None:
self._meta_clear(old_current)
self._meta_set(row, "current")
def _meta_set_next(self, row):
"Mark row as next track"
old_next = self._meta_get_next()
if old_next is not None:
self._meta_clear(old_next)
self._meta_set(row, "next")
def _meta_set_note(self, row):
"Mark row as note"
self._meta_set(row, "note")
def _meta_set(self, row, metadata):
"Set row metadata"
if self.item(row, self.COL_TITLE):
title = self.item(row, self.COL_TITLE).text()
else:
title = ""
DEBUG(
f"playlist[{self.db.id}:{self.db.name}]._meta_set(row={row}, "
f"title={title}, metadata={metadata})"
)
if row is None:
raise ValueError("_meta_set() with row=None")
self.item(row, self.COL_INDEX).setData(Qt.UserRole, metadata)
def _set_next(self, row):
"""
If passed row is track row, set that track as the next track to
be played and return track_id. Otherwise return None.
"""
DEBUG(f"_set_next({row})")
if row in self._meta_get_notes():
return None
if self.item(row, self.COL_INDEX):
self._meta_set_next(row)
self._repaint(save_playlist=False)
return self._get_row_id(row)
else:
return None
def _repaint(self, clear_selection=True, save_playlist=True):
"Set row colours, fonts, etc, and save playlist"
DEBUG(
f"playlist[{self.db.id}:{self.db.name}]."
f"_repaint({clear_selection=}, {save_playlist=})"
)
if clear_selection:
self.clearSelection()
if save_playlist:
self._save_playlist()
current = self._meta_get_current()
next = self._meta_get_next()
notes = self._meta_get_notes()
# Set colours and start times
next_start_time = None
# Cycle through all rows
for row in range(self.rowCount()):
# We can't calculate start times until next_start_time is
# set. That can be set by either a note with a time, or the
# current track.
if row in notes:
row_time = self._get_row_time(row)
if row_time:
next_start_time = row_time
# Set colour
self._set_row_colour(
row, QColor(Config.COLOUR_NOTES_PLAYLIST)
)
self._set_row_bold(row)
elif row == current:
# Set start time
self._set_row_time(row, self.current_track_start_time)
# Calculate next_start_time
next_start_time = self._calculate_next_start_time(
row, self.current_track_start_time)
# Set colour
self._set_row_colour(row, QColor(
Config.COLOUR_CURRENT_PLAYLIST))
# Make bold
self._set_row_bold(row)
elif row == next:
# if there's a current track playing, set start time from that
if self.current_track_start_time:
start_time = self._calculate_next_start_time(
current, 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_time(row, start_time)
next_start_time = self._calculate_next_start_time(
row, start_time)
# Set colour
self._set_row_colour(row, QColor(Config.COLOUR_NEXT_PLAYLIST))
# Make bold
self._set_row_bold(row)
else:
# Stripe remaining rows
if row % 2:
colour = QColor(Config.COLOUR_ODD_PLAYLIST)
else:
colour = QColor(Config.COLOUR_EVEN_PLAYLIST)
self._set_row_colour(row, colour)
if self._get_row_id(row) in self.played_tracks:
self._set_row_not_bold(row)
else:
# Set time only if we haven't played it yet
if next_start_time:
self._set_row_time(row, next_start_time)
next_start_time = self._calculate_next_start_time(
row, next_start_time)
# Don't dim unplayed tracks
self._set_row_bold(row)
def _save_playlist(self):
"""
Save playlist to database. We do this by correcting differences
between the on-screen (definitive) playlist and that in the
database.
We treat the notes rows and the tracks rows differently. Notes must
appear only once in only one playlist. Tracks can appear multiple
times in one playlist and in multiple playlists.
"""
# Notes first
# Create dictionaries indexed by note_id
playlist_notes = {}
database_notes = {}
notes_rows = self._meta_get_notes()
# Playlist
for row in notes_rows:
note_id = self._get_row_id(row)
if not note_id:
DEBUG(f"(_save_playlist(): no COL_INDEX data in row {row}")
continue
playlist_notes[note_id] = row
# Database
for note in self.db.notes:
database_notes[note.id] = note.row
# Notes to add to database
# This should never be needed as notes are added to a specific
# playlist upon creation
for note_id in set(playlist_notes.keys()) - set(database_notes.keys()):
ERROR(
f"_save_playlist(): Note.id={note_id} "
f"missing from playlist {self} in database"
)
# Notes to remove from database
for note_id in set(database_notes.keys()) - set(playlist_notes.keys()):
DEBUG(
f"_save_playlist(): Delete note note_id={note_id} "
f"from playlist {self} in database"
)
Notes.delete_note(note_id)
# Note rows to update in playlist database
for note_id in set(playlist_notes.keys()) & set(database_notes.keys()):
if playlist_notes[note_id] != database_notes[note_id]:
DEBUG(
f"_save_playlist(): Update database note.id {note_id} "
f"from row={database_notes[note_id]} to "
f"row={playlist_notes[note_id]}"
)
Notes.update_note(note_id, playlist_notes[note_id])
# Now check tracks
# Create dictionaries indexed by row
playlist_tracks = {}
database_tracks = {}
# Playlist
for row in range(self.rowCount()):
if row in notes_rows:
continue
playlist_tracks[row] = self._get_row_id(row)
# Database
for track in self.db.tracks:
database_tracks[track.row] = track.track_id
# Tracks rows to add to database
for row in (
set(set(playlist_tracks.keys()) - set(database_tracks.keys()))
):
DEBUG(f"_save_playlist(): row {row} missing from database")
PlaylistTracks.add_track(self.db.id, playlist_tracks[row], row)
# Track rows to remove from database
for row in (
set(database_tracks.keys()) - set(playlist_tracks.keys())
):
track = database_tracks[row]
DEBUG(
f"_save_playlist(): row {row} in database not playlist "
f"(track={track})"
)
PlaylistTracks.remove_track(self.db.id, row)
# Track rows to update in database
for row in (
set(playlist_tracks.keys()) & set(database_tracks.keys())
):
if playlist_tracks[row] != database_tracks[row]:
DEBUG(
"_save_playlist(): Update row={row} in database for "
f"playlist {self} from track={database_tracks[row]} "
f"to track={playlist_tracks[row]}"
)
PlaylistTracks.update_row_track(
self.db.id, row, playlist_tracks[row])
def _set_column_widths(self):
# Column widths from settings
for column in range(self.columnCount()):
# Only show column 0 in test mode
if (column == 0 and not Config.TESTMODE):
self.setColumnWidth(0, 0)
else:
name = f"playlist_col_{str(column)}_width"
record = Settings.get_int(name)
if record.f_int is not None:
print("setting column width")
self.setColumnWidth(column, record.f_int)
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(self.columnCount()):
if self.item(row, j):
self.item(row, j).setBackground(colour)
def _set_row_not_bold(self, row):
self._set_row_bold(row, False)
def _set_row_time(self, row, time):
try:
time_str = time.strftime("%H:%M:%S")
except AttributeError:
time_str = ""
item = QTableWidgetItem(time_str)
self.setItem(row, self.COL_START_TIME, item)