Bug in Qt means automatically setting row height doesn't take into account row spans, so putting headers in narrow column makes for tall rows.
1874 lines
64 KiB
Python
1874 lines
64 KiB
Python
import re
|
|
import subprocess
|
|
import threading
|
|
|
|
from collections import namedtuple
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional
|
|
|
|
from PyQt5.QtCore import (
|
|
pyqtSignal,
|
|
QEvent,
|
|
QModelIndex,
|
|
QObject,
|
|
QSize,
|
|
Qt,
|
|
)
|
|
from PyQt5.QtGui import (
|
|
QBrush,
|
|
QColor,
|
|
QFont,
|
|
QDropEvent,
|
|
)
|
|
from PyQt5.QtWidgets import (
|
|
QAbstractItemDelegate,
|
|
QAbstractItemView,
|
|
QApplication,
|
|
QLineEdit,
|
|
QMainWindow,
|
|
QMenu,
|
|
QMessageBox,
|
|
QPlainTextEdit,
|
|
QStyledItemDelegate,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QTextEdit,
|
|
QWidget
|
|
)
|
|
|
|
from config import Config
|
|
from dbconfig import Session
|
|
from helpers import (
|
|
ask_yes_no,
|
|
file_is_readable,
|
|
get_relative_date,
|
|
ms_to_mmss,
|
|
open_in_audacity
|
|
)
|
|
from log import log
|
|
from models import (
|
|
Playdates,
|
|
Playlists,
|
|
PlaylistRows,
|
|
Settings,
|
|
Tracks,
|
|
NoteColours
|
|
)
|
|
|
|
start_time_re = re.compile(r"@\d\d:\d\d:\d\d")
|
|
HEADER_NOTES_COLUMN = 2
|
|
|
|
|
|
class RowMeta:
|
|
CLEAR = 0
|
|
NOTE = 1
|
|
UNREADABLE = 2
|
|
NEXT = 3
|
|
CURRENT = 4
|
|
|
|
|
|
# Columns
|
|
Column = namedtuple("Column", ['idx', 'heading'])
|
|
columns = {}
|
|
columns["userdata"] = Column(idx=0, heading=Config.COLUMN_NAME_AUTOPLAY)
|
|
columns["start_gap"] = Column(
|
|
idx=1, heading=Config.COLUMN_NAME_LEADING_SILENCE)
|
|
columns["title"] = Column(idx=2, heading=Config.COLUMN_NAME_TITLE)
|
|
columns["artist"] = Column(idx=3, heading=Config.COLUMN_NAME_ARTIST)
|
|
columns["duration"] = Column(idx=4, heading=Config.COLUMN_NAME_LENGTH)
|
|
columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME)
|
|
columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME)
|
|
columns["lastplayed"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED)
|
|
columns["bitrate"] = Column(idx=8, heading=Config.COLUMN_NAME_BITRATE)
|
|
columns["row_notes"] = Column(idx=9, heading=Config.COLUMN_NAME_NOTES)
|
|
|
|
|
|
class NoSelectDelegate(QStyledItemDelegate):
|
|
"""
|
|
This originally used the following link to not select text on edit;
|
|
however, using a QPlainTextBox means a) text isn't selected anyway and
|
|
b) it provides a multiline edit.
|
|
|
|
https://stackoverflow.com/questions/72790705/
|
|
dont-select-text-in-qtablewidget-cell-when-editing/72792962#72792962
|
|
"""
|
|
|
|
def createEditor(self, parent, option, index):
|
|
if isinstance(index.data(), str):
|
|
return QPlainTextEdit(parent)
|
|
return super().createEditor(parent, option, index)
|
|
|
|
def eventFilter(self, editor: QObject, event: QEvent):
|
|
"""By default, QPlainTextEdit doesn't handle enter or return"""
|
|
|
|
if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Return:
|
|
if (Qt.ShiftModifier & event.modifiers()) != Qt.ShiftModifier:
|
|
self.commitData.emit(editor)
|
|
self.closeEditor.emit(editor)
|
|
|
|
return super().eventFilter(editor, event)
|
|
|
|
|
|
class PlaylistTab(QTableWidget):
|
|
# Qt.UserRoles
|
|
ROW_FLAGS = Qt.UserRole
|
|
ROW_TRACK_ID = Qt.UserRole + 1
|
|
ROW_DURATION = Qt.UserRole + 2
|
|
PLAYLISTROW_ID = Qt.UserRole + 3
|
|
|
|
def __init__(self, musicmuster: QMainWindow, session: Session,
|
|
playlist_id: int, *args, **kwargs) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.musicmuster = musicmuster
|
|
self.playlist_id = playlist_id
|
|
|
|
self.menu: Optional[QMenu] = None
|
|
self.current_track_start_time: Optional[datetime] = None
|
|
|
|
# Don't select text on edit
|
|
self.setItemDelegate(NoSelectDelegate(self))
|
|
|
|
# Set up widget
|
|
self.setEditTriggers(QAbstractItemView.DoubleClicked)
|
|
self.setAlternatingRowColors(True)
|
|
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
self.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
|
|
self.setRowCount(0)
|
|
self.setColumnCount(len(columns))
|
|
|
|
# Header row
|
|
for idx in [a for a in range(len(columns))]:
|
|
item: QTableWidgetItem = QTableWidgetItem()
|
|
self.setHorizontalHeaderItem(idx, item)
|
|
self.horizontalHeader().setMinimumSectionSize(0)
|
|
self._set_column_widths(session)
|
|
# Set column headings sorted by idx
|
|
self.setHorizontalHeaderLabels(
|
|
[a.heading for a in list(sorted(columns.values(),
|
|
key=lambda item: item.idx))]
|
|
)
|
|
|
|
self.setAcceptDrops(True)
|
|
self.viewport().setAcceptDrops(True)
|
|
self.setDragDropOverwriteMode(False)
|
|
self.setDropIndicatorShown(True)
|
|
self.setDragDropMode(QAbstractItemView.InternalMove)
|
|
self.setDragEnabled(False)
|
|
|
|
# This property defines how the widget shows a context menu
|
|
self.setContextMenuPolicy(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.search_text: str = ""
|
|
self.edit_cell_type = None
|
|
self.selecting_in_progress = False
|
|
# Connect signals
|
|
self.horizontalHeader().sectionResized.connect(self._column_resize)
|
|
# self.horizontalHeader().sectionClicked.connect(self._header_click)
|
|
# self.setSortingEnabled(True)
|
|
|
|
# Now load our tracks and notes
|
|
self.populate(session, self.playlist_id)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<PlaylistTab(id={self.playlist_id}>"
|
|
|
|
# ########## Events other than cell editing ##########
|
|
|
|
def closeEvent(self, event) -> None:
|
|
"""Handle closing playist tab"""
|
|
|
|
with Session() as session:
|
|
# Record playlist as closed
|
|
playlist = session.get(Playlists, self.playlist_id)
|
|
playlist.close(session)
|
|
|
|
event.accept()
|
|
|
|
def dropEvent(self, event: QDropEvent) -> None:
|
|
"""
|
|
Handle drag/drop of rows
|
|
|
|
https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
|
|
"""
|
|
|
|
if not event.source() == self:
|
|
return # We don't accept external drops
|
|
|
|
drop_row: int = self._drop_on(event)
|
|
|
|
rows: List = 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:
|
|
for row in range(drop_row, drop_row + len(rows_to_move)):
|
|
if not self._get_row_track_id(row):
|
|
self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns))
|
|
|
|
# Scroll to drop zone
|
|
self.scrollToItem(self.item(row, 1))
|
|
|
|
# Reset drag mode to allow row selection by dragging
|
|
self.setDragEnabled(False)
|
|
|
|
super().dropEvent(event)
|
|
|
|
log.debug(
|
|
"playlist.dropEvent(): "
|
|
f"Moved row(s) {rows} to become row {drop_row}"
|
|
)
|
|
|
|
with Session() as session: # checked
|
|
self.save_playlist(session)
|
|
self.update_display(session)
|
|
|
|
def eventFilter(self, source, event):
|
|
"""Used to process context (right-click) menu, which is defined here"""
|
|
|
|
if (event.type() == QEvent.MouseButtonPress and # noqa W504
|
|
event.buttons() == Qt.RightButton and # noqa W504
|
|
source is self.viewport()):
|
|
self.menu = QMenu(self)
|
|
item = self.itemAt(event.pos())
|
|
if item is not None:
|
|
row_number = item.row()
|
|
track_id = self._get_row_track_id(row_number)
|
|
track_row = track_id > 0
|
|
header_row = not track_row
|
|
if track_row:
|
|
current = row_number == self._get_current_track_row()
|
|
next_row = row_number == self._get_next_track_row()
|
|
else:
|
|
current = next_row = False
|
|
|
|
if track_row:
|
|
# Info
|
|
act_info = self.menu.addAction('Info')
|
|
act_info.triggered.connect(
|
|
lambda: self._info_row(track_id)
|
|
)
|
|
act_copypath = self.menu.addAction("Copy track path")
|
|
act_copypath.triggered.connect(
|
|
lambda: self._copy_path(row_number))
|
|
|
|
self.menu.addSeparator()
|
|
|
|
# Play with mplayer
|
|
act_mplayer = self.menu.addAction(
|
|
"Play with mplayer")
|
|
act_mplayer.triggered.connect(
|
|
lambda: self._mplayer_play(track_id))
|
|
|
|
# Set next
|
|
if not current and not next_row:
|
|
act_setnext = self.menu.addAction("Set next")
|
|
with Session() as session:
|
|
act_setnext.triggered.connect(
|
|
lambda: self._set_next(session, row_number))
|
|
|
|
# Open in Audacity
|
|
if not current:
|
|
act_audacity = self.menu.addAction(
|
|
"Open in Audacity")
|
|
act_audacity.triggered.connect(
|
|
lambda: self._open_in_audacity(track_id))
|
|
|
|
# Rescan
|
|
act_rescan = self.menu.addAction("Rescan")
|
|
act_rescan.triggered.connect(
|
|
lambda: self._rescan(row_number, track_id)
|
|
)
|
|
|
|
self.menu.addSeparator()
|
|
|
|
# Look up in wikipedia
|
|
act_wikip = self.menu.addAction("Wikipedia")
|
|
act_wikip.triggered.connect(
|
|
lambda: self._wikipedia(row_number)
|
|
)
|
|
|
|
# Look up in songfacts
|
|
act_songfacts = self.menu.addAction("Songfacts")
|
|
act_songfacts.triggered.connect(
|
|
lambda: self._songfacts(row_number)
|
|
)
|
|
|
|
self.menu.addSeparator()
|
|
|
|
# Remove track
|
|
act_remove_track = self.menu.addAction('Remove track')
|
|
act_remove_track.triggered.connect(
|
|
lambda: self._remove_track(row_number)
|
|
)
|
|
if header_row:
|
|
# Add track to section header (ie, make this a track
|
|
# row)
|
|
act_add_track = self.menu.addAction('Add track')
|
|
act_add_track.triggered.connect(
|
|
lambda: self._add_track(row_number))
|
|
|
|
if not current and not next_row:
|
|
# Remove row
|
|
act_delete = self.menu.addAction('Remove row')
|
|
act_delete.triggered.connect(self._delete_rows)
|
|
|
|
self.menu.addSeparator()
|
|
|
|
if not current and not next_row:
|
|
act_move = self.menu.addAction('Move to playlist...')
|
|
act_move.triggered.connect(self.musicmuster.move_selected)
|
|
self.menu.addSeparator()
|
|
|
|
return super(PlaylistTab, self).eventFilter(source, event)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
"""
|
|
Enable dragging if rows are selected
|
|
"""
|
|
|
|
if self.selectedItems():
|
|
self.setDragEnabled(True)
|
|
else:
|
|
self.setDragEnabled(False)
|
|
super().mouseReleaseEvent(event)
|
|
|
|
# ########## Cell editing ##########
|
|
#
|
|
# We only want to allow cell editing on tracks, artists and notes,
|
|
# although notes may be section headers.
|
|
#
|
|
# Once editing starts, we need to disable play controls so that a
|
|
# 'return' doesn't play the next track.
|
|
#
|
|
# Earlier in this file:
|
|
# - self.setEditTriggers(QAbstractItemView.DoubleClicked) - triggers
|
|
# editing on double-click
|
|
# - self.setItemDelegate(NoSelectDelegate(self)) and associated class
|
|
# ensure that the text is not selected when editing starts
|
|
#
|
|
# Call sequences:
|
|
# Start editing:
|
|
# edit()
|
|
# _cell_edit_started()
|
|
# End editing:
|
|
# _cell_changed() (only if changes made)
|
|
# closeEditor()
|
|
# _cell_edit_ended()
|
|
|
|
def _cell_changed(self, row: int, column: int) -> None:
|
|
"""Called when cell content has changed"""
|
|
|
|
# Disable cell changed signal connection as note updates will
|
|
# change cell again (metadata)
|
|
self.cellChanged.disconnect(self._cell_changed)
|
|
|
|
new_text = self.item(row, column).text()
|
|
track_id = self._get_row_track_id(row)
|
|
|
|
# Determin cell type changed
|
|
with Session() as session:
|
|
if self.edit_cell_type == "row_notes":
|
|
# Get playlistrow object
|
|
plr_id = self._get_playlistrow_id(row)
|
|
plr_item = session.get(PlaylistRows, plr_id)
|
|
plr_item.note = 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)
|
|
else:
|
|
self._set_row_start_time(row, None)
|
|
else:
|
|
track = None
|
|
if track_id:
|
|
track = session.get(Tracks, track_id)
|
|
if track:
|
|
if self.edit_cell_type == "title":
|
|
track.title = new_text
|
|
elif self.edit_cell_type == "artist":
|
|
track.artist = new_text
|
|
# Headers will be incorrect if the edited track is
|
|
# previous / current / next TODO: this will require
|
|
# the stored data in musicmuster to be updated,
|
|
# which currently it isn't).
|
|
self.musicmuster.update_headers()
|
|
|
|
def closeEditor(self,
|
|
editor: QWidget,
|
|
hint: QAbstractItemDelegate.EndEditHint) -> None:
|
|
"""
|
|
Override PySide2.QAbstractItemView.closeEditor to enable
|
|
play controls and update display.
|
|
"""
|
|
|
|
# update_display to update start times, such as when a note has
|
|
# been edited
|
|
with Session() as session:
|
|
self.update_display(session)
|
|
|
|
self.edit_cell_type = None
|
|
self.musicmuster.enable_play_next_controls()
|
|
|
|
super(PlaylistTab, self).closeEditor(editor, hint)
|
|
|
|
def edit(self, index: QModelIndex,
|
|
trigger: QAbstractItemView.EditTrigger,
|
|
event: QEvent) -> bool:
|
|
"""
|
|
Override PySide2.QAbstractItemView.edit to catch when editing starts
|
|
"""
|
|
|
|
result = super(PlaylistTab, self).edit(index, trigger, event)
|
|
if result: # will only be true on double-clicke
|
|
row = index.row()
|
|
column = index.column()
|
|
|
|
# Is this a track row?
|
|
track_row = self._get_row_track_id(row)
|
|
|
|
note_column = 0
|
|
if track_row:
|
|
# If a track row, we only allow editing of title, artist and
|
|
# note. Check that this column is one of those.
|
|
self.edit_cell_type = None
|
|
if column == columns['title'].idx:
|
|
self.edit_cell_type = "title"
|
|
elif column == columns['artist'].idx:
|
|
self.edit_cell_type = "artist"
|
|
elif column == columns['row_notes'].idx:
|
|
self.edit_cell_type = "row_notes"
|
|
else:
|
|
# Can't edit other columns
|
|
return False
|
|
|
|
# Check whether we're editing a notes row for later
|
|
if self.edit_cell_type == "row_notes":
|
|
note_column = columns['row_notes'].idx
|
|
else:
|
|
# This is a section header.
|
|
if column != HEADER_NOTES_COLUMN:
|
|
return False
|
|
note_column = HEADER_NOTES_COLUMN
|
|
self.edit_cell_type = "row_notes"
|
|
|
|
# Disable play controls so that keyboard input doesn't
|
|
# disturb playing
|
|
self.musicmuster.disable_play_next_controls()
|
|
|
|
# If this is a note cell, we need to remove any existing section
|
|
# timing so user can't edit that. Keep it simple: refresh text
|
|
# from database. Note column will only be non-zero if we are
|
|
# editing a note.
|
|
if note_column:
|
|
with Session() as session:
|
|
plr_id = self._get_playlistrow_id(row)
|
|
plr_item = session.get(PlaylistRows, plr_id)
|
|
item = self.item(row, note_column)
|
|
item.setText(plr_item.note)
|
|
|
|
# Connect signal so we know when cell has changed.
|
|
self.cellChanged.connect(self._cell_changed)
|
|
|
|
return result
|
|
|
|
# # ########## Externally called functions ##########
|
|
|
|
def clear_next(self, session) -> None:
|
|
"""Clear next track marker"""
|
|
|
|
self._meta_clear_next()
|
|
self.update_display(session)
|
|
|
|
def clear_selection(self) -> None:
|
|
"""Unselect all tracks and reset drag mode"""
|
|
|
|
self.clearSelection()
|
|
self.setDragEnabled(False)
|
|
|
|
def get_selected_playlistrow_ids(self) -> Optional[List]:
|
|
"""
|
|
Return a list of PlaylistRow ids of the selected rows
|
|
"""
|
|
|
|
return [self._get_playlistrow_id(a) for a in self._get_selected_rows()]
|
|
|
|
def get_selected_playlistrows(self, session: Session) -> Optional[List]:
|
|
"""
|
|
Return a list of PlaylistRows of the selected rows
|
|
"""
|
|
|
|
plr_ids = self.get_selected_playlistrow_ids()
|
|
return [session.get(PlaylistRows, a) for a in plr_ids]
|
|
|
|
def insert_header(self, session: Session, note: str,
|
|
repaint: bool = True) -> None:
|
|
"""
|
|
Insert section header into playlist tab.
|
|
|
|
If a row is selected, add header above. Otherwise, add to end of
|
|
playlist.
|
|
|
|
We simply build a PlaylistRows object and pass it to insert_row()
|
|
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
|
|
plr = PlaylistRows(session, self.playlist_id, None, row_number)
|
|
plr.note = note
|
|
self.insert_row(session, plr)
|
|
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
|
|
if repaint:
|
|
self.update_display(session)
|
|
|
|
def insert_row(self, session: Session, row_data: 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.
|
|
"""
|
|
|
|
if self.selectionModel().hasSelection():
|
|
row = self.currentRow()
|
|
else:
|
|
row = self.rowCount()
|
|
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)
|
|
self.setItem(row, columns['userdata'].idx, userdata_item)
|
|
|
|
if row_data.track_id:
|
|
# Add track details to items
|
|
try:
|
|
start_gap = row_data.track.start_gap
|
|
except:
|
|
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, columns['start_gap'].idx, start_gap_item)
|
|
|
|
title_item = QTableWidgetItem(row_data.track.title)
|
|
self.setItem(row, columns['title'].idx, title_item)
|
|
|
|
artist_item = QTableWidgetItem(row_data.track.artist)
|
|
self.setItem(row, columns['artist'].idx, artist_item)
|
|
|
|
duration_item = QTableWidgetItem(
|
|
ms_to_mmss(row_data.track.duration))
|
|
self.setItem(row, columns['duration'].idx, duration_item)
|
|
self._set_row_duration(row, row_data.track.duration)
|
|
|
|
start_item = QTableWidgetItem()
|
|
self.setItem(row, columns['start_time'].idx, start_item)
|
|
|
|
end_item = QTableWidgetItem()
|
|
self.setItem(row, columns['end_time'].idx, end_item)
|
|
|
|
if row_data.track.bitrate:
|
|
bitrate = str(row_data.track.bitrate)
|
|
else:
|
|
bitrate = ""
|
|
bitrate_item = QTableWidgetItem(bitrate)
|
|
self.setItem(row, columns['bitrate'].idx, bitrate_item)
|
|
|
|
# As we have track info, any notes should be contained in
|
|
# the notes column
|
|
notes_item = QTableWidgetItem(row_data.note)
|
|
self.setItem(row, columns['row_notes'].idx, notes_item)
|
|
|
|
last_playtime = Playdates.last_played(session, row_data.track.id)
|
|
last_played_str = get_relative_date(last_playtime)
|
|
last_played_item = QTableWidgetItem(last_played_str)
|
|
self.setItem(row, columns['lastplayed'].idx, last_played_item)
|
|
|
|
# Mark track if file is unreadable
|
|
if not file_is_readable(row_data.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:
|
|
log.debug(
|
|
f"insert_row({row_data=}) with no track_id and no note"
|
|
)
|
|
return
|
|
|
|
# Make empty items (row background won't be coloured without
|
|
# items present). Any notes should displayed starting in
|
|
# column 2 for now - bug in Qt means that when row size is
|
|
# set, spanned columns are ignored, so put notes in col2
|
|
# (typically title).
|
|
for i in range(1, len(columns)):
|
|
if i == 2:
|
|
continue
|
|
self.setItem(row, i, QTableWidgetItem())
|
|
self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1)
|
|
notes_item = QTableWidgetItem(row_data.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: Tracks,
|
|
repaint: bool = True) -> None:
|
|
"""
|
|
Insert track into playlist tab.
|
|
|
|
If a row is selected, add track above. Otherwise, add to end of
|
|
playlist.
|
|
|
|
We simply build a PlaylistRows object and pass it to insert_row()
|
|
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
|
|
plr = PlaylistRows(session, self.playlist_id, track.id, row_number)
|
|
self.insert_row(session, plr)
|
|
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
|
|
if repaint:
|
|
self.update_display(session)
|
|
|
|
def play_started(self, session: Session) -> None:
|
|
"""
|
|
Notification from musicmuster that track has started playing.
|
|
|
|
Actions required:
|
|
- Note start time
|
|
- Mark next-track row as current
|
|
- Mark current row as played
|
|
- Scroll to put next track as required
|
|
- Set next track
|
|
- Update display
|
|
"""
|
|
|
|
# Note start time
|
|
self.current_track_start_time = datetime.now()
|
|
|
|
# Mark next-track row as current
|
|
current_row = self._get_next_track_row()
|
|
if current_row is None:
|
|
return
|
|
self._set_current_track_row(current_row)
|
|
|
|
# Mark current row as played
|
|
self._set_played_row(session, current_row)
|
|
|
|
# Set next track
|
|
search_from = current_row + 1
|
|
next_row = self._find_next_track_row(session, search_from)
|
|
if next_row:
|
|
self._set_next(session, next_row)
|
|
|
|
# Update display
|
|
self.update_display(session)
|
|
|
|
def play_stopped(self) -> None:
|
|
"""
|
|
Notification from musicmuster that track has ended.
|
|
|
|
Actions required:
|
|
- Remove current track marker
|
|
- Reset current track start time
|
|
"""
|
|
|
|
self._clear_current_track_row()
|
|
self.current_track_start_time = None
|
|
|
|
def populate(self, session: Session, playlist_id: int) -> None:
|
|
"""
|
|
Populate from the associated playlist ID
|
|
"""
|
|
|
|
# Sanity check row numbering before we load
|
|
PlaylistRows.fixup_rownumbers(session, playlist_id)
|
|
|
|
# Clear playlist
|
|
self.setRowCount(0)
|
|
|
|
# Add the rows
|
|
playlist = session.get(Playlists, playlist_id)
|
|
for row in playlist.rows:
|
|
self.insert_row(session, row, repaint=False)
|
|
|
|
# Scroll to top
|
|
scroll_to: QTableWidgetItem = self.item(0, 0)
|
|
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(session)
|
|
|
|
def remove_rows(self, row_numbers: List[int]) -> None:
|
|
"""Remove passed rows from display"""
|
|
|
|
# Remove rows from display. Do so in reverse order so that
|
|
# row numbers remain valid.
|
|
for row in sorted(row_numbers, reverse=True):
|
|
self.removeRow(row)
|
|
|
|
def remove_selected_rows(self) -> None:
|
|
"""Remove selected rows from display"""
|
|
|
|
self.remove_rows(self._get_selected_rows())
|
|
# Reset drag mode
|
|
self.setDragEnabled(False)
|
|
|
|
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).
|
|
"""
|
|
|
|
for row in range(self.rowCount()):
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
|
|
# Set the row number and playlist id (even if correct)
|
|
plr.row_number = row
|
|
plr.playlist_id = self.playlist_id
|
|
|
|
# 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.
|
|
session.commit()
|
|
PlaylistRows.delete_higher_rows(session, self.playlist_id, row)
|
|
|
|
def set_search(self, text: str) -> None:
|
|
"""Set search text and find first match"""
|
|
|
|
self.search_text = text
|
|
if not text:
|
|
# Search string has been reset
|
|
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.
|
|
"""
|
|
|
|
self._search(next=True)
|
|
|
|
def search_previous(self) -> None:
|
|
"""
|
|
Select previous row containg self.search_string.
|
|
"""
|
|
|
|
self._search(next=False)
|
|
|
|
def select_next_row(self) -> None:
|
|
"""
|
|
Select next or first row. Don't select section headers.
|
|
|
|
Wrap at last row.
|
|
"""
|
|
|
|
row: int
|
|
selected_rows: List[int]
|
|
|
|
selected_rows = self._get_selected_rows()
|
|
# 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 section headers
|
|
wrapped: bool = False
|
|
track_id = self._get_row_track_id(row)
|
|
while not track_id:
|
|
row += 1
|
|
if row >= self.rowCount():
|
|
if wrapped:
|
|
# we're already wrapped once, so there are no
|
|
# non-notes
|
|
return
|
|
row = 0
|
|
wrapped = True
|
|
track_id = self._get_row_track_id(row)
|
|
|
|
self.selectRow(row)
|
|
|
|
def select_previous_row(self) -> None:
|
|
"""
|
|
Select previous or last track. Don't select section headers.
|
|
Wrap at first row.
|
|
"""
|
|
|
|
row: int
|
|
selected_rows: List[int]
|
|
|
|
selected_rows = self._get_selected_rows()
|
|
# 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 section headers
|
|
wrapped: bool = False
|
|
track_id = self._get_row_track_id(row)
|
|
while not track_id:
|
|
row -= 1
|
|
if row < 0:
|
|
if wrapped:
|
|
# we're already wrapped once, so there are no
|
|
# non-notes
|
|
return
|
|
row = last_row
|
|
wrapped = True
|
|
track_id = self._get_row_track_id(row)
|
|
|
|
self.selectRow(row)
|
|
|
|
def set_searchtext(self, text: Optional[str]) -> None:
|
|
"""Set the search text and find first match"""
|
|
|
|
self.search_text = text
|
|
self._find_next_match()
|
|
|
|
def set_selected_as_next(self) -> None:
|
|
"""Sets the select track as next to play"""
|
|
|
|
row = self._get_selected_row()
|
|
if row is None:
|
|
return None
|
|
|
|
with Session() as session:
|
|
self._set_next(session, row)
|
|
|
|
def update_display(self, session, clear_selection: bool = True) -> None:
|
|
"""
|
|
Set row colours, fonts, etc
|
|
|
|
Actions required:
|
|
- Clear selection if required
|
|
- Render notes in correct colour
|
|
- Render current, next and unplayable tracks in correct colour
|
|
- Set start and end times
|
|
- Show unplayed tracks in bold
|
|
"""
|
|
|
|
# Clear selection if required
|
|
if clear_selection:
|
|
self.clear_selection()
|
|
|
|
current_row: Optional[int] = self._get_current_track_row()
|
|
next_row: Optional[int] = self._get_next_track_row()
|
|
played = [
|
|
p.row_number for p in PlaylistRows.get_played_rows(
|
|
session, self.playlist_id)
|
|
]
|
|
unreadable: List[int] = self._get_unreadable_track_rows()
|
|
|
|
next_start_time = None
|
|
section_start_plr = None
|
|
section_time = 0
|
|
|
|
# Start time calculations
|
|
# 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.
|
|
|
|
# Cycle through all rows
|
|
for row in range(self.rowCount()):
|
|
|
|
# Extract note text from database to ignore section timings
|
|
playlist_row = session.get(PlaylistRows,
|
|
self._get_playlistrow_id(row))
|
|
note_text = playlist_row.note
|
|
# Get note colour
|
|
note_colour = NoteColours.get_colour(session, note_text)
|
|
if not note_colour:
|
|
note_colour = Config.COLOUR_NOTES_PLAYLIST
|
|
|
|
# Get track if there is one
|
|
track_id = self._get_row_track_id(row)
|
|
track = None
|
|
if track_id:
|
|
track = session.get(Tracks, track_id)
|
|
|
|
if track:
|
|
# Reset colour in case it was current/next/unplayable
|
|
self._set_row_colour(row, None)
|
|
|
|
# Render unplayable tracks in correct colour
|
|
if not file_is_readable(track.path):
|
|
self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE))
|
|
self._set_row_bold(row)
|
|
continue
|
|
|
|
# Add track time to section time if in timed section
|
|
if section_start_plr is not None:
|
|
section_time += track.duration
|
|
|
|
# Colour any note
|
|
if note_text:
|
|
(self.item(row, columns['row_notes'].idx)
|
|
.setBackground(QColor(note_colour)))
|
|
|
|
# Ensure content is visible by wrapping cells
|
|
self.resizeRowToContents(row)
|
|
|
|
# Highlight low bitrates
|
|
if track.bitrate:
|
|
if track.bitrate < Config.BITRATE_LOW_THRESHOLD:
|
|
cell_colour = Config.COLOUR_BITRATE_LOW
|
|
elif track.bitrate < Config.BITRATE_OK_THRESHOLD:
|
|
cell_colour = Config.COLOUR_BITRATE_MEDIUM
|
|
else:
|
|
cell_colour = Config.COLOUR_BITRATE_OK
|
|
brush = QBrush(QColor(cell_colour))
|
|
self.item(row, columns['bitrate'].idx).setBackground(brush)
|
|
|
|
# Render playing track
|
|
if row == current_row:
|
|
# Set start time
|
|
self._set_row_start_time(
|
|
row, self.current_track_start_time)
|
|
# Set last played time to "Today"
|
|
self.item(row, columns['lastplayed'].idx).setText("Today")
|
|
# Calculate next_start_time
|
|
next_start_time = self._calculate_end_time(
|
|
self.current_track_start_time, track.duration)
|
|
# 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)
|
|
continue
|
|
|
|
# Render next track
|
|
if row == next_row:
|
|
# Set start time
|
|
# if there's a track playing, set start time from that
|
|
if current_row is not None:
|
|
start_time = self._calculate_end_time(
|
|
self.current_track_start_time, track.duration)
|
|
else:
|
|
# No current track to base from, but don't change
|
|
# time if it's already set
|
|
start_time = self._get_row_start_time(row)
|
|
if not start_time:
|
|
start_time = next_start_time
|
|
self._set_row_start_time(row, start_time)
|
|
# Calculate next_start_time
|
|
next_start_time = self._calculate_end_time(start_time,
|
|
track.duration)
|
|
# 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)
|
|
continue
|
|
|
|
if row in played:
|
|
# Played today, so update last played column
|
|
self.item(row, columns['lastplayed'].idx).setText(
|
|
Config.LAST_PLAYED_TODAY_STRING)
|
|
if self.musicmuster.hide_played_tracks:
|
|
self.hideRow(row)
|
|
else:
|
|
self._set_row_not_bold(row)
|
|
else:
|
|
# Set start/end times as we haven't played it yet
|
|
if next_start_time:
|
|
self._set_row_start_time(row, next_start_time)
|
|
next_start_time = self._calculate_end_time(
|
|
next_start_time, track.duration)
|
|
# 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)
|
|
|
|
continue
|
|
|
|
# No track associated, so this row is a section header
|
|
# Does the note have a start time?
|
|
row_time = self._get_note_text_time(note_text)
|
|
if row_time:
|
|
next_start_time = row_time
|
|
# Does it delimit a section?
|
|
if section_start_plr is not None:
|
|
if note_text.endswith("-"):
|
|
self._update_note_text(
|
|
section_start_plr,
|
|
self._get_section_timing_string(section_time)
|
|
)
|
|
section_start_plr = None
|
|
section_time = 0
|
|
elif note_text.endswith("+"):
|
|
section_start_plr = playlist_row
|
|
section_time = 0
|
|
self._set_row_colour(row, QColor(note_colour))
|
|
# Section headers are always bold
|
|
self._set_row_bold(row)
|
|
# Ensure content is visible by wrapping cells
|
|
self.resizeRowToContents(row)
|
|
continue
|
|
|
|
# Have we had a section start but not end?
|
|
if section_start_plr is not None:
|
|
self._update_note_text(
|
|
section_start_plr,
|
|
self._get_section_timing_string(section_time, no_end=True)
|
|
)
|
|
|
|
# Scroll to put next track Config.SCROLL_TOP_MARGIN from the
|
|
# top. Rows number from zero, so set (current_row -
|
|
# Config.SCROLL_TOP_MARGIN + 1) row to be top row
|
|
|
|
if next_row is not None:
|
|
top_row = max(0, next_row - Config.SCROLL_TOP_MARGIN + 1)
|
|
scroll_item = self.item(top_row, 0)
|
|
self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop)
|
|
|
|
#
|
|
# # ########## Internally called functions ##########
|
|
|
|
def _add_track(self, row: int) -> None:
|
|
"""Add a track to a section header making it a normal track row"""
|
|
|
|
with Session() as session:
|
|
track = self.musicmuster.get_one_track(session)
|
|
if not track:
|
|
return
|
|
|
|
# Add track to playlist row
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
|
|
plr.track_id = track.id
|
|
session.commit()
|
|
# Update attributes of row
|
|
self.item(row, columns["userdata"].idx).setData(
|
|
self.ROW_TRACK_ID, track.id)
|
|
self.item(row, columns["start_gap"].idx).setText(
|
|
str(track.start_gap))
|
|
self.item(row, columns["title"].idx).setText(str(track.title))
|
|
self.item(row, columns["artist"].idx).setText(str(track.artist))
|
|
self.item(row, columns["duration"].idx).setText(
|
|
ms_to_mmss(track.duration))
|
|
last_playtime = Playdates.last_played(session, track.id)
|
|
last_played_str = get_relative_date(last_playtime)
|
|
self.item(row, columns['lastplayed'].idx).setText(last_played_str)
|
|
|
|
# Reset row span
|
|
self.setSpan(row, 1, 1, 1)
|
|
|
|
self.update_display(session)
|
|
|
|
def _calculate_end_time(self, start: Optional[datetime],
|
|
duration: int) -> Optional[datetime]:
|
|
"""Return datetime 'duration' ms after 'start'"""
|
|
|
|
if start is None:
|
|
return None
|
|
|
|
return start + timedelta(milliseconds=duration)
|
|
|
|
def _clear_current_track_row(self) -> None:
|
|
"""
|
|
Clear current row if there is one.
|
|
"""
|
|
|
|
current_row = self._get_current_track_row()
|
|
|
|
if current_row is None:
|
|
return
|
|
|
|
self._meta_clear_attribute(current_row, RowMeta.CURRENT)
|
|
|
|
def _column_resize(self, idx: int, old: int, new: int) -> None:
|
|
"""
|
|
Called when column widths are changed.
|
|
|
|
Save column sizes to database
|
|
"""
|
|
|
|
with Session() as session:
|
|
for column_name, data in columns.items():
|
|
idx = data.idx
|
|
width = self.columnWidth(idx)
|
|
attribute_name = f"playlist_{column_name}_col_width"
|
|
record = Settings.get_int_settings(session, attribute_name)
|
|
if record.f_int != self.columnWidth(idx):
|
|
record.update(session, {'f_int': width})
|
|
|
|
def _context_menu(self, pos):
|
|
"""Display right-click menu"""
|
|
|
|
assert self.menu
|
|
self.menu.exec_(self.mapToGlobal(pos))
|
|
|
|
def _copy_path(self, row: int) -> None:
|
|
"""
|
|
If passed row has a track, copy the track path, single-quoted,
|
|
to the clipboard. Otherwise, return None.
|
|
"""
|
|
|
|
track_id = self._get_row_track_id(row)
|
|
if track_id is None:
|
|
return
|
|
|
|
with Session() as session:
|
|
track = session.get(Tracks, track_id)
|
|
if track:
|
|
# Escape single quotes and spaces in name
|
|
path = track.path
|
|
pathq = path.replace("'", "\\'")
|
|
pathqs = pathq.replace(" ", "\\ ")
|
|
cb = QApplication.clipboard()
|
|
cb.clear(mode=cb.Clipboard)
|
|
cb.setText(pathqs, mode=cb.Clipboard)
|
|
|
|
def _delete_rows(self) -> None:
|
|
"""
|
|
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
|
|
"""
|
|
|
|
# Delete rows from database
|
|
plr_ids = self.get_selected_playlistrow_ids()
|
|
|
|
# Get confirmation
|
|
row_count = len(plr_ids)
|
|
plural = 's' if row_count > 1 else ''
|
|
if not ask_yes_no("Delete rows",
|
|
f"Really delete {row_count} row{plural}?"):
|
|
return
|
|
|
|
with Session() as session:
|
|
PlaylistRows.delete_rows(session, plr_ids)
|
|
|
|
# Fix up row numbers left in this playlist
|
|
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
|
|
# Remove selected rows from display
|
|
self.remove_selected_rows()
|
|
|
|
def _drop_on(self, event):
|
|
"""
|
|
https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
|
|
"""
|
|
|
|
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 _find_next_match(self) -> None:
|
|
"""
|
|
Find next match of search_text. Start at first highlighted row
|
|
if there is one, else from top of playlist.
|
|
"""
|
|
|
|
start_row = self._get_selected_row()
|
|
if start_row is None:
|
|
start_row = 0
|
|
|
|
def _find_next_track_row(self, session: Session,
|
|
starting_row: int = None) -> Optional[int]:
|
|
"""
|
|
Find next track to play. If a starting row is given, start there;
|
|
otherwise, start from top. Skip rows already played.
|
|
|
|
If not found, return None.
|
|
|
|
If found, return row number.
|
|
"""
|
|
|
|
if starting_row is None:
|
|
starting_row = 0
|
|
|
|
track_rows = [
|
|
p.row_number for p in PlaylistRows.get_rows_with_tracks(
|
|
session, self.playlist_id)
|
|
]
|
|
played_rows = [
|
|
p.row_number for p in PlaylistRows.get_played_rows(
|
|
session, self.playlist_id)
|
|
]
|
|
for row in range(starting_row, self.rowCount()):
|
|
if row not in track_rows or row in played_rows:
|
|
continue
|
|
else:
|
|
return row
|
|
|
|
return None
|
|
|
|
def _get_current_track_row(self) -> Optional[int]:
|
|
"""Return row marked as current, or None"""
|
|
|
|
row = self._meta_search(RowMeta.CURRENT)
|
|
if len(row) > 0:
|
|
return row[0]
|
|
else:
|
|
return None
|
|
|
|
def _get_next_track_row(self) -> Optional[int]:
|
|
"""Return row marked as next, or None"""
|
|
|
|
row = self._meta_search(RowMeta.NEXT)
|
|
if len(row) > 0:
|
|
return row[0]
|
|
else:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_note_text_time(text: str) -> Optional[datetime]:
|
|
"""Return time specified as @hh:mm:ss in text"""
|
|
|
|
match = start_time_re.search(text)
|
|
if not match:
|
|
return None
|
|
|
|
try:
|
|
return datetime.strptime(match.group(0)[1:],
|
|
Config.NOTE_TIME_FORMAT)
|
|
except ValueError:
|
|
return None
|
|
|
|
def _get_playlistrow_id(self, row: int) -> int:
|
|
"""Return the playlistrow_id associated with this row"""
|
|
|
|
playlistrow_id = (self.item(row, columns['userdata'].idx)
|
|
.data(self.PLAYLISTROW_ID))
|
|
|
|
return playlistrow_id
|
|
|
|
def _get_row_artist(self, row: int) -> Optional[str]:
|
|
"""Return artist on this row or None if none"""
|
|
|
|
track_id = self._get_row_track_id(row)
|
|
if not track_id:
|
|
return None
|
|
|
|
item_artist = self.item(row, columns['artist'].idx)
|
|
return item_artist.text()
|
|
|
|
def _get_row_duration(self, row: int) -> int:
|
|
"""Return duration associated with this row"""
|
|
|
|
duration = (self.item(row, columns['userdata'].idx)
|
|
.data(self.ROW_DURATION))
|
|
if duration:
|
|
return duration
|
|
else:
|
|
return 0
|
|
|
|
def _get_row_note(self, row: int) -> Optional[str]:
|
|
"""Return note on this row or None if none"""
|
|
|
|
track_id = self._get_row_track_id(row)
|
|
if track_id:
|
|
item_note = self.item(row, columns['row_notes'].idx)
|
|
else:
|
|
item_note = self.item(row, HEADER_NOTES_COLUMN)
|
|
return item_note.text()
|
|
|
|
def _get_row_start_time(self, row: int) -> Optional[datetime]:
|
|
try:
|
|
if self.item(row, columns['start_time'].idx):
|
|
return datetime.strptime(self.item(
|
|
row, columns['start_time'].idx).text(),
|
|
Config.NOTE_TIME_FORMAT
|
|
)
|
|
else:
|
|
return None
|
|
except ValueError:
|
|
return None
|
|
|
|
def _get_row_title(self, row: int) -> Optional[str]:
|
|
"""Return title on this row or None if none"""
|
|
|
|
track_id = self._get_row_track_id(row)
|
|
if not track_id:
|
|
return None
|
|
|
|
item_title = self.item(row, columns['title'].idx)
|
|
return item_title.text()
|
|
|
|
def _get_row_track_id(self, row: int) -> int:
|
|
"""Return the track_id associated with this row or None"""
|
|
|
|
try:
|
|
track_id = (self.item(row, columns['userdata'].idx)
|
|
.data(self.ROW_TRACK_ID))
|
|
except AttributeError:
|
|
return None
|
|
|
|
return track_id
|
|
|
|
def _get_selected_row(self) -> Optional[int]:
|
|
"""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(self) -> List[int]:
|
|
"""Return a list of selected row numbers"""
|
|
|
|
# Use a set to deduplicate result (a selected row will have all
|
|
# items in that row selected)
|
|
return [row for row in set([a.row() for a in self.selectedItems()])]
|
|
|
|
def _get_unreadable_track_rows(self) -> List[int]:
|
|
"""Return rows marked as unreadable, or None"""
|
|
|
|
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"""
|
|
|
|
with Session() as session:
|
|
track = session.get(Tracks, track_id)
|
|
if track:
|
|
txt = (
|
|
f"Title: {track.title}\n"
|
|
f"Artist: {track.artist}\n"
|
|
f"Track ID: {track.id}\n"
|
|
f"Track duration: {ms_to_mmss(track.duration)}\n"
|
|
f"Track bitrate: {track.bitrate}\n"
|
|
f"Track fade at: {ms_to_mmss(track.fade_at)}\n"
|
|
f"Track silence at: {ms_to_mmss(track.silence_at)}"
|
|
"\n\n"
|
|
f"Path: {track.path}\n"
|
|
)
|
|
else:
|
|
txt = f"Can't find {track_id=}"
|
|
|
|
info: QMessageBox = 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): # review
|
|
"""
|
|
https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
|
|
"""
|
|
|
|
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 _meta_clear_attribute(self, row: int, attribute: int) -> None:
|
|
"""Clear given metadata for row"""
|
|
|
|
if row is None:
|
|
raise ValueError(f"_meta_clear_attribute({row=}, {attribute=})")
|
|
|
|
new_metadata: int = self._meta_get(row) & ~(1 << attribute)
|
|
self.item(row, columns['userdata'].idx).setData(
|
|
self.ROW_FLAGS, new_metadata)
|
|
|
|
def _meta_clear_next(self) -> None:
|
|
"""
|
|
Clear next row if there is one.
|
|
"""
|
|
|
|
next_row: Optional[int] = self._get_next_track_row()
|
|
if next_row is not None:
|
|
self._meta_clear_attribute(next_row, RowMeta.NEXT)
|
|
|
|
def _meta_get(self, row: int) -> int:
|
|
"""Return row metadata"""
|
|
|
|
return (self.item(row, columns['userdata'].idx)
|
|
.data(self.ROW_FLAGS))
|
|
|
|
def _meta_search(self, metadata: int, one: bool = True) -> List[int]:
|
|
"""
|
|
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):
|
|
if self._meta_get(row) & (1 << metadata):
|
|
matches.append(row)
|
|
|
|
if not one:
|
|
return matches
|
|
|
|
if len(matches) <= 1:
|
|
return matches
|
|
else:
|
|
log.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: int, attribute: int) -> None:
|
|
"""Set row metadata"""
|
|
|
|
if row is None:
|
|
raise ValueError(f"_meta_set_attribute({row=}, {attribute=})")
|
|
|
|
current_metadata: int = self._meta_get(row)
|
|
if not current_metadata:
|
|
new_metadata: int = (1 << attribute)
|
|
else:
|
|
new_metadata = self._meta_get(row) | (1 << attribute)
|
|
self.item(row, columns['userdata'].idx).setData(
|
|
self.ROW_FLAGS, new_metadata)
|
|
|
|
def _mplayer_play(self, track_id: int) -> None:
|
|
"""Play track with mplayer"""
|
|
|
|
with Session() as session:
|
|
track = session.get(Tracks, track_id)
|
|
if not track:
|
|
log.error(
|
|
f"playlists._mplayer_play({track_id=}): "
|
|
"Track not found"
|
|
)
|
|
return
|
|
|
|
cmd_list = ['gmplayer', '-vc', 'null', '-vo', 'null', track.path]
|
|
thread = threading.Thread(
|
|
target=self._run_subprocess, args=(cmd_list,))
|
|
thread.start()
|
|
|
|
def _open_in_audacity(self, track_id: int) -> None:
|
|
"""Open track in Audacity. Audacity must be already running"""
|
|
|
|
with Session() as session:
|
|
track = session.get(Tracks, track_id)
|
|
if not track:
|
|
log.error(
|
|
f"playlists._open_in_audacity({track_id=}): "
|
|
"Track not found"
|
|
)
|
|
return
|
|
|
|
open_in_audacity(track.path)
|
|
|
|
def _remove_track(self, row: int) -> None:
|
|
"""Remove track from row, making it a section header"""
|
|
|
|
# Get confirmation
|
|
if not ask_yes_no("Remove music",
|
|
"Really remove the music track from this row?"):
|
|
return
|
|
|
|
# Update playlist_rows record
|
|
with Session() as session:
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
|
|
plr.track_id = None
|
|
# We can't have null text
|
|
if not plr.note:
|
|
plr.note = Config.TEXT_NO_TRACK_NO_NOTE
|
|
session.commit()
|
|
|
|
# Clear track text items
|
|
for i in range(2, len(columns)):
|
|
self.item(row, i).setText("")
|
|
# Set note text in correct column for section head
|
|
self.item(row, HEADER_NOTES_COLUMN).setText(plr.note)
|
|
# Remove row duration
|
|
self._set_row_duration(row, 0)
|
|
# Remote track_id from row
|
|
self.item(row, columns['userdata'].idx).setData(
|
|
self.ROW_TRACK_ID, 0)
|
|
# Span the rows
|
|
self.setSpan(row, 1, 1, len(columns))
|
|
# And refresh display
|
|
self.update_display(session)
|
|
|
|
def _rescan(self, row: int, track_id: int) -> None:
|
|
"""Rescan track"""
|
|
|
|
with Session() as session:
|
|
track = session.get(Tracks, track_id)
|
|
if not track:
|
|
log.error(
|
|
f"playlists._rescan({track_id=}): "
|
|
"Track not found"
|
|
)
|
|
return
|
|
|
|
track.rescan(session)
|
|
self._update_row(session, row, track)
|
|
|
|
def _run_subprocess(self, args):
|
|
"""Run args in subprocess"""
|
|
|
|
subprocess.call(args)
|
|
|
|
def _select_event(self) -> None:
|
|
"""
|
|
Called when item selection changes.
|
|
If multiple rows are selected, display sum of durations in status bar.
|
|
"""
|
|
|
|
# If we are in the process of selecting multiple tracks, no-op here
|
|
if self.selecting_in_progress:
|
|
return
|
|
|
|
selected_rows = self._get_selected_rows()
|
|
# If no rows are selected, we have nothing to do
|
|
if len(selected_rows) == 0:
|
|
self.musicmuster.lblSumPlaytime.setText("")
|
|
return
|
|
|
|
ms = 0
|
|
for row in selected_rows:
|
|
ms += self._get_row_duration(row)
|
|
|
|
# Only paint message if there are selected track rows
|
|
if ms > 0:
|
|
self.musicmuster.lblSumPlaytime.setText(
|
|
f"Selected duration: {ms_to_mmss(ms)}")
|
|
else:
|
|
self.musicmuster.lblSumPlaytime.setText("")
|
|
|
|
def _set_column_widths(self, session: Session) -> None:
|
|
"""Column widths from settings"""
|
|
|
|
for column_name, data in columns.items():
|
|
idx = data.idx
|
|
attr_name = f"playlist_{column_name}_col_width"
|
|
record: Settings = Settings.get_int_settings(session, attr_name)
|
|
if record and record.f_int is not None:
|
|
self.setColumnWidth(idx, record.f_int)
|
|
else:
|
|
self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH)
|
|
|
|
def _set_current_track_row(self, row: int) -> None:
|
|
"""Mark this row as current track"""
|
|
|
|
self._clear_current_track_row()
|
|
self._meta_set_attribute(row, RowMeta.CURRENT)
|
|
|
|
def _set_next(self, session: Session, row_number: int) -> None:
|
|
"""
|
|
Set passed row as next track to play.
|
|
|
|
Actions required:
|
|
- Check row has a track
|
|
- Check track is readable
|
|
- Mark as next track
|
|
- Update display
|
|
- Notify musicmuster
|
|
"""
|
|
|
|
track_id = self._get_row_track_id(row_number)
|
|
if not track_id:
|
|
log.error(
|
|
f"playlists._set_next({row_number=}) has no track associated"
|
|
)
|
|
return
|
|
|
|
track = session.get(Tracks, track_id)
|
|
if not track:
|
|
log.error(f"playlists._set_next({row_number=}): Track not found")
|
|
return
|
|
|
|
# Check track is readable
|
|
if not file_is_readable(track.path):
|
|
self._set_unreadable_row(row_number)
|
|
return None
|
|
|
|
# Mark as next track
|
|
self._set_next_track_row(row_number)
|
|
|
|
# Update display
|
|
self.update_display(session)
|
|
|
|
# Notify musicmuster
|
|
self.musicmuster.this_is_the_next_track(session, self, track)
|
|
|
|
def _set_next_track_row(self, row: int) -> None:
|
|
"""Mark this row as next track"""
|
|
|
|
self._meta_clear_next()
|
|
self._meta_set_attribute(row, RowMeta.NEXT)
|
|
|
|
def _set_played_row(self, session: Session, row: int) -> None:
|
|
"""Mark this row as played"""
|
|
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
|
|
plr.played = True
|
|
session.commit()
|
|
|
|
def _set_row_bold(self, row: int, bold: bool = True) -> None:
|
|
"""
|
|
Make row bold (bold=True) or not bold.
|
|
|
|
Don't make notes column bold.
|
|
"""
|
|
|
|
boldfont = QFont()
|
|
boldfont.setBold(bold)
|
|
for column in range(self.columnCount()):
|
|
if column == columns['row_notes'].idx:
|
|
continue
|
|
if self.item(row, column):
|
|
self.item(row, column).setFont(boldfont)
|
|
|
|
def _set_row_colour(self, row: int,
|
|
colour: Optional[QColor] = None) -> None:
|
|
"""
|
|
Set or reset row background colour
|
|
"""
|
|
|
|
column: int
|
|
|
|
if colour:
|
|
brush = QBrush(colour)
|
|
else:
|
|
brush = QBrush()
|
|
|
|
for column in range(1, self.columnCount()):
|
|
# Don't clear colour on start gap row
|
|
if not colour and column == columns['start_gap'].idx:
|
|
continue
|
|
if self.item(row, column):
|
|
self.item(row, column).setBackground(brush)
|
|
|
|
def _set_row_duration(self, row: int, ms: int) -> None:
|
|
"""Set duration of this row in row metadata"""
|
|
|
|
self.item(row, columns['userdata'].idx).setData(self.ROW_DURATION, ms)
|
|
|
|
def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None:
|
|
"""Set passed row end time to passed time"""
|
|
|
|
try:
|
|
time_str = time.strftime(Config.TRACK_TIME_FORMAT)
|
|
except AttributeError:
|
|
time_str = ""
|
|
item = QTableWidgetItem(time_str)
|
|
self.setItem(row, columns['end_time'].idx, item)
|
|
|
|
def _set_row_not_bold(self, row: int) -> None:
|
|
"""Set row to not be bold"""
|
|
|
|
self._set_row_bold(row, False)
|
|
|
|
def _set_row_start_time(self, row: int, time: Optional[datetime]) -> None:
|
|
"""Set passed row start time to passed time"""
|
|
|
|
try:
|
|
time_str = time.strftime(Config.TRACK_TIME_FORMAT)
|
|
except AttributeError:
|
|
time_str = ""
|
|
item = QTableWidgetItem(time_str)
|
|
self.setItem(row, columns['start_time'].idx, item)
|
|
|
|
def _set_unreadable_row(self, row: int) -> None:
|
|
"""Mark this row as unreadable"""
|
|
|
|
self._meta_set_attribute(row, RowMeta.UNREADABLE)
|
|
|
|
def _get_section_timing_string(self, ms: int,
|
|
no_end: bool = False) -> None:
|
|
"""Return string describing section duration"""
|
|
|
|
duration = ms_to_mmss(ms)
|
|
caveat = ""
|
|
if no_end:
|
|
caveat = " (to end of playlist)"
|
|
return ' [' + duration + caveat + ']'
|
|
|
|
def _songfacts(self, row_number: int) -> None:
|
|
"""Look up passed row title in songfacts and display info tab"""
|
|
|
|
title = self._get_row_title(row_number)
|
|
|
|
self.musicmuster.tabInfolist.open_in_songfacts(title)
|
|
|
|
def _update_note_text(self, playlist_row: PlaylistRows,
|
|
additional_text: str) -> None:
|
|
"""Append additional_text to row display"""
|
|
|
|
# Column to update is either HEADER_NOTES_COLUMN for a section
|
|
# header or the appropriate row_notes column for a track row
|
|
if playlist_row.track_id:
|
|
column = columns['row_notes'].idx
|
|
else:
|
|
column = HEADER_NOTES_COLUMN
|
|
|
|
# Update text
|
|
new_text = playlist_row.note + additional_text
|
|
self.item(playlist_row.row_number, column).setText(new_text)
|
|
|
|
def _update_row(self, session, row: int, track: Tracks) -> None:
|
|
"""
|
|
Update the passed row with info from the passed track.
|
|
"""
|
|
|
|
columns['start_time'].idx
|
|
item_startgap = self.item(row, columns['start_gap'].idx)
|
|
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, columns['title'].idx)
|
|
item_title.setText(track.title)
|
|
|
|
item_artist = self.item(row, columns['artist'].idx)
|
|
item_artist.setText(track.artist)
|
|
|
|
item_duration = self.item(row, columns['duration'].idx)
|
|
item_duration.setText(ms_to_mmss(track.duration))
|
|
|
|
self.update_display(session)
|
|
|
|
def _wikipedia(self, row_number: int) -> None:
|
|
"""Look up passed row title in Wikipedia and display info tab"""
|
|
|
|
title = self._get_row_title(row_number)
|
|
|
|
self.musicmuster.tabInfolist.open_in_wikipedia(title)
|