Compare commits

..

5 Commits

Author SHA1 Message Date
Keith Edmunds
de710b1dc7 WIP V3: start/end times, moving row bug
Start/end times now stored separately from self.playlist_rows. Moving
next row to above current row now works.
2023-11-15 20:09:00 +00:00
Keith Edmunds
3cbc69b11e Fix off-by-one errors in tests 2023-11-15 19:07:23 +00:00
Keith Edmunds
56087870f4 WIP V3: recalculate start/end times after moving rows 2023-11-15 15:14:23 +00:00
Keith Edmunds
b83bd0d5c3 WIP V3: display last played date 2023-11-15 15:09:41 +00:00
Keith Edmunds
3e49ad08b9 WIP V3: sort by each element implemented 2023-11-15 08:41:06 +00:00
3 changed files with 94 additions and 61 deletions

View File

@ -1,5 +1,7 @@
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import auto, Enum from enum import auto, Enum
from operator import attrgetter
from typing import List, Optional from typing import List, Optional
from PyQt6.QtCore import ( from PyQt6.QtCore import (
@ -20,6 +22,7 @@ from dbconfig import scoped_session, Session
from helpers import ( from helpers import (
file_is_unreadable, file_is_unreadable,
get_embedded_time, get_embedded_time,
get_relative_date,
open_in_audacity, open_in_audacity,
ms_to_mmss, ms_to_mmss,
set_track_metadata, set_track_metadata,
@ -52,12 +55,10 @@ class PlaylistRowData:
self.artist: str = "" self.artist: str = ""
self.bitrate = 0 self.bitrate = 0
self.duration: int = 0 self.duration: int = 0
self.end_time: Optional[datetime] = None
self.lastplayed: datetime = Config.EPOCH self.lastplayed: datetime = Config.EPOCH
self.path = "" self.path = ""
self.played = False self.played = False
self.start_gap: Optional[int] = None self.start_gap: Optional[int] = None
self.start_time: Optional[datetime] = None
self.title: str = "" self.title: str = ""
self.plrid: int = plr.id self.plrid: int = plr.id
@ -84,6 +85,12 @@ class PlaylistRowData:
) )
@dataclass
class StartEndTimes:
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
class PlaylistModel(QAbstractTableModel): class PlaylistModel(QAbstractTableModel):
""" """
The Playlist Model The Playlist Model
@ -110,6 +117,7 @@ class PlaylistModel(QAbstractTableModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, PlaylistRowData] = {} self.playlist_rows: dict[int, PlaylistRowData] = {}
self.start_end_times: dict[int, StartEndTimes] = {}
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.signals.add_track_to_header_signal.connect(self.add_track_to_header) self.signals.add_track_to_header_signal.connect(self.add_track_to_header)
@ -266,10 +274,9 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_row(session, plr.plr_rownum) self.refresh_row(session, plr.plr_rownum)
# Update track times # Update track times
self.playlist_rows[row_number].start_time = datetime.now() self.start_end_times[row_number].start_time = track_sequence.now.start_time
self.playlist_rows[row_number].end_time = datetime.now() + timedelta( self.start_end_times[row_number].end_time = track_sequence.now.end_time
milliseconds=self.playlist_rows[row_number].duration
)
# Update colour and times for current row # Update colour and times for current row
self.invalidate_row(row_number) self.invalidate_row(row_number)
# Update all other track times # Update all other track times
@ -367,17 +374,19 @@ class PlaylistModel(QAbstractTableModel):
if column == Col.DURATION.value: if column == Col.DURATION.value:
return QVariant(ms_to_mmss(prd.duration)) return QVariant(ms_to_mmss(prd.duration))
if column == Col.START_TIME.value: if column == Col.START_TIME.value:
if prd.start_time: if row in self.start_end_times:
return QVariant(prd.start_time.strftime(Config.TRACK_TIME_FORMAT)) start_time = self.start_end_times[row].start_time
else: if start_time:
return QVariant() return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant()
if column == Col.END_TIME.value: if column == Col.END_TIME.value:
if prd.end_time: if row in self.start_end_times:
return QVariant(prd.end_time.strftime(Config.TRACK_TIME_FORMAT)) end_time = self.start_end_times[row].end_time
else: if end_time:
return QVariant() return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant()
if column == Col.LAST_PLAYED.value: if column == Col.LAST_PLAYED.value:
return QVariant(prd.lastplayed) return QVariant(get_relative_date(prd.lastplayed))
if column == Col.BITRATE.value: if column == Col.BITRATE.value:
return QVariant(prd.bitrate) return QVariant(prd.bitrate)
if column == Col.NOTE.value: if column == Col.NOTE.value:
@ -647,13 +656,20 @@ class PlaylistModel(QAbstractTableModel):
for modified_row in modified_rows: for modified_row in modified_rows:
self.invalidate_row(modified_row) self.invalidate_row(modified_row)
def mark_unplayed(self, row_number: int) -> None: def mark_unplayed(self, row_numbers: List[int]) -> None:
""" """
Mark row as unplayed Mark row as unplayed
""" """
self.playlist_rows[row_number].played = False with Session() as session:
self.invalidate_row(row_number) for row_number in row_numbers:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if not plr:
return
plr.played = False
self.refresh_row(session, row_number)
self.invalidate_rows(row_numbers)
def move_rows(self, from_rows: List[int], to_row_number: int) -> None: def move_rows(self, from_rows: List[int], to_row_number: int) -> None:
""" """
@ -676,6 +692,7 @@ class PlaylistModel(QAbstractTableModel):
from_rows, range(next_to_row, next_to_row + len(from_rows)) from_rows, range(next_to_row, next_to_row + len(from_rows))
): ):
row_map[from_row] = to_row row_map[from_row] = to_row
# Move the remaining rows to the row_map. We want to fill it # Move the remaining rows to the row_map. We want to fill it
# before (if there are gaps) and after (likewise) the rows that # before (if there are gaps) and after (likewise) the rows that
# are moving. # are moving.
@ -693,6 +710,14 @@ class PlaylistModel(QAbstractTableModel):
old_row, HEADER_NOTES_COLUMN, 1, 1 old_row, HEADER_NOTES_COLUMN, 1, 1
) )
# Check to see whether any rows in track_sequence have moved
if track_sequence.previous.plr_rownum in row_map:
track_sequence.previous.plr_rownum = row_map[track_sequence.previous.plr_rownum]
if track_sequence.now.plr_rownum in row_map:
track_sequence.now.plr_rownum = row_map[track_sequence.now.plr_rownum]
if track_sequence.next.plr_rownum in row_map:
track_sequence.next.plr_rownum = row_map[track_sequence.next.plr_rownum]
# For SQLAlchemy, build a list of dictionaries that map plrid to # For SQLAlchemy, build a list of dictionaries that map plrid to
# new row number: # new row number:
sqla_map: List[dict[str, int]] = [] sqla_map: List[dict[str, int]] = []
@ -706,6 +731,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session) self.refresh_data(session)
# Update display # Update display
self.update_track_times()
self.invalidate_rows(list(row_map.keys())) self.invalidate_rows(list(row_map.keys()))
def open_in_audacity(self, row_number: int) -> None: def open_in_audacity(self, row_number: int) -> None:
@ -753,7 +779,7 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_rows[p.plr_rownum] = PlaylistRowData(p) self.playlist_rows[p.plr_rownum] = PlaylistRowData(p)
def refresh_row(self, session, row_number): def refresh_row(self, session, row_number):
"""Populate dict for one row for data calls""" """Populate dict for one row from database"""
p = PlaylistRows.deep_row(session, self.playlist_id, row_number) p = PlaylistRows.deep_row(session, self.playlist_id, row_number)
self.playlist_rows[row_number] = PlaylistRowData(p) self.playlist_rows[row_number] = PlaylistRowData(p)
@ -897,35 +923,44 @@ class PlaylistModel(QAbstractTableModel):
Sort selected rows by artist Sort selected rows by artist
""" """
pass self.sort_by_attribute(row_numbers, 'artist')
def sort_by_duration(self, row_numbers: List[int]) -> None: def sort_by_attribute(self, row_numbers: List[int], attr_name: str) -> None:
""" """
Sort selected rows by duration Sort selected rows by passed attribute name where 'attribute' is a
""" key in PlaylistRowData
pass
def sort_by_lastplayed(self, row_numbers: List[int]) -> None:
"""
Sort selected rows by lastplayed
"""
pass
def sort_by_title(self, row_numbers: List[int]) -> None:
"""
Sort selected rows by title
""" """
# Create a subset of playlist_rows with the rows we are # Create a subset of playlist_rows with the rows we are
# interested in # interested in
shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers} shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers}
sorted_list = [ sorted_list = [
k for k, v in sorted(shortlist_rows.items(), key=lambda item: item[1].title) plr.plr_rownum for plr in
sorted(shortlist_rows.values(), key=attrgetter(attr_name))
] ]
self.move_rows(sorted_list, min(sorted_list)) self.move_rows(sorted_list, min(sorted_list))
def sort_by_duration(self, row_numbers: List[int]) -> None:
"""
Sort selected rows by duration
"""
self.sort_by_attribute(row_numbers, 'duration')
def sort_by_lastplayed(self, row_numbers: List[int]) -> None:
"""
Sort selected rows by lastplayed
"""
self.sort_by_attribute(row_numbers, 'lastplayed')
def sort_by_title(self, row_numbers: List[int]) -> None:
"""
Sort selected rows by title
"""
self.sort_by_attribute(row_numbers, 'title')
def supportedDropActions(self) -> Qt.DropAction: def supportedDropActions(self) -> Qt.DropAction:
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
@ -938,21 +973,22 @@ class PlaylistModel(QAbstractTableModel):
update_rows: List[int] = [] update_rows: List[int] = []
for row_number in range(len(self.playlist_rows)): for row_number in range(len(self.playlist_rows)):
stend = self.start_end_times[row_number] = StartEndTimes()
prd = self.playlist_rows[row_number] prd = self.playlist_rows[row_number]
# Reset start_time if this is the current row # Reset start_time if this is the current row
if row_number == track_sequence.now.plr_rownum: if row_number == track_sequence.now.plr_rownum:
# Start/end times for current track are set in current_track_started stend.start_time = track_sequence.now.start_time
stend.end_time = track_sequence.now.end_time
if not next_start_time: if not next_start_time:
next_start_time = prd.end_time next_start_time = stend.end_time
continue continue
# Set start time for next row if we have a current track # Set start time for next row if we have a current track
current_end_time = track_sequence.now.end_time if row_number == track_sequence.next.plr_rownum and track_sequence.now.end_time:
if row_number == track_sequence.next.plr_rownum and current_end_time: stend.start_time = track_sequence.now.end_time
prd.start_time = current_end_time stend.end_time = stend.start_time + timedelta(milliseconds=prd.duration)
prd.end_time = current_end_time + timedelta(milliseconds=prd.duration) next_start_time = stend.end_time
next_start_time = prd.end_time
update_rows.append(row_number) update_rows.append(row_number)
continue continue
@ -973,14 +1009,11 @@ class PlaylistModel(QAbstractTableModel):
< row_number < row_number
< track_sequence.next.plr_rownum < track_sequence.next.plr_rownum
): ):
prd.start_time = None
prd.end_time = None
update_rows.append(row_number) update_rows.append(row_number)
continue continue
# Reset start time if timing in header or at current track # Reset start time if timing in header or at current track
if not prd.path: if self.is_header_row(row_number):
# This is a header row
header_time = get_embedded_time(prd.note) header_time = get_embedded_time(prd.note)
if header_time: if header_time:
next_start_time = header_time next_start_time = header_time
@ -989,14 +1022,14 @@ class PlaylistModel(QAbstractTableModel):
# start time # start time
if next_start_time is None: if next_start_time is None:
continue continue
if prd.start_time != next_start_time: if stend.start_time != next_start_time:
prd.start_time = next_start_time stend.start_time = next_start_time
update_rows.append(row_number) update_rows.append(row_number)
next_start_time += timedelta( next_start_time += timedelta(
milliseconds=self.playlist_rows[row_number].duration milliseconds=self.playlist_rows[row_number].duration
) )
if prd.end_time != next_start_time: if stend.end_time != next_start_time:
prd.end_time = next_start_time stend.end_time = next_start_time
update_rows.append(row_number) update_rows.append(row_number)
# Update start/stop times of rows that have changed # Update start/stop times of rows that have changed

View File

@ -1103,11 +1103,15 @@ class PlaylistTab(QTableView):
# Mark unplayed # Mark unplayed
if track_row and model.is_unplayed_row(row_number): if track_row and model.is_unplayed_row(row_number):
self._add_context_menu("Mark unplayed", lambda: model.mark_unplayed(row_number)) self._add_context_menu(
"Mark unplayed", lambda: model.mark_unplayed(self._get_selected_rows())
)
# Unmark as next # Unmark as next
if next_row: if next_row:
self._add_context_menu("Unmark as next track", lambda: model.set_next_row(None)) self._add_context_menu(
"Unmark as next track", lambda: model.set_next_row(None)
)
# ---------------------- # ----------------------
self.menu.addSeparator() self.menu.addSeparator()
@ -1134,9 +1138,6 @@ class PlaylistTab(QTableView):
lambda: model.sort_by_lastplayed(self._get_selected_rows()), lambda: model.sort_by_lastplayed(self._get_selected_rows()),
parent_menu=sort_menu, parent_menu=sort_menu,
) )
if sort_menu:
sort_menu.setEnabled(model.selection_is_sortable(self._get_selected_rows()))
self._add_context_menu("Undo sort", self._sort_undo, not bool(self.sort_undo))
# Info TODO # Info TODO
if track_row: if track_row:
@ -1144,8 +1145,7 @@ class PlaylistTab(QTableView):
# Track path TODO # Track path TODO
if track_row: if track_row:
self._add_context_menu( self._add_context_menu("Copy track path", lambda: print("Track path"))
"Copy track path", lambda: print("Track path"))
def _calculate_end_time( def _calculate_end_time(
self, start: Optional[datetime], duration: int self, start: Optional[datetime], duration: int

View File

@ -202,7 +202,7 @@ def test_insert_header_row_end(monkeypatch, session):
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
# handled differently (sets up row span) # handled differently (sets up row span)
assert ( assert (
model.edit_role(model.rowCount(), playlistmodel.Col.NOTE.value, prd) model.edit_role(model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd)
== note_text == note_text
) )
@ -222,7 +222,7 @@ def test_insert_header_row_middle(monkeypatch, session):
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
# handled differently (sets up row span) # handled differently (sets up row span)
assert ( assert (
model.edit_role(model.rowCount(), playlistmodel.Col.NOTE.value, prd) model.edit_role(model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd)
== note_text == note_text
) )