musicmuster/app/playlists.py
2021-08-24 15:13:03 +01:00

1289 lines
43 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,
QApplication,
QMenu,
QMessageBox,
QTableWidget,
QTableWidgetItem,
)
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 model import (
Notes, Playdates, Playlists, PlaylistTracks, Session, Settings, Tracks
)
from songdb import create_track_from_file, update_meta
class PlaylistTab(QTableWidget):
cellEditingStarted = QtCore.pyqtSignal(int, int)
cellEditingEnded = QtCore.pyqtSignal()
# 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_END_TIME = 6
COL_LAST_PLAYED = 7
NOTE_COL_SPAN = 4
NOTE_ROW_SPAN = 1
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.master_process = self.parent()
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)
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(["ID", "Lead", "Title", "Artist",
"Len", "Start", "End", "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 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.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)
self.current_track_start_time = None
self.played_tracks = []
# ########## Events ##########
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}"
)
with Session() as session:
self._save_playlist(session)
self._repaint()
def edit(self, index, trigger, event):
result = super(PlaylistTab, self).edit(index, trigger, event)
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"
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 == 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:
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:
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 close(self, session):
"Save column widths"
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})
def insert_note(self, session, note, repaint=True):
"""
Add note to playlist
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}")
# Does note end with a time?
start_time = None
try:
start_time = datetime.strptime(note.note[-9:], " %H:%M:%S").time()
DEBUG(
f"playlist.inset_note(): Note contains valid time={start_time}"
)
except ValueError:
DEBUG(
f"playlist.inset_note(): 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/end times or empty items as background
# colour won't be set for columns without items
self._set_row_start_time(row, start_time)
item = QTableWidgetItem()
self.setItem(row, self.COL_END_TIME, item)
item = QTableWidgetItem()
self.setItem(row, self.COL_LAST_PLAYED, item)
self._meta_set_note(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self._save_playlist(session)
self._repaint(clear_selection=False)
return row
def insert_track(self, session, track, repaint=True):
"""
Insert track into on-screen playlist.
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)
item = QTableWidgetItem(str(track.id))
self.setItem(row, self.COL_INDEX, item)
item = QTableWidgetItem(str(track.start_gap))
if track.start_gap >= 500:
item.setBackground(QColor(Config.COLOUR_LONG_START))
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)
last_playtime = Playdates.last_played(session, track.id)
last_played_str = get_relative_date(last_playtime)
item = QTableWidgetItem(last_played_str)
self.setItem(row, self.COL_LAST_PLAYED, 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_START_TIME, item)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if not self._track_path_is_readable(track.id):
self._meta_set_unreadable(row)
if repaint:
self._save_playlist(session)
self._repaint(clear_selection=False)
return row
def clear_current(self):
"Clear current track"
self._meta_clear_current()
self._repaint()
def clear_next(self):
"Clear next track"
self._meta_clear_next()
self._repaint()
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_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_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 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 lowest
# row first
for row in sorted(rows, reverse=True):
self.removeRow(row)
self._repaint()
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(self._get_row_id(current_row))
# Scroll to put current track in centre
scroll_to = self.item(current_row, self.COL_INDEX)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtCenter)
# Get next track
next_track_row = self._find_next_track_row()
next_track_id = self._set_next(next_track_row)
self._repaint()
return next_track_id
def play_stopped(self):
self._meta_clear_current()
self.current_track_start_time = None
self._repaint()
def populate(self, session, playlist_db):
"""
Populate ourself from the passed playlist_db 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.
# First, save our id for the future
self.id = playlist_db.id
self.name = playlist_db.name
data = []
for t in playlist_db.tracks:
data.append(([t.row], t.tracks))
for n in playlist_db.notes:
data.append(([n.row], n))
# 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_INDEX)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop)
self._repaint()
def repaint(self):
# Called when we change tabs
self._repaint()
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"
# Need to allow multiple rows to be selected
self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
self.clearSelection()
for row in range(self.rowCount()):
if self._get_row_id(row) in self.played_tracks:
self.selectRow(row)
# Reset extended selection
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
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"
# Need to allow multiple rows to be selected
self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
notes_rows = self._meta_get_notes()
self.clearSelection()
for row in range(self.rowCount()):
if row in notes_rows:
continue
if self._get_row_id(row) in self.played_tracks:
continue
self.selectRow(row)
# Reset extended selection
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
def set_selected_as_next(self):
"""
Sets the selected track as the next track.
"""
if not self.selectionModel().hasSelection():
return
self._set_next(self.currentRow())
self._repaint()
# ########## 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_id = self._get_row_id(row)
if track_id:
with Session() as session:
track = Tracks.get_track(session, track_id)
open_in_audacity(track.path)
def _calculate_next_start_time(self, session, 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(session, 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 _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_id = self._get_row_id(row)
if track_id:
with Session() as session:
path = Tracks.get_path(session, track_id)
cb = QApplication.clipboard()
cb.clear(mode=cb.Clipboard)
cb.setText(path, mode=cb.Clipboard)
def _cell_changed(self, row, column):
"Called when cell content has changed"
if not self.editing_cell:
return
# If we update start time, _cell_changed will be called
if column not in [self.COL_TITLE, self.COL_ARTIST]:
return
new = self.item(row, column).text()
DEBUG(f"_cell_changed({row=}, {column=}, {new=}")
row_id = self._get_row_id(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=}'",
True
)
Notes.update_note(session, row_id, row, new)
# Set/clear row start time accordingly
try:
start_dt = datetime.strptime(new[-9:], " %H:%M:%S")
start_time = start_dt.time()
self._set_row_start_time(row, start_time)
DEBUG(
f"_cell_changed:Note {new} contains valid "
f"time={start_time}"
)
except ValueError:
# Reset row start time in case it used to have one
self._set_row_start_time(row, None)
DEBUG(
f"_cell_changed:Note {new} does not contain "
"start time"
)
else:
track = Tracks.get_track(session, row_id)
if column == self.COL_ARTIST:
update_meta(session, track, artist=new)
elif column == self.COL_TITLE:
update_meta(session, track, title=new)
else:
ERROR("_cell_changed(): unrecognised column")
def _cell_edit_started(self, row, column):
DEBUG(f"_cell_edit_started({row=}, {column=})")
self.editing_cell = True
self.master_process.disable_play_next_controls()
def _cell_edit_ended(self):
DEBUG("_cell_edit_ended()")
self.editing_cell = False
# Call repaint to update start times, such as when a note has
# been edited
self._repaint()
self.master_process.enable_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 del_row in sorted(rows_to_delete, reverse=True):
id = self._get_row_id(del_row)
if del_row in notes:
Notes.delete_note(session, id)
else:
PlaylistTracks.remove_track(session, self.id, del_row)
self.removeRow(del_row)
self._save_playlist(session)
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 _info_row(self, row):
"Display popup with info re row"
id = self._get_row_id(row)
if row in self._meta_get_notes():
note_text = self.item(row, self.COL_TITLE).text()
txt = f"Note: {note_text}"
else:
with Session() as session:
track = Tracks.get_track(session, id)
if not track:
txt = f"Track not found (track.id={id})"
else:
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 _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):
"""
Find next track to play.
If not found, return None.
If found, return row number.
"""
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
found_next_track = True
break
if found_next_track:
return row
else:
return None
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_get_unreadable(self):
"Return rows marked as unreadable, or None"
return self._meta_find("unreadable", 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_unreadable(self, row):
"Mark row as unreadable"
self._meta_set(row, "unreadable")
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.id}:{self.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, check track is readable and, if it is,
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
track_id = self._get_row_id(row)
if track_id:
if self._track_path_is_readable(track_id):
self._meta_set_next(row)
self.master_process.set_next_track(track_id)
else:
self._meta_set_unreadable(row)
track_id = None
return track_id
def _repaint(self, clear_selection=True):
"Set row colours, fonts, etc"
DEBUG(
f"playlist[{self.id}:{self.name}]."
f"_repaint(clear_selection={clear_selection}"
)
with Session() as session:
if clear_selection:
self.clearSelection()
current = self._meta_get_current()
next = self._meta_get_next()
notes = self._meta_get_notes()
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. If
# neither, populate start times from first note with a start
# time.
if current and next:
start_times_row = min(current, next)
else:
start_times_row = current or next
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. 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 in unreadable:
# Set colour
self._set_row_colour(
row, QColor(Config.COLOUR_UNREADABLE)
)
self._set_row_bold(row)
elif row == current:
# Set start time
self._set_row_start_time(
row, self.current_track_start_time)
# Calculate next_start_time
next_start_time = self._calculate_next_start_time(
session, row, 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:
# if there's a track playing, set start time from that
if self.current_track_start_time:
start_time = self._calculate_next_start_time(
session, 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_start_time(row, start_time)
next_start_time = self._calculate_next_start_time(
session, row, 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:
# 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)
track_id = self._get_row_id(row)
if track_id in self.played_tracks:
# 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 only if 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_next_start_time(
session, row, 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)
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_id = self._get_row_id(row)
if track_id:
with Session() as session:
track = Tracks.get_track(session, track_id)
create_track_from_file(session, track.path)
self._update_row(row, track)
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 there is only one notes
appearance in all playlists.
For tracks: erase the playlist tracks and recreate. This is much
simpler than trying to correct any Playlists:Tracks many:many
errors.
"""
# We need to add ourself to the session
playlist_db = session.query(Playlists).filter(
Playlists.id == self.id).one()
# 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_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 playlist_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 {playlist_db} 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 {playlist_db} in database"
)
Notes.delete_note(session, 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(session, note_id, playlist_notes[note_id])
# Tracks
# Remove all tracks for us in datbase
PlaylistTracks.remove_all_tracks(session, self.id)
# Iterate on-screen playlist and add tracks back in
for row in range(self.rowCount()):
if row in notes_rows:
continue
PlaylistTracks.add_track(
session, self.id, self._get_row_id(row), row)
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()])
notes = self._meta_get_notes()
ms = 0
with Session() as session:
for row in rows:
if row in notes:
continue
ms += Tracks.get_duration(session, self._get_row_id(row))
# Only paint message if there are selected track rows
if ms > 0:
self.master_process.lblSumPlaytime.setText(
f"Selected duration: {helpers.ms_to_mmss(ms)}")
else:
self.master_process.lblSumPlaytime.setText("")
def _set_column_widths(self):
# Column widths from settings
with Session() as session:
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(session, name)
if record.f_int is not None:
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(2, 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_end_time(self, row, time):
"Set passed row end time to passed time"
try:
time_str = time.strftime("%H:%M:%S")
except AttributeError:
time_str = ""
item = QTableWidgetItem(time_str)
self.setItem(row, self.COL_END_TIME, item)
def _set_row_start_time(self, row, time):
"Set passed row start time to passed 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)
def _track_path_is_readable(self, track_id):
"Returns True if track path is readable, else False"
with Session() as session:
return os.access(Tracks.get_path(session, track_id), os.R_OK)
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._repaint()