diff --git a/app/classes.py b/app/classes.py index 04b7cd0..7a31093 100644 --- a/app/classes.py +++ b/app/classes.py @@ -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 diff --git a/app/config.py b/app/config.py index 682fedf..41c19bc 100644 --- a/app/config.py +++ b/app/config.py @@ -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"] diff --git a/app/dbconfig.py b/app/dbconfig.py index d1c1f3c..30dc603 100644 --- a/app/dbconfig.py +++ b/app/dbconfig.py @@ -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() diff --git a/app/dialogs.py b/app/dialogs.py index 861490f..4910216 100644 --- a/app/dialogs.py +++ b/app/dialogs.py @@ -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) diff --git a/app/helpers.py b/app/helpers.py index 389acfc..32e2df5 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -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""" diff --git a/app/models.py b/app/models.py index 5748b69..6e1aa2b 100644 --- a/app/models.py +++ b/app/models.py @@ -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. """ diff --git a/app/music.py b/app/music.py index c428d5e..50db2f7 100644 --- a/app/music.py +++ b/app/music.py @@ -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, ) diff --git a/app/playlistmodel.py b/app/playlistmodel.py index f235d1e..bc0fdb6 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -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() diff --git a/app/playlists.py b/app/playlists.py index 1403d41..a452a79 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -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() diff --git a/app/utilities.py b/app/utilities.py index 878a7aa..b3a9a86 100755 --- a/app/utilities.py +++ b/app/utilities.py @@ -1,4 +1,4 @@ -# #!/usr/bin/env python +#!/usr/bin/env python # import os diff --git a/test_helpers.py b/test_helpers.py index cbb7c55..a1b9a6d 100644 --- a/test_helpers.py +++ b/test_helpers.py @@ -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)