Clean up function. If next track is above current track, flow start times around current track rather than resetting them at current track.
2332 lines
80 KiB
Python
2332 lines
80 KiB
Python
import re
|
|
import stackprinter # type: ignore
|
|
import subprocess
|
|
import threading
|
|
|
|
from collections import namedtuple
|
|
from datetime import datetime, timedelta
|
|
from typing import cast, List, Optional, TYPE_CHECKING, Union
|
|
|
|
from PyQt5.QtCore import (
|
|
QEvent,
|
|
QModelIndex,
|
|
QObject,
|
|
QSize,
|
|
Qt,
|
|
QTimer,
|
|
)
|
|
from PyQt5.QtGui import (
|
|
QBrush,
|
|
QColor,
|
|
QFont,
|
|
QDropEvent,
|
|
QKeyEvent
|
|
)
|
|
from PyQt5.QtWidgets import (
|
|
QAbstractItemDelegate,
|
|
QAbstractItemView,
|
|
QApplication,
|
|
QLineEdit,
|
|
QMainWindow,
|
|
QMenu,
|
|
QMessageBox,
|
|
QPlainTextEdit,
|
|
QStyledItemDelegate,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QWidget
|
|
)
|
|
|
|
from config import Config
|
|
from dbconfig import Session, scoped_session
|
|
from helpers import (
|
|
ask_yes_no,
|
|
file_is_readable,
|
|
get_relative_date,
|
|
ms_to_mmss,
|
|
open_in_audacity,
|
|
send_mail,
|
|
set_track_metadata,
|
|
)
|
|
from log import log
|
|
from models import (
|
|
Playdates,
|
|
Playlists,
|
|
PlaylistRows,
|
|
Settings,
|
|
Tracks,
|
|
NoteColours
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from musicmuster import Window, MusicMusterSignals
|
|
|
|
start_time_re = re.compile(r"@\d\d:\d\d:\d\d")
|
|
HEADER_NOTES_COLUMN = 2
|
|
|
|
# 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)
|
|
|
|
USERDATA = columns["userdata"].idx
|
|
START_GAP = columns["start_gap"].idx
|
|
TITLE = columns["title"].idx
|
|
ARTIST = columns["artist"].idx
|
|
DURATION = columns["duration"].idx
|
|
START_TIME = columns["start_time"].idx
|
|
END_TIME = columns["end_time"].idx
|
|
LASTPLAYED = columns["lastplayed"].idx
|
|
BITRATE = columns["bitrate"].idx
|
|
ROW_NOTES = columns["row_notes"].idx
|
|
|
|
|
|
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
|
|
|
|
Now this:
|
|
- increases the height of a row when editing to make editing easier
|
|
- closes the edit on control-return
|
|
"""
|
|
|
|
def createEditor(self, parent, option, index):
|
|
if isinstance(index.data(), str):
|
|
# Make row just a little bit taller
|
|
row = index.row()
|
|
row_height = self.parent().rowHeight(row)
|
|
self.parent().setRowHeight(row,
|
|
row_height + Config.MINIMUM_ROW_HEIGHT)
|
|
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:
|
|
key_event = cast(QKeyEvent, event)
|
|
if key_event.key() == Qt.Key_Return:
|
|
if key_event.modifiers() == Qt.ControlModifier:
|
|
self.commitData.emit(editor)
|
|
self.closeEditor.emit(editor)
|
|
|
|
return super().eventFilter(editor, event)
|
|
|
|
|
|
class PlaylistTab(QTableWidget):
|
|
# Qt.UserRoles
|
|
ROW_TRACK_ID = Qt.UserRole
|
|
ROW_DURATION = Qt.UserRole + 1
|
|
PLAYLISTROW_ID = Qt.UserRole + 2
|
|
TRACK_PATH = Qt.UserRole + 3
|
|
|
|
def __init__(self, musicmuster: "Window",
|
|
session: scoped_session,
|
|
playlist_id: int, signals: "MusicMusterSignals") -> None:
|
|
super().__init__()
|
|
self.musicmuster: Window = musicmuster
|
|
self.playlist_id = playlist_id
|
|
self.signals = signals
|
|
|
|
# Set up widget
|
|
self.menu: Optional[QMenu] = None
|
|
self.setItemDelegate(NoSelectDelegate(self))
|
|
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))
|
|
self.v_header = self.verticalHeader()
|
|
self.v_header.setMinimumSectionSize(Config.MINIMUM_ROW_HEIGHT)
|
|
self.horizontalHeader().setStretchLastSection(True)
|
|
|
|
# Header row
|
|
for idx in [a for a in range(len(columns))]:
|
|
item = QTableWidgetItem()
|
|
self.setHorizontalHeaderItem(idx, item)
|
|
self.horizontalHeader().setMinimumSectionSize(0)
|
|
# Set column headings sorted by idx
|
|
self.setHorizontalHeaderLabels(
|
|
[a.heading for a in list(sorted(columns.values(),
|
|
key=lambda item: item.idx))]
|
|
)
|
|
self.horizontalHeader().sectionResized.connect(
|
|
self.resizeRowsToContents)
|
|
|
|
# Drag and drop setup
|
|
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)
|
|
# Call self.eventFilter() for events
|
|
self.viewport().installEventFilter(self)
|
|
|
|
self.itemSelectionChanged.connect(self._select_event)
|
|
|
|
self.search_text: str = ""
|
|
self.edit_cell_type: Optional[int]
|
|
self.selecting_in_progress = False
|
|
# Connect signals
|
|
self.horizontalHeader().sectionResized.connect(self._column_resize)
|
|
self.signals.save_playlist_signal.connect(self._deferred_save)
|
|
|
|
# Load playlist rows
|
|
self.populate_display(session, self.playlist_id)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<PlaylistTab(id={self.playlist_id}>"
|
|
|
|
# ########## Events other than cell editing ##########
|
|
|
|
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) # type: ignore
|
|
) 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)
|
|
|
|
with Session() as session:
|
|
self.save_playlist(session)
|
|
|
|
# Update track times
|
|
self._update_start_end_times()
|
|
|
|
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:
|
|
with Session() as session:
|
|
row_number = item.row()
|
|
plr_id = self._get_playlistrow_id(row_number)
|
|
plr = session.get(PlaylistRows, plr_id)
|
|
track_id = plr.track_id
|
|
track_row = track_id is not None
|
|
header_row = not track_row
|
|
if track_row:
|
|
current = (
|
|
row_number == self._get_current_track_row_number()
|
|
)
|
|
next_row = (
|
|
row_number == self._get_next_track_row_number())
|
|
else:
|
|
current = next_row = False
|
|
|
|
# Mark unplayed / un-next
|
|
sep = False
|
|
if plr.played:
|
|
sep = True
|
|
act_unplay = self.menu.addAction("Mark unplayed")
|
|
act_unplay.triggered.connect(
|
|
lambda: self._mark_unplayed(plr))
|
|
if next_row:
|
|
sep = True
|
|
act_unnext = self.menu.addAction(
|
|
"Unmark as next track")
|
|
act_unnext.triggered.connect(
|
|
lambda: self.mark_unnext())
|
|
if sep:
|
|
self.menu.addSeparator()
|
|
|
|
# Cut/paste
|
|
act_cut = self.menu.addAction(
|
|
"Mark for moving")
|
|
act_cut.triggered.connect(
|
|
lambda: self.musicmuster.cut_rows())
|
|
|
|
act_paste = self.menu.addAction(
|
|
"Paste")
|
|
act_paste.setDisabled(
|
|
self.musicmuster.selected_plrs is None)
|
|
act_paste.triggered.connect(
|
|
lambda: self.musicmuster.paste_rows())
|
|
|
|
self.menu.addSeparator()
|
|
|
|
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")
|
|
act_setnext.triggered.connect(
|
|
lambda: self._set_next(session, row_number))
|
|
|
|
if not current:
|
|
# Open in Audacity
|
|
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)
|
|
)
|
|
|
|
# Remove track
|
|
act_remove_track = self.menu.addAction(
|
|
'Remove track')
|
|
act_remove_track.triggered.connect(
|
|
lambda: self._remove_track(row_number)
|
|
)
|
|
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()
|
|
|
|
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
|
|
#
|
|
# 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)
|
|
|
|
cell = self.item(row, column)
|
|
if not cell:
|
|
return
|
|
|
|
new_text = cell.text().strip()
|
|
|
|
# Update cell with strip()'d text
|
|
cell.setText(new_text)
|
|
|
|
track_id = self._get_row_track_id(row)
|
|
|
|
# Determine cell type changed
|
|
with Session() as session:
|
|
# Get playlistrow object
|
|
plr_id = self._get_playlistrow_id(row)
|
|
plr_item = session.get(PlaylistRows, plr_id)
|
|
if not plr_item:
|
|
return
|
|
|
|
# Note any updates needed to PlaylistTrack objects
|
|
update_current = self.musicmuster.current_track.plr_id == plr_id
|
|
update_next = self.musicmuster.next_track.plr_id == plr_id
|
|
|
|
if self.edit_cell_type == ROW_NOTES:
|
|
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)
|
|
# Update note display
|
|
self._set_row_note(session, row, new_text)
|
|
# If this is a header row, ecalcuate track times in case
|
|
# note added a start time
|
|
if not track_id:
|
|
self._update_start_end_times()
|
|
else:
|
|
track = None
|
|
if track_id:
|
|
track = session.get(Tracks, track_id)
|
|
if track:
|
|
if self.edit_cell_type == TITLE:
|
|
track.title = new_text
|
|
if update_current:
|
|
self.musicmuster.current_track.title = new_text
|
|
if update_next:
|
|
self.musicmuster.next_track.title = new_text
|
|
elif self.edit_cell_type == ARTIST:
|
|
track.artist = new_text
|
|
if update_current:
|
|
self.musicmuster.current_track.artist = \
|
|
new_text
|
|
if update_next:
|
|
self.musicmuster.next_track.artist = new_text
|
|
|
|
if update_next or update_current:
|
|
self.musicmuster.update_headers()
|
|
|
|
self.clear_selection()
|
|
|
|
def closeEditor(self,
|
|
editor: QWidget,
|
|
hint: QAbstractItemDelegate.EndEditHint) -> None:
|
|
"""
|
|
Override PySide2.QAbstractItemView.closeEditor to enable
|
|
play controls and update display.
|
|
"""
|
|
|
|
# Update start times in case a start time in a note has been
|
|
# edited
|
|
self._update_start_end_times()
|
|
|
|
self.edit_cell_type = None
|
|
self.musicmuster.enable_play_next_controls()
|
|
self.musicmuster.actionSetNext.setEnabled(True)
|
|
|
|
super(PlaylistTab, self).closeEditor(editor, hint)
|
|
|
|
def edit(self, index: QModelIndex, # type: ignore # FIXME
|
|
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.
|
|
if column in [TITLE, ARTIST, ROW_NOTES]:
|
|
self.edit_cell_type = column
|
|
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 = ROW_NOTES
|
|
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()
|
|
self.musicmuster.actionSetNext.setEnabled(False)
|
|
|
|
# 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)
|
|
if not item:
|
|
return False
|
|
if not plr_item:
|
|
return False
|
|
if not plr_item.note:
|
|
plr_item.note = ''
|
|
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_selection(self) -> None:
|
|
"""Unselect all tracks and reset drag mode"""
|
|
|
|
self.clearSelection()
|
|
self.setDragEnabled(False)
|
|
|
|
def get_new_row_number(self) -> int:
|
|
"""
|
|
Return the selected row or the row count if no row selected
|
|
(ie, new row will be appended)
|
|
"""
|
|
|
|
if self.selectionModel().hasSelection():
|
|
return self.currentRow()
|
|
else:
|
|
return self.rowCount()
|
|
|
|
def get_selected_playlistrow_ids(self) -> Optional[List]:
|
|
"""
|
|
Return a list of PlaylistRow ids of the selected rows
|
|
"""
|
|
|
|
return [self._get_playlistrow_id(a) for a in self._get_selected_rows()]
|
|
|
|
def get_selected_playlistrows(
|
|
self, session: scoped_session) -> List[PlaylistRows]:
|
|
"""
|
|
Return a list of PlaylistRows of the selected rows
|
|
"""
|
|
|
|
plr_ids = self.get_selected_playlistrow_ids()
|
|
if not plr_ids:
|
|
return []
|
|
plrs = [session.get(PlaylistRows, a) for a in plr_ids]
|
|
|
|
return [plr for plr in plrs if plr is not None]
|
|
|
|
def hide_played_tracks(self, hide: bool) -> None:
|
|
"""Hide played tracks if hide is True else show them"""
|
|
|
|
with Session() as session:
|
|
played = [
|
|
p.row_number for p in PlaylistRows.get_played_rows(
|
|
session, self.playlist_id)
|
|
]
|
|
for row in range(self.rowCount()):
|
|
if row in played:
|
|
if hide:
|
|
self.hideRow(row)
|
|
else:
|
|
self.showRow(row)
|
|
|
|
def insert_header(self, session: scoped_session, note: str) -> 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.
|
|
"""
|
|
|
|
row_number = self.get_new_row_number()
|
|
plr = PlaylistRows(session, self.playlist_id, None, row_number, note)
|
|
self.insert_row(session, plr)
|
|
self.save_playlist(session)
|
|
|
|
def insert_row(self, session: scoped_session, plr: PlaylistRows,
|
|
update_track_times: bool = True, played=False) -> None:
|
|
"""
|
|
Insert passed playlist row (plr) into playlist tab.
|
|
"""
|
|
|
|
if plr.row_number is None:
|
|
return
|
|
|
|
row = plr.row_number
|
|
self.insertRow(row)
|
|
|
|
# Add row metadata to userdata column
|
|
self._set_row_userdata(row, self.PLAYLISTROW_ID, plr.id)
|
|
|
|
if plr.track_id:
|
|
_ = self._set_row_userdata(row, self.ROW_TRACK_ID, plr.track_id)
|
|
_ = self._set_row_userdata(row, self.TRACK_PATH, plr.track.path)
|
|
_ = self._set_row_start_gap(row, plr.track.start_gap)
|
|
_ = self._set_row_title(row, plr.track.title)
|
|
_ = self._set_row_artist(row, plr.track.artist)
|
|
_ = self._set_row_duration(row, plr.track.duration)
|
|
_ = self._set_row_start_time(row, None)
|
|
_ = self._set_row_end_time(row, None)
|
|
_ = self._set_row_bitrate(row, plr.track.bitrate)
|
|
_ = self._set_row_note(session, row, plr.note)
|
|
_ = self._set_row_last_played(
|
|
row, Playdates.last_played(session, plr.track.id))
|
|
|
|
if not file_is_readable(plr.track.path):
|
|
self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE))
|
|
if not played:
|
|
self._set_row_bold(row)
|
|
|
|
else:
|
|
# This is a section header so it must have note text
|
|
if plr.note is None:
|
|
log.debug(
|
|
f"insert_row({plr=}) with no track_id and no note"
|
|
)
|
|
return
|
|
|
|
# In order to colour the row, we need items in every column.
|
|
# Bug in PyQt5 means that required height of row considers
|
|
# text to be wrapped in one column and ignores any spanned
|
|
# columns, hence putting notes in HEADER_NOTES_COLUMN which
|
|
# is typically reasonably wide and thus minimises
|
|
# unneccessary row height increases.
|
|
for i in range(1, len(columns)):
|
|
if i == HEADER_NOTES_COLUMN:
|
|
continue
|
|
self._set_item_text(row, i, None)
|
|
|
|
self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1)
|
|
_ = self._set_item_text(row, HEADER_NOTES_COLUMN, plr.note)
|
|
|
|
note_colour = NoteColours.get_colour(session, plr.note)
|
|
if not note_colour:
|
|
note_colour = Config.COLOUR_NOTES_PLAYLIST
|
|
self._set_row_colour(row, QColor(note_colour))
|
|
|
|
# Save (or clear) track_id
|
|
_ = self._set_row_userdata(row, self.ROW_TRACK_ID, 0)
|
|
|
|
if update_track_times:
|
|
# Queue time updates so playlist updates first
|
|
QTimer.singleShot(0, lambda: self._update_start_end_times())
|
|
|
|
def insert_track(self, session: scoped_session, track: Tracks,
|
|
note: Optional[str] = None, 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.
|
|
"""
|
|
|
|
if not track:
|
|
log.debug(
|
|
f"insert_track({session=}, {note=}, {repaint=}"
|
|
" called with no track"
|
|
)
|
|
return
|
|
|
|
row_number = self.get_new_row_number()
|
|
|
|
# Check to see whether track is already in playlist
|
|
existing_plr = PlaylistRows.get_track_plr(session, track.id,
|
|
self.playlist_id)
|
|
if existing_plr and ask_yes_no("Duplicate row",
|
|
"Track already in playlist. "
|
|
"Move to new location?"):
|
|
# Yes it is and we should reuse it
|
|
# If we've been passed a note, we need to add that to the
|
|
# existing track
|
|
if note:
|
|
existing_plr.append_note(note)
|
|
return self._move_row(session, existing_plr, row_number)
|
|
|
|
# Build playlist_row object
|
|
plr = PlaylistRows(session, self.playlist_id, track.id,
|
|
row_number, note)
|
|
self.insert_row(session, plr)
|
|
# Let display update, then save playlist
|
|
QTimer.singleShot(0, lambda: self.save_playlist(session))
|
|
|
|
def mark_unnext(self) -> None:
|
|
"""
|
|
Unmark passed row as next track
|
|
"""
|
|
|
|
row = self._get_next_track_row_number()
|
|
if not row:
|
|
return
|
|
self.musicmuster.clear_next()
|
|
self.clear_selection()
|
|
self._set_row_colour(row, None)
|
|
self.musicmuster.update_headers()
|
|
|
|
def play_started(self, session: scoped_session) -> None:
|
|
"""
|
|
Notification from musicmuster that track has started playing.
|
|
|
|
Actions required:
|
|
- Mark current row as played
|
|
- Set next track
|
|
- Display track as current
|
|
- Update start/stop times
|
|
"""
|
|
|
|
current_row = self._get_current_track_row_number()
|
|
if current_row is None:
|
|
send_mail(Config.ERRORS_TO,
|
|
Config.ERRORS_FROM,
|
|
"MusicMuster unexpected failure",
|
|
stackprinter.format()
|
|
)
|
|
return
|
|
|
|
# Mark current row as played
|
|
self._set_played_row(session, current_row)
|
|
|
|
# Set next track
|
|
next_row = self._find_next_track_row(session, current_row + 1)
|
|
if next_row:
|
|
self._set_next(session, next_row)
|
|
|
|
# Display row as current track
|
|
self._set_row_colour(current_row,
|
|
QColor(Config.COLOUR_CURRENT_PLAYLIST))
|
|
|
|
# Update start/stop times
|
|
self._update_start_end_times()
|
|
|
|
def populate_display(self, session: scoped_session, playlist_id: int,
|
|
scroll_to_top: bool = True) -> None:
|
|
"""
|
|
Populate display from the associated playlist ID
|
|
"""
|
|
|
|
# Sanity check row numbering before we load
|
|
PlaylistRows.fixup_rownumbers(session, playlist_id)
|
|
|
|
# Clear playlist
|
|
self.setRowCount(0)
|
|
|
|
# Get played tracks
|
|
played_rows = self._get_played_rows(session)
|
|
|
|
# Add the rows
|
|
playlist = session.get(Playlists, playlist_id)
|
|
if not playlist:
|
|
send_mail(Config.ERRORS_TO,
|
|
Config.ERRORS_FROM,
|
|
"MusicMuster unexpected failure",
|
|
stackprinter.format()
|
|
)
|
|
return
|
|
|
|
for plr in playlist.rows:
|
|
self.insert_row(session, plr, update_track_times=False,
|
|
played=plr.row_number in played_rows)
|
|
|
|
self._update_start_end_times()
|
|
|
|
# Scroll to top
|
|
if scroll_to_top:
|
|
row0_item = self.item(0, 0)
|
|
if row0_item:
|
|
self.scrollToItem(row0_item, QAbstractItemView.PositionAtTop)
|
|
|
|
# Set widths
|
|
self._set_column_widths(session)
|
|
|
|
# Needed to wrap notes column correctly - add to event queue so
|
|
# that it's processed after list is populated
|
|
QTimer.singleShot(0, self.tab_visible)
|
|
|
|
# Set track start/end times after track list is populated
|
|
QTimer.singleShot(0, self._update_start_end_times)
|
|
|
|
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 reset_plr_row_colour(self, plr_id: int) -> None:
|
|
"""Reset background of row pointed to by plr_id"""
|
|
|
|
row = self._plrid_to_row_number(plr_id)
|
|
if not row:
|
|
return
|
|
|
|
self._set_row_colour(row, None)
|
|
|
|
def save_playlist(self, session: scoped_session) -> None:
|
|
"""
|
|
Get the PlaylistRow objects for each row in the display. Correct
|
|
the row_number and playlist_id if necessary. Remove any row
|
|
numbers in the database that are higher than the last row in
|
|
the display.
|
|
"""
|
|
|
|
# Ensure all row plrs have correct row number and playlist_id
|
|
for row in range(self.rowCount()):
|
|
plr = self._get_playlistrow_object(session, row)
|
|
if not plr:
|
|
continue
|
|
plr.row_number = row
|
|
plr.playlist_id = self.playlist_id
|
|
|
|
# Any rows in the database for this playlist that has a row
|
|
# number equal to or greater than the row count needs to be
|
|
# removed.
|
|
session.flush()
|
|
PlaylistRows.delete_higher_rows(
|
|
session, self.playlist_id, self.rowCount() - 1)
|
|
|
|
def scroll_current_to_top(self) -> None:
|
|
"""Scroll currently-playing row to top"""
|
|
|
|
current_row = self._get_current_track_row_number()
|
|
if current_row is not None:
|
|
self._scroll_to_top(current_row)
|
|
|
|
def scroll_next_to_top(self) -> None:
|
|
"""Scroll nextly-playing row to top"""
|
|
|
|
next_row = self._get_next_track_row_number()
|
|
if next_row is not None:
|
|
self._scroll_to_top(next_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_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.
|
|
"""
|
|
|
|
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 = 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-headers
|
|
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.
|
|
"""
|
|
|
|
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 = 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_selected_as_next(self) -> None:
|
|
"""Sets the select track as next to play"""
|
|
|
|
row = self._get_selected_row()
|
|
if row is not None:
|
|
with Session() as session:
|
|
self._set_next(session, row)
|
|
|
|
def tab_visible(self) -> None:
|
|
"""Called when tab becomes visible"""
|
|
|
|
# Set row heights
|
|
self.resizeRowsToContents()
|
|
self.setColumnWidth(len(columns) - 1, 0)
|
|
|
|
# def update_display(self, session: scoped_session) -> None:
|
|
# """
|
|
# Set row colours, fonts, etc
|
|
|
|
# Actions 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
|
|
# """
|
|
|
|
# current_row = self._get_current_track_row_number()
|
|
# next_row = self._get_next_track_row_number()
|
|
# played = [
|
|
# p.row_number for p in PlaylistRows.get_played_rows(
|
|
# session, self.playlist_id)
|
|
# ]
|
|
|
|
# # 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))
|
|
# if not playlist_row:
|
|
# continue
|
|
# note_text = playlist_row.note
|
|
# note_colour = None
|
|
# if not note_text:
|
|
# note_text = ""
|
|
# # Get note colour
|
|
# else:
|
|
# note_colour = NoteColours.get_colour(session, note_text)
|
|
|
|
# # 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 not track:
|
|
# # We have a track_id but we can't find the track.
|
|
# # Update playlist_row accordingly
|
|
# missing_track = playlist_row.track_id
|
|
# playlist_row.track_id = None
|
|
# if note_text:
|
|
# note_text += f"track_id {missing_track} not found"
|
|
# else:
|
|
# note_text = f"track_id {missing_track} not found"
|
|
# playlist_row.note = note_text
|
|
# session.flush()
|
|
# _ = self._set_item_text(row, HEADER_NOTES_COLUMN,
|
|
# note_text)
|
|
|
|
# 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_colour:
|
|
# notes_item = self.item(row, ROW_NOTES)
|
|
# if notes_item:
|
|
# notes_item.setBackground(QColor(note_colour))
|
|
|
|
# # Highlight low bitrates
|
|
# if track.bitrate:
|
|
# bitrate_str = str(track.bitrate)
|
|
# bitrate_item = self._set_item_text(
|
|
# row, BITRATE, str(track.bitrate))
|
|
# if bitrate_item:
|
|
# 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))
|
|
# bitrate_item.setBackground(brush)
|
|
|
|
# # Render playing track
|
|
# if row == current_row:
|
|
# # Set last played time to "Today"
|
|
# self._set_item_text(
|
|
# row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING)
|
|
# # Calculate next_start_time
|
|
# # if track.duration:
|
|
# # next_start_time = self._calculate_end_time(
|
|
# # self.musicmuster.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. It may be on a different tab, so we get
|
|
# # start time from musicmuster.
|
|
# # start_time = self.musicmuster.current_track.end_time
|
|
# # if start_time is None:
|
|
# # # 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
|
|
# # if track.duration:
|
|
# # 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._set_item_text(
|
|
# row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING)
|
|
# if self.musicmuster.hide_played_tracks:
|
|
# self.hideRow(row)
|
|
# else:
|
|
# self.showRow(row)
|
|
# 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)
|
|
# # if track.duration:
|
|
# # 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
|
|
# if not note_colour:
|
|
# note_colour = Config.COLOUR_NOTES_PLAYLIST
|
|
# self._set_row_colour(row, QColor(note_colour))
|
|
# # Section headers are always bold
|
|
# self._set_row_bold(row)
|
|
# continue
|
|
|
|
# # Set row heights
|
|
# self.resizeRowsToContents()
|
|
|
|
# # 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)
|
|
# )
|
|
|
|
# # ########## 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))
|
|
if not plr:
|
|
return
|
|
|
|
# Don't add track if there's already a track there
|
|
if plr.track_id is not None:
|
|
return
|
|
|
|
plr.track_id = track.id
|
|
session.flush()
|
|
|
|
# Reset row span
|
|
for column in range(len(columns)):
|
|
self.setSpan(row, column, 1, 1)
|
|
|
|
# Update attributes of row
|
|
_ = self._set_row_userdata(row, self.ROW_TRACK_ID, plr.track_id)
|
|
_ = self._set_row_last_played(
|
|
row, Playdates.last_played(session, plr.track.id))
|
|
_ = self._set_row_note(session, row, plr.note)
|
|
_ = self._set_row_start_gap(row, plr.track.start_gap)
|
|
|
|
self._update_row(session, row, track)
|
|
|
|
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 _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
|
|
if idx == len(columns) - 1:
|
|
# Don't set width of last column as it's set to
|
|
# stretch
|
|
continue
|
|
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 and track.path:
|
|
# 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 _deferred_save(self) -> None:
|
|
"""
|
|
Create session and save playlist
|
|
"""
|
|
|
|
print("_deferred_save() called")
|
|
with Session() as session:
|
|
self.save_playlist(session)
|
|
|
|
def _delete_rows(self) -> None:
|
|
"""
|
|
Delete mutliple rows
|
|
|
|
Actions required:
|
|
- Remove the rows from the display
|
|
- Save the playlist
|
|
"""
|
|
|
|
with Session() as session:
|
|
plrs = self.get_selected_playlistrows(session)
|
|
row_count = len(plrs)
|
|
if not row_count:
|
|
return
|
|
|
|
# Get confirmation
|
|
plural = 's' if row_count > 1 else ''
|
|
if not ask_yes_no("Delete rows",
|
|
f"Really delete {row_count} row{plural}?"):
|
|
return
|
|
|
|
rows_to_delete = [a.row_number for a in plrs]
|
|
|
|
# Delete rows from database. Would be more efficient to
|
|
# query then have a single delete.
|
|
for plr in plrs:
|
|
session.delete(plr)
|
|
|
|
# Remove from display
|
|
self.remove_rows(rows_to_delete)
|
|
|
|
# Reset drag mode
|
|
self.setDragEnabled(False)
|
|
|
|
# QTimer.singleShot(0, lambda: self._deferred_save())
|
|
self.signals.save_playlist_signal.emit()
|
|
|
|
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_track_row(self, session: scoped_session,
|
|
starting_row: Optional[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
|
|
plr = self._get_playlistrow_object(session, row)
|
|
if not plr:
|
|
continue
|
|
if not file_is_readable(plr.track.path):
|
|
continue
|
|
else:
|
|
return row
|
|
|
|
return None
|
|
|
|
def _get_current_track_end_time(self) -> Optional[datetime]:
|
|
"""
|
|
Return current track end time or None if no current track
|
|
"""
|
|
|
|
current_track_row = self._get_current_track_row_number()
|
|
if current_track_row is None:
|
|
return None
|
|
|
|
return self.musicmuster.current_track.end_time
|
|
|
|
def _get_current_track_start_time(self) -> Optional[datetime]:
|
|
"""
|
|
Return current track start time or None if no current track
|
|
"""
|
|
|
|
current_track_row = self._get_current_track_row_number()
|
|
if current_track_row is None:
|
|
return None
|
|
|
|
return self.musicmuster.current_track.start_time
|
|
|
|
def _get_current_track_row_number(self) -> Optional[int]:
|
|
"""Return current track row or None"""
|
|
|
|
current_track = self.musicmuster.current_track
|
|
if not current_track or not current_track.plr_id:
|
|
return None
|
|
|
|
return self._plrid_to_row_number(current_track.plr_id)
|
|
|
|
def _get_next_track_row_number(self) -> Optional[int]:
|
|
"""Return next track row or None"""
|
|
|
|
next_track = self.musicmuster.next_track
|
|
if not next_track or not next_track.plr_id:
|
|
return None
|
|
|
|
return self._plrid_to_row_number(next_track.plr_id)
|
|
|
|
@staticmethod
|
|
def _get_note_text_time(text: str) -> Optional[datetime]:
|
|
"""Return time specified as @hh:mm:ss in text"""
|
|
|
|
try:
|
|
match = start_time_re.search(text)
|
|
except TypeError:
|
|
return None
|
|
if not match:
|
|
return None
|
|
|
|
try:
|
|
return datetime.strptime(match.group(0)[1:],
|
|
Config.NOTE_TIME_FORMAT)
|
|
except ValueError:
|
|
return None
|
|
|
|
def _get_played_rows(self, session: scoped_session) -> List[int]:
|
|
"""
|
|
Return a list of row numbers that have been played
|
|
"""
|
|
|
|
return [
|
|
p.row_number for p in PlaylistRows.get_played_rows(
|
|
session, self.playlist_id) if p.row_number is not None
|
|
]
|
|
|
|
def _get_playlistrow_id(self, row: int) -> Optional[int]:
|
|
"""Return the playlistrow_id associated with this row"""
|
|
|
|
plrid = self._get_row_userdata(row, self.PLAYLISTROW_ID)
|
|
if plrid is None:
|
|
return None
|
|
return int(plrid)
|
|
|
|
def _get_playlistrow_object(self, session: scoped_session,
|
|
row: int) -> Optional[PlaylistRows]:
|
|
"""Return the playlistrow object associated with this row"""
|
|
|
|
playlistrow_id = self._get_playlistrow_id(row)
|
|
if not playlistrow_id:
|
|
return None
|
|
|
|
return session.get(PlaylistRows, playlistrow_id)
|
|
|
|
def _get_row_artist(self, row: int) -> Optional[str]:
|
|
"""Return artist on this row or None if none"""
|
|
|
|
item_artist = self.item(row, ARTIST)
|
|
if not item_artist:
|
|
return None
|
|
|
|
return item_artist.text()
|
|
|
|
def _get_row_duration(self, row: int) -> int:
|
|
"""Return duration associated with this row"""
|
|
|
|
duration_userdata = self._get_row_userdata(row, self.ROW_DURATION)
|
|
if not duration_userdata:
|
|
return 0
|
|
else:
|
|
return int(duration_userdata)
|
|
|
|
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, ROW_NOTES)
|
|
else:
|
|
item_note = self.item(row, HEADER_NOTES_COLUMN)
|
|
if not item_note:
|
|
return None
|
|
|
|
return item_note.text()
|
|
|
|
def _get_row_path(self, row: int) -> Optional[str]:
|
|
"""
|
|
Return path of track associated with this row or None
|
|
"""
|
|
|
|
return str(self._get_row_userdata(row, self.TRACK_PATH))
|
|
|
|
def _get_row_start_time(self, row: int) -> Optional[datetime]:
|
|
"""Return row start time as string or None"""
|
|
|
|
start_time_item = self.item(row, START_TIME)
|
|
if not start_time_item:
|
|
return None
|
|
|
|
try:
|
|
return datetime.strptime(start_time_item.text(),
|
|
Config.NOTE_TIME_FORMAT)
|
|
except ValueError:
|
|
return None
|
|
|
|
def _get_row_title(self, row: int) -> Optional[str]:
|
|
"""Return title on this row or None if none"""
|
|
|
|
# Header rows may have note in TITLE row so check for track_id
|
|
if not self._get_row_track_id(row):
|
|
return None
|
|
|
|
item_title = self.item(row, TITLE)
|
|
if not item_title:
|
|
return None
|
|
|
|
return item_title.text()
|
|
|
|
def _get_row_track(self, session: scoped_session,
|
|
row: int) -> Optional[Tracks]:
|
|
"""Return the track associated with this row or None"""
|
|
|
|
track_id = self._get_row_track_id(row)
|
|
if track_id:
|
|
return session.get(Tracks, track_id)
|
|
else:
|
|
return None
|
|
|
|
def _get_row_track_id(self, row: int) -> int:
|
|
"""Return the track_id associated with this row or None"""
|
|
|
|
track_id = self._get_row_userdata(row, self.ROW_TRACK_ID)
|
|
if not track_id:
|
|
return 0
|
|
else:
|
|
return int(track_id)
|
|
|
|
def _get_row_userdata(self, row: int,
|
|
role: int) -> Optional[Union[str, int]]:
|
|
"""
|
|
Return the specified userdata, if any.
|
|
"""
|
|
|
|
userdata_item = self.item(row, USERDATA)
|
|
if not userdata_item:
|
|
return None
|
|
|
|
return userdata_item.data(role)
|
|
|
|
def _get_section_start_time(self, session: scoped_session,
|
|
row: int) -> Optional[datetime]:
|
|
"""
|
|
Parse section header for a start time.
|
|
Return None if:
|
|
- it's not a section header row or
|
|
- we can't parse a time from it
|
|
Otherwise return datetime from header.
|
|
"""
|
|
|
|
# If we have a track_id, we're not a section header
|
|
if self._get_row_track_id(row):
|
|
return None
|
|
|
|
# Check for start time in note. Extract note text from database
|
|
# to ignore section timings.
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
|
|
if plr and plr.note:
|
|
return self._get_note_text_time(plr.note)
|
|
else:
|
|
return None
|
|
|
|
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 sorted by row"""
|
|
|
|
# Use a set to deduplicate result (a selected row will have all
|
|
# items in that row selected)
|
|
return sorted(
|
|
[row for row in set([a.row() for a in self.selectedItems()])]
|
|
)
|
|
|
|
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 _mark_unplayed(self, plr: PlaylistRows) -> None:
|
|
"""
|
|
Mark passed row as unplayed in this playlist
|
|
"""
|
|
|
|
if not plr.row_number:
|
|
return
|
|
|
|
with Session() as session:
|
|
session.add(plr)
|
|
plr.played = False
|
|
self._set_row_colour(plr.row_number, None)
|
|
|
|
def _move_row(self, session: scoped_session, plr: PlaylistRows,
|
|
new_row_number: int) -> None:
|
|
"""Move playlist row to new_row_number using parent copy/paste"""
|
|
|
|
if plr.row_number is None:
|
|
return
|
|
|
|
# Remove source row
|
|
self.removeRow(plr.row_number)
|
|
# Fixup plr row number
|
|
if plr.row_number < new_row_number:
|
|
plr.row_number = new_row_number - 1
|
|
else:
|
|
plr.row_number = new_row_number
|
|
self.insert_row(session, plr)
|
|
self.save_playlist(session)
|
|
|
|
def _mplayer_play(self, track_id: int) -> None:
|
|
"""Play track with mplayer"""
|
|
|
|
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
|
|
|
|
if track.path is None:
|
|
log.error(
|
|
f"playlists._open_in_audacity({track_id=}): "
|
|
"Track has no path"
|
|
)
|
|
else:
|
|
open_in_audacity(track.path)
|
|
|
|
def _plrid_to_row_number(self, plrid: int) -> Optional[int]:
|
|
"""
|
|
Return row number of passed plrid, or None if not found
|
|
"""
|
|
|
|
for row_number in range(self.rowCount()):
|
|
if self._get_playlistrow_id(row_number) == plrid:
|
|
return row_number
|
|
|
|
return None
|
|
|
|
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))
|
|
if not plr:
|
|
return
|
|
|
|
plr.track_id = None
|
|
# We can't have null text
|
|
if not plr.note:
|
|
plr.note = Config.TEXT_NO_TRACK_NO_NOTE
|
|
session.flush()
|
|
|
|
# Clear track text items
|
|
for i in range(2, len(columns)):
|
|
_ = self._set_item_text(row, i, "")
|
|
# Remove row duration
|
|
self._set_row_duration(row, 0)
|
|
# Remote track_id from row
|
|
_ = self._set_row_userdata(row, self.ROW_TRACK_ID, 0)
|
|
# Span the rows
|
|
self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1)
|
|
# Set note text in correct column for section head
|
|
_ = self._set_item_text(row, HEADER_NOTES_COLUMN, plr.note)
|
|
|
|
note_colour = NoteColours.get_colour(session, plr.note)
|
|
if note_colour:
|
|
self._set_row_colour(row, QColor(note_colour))
|
|
|
|
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
|
|
|
|
set_track_metadata(session, track)
|
|
# TODO: set readable/unreadable
|
|
self._update_row(session, row, track)
|
|
|
|
def _run_subprocess(self, args):
|
|
"""Run args in subprocess"""
|
|
|
|
subprocess.call(args)
|
|
|
|
def _scroll_to_top(self, row: int) -> None:
|
|
"""
|
|
Scroll to put passed row Config.SCROLL_TOP_MARGIN from the
|
|
top.
|
|
"""
|
|
|
|
if row is None:
|
|
return
|
|
|
|
padding_required = Config.SCROLL_TOP_MARGIN
|
|
top_row = row
|
|
|
|
if row > Config.SCROLL_TOP_MARGIN:
|
|
# We can't scroll to a hidden row. Calculate target_row as the
|
|
# one that is ideal to be at the top. Then count upwards from
|
|
# passed row until we either reach the target, pass it or reach
|
|
# row 0.
|
|
# target_row = max(0, row - Config.SCROLL_TOP_MARGIN + 1)
|
|
for i in range(row - 1, -1, -1):
|
|
if self.isRowHidden(i):
|
|
continue
|
|
if padding_required == 0:
|
|
break
|
|
top_row = i
|
|
padding_required -= 1
|
|
|
|
scroll_item = self.item(top_row, 0)
|
|
self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop)
|
|
|
|
def _search(self, next: bool = True) -> None:
|
|
"""
|
|
Select next/previous row containg self.search_string. Start from
|
|
top selected row if there is one, else from top.
|
|
|
|
Wrap at last/first row.
|
|
"""
|
|
|
|
if not self.search_text:
|
|
return
|
|
|
|
selected_row = self._get_selected_row()
|
|
if next:
|
|
if selected_row is not None and selected_row < self.rowCount() - 1:
|
|
starting_row = selected_row + 1
|
|
else:
|
|
starting_row = 0
|
|
else:
|
|
if selected_row is not None and selected_row > 0:
|
|
starting_row = selected_row - 1
|
|
else:
|
|
starting_row = self.rowCount() - 1
|
|
|
|
wrapped = False
|
|
match_row = None
|
|
row = starting_row
|
|
needle = self.search_text.lower()
|
|
while True:
|
|
# Check for match in title, artist or notes
|
|
title = self._get_row_title(row)
|
|
if title and needle in title.lower():
|
|
match_row = row
|
|
break
|
|
artist = self._get_row_artist(row)
|
|
if artist and needle in artist.lower():
|
|
match_row = row
|
|
break
|
|
note = self._get_row_note(row)
|
|
if note and needle in note.lower():
|
|
match_row = row
|
|
break
|
|
if next:
|
|
row += 1
|
|
if wrapped and row >= starting_row:
|
|
break
|
|
if row >= self.rowCount():
|
|
row = 0
|
|
wrapped = True
|
|
else:
|
|
row -= 1
|
|
if wrapped and row <= starting_row:
|
|
break
|
|
if row < 0:
|
|
row = self.rowCount() - 1
|
|
wrapped = True
|
|
|
|
if match_row is not None:
|
|
self.selectRow(row)
|
|
|
|
def _select_event(self) -> None:
|
|
"""
|
|
Called when item selection changes.
|
|
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
|
|
|
|
# If only one row is selected and it's a track row, show
|
|
# Wikipedia page for that track
|
|
if len(selected_rows) == 1:
|
|
self._wikipedia(selected_rows[0])
|
|
|
|
ms = 0
|
|
for row in selected_rows:
|
|
ms += self._get_row_duration(row)
|
|
|
|
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: scoped_session) -> None:
|
|
"""Column widths from settings"""
|
|
|
|
for column_name, data in columns.items():
|
|
idx = data.idx
|
|
if idx == len(columns) - 1:
|
|
# Set width of last column to zero as it's set to stretch
|
|
self.setColumnWidth(idx, 0)
|
|
continue
|
|
attr_name = f"playlist_{column_name}_col_width"
|
|
record: Settings = Settings.get_int_settings(session, attr_name)
|
|
if record and record.f_int >= 0:
|
|
self.setColumnWidth(idx, record.f_int)
|
|
else:
|
|
self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH)
|
|
|
|
def _set_item_text(self, row: int, column: int,
|
|
text: Optional[str]) -> QTableWidgetItem:
|
|
"""
|
|
Set text for item if it exists, else create it, and return item
|
|
"""
|
|
|
|
if not text:
|
|
text = ""
|
|
|
|
item = self.item(row, column)
|
|
if not item:
|
|
item = QTableWidgetItem(text)
|
|
self.setItem(row, column, item)
|
|
else:
|
|
item.setText(text)
|
|
|
|
return item
|
|
|
|
def _set_next(self, session: scoped_session, row_number: int) -> None:
|
|
"""
|
|
Set passed row as next playlist row to play.
|
|
|
|
Actions required:
|
|
- Check row has a track
|
|
- Check track is readable
|
|
- Notify musicmuster
|
|
- Display row as next track
|
|
- Update start/stop times
|
|
"""
|
|
|
|
# Check row has a track
|
|
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):
|
|
return None
|
|
|
|
# Clear any existing next track
|
|
next_track_row = self._get_next_track_row_number()
|
|
if next_track_row:
|
|
self._set_row_colour(next_track_row, None)
|
|
|
|
# Notify musicmuster
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row_number))
|
|
if not plr:
|
|
log.debug(f"playists._set_next({row_number=}) can't retrieve plr")
|
|
else:
|
|
self.musicmuster.this_is_the_next_playlist_row(session, plr, self)
|
|
|
|
# Display row as next track
|
|
self._set_row_colour(row_number, QColor(Config.COLOUR_NEXT_PLAYLIST))
|
|
|
|
# Update start/stop times
|
|
self.clear_selection()
|
|
self._update_start_end_times()
|
|
|
|
def _set_played_row(self, session: scoped_session, row: int) -> None:
|
|
"""Mark this row as played"""
|
|
|
|
plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
|
|
if not plr:
|
|
return
|
|
|
|
plr.played = True
|
|
session.flush()
|
|
|
|
def _set_row_artist(self, row: int,
|
|
artist: Optional[str]) -> QTableWidgetItem:
|
|
"""
|
|
Set row artist.
|
|
|
|
Return QTableWidgetItem.
|
|
"""
|
|
|
|
if not artist:
|
|
artist = ""
|
|
|
|
return self._set_item_text(row, ARTIST, artist)
|
|
|
|
def _set_row_bitrate(self, row: int,
|
|
bitrate: Optional[int]) -> QTableWidgetItem:
|
|
"""Set bitrate of this row."""
|
|
|
|
if not bitrate:
|
|
bitrate_str = ""
|
|
# If no bitrate, flag it as too low
|
|
bitrate = Config.BITRATE_LOW_THRESHOLD - 1
|
|
else:
|
|
bitrate_str = str(bitrate)
|
|
bitrate_item = self._set_item_text(row, BITRATE, bitrate_str)
|
|
|
|
if bitrate < Config.BITRATE_LOW_THRESHOLD:
|
|
cell_colour = Config.COLOUR_BITRATE_LOW
|
|
elif bitrate < Config.BITRATE_OK_THRESHOLD:
|
|
cell_colour = Config.COLOUR_BITRATE_MEDIUM
|
|
else:
|
|
cell_colour = Config.COLOUR_BITRATE_OK
|
|
brush = QBrush(QColor(cell_colour))
|
|
bitrate_item.setBackground(brush)
|
|
|
|
return bitrate_item
|
|
|
|
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 == ROW_NOTES:
|
|
continue
|
|
item = self.item(row, column)
|
|
if item:
|
|
item.setFont(boldfont)
|
|
|
|
def _set_row_colour(self, row: int,
|
|
colour: Optional[QColor] = None) -> None:
|
|
"""
|
|
Set or reset row background colour
|
|
"""
|
|
|
|
if colour:
|
|
brush = QBrush(colour)
|
|
else:
|
|
brush = QBrush()
|
|
|
|
for column in range(1, self.columnCount()):
|
|
# Don't change colour on start gap columns
|
|
if column == START_GAP:
|
|
continue
|
|
item = self.item(row, column)
|
|
if item:
|
|
item.setBackground(brush)
|
|
|
|
def _set_row_duration(self, row: int,
|
|
ms: Optional[int]) -> QTableWidgetItem:
|
|
"""Set duration of this row. Also set in row metadata"""
|
|
|
|
duration_item = self._set_item_text(row, DURATION, ms_to_mmss(ms))
|
|
self._set_row_userdata(row, self.ROW_DURATION, ms)
|
|
|
|
return duration_item
|
|
|
|
def _set_row_end_time(self, row: int,
|
|
time: Optional[datetime]) -> QTableWidgetItem:
|
|
"""Set row end time"""
|
|
|
|
if not time:
|
|
time_str = ""
|
|
else:
|
|
try:
|
|
time_str = time.strftime(Config.TRACK_TIME_FORMAT)
|
|
except AttributeError:
|
|
time_str = ""
|
|
|
|
return self._set_item_text(row, END_TIME, time_str)
|
|
|
|
def _set_row_last_played(self, row: int, last_played: Optional[datetime]) \
|
|
-> QTableWidgetItem:
|
|
"""Set row last played time"""
|
|
|
|
last_played_str = get_relative_date(last_played)
|
|
|
|
return self._set_item_text(row, LASTPLAYED, last_played_str)
|
|
|
|
def _set_row_not_bold(self, row: int) -> None:
|
|
"""Set row to not be bold"""
|
|
|
|
self._set_row_bold(row, False)
|
|
|
|
def _set_row_note(self, session: scoped_session, row: int,
|
|
note_text: Optional[str]) -> QTableWidgetItem:
|
|
"""Set row note"""
|
|
|
|
if not note_text:
|
|
note_text = ""
|
|
notes_item = self._set_item_text(row, ROW_NOTES, note_text)
|
|
|
|
note_colour = NoteColours.get_colour(session, note_text)
|
|
if note_colour:
|
|
notes_item.setBackground(QColor(note_colour))
|
|
|
|
return notes_item
|
|
|
|
def _set_row_start_gap(self, row: int,
|
|
start_gap: Optional[int]) -> QTableWidgetItem:
|
|
"""
|
|
Set start gap on row, set backgroud colour.
|
|
|
|
Return QTableWidgetItem.
|
|
"""
|
|
|
|
if not start_gap:
|
|
start_gap = 0
|
|
start_gap_item = self._set_item_text(row, START_GAP, str(start_gap))
|
|
if start_gap >= 500:
|
|
start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START))
|
|
else:
|
|
start_gap_item.setBackground(QColor("white"))
|
|
|
|
return start_gap_item
|
|
|
|
def _set_row_start_time(self, row: int,
|
|
time: Optional[datetime]) -> QTableWidgetItem:
|
|
"""Set row start time"""
|
|
|
|
if not time:
|
|
time_str = ""
|
|
else:
|
|
try:
|
|
time_str = time.strftime(Config.TRACK_TIME_FORMAT)
|
|
except AttributeError:
|
|
time_str = ""
|
|
|
|
return self._set_item_text(row, START_TIME, time_str)
|
|
|
|
def _set_row_times(self, row: int, start: datetime,
|
|
duration: int) -> datetime:
|
|
"""
|
|
Set row start and end times, return end time
|
|
"""
|
|
|
|
self._set_row_start_time(row, start)
|
|
end_time = self._calculate_end_time(start, duration)
|
|
self._set_row_end_time(row, end_time)
|
|
|
|
return end_time
|
|
|
|
def _set_row_title(self, row: int,
|
|
title: Optional[str]) -> QTableWidgetItem:
|
|
"""
|
|
Set row title.
|
|
|
|
Return QTableWidgetItem.
|
|
"""
|
|
|
|
if not title:
|
|
title = ""
|
|
|
|
return self._set_item_text(row, TITLE, title)
|
|
|
|
def _set_row_userdata(self, row: int, role: int,
|
|
value: Optional[Union[str, int]]) \
|
|
-> QTableWidgetItem:
|
|
"""
|
|
Set passed userdata in USERDATA column
|
|
"""
|
|
|
|
item = self.item(row, USERDATA)
|
|
if not item:
|
|
item = QTableWidgetItem()
|
|
self.setItem(row, USERDATA, item)
|
|
|
|
item.setData(role, value)
|
|
|
|
return item
|
|
|
|
def _get_section_timing_string(self, ms: int,
|
|
no_end: bool = False) -> str:
|
|
"""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, session: scoped_session,
|
|
playlist_row: PlaylistRows,
|
|
additional_text: str) -> None:
|
|
"""Append additional_text to row display"""
|
|
|
|
if not playlist_row.row_number:
|
|
return
|
|
|
|
# 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 = ROW_NOTES
|
|
else:
|
|
column = HEADER_NOTES_COLUMN
|
|
|
|
# Update text
|
|
if playlist_row.note:
|
|
new_text = playlist_row.note + additional_text
|
|
else:
|
|
new_text = additional_text
|
|
|
|
_ = self._set_row_note(session, playlist_row.row_number, new_text)
|
|
|
|
def _update_row(self, session, row: int, track: Tracks) -> None:
|
|
"""
|
|
Update the passed row with info from the passed track.
|
|
"""
|
|
|
|
_ = self._set_row_start_gap(row, track.start_gap)
|
|
_ = self._set_row_title(row, track.title)
|
|
_ = self._set_row_artist(row, track.artist)
|
|
_ = self._set_row_duration(row, track.duration)
|
|
_ = self._set_row_bitrate(row, track.bitrate)
|
|
|
|
self.update_display(session)
|
|
|
|
def _update_start_end_times(self) -> None:
|
|
""" Update track start and end times """
|
|
|
|
with Session() as session:
|
|
current_track_end_time = self._get_current_track_end_time()
|
|
current_track_row = self._get_current_track_row_number()
|
|
current_track_start_time = self._get_current_track_start_time()
|
|
next_start_time = None
|
|
next_track_row = self._get_next_track_row_number()
|
|
played_rows = self._get_played_rows(session)
|
|
|
|
for row in range(self.rowCount()):
|
|
# Don't change start times for tracks that have been
|
|
# played other than current/next row
|
|
if row in played_rows and row not in [
|
|
current_track_row, next_track_row]:
|
|
continue
|
|
|
|
# Get any timing from header row (that's all we need)
|
|
if self._get_row_track_id(row) == 0:
|
|
note_time = self._get_section_start_time(session, row)
|
|
if note_time:
|
|
next_start_time = note_time
|
|
continue
|
|
|
|
# We have a track. Skip if it is unreadable
|
|
if not file_is_readable(self._get_row_path(row)):
|
|
continue
|
|
|
|
# Set next track start from end of current track
|
|
if row == next_track_row:
|
|
if current_track_end_time:
|
|
next_start_time = self._set_row_times(
|
|
row, current_track_end_time,
|
|
self._get_row_duration(row))
|
|
continue
|
|
# Else set track times below
|
|
|
|
if row == current_track_row:
|
|
if not current_track_start_time:
|
|
continue
|
|
self._set_row_start_time(row, current_track_start_time)
|
|
self._set_row_end_time(row, current_track_end_time)
|
|
# Next track may be above us so only reset
|
|
# next_start_time if it's not set
|
|
if not next_start_time:
|
|
next_start_time = current_track_end_time
|
|
continue
|
|
|
|
if not next_start_time:
|
|
# Clear any existing times
|
|
self._set_row_start_time(row, None)
|
|
self._set_row_end_time(row, None)
|
|
continue
|
|
|
|
# If we're between the current and next row, zero out
|
|
# times
|
|
if (current_track_row and next_track_row and
|
|
current_track_row < row < next_track_row):
|
|
self._set_row_start_time(row, None)
|
|
self._set_row_end_time(row, None)
|
|
else:
|
|
next_start_time = self._set_row_times(
|
|
row, next_start_time, self._get_row_duration(row))
|
|
|
|
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)
|
|
if not title:
|
|
return
|
|
|
|
self.musicmuster.tabInfolist.open_in_wikipedia(title)
|