musicmuster/app/playlists.py
2023-12-01 09:51:42 +00:00

1284 lines
44 KiB
Python

from pprint import pprint
from typing import Callable, cast, List, Optional, overload, TYPE_CHECKING
from PyQt6.QtCore import (
QEvent,
QModelIndex,
QObject,
QItemSelection,
Qt,
)
from PyQt6.QtGui import QAction, QKeyEvent
from PyQt6.QtWidgets import (
QAbstractItemDelegate,
QAbstractItemView,
QApplication,
QHeaderView,
QMenu,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
QStyleOptionViewItem,
QTableView,
QTableWidgetItem,
QWidget,
QProxyStyle,
QStyle,
QStyleOption,
)
from dbconfig import Session
from dialogs import TrackSelectDialog
from classes import MusicMusterSignals, track_sequence
from config import Config
from helpers import (
ask_yes_no,
ms_to_mmss,
show_OK,
show_warning,
)
from models import Settings
if TYPE_CHECKING:
from musicmuster import Window
from playlistmodel import PlaylistModel, PlaylistProxyModel
class EscapeDelegate(QStyledItemDelegate):
"""
- increases the height of a row when editing to make editing easier
- closes the edit on control-return
- checks with user before abandoning edit on Escape
"""
def __init__(self, parent, data_model: PlaylistModel) -> None:
super().__init__(parent)
self.data_model = data_model
self.signals = MusicMusterSignals()
def createEditor(
self,
parent: Optional[QWidget],
option: QStyleOptionViewItem,
index: QModelIndex,
):
"""
Intercept createEditor call and make row just a little bit taller
"""
self.signals = MusicMusterSignals()
self.signals.enable_escape_signal.emit(False)
if isinstance(self.parent(), PlaylistTab):
p = cast(PlaylistTab, self.parent())
if isinstance(index.data(), str):
row = index.row()
row_height = p.rowHeight(row)
p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT)
return QPlainTextEdit(parent)
return super().createEditor(parent, option, index)
def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None:
"""
Intercept editor destroyment
"""
self.signals.enable_escape_signal.emit(True)
return super().destroyEditor(editor, index)
def eventFilter(self, editor: Optional[QObject], event: Optional[QEvent]) -> bool:
"""By default, QPlainTextEdit doesn't handle enter or return"""
if editor is None or event is None:
return False
if event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
if key_event.key() == Qt.Key.Key_Return:
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
self.commitData.emit(editor)
self.closeEditor.emit(editor)
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
cast(QWidget, self.parent()), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
self.closeEditor.emit(editor)
return True
return False
def setEditorData(self, editor, index):
model = index.model()
if hasattr(model, "mapToSource"):
edit_index = model.mapToSource(index)
else:
edit_index = index
value = self.data_model.data(edit_index, Qt.ItemDataRole.EditRole)
editor.setPlainText(value.value())
def setModelData(self, editor, model, index):
model = index.model()
if hasattr(model, "mapToSource"):
edit_index = model.mapToSource(index)
else:
edit_index = index
value = editor.toPlainText().strip()
self.data_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
and not option.rect.isNull()
):
option_new = QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class PlaylistTab(QTableView):
"""
The playlist view
"""
def __init__(
self,
musicmuster: "Window",
playlist_id: int,
) -> None:
super().__init__()
# Save passed settings
self.musicmuster = musicmuster
self.playlist_id = playlist_id
# Set up widget
self.data_model = PlaylistModel(playlist_id)
self.proxy_model = PlaylistProxyModel(self.data_model)
self.setItemDelegate(EscapeDelegate(self, self.data_model))
self.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.setDragDropOverwriteMode(False)
self.setAcceptDrops(True)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(PlaylistStyle())
# We will enable dragging when rows are selected. Disabling it
# here means we can click and drag to select rows.
self.setDragEnabled(False)
# Prepare for context menu
self.menu = QMenu()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._context_menu)
# Connect signals
# This dancing is to satisfy mypy
h_header = self.horizontalHeader()
if isinstance(h_header, QHeaderView):
h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True)
# self.signals.set_next_track_signal.connect(self._reset_next)
self.signals = MusicMusterSignals()
self.signals.resize_rows_signal.connect(self.resizeRowsToContents)
self.signals.span_cells_signal.connect(self._span_cells)
# Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Load playlist rows
self.setModel(self.proxy_model)
self._set_column_widths()
# ########## Overrident class functions ##########
def closeEditor(
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
) -> None:
"""
Override closeEditor to enable play controls and update display.
"""
self.musicmuster.enable_play_next_controls()
self.musicmuster.actionSetNext.setEnabled(True)
self.musicmuster.action_Clear_selection.setEnabled(True)
super(PlaylistTab, self).closeEditor(editor, hint)
# Optimise row heights after increasing row height for editing
self.resizeRowsToContents()
# Update start times in case a start time in a note has been
# edited
self.data_model.update_track_times()
# Deselect edited line
self.clear_selection()
def dropEvent(self, event):
if event.source() is not self or (
event.dropAction() != Qt.DropAction.MoveAction
and self.dragDropMode() != QAbstractItemView.InternalMove
):
super().dropEvent(event)
from_rows = self.selected_model_row_numbers()
to_row = self.indexAt(event.position().toPoint()).row()
if (
0 <= min(from_rows) <= self.model().rowCount()
and 0 <= max(from_rows) <= self.model().rowCount()
and 0 <= to_row <= self.model().rowCount()
):
self.model().move_rows(from_rows, to_row)
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Deselect rows
self.clear_selection()
# Resize rows
self.resizeRowsToContents()
event.accept()
@overload
def edit(self, index: QModelIndex) -> None:
...
@overload
def edit(
self,
index: QModelIndex,
trigger: QAbstractItemView.EditTrigger,
event: Optional[QEvent]
) -> bool:
...
def edit(self, index, trigger, event):
"""
Override QAbstractItemView.edit to catch when editing starts
Editing only ever starts with a double click on a cell
"""
# 'result' will only be true on double-click
result = super().edit(index, trigger, event)
if result:
self.musicmuster.disable_play_next_controls()
return result
def mouseReleaseEvent(self, event):
"""
Enable dragging if rows are selected
"""
if self.selectedIndexes():
self.setDragEnabled(True)
else:
self.setDragEnabled(False)
self.reset()
super().mouseReleaseEvent(event)
def selectionChanged(
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
"""
Toggle drag behaviour according to whether rows are selected
"""
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("")
else:
selected_duration = self.data_model.get_rows_duration(
self.get_selected_rows()
)
if selected_duration > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"
)
else:
self.musicmuster.lblSumPlaytime.setText("")
super().selectionChanged(selected, deselected)
# ########## Custom functions ##########
def _add_context_menu(
self,
text: str,
action: Callable,
disabled: bool = False,
parent_menu: Optional[QMenu] = None,
) -> Optional[QAction]:
"""
Add item to self.menu
"""
if parent_menu is None:
parent_menu = self.menu
menu_item = parent_menu.addAction(text)
if not menu_item:
return None
menu_item.setDisabled(disabled)
menu_item.triggered.connect(action)
return menu_item
def _add_track(self) -> None:
"""Add a track to a section header making it a normal track row"""
model_row_number = self.selected_model_row_number()
if model_row_number is None:
return
with Session() as session:
dlg = TrackSelectDialog(
session=session,
new_row_number=model_row_number,
model=self.data_model,
add_to_header=True,
)
dlg.exec()
def _build_context_menu(self, item: QTableWidgetItem) -> None:
"""Used to process context (right-click) menu, which is defined here"""
self.menu.clear()
model = self.proxy_model
display_row_number = item.row()
if hasattr(model, "mapToSource"):
index = model.index(item.row(), item.column())
model_row_number = model.mapToSource(index).row()
else:
model_row_number = display_row_number
header_row = model.is_header_row(model_row_number)
track_row = not header_row
current_row = model_row_number == track_sequence.now.plr_rownum
next_row = model_row_number == track_sequence.next.plr_rownum
# Open in Audacity
if track_row and not current_row:
self._add_context_menu(
"Open in Audacity", lambda: model.open_in_audacity(model_row_number)
)
# Rescan
if track_row and not current_row:
self._add_context_menu(
"Rescan track", lambda: self._rescan(model_row_number)
)
# ----------------------
self.menu.addSeparator()
# Delete row
if not current_row and not next_row:
self._add_context_menu("Delete row", lambda: self._delete_rows())
# Remove track from row
if track_row and not current_row and not next_row:
self._add_context_menu(
"Remove track from row", lambda: model.remove_track(model_row_number)
)
# Add track to section header (ie, make this a track row)
if header_row:
self._add_context_menu("Add a track", lambda: self._add_track())
# # ----------------------
self.menu.addSeparator()
# Mark unplayed
if track_row and model.is_played_row(model_row_number):
self._add_context_menu(
"Mark unplayed",
lambda: self._mark_as_unplayed(self.get_selected_rows()),
)
# Unmark as next
if next_row:
self._add_context_menu(
"Unmark as next track", lambda: self._unmark_as_next()
)
# ----------------------
self.menu.addSeparator()
# Sort
sort_menu = self.menu.addMenu("Sort")
self._add_context_menu(
"by title",
lambda: model.sort_by_title(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by artist",
lambda: model.sort_by_artist(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by duration",
lambda: model.sort_by_duration(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by last played",
lambda: model.sort_by_lastplayed(self.get_selected_rows()),
parent_menu=sort_menu,
)
# Info
if track_row:
self._add_context_menu("Info", lambda: self._info_row(model_row_number))
# Track path
if track_row:
self._add_context_menu(
"Copy track path", lambda: self._copy_path(model_row_number)
)
def clear_selection(self) -> None:
"""Unselect all tracks and reset drag mode"""
self.clearSelection()
self.setDragEnabled(False)
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
"""
Called when column width changes. Save new width to database.
"""
header = self.horizontalHeader()
if not header:
return
# Resize rows if necessary
self.resizeRowsToContents()
with Session() as session:
attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_int_settings(session, attr_name)
record.f_int = self.columnWidth(column_number)
def _context_menu(self, pos):
"""Display right-click menu"""
item = self.indexAt(pos)
self._build_context_menu(item)
self.menu.exec(self.mapToGlobal(pos))
def _copy_path(self, row_number: int) -> None:
"""
If passed row_number has a track, copy the track path, single-quoted,
to the clipboard. Otherwise, return None.
"""
track_path = self.data_model.get_row_info(row_number).path
if not track_path:
return
replacements = [
("'", "\\'"),
(" ", "\\ "),
("(", "\\("),
(")", "\\)"),
]
for old, new in replacements:
track_path = track_path.replace(old, new)
cb = QApplication.clipboard()
if cb:
cb.clear(mode=cb.Mode.Clipboard)
cb.setText(track_path, mode=cb.Mode.Clipboard)
def _delete_rows(self) -> None:
"""
Delete mutliple rows
Actions required:
- Confirm deletion should go ahead
- Pass to model to do the deed
"""
rows_to_delete = self.get_selected_rows()
row_count = len(rows_to_delete)
if row_count < 1:
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
self.data_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection()
def get_selected_row_track_path(self) -> str:
"""
Return the path of the selected row. If no row selected or selected
row does not have a track, return empty string.
"""
model_row_number = self.selected_model_row_number()
if model_row_number is None:
return ""
return self.data_model.get_row_track_path(model_row_number)
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(list(set([a.row() for a in self.selectedIndexes()])))
def _info_row(self, row_number: int) -> None:
"""Display popup with info re row"""
prd = self.data_model.get_row_info(row_number)
if prd:
txt = (
f"Title: {prd.title}\n"
f"Artist: {prd.artist}\n"
f"Track ID: {prd.track_id}\n"
f"Track duration: {ms_to_mmss(prd.duration)}\n"
f"Track bitrate: {prd.bitrate}\n"
"\n\n"
f"Path: {prd.path}\n"
)
else:
txt = f"Can't find info about row{row_number}"
show_OK(self.musicmuster, "Track info", txt)
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Rescan track"""
self.data_model.mark_unplayed(row_numbers)
self.clear_selection()
def _rescan(self, row_number: int) -> None:
"""Rescan track"""
self.data_model.rescan_track(row_number)
self.clear_selection()
def scroll_to_top(self, row_number: int) -> None:
"""
Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the
top.
"""
if row_number is None:
return
row_index = self.proxy_model.index(row_number, 0)
self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)
def select_duplicate_rows(self) -> None:
"""
Select the last of any rows with duplicate tracks in current playlist.
This allows the selection to typically come towards the end of the playlist away
from any show specific sections.
"""
# Clear any selected rows to avoid confustion
self.clear_selection()
# We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows
duplicate_rows = self.data_model.get_duplicate_rows()
# Select the rows
for duplicate_row in duplicate_rows:
self.selectRow(duplicate_row)
# Reset selection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
def selected_model_row_number(self) -> Optional[int]:
"""
Return the model row number corresponding to the selected row or None
"""
selected_index = self._selected_row_index()
if selected_index is None:
return None
if hasattr(self.proxy_model, "mapToSource"):
return self.proxy_model.mapToSource(selected_index).row()
return selected_index.row()
def selected_model_row_numbers(self) -> List[int]:
"""
Return a list of model row numbers corresponding to the selected rows or
an empty list.
"""
selected_indexes = self._selected_row_indexes()
if selected_indexes is None:
return []
if hasattr(self.proxy_model, "mapToSource"):
return [self.proxy_model.mapToSource(a).row() for a in selected_indexes]
return [a.row() for a in selected_indexes]
def _selected_row_index(self) -> Optional[QModelIndex]:
"""
Return the selected row index or None if none selected.
"""
row_indexes = self._selected_row_indexes()
if len(row_indexes) > 1:
show_warning(
self.musicmuster, "Multiple rows selected", "Select only one row"
)
return None
elif not row_indexes:
return None
return row_indexes[0]
def _selected_row_indexes(self) -> List[QModelIndex]:
"""
Return a list of indexes of column 1 of selected rows
"""
sm = self.selectionModel()
if sm and sm.hasSelection():
return sm.selectedRows()
return []
def _set_column_widths(self) -> None:
"""Column widths from settings"""
header = self.horizontalHeader()
if not header:
return
# Set width of last column to zero as it's set to stretch
self.setColumnWidth(header.count() - 1, 0)
# Set remaining column widths from settings
with Session() as session:
for column_number in range(header.count() - 1):
attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_int_settings(session, attr_name)
if record.f_int is not None:
self.setColumnWidth(column_number, record.f_int)
else:
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
def set_row_as_next_track(self) -> None:
"""
Set selected row as next track
"""
model_row_number = self.selected_model_row_number()
if model_row_number is None:
return
self.data_model.set_next_row(model_row_number)
self.clearSelection()
def _span_cells(
self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int
) -> None:
"""
Implement spanning of cells, initiated by signal
"""
if playlist_id != self.playlist_id:
return
model = self.proxy_model
if hasattr(model, "mapToSource"):
edit_index = model.mapFromSource(self.data_model.createIndex(row, column))
row = edit_index.row()
column = edit_index.column()
# Don't set spanning if already in place because that is seen as
# a change to the view and thus it refreshes the data which
# again calls us here.
if (
self.rowSpan(row, column) == rowSpan
and self.columnSpan(row, column) == columnSpan
):
return
self.setSpan(row, column, rowSpan, columnSpan)
def _unmark_as_next(self) -> None:
"""Rescan track"""
<<<<<<< HEAD
self.data_model.set_next_row(None)
self.clear_selection()
||||||| parent of 705f3ea (Fix bug with unended timed section)
# Reorder rows
new_order = [a[0] for a in sorted_rows]
self.sort_undo = [
new_order.index(x) + min(new_order)
for x in range(min(new_order), max(new_order) + 1)
]
self._reorder_rows(new_order)
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Save playlist
with Session() as session:
self.save_playlist(session)
self._update_start_end_times(session)
def _sort_undo(self):
"""Undo last sort"""
if not self.sort_undo:
return
new_order = self.sort_undo
self._reorder_rows(new_order)
self.sort_undo = [
new_order.index(x) + min(new_order)
for x in range(min(new_order), max(new_order) + 1)
]
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Save playlist
with Session() as session:
self.save_playlist(session)
self._update_start_end_times(session)
def _unplayed_track_time_between_rows(
self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows
) -> int:
"""
Returns the total unplayed duration of all tracks in rows between
from_row and to_row inclusive
"""
plr_tracks = PlaylistRows.get_rows_with_tracks(
session, self.playlist_id, from_plr.plr_rownum, to_plr.plr_rownum
)
unplayed_time = 0
unplayed_time = sum(
[a.track.duration for a in plr_tracks if a.track.duration and not a.played]
)
return unplayed_time
def _update_row_track_info(
self, session: scoped_session, row: int, track: Tracks
) -> None:
"""
Update the passed row with info from the passed track.
"""
_ = self._set_row_artist(row, track.artist)
_ = self._set_row_bitrate(row, track.bitrate)
_ = self._set_row_duration(row, track.duration)
_ = self._set_row_end_time(row, None)
if track.playdates:
last_play = max([a.lastplayed for a in track.playdates])
else:
last_play = Config.EPOCH
_ = self._set_row_last_played_time(row, last_play)
_ = self._set_row_start_gap(row, track.start_gap)
_ = self._set_row_start_time(row, None)
_ = self._set_row_title(row, track.title)
_ = self._set_row_track_id(row, track.id)
_ = self._set_row_track_path(row, track.path)
if file_is_unreadable(track.path):
self._set_row_colour_unreadable(row)
def _update_section_headers(self, session: scoped_session) -> None:
"""
Update section headers with run time of section
"""
section_start_rows: List[PlaylistRows] = []
subtotal_from: Optional[PlaylistRows] = None
active_row: Optional[int] = None
active_endtime: Optional[datetime] = None
current_row_prlid = self.musicmuster.current_track.plr_id
if current_row_prlid:
current_row = self._plrid_to_row_number(current_row_prlid)
if current_row:
active_row = current_row
active_end = self.musicmuster.current_track.end_time
else:
previous_row_plrid = self.musicmuster.previous_track.plr_id
if previous_row_plrid:
previous_row = self._plrid_to_row_number(previous_row_plrid)
if previous_row:
active_row = previous_row
active_end = self.musicmuster.previous_track.end_time
header_rows = [
self._get_row_plr_id(row_number)
for row_number in range(self.rowCount())
if self._get_row_track_id(row_number) == 0
]
plrs = PlaylistRows.plrids_to_plrs(session, self.playlist_id, header_rows)
for plr in plrs:
# Start of timed section
if plr.note.endswith("+"):
section_start_rows.append(plr)
subtotal_from = plr
continue
# End of timed section
elif plr.note.endswith("-"):
try:
from_plr = section_start_rows.pop()
to_plr = plr
unplayed_time = self._unplayed_track_time_between_rows(
session, from_plr, to_plr
)
if (
active_row
and active_row >= from_plr.plr_rownum
and active_row <= to_plr.plr_rownum
):
time_str = self._get_section_timing_string(
unplayed_time, active_end
)
else:
time_str = self._get_section_timing_string(unplayed_time, None)
self._set_row_header_text(
session, from_plr.plr_rownum, from_plr.note + time_str
)
# Update section end
if to_plr.note.strip() == "-":
new_text = (
"[End "
+ re.sub(
section_header_cleanup_re,
"",
from_plr.note,
).strip()
+ "]"
)
self._set_row_header_text(session, to_plr.plr_rownum, new_text)
subtotal_from = None
except IndexError:
# This ending row may have a time left from before a
# starting row above was deleted, so replace content
self._set_row_header_text(session, plr.plr_rownum, plr.note)
continue
# Subtotal
elif plr.note.endswith("="):
if not subtotal_from:
return
from_plr = subtotal_from
to_plr = plr
unplayed_time = self._unplayed_track_time_between_rows(
session, subtotal_from, to_plr
)
if (
active_row
and active_row >= from_plr.plr_rownum
and active_row <= to_plr.plr_rownum
):
time_str = self._get_section_timing_string(
unplayed_time, active_end
)
else:
time_str = self._get_section_timing_string(unplayed_time, None)
if to_plr.note.strip() == "=":
leader_text = "Subtotal: "
else:
leader_text = to_plr.note[:-1] + " "
new_text = leader_text + time_str
self._set_row_header_text(session, to_plr.plr_rownum, new_text)
subtotal_from = to_plr
# If we still have plrs in section_start_rows, there isn't an end
# section row for them
possible_plr = self._get_row_plr(session, self.rowCount() - 1)
if possible_plr:
to_plr = possible_plr
for from_plr in section_start_rows:
unplayed_time = self._unplayed_track_time_between_rows(
session, from_plr, to_plr
)
time_str = self._get_section_timing_string(
total_time, unplayed_time, no_end=True
)
self._set_row_header_text(
session, from_plr.plr_rownum, from_plr.note + time_str
)
def _update_start_end_times(self, session: scoped_session) -> None:
"""Update track start and end times"""
current_track_end_time = self.musicmuster.current_track.end_time
current_track_row = self._get_current_track_row_number()
current_track_start_time = self.musicmuster.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_number in range(self.rowCount()):
# Don't change start times for tracks that have been
# played other than current/next row
if row_number in played_rows and row_number 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_number) == 0:
note_time = self._get_note_text_time(self._get_row_note(row_number))
if note_time:
next_start_time = note_time
continue
# We have a track. Skip if it is unreadable
if file_is_unreadable(self._get_row_path(row_number)):
continue
# Set next track start from end of current track
if row_number == next_track_row:
if current_track_end_time:
next_start_time = self._set_row_times(
row_number,
current_track_end_time,
self._get_row_duration(row_number),
)
continue
# Else set track times below
if row_number == current_track_row:
if not current_track_start_time:
continue
self._set_row_start_time(row_number, current_track_start_time)
self._set_row_end_time(row_number, 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_number, None)
self._set_row_end_time(row_number, 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_number < next_track_row
):
self._set_row_start_time(row_number, None)
self._set_row_end_time(row_number, None)
else:
next_start_time = self._set_row_times(
row_number, next_start_time, self._get_row_duration(row_number)
)
self._update_section_headers(session)
=======
# Reorder rows
new_order = [a[0] for a in sorted_rows]
self.sort_undo = [
new_order.index(x) + min(new_order)
for x in range(min(new_order), max(new_order) + 1)
]
self._reorder_rows(new_order)
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Save playlist
with Session() as session:
self.save_playlist(session)
self._update_start_end_times(session)
def _sort_undo(self):
"""Undo last sort"""
if not self.sort_undo:
return
new_order = self.sort_undo
self._reorder_rows(new_order)
self.sort_undo = [
new_order.index(x) + min(new_order)
for x in range(min(new_order), max(new_order) + 1)
]
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Save playlist
with Session() as session:
self.save_playlist(session)
self._update_start_end_times(session)
def _unplayed_track_time_between_rows(
self, session: scoped_session, from_plr: PlaylistRows, to_plr: PlaylistRows
) -> int:
"""
Returns the total unplayed duration of all tracks in rows between
from_row and to_row inclusive
"""
plr_tracks = PlaylistRows.get_rows_with_tracks(
session, self.playlist_id, from_plr.plr_rownum, to_plr.plr_rownum
)
unplayed_time = 0
unplayed_time = sum(
[a.track.duration for a in plr_tracks if a.track.duration and not a.played]
)
return unplayed_time
def _update_row_track_info(
self, session: scoped_session, row: int, track: Tracks
) -> None:
"""
Update the passed row with info from the passed track.
"""
_ = self._set_row_artist(row, track.artist)
_ = self._set_row_bitrate(row, track.bitrate)
_ = self._set_row_duration(row, track.duration)
_ = self._set_row_end_time(row, None)
if track.playdates:
last_play = max([a.lastplayed for a in track.playdates])
else:
last_play = Config.EPOCH
_ = self._set_row_last_played_time(row, last_play)
_ = self._set_row_start_gap(row, track.start_gap)
_ = self._set_row_start_time(row, None)
_ = self._set_row_title(row, track.title)
_ = self._set_row_track_id(row, track.id)
_ = self._set_row_track_path(row, track.path)
if file_is_unreadable(track.path):
self._set_row_colour_unreadable(row)
def _update_section_headers(self, session: scoped_session) -> None:
"""
Update section headers with run time of section
"""
section_start_rows: List[PlaylistRows] = []
subtotal_from: Optional[PlaylistRows] = None
active_row: Optional[int] = None
active_endtime: Optional[datetime] = None
current_row_prlid = self.musicmuster.current_track.plr_id
if current_row_prlid:
current_row = self._plrid_to_row_number(current_row_prlid)
if current_row:
active_row = current_row
active_end = self.musicmuster.current_track.end_time
else:
previous_row_plrid = self.musicmuster.previous_track.plr_id
if previous_row_plrid:
previous_row = self._plrid_to_row_number(previous_row_plrid)
if previous_row:
active_row = previous_row
active_end = self.musicmuster.previous_track.end_time
header_rows = [
self._get_row_plr_id(row_number)
for row_number in range(self.rowCount())
if self._get_row_track_id(row_number) == 0
]
plrs = PlaylistRows.plrids_to_plrs(session, self.playlist_id, header_rows)
for plr in plrs:
# Start of timed section
if plr.note.endswith("+"):
section_start_rows.append(plr)
subtotal_from = plr
continue
# End of timed section
elif plr.note.endswith("-"):
try:
from_plr = section_start_rows.pop()
to_plr = plr
unplayed_time = self._unplayed_track_time_between_rows(
session, from_plr, to_plr
)
if (
active_row
and active_row >= from_plr.plr_rownum
and active_row <= to_plr.plr_rownum
):
time_str = self._get_section_timing_string(
unplayed_time, active_end
)
else:
time_str = self._get_section_timing_string(unplayed_time, None)
self._set_row_header_text(
session, from_plr.plr_rownum, from_plr.note + time_str
)
# Update section end
if to_plr.note.strip() == "-":
new_text = (
"[End "
+ re.sub(
section_header_cleanup_re,
"",
from_plr.note,
).strip()
+ "]"
)
self._set_row_header_text(session, to_plr.plr_rownum, new_text)
subtotal_from = None
except IndexError:
# This ending row may have a time left from before a
# starting row above was deleted, so replace content
self._set_row_header_text(session, plr.plr_rownum, plr.note)
continue
# Subtotal
elif plr.note.endswith("="):
if not subtotal_from:
return
from_plr = subtotal_from
to_plr = plr
unplayed_time = self._unplayed_track_time_between_rows(
session, subtotal_from, to_plr
)
if (
active_row
and active_row >= from_plr.plr_rownum
and active_row <= to_plr.plr_rownum
):
time_str = self._get_section_timing_string(
unplayed_time, active_end
)
else:
time_str = self._get_section_timing_string(unplayed_time, None)
if to_plr.note.strip() == "=":
leader_text = "Subtotal: "
else:
leader_text = to_plr.note[:-1] + " "
new_text = leader_text + time_str
self._set_row_header_text(session, to_plr.plr_rownum, new_text)
subtotal_from = to_plr
# If we still have plrs in section_start_rows, there isn't an end
# section row for them
possible_plr = self._get_row_plr(session, self.rowCount() - 1)
if possible_plr:
to_plr = possible_plr
for from_plr in section_start_rows:
unplayed_time = self._unplayed_track_time_between_rows(
session, from_plr, to_plr
)
time_str = self._get_section_timing_string(
unplayed_time, no_end=True
)
self._set_row_header_text(
session, from_plr.plr_rownum, from_plr.note + time_str
)
def _update_start_end_times(self, session: scoped_session) -> None:
"""Update track start and end times"""
current_track_end_time = self.musicmuster.current_track.end_time
current_track_row = self._get_current_track_row_number()
current_track_start_time = self.musicmuster.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_number in range(self.rowCount()):
# Don't change start times for tracks that have been
# played other than current/next row
if row_number in played_rows and row_number 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_number) == 0:
note_time = self._get_note_text_time(self._get_row_note(row_number))
if note_time:
next_start_time = note_time
continue
# We have a track. Skip if it is unreadable
if file_is_unreadable(self._get_row_path(row_number)):
continue
# Set next track start from end of current track
if row_number == next_track_row:
if current_track_end_time:
next_start_time = self._set_row_times(
row_number,
current_track_end_time,
self._get_row_duration(row_number),
)
continue
# Else set track times below
if row_number == current_track_row:
if not current_track_start_time:
continue
self._set_row_start_time(row_number, current_track_start_time)
self._set_row_end_time(row_number, 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_number, None)
self._set_row_end_time(row_number, 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_number < next_track_row
):
self._set_row_start_time(row_number, None)
self._set_row_end_time(row_number, None)
else:
next_start_time = self._set_row_times(
row_number, next_start_time, self._get_row_duration(row_number)
)
self._update_section_headers(session)
>>>>>>> 705f3ea (Fix bug with unended timed section)