V3 tweaks and polishes

This commit is contained in:
Keith Edmunds 2023-11-28 14:29:09 +00:00
parent 63a38b5bf9
commit 3179c6f5de
11 changed files with 313 additions and 362 deletions

View File

@ -104,10 +104,6 @@ class PlaylistTrack:
def __init__(self) -> None:
"""
Only initialises data structure. Call set_plr to populate.
Do NOT store row_number here - that changes if tracks are reordered
in playlist (add, remove, drag/drop) and we shouldn't care about row
number: that's the playlist's problem.
"""
self.artist: Optional[str] = None

View File

@ -32,16 +32,6 @@ class Config(object):
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_BITRATE = "bps"
COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_NOTES = "Notes"
COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title"
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ["dbconfig"]

View File

@ -31,9 +31,7 @@ def Session() -> Generator[scoped_session, None, None]:
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine))
log.debug(
f"Session acquired: {file}:{function}:{lineno} " f"[{hex(id(Session))}]"
)
log.debug(f"Session acquired: {file}:{function}:{lineno} " f"[{hex(id(Session))}]")
yield Session
log.debug(f" Session released [{hex(id(Session))}]")
Session.commit()

View File

@ -87,7 +87,7 @@ class TrackSelectDialog(QDialog):
default_yes=True,
):
move_existing = True
if self.add_to_header and existing_prd: # and existing_prd for mypy's benefit
if self.add_to_header and existing_prd: # "and existing_prd" for mypy's benefit
if move_existing:
self.model.move_track_to_header(self.new_row_number, existing_prd, note)
else:
@ -95,7 +95,7 @@ class TrackSelectDialog(QDialog):
# Close dialog - we can only add one track to a header
self.accept()
else:
if move_existing and existing_prd: # and existing_prd for mypy's benefit
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.model.move_track_add_note(self.new_row_number, existing_prd, note)
else:
self.model.insert_row(self.new_row_number, track_id, note)

View File

@ -14,7 +14,7 @@ from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects
from pydub.utils import mediainfo
from PyQt6.QtWidgets import QMainWindow, QMessageBox # type: ignore
from PyQt6.QtWidgets import QMainWindow, QMessageBox
from tinytag import TinyTag # type: ignore
from config import Config
@ -99,7 +99,7 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
def get_embedded_time(text: str) -> Optional[datetime]:
"""Return datetime specified as @hh:mm:ss in text"""
"""Return datetime specified as @hh:mm in text"""
try:
match = start_time_re.search(text)
@ -171,7 +171,7 @@ def get_relative_date(
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Same day so return time instead
return past_date.strftime("%H:%M")
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
else:
@ -226,32 +226,6 @@ def leading_silence(
return min(trim_ms, len(audio_segment))
def send_mail(to_addr, from_addr, subj, body):
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def ms_to_mmss(
ms: Optional[int],
decimals: int = 0,
@ -390,6 +364,32 @@ def open_in_audacity(path: str) -> bool:
return True
def send_mail(to_addr, from_addr, subj, body):
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def set_track_metadata(track):
"""Set/update track metadata in database"""

View File

@ -307,8 +307,7 @@ class Playlists(Base):
"""
return session.scalars(
select(cls).where(cls.open.is_(True))
.order_by(cls.tab)
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
).all()
def mark_open(self) -> None:
@ -323,10 +322,10 @@ class Playlists(Base):
Return True if no playlist of this name exists else false.
"""
return session.execute(
select(Playlists)
.where(Playlists.name == name)
).first() is None
return (
session.execute(select(Playlists).where(Playlists.name == name)).first()
is None
)
def rename(self, session: scoped_session, new_name: str) -> None:
"""
@ -479,9 +478,7 @@ class PlaylistRows(Base):
session.flush()
@staticmethod
def delete_row(
session: scoped_session, playlist_id: int, row_number: int
) -> None:
def delete_row(session: scoped_session, playlist_id: int, row_number: int) -> None:
"""
Delete passed row in given playlist.
"""

View File

@ -1,8 +1,6 @@
# import os
import threading
import vlc # type: ignore
#
from config import Config
from helpers import file_is_unreadable
from typing import Optional
@ -10,7 +8,7 @@ from time import sleep
from log import log
from PyQt6.QtCore import ( # type: ignore
from PyQt6.QtCore import (
QRunnable,
QThreadPool,
)

View File

@ -170,7 +170,7 @@ class PlaylistModel(QAbstractTableModel):
if plr:
# Add track to PlaylistRows
plr.track_id = track_id
# Add any further note
# Add any further note (header will already have a note)
if note:
plr.note += "\n" + note
# Reset header row spanning
@ -262,9 +262,11 @@ class PlaylistModel(QAbstractTableModel):
# Check for OBS scene change
self.obs_scene_change(row_number)
# Update Playdates in database
with Session() as session:
# Update Playdates in database
Playdates(session, track_sequence.now.track_id)
# Mark track as played in playlist
plr = session.get(PlaylistRows, track_sequence.now.plr_id)
if plr:
plr.played = True
@ -305,32 +307,29 @@ class PlaylistModel(QAbstractTableModel):
prd = self.playlist_rows[row]
# Dispatch to role-specific functions
if role == Qt.ItemDataRole.DisplayRole:
return self.display_role(row, column, prd)
elif role == Qt.ItemDataRole.DecorationRole:
pass
elif role == Qt.ItemDataRole.EditRole:
return self.edit_role(row, column, prd)
elif role == Qt.ItemDataRole.ToolTipRole:
pass
elif role == Qt.ItemDataRole.StatusTipRole:
pass
elif role == Qt.ItemDataRole.WhatsThisRole:
pass
elif role == Qt.ItemDataRole.SizeHintRole:
pass
elif role == Qt.ItemDataRole.FontRole:
return self.font_role(row, column, prd)
elif role == Qt.ItemDataRole.TextAlignmentRole:
pass
elif role == Qt.ItemDataRole.BackgroundRole:
return self.background_role(row, column, prd)
elif role == Qt.ItemDataRole.ForegroundRole:
pass
elif role == Qt.ItemDataRole.CheckStateRole:
pass
elif role == Qt.ItemDataRole.InitialSortOrderRole:
pass
dispatch_table = {
int(Qt.ItemDataRole.DisplayRole): self.display_role,
int(Qt.ItemDataRole.EditRole): self.edit_role,
int(Qt.ItemDataRole.FontRole): self.font_role,
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
}
if role in dispatch_table:
return dispatch_table[role](row, column, prd)
# Document other roles but don't use them
if role in [
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.ToolTipRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.WhatsThisRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.ForegroundRole,
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.InitialSortOrderRole,
]:
return QVariant()
# Fall through to no-op
return QVariant()
@ -363,36 +362,39 @@ class PlaylistModel(QAbstractTableModel):
self.signals.span_cells_signal.emit(
row, HEADER_NOTES_COLUMN, 1, self.columnCount() - 1
)
return QVariant(self.header_text(prd))
header_text = self.header_text(prd)
if not header_text:
return QVariant(Config.TEXT_NO_TRACK_NO_NOTE)
else:
return QVariant(self.header_text(prd))
else:
return QVariant()
if column == Col.START_GAP.value:
return QVariant(prd.start_gap)
if column == Col.TITLE.value:
return QVariant(prd.title)
if column == Col.ARTIST.value:
return QVariant(prd.artist)
if column == Col.DURATION.value:
return QVariant(ms_to_mmss(prd.duration))
if column == Col.START_TIME.value:
if row in self.start_end_times:
start_time = self.start_end_times[row].start_time
if start_time:
return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant()
if column == Col.END_TIME.value:
if row in self.start_end_times:
end_time = self.start_end_times[row].end_time
if end_time:
return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant()
if column == Col.LAST_PLAYED.value:
return QVariant(get_relative_date(prd.lastplayed))
if column == Col.BITRATE.value:
return QVariant(prd.bitrate)
if column == Col.NOTE.value:
return QVariant(prd.note)
dispatch_table = {
Col.START_GAP.value: QVariant(prd.start_gap),
Col.TITLE.value: QVariant(prd.title),
Col.ARTIST.value: QVariant(prd.artist),
Col.DURATION.value: QVariant(ms_to_mmss(prd.duration)),
Col.LAST_PLAYED.value: QVariant(get_relative_date(prd.lastplayed)),
Col.BITRATE.value: QVariant(prd.bitrate),
Col.NOTE.value: QVariant(prd.note),
}
if column in dispatch_table:
return dispatch_table[column]
return QVariant()
@ -408,26 +410,6 @@ class PlaylistModel(QAbstractTableModel):
super().endResetModel()
self.row_order_changed(self.playlist_id)
def get_duplicate_rows(self) -> List[int]:
"""
Return a list of duplicate rows. If track appears in rows 2, 3 and 4, return [3, 4]
(ie, ignore the first, not-yet-duplicate, track).
"""
found = []
result = []
for i in range(len(self.playlist_rows)):
track_id = self.playlist_rows[i].track_id
if track_id is None:
continue
if track_id in found:
result.append(i)
else:
found.append(track_id)
return result
def edit_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
"""
Return text for editing
@ -479,6 +461,26 @@ class PlaylistModel(QAbstractTableModel):
return QVariant(boldfont)
def get_duplicate_rows(self) -> List[int]:
"""
Return a list of duplicate rows. If track appears in rows 2, 3 and 4, return [3, 4]
(ie, ignore the first, not-yet-duplicate, track).
"""
found = []
result = []
for i in range(len(self.playlist_rows)):
track_id = self.playlist_rows[i].track_id
if track_id is None:
continue
if track_id in found:
result.append(i)
else:
found.append(track_id)
return result
def _get_new_row_number(self, proposed_row_number: Optional[int]) -> int:
"""
Sanitises proposed new row number.
@ -498,6 +500,13 @@ class PlaylistModel(QAbstractTableModel):
return new_row_number
def get_row_info(self, row_number: int) -> PlaylistRowData:
"""
Return info about passed row
"""
return self.playlist_rows[row_number]
def get_row_track_path(self, row_number: int) -> str:
"""
Return path of track associated with row or empty string if no track associated
@ -516,13 +525,6 @@ class PlaylistModel(QAbstractTableModel):
return duration
def get_row_info(self, row_number: int) -> PlaylistRowData:
"""
Return info about passed row
"""
return self.playlist_rows[row_number]
def get_unplayed_rows(self) -> List[int]:
"""
Return a list of unplayed row numbers
@ -677,22 +679,6 @@ class PlaylistModel(QAbstractTableModel):
if self.is_played_row(row_number):
self.invalidate_row(row_number)
def is_header_row(self, row_number: int) -> bool:
"""
Return True if row is a header row, else False
"""
if row_number in self.playlist_rows:
return self.playlist_rows[row_number].path == ""
return False
def is_played_row(self, row_number: int) -> bool:
"""
Return True if row is an unplayed track row, else False
"""
return self.playlist_rows[row_number].played
def insert_row(
self,
proposed_row_number: Optional[int],
@ -737,6 +723,22 @@ class PlaylistModel(QAbstractTableModel):
for modified_row in modified_rows:
self.invalidate_row(modified_row)
def is_header_row(self, row_number: int) -> bool:
"""
Return True if row is a header row, else False
"""
if row_number in self.playlist_rows:
return self.playlist_rows[row_number].path == ""
return False
def is_played_row(self, row_number: int) -> bool:
"""
Return True if row is an unplayed track row, else False
"""
return self.playlist_rows[row_number].played
def is_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
"""
If this track_id is in the playlist, return the PlaylistRowData object
@ -749,21 +751,6 @@ class PlaylistModel(QAbstractTableModel):
return None
def mark_unplayed(self, row_numbers: List[int]) -> None:
"""
Mark row as unplayed
"""
with Session() as session:
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:
"""
Move the playlist rows given to to_row and below.
@ -829,6 +816,21 @@ class PlaylistModel(QAbstractTableModel):
self.signals.row_order_changed_signal.emit(self.playlist_id)
self.invalidate_rows(list(row_map.keys()))
def mark_unplayed(self, row_numbers: List[int]) -> None:
"""
Mark row as unplayed
"""
with Session() as session:
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_between_playlists(
self, from_rows: List[int], to_row_number: int, to_playlist_id: int
) -> None:
@ -969,7 +971,6 @@ class PlaylistModel(QAbstractTableModel):
Actions required:
- sanity check
- update display
- update track times
"""
# Sanity check
@ -1124,7 +1125,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.next_track_changed_signal.emit()
return
# Update playing_trtack
# Update playing_track
with Session() as session:
track_sequence.next = PlaylistTrack()
try:
@ -1331,14 +1332,14 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def __init__(
self,
playlist_model: PlaylistModel,
data_model: PlaylistModel,
*args,
**kwargs,
):
self.playlist_model = playlist_model
self.data_model = data_model
super().__init__(*args, **kwargs)
self.setSourceModel(playlist_model)
self.setSourceModel(data_model)
# Search all columns
self.setFilterKeyColumn(-1)
@ -1347,8 +1348,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
Subclass to filter by played status
"""
if self.playlist_model.played_tracks_hidden:
if self.playlist_model.is_played_row(source_row):
if self.data_model.played_tracks_hidden:
if self.data_model.is_played_row(source_row):
# Don't hide current or next track
with Session() as session:
if track_sequence.next.plr_id:
@ -1356,7 +1357,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if (
next_plr
and next_plr.plr_rownum == source_row
and next_plr.playlist_id == self.playlist_model.playlist_id
and next_plr.playlist_id == self.data_model.playlist_id
):
return True
if track_sequence.now.plr_id:
@ -1386,25 +1387,25 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# ######################################
def current_track_started(self):
return self.playlist_model.current_track_started()
return self.data_model.current_track_started()
def delete_rows(self, row_numbers: List[int]) -> None:
return self.playlist_model.delete_rows(row_numbers)
return self.data_model.delete_rows(row_numbers)
def get_duplicate_rows(self) -> List[int]:
return self.playlist_model.get_duplicate_rows()
return self.data_model.get_duplicate_rows()
def get_rows_duration(self, row_numbers: List[int]) -> int:
return self.playlist_model.get_rows_duration(row_numbers)
return self.data_model.get_rows_duration(row_numbers)
def get_row_info(self, row_number: int) -> PlaylistRowData:
return self.playlist_model.get_row_info(row_number)
return self.data_model.get_row_info(row_number)
def get_row_track_path(self, row_number: int) -> str:
return self.playlist_model.get_row_track_path(row_number)
return self.data_model.get_row_track_path(row_number)
def hide_played_tracks(self, hide: bool) -> None:
return self.playlist_model.hide_played_tracks(hide)
return self.data_model.hide_played_tracks(hide)
def insert_row(
self,
@ -1412,70 +1413,68 @@ class PlaylistProxyModel(QSortFilterProxyModel):
track_id: Optional[int] = None,
note: Optional[str] = None,
) -> None:
return self.playlist_model.insert_row(proposed_row_number, track_id, note)
return self.data_model.insert_row(proposed_row_number, track_id, note)
def is_header_row(self, row_number: int) -> bool:
return self.playlist_model.is_header_row(row_number)
return self.data_model.is_header_row(row_number)
def is_played_row(self, row_number: int) -> bool:
return self.playlist_model.is_played_row(row_number)
return self.data_model.is_played_row(row_number)
def is_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
return self.playlist_model.is_track_in_playlist(track_id)
return self.data_model.is_track_in_playlist(track_id)
def mark_unplayed(self, row_numbers: List[int]) -> None:
return self.playlist_model.mark_unplayed(row_numbers)
return self.data_model.mark_unplayed(row_numbers)
def move_rows(self, from_rows: List[int], to_row_number: int) -> None:
return self.playlist_model.move_rows(from_rows, to_row_number)
return self.data_model.move_rows(from_rows, to_row_number)
def move_rows_between_playlists(
self, from_rows: List[int], to_row_number: int, to_playlist_id: int
) -> None:
return self.playlist_model.move_rows_between_playlists(
return self.data_model.move_rows_between_playlists(
from_rows, to_row_number, to_playlist_id
)
def move_track_add_note(
self, new_row_number: int, existing_prd: PlaylistRowData, note: str
) -> None:
return self.playlist_model.move_track_add_note(
new_row_number, existing_prd, note
)
return self.data_model.move_track_add_note(new_row_number, existing_prd, note)
def move_track_to_header(
self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str]
) -> None:
return self.playlist_model.move_track_to_header(
return self.data_model.move_track_to_header(
header_row_number, existing_prd, note
)
def open_in_audacity(self, row_number: int) -> None:
return self.playlist_model.open_in_audacity(row_number)
return self.data_model.open_in_audacity(row_number)
def previous_track_ended(self) -> None:
return self.playlist_model.previous_track_ended()
return self.data_model.previous_track_ended()
def remove_track(self, row_number: int) -> None:
return self.playlist_model.remove_track(row_number)
return self.data_model.remove_track(row_number)
def rescan_track(self, row_number: int) -> None:
return self.playlist_model.rescan_track(row_number)
return self.data_model.rescan_track(row_number)
def set_next_row(self, row_number: Optional[int]) -> None:
return self.playlist_model.set_next_row(row_number)
return self.data_model.set_next_row(row_number)
def sort_by_artist(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_artist(row_numbers)
return self.data_model.sort_by_artist(row_numbers)
def sort_by_duration(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_duration(row_numbers)
return self.data_model.sort_by_duration(row_numbers)
def sort_by_lastplayed(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_lastplayed(row_numbers)
return self.data_model.sort_by_lastplayed(row_numbers)
def sort_by_title(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_title(row_numbers)
return self.data_model.sort_by_title(row_numbers)
def update_track_times(self) -> None:
return self.playlist_model.update_track_times()
return self.data_model.update_track_times()

View File

@ -34,6 +34,7 @@ from config import Config
from helpers import (
ask_yes_no,
ms_to_mmss,
show_OK,
show_warning,
)
from models import Settings
@ -50,9 +51,9 @@ class EscapeDelegate(QStyledItemDelegate):
- checks with user before abandoning edit on Escape
"""
def __init__(self, parent, playlist_model: PlaylistModel) -> None:
def __init__(self, parent, data_model: PlaylistModel) -> None:
super().__init__(parent)
self.playlist_model = playlist_model
self.data_model = data_model
self.signals = MusicMusterSignals()
def createEditor(
@ -113,7 +114,7 @@ class EscapeDelegate(QStyledItemDelegate):
else:
edit_index = index
value = self.playlist_model.data(edit_index, Qt.ItemDataRole.EditRole)
value = self.data_model.data(edit_index, Qt.ItemDataRole.EditRole)
editor.setPlainText(value.value())
def setModelData(self, editor, model, index):
@ -124,7 +125,7 @@ class EscapeDelegate(QStyledItemDelegate):
edit_index = index
value = editor.toPlainText().strip()
self.playlist_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
self.data_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
@ -149,6 +150,10 @@ class PlaylistStyle(QProxyStyle):
class PlaylistTab(QTableView):
"""
The playlist view
"""
def __init__(
self,
musicmuster: "Window",
@ -161,9 +166,9 @@ class PlaylistTab(QTableView):
self.playlist_id = playlist_id
# Set up widget
self.playlist_model = PlaylistModel(playlist_id)
self.proxy_model = PlaylistProxyModel(self.playlist_model)
self.setItemDelegate(EscapeDelegate(self, self.playlist_model))
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)
@ -190,10 +195,6 @@ class PlaylistTab(QTableView):
self.signals.resize_rows_signal.connect(self.resizeRowsToContents)
self.signals.span_cells_signal.connect(self._span_cells)
# Initialise miscellaneous instance variables
self.search_text: str = ""
self.sort_undo: List[int] = []
# Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
@ -202,6 +203,8 @@ class PlaylistTab(QTableView):
self.setModel(self.proxy_model)
self._set_column_widths()
# ########## Overrident class functions ##########
def closeEditor(
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
) -> None:
@ -220,7 +223,7 @@ class PlaylistTab(QTableView):
# Update start times in case a start time in a note has been
# edited
self.playlist_model.update_track_times()
self.data_model.update_track_times()
def dropEvent(self, event):
if event.source() is not self or (
@ -240,6 +243,7 @@ class PlaylistTab(QTableView):
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Deselect rows
self.clear_selection()
@ -252,7 +256,7 @@ class PlaylistTab(QTableView):
event: Optional[QEvent],
) -> bool:
"""
Override PySide2.QAbstractItemView.edit to catch when editing starts
Override QAbstractItemView.edit to catch when editing starts
Editing only ever starts with a double click on a cell
"""
@ -264,6 +268,43 @@ class PlaylistTab(QTableView):
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,
@ -286,121 +327,6 @@ class PlaylistTab(QTableView):
return menu_item
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)
# # ########## Externally called functions ##########
def clear_selection(self) -> None:
"""Unselect all tracks and reset drag mode"""
self.clearSelection()
self.setDragEnabled(False)
def selected_display_row_number(self):
"""
Return the selected row number or None if none selected.
"""
row_index = self._selected_row_index()
if row_index:
return row_index.row()
else:
return None
return row_index.row()
def selected_display_row_numbers(self):
"""
Return a list of the selected row numbers
"""
indexes = self._selected_row_indexes()
return [a.row() for a in indexes]
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, "No or multiple rows selected", "Select only one row"
)
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 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.playlist_model.get_row_track_path(model_row_number)
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.playlist_model.set_next_row(model_row_number)
self.clearSelection()
# # # ########## Internally called functions ##########
def _add_track(self) -> None:
"""Add a track to a section header making it a normal track row"""
@ -412,7 +338,7 @@ class PlaylistTab(QTableView):
dlg = TrackSelectDialog(
session=session,
new_row_number=model_row_number,
model=self.playlist_model,
model=self.data_model,
add_to_header=True,
)
dlg.exec()
@ -516,6 +442,12 @@ class PlaylistTab(QTableView):
"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.
@ -546,7 +478,7 @@ class PlaylistTab(QTableView):
to the clipboard. Otherwise, return None.
"""
track_path = self.playlist_model.get_row_info(row_number).path
track_path = self.data_model.get_row_info(row_number).path
if not track_path:
return
@ -583,9 +515,20 @@ class PlaylistTab(QTableView):
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
return
self.playlist_model.delete_rows(self.selected_model_row_numbers())
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"""
@ -596,7 +539,7 @@ class PlaylistTab(QTableView):
def _info_row(self, row_number: int) -> None:
"""Display popup with info re row"""
prd = self.playlist_model.get_row_info(row_number)
prd = self.data_model.get_row_info(row_number)
if prd:
txt = (
f"Title: {prd.title}\n"
@ -610,23 +553,18 @@ class PlaylistTab(QTableView):
else:
txt = f"Can't find info about row{row_number}"
info: QMessageBox = QMessageBox(self)
info.setIcon(QMessageBox.Icon.Information)
info.setText(txt)
info.setStandardButtons(QMessageBox.StandardButton.Ok)
info.setDefaultButton(QMessageBox.StandardButton.Cancel)
info.exec()
show_OK(self.musicmuster, "Track info", txt)
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Rescan track"""
self.playlist_model.mark_unplayed(row_numbers)
self.data_model.mark_unplayed(row_numbers)
self.clear_selection()
def _rescan(self, row_number: int) -> None:
"""Rescan track"""
self.playlist_model.rescan_track(row_number)
self.data_model.rescan_track(row_number)
self.clear_selection()
def scroll_to_top(self, row_number: int) -> None:
@ -653,36 +591,62 @@ class PlaylistTab(QTableView):
# We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows
duplicate_rows = self.playlist_model.get_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 selectionChanged(
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
def selected_model_row_number(self) -> Optional[int]:
"""
Toggle drag behaviour according to whether rows are selected
Return the model row number corresponding to the selected row or None
"""
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.playlist_model.get_rows_duration(
self.get_selected_rows()
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, "No or multiple rows selected", "Select only one row"
)
if selected_duration > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"
)
else:
self.musicmuster.lblSumPlaytime.setText("")
return None
super().selectionChanged(selected, deselected)
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"""
@ -704,6 +668,17 @@ class PlaylistTab(QTableView):
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, row: int, column: int, rowSpan: int, columnSpan: int) -> None:
"""
Implement spanning of cells, initiated by signal
@ -711,9 +686,7 @@ class PlaylistTab(QTableView):
model = self.proxy_model
if hasattr(model, "mapToSource"):
edit_index = model.mapFromSource(
self.playlist_model.createIndex(row, column)
)
edit_index = model.mapFromSource(self.data_model.createIndex(row, column))
row = edit_index.row()
column = edit_index.column()
@ -731,5 +704,5 @@ class PlaylistTab(QTableView):
def _unmark_as_next(self) -> None:
"""Rescan track"""
self.playlist_model.set_next_row(None)
self.data_model.set_next_row(None)
self.clear_selection()

View File

@ -1,4 +1,4 @@
# #!/usr/bin/env python
#!/usr/bin/env python
#
import os

View File

@ -45,7 +45,7 @@ def test_get_relative_date():
assert get_relative_date(None) == "Never"
today_at_10 = datetime.now().replace(hour=10, minute=0)
today_at_11 = datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "10:00"
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - timedelta(days=16)