Compare commits

..

8 Commits

Author SHA1 Message Date
Keith Edmunds
63340a408d V3: fix display corruption when moving a header row 2023-11-28 21:13:16 +00:00
Keith Edmunds
f9b8f1d8d3 V3 tweaks and polishes 2023-11-28 19:59:45 +00:00
Keith Edmunds
f8093bc642 V3: track highlighting fix
When a track is moved to above the marked next track, and the moved
track is made the next track, the original next track remained marked
as next.
2023-11-28 18:29:19 +00:00
Keith Edmunds
cf4d06db16 V3 tidying 2023-11-28 14:36:12 +00:00
Keith Edmunds
95aadb867a V3 hide played tracks
Don't hide previous track until delay after playing next track.
2023-11-28 14:29:49 +00:00
Keith Edmunds
3179c6f5de V3 tweaks and polishes 2023-11-28 14:29:09 +00:00
Keith Edmunds
63a38b5bf9 V3 polish: fix @starttime in headers 2023-11-28 07:28:33 +00:00
Keith Edmunds
15c10431e6 V3 polish: header with "-" echoes section start text 2023-11-28 07:19:09 +00:00
12 changed files with 421 additions and 403 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ from PyQt6.QtCore import (
QRegularExpression,
QSortFilterProxyModel,
Qt,
QTimer,
QVariant,
)
from PyQt6.QtGui import (
@ -170,7 +171,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 +263,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
@ -276,6 +279,11 @@ class PlaylistModel(QAbstractTableModel):
# Update colour and times for current row
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
self.update_track_times()
@ -305,32 +313,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()
@ -351,49 +356,51 @@ class PlaylistModel(QAbstractTableModel):
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
self.refresh_data(session)
self.row_order_changed(self.playlist_id)
self.reset_track_sequence_row_numbers()
def display_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
"""
Return text for display
"""
if not prd.path:
# No track so this is a header row
if self.is_header_row(row):
if column == HEADER_NOTES_COLUMN:
self.signals.span_cells_signal.emit(
row, HEADER_NOTES_COLUMN, 1, self.columnCount() - 1
)
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()
@ -407,27 +414,7 @@ class PlaylistModel(QAbstractTableModel):
with Session() as session:
self.refresh_data(session)
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
self.reset_track_sequence_row_numbers()
def edit_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
"""
@ -480,6 +467,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.
@ -499,6 +506,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
@ -517,13 +531,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
@ -653,6 +660,19 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count += 1
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
def hide_played_tracks(self, hide: bool) -> None:
@ -665,22 +685,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],
@ -704,7 +708,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session)
super().endInsertRows()
self.row_order_changed(self.playlist_id)
self.reset_track_sequence_row_numbers()
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))))
def invalidate_row(self, modified_row: int) -> None:
@ -725,6 +729,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
@ -737,21 +757,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.
@ -785,12 +790,15 @@ class PlaylistModel(QAbstractTableModel):
# Optimise: only add to map if there is a change
if 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
if track_sequence.previous.plr_rownum in row_map:
track_sequence.previous.plr_rownum = row_map[
@ -814,9 +822,24 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session)
# Update display
self.signals.row_order_changed_signal.emit(self.playlist_id)
self.reset_track_sequence_row_numbers()
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:
@ -867,7 +890,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session)
# Reset of model must come after session has been closed
self.signals.row_order_changed_signal.emit(self.playlist_id)
self.reset_track_sequence_row_numbers()
self.signals.row_order_changed_signal.emit(to_playlist_id)
self.signals.end_reset_model_signal.emit(to_playlist_id)
self.update_track_times()
@ -957,7 +980,6 @@ class PlaylistModel(QAbstractTableModel):
Actions required:
- sanity check
- update display
- update track times
"""
# Sanity check
@ -1014,6 +1036,31 @@ class PlaylistModel(QAbstractTableModel):
self.invalidate_row(row_number)
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(
self, row_numbers: List[int]
) -> List[List[int]]:
@ -1052,26 +1099,11 @@ class PlaylistModel(QAbstractTableModel):
Signal handler for when row ordering has changed
"""
# Only action if this is for us
if playlist_id != self.playlist_id:
return
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()
self.reset_track_sequence_row_numbers()
def selection_is_sortable(self, row_numbers: List[int]) -> bool:
"""
@ -1102,8 +1134,6 @@ class PlaylistModel(QAbstractTableModel):
"""
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 next_row_was is None:
@ -1112,7 +1142,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:
@ -1137,6 +1167,9 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_rows[row_number].title
)
self.invalidate_row(row_number)
if next_row_was is not None:
self.invalidate_row(next_row_was)
self.update_track_times()
def setData(
@ -1268,10 +1301,6 @@ class PlaylistModel(QAbstractTableModel):
if prd.played:
continue
# Don't schedule unplayable tracks
if file_is_unreadable(prd.path):
continue
# If we're between the current and next row, zero out
# times
if (
@ -1290,8 +1319,12 @@ class PlaylistModel(QAbstractTableModel):
if header_time:
next_start_time = header_time
else:
# This is an unplayed track; set start/end if we have a
# start time
# This is an unplayed track
# Don't schedule unplayable tracks
if file_is_unreadable(prd.path):
continue
# Set start/end if we have a start time
if next_start_time is None:
continue
if stend.start_time != next_start_time:
@ -1319,14 +1352,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)
@ -1335,8 +1368,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:
@ -1344,7 +1377,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:
@ -1352,9 +1385,44 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if (
now_plr
and now_plr.plr_rownum == source_row
and now_plr.playlist_id == self.playlist_model.playlist_id
and now_plr.playlist_id == self.data_model.playlist_id
):
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 super().filterAcceptsRow(source_row, source_parent)
@ -1374,25 +1442,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,
@ -1400,70 +1468,68 @@ class PlaylistProxyModel(QSortFilterProxyModel):
track_id: Optional[int] = None,
note: Optional[str] = None,
) -> None:
return self.playlist_model.insert_row(proposed_row_number, track_id, note)
return self.data_model.insert_row(proposed_row_number, track_id, note)
def is_header_row(self, row_number: int) -> bool:
return self.playlist_model.is_header_row(row_number)
return self.data_model.is_header_row(row_number)
def is_played_row(self, row_number: int) -> bool:
return self.playlist_model.is_played_row(row_number)
return self.data_model.is_played_row(row_number)
def is_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
return self.playlist_model.is_track_in_playlist(track_id)
return self.data_model.is_track_in_playlist(track_id)
def mark_unplayed(self, row_numbers: List[int]) -> None:
return self.playlist_model.mark_unplayed(row_numbers)
return self.data_model.mark_unplayed(row_numbers)
def move_rows(self, from_rows: List[int], to_row_number: int) -> None:
return self.playlist_model.move_rows(from_rows, to_row_number)
return self.data_model.move_rows(from_rows, to_row_number)
def move_rows_between_playlists(
self, from_rows: List[int], to_row_number: int, to_playlist_id: int
) -> None:
return self.playlist_model.move_rows_between_playlists(
return self.data_model.move_rows_between_playlists(
from_rows, to_row_number, to_playlist_id
)
def move_track_add_note(
self, new_row_number: int, existing_prd: PlaylistRowData, note: str
) -> None:
return self.playlist_model.move_track_add_note(
new_row_number, existing_prd, note
)
return self.data_model.move_track_add_note(new_row_number, existing_prd, note)
def move_track_to_header(
self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str]
) -> None:
return self.playlist_model.move_track_to_header(
return self.data_model.move_track_to_header(
header_row_number, existing_prd, note
)
def open_in_audacity(self, row_number: int) -> None:
return self.playlist_model.open_in_audacity(row_number)
return self.data_model.open_in_audacity(row_number)
def previous_track_ended(self) -> None:
return self.playlist_model.previous_track_ended()
return self.data_model.previous_track_ended()
def remove_track(self, row_number: int) -> None:
return self.playlist_model.remove_track(row_number)
return self.data_model.remove_track(row_number)
def rescan_track(self, row_number: int) -> None:
return self.playlist_model.rescan_track(row_number)
return self.data_model.rescan_track(row_number)
def set_next_row(self, row_number: Optional[int]) -> None:
return self.playlist_model.set_next_row(row_number)
return self.data_model.set_next_row(row_number)
def sort_by_artist(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_artist(row_numbers)
return self.data_model.sort_by_artist(row_numbers)
def sort_by_duration(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_duration(row_numbers)
return self.data_model.sort_by_duration(row_numbers)
def sort_by_lastplayed(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_lastplayed(row_numbers)
return self.data_model.sort_by_lastplayed(row_numbers)
def sort_by_title(self, row_numbers: List[int]) -> None:
return self.playlist_model.sort_by_title(row_numbers)
return self.data_model.sort_by_title(row_numbers)
def update_track_times(self) -> None:
return self.playlist_model.update_track_times()
return self.data_model.update_track_times()

View File

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

View File

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

View File

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