Compare commits

...

8 Commits

Author SHA1 Message Date
Keith Edmunds
262ab202fc WIP V3: catch proposed duplicate playlist name
Fixes #197
2023-11-19 11:13:49 +00:00
Keith Edmunds
4f4408400f WIP V3: info popup implemented 2023-11-19 03:11:03 +00:00
Keith Edmunds
f4a374f68c WIP V3: select duplicate rows working 2023-11-19 03:09:58 +00:00
Keith Edmunds
77774dc403 WIP V3: marn new playlist as open 2023-11-18 15:46:07 +00:00
Keith Edmunds
8f2ab98be0 Fix create playlist from template and tab handlding
Tab restore code rewritten.
2023-11-18 14:29:52 +00:00
Keith Edmunds
199f0e27fa WIP V3: fixup row insertion/deletion
All row insertions and deletions are now wrapped in beginRemoveRows /
endRemoveRows (and similar for insertions).
2023-11-17 22:17:47 +00:00
Keith Edmunds
e37f62fe87 WIP V3: fixup closing tabs 2023-11-17 22:14:51 +00:00
Keith Edmunds
be7071aae0 Change intro gap warning to 300ms 2023-11-16 22:23:22 +00:00
7 changed files with 339 additions and 219 deletions

View File

@ -90,7 +90,7 @@ class Config(object):
ROWS_FROM_ZERO = True ROWS_FROM_ZERO = True
IMPORT_DESTINATION = os.path.join(ROOT, "Singles") IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
SCROLL_TOP_MARGIN = 3 SCROLL_TOP_MARGIN = 3
START_GAP_WARNING_THRESHOLD = 500 START_GAP_WARNING_THRESHOLD = 300
TEXT_NO_TRACK_NO_NOTE = "[Section header]" TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S" TOD_TIME_FORMAT = "%H:%M:%S"
TRACK_TIME_FORMAT = "%H:%M:%S" TRACK_TIME_FORMAT = "%H:%M:%S"

View File

@ -25,7 +25,6 @@ from sqlalchemy import (
from sqlalchemy.orm import ( from sqlalchemy.orm import (
DeclarativeBase, DeclarativeBase,
joinedload, joinedload,
lazyload,
Mapped, Mapped,
mapped_column, mapped_column,
relationship, relationship,
@ -217,7 +216,8 @@ class Playlists(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True) name: Mapped[str] = mapped_column(String(32), unique=True)
last_used: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None) last_used: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None)
tab: Mapped[Optional[int]] = mapped_column(default=None, unique=True) tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False) is_template: Mapped[bool] = mapped_column(default=False)
deleted: Mapped[bool] = mapped_column(default=False) deleted: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRows"]] = relationship( rows: Mapped[List["PlaylistRows"]] = relationship(
@ -230,7 +230,7 @@ class Playlists(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<Playlists(id={self.id}, name={self.name}, " f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}>" f"is_templatee={self.is_template}, open={self.open}>"
) )
def __init__(self, session: scoped_session, name: str): def __init__(self, session: scoped_session, name: str):
@ -238,19 +238,10 @@ class Playlists(Base):
session.add(self) session.add(self)
session.flush() session.flush()
def close(self, session: scoped_session) -> None: def close(self) -> None:
"""Mark playlist as unloaded""" """Mark playlist as unloaded"""
closed_idx = self.tab self.open = False
self.tab = None
# Closing this tab will mean all higher-number tabs have moved
# down by one
session.execute(
update(Playlists)
.where(Playlists.tab > closed_idx)
.values(tab=Playlists.tab - 1)
)
@classmethod @classmethod
def create_playlist_from_template( def create_playlist_from_template(
@ -283,7 +274,7 @@ class Playlists(Base):
return session.scalars( return session.scalars(
select(cls) select(cls)
.filter(cls.is_template.is_(False)) .filter(cls.is_template.is_(False))
.order_by(cls.tab.desc(), cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).all()
@classmethod @classmethod
@ -301,7 +292,7 @@ class Playlists(Base):
return session.scalars( return session.scalars(
select(cls) select(cls)
.filter( .filter(
cls.tab.is_(None), cls.open.is_(False),
cls.is_template.is_(False), cls.is_template.is_(False),
cls.deleted.is_(False), cls.deleted.is_(False),
) )
@ -311,32 +302,29 @@ class Playlists(Base):
@classmethod @classmethod
def get_open(cls, session: scoped_session) -> Sequence[Optional["Playlists"]]: def get_open(cls, session: scoped_session) -> Sequence[Optional["Playlists"]]:
""" """
Return a list of loaded playlists ordered by tab order. Return a list of loaded playlists ordered by tab.
""" """
return session.scalars( return session.scalars(
select(cls).where(cls.tab.is_not(None)).order_by(cls.tab) select(cls).where(cls.open.is_(True))
.order_by(cls.tab)
).all() ).all()
def mark_open(self, session: scoped_session, tab_index: int) -> None: def mark_open(self) -> None:
"""Mark playlist as loaded and used now""" """Mark playlist as loaded and used now"""
self.tab = tab_index self.open = True
self.last_used = datetime.now()
@staticmethod @staticmethod
def move_tab(session: scoped_session, frm: int, to: int) -> None: def name_is_available(session: scoped_session, name: str) -> bool:
"""Move tabs""" """
Return True if no playlist of this name exists else false.
"""
row_frm = session.execute(select(Playlists).filter_by(tab=frm)).scalar_one() return session.execute(
select(Playlists)
row_to = session.execute(select(Playlists).filter_by(tab=to)).scalar_one() .where(Playlists.name == name)
).first() is None
row_frm.tab = None
row_to.tab = None
session.commit()
row_to.tab = frm
row_frm.tab = to
def rename(self, session: scoped_session, new_name: str) -> None: def rename(self, session: scoped_session, new_name: str) -> None:
""" """
@ -489,17 +477,17 @@ class PlaylistRows(Base):
session.flush() session.flush()
@staticmethod @staticmethod
def delete_rows( def delete_row(
session: scoped_session, playlist_id: int, row_numbers: List[int] session: scoped_session, playlist_id: int, row_number: int
) -> None: ) -> None:
""" """
Delete passed rows in given playlist. Delete passed row in given playlist.
""" """
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum.in_(row_numbers), PlaylistRows.plr_rownum == row_number,
) )
) )
@ -684,7 +672,6 @@ class Settings(Base):
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None) f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str: def __repr__(self) -> str:
value = self.f_datetime or self.f_int or self.f_string
return ( return (
f"<Settings(id={self.id}, name={self.name}, " f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>" f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"

View File

@ -442,6 +442,14 @@ class Window(QMainWindow, Ui_MainWindow):
if record.f_int != splitter_bottom: if record.f_int != splitter_bottom:
record.update(session, {"f_int": splitter_bottom}) record.update(session, {"f_int": splitter_bottom})
# Save tab number of open playlists
for idx in range(self.tabPlaylist.count()):
playlist_id = self.tabPlaylist.widget(idx).playlist_id
playlist = session.get(Playlists, playlist_id)
if playlist:
playlist.tab = idx
session.flush()
# Save current tab # Save current tab
record = settings["active_tab"] record = settings["active_tab"]
record.update(session, {"f_int": self.tabPlaylist.currentIndex()}) record.update(session, {"f_int": self.tabPlaylist.currentIndex()})
@ -463,29 +471,25 @@ class Window(QMainWindow, Ui_MainWindow):
Return True if tab closed else False. Return True if tab closed else False.
""" """
return False # Don't close current track playlist
# TODO Reimplement without ussing self.current_track.playlist_tab current_track_playlist_id = track_sequence.now.playlist_id
# # Don't close current track playlist closing_tab_playlist_id = self.tabPlaylist.widget(tab_index).playlist_id
# if self.tabPlaylist.widget(tab_index) == (self.current_track.playlist_tab): if current_track_playlist_id:
# self.statusbar.showMessage("Can't close current track playlist", 5000) if closing_tab_playlist_id == current_track_playlist_id:
# return False self.statusbar.showMessage("Can't close current track playlist", 5000)
return False
# # Attempt to close next track playlist # Record playlist as closed and update remaining playlist tabs
# if self.tabPlaylist.widget(tab_index) == self.next_track.playlist_tab: with Session() as session:
# self.next_track.playlist_tab.clear_next() playlist = session.get(Playlists, closing_tab_playlist_id)
if playlist:
playlist.close()
# # Record playlist as closed and update remaining playlist tabs # Close playlist and remove tab
# with Session() as session: self.tabPlaylist.widget(tab_index).close()
# playlist_id = self.tabPlaylist.widget(tab_index).playlist_id self.tabPlaylist.removeTab(tab_index)
# playlist = session.get(Playlists, playlist_id)
# if playlist:
# playlist.close(session)
# # Close playlist and remove tab return True
# self.tabPlaylist.widget(tab_index).close()
# self.tabPlaylist.removeTab(tab_index)
# return True
def connect_signals_slots(self) -> None: def connect_signals_slots(self) -> None:
self.action_About.triggered.connect(self.about) self.action_About.triggered.connect(self.about)
@ -525,7 +529,9 @@ class Window(QMainWindow, Ui_MainWindow):
lambda: self.tabPlaylist.currentWidget().lookup_row_in_wikipedia() lambda: self.tabPlaylist.currentWidget().lookup_row_in_wikipedia()
) )
self.actionSearch.triggered.connect(self.search_playlist) self.actionSearch.triggered.connect(self.search_playlist)
self.actionSelect_duplicate_rows.triggered.connect(self.select_duplicate_rows) self.actionSelect_duplicate_rows.triggered.connect(
lambda: self.active_tab().select_duplicate_rows()
)
self.actionSelect_next_track.triggered.connect(self.select_next_row) self.actionSelect_next_track.triggered.connect(self.select_next_row)
self.actionSelect_previous_track.triggered.connect(self.select_previous_row) self.actionSelect_previous_track.triggered.connect(self.select_previous_row)
self.actionMoveUnplayed.triggered.connect(self.move_unplayed) self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
@ -539,10 +545,8 @@ class Window(QMainWindow, Ui_MainWindow):
self.btnStop.clicked.connect(self.stop) self.btnStop.clicked.connect(self.stop)
self.hdrCurrentTrack.clicked.connect(self.show_current) self.hdrCurrentTrack.clicked.connect(self.show_current)
self.hdrNextTrack.clicked.connect(self.show_next) self.hdrNextTrack.clicked.connect(self.show_next)
self.tabPlaylist.currentChanged.connect(self.tab_change)
self.tabPlaylist.tabCloseRequested.connect(self.close_tab) self.tabPlaylist.tabCloseRequested.connect(self.close_tab)
self.tabBar = self.tabPlaylist.tabBar() self.tabBar = self.tabPlaylist.tabBar()
self.tabBar.tabMoved.connect(self.move_tab)
self.txtSearch.returnPressed.connect(self.search_playlist_return) self.txtSearch.returnPressed.connect(self.search_playlist_return)
self.signals.enable_escape_signal.connect(self.enable_escape) self.signals.enable_escape_signal.connect(self.enable_escape)
@ -557,12 +561,16 @@ class Window(QMainWindow, Ui_MainWindow):
) -> Optional[Playlists]: ) -> Optional[Playlists]:
"""Create new playlist""" """Create new playlist"""
playlist_name = self.solicit_playlist_name() playlist_name = self.solicit_playlist_name(session)
if not playlist_name: if not playlist_name:
return None return None
playlist = Playlists(session, playlist_name) playlist = Playlists(session, playlist_name)
return playlist if playlist:
playlist.mark_open()
return playlist
return None
def create_and_show_playlist(self) -> None: def create_and_show_playlist(self) -> None:
"""Create new playlist and display it""" """Create new playlist and display it"""
@ -570,9 +578,9 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session: with Session() as session:
playlist = self.create_playlist(session) playlist = self.create_playlist(session)
if playlist: if playlist:
self.create_playlist_tab(session, playlist) self.create_playlist_tab(playlist)
def create_playlist_tab(self, session: scoped_session, playlist: Playlists) -> int: def create_playlist_tab(self, playlist: Playlists) -> int:
""" """
Take the passed playlist database object, create a playlist tab and Take the passed playlist database object, create a playlist tab and
add tab to display. Return index number of tab. add tab to display. Return index number of tab.
@ -851,8 +859,9 @@ class Window(QMainWindow, Ui_MainWindow):
dlg.resize(500, 100) dlg.resize(500, 100)
ok = dlg.exec() ok = dlg.exec()
if ok: if ok:
model.insert_header_row( model.insert_row(
self.active_tab().get_selected_row_number(), dlg.textValue() proposed_row_number=self.active_tab().get_selected_row_number(),
note=dlg.textValue(),
) )
def insert_track(self) -> None: def insert_track(self) -> None:
@ -872,7 +881,7 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session: with Session() as session:
for playlist in Playlists.get_open(session): for playlist in Playlists.get_open(session):
if playlist: if playlist:
_ = self.create_playlist_tab(session, playlist) _ = self.create_playlist_tab(playlist)
# Set active tab # Set active tab
record = Settings.get_int_settings(session, "active_tab") record = Settings.get_int_settings(session, "active_tab")
if record.f_int and record.f_int >= 0: if record.f_int and record.f_int >= 0:
@ -966,12 +975,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.move_playlist_rows(session, selected_plrs) self.move_playlist_rows(session, selected_plrs)
def move_tab(self, frm: int, to: int) -> None:
"""Handle tabs being moved"""
with Session() as session:
Playlists.move_tab(session, frm, to)
def move_unplayed(self) -> None: def move_unplayed(self) -> None:
""" """
Move unplayed rows to another playlist Move unplayed rows to another playlist
@ -994,18 +997,22 @@ class Window(QMainWindow, Ui_MainWindow):
dlg.exec() dlg.exec()
template = dlg.playlist template = dlg.playlist
if template: if template:
playlist_name = self.solicit_playlist_name() playlist_name = self.solicit_playlist_name(session)
if not playlist_name: if not playlist_name:
return return
playlist = Playlists.create_playlist_from_template( playlist = Playlists.create_playlist_from_template(
session, template, playlist_name session, template, playlist_name
) )
if not playlist:
return
tab_index = self.create_playlist_tab(session, playlist)
playlist.mark_open(session, tab_index)
def open_playlist(self): # Need to ensure that the new playlist is committed to
# the database before it is opened by the model.
session.commit()
if playlist:
playlist.mark_open()
self.create_playlist_tab(playlist)
def open_playlist(self) -> None:
"""Open existing playlist""" """Open existing playlist"""
with Session() as session: with Session() as session:
@ -1014,8 +1021,8 @@ class Window(QMainWindow, Ui_MainWindow):
dlg.exec() dlg.exec()
playlist = dlg.playlist playlist = dlg.playlist
if playlist: if playlist:
tab_index = self.create_playlist_tab(session, playlist) self.create_playlist_tab(playlist)
playlist.mark_open(session, tab_index) playlist.mark_open()
def paste_rows(self) -> None: def paste_rows(self) -> None:
""" """
@ -1291,35 +1298,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.active_tab().set_search(self.txtSearch.text()) self.active_tab().set_search(self.txtSearch.text())
self.enable_play_next_controls() self.enable_play_next_controls()
def select_duplicate_rows(self) -> None:
"""
Select the last of any rows with duplicate tracks in current playlist.
This allows the selection to typically come towards the end of the playlist away
from any show specific sections.
If there a track is selected on three or more rows, only the last one is selected.
"""
visible_playlist_id = self.active_tab().playlist_id
# Get row number of duplicate rows
sql = text(
f"""
SELECT max(plr_rownum)
FROM playlist_rows
WHERE playlist_id = {visible_playlist_id}
AND track_id != 0
GROUP BY track_id
HAVING count(id) > 1
"""
)
with Session() as session:
row_numbers = [int(a) for a in session.execute(sql).scalars().all()]
if row_numbers:
self.active_tab().select_rows(row_numbers)
self.statusbar.showMessage(
f"{len(row_numbers)} duplicate rows selected", 10000
)
def select_next_row(self) -> None: def select_next_row(self) -> None:
"""Select next or first row in playlist""" """Select next or first row in playlist"""
@ -1386,20 +1364,32 @@ class Window(QMainWindow, Ui_MainWindow):
# self.tabPlaylist.setCurrentWidget(self.next_track.playlist_tab) # self.tabPlaylist.setCurrentWidget(self.next_track.playlist_tab)
# self.tabPlaylist.currentWidget().scroll_next_to_top() # self.tabPlaylist.currentWidget().scroll_next_to_top()
def solicit_playlist_name(self, default: Optional[str] = "") -> Optional[str]: def solicit_playlist_name(
"""Get name of playlist from user""" self, session: scoped_session, default: str = ""
) -> Optional[str]:
"""Get name of new playlist from user"""
dlg = QInputDialog(self) dlg = QInputDialog(self)
dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setInputMode(QInputDialog.InputMode.TextInput)
dlg.setLabelText("Playlist name:") dlg.setLabelText("Playlist name:")
if default: while True:
dlg.setTextValue(default) if default:
dlg.resize(500, 100) dlg.setTextValue(default)
ok = dlg.exec() dlg.resize(500, 100)
if ok: ok = dlg.exec()
return dlg.textValue() if ok:
else: proposed_name = dlg.textValue()
return None if Playlists.name_is_available(session, proposed_name):
return proposed_name
else:
helpers.show_warning(
self,
"Name in use",
f"There's already a playlist called '{proposed_name}'",
)
continue
else:
return None
def stop(self) -> None: def stop(self) -> None:
"""Stop playing immediately""" """Stop playing immediately"""
@ -1463,15 +1453,6 @@ class Window(QMainWindow, Ui_MainWindow):
# Enable controls # Enable controls
self.enable_play_next_controls() self.enable_play_next_controls()
def tab_change(self):
"""Called when active tab changed"""
try:
self.tabPlaylist.currentWidget().tab_visible()
except AttributeError:
# May also be called when last tab is closed
pass
def set_next_plr_id( def set_next_plr_id(
self, next_plr_id: Optional[int], playlist_tab: PlaylistTab self, next_plr_id: Optional[int], playlist_tab: PlaylistTab
) -> None: ) -> None:

View File

@ -124,6 +124,9 @@ class PlaylistModel(QAbstractTableModel):
self.signals.add_track_to_playlist_signal.connect(self.add_track) self.signals.add_track_to_playlist_signal.connect(self.add_track)
with Session() as session: with Session() as session:
# Ensure row numbers in playlist are contiguous
PlaylistRows.fixup_rownumbers(session, playlist_id)
# Populate self.playlist_rows
self.refresh_data(session) self.refresh_data(session)
self.update_track_times() self.update_track_times()
@ -147,15 +150,7 @@ class PlaylistModel(QAbstractTableModel):
if playlist_id != self.playlist_id: if playlist_id != self.playlist_id:
return return
# Insert track if we have one self.insert_row(proposed_row_number=new_row_number, track_id=track_id, note=note)
if track_id:
self.insert_track_row(new_row_number, track_id, note)
# If we only have a note, add as a header row
elif note:
self.insert_header_row(new_row_number, note)
else:
# No track, no note, no point
return
def add_track_to_header( def add_track_to_header(
self, self,
@ -345,10 +340,17 @@ class PlaylistModel(QAbstractTableModel):
def delete_rows(self, row_numbers: List[int]) -> None: def delete_rows(self, row_numbers: List[int]) -> None:
""" """
Delete passed rows from model Delete passed rows from model
Need to delete them in contiguous groups wrapped in beginRemoveRows / endRemoveRows
calls. To keep it simple, if inefficient, delete rows one by one.
""" """
with Session() as session: with Session() as session:
PlaylistRows.delete_rows(session, self.playlist_id, row_numbers) for row_number in row_numbers:
super().beginRemoveRows(QModelIndex(), row_number, row_number)
PlaylistRows.delete_row(session, self.playlist_id, row_number)
super().endRemoveRows()
PlaylistRows.fixup_rownumbers(session, self.playlist_id) PlaylistRows.fixup_rownumbers(session, self.playlist_id)
self.refresh_data(session) self.refresh_data(session)
self.update_track_times() self.update_track_times()
@ -397,6 +399,26 @@ class PlaylistModel(QAbstractTableModel):
return QVariant() return QVariant()
def get_duplicate_rows(self) -> List[int]:
"""
Return a list of duplicate rows. If track appears in rows 2, 3 and 4, return [3, 4]
(ie, ignore the first, not-yet-duplicate, track).
"""
found = []
result = []
for i in range(len(self.playlist_rows)):
track_id = self.playlist_rows[i].track_id
if track_id is None:
continue
if track_id in found:
result.append(i)
else:
found.append(track_id)
return result
def edit_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant: def edit_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return text for editing Return text for editing
@ -448,6 +470,25 @@ class PlaylistModel(QAbstractTableModel):
return QVariant(boldfont) return QVariant(boldfont)
def _get_new_row_number(self, proposed_row_number: Optional[int]) -> int:
"""
Sanitises proposed new row number.
If proposed_row_number given, ensure it is valid.
If not given, return row number to add to end of model.
"""
if proposed_row_number is None or proposed_row_number > len(self.playlist_rows):
# We are adding to the end of the list
new_row_number = len(self.playlist_rows)
elif proposed_row_number < 0:
# Add to start of list
new_row_number = 0
else:
new_row_number = proposed_row_number
return new_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
@ -466,6 +507,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 headerData( def headerData(
self, self,
section: int, section: int,
@ -600,66 +648,33 @@ class PlaylistModel(QAbstractTableModel):
return self.playlist_rows[row_number].played return self.playlist_rows[row_number].played
def insert_header_row(self, row_number: Optional[int], text: str) -> None: def insert_row(
""" self,
Insert a header row. proposed_row_number: Optional[int],
""" track_id: Optional[int] = None,
note: Optional[str] = None,
with Session() as session:
plr = self._insert_row(session, row_number)
# Update the PlaylistRows object
plr.note = text
# Repopulate self.playlist_rows
self.refresh_data(session)
# Update the display from the new row onwards
self.invalidate_rows(list(range(plr.plr_rownum, len(self.playlist_rows))))
def _insert_row(
self, session: scoped_session, row_number: Optional[int]
) -> PlaylistRows: ) -> PlaylistRows:
"""
Insert a row in the database.
If row_number is greater than length of list plus 1, or if row
number is None, put row at end of list.
Move existing rows to make space if ncessary.
Return the new PlaylistRows object.
"""
if row_number is None or row_number > len(self.playlist_rows):
# We are adding to the end of the list so we can optimise
new_row_number = len(self.playlist_rows)
return PlaylistRows(session, self.playlist_id, new_row_number)
elif row_number < 0:
raise ValueError(
f"playlistmodel._insert_row, invalid row number ({row_number})"
)
else:
new_row_number = row_number
# Insert the new row and return it
return PlaylistRows.insert_row(session, self.playlist_id, new_row_number)
def insert_track_row(
self, row_number: Optional[int], track_id: int, text: Optional[str]
) -> None:
""" """
Insert a track row. Insert a track row.
""" """
new_row_number = self._get_new_row_number(proposed_row_number)
with Session() as session: with Session() as session:
plr = self._insert_row(session, row_number) super().beginInsertRows(QModelIndex(), new_row_number, new_row_number)
# Update the PlaylistRows object plr = PlaylistRows.insert_row(session, self.playlist_id, new_row_number)
plr.track_id = track_id plr.track_id = track_id
if text: if note:
plr.note = text plr.note = note
# Repopulate self.playlist_rows
self.refresh_data(session) self.refresh_data(session)
# Update the display from the new row onwards super().endInsertRows()
self.invalidate_rows(list(range(plr.plr_rownum, len(self.playlist_rows)))) self.invalidate_rows(list(range(plr.plr_rownum, len(self.playlist_rows))))
return plr
def invalidate_row(self, modified_row: int) -> None: def invalidate_row(self, modified_row: int) -> None:
""" """
Signal to view to refresh invlidated row Signal to view to refresh invlidated row

View File

@ -205,7 +205,26 @@ class PlaylistTab(QTableView):
self.setModel(PlaylistModel(playlist_id)) self.setModel(PlaylistModel(playlist_id))
self._set_column_widths() self._set_column_widths()
# ########## Events other than cell editing ########## def closeEditor(
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
) -> None:
"""
Override closeEditor to enable play controls and update display.
"""
self.musicmuster.enable_play_next_controls()
self.musicmuster.actionSetNext.setEnabled(True)
self.musicmuster.action_Clear_selection.setEnabled(True)
super(PlaylistTab, self).closeEditor(editor, hint)
# Optimise row heights after increasing row height for editing
self.resizeRowsToContents()
# Update start times in case a start time in a note has been
# edited
model = cast(PlaylistModel, self.model())
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 (
@ -230,6 +249,25 @@ class PlaylistTab(QTableView):
event.accept() event.accept()
def edit(
self,
index: QModelIndex,
trigger: QAbstractItemView.EditTrigger,
event: Optional[QEvent],
) -> bool:
"""
Override PySide2.QAbstractItemView.edit to catch when editing starts
Editing only ever starts with a double click on a cell
"""
# 'result' will only be true on double-click
result = super().edit(index, trigger, event)
if result:
self.musicmuster.disable_play_next_controls()
return result
def _add_context_menu( def _add_context_menu(
self, self,
text: str, text: str,
@ -538,9 +576,9 @@ class PlaylistTab(QTableView):
parent_menu=sort_menu, parent_menu=sort_menu,
) )
# Info TODO # Info
if track_row: if track_row:
self._add_context_menu("Info", lambda: print("Track info")) self._add_context_menu("Info", lambda: self._info_row(row_number))
# Track path TODO # Track path TODO
if track_row: if track_row:
@ -644,25 +682,23 @@ class PlaylistTab(QTableView):
# items in that row selected) # items in that row selected)
return sorted(list(set([a.row() for a in self.selectedIndexes()]))) return sorted(list(set([a.row() for a in self.selectedIndexes()])))
def _info_row(self, track_id: int) -> None: def _info_row(self, row_number: int) -> None:
"""Display popup with info re row""" """Display popup with info re row"""
with Session() as session: model = cast(PlaylistModel, self.model())
track = session.get(Tracks, track_id) prd = model.get_row_info(row_number)
if track: if prd:
txt = ( txt = (
f"Title: {track.title}\n" f"Title: {prd.title}\n"
f"Artist: {track.artist}\n" f"Artist: {prd.artist}\n"
f"Track ID: {track.id}\n" f"Track ID: {prd.track_id}\n"
f"Track duration: {ms_to_mmss(track.duration)}\n" f"Track duration: {ms_to_mmss(prd.duration)}\n"
f"Track bitrate: {track.bitrate}\n" f"Track bitrate: {prd.bitrate}\n"
f"Track fade at: {ms_to_mmss(track.fade_at)}\n" "\n\n"
f"Track silence at: {ms_to_mmss(track.silence_at)}" f"Path: {prd.path}\n"
"\n\n" )
f"Path: {track.path}\n" else:
) txt = f"Can't find info about row{row_number}"
else:
txt = f"Can't find {track_id=}"
info: QMessageBox = QMessageBox(self) info: QMessageBox = QMessageBox(self)
info.setIcon(QMessageBox.Icon.Information) info.setIcon(QMessageBox.Icon.Information)
@ -879,6 +915,26 @@ class PlaylistTab(QTableView):
# if match_row is not None: # if match_row is not None:
# self.selectRow(row_number) # self.selectRow(row_number)
def select_duplicate_rows(self) -> None:
"""
Select the last of any rows with duplicate tracks in current playlist.
This allows the selection to typically come towards the end of the playlist away
from any show specific sections.
"""
# Clear any selected rows to avoid confustion
self.clear_selection()
# We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows
model = cast(PlaylistModel, self.model())
duplicate_rows = 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( def selectionChanged(
self, selected: QItemSelection, deselected: QItemSelection self, selected: QItemSelection, deselected: QItemSelection
) -> None: ) -> None:

View File

@ -0,0 +1,60 @@
"""Add 'open' field to Playlists
Revision ID: 5bb2c572e1e5
Revises: 3a53a9fb26ab
Create Date: 2023-11-18 14:19:02.643914
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '5bb2c572e1e5'
down_revision = '3a53a9fb26ab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('carts', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('carts', 'path',
existing_type=mysql.VARCHAR(length=2048),
nullable=True)
op.alter_column('carts', 'enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.alter_column('playlist_rows', 'note',
existing_type=mysql.VARCHAR(length=2048),
nullable=False)
op.add_column('playlists', sa.Column('open', sa.Boolean(), nullable=False))
op.alter_column('settings', 'name',
existing_type=mysql.VARCHAR(length=32),
type_=sa.String(length=64),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('settings', 'name',
existing_type=sa.String(length=64),
type_=mysql.VARCHAR(length=32),
existing_nullable=False)
op.drop_column('playlists', 'open')
op.alter_column('playlist_rows', 'note',
existing_type=mysql.VARCHAR(length=2048),
nullable=True)
op.alter_column('carts', 'enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
op.alter_column('carts', 'path',
existing_type=mysql.VARCHAR(length=2048),
nullable=False)
op.alter_column('carts', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
# ### end Alembic commands ###

View File

@ -2,6 +2,8 @@ from app.models import (
Playlists, Playlists,
Tracks, Tracks,
) )
from PyQt6.QtCore import Qt
from app.helpers import get_file_metadata from app.helpers import get_file_metadata
from app import playlistmodel from app import playlistmodel
from dbconfig import scoped_session from dbconfig import scoped_session
@ -25,7 +27,7 @@ def create_model_with_tracks(session: scoped_session) -> "playlistmodel.Playlist
track_path = test_tracks[row % len(test_tracks)] track_path = test_tracks[row % len(test_tracks)]
metadata = get_file_metadata(track_path) metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata) track = Tracks(session, **metadata)
model.insert_track_row(row, track.id, f"{row=}") model.insert_row(proposed_row_number=row, track_id=track.id, note=f"{row=}")
session.commit() session.commit()
return model return model
@ -38,10 +40,8 @@ def create_model_with_playlist_rows(
# Create a model # Create a model
model = playlistmodel.PlaylistModel(playlist.id) model = playlistmodel.PlaylistModel(playlist.id)
for row in range(rows): for row in range(rows):
plr = model._insert_row(session, row) plr = model.insert_row(proposed_row_number=row, note=str(row))
newrow = plr.plr_rownum model.playlist_rows[plr.plr_rownum] = playlistmodel.PlaylistRowData(plr)
plr.note = str(newrow)
model.playlist_rows[newrow] = playlistmodel.PlaylistRowData(plr)
session.commit() session.commit()
return model return model
@ -195,7 +195,7 @@ def test_insert_header_row_end(monkeypatch, session):
initial_row_count = 11 initial_row_count = 11
model = create_model_with_playlist_rows(session, initial_row_count) model = create_model_with_playlist_rows(session, initial_row_count)
model.insert_header_row(None, note_text) model.insert_row(proposed_row_number=None, note=note_text)
assert model.rowCount() == initial_row_count + 1 assert model.rowCount() == initial_row_count + 1
prd = model.playlist_rows[model.rowCount() - 1] prd = model.playlist_rows[model.rowCount() - 1]
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
@ -215,7 +215,7 @@ def test_insert_header_row_middle(monkeypatch, session):
insert_row = 6 insert_row = 6
model = create_model_with_playlist_rows(session, initial_row_count) model = create_model_with_playlist_rows(session, initial_row_count)
model.insert_header_row(insert_row, note_text) model.insert_row(proposed_row_number=insert_row, note=note_text)
assert model.rowCount() == initial_row_count + 1 assert model.rowCount() == initial_row_count + 1
prd = model.playlist_rows[insert_row] prd = model.playlist_rows[insert_row]
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
@ -239,14 +239,35 @@ def test_timing_one_track(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session) monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_tracks(session) model = create_model_with_tracks(session)
model.insert_header_row(START_ROW, "start+") model.insert_row(proposed_row_number=START_ROW, note="start+")
model.insert_header_row(END_ROW, "-") model.insert_row(proposed_row_number=END_ROW, note="-")
prd = model.playlist_rows[START_ROW] prd = model.playlist_rows[START_ROW]
qv_value = model.display_role(START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd) qv_value = model.display_role(START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd)
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]" assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
def test_insert_track_new_playlist(monkeypatch, session):
# insert a track into a new playlist
monkeypatch.setattr(playlistmodel, "Session", session)
playlist = Playlists(session, "test playlist")
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
track_path = test_tracks[0]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
model.insert_row(proposed_row_number=0, track_id=track.id)
prd = model.playlist_rows[model.rowCount() - 1]
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd)
== metadata["title"]
)
# def test_edit_header(monkeypatch, session): # edit header row in middle of playlist # def test_edit_header(monkeypatch, session): # edit header row in middle of playlist
# monkeypatch.setattr(playlistmodel, "Session", session) # monkeypatch.setattr(playlistmodel, "Session", session)