Compare commits

..

No commits in common. "63340a408d8eda82341039a873e7259cf93dc174" and "0f1d5117cc14d619de6df2c6db2c539b82c1a038" have entirely different histories.

12 changed files with 403 additions and 421 deletions

View File

@ -104,6 +104,10 @@ 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,6 +32,16 @@ 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,7 +31,9 @@ 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(f"Session acquired: {file}:{function}:{lineno} " f"[{hex(id(Session))}]") log.debug(
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 from PyQt6.QtWidgets import QMainWindow, QMessageBox # type: ignore
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 in text""" """Return datetime specified as @hh:mm:ss 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 Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M") return past_date.strftime("%H:%M")
if weeks == 1: if weeks == 1:
weeks_str = "week" weeks_str = "week"
else: else:
@ -226,6 +226,32 @@ 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,
@ -364,32 +390,6 @@ 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,7 +307,8 @@ class Playlists(Base):
""" """
return session.scalars( 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() ).all()
def mark_open(self) -> None: def mark_open(self) -> None:
@ -322,10 +323,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 ( return session.execute(
session.execute(select(Playlists).where(Playlists.name == name)).first() select(Playlists)
is None .where(Playlists.name == name)
) ).first() is None
def rename(self, session: scoped_session, new_name: str) -> None: def rename(self, session: scoped_session, new_name: str) -> None:
""" """
@ -478,7 +479,9 @@ class PlaylistRows(Base):
session.flush() session.flush()
@staticmethod @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. Delete passed row in given playlist.
""" """

View File

@ -1,6 +1,8 @@
# 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
@ -8,7 +10,7 @@ from time import sleep
from log import log from log import log
from PyQt6.QtCore import ( from PyQt6.QtCore import ( # type: ignore
QRunnable, QRunnable,
QThreadPool, QThreadPool,
) )

View File

@ -65,7 +65,7 @@ from dbconfig import (
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from log import log from log import log
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel
from playlists import PlaylistTab from playlists import PlaylistTab
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
@ -218,7 +218,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.active_tab = lambda: self.tabPlaylist.currentWidget() self.active_tab = lambda: self.tabPlaylist.currentWidget()
self.active_model = lambda: self.tabPlaylist.currentWidget().model() self.active_model = lambda: self.tabPlaylist.currentWidget().model()
self.move_source_rows: Optional[List[int]] = None self.move_source_rows: Optional[List[int]] = None
self.move_source_model: Optional[PlaylistProxyModel] = None self.move_source_model: Optional[PlaylistModel] = None
self.load_last_playlists() self.load_last_playlists()
if Config.CARTS_HIDE: if Config.CARTS_HIDE:
@ -1021,7 +1021,7 @@ class Window(QMainWindow, Ui_MainWindow):
else: else:
destination_row = self.active_model().rowCount() destination_row = self.active_model().rowCount()
if to_playlist_id == self.move_source_model.data_model.playlist_id: if to_playlist_id == self.move_source_model.playlist_id:
self.move_source_model.move_rows(self.move_source_rows, destination_row) self.move_source_model.move_rows(self.move_source_rows, destination_row)
else: else:
self.move_source_model.move_rows_between_playlists( self.move_source_model.move_rows_between_playlists(

View File

@ -13,7 +13,6 @@ from PyQt6.QtCore import (
QRegularExpression, QRegularExpression,
QSortFilterProxyModel, QSortFilterProxyModel,
Qt, Qt,
QTimer,
QVariant, QVariant,
) )
from PyQt6.QtGui import ( from PyQt6.QtGui import (
@ -171,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 (header will already have a note) # Add any further note
if note: if note:
plr.note += "\n" + note plr.note += "\n" + note
# Reset header row spanning # Reset header row spanning
@ -263,11 +262,9 @@ 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
@ -279,11 +276,6 @@ class PlaylistModel(QAbstractTableModel):
# Update colour and times for current row # Update colour and times for current row
self.invalidate_row(row_number) self.invalidate_row(row_number)
# Update previous row in case we're hiding played rows
if track_sequence.previous.plr_rownum:
self.invalidate_row(track_sequence.previous.plr_rownum)
# Update all other track times # Update all other track times
self.update_track_times() self.update_track_times()
@ -313,29 +305,32 @@ class PlaylistModel(QAbstractTableModel):
prd = self.playlist_rows[row] prd = self.playlist_rows[row]
# Dispatch to role-specific functions # Dispatch to role-specific functions
dispatch_table = { if role == Qt.ItemDataRole.DisplayRole:
int(Qt.ItemDataRole.DisplayRole): self.display_role, return self.display_role(row, column, prd)
int(Qt.ItemDataRole.EditRole): self.edit_role, elif role == Qt.ItemDataRole.DecorationRole:
int(Qt.ItemDataRole.FontRole): self.font_role, pass
int(Qt.ItemDataRole.BackgroundRole): self.background_role, elif role == Qt.ItemDataRole.EditRole:
} return self.edit_role(row, column, prd)
elif role == Qt.ItemDataRole.ToolTipRole:
if role in dispatch_table: pass
return dispatch_table[role](row, column, prd) elif role == Qt.ItemDataRole.StatusTipRole:
pass
# Document other roles but don't use them elif role == Qt.ItemDataRole.WhatsThisRole:
if role in [ pass
Qt.ItemDataRole.DecorationRole, elif role == Qt.ItemDataRole.SizeHintRole:
Qt.ItemDataRole.ToolTipRole, pass
Qt.ItemDataRole.StatusTipRole, elif role == Qt.ItemDataRole.FontRole:
Qt.ItemDataRole.WhatsThisRole, return self.font_role(row, column, prd)
Qt.ItemDataRole.SizeHintRole, elif role == Qt.ItemDataRole.TextAlignmentRole:
Qt.ItemDataRole.TextAlignmentRole, pass
Qt.ItemDataRole.ForegroundRole, elif role == Qt.ItemDataRole.BackgroundRole:
Qt.ItemDataRole.CheckStateRole, return self.background_role(row, column, prd)
Qt.ItemDataRole.InitialSortOrderRole, elif role == Qt.ItemDataRole.ForegroundRole:
]: pass
return QVariant() elif role == Qt.ItemDataRole.CheckStateRole:
pass
elif role == Qt.ItemDataRole.InitialSortOrderRole:
pass
# Fall through to no-op # Fall through to no-op
return QVariant() return QVariant()
@ -356,51 +351,49 @@ class PlaylistModel(QAbstractTableModel):
PlaylistRows.fixup_rownumbers(session, self.playlist_id) PlaylistRows.fixup_rownumbers(session, self.playlist_id)
self.refresh_data(session) self.refresh_data(session)
self.reset_track_sequence_row_numbers() self.row_order_changed(self.playlist_id)
def display_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant: def display_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return text for display Return text for display
""" """
if self.is_header_row(row): if not prd.path:
# No track so this is a header row
if column == HEADER_NOTES_COLUMN: if column == HEADER_NOTES_COLUMN:
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
) )
header_text = self.header_text(prd) return QVariant(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:
dispatch_table = { return QVariant(get_relative_date(prd.lastplayed))
Col.START_GAP.value: QVariant(prd.start_gap), if column == Col.BITRATE.value:
Col.TITLE.value: QVariant(prd.title), return QVariant(prd.bitrate)
Col.ARTIST.value: QVariant(prd.artist), if column == Col.NOTE.value:
Col.DURATION.value: QVariant(ms_to_mmss(prd.duration)), return QVariant(prd.note)
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()
@ -414,7 +407,27 @@ class PlaylistModel(QAbstractTableModel):
with Session() as session: with Session() as session:
self.refresh_data(session) self.refresh_data(session)
super().endResetModel() super().endResetModel()
self.reset_track_sequence_row_numbers() 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:
""" """
@ -467,26 +480,6 @@ 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.
@ -506,13 +499,6 @@ 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
@ -531,6 +517,13 @@ 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
@ -660,19 +653,6 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count += 1 unplayed_count += 1
duration += row_prd.duration duration += row_prd.duration
elif prd.note == "-":
# If the hyphen is the only thing on the line, echo the note
# tha started the section without the trailing "+".
for row_number in range(prd.plr_rownum - 1, -1, -1):
row_prd = self.playlist_rows[row_number]
if self.is_header_row(row_number):
if row_prd.note.endswith("-"):
# We didn't find a matching section start
break
if row_prd.note.endswith("+"):
return f"[End: {row_prd.note[:-1]}]"
return "-"
return prd.note return prd.note
def hide_played_tracks(self, hide: bool) -> None: def hide_played_tracks(self, hide: bool) -> None:
@ -685,6 +665,22 @@ 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],
@ -708,7 +704,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session) self.refresh_data(session)
super().endInsertRows() super().endInsertRows()
self.reset_track_sequence_row_numbers() self.row_order_changed(self.playlist_id)
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows)))) self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))))
def invalidate_row(self, modified_row: int) -> None: def invalidate_row(self, modified_row: int) -> None:
@ -729,22 +725,6 @@ 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
@ -757,6 +737,21 @@ 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.
@ -790,15 +785,12 @@ class PlaylistModel(QAbstractTableModel):
# Optimise: only add to map if there is a change # Optimise: only add to map if there is a change
if old_row != new_row: if old_row != new_row:
row_map[old_row] = new_row row_map[old_row] = new_row
if self.is_header_row(old_row):
# Reset column span
self.signals.span_cells_signal.emit(
old_row, HEADER_NOTES_COLUMN, 1, 1
)
# Reset any header rows that we're moving
for moving_row in row_map:
if self.is_header_row(moving_row):
# Reset column span
print(f"Reset column span {moving_row=}")
self.signals.span_cells_signal.emit(
moving_row, HEADER_NOTES_COLUMN, 1, 1
)
# Check to see whether any rows in track_sequence have moved # Check to see whether any rows in track_sequence have moved
if track_sequence.previous.plr_rownum in row_map: if track_sequence.previous.plr_rownum in row_map:
track_sequence.previous.plr_rownum = row_map[ track_sequence.previous.plr_rownum = row_map[
@ -822,24 +814,9 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session) self.refresh_data(session)
# Update display # Update display
self.reset_track_sequence_row_numbers() 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:
@ -890,7 +867,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session) self.refresh_data(session)
# Reset of model must come after session has been closed # Reset of model must come after session has been closed
self.reset_track_sequence_row_numbers() self.signals.row_order_changed_signal.emit(self.playlist_id)
self.signals.row_order_changed_signal.emit(to_playlist_id) self.signals.row_order_changed_signal.emit(to_playlist_id)
self.signals.end_reset_model_signal.emit(to_playlist_id) self.signals.end_reset_model_signal.emit(to_playlist_id)
self.update_track_times() self.update_track_times()
@ -980,6 +957,7 @@ class PlaylistModel(QAbstractTableModel):
Actions required: Actions required:
- sanity check - sanity check
- update display - update display
- update track times
""" """
# Sanity check # Sanity check
@ -1036,31 +1014,6 @@ class PlaylistModel(QAbstractTableModel):
self.invalidate_row(row_number) self.invalidate_row(row_number)
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
def reset_track_sequence_row_numbers(self) -> None:
"""
Signal handler for when row ordering has changed
"""
# Check the track_sequence next, now and previous plrs and
# update the row number
with Session() as session:
if track_sequence.next.plr_rownum:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if next_plr:
track_sequence.next.plr_rownum = next_plr.plr_rownum
if track_sequence.now.plr_rownum:
now_plr = session.get(PlaylistRows, track_sequence.now.plr_id)
if now_plr:
track_sequence.now.plr_rownum = now_plr.plr_rownum
if track_sequence.previous.plr_rownum:
previous_plr = session.get(
PlaylistRows, track_sequence.previous.plr_id
)
if previous_plr:
track_sequence.previous.plr_rownum = previous_plr.plr_rownum
self.update_track_times()
def _reversed_contiguous_row_groups( def _reversed_contiguous_row_groups(
self, row_numbers: List[int] self, row_numbers: List[int]
) -> List[List[int]]: ) -> List[List[int]]:
@ -1099,11 +1052,26 @@ class PlaylistModel(QAbstractTableModel):
Signal handler for when row ordering has changed Signal handler for when row ordering has changed
""" """
# Only action if this is for us
if playlist_id != self.playlist_id: if playlist_id != self.playlist_id:
return return
self.reset_track_sequence_row_numbers() with Session() as session:
if track_sequence.next.plr_rownum:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_rownum)
if next_plr:
track_sequence.next.plr_rownum = next_plr.plr_rownum
if track_sequence.now.plr_rownum:
now_plr = session.get(PlaylistRows, track_sequence.now.plr_rownum)
if now_plr:
track_sequence.now.plr_rownum = now_plr.plr_rownum
if track_sequence.previous.plr_rownum:
previous_plr = session.get(
PlaylistRows, track_sequence.previous.plr_rownum
)
if previous_plr:
track_sequence.previous.plr_rownum = previous_plr.plr_rownum
self.update_track_times()
def selection_is_sortable(self, row_numbers: List[int]) -> bool: def selection_is_sortable(self, row_numbers: List[int]) -> bool:
""" """
@ -1134,6 +1102,8 @@ class PlaylistModel(QAbstractTableModel):
""" """
next_row_was = track_sequence.next.plr_rownum next_row_was = track_sequence.next.plr_rownum
if next_row_was is not None:
self.invalidate_row(next_row_was)
if row_number is None: if row_number is None:
if next_row_was is None: if next_row_was is None:
@ -1142,7 +1112,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.next_track_changed_signal.emit() self.signals.next_track_changed_signal.emit()
return return
# Update playing_track # Update playing_trtack
with Session() as session: with Session() as session:
track_sequence.next = PlaylistTrack() track_sequence.next = PlaylistTrack()
try: try:
@ -1167,9 +1137,6 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_rows[row_number].title self.playlist_rows[row_number].title
) )
self.invalidate_row(row_number) self.invalidate_row(row_number)
if next_row_was is not None:
self.invalidate_row(next_row_was)
self.update_track_times() self.update_track_times()
def setData( def setData(
@ -1301,6 +1268,10 @@ class PlaylistModel(QAbstractTableModel):
if prd.played: if prd.played:
continue continue
# Don't schedule unplayable tracks
if file_is_unreadable(prd.path):
continue
# If we're between the current and next row, zero out # If we're between the current and next row, zero out
# times # times
if ( if (
@ -1319,12 +1290,8 @@ class PlaylistModel(QAbstractTableModel):
if header_time: if header_time:
next_start_time = header_time next_start_time = header_time
else: else:
# This is an unplayed track # This is an unplayed track; set start/end if we have a
# Don't schedule unplayable tracks # start time
if file_is_unreadable(prd.path):
continue
# Set start/end if we have a start time
if next_start_time is None: if next_start_time is None:
continue continue
if stend.start_time != next_start_time: if stend.start_time != next_start_time:
@ -1352,14 +1319,14 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def __init__( def __init__(
self, self,
data_model: PlaylistModel, playlist_model: PlaylistModel,
*args, *args,
**kwargs, **kwargs,
): ):
self.data_model = data_model self.playlist_model = playlist_model
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.setSourceModel(data_model) self.setSourceModel(playlist_model)
# Search all columns # Search all columns
self.setFilterKeyColumn(-1) self.setFilterKeyColumn(-1)
@ -1368,8 +1335,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
Subclass to filter by played status Subclass to filter by played status
""" """
if self.data_model.played_tracks_hidden: if self.playlist_model.played_tracks_hidden:
if self.data_model.is_played_row(source_row): if self.playlist_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:
@ -1377,7 +1344,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.data_model.playlist_id and next_plr.playlist_id == self.playlist_model.playlist_id
): ):
return True return True
if track_sequence.now.plr_id: if track_sequence.now.plr_id:
@ -1385,44 +1352,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if ( if (
now_plr now_plr
and now_plr.plr_rownum == source_row and now_plr.plr_rownum == source_row
and now_plr.playlist_id == self.data_model.playlist_id and now_plr.playlist_id == self.playlist_model.playlist_id
): ):
return True return True
# Don't hide previous track until
# HIDE_AFTER_PLAYING_OFFSET milliseconds after
# current track has started
if track_sequence.previous.plr_id:
previous_plr = session.get(
PlaylistRows, track_sequence.previous.plr_id
)
if (
previous_plr
and previous_plr.plr_rownum == source_row
and previous_plr.playlist_id == self.data_model.playlist_id
):
if track_sequence.now.start_time:
if datetime.now() > (
track_sequence.now.start_time
+ timedelta(
milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET
)
):
return False
else:
# Invalidate this row in
# HIDE_AFTER_PLAYING_OFFSET and a
# bit milliseconds
# so that it hides then - add 100mS
# on so that it if clause above it
# true next time through.
QTimer.singleShot(
Config.HIDE_AFTER_PLAYING_OFFSET + 100,
lambda: self.data_model.invalidate_row(source_row),
)
return True
else:
return True
return False return False
return super().filterAcceptsRow(source_row, source_parent) return super().filterAcceptsRow(source_row, source_parent)
@ -1442,25 +1374,25 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# ###################################### # ######################################
def current_track_started(self): def current_track_started(self):
return self.data_model.current_track_started() return self.playlist_model.current_track_started()
def delete_rows(self, row_numbers: List[int]) -> None: def delete_rows(self, row_numbers: List[int]) -> None:
return self.data_model.delete_rows(row_numbers) return self.playlist_model.delete_rows(row_numbers)
def get_duplicate_rows(self) -> List[int]: def get_duplicate_rows(self) -> List[int]:
return self.data_model.get_duplicate_rows() return self.playlist_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.data_model.get_rows_duration(row_numbers) return self.playlist_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.data_model.get_row_info(row_number) return self.playlist_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.data_model.get_row_track_path(row_number) return self.playlist_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.data_model.hide_played_tracks(hide) return self.playlist_model.hide_played_tracks(hide)
def insert_row( def insert_row(
self, self,
@ -1468,68 +1400,70 @@ 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.data_model.insert_row(proposed_row_number, track_id, note) return self.playlist_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.data_model.is_header_row(row_number) return self.playlist_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.data_model.is_played_row(row_number) return self.playlist_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.data_model.is_track_in_playlist(track_id) return self.playlist_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.data_model.mark_unplayed(row_numbers) return self.playlist_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.data_model.move_rows(from_rows, to_row_number) return self.playlist_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.data_model.move_rows_between_playlists( return self.playlist_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.data_model.move_track_add_note(new_row_number, existing_prd, note) return self.playlist_model.move_track_add_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.data_model.move_track_to_header( return self.playlist_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.data_model.open_in_audacity(row_number) return self.playlist_model.open_in_audacity(row_number)
def previous_track_ended(self) -> None: def previous_track_ended(self) -> None:
return self.data_model.previous_track_ended() return self.playlist_model.previous_track_ended()
def remove_track(self, row_number: int) -> None: def remove_track(self, row_number: int) -> None:
return self.data_model.remove_track(row_number) return self.playlist_model.remove_track(row_number)
def rescan_track(self, row_number: int) -> None: def rescan_track(self, row_number: int) -> None:
return self.data_model.rescan_track(row_number) return self.playlist_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.data_model.set_next_row(row_number) return self.playlist_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.data_model.sort_by_artist(row_numbers) return self.playlist_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.data_model.sort_by_duration(row_numbers) return self.playlist_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.data_model.sort_by_lastplayed(row_numbers) return self.playlist_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.data_model.sort_by_title(row_numbers) return self.playlist_model.sort_by_title(row_numbers)
def update_track_times(self) -> None: def update_track_times(self) -> None:
return self.data_model.update_track_times() return self.playlist_model.update_track_times()

View File

@ -34,7 +34,6 @@ 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
@ -51,9 +50,9 @@ class EscapeDelegate(QStyledItemDelegate):
- checks with user before abandoning edit on Escape - checks with user before abandoning edit on Escape
""" """
def __init__(self, parent, data_model: PlaylistModel) -> None: def __init__(self, parent, playlist_model: PlaylistModel) -> None:
super().__init__(parent) super().__init__(parent)
self.data_model = data_model self.playlist_model = playlist_model
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
def createEditor( def createEditor(
@ -114,7 +113,7 @@ class EscapeDelegate(QStyledItemDelegate):
else: else:
edit_index = index edit_index = index
value = self.data_model.data(edit_index, Qt.ItemDataRole.EditRole) value = self.playlist_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):
@ -125,7 +124,7 @@ class EscapeDelegate(QStyledItemDelegate):
edit_index = index edit_index = index
value = editor.toPlainText().strip() value = editor.toPlainText().strip()
self.data_model.setData(edit_index, value, Qt.ItemDataRole.EditRole) self.playlist_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)
@ -150,10 +149,6 @@ class PlaylistStyle(QProxyStyle):
class PlaylistTab(QTableView): class PlaylistTab(QTableView):
"""
The playlist view
"""
def __init__( def __init__(
self, self,
musicmuster: "Window", musicmuster: "Window",
@ -166,9 +161,9 @@ class PlaylistTab(QTableView):
self.playlist_id = playlist_id self.playlist_id = playlist_id
# Set up widget # Set up widget
self.data_model = PlaylistModel(playlist_id) self.playlist_model = PlaylistModel(playlist_id)
self.proxy_model = PlaylistProxyModel(self.data_model) self.proxy_model = PlaylistProxyModel(self.playlist_model)
self.setItemDelegate(EscapeDelegate(self, self.data_model)) self.setItemDelegate(EscapeDelegate(self, self.playlist_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)
@ -195,6 +190,10 @@ 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)
@ -203,8 +202,6 @@ 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:
@ -223,7 +220,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.data_model.update_track_times() self.playlist_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 (
@ -243,7 +240,6 @@ 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()
@ -256,7 +252,7 @@ class PlaylistTab(QTableView):
event: Optional[QEvent], event: Optional[QEvent],
) -> bool: ) -> bool:
""" """
Override QAbstractItemView.edit to catch when editing starts Override PySide2.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
""" """
@ -268,43 +264,6 @@ 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,
@ -327,6 +286,121 @@ 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"""
@ -338,7 +412,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.data_model, model=self.playlist_model,
add_to_header=True, add_to_header=True,
) )
dlg.exec() dlg.exec()
@ -442,12 +516,6 @@ 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.
@ -478,7 +546,7 @@ class PlaylistTab(QTableView):
to the clipboard. Otherwise, return None. to the clipboard. Otherwise, return None.
""" """
track_path = self.data_model.get_row_info(row_number).path track_path = self.playlist_model.get_row_info(row_number).path
if not track_path: if not track_path:
return return
@ -515,20 +583,9 @@ 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.data_model.delete_rows(self.selected_model_row_numbers()) self.playlist_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"""
@ -539,7 +596,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.data_model.get_row_info(row_number) prd = self.playlist_model.get_row_info(row_number)
if prd: if prd:
txt = ( txt = (
f"Title: {prd.title}\n" f"Title: {prd.title}\n"
@ -553,18 +610,23 @@ 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}"
show_OK(self.musicmuster, "Track info", txt) info: QMessageBox = QMessageBox(self)
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.data_model.mark_unplayed(row_numbers) self.playlist_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.data_model.rescan_track(row_number) self.playlist_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:
@ -591,62 +653,36 @@ 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.data_model.get_duplicate_rows() duplicate_rows = self.playlist_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 selected_model_row_number(self) -> Optional[int]: def selectionChanged(
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
""" """
Return the model row number corresponding to the selected row or None Toggle drag behaviour according to whether rows are selected
""" """
selected_index = self._selected_row_index() selected_rows = self.get_selected_rows()
if selected_index is None: # If no rows are selected, we have nothing to do
return None if len(selected_rows) == 0:
if hasattr(self.proxy_model, "mapToSource"): self.musicmuster.lblSumPlaytime.setText("")
return self.proxy_model.mapToSource(selected_index).row() else:
return selected_index.row() selected_duration = self.playlist_model.get_rows_duration(
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"
) )
return None if selected_duration > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"
)
else:
self.musicmuster.lblSumPlaytime.setText("")
return row_indexes[0] super().selectionChanged(selected, deselected)
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"""
@ -668,17 +704,6 @@ 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
@ -686,7 +711,9 @@ class PlaylistTab(QTableView):
model = self.proxy_model model = self.proxy_model
if hasattr(model, "mapToSource"): if hasattr(model, "mapToSource"):
edit_index = model.mapFromSource(self.data_model.createIndex(row, column)) edit_index = model.mapFromSource(
self.playlist_model.createIndex(row, column)
)
row = edit_index.row() row = edit_index.row()
column = edit_index.column() column = edit_index.column()
@ -704,5 +731,5 @@ class PlaylistTab(QTableView):
def _unmark_as_next(self) -> None: def _unmark_as_next(self) -> None:
"""Rescan track""" """Rescan track"""
self.data_model.set_next_row(None) self.playlist_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) == "Today 10:00" assert get_relative_date(today_at_10, today_at_11) == "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)