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: def __init__(self) -> None:
""" """
Only initialises data structure. Call set_plr to populate. 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 self.artist: Optional[str] = None

View File

@ -32,16 +32,6 @@ class Config(object):
COLOUR_ODD_PLAYLIST = "#f2f2f2" COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" 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 DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = [] DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ["dbconfig"] DEBUG_MODULES: List[Optional[str]] = ["dbconfig"]

View File

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

View File

@ -87,7 +87,7 @@ class TrackSelectDialog(QDialog):
default_yes=True, default_yes=True,
): ):
move_existing = 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: if move_existing:
self.model.move_track_to_header(self.new_row_number, existing_prd, note) self.model.move_track_to_header(self.new_row_number, existing_prd, note)
else: else:
@ -95,7 +95,7 @@ class TrackSelectDialog(QDialog):
# Close dialog - we can only add one track to a header # Close dialog - we can only add one track to a header
self.accept() self.accept()
else: 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) self.model.move_track_add_note(self.new_row_number, existing_prd, note)
else: else:
self.model.insert_row(self.new_row_number, track_id, note) 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 mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects from pydub import AudioSegment, effects
from pydub.utils import mediainfo 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 tinytag import TinyTag # type: ignore
from config import Config from config import Config
@ -99,7 +99,7 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
def get_embedded_time(text: str) -> Optional[datetime]: 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: try:
match = start_time_re.search(text) 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) weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0: if weeks == days == 0:
# Same day so return time instead # 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: if weeks == 1:
weeks_str = "week" weeks_str = "week"
else: else:
@ -226,32 +226,6 @@ def leading_silence(
return min(trim_ms, len(audio_segment)) 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( def ms_to_mmss(
ms: Optional[int], ms: Optional[int],
decimals: int = 0, decimals: int = 0,
@ -390,6 +364,32 @@ def open_in_audacity(path: str) -> bool:
return True 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): def set_track_metadata(track):
"""Set/update track metadata in database""" """Set/update track metadata in database"""

View File

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

View File

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

View File

@ -170,7 +170,7 @@ class PlaylistModel(QAbstractTableModel):
if plr: if plr:
# Add track to PlaylistRows # Add track to PlaylistRows
plr.track_id = track_id plr.track_id = track_id
# Add any further note # Add any further note (header will already have a note)
if note: if note:
plr.note += "\n" + note plr.note += "\n" + note
# Reset header row spanning # Reset header row spanning
@ -262,9 +262,11 @@ class PlaylistModel(QAbstractTableModel):
# Check for OBS scene change # Check for OBS scene change
self.obs_scene_change(row_number) self.obs_scene_change(row_number)
# Update Playdates in database
with Session() as session: with Session() as session:
# Update Playdates in database
Playdates(session, track_sequence.now.track_id) Playdates(session, track_sequence.now.track_id)
# Mark track as played in playlist
plr = session.get(PlaylistRows, track_sequence.now.plr_id) plr = session.get(PlaylistRows, track_sequence.now.plr_id)
if plr: if plr:
plr.played = True plr.played = True
@ -305,32 +307,29 @@ class PlaylistModel(QAbstractTableModel):
prd = self.playlist_rows[row] prd = self.playlist_rows[row]
# Dispatch to role-specific functions # Dispatch to role-specific functions
if role == Qt.ItemDataRole.DisplayRole: dispatch_table = {
return self.display_role(row, column, prd) int(Qt.ItemDataRole.DisplayRole): self.display_role,
elif role == Qt.ItemDataRole.DecorationRole: int(Qt.ItemDataRole.EditRole): self.edit_role,
pass int(Qt.ItemDataRole.FontRole): self.font_role,
elif role == Qt.ItemDataRole.EditRole: int(Qt.ItemDataRole.BackgroundRole): self.background_role,
return self.edit_role(row, column, prd) }
elif role == Qt.ItemDataRole.ToolTipRole:
pass if role in dispatch_table:
elif role == Qt.ItemDataRole.StatusTipRole: return dispatch_table[role](row, column, prd)
pass
elif role == Qt.ItemDataRole.WhatsThisRole: # Document other roles but don't use them
pass if role in [
elif role == Qt.ItemDataRole.SizeHintRole: Qt.ItemDataRole.DecorationRole,
pass Qt.ItemDataRole.ToolTipRole,
elif role == Qt.ItemDataRole.FontRole: Qt.ItemDataRole.StatusTipRole,
return self.font_role(row, column, prd) Qt.ItemDataRole.WhatsThisRole,
elif role == Qt.ItemDataRole.TextAlignmentRole: Qt.ItemDataRole.SizeHintRole,
pass Qt.ItemDataRole.TextAlignmentRole,
elif role == Qt.ItemDataRole.BackgroundRole: Qt.ItemDataRole.ForegroundRole,
return self.background_role(row, column, prd) Qt.ItemDataRole.CheckStateRole,
elif role == Qt.ItemDataRole.ForegroundRole: Qt.ItemDataRole.InitialSortOrderRole,
pass ]:
elif role == Qt.ItemDataRole.CheckStateRole: return QVariant()
pass
elif role == Qt.ItemDataRole.InitialSortOrderRole:
pass
# Fall through to no-op # Fall through to no-op
return QVariant() return QVariant()
@ -363,36 +362,39 @@ class PlaylistModel(QAbstractTableModel):
self.signals.span_cells_signal.emit( self.signals.span_cells_signal.emit(
row, HEADER_NOTES_COLUMN, 1, self.columnCount() - 1 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: else:
return QVariant() 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 column == Col.START_TIME.value:
if row in self.start_end_times: if row in self.start_end_times:
start_time = self.start_end_times[row].start_time start_time = self.start_end_times[row].start_time
if start_time: if start_time:
return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT)) return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant() return QVariant()
if column == Col.END_TIME.value: if column == Col.END_TIME.value:
if row in self.start_end_times: if row in self.start_end_times:
end_time = self.start_end_times[row].end_time end_time = self.start_end_times[row].end_time
if end_time: if end_time:
return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT)) return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant() return QVariant()
if column == Col.LAST_PLAYED.value:
return QVariant(get_relative_date(prd.lastplayed)) dispatch_table = {
if column == Col.BITRATE.value: Col.START_GAP.value: QVariant(prd.start_gap),
return QVariant(prd.bitrate) Col.TITLE.value: QVariant(prd.title),
if column == Col.NOTE.value: Col.ARTIST.value: QVariant(prd.artist),
return QVariant(prd.note) 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() return QVariant()
@ -408,26 +410,6 @@ class PlaylistModel(QAbstractTableModel):
super().endResetModel() super().endResetModel()
self.row_order_changed(self.playlist_id) 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: def edit_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return text for editing Return text for editing
@ -479,6 +461,26 @@ class PlaylistModel(QAbstractTableModel):
return QVariant(boldfont) 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: def _get_new_row_number(self, proposed_row_number: Optional[int]) -> int:
""" """
Sanitises proposed new row number. Sanitises proposed new row number.
@ -498,6 +500,13 @@ class PlaylistModel(QAbstractTableModel):
return new_row_number 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: def get_row_track_path(self, row_number: int) -> str:
""" """
Return path of track associated with row or empty string if no track associated Return path of track associated with row or empty string if no track associated
@ -516,13 +525,6 @@ class PlaylistModel(QAbstractTableModel):
return duration 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]: def get_unplayed_rows(self) -> List[int]:
""" """
Return a list of unplayed row numbers Return a list of unplayed row numbers
@ -677,22 +679,6 @@ class PlaylistModel(QAbstractTableModel):
if self.is_played_row(row_number): if self.is_played_row(row_number):
self.invalidate_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( def insert_row(
self, self,
proposed_row_number: Optional[int], proposed_row_number: Optional[int],
@ -737,6 +723,22 @@ 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 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]: def is_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
""" """
If this track_id is in the playlist, return the PlaylistRowData object If this track_id is in the playlist, return the PlaylistRowData object
@ -749,21 +751,6 @@ class PlaylistModel(QAbstractTableModel):
return None 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: def move_rows(self, from_rows: List[int], to_row_number: int) -> None:
""" """
Move the playlist rows given to to_row and below. 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.signals.row_order_changed_signal.emit(self.playlist_id)
self.invalidate_rows(list(row_map.keys())) 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( def move_rows_between_playlists(
self, from_rows: List[int], to_row_number: int, to_playlist_id: int self, from_rows: List[int], to_row_number: int, to_playlist_id: int
) -> None: ) -> None:
@ -969,7 +971,6 @@ class PlaylistModel(QAbstractTableModel):
Actions required: Actions required:
- sanity check - sanity check
- update display - update display
- update track times
""" """
# Sanity check # Sanity check
@ -1124,7 +1125,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.next_track_changed_signal.emit() self.signals.next_track_changed_signal.emit()
return return
# Update playing_trtack # Update playing_track
with Session() as session: with Session() as session:
track_sequence.next = PlaylistTrack() track_sequence.next = PlaylistTrack()
try: try:
@ -1331,14 +1332,14 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def __init__( def __init__(
self, self,
playlist_model: PlaylistModel, data_model: PlaylistModel,
*args, *args,
**kwargs, **kwargs,
): ):
self.playlist_model = playlist_model self.data_model = data_model
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.setSourceModel(playlist_model) self.setSourceModel(data_model)
# Search all columns # Search all columns
self.setFilterKeyColumn(-1) self.setFilterKeyColumn(-1)
@ -1347,8 +1348,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
Subclass to filter by played status Subclass to filter by played status
""" """
if self.playlist_model.played_tracks_hidden: if self.data_model.played_tracks_hidden:
if self.playlist_model.is_played_row(source_row): if self.data_model.is_played_row(source_row):
# Don't hide current or next track # Don't hide current or next track
with Session() as session: with Session() as session:
if track_sequence.next.plr_id: if track_sequence.next.plr_id:
@ -1356,7 +1357,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if ( if (
next_plr next_plr
and next_plr.plr_rownum == source_row 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 return True
if track_sequence.now.plr_id: if track_sequence.now.plr_id:
@ -1386,25 +1387,25 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# ###################################### # ######################################
def current_track_started(self): 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: 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]: 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: 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: 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: 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: 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( def insert_row(
self, self,
@ -1412,70 +1413,68 @@ class PlaylistProxyModel(QSortFilterProxyModel):
track_id: Optional[int] = None, track_id: Optional[int] = None,
note: Optional[str] = None, note: Optional[str] = None,
) -> 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: 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: 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]: 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: 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: 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( def move_rows_between_playlists(
self, from_rows: List[int], to_row_number: int, to_playlist_id: int self, from_rows: List[int], to_row_number: int, to_playlist_id: int
) -> None: ) -> 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 from_rows, to_row_number, to_playlist_id
) )
def move_track_add_note( def move_track_add_note(
self, new_row_number: int, existing_prd: PlaylistRowData, note: str self, new_row_number: int, existing_prd: PlaylistRowData, note: str
) -> None: ) -> None:
return self.playlist_model.move_track_add_note( return self.data_model.move_track_add_note(new_row_number, existing_prd, note)
new_row_number, existing_prd, note
)
def move_track_to_header( def move_track_to_header(
self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str] self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str]
) -> None: ) -> None:
return self.playlist_model.move_track_to_header( return self.data_model.move_track_to_header(
header_row_number, existing_prd, note header_row_number, existing_prd, note
) )
def open_in_audacity(self, row_number: int) -> None: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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 ( from helpers import (
ask_yes_no, ask_yes_no,
ms_to_mmss, ms_to_mmss,
show_OK,
show_warning, show_warning,
) )
from models import Settings from models import Settings
@ -50,9 +51,9 @@ class EscapeDelegate(QStyledItemDelegate):
- checks with user before abandoning edit on Escape - 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) super().__init__(parent)
self.playlist_model = playlist_model self.data_model = data_model
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
def createEditor( def createEditor(
@ -113,7 +114,7 @@ class EscapeDelegate(QStyledItemDelegate):
else: else:
edit_index = index 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()) editor.setPlainText(value.value())
def setModelData(self, editor, model, index): def setModelData(self, editor, model, index):
@ -124,7 +125,7 @@ class EscapeDelegate(QStyledItemDelegate):
edit_index = index edit_index = index
value = editor.toPlainText().strip() 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): def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect) editor.setGeometry(option.rect)
@ -149,6 +150,10 @@ class PlaylistStyle(QProxyStyle):
class PlaylistTab(QTableView): class PlaylistTab(QTableView):
"""
The playlist view
"""
def __init__( def __init__(
self, self,
musicmuster: "Window", musicmuster: "Window",
@ -161,9 +166,9 @@ class PlaylistTab(QTableView):
self.playlist_id = playlist_id self.playlist_id = playlist_id
# Set up widget # Set up widget
self.playlist_model = PlaylistModel(playlist_id) self.data_model = PlaylistModel(playlist_id)
self.proxy_model = PlaylistProxyModel(self.playlist_model) self.proxy_model = PlaylistProxyModel(self.data_model)
self.setItemDelegate(EscapeDelegate(self, self.playlist_model)) self.setItemDelegate(EscapeDelegate(self, self.data_model))
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
@ -190,10 +195,6 @@ class PlaylistTab(QTableView):
self.signals.resize_rows_signal.connect(self.resizeRowsToContents) self.signals.resize_rows_signal.connect(self.resizeRowsToContents)
self.signals.span_cells_signal.connect(self._span_cells) self.signals.span_cells_signal.connect(self._span_cells)
# Initialise miscellaneous instance variables
self.search_text: str = ""
self.sort_undo: List[int] = []
# Selection model # Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
@ -202,6 +203,8 @@ class PlaylistTab(QTableView):
self.setModel(self.proxy_model) self.setModel(self.proxy_model)
self._set_column_widths() self._set_column_widths()
# ########## Overrident class functions ##########
def closeEditor( def closeEditor(
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
) -> None: ) -> None:
@ -220,7 +223,7 @@ class PlaylistTab(QTableView):
# Update start times in case a start time in a note has been # Update start times in case a start time in a note has been
# edited # edited
self.playlist_model.update_track_times() self.data_model.update_track_times()
def dropEvent(self, event): def dropEvent(self, event):
if event.source() is not self or ( if event.source() is not self or (
@ -240,6 +243,7 @@ class PlaylistTab(QTableView):
# Reset drag mode to allow row selection by dragging # Reset drag mode to allow row selection by dragging
self.setDragEnabled(False) self.setDragEnabled(False)
# Deselect rows # Deselect rows
self.clear_selection() self.clear_selection()
@ -252,7 +256,7 @@ class PlaylistTab(QTableView):
event: Optional[QEvent], event: Optional[QEvent],
) -> bool: ) -> 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 Editing only ever starts with a double click on a cell
""" """
@ -264,6 +268,43 @@ class PlaylistTab(QTableView):
return result 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( def _add_context_menu(
self, self,
text: str, text: str,
@ -286,121 +327,6 @@ class PlaylistTab(QTableView):
return menu_item 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: def _add_track(self) -> None:
"""Add a track to a section header making it a normal track row""" """Add a track to a section header making it a normal track row"""
@ -412,7 +338,7 @@ class PlaylistTab(QTableView):
dlg = TrackSelectDialog( dlg = TrackSelectDialog(
session=session, session=session,
new_row_number=model_row_number, new_row_number=model_row_number,
model=self.playlist_model, model=self.data_model,
add_to_header=True, add_to_header=True,
) )
dlg.exec() dlg.exec()
@ -516,6 +442,12 @@ class PlaylistTab(QTableView):
"Copy track path", lambda: self._copy_path(model_row_number) "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: def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
""" """
Called when column width changes. Save new width to database. Called when column width changes. Save new width to database.
@ -546,7 +478,7 @@ class PlaylistTab(QTableView):
to the clipboard. Otherwise, return None. 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: if not track_path:
return return
@ -583,9 +515,20 @@ class PlaylistTab(QTableView):
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
return return
self.playlist_model.delete_rows(self.selected_model_row_numbers()) self.data_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection() 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]: def get_selected_rows(self) -> List[int]:
"""Return a list of selected row numbers sorted by row""" """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: def _info_row(self, row_number: int) -> None:
"""Display popup with info re row""" """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: if prd:
txt = ( txt = (
f"Title: {prd.title}\n" f"Title: {prd.title}\n"
@ -610,23 +553,18 @@ class PlaylistTab(QTableView):
else: else:
txt = f"Can't find info about row{row_number}" txt = f"Can't find info about row{row_number}"
info: QMessageBox = QMessageBox(self) show_OK(self.musicmuster, "Track info", txt)
info.setIcon(QMessageBox.Icon.Information)
info.setText(txt)
info.setStandardButtons(QMessageBox.StandardButton.Ok)
info.setDefaultButton(QMessageBox.StandardButton.Cancel)
info.exec()
def _mark_as_unplayed(self, row_numbers: List[int]) -> None: def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Rescan track""" """Rescan track"""
self.playlist_model.mark_unplayed(row_numbers) self.data_model.mark_unplayed(row_numbers)
self.clear_selection() self.clear_selection()
def _rescan(self, row_number: int) -> None: def _rescan(self, row_number: int) -> None:
"""Rescan track""" """Rescan track"""
self.playlist_model.rescan_track(row_number) self.data_model.rescan_track(row_number)
self.clear_selection() self.clear_selection()
def scroll_to_top(self, row_number: int) -> None: def scroll_to_top(self, row_number: int) -> None:
@ -653,36 +591,62 @@ class PlaylistTab(QTableView):
# We need to be in MultiSelection mode # We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows # Get the duplicate rows
duplicate_rows = self.playlist_model.get_duplicate_rows() duplicate_rows = self.data_model.get_duplicate_rows()
# Select the rows # Select the rows
for duplicate_row in duplicate_rows: for duplicate_row in duplicate_rows:
self.selectRow(duplicate_row) self.selectRow(duplicate_row)
# Reset selection mode # Reset selection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
def selectionChanged( def selected_model_row_number(self) -> Optional[int]:
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
""" """
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() selected_index = self._selected_row_index()
# If no rows are selected, we have nothing to do if selected_index is None:
if len(selected_rows) == 0: return None
self.musicmuster.lblSumPlaytime.setText("") if hasattr(self.proxy_model, "mapToSource"):
else: return self.proxy_model.mapToSource(selected_index).row()
selected_duration = self.playlist_model.get_rows_duration( return selected_index.row()
self.get_selected_rows()
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: return None
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"
)
else:
self.musicmuster.lblSumPlaytime.setText("")
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: def _set_column_widths(self) -> None:
"""Column widths from settings""" """Column widths from settings"""
@ -704,6 +668,17 @@ class PlaylistTab(QTableView):
else: else:
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH) 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: def _span_cells(self, row: int, column: int, rowSpan: int, columnSpan: int) -> None:
""" """
Implement spanning of cells, initiated by signal Implement spanning of cells, initiated by signal
@ -711,9 +686,7 @@ class PlaylistTab(QTableView):
model = self.proxy_model model = self.proxy_model
if hasattr(model, "mapToSource"): if hasattr(model, "mapToSource"):
edit_index = model.mapFromSource( edit_index = model.mapFromSource(self.data_model.createIndex(row, column))
self.playlist_model.createIndex(row, column)
)
row = edit_index.row() row = edit_index.row()
column = edit_index.column() column = edit_index.column()
@ -731,5 +704,5 @@ class PlaylistTab(QTableView):
def _unmark_as_next(self) -> None: def _unmark_as_next(self) -> None:
"""Rescan track""" """Rescan track"""
self.playlist_model.set_next_row(None) self.data_model.set_next_row(None)
self.clear_selection() self.clear_selection()

View File

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

View File

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