musicmuster/app/playlists.py
2022-03-20 22:40:38 +00:00

1594 lines
54 KiB
Python

from typing import Dict, List, Optional, Set, Tuple, Union
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from PyQt5.Qt import QFont
from PyQt5.QtGui import QColor, QDropEvent
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import (
QAbstractItemView,
QApplication,
QInputDialog,
QMainWindow,
QMenu,
QMessageBox,
QTableWidget,
QTableWidgetItem,
)
import helpers
import os
import re
from config import Config
from datetime import datetime, timedelta
from helpers import get_relative_date, open_in_audacity
from log import DEBUG, ERROR
from models import (
Notes,
Playdates,
Playlists,
Settings,
Tracks,
NoteColours
)
from dbconfig import Session
start_time_re = re.compile(r"@\d\d:\d\d:\d\d")
class RowMeta:
CLEAR = 0
NOTE = 1
UNREADABLE = 2
NEXT = 3
CURRENT = 4
PLAYED = 5
class PlaylistTab(QTableWidget):
cellEditingStarted = QtCore.pyqtSignal(int, int)
cellEditingEnded = QtCore.pyqtSignal()
# Column names
COL_AUTOPLAY = COL_USERDATA = 0
COL_MSS = 1
COL_NOTE = 2
COL_TITLE = 2
COL_ARTIST = 3
COL_DURATION = 4
COL_START_TIME = 5
COL_END_TIME = 6
COL_LAST_PLAYED = COL_LAST = 7
NOTE_COL_SPAN = COL_LAST - COL_NOTE + 1
NOTE_ROW_SPAN = 1
# Qt.UserRoles
ROW_METADATA = Qt.UserRole
CONTENT_OBJECT = Qt.UserRole + 1
def __init__(self, musicmuster: QMainWindow, session: Session,
playlist_id: int, *args, **kwargs):
super().__init__(*args, **kwargs)
self.musicmuster: QMainWindow = musicmuster
self.playlist_id: int = playlist_id
self.menu: Optional[QMenu] = None
self.current_track_start_time: Optional[datetime] = None
# Set up widget
self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.setAlternatingRowColors(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setRowCount(0)
self.setColumnCount(8)
# Add header row
item: QTableWidgetItem = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(0, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(1, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(2, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(3, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(4, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(5, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(6, item)
item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(7, item)
self.horizontalHeader().setMinimumSectionSize(0)
self._set_column_widths(session)
self.setHorizontalHeaderLabels([
Config.COLUMN_NAME_AUTOPLAY,
Config.COLUMN_NAME_LEADING_SILENCE,
Config.COLUMN_NAME_TITLE,
Config.COLUMN_NAME_ARTIST,
Config.COLUMN_NAME_LENGTH,
Config.COLUMN_NAME_START_TIME,
Config.COLUMN_NAME_END_TIME,
Config.COLUMN_NAME_LAST_PLAYED,
])
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True)
self.setDragDropOverwriteMode(False)
self.setDropIndicatorShown(True)
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setDragDropMode(QAbstractItemView.InternalMove)
# This property defines how the widget shows a context menu
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
# This signal is emitted when the widget's contextMenuPolicy is
# Qt::CustomContextMenu, and the user has requested a context
# menu on the widget.
self.customContextMenuRequested.connect(self._context_menu)
self.viewport().installEventFilter(self)
self.itemSelectionChanged.connect(self._select_event)
self.editing_cell: bool = False
self.cellChanged.connect(self._cell_changed)
self.doubleClicked.connect(self._edit_cell)
self.cellEditingStarted.connect(self._cell_edit_started)
self.cellEditingEnded.connect(self._cell_edit_ended)
# Now load our tracks and notes
self.populate(session, self.playlist_id)
def __repr__(self) -> str:
return (f"<PlaylistTab(id={self.playlist_id}")
# ########## Events ##########
def dropEvent(self, event: QDropEvent) -> None:
# if not event.isAccepted() and event.source() == self:
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:
row = 0 # So row is defined even if there are no rows in range
for row in range(drop_row, drop_row + len(rows_to_move)):
if row in self._get_notes_rows():
self.setSpan(
row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN)
# Scroll to drop zone
self.scrollToItem(self.item(row, 1))
super().dropEvent(event)
DEBUG(
"playlist.dropEvent(): "
f"Moved row(s) {rows} to become row {drop_row}"
)
with Session() as session: # checked
self.save_playlist(session)
self.update_display(session)
def edit(self, index, trigger, event): # review
result = super(PlaylistTab, self).edit(index, trigger, event)
if result:
self.cellEditingStarted.emit(index.row(), index.column())
return result
def closeEditor(self, editor, hint): # review
super(PlaylistTab, self).closeEditor(editor, hint)
self.cellEditingEnded.emit()
def eventFilter(self, source, event): # review
"""Used to process context (right-click) menu, which is defined here"""
if (event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504
event.buttons() == QtCore.Qt.RightButton and # noqa W504
source is self.viewport()):
item = self.itemAt(event.pos())
if item is not None:
row = item.row()
DEBUG(f"playlist.eventFilter(): Right-click on row {row}")
current = row == self._get_current_track_row()
next_row = row == self._get_next_track_row()
self.menu = QMenu(self)
act_info = self.menu.addAction('Info')
act_info.triggered.connect(lambda: self._info_row(row))
self.menu.addSeparator()
if row not in self._get_notes_rows():
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(row, session))
act_copypath = self.menu.addAction("Copy track path")
act_copypath.triggered.connect(
lambda: self._copy_path(row))
if not current:
act_rescan = self.menu.addAction("Rescan track")
act_rescan.triggered.connect(lambda: self._rescan(row))
act_audacity = self.menu.addAction(
"Open track in Audacity")
act_audacity.triggered.connect(
lambda: self._audacity(row))
if not current and not next_row:
self.menu.addSeparator()
act_delete = self.menu.addAction('Delete')
act_delete.triggered.connect(self._delete_rows)
return super(PlaylistTab, self).eventFilter(source, event)
# ########## Externally called functions ##########
def closeEvent(self, event) -> None:
"""Save column widths"""
with Session() as session:
for column in range(self.columnCount()):
width = self.columnWidth(column)
name = f"playlist_col_{str(column)}_width"
record = Settings.get_int_settings(session, name)
if record.f_int != self.columnWidth(column):
record.update(session, {'f_int': width})
# Record playlist as closed
playlist = Playlists.get_by_id(session, self.playlist_id)
playlist.close(session)
event.accept()
def clear_next(self, session) -> None:
"""Clear next track"""
self._meta_clear_next()
self.update_display(session)
def create_note(self) -> None:
"""
Create note
If a row is selected, set note row to be row above. Otherwise,
set note row to be end of playlist.
"""
row: Optional[int] = self.get_selected_row()
if not row:
row = self.rowCount()
# Get note text
dlg: QInputDialog = QInputDialog(self)
dlg.setInputMode(QInputDialog.TextInput)
dlg.setLabelText("Note:")
dlg.resize(500, 100)
ok: int = dlg.exec()
if ok:
with Session() as session:
note: Notes = Notes(
session, self.playlist_id, row, dlg.textValue())
self._insert_note(session, note, row, True) # checked
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"""
rows = self.selectionModel().selectedRows()
return [row.row() for row in rows]
def get_selected_title(self) -> Optional[str]:
"""Return title of selected row or None"""
if self.selectionModel().hasSelection():
row = self.currentRow()
return self.item(row, self.COL_TITLE).text()
else:
return None
def 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.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(
f"playlists.insert_track({session=}, {track=}, {repaint=}), "
f"{row=}"
)
self.insertRow(row)
# Put an item in COL_USERDATA for later
item: QTableWidgetItem = QTableWidgetItem()
# Add row metadata
item.setData(self.ROW_METADATA, 0)
self.setItem(row, self.COL_USERDATA, item)
# Add track details to columns
mss_item: QTableWidgetItem = QTableWidgetItem(str(track.start_gap))
if track.start_gap and track.start_gap >= 500:
mss_item.setBackground(QColor(Config.COLOUR_LONG_START))
self.setItem(row, self.COL_MSS, mss_item)
title_item: QTableWidgetItem = QTableWidgetItem(track.title)
self.setItem(row, self.COL_TITLE, title_item)
artist_item: QTableWidgetItem = QTableWidgetItem(track.artist)
self.setItem(row, self.COL_ARTIST, artist_item)
duration_item: QTableWidgetItem = QTableWidgetItem(
helpers.ms_to_mmss(track.duration)
)
self.setItem(row, self.COL_DURATION, duration_item)
last_playtime: Optional[datetime] = Playdates.last_played(
session, track.id)
last_played_str: str = get_relative_date(last_playtime)
last_played_item: QTableWidgetItem = QTableWidgetItem(last_played_str)
self.setItem(row, self.COL_LAST_PLAYED, last_played_item)
# Add empty start and stop time because background
# colour won't be set for columns without items
start_item: QTableWidgetItem = QTableWidgetItem()
self.setItem(row, self.COL_START_TIME, start_item)
stop_item: QTableWidgetItem = QTableWidgetItem()
self.setItem(row, self.COL_END_TIME, stop_item)
# Attach track.id object to row
self._set_row_content(row, track.id)
# Mark track if file is unreadable
if not self._file_is_readable(track.path):
self._set_unreadable_row(row)
# Scroll to new row
self.scrollToItem(title_item, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(session, clear_selection=False)
def remove_rows(self, rows) -> None:
"""Remove rows passed in rows list"""
# Row number will change as we delete rows so remove them in
# reverse order.
for row in sorted(rows, reverse=True):
self.removeRow(row)
with Session() as session:
self.save_playlist(session)
self.update_display(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 current 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 not current_row:
return
self._set_current_track_row(current_row)
# Mark current row as played
self._set_played_row(current_row)
# Scroll to put current track as requiredin middle We want this
# row to be 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
top_row = max(0, current_row - Config.SCROLL_TOP_MARGIN + 1)
scroll_item = self.item(top_row, self.COL_MSS)
self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop)
# Set next track
search_from = current_row + 1
next_row = self._find_next_track_row(search_from)
if next_row:
self._set_next(next_row, session)
# 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
- Update display
"""
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
We don't mandate that an item will be on its specified row, only
that it will be above larger-numbered row items, and below
lower-numbered ones.
"""
data: List[Union[Tuple[List[int], Tracks], Tuple[List[int], Notes]]] \
= []
item: Union[Notes, Tracks]
note: Notes
row: int
track: Tracks
playlist = Playlists.get_by_id(session, playlist_id)
for row, track in playlist.tracks.items():
data.append(([row], track))
for note in playlist.notes:
data.append(([note.row], note))
# Clear playlist
self.setRowCount(0)
# Now add data in row order
for i in sorted(data, key=lambda x: x[0]):
item = i[1]
if isinstance(item, Tracks):
self.insert_track(session, item, repaint=False)
elif isinstance(item, Notes):
self._insert_note(session, item, repaint=False)
# Scroll to top
scroll_to: 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 save_playlist(self, session) -> None:
"""
Save playlist to database.
For notes: check the database entry is correct and update it if
necessary. Playlists:Note is one:many, so each note may only appear
in one playlist.
For tracks: erase the playlist tracks and recreate. This is much
simpler than trying to implement any Playlists:Tracks many:many
changes.
"""
playlist = Playlists.get_by_id(session, self.playlist_id)
# Notes first
# Create dictionaries indexed by note_id
playlist_notes: Dict[int, Notes] = {}
database_notes: Dict[int, Notes] = {}
notes_rows: List[int] = self._get_notes_rows()
# PlaylistTab
for row in notes_rows:
note: Notes = self._get_row_notes_object(row, session)
session.add(note)
playlist_notes[note.id] = note
# Database
for note in playlist.notes:
database_notes[note.id] = note
# We don't need to check for notes to add to the database as
# they can't exist in the playlist without being in the database
# and pointing at this playlist.
# Notes to remove from database
for note_id in set(database_notes.keys()) - set(playlist_notes.keys()):
DEBUG(
"_save_playlist(): "
f"Delete {note_id=} from {self=} in database"
)
database_notes[note_id].delete_note(session)
# Note rows to update in playlist database
for note_id in set(playlist_notes.keys()) & set(database_notes.keys()):
if playlist_notes[note_id].row != database_notes[note_id].row:
DEBUG(
f"_save_playlist(): Update notes row in database "
f"from {database_notes[note_id]=} "
f"to {playlist_notes[note_id]=}"
)
database_notes[note_id].update_note(
session, row=playlist_notes[note_id].row)
# Tracks
# Remove all tracks from this playlist
playlist.remove_all_tracks(session)
# Iterate on-screen playlist and add tracks back in
for row in range(self.rowCount()):
if row in notes_rows:
continue
track_id: int = self.item(
row, self.COL_USERDATA).data(self.CONTENT_OBJECT)
playlist.add_track(session, track_id, row)
def select_next_row(self) -> None:
"""
Select next or first row. Don't select notes. Wrap at last row.
"""
row: int
selected_rows: List[int]
selected_rows = [row for row in
set([a.row() for a in self.selectedItems()])]
# we will only handle zero or one selected rows
if len(selected_rows) > 1:
return
# select first row if none selected
if len(selected_rows) == 0:
row = 0
else:
row = selected_rows[0] + 1
if row >= self.rowCount():
row = 0
# Don't select notes
wrapped: bool = False
while row in self._get_notes_rows():
row += 1
if row >= self.rowCount():
if wrapped:
# we're already wrapped once, so there are no
# non-notes
return
row = 0
wrapped = True
self.selectRow(row)
def select_played_tracks(self) -> None:
"""Select all played tracks in playlist"""
self._select_tracks(played=True)
def select_previous_row(self) -> None:
"""
Select previous or last track. Don't select notes. Wrap at first row.
"""
row: int
selected_rows: List[int]
selected_rows = [row for row in
set([a.row() for a in self.selectedItems()])]
# we will only handle zero or one selected rows
if len(selected_rows) > 1:
return
# select last row if none selected
last_row: int = self.rowCount() - 1
if len(selected_rows) == 0:
row = last_row
else:
row = selected_rows[0] - 1
if row < 0:
row = last_row
# Don't select notes
wrapped: bool = False
while row in self._get_notes_rows():
row -= 1
if row < 0:
if wrapped:
# we're already wrapped once, so there are no
# non-notes
return
row = last_row
wrapped = True
self.selectRow(row)
def select_unplayed_tracks(self) -> None:
"""Select all unplayed tracks in playlist"""
self._select_tracks(played=False)
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(row, session)
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.clearSelection()
current_row: Optional[int] = self._get_current_track_row()
next_row: Optional[int] = self._get_next_track_row()
notes: List[int] = self._get_notes_rows()
played: Optional[List[int]] = self._get_played_track_rows()
unreadable: List[int] = self._get_unreadable_track_rows()
last_played_str: str
last_playedtime: Optional[datetime]
next_start_time: Optional[datetime] = None
note_colour: str
note_start_time: Optional[str]
note_text: str
row: int
row_time: Optional[datetime]
section_start_row: Optional[int] = None
section_time: int = 0
start_time: Optional[datetime]
start_times_row: Optional[int]
track: Optional[Tracks]
# 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.
if current_row and next_row:
start_times_row = min(current_row, next_row)
else:
start_times_row = current_row or next_row
if not start_times_row:
start_times_row = 0
# Cycle through all rows
for row in range(self.rowCount()):
# Render notes in correct colour
if row in notes:
# Extract note text from database to ignore section timings
note_text = self._get_row_notes_object(row, session).note
# 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_row is not None:
if note_text.endswith("-"):
self._set_timed_section(session, section_start_row,
section_time)
section_start_row = None
section_time = 0
elif note_text.endswith("+"):
section_start_row = row
section_time = 0
# Set colour
note_colour = NoteColours.get_colour(session, note_text)
if not note_colour:
note_colour = Config.COLOUR_NOTES_PLAYLIST
self._set_row_colour(
row, QColor(note_colour)
)
# Notes are always bold
self._set_row_bold(row)
continue
# Render unplayable tracks in correct colour
if row in unreadable:
self._set_row_colour(
row, QColor(Config.COLOUR_UNREADABLE)
)
self._set_row_bold(row)
continue
# Current row is a track row
track = self._get_row_track_object(row, session)
# Add track time to section time if in timed section
if section_start_row is not None:
section_time += track.duration
# Render current track
if row == current_row:
# Set start time
self._set_row_start_time(
row, self.current_track_start_time)
# Set last played time
last_played_str = get_relative_date(
self.current_track_start_time)
self.item(row, self.COL_LAST_PLAYED).setText(
last_played_str)
# Calculate next_start_time
next_start_time = self._calculate_track_end_time(
track, self.current_track_start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
# Set colour
self._set_row_colour(row, QColor(
Config.COLOUR_CURRENT_PLAYLIST))
# Make bold
self._set_row_bold(row)
continue
# Render next track
if row == next_row:
# if there's a track playing, set start time from that
if current_row:
start_time = self.current_track_start_time
else:
# No current track to base from, but don't change
# time if it's already set
start_time = self._get_row_start_time(row)
if not start_time:
start_time = next_start_time
self._set_row_start_time(row, start_time)
# Set end time
next_start_time = self._calculate_track_end_time(
track, start_time)
self._set_row_end_time(row, next_start_time)
# Set colour
self._set_row_colour(
row, QColor(Config.COLOUR_NEXT_PLAYLIST))
# Make bold
self._set_row_bold(row)
else:
# This is a track row other than next or current
if row in played:
# Played today, so update last played column
last_playedtime = Playdates.last_played(
session, track.id)
last_played_str = get_relative_date(last_playedtime)
self.item(row, self.COL_LAST_PLAYED).setText(
last_played_str)
self._set_row_not_bold(row)
else:
# Set start/end times as we haven't played it yet
if next_start_time and row >= start_times_row:
self._set_row_start_time(row, next_start_time)
next_start_time = self._calculate_track_end_time(
track, next_start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
else:
# Clear start and end time
self._set_row_start_time(row, None)
self._set_row_end_time(row, None)
# Don't dim unplayed tracks
self._set_row_bold(row)
# Stripe rows
if row % 2:
self._set_row_colour(
row, QColor(Config.COLOUR_ODD_PLAYLIST))
else:
self._set_row_colour(
row, QColor(Config.COLOUR_EVEN_PLAYLIST))
# Have we had a section start but not end?
if section_start_row is not None:
self._set_timed_section(
session, section_start_row, section_time, no_end=True)
# ########## Internally called functions ##########
def _audacity(self, row: int) -> None:
"""Open track in Audacity. Audacity must be already running"""
DEBUG(f"_audacity({row})")
if row in self._get_notes_rows():
return None
with Session() as session:
track: Tracks = self._get_row_track_object(row, session)
open_in_audacity(track.path)
@staticmethod
def _calculate_track_end_time(
track: Tracks, start: Optional[datetime]) -> Optional[datetime]:
"""Return this track's end time given its start time"""
if start is None:
return None
if track is None:
DEBUG("_calculate_next_start_time() called with track=None")
return None
duration = track.duration
return start + timedelta(milliseconds=duration)
def _context_menu(self, pos): # review
self.menu.exec_(self.mapToGlobal(pos))
def _copy_path(self, row: int) -> None:
"""
If passed row is track row, copy the track path to the clipboard.
Otherwise, return None.
"""
DEBUG(f"_copy_path({row})")
if row in self._get_notes_rows():
return None
with Session() as session:
track: Optional[Tracks] = self._get_row_track_object(row, session)
if track:
cb = QApplication.clipboard()
cb.clear(mode=cb.Clipboard)
cb.setText(track.path, mode=cb.Clipboard)
def _cell_changed(self, row: int, column: int) -> None:
"""Called when cell content has changed"""
if not self.editing_cell:
return
if column not in [self.COL_TITLE, self.COL_ARTIST]:
return
new_text: str = self.item(row, column).text()
DEBUG(f"_cell_changed({row=}, {column=}, {new_text=}")
with Session() as session:
if row in self._get_notes_rows():
# Save change to database
DEBUG(f"Notes.update_note: saving new note text '{new_text=}'")
note: Notes = self._get_row_notes_object(row, session)
note.update_note(session, row, new_text)
# Set/clear row start time accordingly
start_time = self._get_note_text_time(new_text)
if start_time:
self._set_row_start_time(row, start_time)
DEBUG(
f"_cell_changed:Note {new_text} contains valid "
f"{start_time=}"
)
else:
# Reset row start time in case it used to have one
self._set_row_start_time(row, None)
DEBUG(
f"_cell_changed:Note {new_text} does not contain "
"start time"
)
else:
track: Tracks = self._get_row_track_object(row, session)
if column == self.COL_ARTIST:
track.update_artist(session, artist=new_text)
elif column == self.COL_TITLE:
track.update_title(session, title=new_text)
else:
ERROR("_cell_changed(): unrecognised column")
def _cell_edit_ended(self) -> None:
"""Called when cell edit ends"""
DEBUG("_cell_edit_ended()")
self.editing_cell = False
# update_display to update start times, such as when a note has
# been edited
with Session() as session:
self.update_display(session)
self.musicmuster.enable_play_next_controls()
def _cell_edit_started(self, row: int, column: int) -> None:
"""
Called when cell editing started. Disable play controls so
that keys work during edit.
"""
DEBUG(f"_cell_edit_started({row=}, {column=})")
self.editing_cell = True
# Disable play controls so that keyboard input doesn't disturb playing
self.musicmuster.disable_play_next_controls()
# If this is a note cell and it's a section start, we need to
# remove any existing section timing so user can't edit that.
# Section timing is only in display of item, not in note text in
# database. Keep it simple: if this is a note, pull text from
# database.
if self._is_note_row(row):
item = self.item(row, self.COL_TITLE)
with Session() as session:
note_object = self._get_row_notes_object(row, session)
if note_object:
item.setText(note_object.note)
return
def _clear_current_track_row(self) -> None:
"""
Clear current row if there is one.
"""
current_row: Optional[int] = self._get_current_track_row()
if current_row is not None:
self._meta_clear_attribute(current_row, RowMeta.CURRENT)
# Reset row colour
if current_row % 2:
self._set_row_colour(
current_row, QColor(Config.COLOUR_ODD_PLAYLIST))
else:
self._set_row_colour(
current_row, QColor(Config.COLOUR_EVEN_PLAYLIST))
def _clear_played_row_status(self, row: int) -> None:
"""Clear played status on row"""
self._meta_clear_attribute(row, RowMeta.PLAYED)
def _delete_rows(self) -> None:
"""Delete mutliple rows"""
DEBUG("playlist._delete_rows()")
rows: List[int] = sorted(
set(item.row() for item in self.selectedItems())
)
rows_to_delete: List[int] = []
note_rows: Optional[List[int]] = self._get_notes_rows()
row: int
row_object: Union[Tracks, Notes]
with Session() as session:
for row in rows:
title = self.item(row, self.COL_TITLE).text()
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Warning)
msg.setText(f"Delete '{title}'?")
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel)
msg.setDefaultButton(QMessageBox.Cancel)
msg.setWindowTitle("Delete row")
# Store list of rows to delete
if msg.exec() == QMessageBox.Yes:
rows_to_delete.append(row)
# delete in reverse row order so row numbers don't
# change
playlist = Playlists.get_by_id(session, self.playlist_id)
for row in sorted(rows_to_delete, reverse=True):
if row in note_rows:
note: Notes = self._get_row_notes_object(row, session)
note.delete_note(session)
else:
playlist.remove_track(session, row)
self.removeRow(row)
self.save_playlist(session)
self.update_display(session)
def _drop_on(self, event): # review
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 _edit_cell(self, mi): # review
"""Called when table is double-clicked"""
row = mi.row()
column = mi.column()
item = self.item(row, column)
if column in [self.COL_TITLE, self.COL_ARTIST]:
self.editItem(item)
@staticmethod
def _file_is_readable(path: str) -> bool:
"""
Returns True if track path is readable, else False
vlc cannot read files with a colon in the path
"""
if os.access(path, os.R_OK):
if ':' not in path:
return True
return False
def _find_next_track_row(self, starting_row: int = None) -> Optional[int]:
"""
Find next track to play. If a starting row is given, start there;
else if there's a track selected, start looking from next track;
otherwise, start from top. Skip rows already played.
If not found, return None.
If found, return row number.
"""
if starting_row is None:
current_row = self._get_current_track_row()
if current_row is not None:
starting_row = current_row + 1
else:
starting_row = 0
notes_rows = self._get_notes_rows()
played_rows = self._get_played_track_rows()
for row in range(starting_row, self.rowCount()):
if row in notes_rows or row in played_rows:
continue
else:
return row
return None
def _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_notes_rows(self) -> List[int]:
"""Return rows marked as notes, or None"""
return self._meta_search(RowMeta.NOTE, one=False)
def _get_row_end_time(self, row) -> Optional[datetime]:
"""
Return row end time as string
"""
try:
if self.item(row, self.COL_END_TIME):
return datetime.strptime(self.item(
row, self.COL_END_TIME).text(),
Config.NOTE_TIME_FORMAT
)
else:
return None
except ValueError:
return None
def _get_row_notes_object(self, row: int, session: Session) \
-> Optional[Notes]:
"""Return note associated with this row"""
note_id = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT)
note = Notes.get_by_id(session, note_id)
return note
def _get_played_track_rows(self) -> List[int]:
"""Return rows marked as played, or None"""
return self._meta_search(RowMeta.PLAYED, one=False)
def _get_unplayed_track_rows(self) -> Optional[List[int]]:
"""Return rows marked as unplayed, or None"""
unplayed_rows: Set[int] = set(self._meta_notset(RowMeta.PLAYED))
notes_rows: Set[int] = set(self._get_notes_rows())
return list(unplayed_rows - notes_rows)
def _get_row_start_time(self, row: int) -> Optional[datetime]:
try:
if self.item(row, self.COL_START_TIME):
return datetime.strptime(self.item(
row, self.COL_START_TIME).text(),
Config.NOTE_TIME_FORMAT
)
else:
return None
except ValueError:
return None
def _get_row_track_object(self, row: int, session: Session) \
-> Optional[Tracks]:
"""Return track associated with this row"""
track_id = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT)
track = Tracks.get_by_id(session, track_id)
return track
def _get_track_rows(self) -> List[int]:
"""Return rows marked as tracks, or None"""
return self._meta_notset(RowMeta.NOTE)
def _get_unreadable_track_rows(self) -> List[int]:
"""Return rows marked as unreadable, or None"""
return self._meta_search(RowMeta.UNREADABLE, one=False)
def _info_row(self, row: int) -> None:
"""Display popup with info re row"""
txt: str
with Session() as session:
if row in self._get_notes_rows():
note: Notes = self._get_row_notes_object(row, session)
txt = note.note
else:
track: Tracks = self._get_row_track_object(row, session)
txt = (
f"Title: {track.title}\n"
f"Artist: {track.artist}\n"
f"Track ID: {track.id}\n"
f"Track duration: {helpers.ms_to_mmss(track.duration)}\n"
f"Track fade at: {helpers.ms_to_mmss(track.fade_at)}\n"
f"Track silence at: {helpers.ms_to_mmss(track.silence_at)}"
"\n\n"
f"Path: {track.path}\n"
)
info: QMessageBox = QMessageBox(self)
info.setIcon(QMessageBox.Information)
info.setText(txt)
info.setStandardButtons(QMessageBox.Ok)
info.setDefaultButton(QMessageBox.Cancel)
info.exec()
def _insert_note(self, session: Session, note: Notes,
row: Optional[int] = None, repaint: bool = True) -> None:
"""
Insert a note to playlist tab.
If a row is given, add note above. Otherwise, add to end of
playlist.
"""
if row is None:
row = self.rowCount()
DEBUG(f"playlist.inset_note(): row={row}")
self.insertRow(row)
# Add empty items to unused columns because
# colour won't be set for columns without items
item: QTableWidgetItem = QTableWidgetItem()
self.setItem(row, self.COL_USERDATA, item)
item = QTableWidgetItem()
self.setItem(row, self.COL_MSS, item)
# Add text of note from title column onwards
titleitem: QTableWidgetItem = QTableWidgetItem(note.note)
self.setItem(row, self.COL_NOTE, titleitem)
self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN,
self.NOTE_COL_SPAN)
# Attach note id to row
self._set_row_content(row, note.id)
# Mark row as a Note row
self._set_note_row(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(session, clear_selection=False)
def _is_below(self, pos, index): # review
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 _is_note_row(self, row: int) -> bool:
"""
Return True if passed row is a note row, else False
"""
if self._meta_get(row):
if self._meta_get(row) & (1 << RowMeta.NOTE):
return True
return False
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, self.COL_USERDATA).setData(
self.ROW_METADATA, 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, self.COL_USERDATA).data(self.ROW_METADATA)
def _meta_notset(self, metadata: int) -> List[int]:
"""
Search rows for metadata not set.
Return a list of matching row numbers.
"""
matches = []
for row in range(self.rowCount()):
row_meta = self._meta_get(row)
if row_meta is not None:
if not self._meta_get(row) & (1 << metadata):
matches.append(row)
return matches
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:
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, self.COL_USERDATA).setData(
self.ROW_METADATA, new_metadata)
def _rescan(self, row: int) -> None:
"""
If passed row is track row, rescan it.
Otherwise, return None.
"""
DEBUG(f"_rescan({row=})")
with Session() as session:
if row in self._get_track_rows():
track: Tracks = self._get_row_track_object(row, session)
if track:
track.rescan(session)
self._update_row(session, row, track)
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_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_note_row(self, row: int) -> None:
"""Mark this row as a note"""
self._meta_set_attribute(row, RowMeta.NOTE)
def _set_played_row(self, row: int) -> None:
"""Mark this row as played"""
self._meta_set_attribute(row, RowMeta.PLAYED)
def _set_unreadable_row(self, row: int) -> None:
"""Mark this row as unreadable"""
self._meta_set_attribute(row, RowMeta.UNREADABLE)
def _select_event(self) -> None:
"""
Called when item selection changes.
If multiple rows are selected, display sum of durations in status bar.
"""
# Get the row number of all selected items and put into a set
# to deduplicate
sel_rows: Set[int] = set([item.row() for item in self.selectedItems()])
# If no rows are selected, we have nothing to do
if len(sel_rows) == 0:
return
notes_rows: Set[int] = set(self._get_notes_rows())
ms: int = 0
with Session() as session:
for row in (sel_rows - notes_rows):
ms += self._get_row_track_object(row, session).duration or 0
# Only paint message if there are selected track rows
if ms > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {helpers.ms_to_mmss(ms)}")
else:
self.musicmuster.lblSumPlaytime.setText("")
def _select_tracks(self, played: bool) -> None:
"""
Select all played (played=True) or unplayed (played=False)
tracks in playlist
"""
# Need to allow multiple rows to be selected
self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
self.clearSelection()
if played:
rows = self._get_played_track_rows()
else:
rows = self._get_unplayed_track_rows()
for row in rows:
self.selectRow(row)
# Reset extended selection
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
def _set_column_widths(self, session: Session) -> None:
"""Column widths from settings"""
for column in range(self.columnCount()):
name: str = f"playlist_col_{str(column)}_width"
record: Settings = Settings.get_int_settings(session, name)
if record and record.f_int is not None:
self.setColumnWidth(column, record.f_int)
else:
self.setColumnWidth(column, Config.DEFAULT_COLUMN_WIDTH)
def _set_next(self, row: int, session: Session) -> None:
"""
Set passed row as next track to play.
Actions required:
- Check row is a track row
- Check track is readable
- Mark as next track
- Update display
- Notify musicmuster
"""
DEBUG(f"_set_next({row=})")
# Check row is a track row
if row in self._get_notes_rows():
return None
track: Tracks = self._get_row_track_object(row, session)
if not track:
return None
# Check track is readable
if not self._file_is_readable(track.path):
self._set_unreadable_row(row)
return None
# Mark as next track
self._set_next_track_row(row)
# Update display
self.update_display(session)
# Notify musicmuster
self.musicmuster.this_is_the_next_track(self, track, session)
def _set_row_bold(self, row: int, bold: bool = True) -> None:
"""Make row bold (bold=True) or not bold"""
i: int
j: int
boldfont: QFont = QFont()
boldfont.setBold(bold)
for j in range(self.columnCount()):
if self.item(row, j):
self.item(row, j).setFont(boldfont)
def _set_row_colour(self, row: int, colour: QColor) -> None:
"""Set row background colour"""
j: int
for j in range(2, self.columnCount()):
if self.item(row, j):
self.item(row, j).setBackground(colour)
def _set_row_content(self, row: int, object_id: int) -> None:
"""Set content associated with this row"""
assert self.item(row, self.COL_USERDATA)
self.item(row, self.COL_USERDATA).setData(
self.CONTENT_OBJECT, object_id)
def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None:
"""Set passed row end time to passed time"""
try:
time_str: str = time.strftime(Config.TRACK_TIME_FORMAT)
except AttributeError:
time_str = ""
item = QTableWidgetItem(time_str)
self.setItem(row, self.COL_END_TIME, item)
def _set_row_not_bold(self, row: 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: str = time.strftime(Config.TRACK_TIME_FORMAT)
except AttributeError:
time_str = ""
item: QTableWidgetItem = QTableWidgetItem(time_str)
self.setItem(row, self.COL_START_TIME, item)
def _set_timed_section(self, session, start_row, ms, no_end=False):
"""Add duration to a marked section"""
duration = helpers.ms_to_mmss(ms)
note_object = self._get_row_notes_object(start_row, session)
if not note_object:
ERROR("Can't get note_object in playlists._set_timed_section")
note_text = note_object.note
caveat = ""
if no_end:
caveat = " (to end of playlist)"
display_text = note_text + ' [' + duration + caveat + ']'
item = self.item(start_row, self.COL_TITLE)
item.setText(display_text)
def _update_row(self, session, row: int, track: Tracks) -> None:
"""
Update the passed row with info from the passed track.
"""
DEBUG(f"_update_row({row=}, {track=}")
item_startgap: QTableWidgetItem = self.item(row, self.COL_MSS)
item_startgap.setText(str(track.start_gap))
if track.start_gap >= 500:
item_startgap.setBackground(QColor(Config.COLOUR_LONG_START))
else:
item_startgap.setBackground(QColor("white"))
item_title: QTableWidgetItem = self.item(row, self.COL_TITLE)
item_title.setText(track.title)
item_artist: QTableWidgetItem = self.item(row, self.COL_ARTIST)
item_artist.setText(track.artist)
item_duration: QTableWidgetItem = self.item(row, self.COL_DURATION)
item_duration.setText(helpers.ms_to_mmss(track.duration))
self.update_display(session)